patx/makeitpdf

makeitpdf v0.2

Commit 69e5c3a · patx · 2026-08-17T23:37:40-04:00

Changeset
69e5c3af0f4b49157dab30036bac5b9a2f18ea8f
Parents
119e4ad7f115d0e410aa1c3afc9a1b400f885522

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/docs/README.md b/docs/README.md
index bf6ab00..2cdfd24 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,121 +1,126 @@
 # MakeItPDF
 
-Combine up to 10 images into a single PDF. Everything runs in the browser —
-no server, no upload, no account.
+Turn images, Word documents, Excel workbooks, Markdown, and CSV files into one
+PDF. Everything runs in the browser: no upload, account, server-side converter,
+or third-party runtime request.
 
 ## Run it
 
-It's a static site. Any file server will do:
+Serve the directory over HTTP:
 
 ```bash
-npm run serve       # python3 -m http.server 8000
+npm run serve
 ```
 
-Then open http://127.0.0.1:8000.
+Open <http://127.0.0.1:8000>. The app uses a Web Worker and lazy-loaded local
+fonts, so opening `index.html` directly with a `file:` URL is not supported.
 
-Deploying is copying the folder to GitHub Pages, Netlify, S3, or any CDN.
-There is nothing to install and nothing to keep running.
+The production app is static and all runtime libraries and fonts are committed
+under `vendor/`. `npm install` is only needed to run the browser integration
+test or refresh vendored assets.
 
-## Tests
-
-```bash
-npm test            # node --test "tests/*.test.js"
-```
+## Supported files
 
-16 tests, no dependencies — they cover `pages.js`, which holds the page
-geometry and is deliberately free of DOM and jsPDF so it runs straight through
-node. The parts that need a real browser (decoding, rasterising, jsPDF output)
-are verified by using the thing; see *Verified behaviour* below.
+- Images: JPEG, PNG, WebP, GIF, BMP, TIFF
+- Documents: DOCX
+- Spreadsheets: XLSX and UTF-8 CSV
+- Text: Markdown (`.md` and `.markdown`)
 
-## How it works
+Select or drop up to 10 files, reorder them, and convert them into one PDF.
+Files begin new PDF sections in queue order. Legacy DOC/XLS and encrypted
+Office files are rejected with a specific message.
 
-`app.js` walks the images in order and, for each one:
+### Images keep their physical size
 
-1. **Decodes** it with `createImageBitmap(file, { imageOrientation: "from-image" })`.
-   That option applies the EXIF orientation tag. Without it, phone photos come
-   out sideways — a PDF page has no orientation metadata to defer to, so the
-   rotation has to be baked into the pixels.
-2. **Places** it on an A4 page turned to match the image, scaled to fit inside
-   a 24 pt margin and centred (`pages.js`).
-3. **Rasterises** it to the size it will occupy at 150 DPI. The resize happens
-   inside `createImageBitmap`, not `drawImage`: a single `drawImage` step from
-   24 MP down to ~1100 px aliases badly, while the bitmap resizer downsamples
-   properly. The canvas is filled white first, because JPEG has no alpha and
-   transparency would otherwise come out black.
-4. **Adds** it to the jsPDF document as a JPEG at quality 0.9.
+Each image gets a borderless page sized from its pixel dimensions and embedded
+print resolution:
 
-Images are processed one at a time and each bitmap is `close()`d immediately,
-so peak memory is roughly one decoded image rather than the whole batch.
+```text
+page points = image pixels / image DPI × 72
+```
 
-An A4 content area is only about 1140 px wide at 150 DPI, so large camera
-images are downscaled to that. This is the same output the previous
-server-side version produced.
+Resolution is read from JPEG JFIF/EXIF, PNG `pHYs`, TIFF resolution tags, and
+BMP pixels-per-metre fields. Images without valid resolution metadata use 96
+DPI. EXIF rotation is applied before page dimensions are calculated.
 
-### Formats
+Compatible JPEG and PNG data is embedded directly when no rotation is needed.
+Other image types and rotated images are decoded at their original pixel grid;
+they are never reduced to an A4 raster. Pages over the common PDF limit of 200
+inches per side are proportionally capped without discarding source pixels.
 
-JPEG, PNG, WebP, GIF, and BMP go through the browser's own decoder.
+Animated GIFs use their first frame. Multi-page TIFFs currently use their first
+image directory.
 
-**TIFF has no browser decoder**, so `UTIF.js` unpacks it to RGBA and it gets
-handed back through a canvas. TIFF is detected by magic bytes (`II*\0` or
-`MM\0*`), not by filename or content-type, because browsers frequently report
-an empty type for `.tif` files.
+### DOCX and Markdown
 
-`pako` must load before `UTIF` — UTIF picks it up as `self.pako` at load time
-and needs it to inflate deflate-compressed TIFFs (compression tag 8). Without
-it, LZW TIFFs work and deflate ones fail.
+DOCX conversion is semantic rather than a clone of Word's print engine. It
+preserves ordinary headings, paragraphs, lists, tables, links, embedded images,
+and inline emphasis. Exact Word pagination, installed fonts, text boxes,
+headers/footers, tracked changes, and floating layouts are not reproduced.
 
-TIFF thumbnails show a blank sheet outline, since `<img>` can't render TIFF
-either. The PDF page is correct regardless.
+Markdown supports headings, emphasis, links, lists and task lists, quotes,
+fenced code, tables, rules, and strikethrough. Raw HTML is disabled. Embedded
+data-URI images are allowed; remote and relative images are represented by alt
+text and their URL and are never fetched.
 
-### Limits
+### XLSX and CSV
 
-In `index.html` as `data-` attributes on the form, so the markup and `app.js`
-can't drift apart.
+Every visible XLSX worksheet is included in workbook order. Hidden and very
+hidden worksheets stay out of the PDF. Displayed cell values, cached formula
+results, merges, and stored column widths are retained where available.
+Formulae are not recalculated, and charts, macros, shapes, conditional
+formatting, and Excel print settings are not reproduced.
 
-- 10 images per PDF
-- 25 MB per image
+Spreadsheets use landscape A4. Wide tables are divided into consecutive column
+groups before type becomes too small to read. Workbooks and CSV files are
+limited to 50,000 rendered cells.
 
-There is no batch total limit — that existed to bound an HTTP upload, and
-there is no upload any more.
+## Privacy and safety
 
-## Verified behaviour
+- Runtime code, UI fonts, PDF fonts, and conversion libraries are local files.
+- DOCX and XLSX parsing runs in a worker with a 30-second timeout.
+- Mammoth output is converted through an element and URL allowlist and is never
+  inserted into the live page.
+- Only HTTP, HTTPS, and mailto links are emitted into PDFs.
+- A failed input aborts the whole conversion; no partial PDF is downloaded.
+- Limits are 10 files and 25 MB per file.
 
-Checked by running it and inspecting the resulting PDF, not just the success
-screen:
+The PDF font set covers Latin, Greek, Cyrillic, Arabic, Hebrew, Devanagari,
+Chinese, Japanese, and Korean. Bidirectional text is reordered for display and
+tagged with its logical source text for copying and search.
 
-- A JPEG stored 800×400 with EXIF orientation 6 produces a **portrait** page,
-  and the extracted page image is rotated 90° clockwise — the correct
-  direction, not merely a rotation.
-- Deflate-compressed (tag 8) and LZW-compressed (tag 5) TIFFs both convert.
-- A fully transparent PNG lands with pure white corners, not black.
-- Page geometry is exact: a 1000×1400 image on A4 yields a 1140×1596 px
-  raster, matching `pages.js` to the pixel.
-- jsPDF deduplicates identical images, so two pixel-identical pages share one
-  embedded image.
+## Tests
 
-## Layout
+Install development dependencies, then run:
 
+```bash
+npm test
+npm run test:browser
 ```
-index.html      markup, limits, script order
-app.js          intake, reordering, decode -> PDF pipeline
-pages.js        pure page geometry (tested)
-style.css       one typeface, white ground, one accent
-tests/          node --test, no dependencies
-vendor/         jsPDF 4.2.1, UTIF 3.1.0, pako 2.1.0 (all MIT), with licences
-```
-
-Dependencies are vendored rather than pulled from a CDN, so the page has no
-third-party runtime requests and keeps working offline. The scripts are
-classic UMD builds with relative paths — no ES modules, no `fetch`, no
-workers. (Testing was done over HTTP; opening `index.html` directly should
-work for the same reason but wasn't verified.)
 
-The only external request on the page is the Google Fonts stylesheet for
-Figtree. Drop that `<link>` and the system sans-serif takes over.
-
-## History
+The unit suite covers image geometry and metadata, TIFF detection, URL safety,
+UTF-8 handling, and CSV parsing. The headless-Chrome integration test converts
+a PNG, DOCX, XLSX, Markdown file, and CSV into one PDF and verifies:
+
+- the image page has its exact 96-DPI fallback dimensions;
+- text from all four text-based formats is searchable;
+- hidden worksheets are excluded;
+- no external network request occurs.
+
+## Project layout
+
+```text
+index.html          form and result markup
+app.js              intake, queue, workers, and UI states
+pages.js            pure image metadata and page geometry
+documents.js        safe CSV/HTML/Markdown normalization
+pdf-builder.js      PDFKit layout and mixed page writing
+office-worker.js    isolated DOCX/XLSX parsing
+style.css           responsive visual system
+tests/              node and real-browser coverage
+vendor/             offline libraries, fonts, and licences
+```
 
-The first implementation was a FastAPI + Jinja2 service doing the conversion
-with Pillow. It's preserved in commit `c073366` if the server-side approach is
-ever wanted back — it supported `fit`/`a4`/`letter` page sizes and had 36
-Python tests.
+Runtime libraries are PDFKit, blob-stream, Mammoth, SheetJS CE, markdown-it,
+bidi-js, UTIF, and pako. SheetJS is vendored from its official 0.20.3 release
+rather than the outdated npm registry build.
diff --git a/docs/app.js b/docs/app.js
index 35cc1ee..6a1f7a7 100644
--- a/docs/app.js
+++ b/docs/app.js
@@ -1,12 +1,8 @@
-/* MakeItPDF -- everything happens on this device.
-   Images are decoded, downscaled, and written into a PDF with jsPDF. No
-   network request is made at any point. */
+/* MakeItPDF -- one private, local form for images and everyday documents. */
 
 (() => {
   "use strict";
 
-  const { jsPDF } = window.jspdf;
-
   const form = document.getElementById("composer");
   const dropzone = document.getElementById("dropzone");
   const tray = document.getElementById("tray");
@@ -20,27 +16,67 @@
   const progress = document.getElementById("progress");
   const done = document.getElementById("done");
   const doneMeta = document.getElementById("done-meta");
+  const doneNotes = document.getElementById("done-notes");
   const downloadLink = document.getElementById("download");
   const restartBtn = document.getElementById("restart");
 
   const MAX_FILES = Number(form.dataset.maxFiles);
   const MAX_FILE_BYTES = Number(form.dataset.maxFileBytes);
-  const JPEG_QUALITY = 0.9;
-
-  /** @type {{id:number,file:File,url:string}[]} */
-  let pages = [];
+  const MAX_SHEET_CELLS = 50000;
+  const OFFICE_TIMEOUT_MS = 30000;
+
+  const FORMAT_REGISTRY = [
+    {
+      kind: "image",
+      label: "Image",
+      extensions: ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"],
+      mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp", "image/tiff"],
+    },
+    {
+      kind: "docx",
+      label: "Word",
+      extensions: ["docx"],
+      mimeTypes: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
+    },
+    {
+      kind: "xlsx",
+      label: "Excel",
+      extensions: ["xlsx"],
+      mimeTypes: ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
+    },
+    { kind: "markdown", label: "Markdown", extensions: ["md", "markdown"], mimeTypes: ["text/markdown"] },
+    { kind: "csv", label: "CSV", extensions: ["csv"], mimeTypes: ["text/csv", "application/csv"] },
+  ];
+  const FORMAT_BY_EXTENSION = new Map(
+    FORMAT_REGISTRY.flatMap((format) => format.extensions.map((extension) => [extension, format]))
+  );
+  const FORMAT_BY_MIME = new Map(
+    FORMAT_REGISTRY.flatMap((format) => format.mimeTypes.map((mimeType) => [mimeType, format]))
+  );
+
+  /** @type {{id:number,file:File,kind:string,label:string,previewUrl:string|null}[]} */
+  let files = [];
   let nextId = 1;
   let dragId = null;
   let busy = false;
   let lastUrl = null;
 
   const { formatBytes } = Pages;
-  const totalBytes = () => pages.reduce((sum, page) => sum + page.file.size, 0);
+  const totalBytes = () => files.reduce((sum, item) => sum + item.file.size, 0);
+
+  function extensionOf(name) {
+    const match = String(name).toLowerCase().match(/\.([^.]+)$/);
+    return match ? match[1] : "";
+  }
+
+  function formatFor(file) {
+    return FORMAT_BY_EXTENSION.get(extensionOf(file.name)) || FORMAT_BY_MIME.get(file.type) || null;
+  }
 
   function setAlert(message) {
     let alert = document.querySelector("main > .alert");
     if (!message) {
-      if (alert) alert.remove();
+      alert?.remove();
       return;
     }
     if (!alert) {
@@ -52,131 +88,19 @@
     alert.textContent = message;
   }
 
-  /* ---- decoding -------------------------------------------------------- */
-
-  /**
-   * Decode a file to a bitmap, upright.
-   *
-   * imageOrientation "from-image" applies the EXIF rotation tag, without which
-   * phone photos land sideways -- a PDF has no orientation metadata to defer
-   * to, so it has to be baked in here.
-   */
-  async function decode(file) {
-    const buffer = await file.arrayBuffer();
-    const head = new Uint8Array(buffer, 0, Math.min(4, buffer.byteLength));
-
-    if (Pages.isTiff(head)) return decodeTiff(buffer, file.name);
-
-    try {
-      return await createImageBitmap(file, { imageOrientation: "from-image" });
-    } catch {
-      throw new Error(`${file.name} isn't an image this browser can read.`);
-    }
-  }
-
-  /** Browsers have no TIFF decoder, so UTIF unpacks it to RGBA for us. */
-  async function decodeTiff(buffer, name) {
-    try {
-      const ifds = UTIF.decode(buffer);
-      if (!ifds.length) throw new Error("no pages");
-      UTIF.decodeImage(buffer, ifds[0], ifds);
-      const rgba = UTIF.toRGBA8(ifds[0]);
-      const { width, height } = ifds[0];
-      const canvas = new OffscreenCanvas(width, height);
-      canvas
-        .getContext("2d")
-        .putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0);
-      return await createImageBitmap(canvas);
-    } catch {
-      throw new Error(`${name} is a TIFF we couldn't read.`);
-    }
-  }
-
-  /**
-   * Draw a bitmap into a canvas at its final size on the page.
-   *
-   * The resize happens in createImageBitmap rather than in drawImage: a single
-   * drawImage step from 24MP down to ~1100px aliases badly, while the bitmap
-   * resizer downsamples properly.
-   */
-  async function rasterise(bitmap, raster) {
-    const scaled =
-      bitmap.width === raster.width && bitmap.height === raster.height
-        ? bitmap
-        : await createImageBitmap(bitmap, {
-            resizeWidth: raster.width,
-            resizeHeight: raster.height,
-            resizeQuality: "high",
-          });
-
-    const canvas = document.createElement("canvas");
-    canvas.width = raster.width;
-    canvas.height = raster.height;
-    const ctx = canvas.getContext("2d");
-    // JPEG has no alpha, so transparency would come out black without this.
-    ctx.fillStyle = "#ffffff";
-    ctx.fillRect(0, 0, raster.width, raster.height);
-    ctx.drawImage(scaled, 0, 0);
-    if (scaled !== bitmap) scaled.close();
-    return canvas;
-  }
-
-  /** Build the PDF, one page per image, in list order. */
-  async function buildPdf(items, onProgress) {
-    let doc = null;
-
-    for (let index = 0; index < items.length; index += 1) {
-      const bitmap = await decode(items[index].file);
-      try {
-        const size = Pages.pageSize(bitmap.width, bitmap.height);
-        const placement = Pages.placeImage(
-          bitmap.width, bitmap.height, size.width, size.height
-        );
-        const canvas = await rasterise(bitmap, Pages.rasterSize(placement));
-
-        if (doc === null) {
-          doc = new jsPDF({
-            unit: "pt",
-            format: Pages.A4_PORTRAIT,
-            orientation: size.orientation,
-            compress: true,
-          });
-        } else {
-          doc.addPage(Pages.A4_PORTRAIT, size.orientation);
-        }
-
-        doc.addImage(
-          canvas.toDataURL("image/jpeg", JPEG_QUALITY),
-          "JPEG",
-          placement.x, placement.y, placement.width, placement.height
-        );
-      } finally {
-        bitmap.close();
-      }
-      onProgress(index + 1, items.length);
-      // Yield so the progress bar and counter can actually paint.
-      await new Promise((resolve) => setTimeout(resolve, 0));
-    }
-
-    return doc.output("blob");
-  }
-
-  /* ---- intake ---------------------------------------------------------- */
-
   function accept(fileList) {
     const problems = [];
-    let room = MAX_FILES - pages.length;
+    let room = MAX_FILES - files.length;
 
     for (const file of Array.from(fileList)) {
       if (room <= 0) {
-        problems.push(`${MAX_FILES} images is the limit — skipped the rest.`);
+        problems.push(`${MAX_FILES} files is the limit — skipped the rest.`);
         break;
       }
-      // TIFFs often arrive with an empty type, so fall back to the extension.
-      const looksLikeImage =
-        file.type.startsWith("image/") || /\.tiff?$/i.test(file.name);
-      if (!looksLikeImage) {
-        problems.push(`${file.name} isn't an image.`);
+      const format = formatFor(file);
+      if (!format) {
+        const extension = extensionOf(file.name).toUpperCase() || "That file type";
+        problems.push(`${extension} isn't supported. Use images, DOCX, XLSX, Markdown, or CSV.`);
         continue;
       }
       if (file.size > MAX_FILE_BYTES) {
@@ -185,144 +109,233 @@
         );
         continue;
       }
-      pages.push({ id: nextId++, file, url: URL.createObjectURL(file) });
+      files.push({
+        id: nextId++,
+        file,
+        kind: format.kind,
+        label: format.label,
+        previewUrl: format.kind === "image" ? URL.createObjectURL(file) : null,
+      });
       room -= 1;
     }
 
-    setAlert(problems.length ? problems[0] : "");
+    setAlert(problems[0] || "");
     showComposer();
     render();
   }
 
   function remove(id) {
-    const index = pages.findIndex((page) => page.id === id);
+    if (busy) return;
+    const index = files.findIndex((item) => item.id === id);
     if (index === -1) return;
-    URL.revokeObjectURL(pages[index].url);
-    pages.splice(index, 1);
+    if (files[index].previewUrl) URL.revokeObjectURL(files[index].previewUrl);
+    files.splice(index, 1);
     render();
   }
 
   function move(id, delta) {
-    const from = pages.findIndex((page) => page.id === id);
+    if (busy) return;
+    const from = files.findIndex((item) => item.id === id);
     const to = from + delta;
-    if (from === -1 || to < 0 || to >= pages.length) return;
-    [pages[from], pages[to]] = [pages[to], pages[from]];
+    if (from === -1 || to < 0 || to >= files.length) return;
+    [files[from], files[to]] = [files[to], files[from]];
     render();
-    const arrow = sheetsEl.querySelector(
-      `.card[data-id="${id}"] .card__arrow--${delta < 0 ? "up" : "down"}`
-    );
-    if (arrow && !arrow.disabled) arrow.focus();
+    sheetsEl
+      .querySelector(`.card[data-id="${id}"] .card__arrow--${delta < 0 ? "up" : "down"}`)
+      ?.focus();
   }
 
   function clearAll() {
-    pages.forEach((page) => URL.revokeObjectURL(page.url));
-    pages = [];
+    if (busy) return;
+    files.forEach((item) => item.previewUrl && URL.revokeObjectURL(item.previewUrl));
+    files = [];
     setAlert("");
     render();
   }
 
-  /* ---- render ---------------------------------------------------------- */
-
   function render() {
     sheetsEl.textContent = "";
-
-    pages.forEach((page, index) => {
+    files.forEach((item, index) => {
       const node = template.content.firstElementChild.cloneNode(true);
-      node.dataset.id = String(page.id);
+      node.dataset.id = String(item.id);
       node.querySelector(".card__num").textContent = String(index + 1);
-      node.querySelector(".card__name").textContent = page.file.name;
+      node.querySelector(".card__name").textContent = item.file.name;
+      node.querySelector(".card__meta").textContent = `${item.label} · ${formatBytes(item.file.size)}`;
+      node.querySelector(".card__type").textContent = extensionOf(item.file.name).toUpperCase();
+      node.classList.add(`card--${item.kind}`);
 
       const img = node.querySelector(".card__img");
-      // No <img> decoder for TIFF either, so the thumbnail falls back to a
-      // plain sheet outline rather than a broken image.
-      img.src = page.url;
-      img.addEventListener("error", () => {
+      if (item.previewUrl) {
+        img.src = item.previewUrl;
+        img.alt = `Preview of ${item.file.name}`;
+        img.addEventListener("error", () => {
+          img.remove();
+          node.querySelector(".card__frame").classList.add("card__frame--document");
+        }, { once: true });
+      } else {
         img.remove();
-        node.querySelector(".card__frame").classList.add("card__frame--blank");
-      }, { once: true });
+        node.querySelector(".card__frame").classList.add("card__frame--document");
+      }
 
       const up = node.querySelector(".card__arrow--up");
       const down = node.querySelector(".card__arrow--down");
-      up.disabled = index === 0;
-      down.disabled = index === pages.length - 1;
-      up.addEventListener("click", () => move(page.id, -1));
-      down.addEventListener("click", () => move(page.id, 1));
-      node.querySelector(".card__remove").addEventListener("click", () => remove(page.id));
+      up.querySelector(".visually-hidden").textContent = `Move ${item.file.name} earlier`;
+      down.querySelector(".visually-hidden").textContent = `Move ${item.file.name} later`;
+      up.disabled = index === 0 || busy;
+      down.disabled = index === files.length - 1 || busy;
+      up.addEventListener("click", () => move(item.id, -1));
+      down.addEventListener("click", () => move(item.id, 1));
+      const removeButton = node.querySelector(".card__remove");
+      removeButton.querySelector(".visually-hidden").textContent = `Remove ${item.file.name}`;
+      removeButton.disabled = busy;
+      removeButton.addEventListener("click", () => remove(item.id));
 
       node.addEventListener("dragstart", (event) => {
-        dragId = page.id;
+        if (busy) return event.preventDefault();
+        dragId = item.id;
         node.classList.add("is-lifted");
         event.dataTransfer.effectAllowed = "move";
-        // Firefox refuses to start a drag without payload.
-        event.dataTransfer.setData("text/plain", String(page.id));
+        event.dataTransfer.setData("text/plain", String(item.id));
       });
       node.addEventListener("dragend", () => {
         dragId = null;
         node.classList.remove("is-lifted");
-        sheetsEl.querySelectorAll(".is-over").forEach((el) => el.classList.remove("is-over"));
+        sheetsEl.querySelectorAll(".is-over").forEach((element) => element.classList.remove("is-over"));
       });
       node.addEventListener("dragover", (event) => {
-        if (dragId === null || dragId === page.id) return;
+        if (dragId === null || dragId === item.id) return;
         event.preventDefault();
         node.classList.add("is-over");
       });
       node.addEventListener("dragleave", () => node.classList.remove("is-over"));
       node.addEventListener("drop", (event) => {
-        if (dragId === null) return;
+        if (dragId === null || busy) return;
         event.preventDefault();
         event.stopPropagation();
         node.classList.remove("is-over");
-        const from = pages.findIndex((p) => p.id === dragId);
-        const to = pages.findIndex((p) => p.id === page.id);
+        const from = files.findIndex((candidate) => candidate.id === dragId);
+        const to = files.findIndex((candidate) => candidate.id === item.id);
         if (from === -1 || to === -1 || from === to) return;
-        pages.splice(to, 0, pages.splice(from, 1)[0]);
+        files.splice(to, 0, files.splice(from, 1)[0]);
         render();
       });
-
       sheetsEl.appendChild(node);
     });
 
-    const count = pages.length;
+    const count = files.length;
     form.classList.toggle("has-pages", count > 0);
     tray.hidden = count === 0;
-    trayCount.textContent =
-      `${count} ${count === 1 ? "image" : "images"} · ${formatBytes(totalBytes())}`;
+    trayCount.textContent = `${count} ${count === 1 ? "file" : "files"} · ${formatBytes(totalBytes())}`;
     buildBtn.disabled = count === 0 || busy;
     buildBtn.hidden = count === 0;
+    clearBtn.disabled = busy;
   }
 
-  /* ---- view switching --------------------------------------------------- */
-
   function showComposer() {
     done.hidden = true;
     form.hidden = false;
   }
 
-  function showDone(blob) {
+  function showDone(result) {
     if (lastUrl) URL.revokeObjectURL(lastUrl);
-    lastUrl = URL.createObjectURL(blob);
+    lastUrl = URL.createObjectURL(result.blob);
     const stamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
     downloadLink.href = lastUrl;
     downloadLink.download = `makeitpdf-${stamp}.pdf`;
-    doneMeta.textContent =
-      `${pages.length} ${pages.length === 1 ? "page" : "pages"} · ${formatBytes(blob.size)}`;
+    doneMeta.textContent = `${result.pageCount} ${result.pageCount === 1 ? "page" : "pages"} · ${formatBytes(result.blob.size)}`;
+    if (result.warnings.length) {
+      doneNotes.hidden = false;
+      doneNotes.textContent = `${result.warnings.length} conversion ${result.warnings.length === 1 ? "note" : "notes"}: ${result.warnings.slice(0, 3).join(" · ")}`;
+    } else {
+      doneNotes.hidden = true;
+      doneNotes.textContent = "";
+    }
     form.hidden = true;
     done.hidden = false;
     downloadLink.focus();
   }
 
-  /* ---- wiring ---------------------------------------------------------- */
+  function runOfficeWorker(kind, file) {
+    return file.arrayBuffer().then(
+      (buffer) =>
+        new Promise((resolve, reject) => {
+          const worker = new Worker("office-worker.js");
+          const timer = setTimeout(() => {
+            worker.terminate();
+            reject(new Error(`${file.name} took longer than 30 seconds to read.`));
+          }, OFFICE_TIMEOUT_MS);
+          worker.addEventListener("message", (event) => {
+            if (event.data.id !== 1) return;
+            clearTimeout(timer);
+            worker.terminate();
+            if (event.data.ok) resolve(event.data.result);
+            else reject(new Error(`${file.name} ${event.data.error}`));
+          });
+          worker.addEventListener("error", () => {
+            clearTimeout(timer);
+            worker.terminate();
+            reject(new Error(`${file.name} could not be read.`));
+          });
+          worker.postMessage({ id: 1, kind, buffer }, [buffer]);
+        })
+    );
+  }
+
+  async function parseItem(item) {
+    if (item.kind === "image") return { ...item, warnings: [] };
+    if (item.kind === "docx") {
+      const result = await runOfficeWorker("docx", item.file);
+      const parsed = Documents.htmlToBlocks(result.html);
+      if (!parsed.blocks.length) throw new Error(`${item.file.name} did not contain readable document content.`);
+      return { ...item, blocks: parsed.blocks, warnings: [...result.warnings, ...parsed.warnings] };
+    }
+    if (item.kind === "xlsx") {
+      const result = await runOfficeWorker("xlsx", item.file);
+      return { ...item, sheets: result.sheets, warnings: [] };
+    }
+
+    let text;
+    try {
+      text = Documents.decodeUtf8(await item.file.arrayBuffer());
+    } catch (error) {
+      throw new Error(`${item.file.name} ${error.message}`);
+    }
+    if (item.kind === "csv") {
+      let parsed;
+      try {
+        parsed = Documents.parseCsv(text);
+      } catch (error) {
+        throw new Error(`${item.file.name} ${error.message}`);
+      }
+      if (parsed.cells > MAX_SHEET_CELLS) {
+        throw new Error(`${item.file.name} has more than ${MAX_SHEET_CELLS.toLocaleString()} rendered cells.`);
+      }
+      const name = item.file.name.replace(/\.csv$/i, "") || "CSV";
+      return { ...item, sheets: [{ name, rows: parsed.rows, merges: [], widths: [] }], warnings: [] };
+    }
+
+    const markdown = window.markdownit({ html: false, linkify: true, typographer: false });
+    markdown.validateLink = (url) => Boolean(Documents.safeUrl(url) || Documents.SAFE_DATA_IMAGE.test(url));
+    const parsed = Documents.htmlToBlocks(markdown.render(text));
+    parsed.blocks.forEach((block) => {
+      if (block.type === "list") {
+        block.items = block.items.map((entry) =>
+          entry.replace(/^\[ \]\s*/, "☐ ").replace(/^\[[xX]\]\s*/, "☒ ")
+        );
+      }
+    });
+    if (!parsed.blocks.length) throw new Error(`${item.file.name} did not contain readable Markdown.`);
+    return { ...item, blocks: parsed.blocks, warnings: parsed.warnings };
+  }
 
   formats.textContent =
-    `JPG · PNG · WEBP · GIF · BMP · TIFF · up to ${formatBytes(MAX_FILE_BYTES)} each`;
+    `JPG · PNG · WEBP · GIF · BMP · TIFF · DOCX · XLSX · MD · CSV · ${formatBytes(MAX_FILE_BYTES)} each`;
 
   picker.addEventListener("change", () => {
     accept(picker.files);
     picker.value = "";
   });
-
   clearBtn.addEventListener("click", clearAll);
-
   restartBtn.addEventListener("click", () => {
     clearAll();
     showComposer();
@@ -336,43 +349,44 @@
       dropzone.classList.add("is-target");
     });
   });
-
   ["dragleave", "drop"].forEach((type) => {
     dropzone.addEventListener(type, (event) => {
       if (type === "dragleave" && dropzone.contains(event.relatedTarget)) return;
       dropzone.classList.remove("is-target");
     });
   });
-
   dropzone.addEventListener("drop", (event) => {
     if (!event.dataTransfer?.files.length) return;
     event.preventDefault();
     accept(event.dataTransfer.files);
   });
 
-  /* ---- convert --------------------------------------------------------- */
-
   form.addEventListener("submit", async (event) => {
     event.preventDefault();
-    if (busy || !pages.length) return;
-
+    if (busy || !files.length) return;
     busy = true;
     setAlert("");
     progress.classList.add("is-running");
-    buildBtn.disabled = true;
+    render();
 
     try {
-      const blob = await buildPdf(pages, (current, total) => {
-        buildBtn.textContent = `Converting ${current} of ${total}…`;
+      const parsed = [];
+      for (let index = 0; index < files.length; index += 1) {
+        buildBtn.textContent = `Reading ${index + 1} of ${files.length}…`;
+        parsed.push(await parseItem(files[index]));
+        await new Promise((resolve) => setTimeout(resolve, 0));
+      }
+      const result = await PdfBuilder.build(parsed, (_current, _total, label) => {
+        buildBtn.textContent = `${label}…`;
       });
-      showDone(blob);
+      showDone(result);
     } catch (error) {
       setAlert(error.message || "Something went wrong building the PDF.");
     } finally {
       busy = false;
       progress.classList.remove("is-running");
       buildBtn.textContent = "Convert to PDF";
-      buildBtn.disabled = pages.length === 0;
+      render();
     }
   });
 
diff --git a/docs/documents.js b/docs/documents.js
new file mode 100644
index 0000000..fc34fda
--- /dev/null
+++ b/docs/documents.js
@@ -0,0 +1,231 @@
+/* Safe, format-neutral document parsing helpers. */
+
+(function (root, factory) {
+  if (typeof module === "object" && module.exports) module.exports = factory();
+  else root.Documents = factory();
+})(typeof self !== "undefined" ? self : globalThis, function () {
+  "use strict";
+
+  const SAFE_LINK = /^(https?:|mailto:)/i;
+  const SAFE_DATA_IMAGE = /^data:image\/(?:png|jpe?g|webp|gif|bmp);base64,/i;
+
+  function safeUrl(value) {
+    const url = String(value || "").trim();
+    return SAFE_LINK.test(url) ? url : null;
+  }
+
+  function decodeUtf8(buffer) {
+    try {
+      return new TextDecoder("utf-8", { fatal: true }).decode(buffer).replace(/^\uFEFF/, "");
+    } catch {
+      throw new Error("isn't valid UTF-8. Save it as UTF-8 and try again.");
+    }
+  }
+
+  function parseDelimited(text, delimiter) {
+    const rows = [];
+    let row = [];
+    let field = "";
+    let quoted = false;
+
+    for (let index = 0; index < text.length; index += 1) {
+      const char = text[index];
+      if (quoted) {
+        if (char === '"' && text[index + 1] === '"') {
+          field += '"';
+          index += 1;
+        } else if (char === '"') {
+          quoted = false;
+        } else {
+          field += char;
+        }
+      } else if (char === '"' && field.length === 0) {
+        quoted = true;
+      } else if (char === delimiter) {
+        row.push(field);
+        field = "";
+      } else if (char === "\n" || char === "\r") {
+        if (char === "\r" && text[index + 1] === "\n") index += 1;
+        row.push(field);
+        rows.push(row);
+        row = [];
+        field = "";
+      } else {
+        field += char;
+      }
+    }
+    if (quoted) throw new Error("has an unclosed quoted field.");
+    if (field.length || row.length || !rows.length) {
+      row.push(field);
+      rows.push(row);
+    }
+    return rows;
+  }
+
+  function detectDelimiter(text) {
+    const candidates = [",", "\t", ";", "|"];
+    let best = { delimiter: ",", score: -Infinity };
+    for (const delimiter of candidates) {
+      let rows;
+      try {
+        rows = parseDelimited(text.slice(0, 65536), delimiter).slice(0, 12);
+      } catch {
+        continue;
+      }
+      const counts = rows.filter((row) => row.some((cell) => cell !== "")).map((row) => row.length);
+      const common = counts.reduce((map, count) => map.set(count, (map.get(count) || 0) + 1), new Map());
+      const consistency = Math.max(0, ...common.values());
+      const width = Math.max(1, ...counts);
+      const score = width > 1 ? consistency * 100 + width : 0;
+      if (score > best.score) best = { delimiter, score };
+    }
+    return best.delimiter;
+  }
+
+  function parseCsv(text) {
+    let inQuotes = false;
+    for (let index = 0; index < text.length; index += 1) {
+      if (text[index] !== '"') continue;
+      if (inQuotes && text[index + 1] === '"') index += 1;
+      else inQuotes = !inQuotes;
+    }
+    if (inQuotes) throw new Error("has an unclosed quoted field.");
+    const delimiter = detectDelimiter(text);
+    const rows = parseDelimited(text, delimiter);
+    const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
+    rows.forEach((row) => {
+      while (row.length < width) row.push("");
+    });
+    return { delimiter, rows, cells: rows.length * width };
+  }
+
+  function cleanText(value) {
+    return String(value || "")
+      .replace(/\u00a0/g, " ")
+      .replace(/[ \t]+\n/g, "\n")
+      .replace(/\n[ \t]+/g, "\n")
+      .trim();
+  }
+
+  function htmlToBlocks(html) {
+    if (typeof DOMParser === "undefined") throw new Error("This browser cannot read document content.");
+    const parsed = new DOMParser().parseFromString(String(html || ""), "text/html");
+    const blocks = [];
+    const warnings = [];
+
+    function inline(node, style, into) {
+      const output = into || [];
+      const current = style || {};
+      if (node.nodeType === 3) {
+        if (node.nodeValue) output.push({ text: node.nodeValue, ...current });
+        return output;
+      }
+      if (node.nodeType !== 1) return output;
+      const tag = node.tagName.toLowerCase();
+      if (tag === "br") {
+        output.push({ text: "\n", ...current });
+        return output;
+      }
+      if (tag === "img") return output;
+
+      const next = { ...current };
+      if (tag === "strong" || tag === "b") next.bold = true;
+      if (tag === "em" || tag === "i") next.italic = true;
+      if (tag === "code") next.code = true;
+      if (tag === "s" || tag === "del" || tag === "strike") next.strike = true;
+      if (tag === "a") {
+        const href = safeUrl(node.getAttribute("href"));
+        if (href) next.link = href;
+        else if (node.getAttribute("href")) warnings.push(`Skipped an unsafe link: ${node.getAttribute("href")}`);
+      }
+      Array.from(node.childNodes).forEach((child) => inline(child, next, output));
+      return output;
+    }
+
+    function imagesFrom(element) {
+      return Array.from(element.querySelectorAll("img")).map((img) => {
+        const src = String(img.getAttribute("src") || "");
+        const alt = cleanText(img.getAttribute("alt") || "Image");
+        if (SAFE_DATA_IMAGE.test(src)) return { type: "image", src, alt };
+        const label = src ? `${alt} — ${src}` : alt;
+        warnings.push(`Did not load linked image: ${src || alt}`);
+        return { type: "paragraph", spans: [{ text: label, italic: true }] };
+      });
+    }
+
+    function addTextBlock(type, element, extra) {
+      const spans = inline(element).filter((span) => span.text);
+      const text = cleanText(spans.map((span) => span.text).join(""));
+      if (text) blocks.push({ type, spans, ...(extra || {}) });
+      blocks.push(...imagesFrom(element));
+    }
+
+    function walk(element) {
+      if (element.nodeType !== 1) return;
+      const tag = element.tagName.toLowerCase();
+      if (/^h[1-6]$/.test(tag)) return addTextBlock("heading", element, { level: Number(tag[1]) });
+      if (tag === "p") return addTextBlock("paragraph", element);
+      if (tag === "pre") {
+        const text = element.textContent || "";
+        if (text) blocks.push({ type: "code", text: text.replace(/\n$/, "") });
+        return;
+      }
+      if (tag === "blockquote") {
+        const text = cleanText(element.textContent);
+        if (text) blocks.push({ type: "quote", text });
+        return;
+      }
+      if (tag === "hr") {
+        blocks.push({ type: "rule" });
+        return;
+      }
+      if (tag === "img") {
+        blocks.push(...imagesFrom(element.parentElement || element));
+        return;
+      }
+      if (tag === "ul" || tag === "ol") {
+        const items = Array.from(element.children)
+          .filter((child) => child.tagName.toLowerCase() === "li")
+          .map((item) => {
+            const clone = item.cloneNode(true);
+            clone.querySelectorAll("ul,ol").forEach((nested) => nested.remove());
+            return cleanText(clone.textContent);
+          })
+          .filter(Boolean);
+        if (items.length) blocks.push({ type: "list", ordered: tag === "ol", items });
+        return;
+      }
+      if (tag === "table") {
+        const rows = Array.from(element.querySelectorAll("tr")).map((row) =>
+          Array.from(row.children)
+            .filter((cell) => /^(td|th)$/i.test(cell.tagName))
+            .map((cell) => ({
+              text: cleanText(cell.textContent),
+              header: cell.tagName.toLowerCase() === "th",
+              colSpan: Math.max(1, Number(cell.getAttribute("colspan")) || 1),
+              rowSpan: Math.max(1, Number(cell.getAttribute("rowspan")) || 1),
+            }))
+        );
+        if (rows.length) blocks.push({ type: "table", rows });
+        return;
+      }
+      if (["body", "main", "article", "section", "div", "figure", "figcaption"].includes(tag)) {
+        Array.from(element.children).forEach(walk);
+      } else if (cleanText(element.textContent)) {
+        addTextBlock("paragraph", element);
+      }
+    }
+
+    Array.from(parsed.body.children).forEach(walk);
+    return { blocks, warnings };
+  }
+
+  return {
+    SAFE_DATA_IMAGE,
+    safeUrl,
+    decodeUtf8,
+    detectDelimiter,
+    parseCsv,
+    htmlToBlocks,
+  };
+});
diff --git a/docs/index.html b/docs/index.html
index a03c147..c4a2371 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,10 +4,7 @@
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>MakeItPDF</title>
-<meta name="description" content="Combine up to 10 images into a single PDF, entirely in your browser.">
-<link rel="preconnect" href="https://fonts.googleapis.com">
-<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link href="https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet">
+<meta name="description" content="Turn images, Word, Excel, Markdown, and CSV files into one PDF, entirely in your browser.">
 <link rel="stylesheet" href="style.css">
 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='9' fill='%23f4402d'/><rect x='7.5' y='5' width='11' height='15' rx='2.4' fill='%23fff' opacity='.5'/><rect x='13.5' y='11' width='11' height='15' rx='2.4' fill='%23fff' stroke='%23f4402d' stroke-width='2.2'/></svg>">
 </head>
@@ -28,8 +25,8 @@
       </svg>
       <span class="logo__text">MakeIt<span class="logo__accent">PDF</span></span>
     </h1>
-    <p class="intro__sub">Combine up to 10 images into a single PDF.
-      Nothing leaves your device.</p>
+    <p class="intro__sub">Turn images, Word, Excel, Markdown, and CSV files
+      into one PDF. Nothing leaves your device.</p>
   </div>
 
   <noscript>
@@ -41,23 +38,26 @@
   <form class="composer" id="composer" data-max-files="10" data-max-file-bytes="26214400">
 
     <input class="picker" id="picker" type="file" multiple
-           accept="image/jpeg,image/png,image/webp,image/gif,image/bmp,image/tiff">
+           accept=".jpg,.jpeg,.png,.webp,.gif,.bmp,.tif,.tiff,.docx,.xlsx,.md,.markdown,.csv">
 
     <div class="dropzone" id="dropzone">
-      <label class="btn btn--accent btn--lg" for="picker">Select images</label>
+      <div class="format-spine" aria-hidden="true">
+        <span>IMG</span><span>DOCX</span><span>XLSX</span><span>MD</span><span>CSV</span>
+      </div>
+      <label class="btn btn--accent btn--lg" for="picker">Select files</label>
       <p class="dropzone__hint">or drop them here</p>
       <p class="dropzone__formats" id="formats"></p>
     </div>
 
     <section class="tray" id="tray" hidden>
       <div class="tray__head">
-        <h2 class="tray__count" id="tray-count">0 images</h2>
+        <h2 class="tray__count" id="tray-count">0 files</h2>
         <button class="link" id="clear" type="button">Clear all</button>
       </div>
       <ol class="grid" id="sheets"></ol>
     </section>
 
-    <button class="btn btn--accent btn--block" id="build" type="submit" hidden>Convert to PDF</button>
+    <button class="btn btn--accent btn--block" id="build" type="submit" aria-live="polite" hidden>Convert to PDF</button>
   </form>
 
   <section class="done" id="done" hidden>
@@ -68,11 +68,12 @@
     </svg>
     <h2 class="done__title">Your PDF is ready</h2>
     <p class="done__meta" id="done-meta"></p>
+    <p class="done__notes" id="done-notes" hidden></p>
     <a class="btn btn--accent btn--lg" id="download" download>Download PDF</a>
-    <button class="link" id="restart" type="button">Convert more images</button>
+    <button class="link" id="restart" type="button">Convert more files</button>
   </section>
 
-  <p class="footnote">Your images are never uploaded. The PDF is built on this
+  <p class="footnote">Your files are never uploaded. The PDF is built on this
     device and stays here.</p>
 </main>
 
@@ -80,6 +81,7 @@
   <li class="card" draggable="true">
     <span class="card__frame">
       <img class="card__img" alt="">
+      <span class="card__paper" aria-hidden="true"><span class="card__type"></span></span>
       <span class="card__num"></span>
       <button class="card__remove" type="button">
         <svg viewBox="0 0 16 16" aria-hidden="true">
@@ -104,15 +106,20 @@
       </span>
     </span>
     <span class="card__name"></span>
+    <span class="card__meta"></span>
   </li>
 </template>
 
-<script src="vendor/jspdf.umd.min.js"></script>
-<!-- pako must load before UTIF: UTIF picks it up as self.pako at load time and
-     needs it to inflate deflate-compressed TIFFs. -->
+<script src="vendor/pdfkit.standalone.js"></script>
+<script src="vendor/blob-stream.js"></script>
+<script src="vendor/bidi.min.js"></script>
+<script src="vendor/markdown-it.min.js"></script>
+<!-- pako must load before UTIF so deflate-compressed TIFFs work. -->
 <script src="vendor/pako_inflate.min.js"></script>
 <script src="vendor/UTIF.js"></script>
 <script src="pages.js"></script>
+<script src="documents.js"></script>
+<script src="pdf-builder.js"></script>
 <script src="app.js"></script>
 </body>
 </html>
diff --git a/docs/office-worker.js b/docs/office-worker.js
new file mode 100644
index 0000000..d747674
--- /dev/null
+++ b/docs/office-worker.js
@@ -0,0 +1,94 @@
+/* DOCX/XLSX parsing is isolated from the UI thread. No network URLs are used;
+   importScripts only reads the vendored files beside this application. */
+
+"use strict";
+
+const MAX_CELLS = 50000;
+
+self.addEventListener("message", async (event) => {
+  const { id, kind, buffer } = event.data || {};
+  try {
+    if (kind === "docx") {
+      importScripts("vendor/mammoth.browser.min.js");
+      const result = await self.mammoth.convertToHtml(
+        { arrayBuffer: buffer },
+        { externalFileAccess: false }
+      );
+      self.postMessage({
+        id,
+        ok: true,
+        result: {
+          html: result.value,
+          warnings: result.messages.map((message) => message.message || String(message)),
+        },
+      });
+      return;
+    }
+
+    if (kind === "xlsx") {
+      importScripts("vendor/xlsx.full.min.js");
+      const workbook = self.XLSX.read(buffer, {
+        type: "array",
+        cellDates: false,
+        cellNF: true,
+        cellStyles: true,
+      });
+      const sheetMeta = new Map(
+        (workbook.Workbook?.Sheets || []).map((sheet) => [sheet.name, sheet])
+      );
+      const sheets = [];
+      let cellCount = 0;
+
+      for (const name of workbook.SheetNames) {
+        if (sheetMeta.get(name)?.Hidden) continue;
+        const worksheet = workbook.Sheets[name];
+        const ref = worksheet["!ref"];
+        if (!ref) {
+          sheets.push({ name, rows: [], merges: [], widths: [] });
+          continue;
+        }
+        const range = self.XLSX.utils.decode_range(ref);
+        const rowCount = range.e.r - range.s.r + 1;
+        const columnCount = range.e.c - range.s.c + 1;
+        cellCount += rowCount * columnCount;
+        if (cellCount > MAX_CELLS) {
+          throw new Error(`has more than ${MAX_CELLS.toLocaleString()} rendered cells.`);
+        }
+
+        const rows = [];
+        for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) {
+          const row = [];
+          for (let columnIndex = range.s.c; columnIndex <= range.e.c; columnIndex += 1) {
+            const address = self.XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex });
+            const cell = worksheet[address];
+            if (!cell) row.push("");
+            else if (cell.w !== undefined) row.push(String(cell.w));
+            else if (cell.v !== undefined && cell.v !== null) row.push(String(cell.v));
+            else if (cell.f) row.push(`=${cell.f}`);
+            else row.push("");
+          }
+          rows.push(row);
+        }
+
+        const merges = (worksheet["!merges"] || []).map((merge) => ({
+          startRow: merge.s.r - range.s.r,
+          startColumn: merge.s.c - range.s.c,
+          rowSpan: merge.e.r - merge.s.r + 1,
+          colSpan: merge.e.c - merge.s.c + 1,
+        }));
+        const widths = Array.from({ length: columnCount }, (_, index) => {
+          const column = worksheet["!cols"]?.[range.s.c + index];
+          return column?.wpx || (column?.wch ? column.wch * 7 : null);
+        });
+        sheets.push({ name, rows, merges, widths });
+      }
+      if (!sheets.length) throw new Error("has no visible worksheets.");
+      self.postMessage({ id, ok: true, result: { sheets, cellCount } });
+      return;
+    }
+
+    throw new Error("uses an unsupported Office format.");
+  } catch (error) {
+    self.postMessage({ id, ok: false, error: error.message || String(error) });
+  }
+});
diff --git a/docs/package-lock.json b/docs/package-lock.json
new file mode 100644
index 0000000..b650b92
--- /dev/null
+++ b/docs/package-lock.json
@@ -0,0 +1,777 @@
+{
+  "name": "makeitpdf",
+  "version": "2.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "makeitpdf",
+      "version": "2.0.0",
+      "dependencies": {
+        "@fontsource/figtree": "^5.3.0",
+        "bidi-js": "^1.0.3",
+        "blob-stream": "^0.1.3",
+        "mammoth": "^1.12.1",
+        "markdown-it": "^15.0.0",
+        "pdfkit": "^0.19.1",
+        "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
+      },
+      "devDependencies": {
+        "pdfjs-dist": "^6.2.108",
+        "playwright-core": "^1.62.1"
+      }
+    },
+    "node_modules/@fontsource/figtree": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/@fontsource/figtree/-/figtree-5.3.0.tgz",
+      "integrity": "sha512-/iTcTXL1eRBGhvD7NN9dnKzXzWqDLVZVEUFI9xIxAC1G/hXU8tCyYeXCAuPOuvSYPInFUP7H6KLGgygmDfy1SA==",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@napi-rs/canvas": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.7.tgz",
+      "integrity": "sha512-26wVFEgs6gbe7wmzCrud1pK8q6oOgcUu7OOF24BuazB8ZUCskU9ZSLrCjoWIFVxx09rjAxsXxPleaWowHvdPCA==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas-android-arm64": "1.0.7",
+        "@napi-rs/canvas-darwin-arm64": "1.0.7",
+        "@napi-rs/canvas-darwin-x64": "1.0.7",
+        "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.7",
+        "@napi-rs/canvas-linux-arm64-gnu": "1.0.7",
+        "@napi-rs/canvas-linux-arm64-musl": "1.0.7",
+        "@napi-rs/canvas-linux-riscv64-gnu": "1.0.7",
+        "@napi-rs/canvas-linux-x64-gnu": "1.0.7",
+        "@napi-rs/canvas-linux-x64-musl": "1.0.7",
+        "@napi-rs/canvas-win32-arm64-msvc": "1.0.7",
+        "@napi-rs/canvas-win32-x64-msvc": "1.0.7"
+      }
+    },
+    "node_modules/@napi-rs/canvas-android-arm64": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.7.tgz",
+      "integrity": "sha512-d5p4hHTykc/9PjiBXkvCH/IC5hT5jH7BjQ1u8ITq+G8x4VmvveOHdy/4BWYcC3ebWRmrG9zEc/oCTy+Yf3iZ4A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-arm64": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.7.tgz",
+      "integrity": "sha512-jKfZl2QDqBr6/Ap/8NKkX0Po9SFfVjUPBJbQQmYGVtQQIazWWIAO60riH3Mz2sOyysa2oJO39sLEfXerypu0vg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-x64": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.7.tgz",
+      "integrity": "sha512-v1asrnKBu0tD+sdSD2qVIUfhoXSrr8LFTn7z76pN+8xsiOrmFcAVty57R/5DB8ZNqNLKsUBDXFSrzf4Wt9NaSg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.7.tgz",
+      "integrity": "sha512-OVW6T1x65BTVfWb//xylCoWxbdII+nzHu3L5T035aerBAnZ5e0nmeTMkMEO9qQrVJq4WcWW8hp+T0dF+JvJPUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.7.tgz",
+      "integrity": "sha512-KX/k1UO61XdKlXxhtIkq1ZUH8uP+NNK4vSrBcM2/4wWUUCZaG93BU5s0KuE9n+TFn0jEoqLSlXOuH1pYuV5dcQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.7.tgz",
+      "integrity": "sha512-r+0Bg+fK8r2AsrbeT7JwBUAERZSjlyhfAMQfZE/zxYGmbswS92hN0FPjynVWbeuz5fIrLfZ6JA5pH8V6drN6qw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.7.tgz",
+      "integrity": "sha512-0tT9KzEcTfb6MWdUWIDfy6uq6UNF691r/9NfXxxl8s9+G5QrD7VP1UC+PAwzsCzJTClIKyidNau/grYTL+QmHw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-musl": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.7.tgz",
+      "integrity": "sha512-rvT0xo1xA+C7e+U/2ngsxWsl9gC5knHU+z8KTT5VK0Zos4AQbWZvtjvegrmzxm4o2yHE32Lexj6CDEJNvuTczA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.7.tgz",
+      "integrity": "sha512-N6s3yoFFPUDkfRpjUiKERVYkli7ex5Sf0Bu/My6D6gOGYxYolC1KvL6x8U0aN+ScCWAzbkIWcMYR9g0OGsFuVw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.7.tgz",
+      "integrity": "sha512-SKTNdBV/ljfhkPsLhXjm4x2PQ0ILpc0PhkBzRHuroJXidZUaF5yh0j3s3dIzB8PrRmW+nEASzyMTaWT3nH1ywQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@noble/ciphers": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
+      "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@noble/hashes": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+      "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+      "engines": {
+        "node": "^14.21.3 || >=16"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.23",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+      "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@xmldom/xmldom": {
+      "version": "0.8.14",
+      "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
+      "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dependencies": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
+    "node_modules/blob": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz",
+      "integrity": "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="
+    },
+    "node_modules/blob-stream": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/blob-stream/-/blob-stream-0.1.3.tgz",
+      "integrity": "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog==",
+      "dependencies": {
+        "blob": "0.0.4"
+      }
+    },
+    "node_modules/bluebird": {
+      "version": "3.4.7",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
+      "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
+    },
+    "node_modules/brotli": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+      "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+      "dependencies": {
+        "base64-js": "^1.1.2"
+      }
+    },
+    "node_modules/browserify-zlib": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
+      "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
+      "dependencies": {
+        "pako": "~1.0.5"
+      }
+    },
+    "node_modules/clone": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+      "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+    },
+    "node_modules/dfa": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+      "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="
+    },
+    "node_modules/dingbat-to-unicode": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
+      "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="
+    },
+    "node_modules/duck": {
+      "version": "0.1.12",
+      "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+      "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
+      "dependencies": {
+        "underscore": "^1.13.1"
+      }
+    },
+    "node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+    },
+    "node_modules/fontkit": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+      "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+      "dependencies": {
+        "@swc/helpers": "^0.5.12",
+        "brotli": "^1.3.2",
+        "clone": "^2.1.2",
+        "dfa": "^1.2.0",
+        "fast-deep-equal": "^3.1.3",
+        "restructure": "^3.0.0",
+        "tiny-inflate": "^1.0.3",
+        "unicode-properties": "^1.4.0",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+    },
+    "node_modules/js-md5": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
+      "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="
+    },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "node_modules/linebreak": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+      "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+      "dependencies": {
+        "base64-js": "0.0.8",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/linebreak/node_modules/base64-js": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+      "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
+      "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "dependencies": {
+        "uc.micro": "^3.0.0"
+      }
+    },
+    "node_modules/lop": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
+      "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
+      "dependencies": {
+        "duck": "^0.1.12",
+        "option": "~0.2.1",
+        "underscore": "^1.13.1"
+      }
+    },
+    "node_modules/mammoth": {
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.1.tgz",
+      "integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==",
+      "dependencies": {
+        "@xmldom/xmldom": "^0.8.6",
+        "argparse": "~1.0.3",
+        "base64-js": "^1.5.1",
+        "bluebird": "~3.4.0",
+        "dingbat-to-unicode": "^1.0.1",
+        "jszip": "^3.7.1",
+        "lop": "^0.4.2",
+        "path-is-absolute": "^1.0.0",
+        "underscore": "^1.13.1",
+        "xmlbuilder": "^10.0.0"
+      },
+      "bin": {
+        "mammoth": "bin/mammoth"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "15.0.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz",
+      "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/markdown-it"
+        }
+      ],
+      "dependencies": {
+        "argparse": "^3.0.0",
+        "entities": "^8.0.0",
+        "linkify-it": "^6.0.0",
+        "mdurl": "^2.1.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^3.0.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/markdown-it/node_modules/argparse": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz",
+      "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ]
+    },
+    "node_modules/mdurl": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
+      "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="
+    },
+    "node_modules/option": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
+      "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="
+    },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pdfjs-dist": {
+      "version": "6.2.108",
+      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
+      "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=22.13.0 || >=24"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas": "^1.0.0"
+      }
+    },
+    "node_modules/pdfkit": {
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
+      "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
+      "dependencies": {
+        "@noble/ciphers": "^1.0.0",
+        "@noble/hashes": "^1.6.0",
+        "fontkit": "^2.0.4",
+        "js-md5": "^0.8.3",
+        "linebreak": "^1.1.0",
+        "png-js": "^1.1.0"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.62.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+      "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+      "dev": true,
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/png-js": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
+      "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
+      "dependencies": {
+        "browserify-zlib": "^0.2.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/restructure": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+      "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/tiny-inflate": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+      "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+    },
+    "node_modules/uc.micro": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
+      "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="
+    },
+    "node_modules/underscore": {
+      "version": "1.13.8",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+      "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="
+    },
+    "node_modules/unicode-properties": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+      "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+      "dependencies": {
+        "base64-js": "^1.3.0",
+        "unicode-trie": "^2.0.0"
+      }
+    },
+    "node_modules/unicode-trie": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+      "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+      "dependencies": {
+        "pako": "^0.2.5",
+        "tiny-inflate": "^1.0.0"
+      }
+    },
+    "node_modules/unicode-trie/node_modules/pako": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+      "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+    },
+    "node_modules/xlsx": {
+      "version": "0.20.3",
+      "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+      "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/xmlbuilder": {
+      "version": "10.1.1",
+      "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
+      "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
+      "engines": {
+        "node": ">=4.0"
+      }
+    }
+  }
+}
diff --git a/docs/package.json b/docs/package.json
index 81fa59b..384a79d 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -1,10 +1,24 @@
 {
   "name": "makeitpdf",
-  "version": "2.0.0",
+  "version": "3.0.0",
   "private": true,
-  "description": "Combine up to 10 images into a single PDF, entirely in the browser",
+  "description": "Turn images, Word, Excel, Markdown, and CSV files into one PDF in the browser",
   "scripts": {
-    "test": "node --test \"tests/*.test.js\"",
+    "test": "node --test tests/pages.test.js tests/documents.test.js",
+    "test:browser": "node --test tests/browser.test.js",
     "serve": "python3 -m http.server 8000"
+  },
+  "dependencies": {
+    "@fontsource/figtree": "^5.3.0",
+    "bidi-js": "^1.0.3",
+    "blob-stream": "^0.1.3",
+    "mammoth": "^1.12.1",
+    "markdown-it": "^15.0.0",
+    "pdfkit": "^0.19.1",
+    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
+  },
+  "devDependencies": {
+    "pdfjs-dist": "^6.2.108",
+    "playwright-core": "^1.62.1"
   }
 }
diff --git a/docs/pages.js b/docs/pages.js
index 42966e1..b675ddc 100644
--- a/docs/pages.js
+++ b/docs/pages.js
@@ -1,6 +1,5 @@
-/* Page geometry. No DOM, no jsPDF -- so tests/pages.test.js can run it
-   straight through node. Loaded as a plain script in the browser
-   (window.Pages) and as CommonJS in the test runner. */
+/* Pure page and image-metadata helpers. This file intentionally has no DOM
+   dependencies so the conversion rules can be exercised with node --test. */
 
 (function (root, factory) {
   if (typeof module === "object" && module.exports) module.exports = factory();
@@ -8,20 +7,12 @@
 })(typeof self !== "undefined" ? self : globalThis, function () {
   "use strict";
 
-  /** A4 in PostScript points (1/72in), portrait. */
   const A4_PORTRAIT = [595.28, 841.89];
-
-  /** Margin around the image on every page, in points. */
   const MARGIN_PT = 24;
-
-  /** Pages are rasterised at this density before being placed. */
   const RENDER_DPI = 150;
+  const DEFAULT_IMAGE_DPI = 96;
+  const MAX_PAGE_PT = 14400; // 200 inches, the widely-supported PDF limit.
 
-  /**
-   * The page an image belongs on: A4, turned to match the image.
-   * jsPDF wants the portrait format plus an orientation flag, so both the
-   * final dimensions and that flag come back together.
-   */
   function pageSize(imageWidth, imageHeight) {
     const landscape = imageWidth > imageHeight;
     const [short, long] = A4_PORTRAIT;
@@ -32,10 +23,6 @@
     };
   }
 
-  /**
-   * Where the image sits on the page: scaled to fit inside the margins,
-   * centred, never enlarged past the box. All values in points.
-   */
   function placeImage(imageWidth, imageHeight, pageWidth, pageHeight, margin) {
     const inset = margin === undefined ? MARGIN_PT : margin;
     const boxWidth = Math.max(1, pageWidth - inset * 2);
@@ -51,11 +38,6 @@
     };
   }
 
-  /**
-   * Pixel dimensions to rasterise a placed image at, so it lands on the page
-   * at RENDER_DPI. Big camera images get downsampled here rather than being
-   * embedded whole -- an A4 content area is only ~1140px wide at 150 DPI.
-   */
   function rasterSize(placement, dpi) {
     const density = (dpi === undefined ? RENDER_DPI : dpi) / 72;
     return {
@@ -70,28 +52,215 @@
     return (bytes / (1024 * 1024)).toFixed(1) + " MB";
   }
 
-  /**
-   * TIFF has no browser decoder, so it has to be spotted before we hand the
-   * file to createImageBitmap. Magic bytes, not the name or content-type:
-   * "II*\0" (little-endian) or "MM\0*" (big-endian).
-   */
   function isTiff(bytes) {
     if (!bytes || bytes.length < 4) return false;
-    const littleEndian =
-      bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00;
-    const bigEndian =
-      bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a;
-    return littleEndian || bigEndian;
+    return (
+      (bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
+      (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
+    );
+  }
+
+  const ascii = (bytes, start, length) =>
+    String.fromCharCode.apply(null, Array.from(bytes.slice(start, start + length)));
+
+  function tiffDirectory(bytes, start) {
+    if (start + 8 > bytes.length) return null;
+    const little = bytes[start] === 0x49 && bytes[start + 1] === 0x49;
+    if (!little && !(bytes[start] === 0x4d && bytes[start + 1] === 0x4d)) return null;
+    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+    const u16 = (offset) => view.getUint16(offset, little);
+    const u32 = (offset) => view.getUint32(offset, little);
+    if (u16(start + 2) !== 42) return null;
+    const ifd = start + u32(start + 4);
+    if (ifd + 2 > bytes.length) return null;
+
+    const out = {};
+    const count = u16(ifd);
+    const typeBytes = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 9: 4, 10: 8 };
+
+    function valueAt(entry, type, amount) {
+      const size = (typeBytes[type] || 0) * amount;
+      if (!size) return undefined;
+      const offset = size <= 4 ? entry + 8 : start + u32(entry + 8);
+      if (offset < 0 || offset + size > bytes.length) return undefined;
+      if (type === 3) return u16(offset);
+      if (type === 4) return u32(offset);
+      if (type === 5) {
+        const denominator = u32(offset + 4);
+        return denominator ? u32(offset) / denominator : undefined;
+      }
+      return bytes[offset];
+    }
+
+    for (let index = 0; index < count; index += 1) {
+      const entry = ifd + 2 + index * 12;
+      if (entry + 12 > bytes.length) break;
+      const tag = u16(entry);
+      const type = u16(entry + 2);
+      const amount = u32(entry + 4);
+      if ([256, 257, 274, 282, 283, 296].includes(tag)) {
+        out[tag] = valueAt(entry, type, amount);
+      }
+    }
+    return out;
+  }
+
+  function parseJpeg(bytes) {
+    let offset = 2;
+    let jfif = null;
+    let exif = null;
+    let width;
+    let height;
+
+    while (offset + 4 <= bytes.length && bytes[offset] === 0xff) {
+      while (bytes[offset] === 0xff) offset += 1;
+      const marker = bytes[offset++];
+      if (marker === 0xd9 || marker === 0xda) break;
+      if (offset + 2 > bytes.length) break;
+      const length = (bytes[offset] << 8) | bytes[offset + 1];
+      const data = offset + 2;
+      if (length < 2 || data + length - 2 > bytes.length) break;
+
+      if (marker === 0xe0 && ascii(bytes, data, 5) === "JFIF\0" && length >= 14) {
+        const unit = bytes[data + 7];
+        let dpiX = (bytes[data + 8] << 8) | bytes[data + 9];
+        let dpiY = (bytes[data + 10] << 8) | bytes[data + 11];
+        if (unit === 2) {
+          dpiX *= 2.54;
+          dpiY *= 2.54;
+        }
+        if (unit === 1 || unit === 2) jfif = { dpiX, dpiY };
+      }
+      if (marker === 0xe1 && ascii(bytes, data, 6) === "Exif\0\0") {
+        const tags = tiffDirectory(bytes, data + 6);
+        if (tags) {
+          let dpiX = tags[282];
+          let dpiY = tags[283];
+          if (tags[296] === 3) {
+            dpiX *= 2.54;
+            dpiY *= 2.54;
+          }
+          exif = { dpiX, dpiY, orientation: tags[274] };
+        }
+      }
+      if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
+        height = (bytes[data + 1] << 8) | bytes[data + 2];
+        width = (bytes[data + 3] << 8) | bytes[data + 4];
+      }
+      offset += length;
+    }
+    const result = { width, height, ...(jfif || {}) };
+    if (exif) {
+      if (Number.isFinite(exif.dpiX) && exif.dpiX > 0) result.dpiX = exif.dpiX;
+      if (Number.isFinite(exif.dpiY) && exif.dpiY > 0) result.dpiY = exif.dpiY;
+      if (exif.orientation) result.orientation = exif.orientation;
+    }
+    return result;
+  }
+
+  function parsePng(bytes) {
+    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+    const out = {};
+    if (bytes.length >= 24) {
+      out.width = view.getUint32(16, false);
+      out.height = view.getUint32(20, false);
+    }
+    let offset = 8;
+    while (offset + 12 <= bytes.length) {
+      const length = view.getUint32(offset, false);
+      const type = ascii(bytes, offset + 4, 4);
+      if (offset + 12 + length > bytes.length) break;
+      if (type === "pHYs" && length >= 9 && bytes[offset + 16] === 1) {
+        out.dpiX = view.getUint32(offset + 8, false) * 0.0254;
+        out.dpiY = view.getUint32(offset + 12, false) * 0.0254;
+      }
+      if (type === "IEND") break;
+      offset += length + 12;
+    }
+    return out;
+  }
+
+  function parseBmp(bytes) {
+    if (bytes.length < 46) return {};
+    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+    return {
+      width: Math.abs(view.getInt32(18, true)),
+      height: Math.abs(view.getInt32(22, true)),
+      dpiX: Math.abs(view.getInt32(38, true)) * 0.0254,
+      dpiY: Math.abs(view.getInt32(42, true)) * 0.0254,
+    };
+  }
+
+  function parseTiff(bytes) {
+    const tags = tiffDirectory(bytes, 0) || {};
+    let dpiX = tags[282];
+    let dpiY = tags[283];
+    if (tags[296] === 3) {
+      dpiX *= 2.54;
+      dpiY *= 2.54;
+    }
+    return {
+      width: tags[256],
+      height: tags[257],
+      orientation: tags[274],
+      dpiX,
+      dpiY,
+    };
+  }
+
+  function parseRasterMetadata(input) {
+    const bytes = input instanceof Uint8Array ? input : new Uint8Array(input || 0);
+    if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) return parseJpeg(bytes);
+    if (bytes.length >= 8 && ascii(bytes, 1, 3) === "PNG") return parsePng(bytes);
+    if (bytes.length >= 2 && ascii(bytes, 0, 2) === "BM") return parseBmp(bytes);
+    if (isTiff(bytes)) return parseTiff(bytes);
+    return {};
+  }
+
+  function validDpi(value) {
+    return Number.isFinite(value) && value > 0 ? value : DEFAULT_IMAGE_DPI;
+  }
+
+  function imagePage(pixelWidth, pixelHeight, dpiX, dpiY, orientation, maxPagePt) {
+    const turn = orientation >= 5 && orientation <= 8;
+    const sourceWidth = Math.max(1, Number(pixelWidth) || 1);
+    const sourceHeight = Math.max(1, Number(pixelHeight) || 1);
+    const resolvedX = validDpi(dpiX);
+    const resolvedY = validDpi(dpiY);
+    const widthPixels = turn ? sourceHeight : sourceWidth;
+    const heightPixels = turn ? sourceWidth : sourceHeight;
+    const widthDpi = turn ? resolvedY : resolvedX;
+    const heightDpi = turn ? resolvedX : resolvedY;
+    const naturalWidth = (widthPixels / widthDpi) * 72;
+    const naturalHeight = (heightPixels / heightDpi) * 72;
+    const cap = maxPagePt === undefined ? MAX_PAGE_PT : maxPagePt;
+    const scale = Math.min(1, cap / naturalWidth, cap / naturalHeight);
+    return {
+      width: naturalWidth * scale,
+      height: naturalHeight * scale,
+      naturalWidth,
+      naturalHeight,
+      pixelWidth: widthPixels,
+      pixelHeight: heightPixels,
+      dpiX: widthDpi,
+      dpiY: heightDpi,
+      scale,
+      capped: scale < 1,
+    };
   }
 
   return {
     A4_PORTRAIT,
     MARGIN_PT,
     RENDER_DPI,
+    DEFAULT_IMAGE_DPI,
+    MAX_PAGE_PT,
     pageSize,
     placeImage,
     rasterSize,
     formatBytes,
     isTiff,
+    parseRasterMetadata,
+    imagePage,
   };
 });
diff --git a/docs/pdf-builder.js b/docs/pdf-builder.js
new file mode 100644
index 0000000..dea1eca
--- /dev/null
+++ b/docs/pdf-builder.js
@@ -0,0 +1,518 @@
+/* Mixed-format PDF writer. PDFKit keeps document text searchable while still
+   allowing every image to use its own physical page size. */
+
+(function (root, factory) {
+  if (typeof module === "object" && module.exports) module.exports = factory();
+  else root.PdfBuilder = factory();
+})(typeof self !== "undefined" ? self : globalThis, function () {
+  "use strict";
+
+  const FONT_FILES = {
+    NotoSans: "vendor/fonts/noto-sans-regular.ttf",
+    NotoSansBold: "vendor/fonts/noto-sans-bold.ttf",
+    NotoSansItalic: "vendor/fonts/noto-sans-italic.ttf",
+    NotoSansBoldItalic: "vendor/fonts/noto-sans-bolditalic.ttf",
+    NotoArabic: "vendor/fonts/noto-arabic-regular.ttf",
+    NotoArabicBold: "vendor/fonts/noto-arabic-bold.ttf",
+    NotoHebrew: "vendor/fonts/noto-hebrew-regular.ttf",
+    NotoHebrewBold: "vendor/fonts/noto-hebrew-bold.ttf",
+    NotoDevanagari: "vendor/fonts/noto-devanagari-regular.ttf",
+    NotoDevanagariBold: "vendor/fonts/noto-devanagari-bold.ttf",
+    NotoCJK: "vendor/fonts/noto-cjk-regular.ttc",
+  };
+  const FONT_CACHE = new Map();
+  const INK = "#101113";
+  const MUTED = "#666b72";
+  const LINE = "#dfe2e6";
+  const SURFACE = "#f5f6f7";
+  const ACCENT = "#f4402d";
+  const RTL_RE = /[\u0590-\u08ff\ufb1d-\ufefc]/;
+
+  async function loadFont(name) {
+    if (!FONT_CACHE.has(name)) {
+      FONT_CACHE.set(
+        name,
+        fetch(FONT_FILES[name]).then((response) => {
+          if (!response.ok) throw new Error(`Could not load the offline font ${name}.`);
+          return response.arrayBuffer();
+        })
+      );
+    }
+    return FONT_CACHE.get(name);
+  }
+
+  function scriptsIn(text) {
+    const value = String(text || "");
+    return {
+      arabic: /[\u0600-\u08ff\ufb50-\ufefc]/.test(value),
+      hebrew: /[\u0590-\u05ff\ufb1d-\ufb4f]/.test(value),
+      devanagari: /[\u0900-\u097f]/.test(value),
+      japanese: /[\u3040-\u30ff]/.test(value),
+      korean: /[\uac00-\ud7af\u1100-\u11ff]/.test(value),
+      han: /[\u3400-\u9fff\uf900-\ufaff]/.test(value),
+    };
+  }
+
+  function collectText(items) {
+    const parts = [];
+    for (const item of items) {
+      parts.push(item.file?.name || "");
+      for (const block of item.blocks || []) {
+        parts.push(block.text || "");
+        parts.push((block.spans || []).map((span) => span.text).join(""));
+        parts.push((block.items || []).join(" "));
+        for (const row of block.rows || []) parts.push(row.map((cell) => cell.text || cell).join(" "));
+      }
+      for (const sheet of item.sheets || []) {
+        parts.push(sheet.name);
+        for (const row of sheet.rows || []) parts.push(row.join(" "));
+      }
+    }
+    return parts.join("\n");
+  }
+
+  async function registerFonts(doc, items) {
+    const text = collectText(items);
+    const scripts = scriptsIn(text);
+    const names = ["NotoSans", "NotoSansBold", "NotoSansItalic", "NotoSansBoldItalic"];
+    if (scripts.arabic) names.push("NotoArabic", "NotoArabicBold");
+    if (scripts.hebrew) names.push("NotoHebrew", "NotoHebrewBold");
+    if (scripts.devanagari) names.push("NotoDevanagari", "NotoDevanagariBold");
+    if (scripts.japanese || scripts.korean || scripts.han) names.push("NotoCJK");
+    const loaded = await Promise.all(names.map(async (name) => [name, await loadFont(name)]));
+    for (const [name, buffer] of loaded) {
+      const bytes = new Uint8Array(buffer);
+      if (name === "NotoCJK") {
+        doc.registerFont("NotoCJKSC", bytes, "NotoSansCJKsc-Regular");
+        doc.registerFont("NotoCJKTC", bytes, "NotoSansCJKtc-Regular");
+        doc.registerFont("NotoCJKJP", bytes, "NotoSansCJKjp-Regular");
+        doc.registerFont("NotoCJKKR", bytes, "NotoSansCJKkr-Regular");
+      } else {
+        doc.registerFont(name, bytes);
+      }
+    }
+  }
+
+  function fontFor(text, bold, italic) {
+    const scripts = scriptsIn(text);
+    if (scripts.arabic) return bold ? "NotoArabicBold" : "NotoArabic";
+    if (scripts.hebrew) return bold ? "NotoHebrewBold" : "NotoHebrew";
+    if (scripts.devanagari) return bold ? "NotoDevanagariBold" : "NotoDevanagari";
+    if (scripts.japanese) return "NotoCJKJP";
+    if (scripts.korean) return "NotoCJKKR";
+    if (scripts.han) return "NotoCJKSC";
+    if (bold && italic) return "NotoSansBoldItalic";
+    if (bold) return "NotoSansBold";
+    if (italic) return "NotoSansItalic";
+    return "NotoSans";
+  }
+
+  function visualRtl(text) {
+    if (!RTL_RE.test(text) || typeof bidi_js !== "function") return text;
+    const bidi = bidi_js();
+    const levels = bidi.getEmbeddingLevels(text);
+    const chars = Array.from(text);
+    for (const [start, end] of bidi.getReorderSegments(text, levels)) {
+      const reversed = chars.slice(start, end + 1).reverse();
+      chars.splice(start, reversed.length, ...reversed);
+    }
+    const mirrored = bidi.getMirroredCharactersMap(text, levels);
+    mirrored.forEach((replacement, index) => {
+      if (index < chars.length) chars[index] = replacement;
+    });
+    return chars.join("");
+  }
+
+  function addLogicalText(doc, logical, options) {
+    const rtl = RTL_RE.test(logical);
+    if (rtl) doc.markContent("Span", { actual: logical });
+    doc.text(rtl ? visualRtl(logical) : logical, { ...(options || {}), align: rtl ? "right" : options?.align });
+    if (rtl) doc.endMarkedContent();
+  }
+
+  function fileToDataUrl(file) {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(reader.result);
+      reader.onerror = () => reject(new Error(`${file.name} could not be read.`));
+      reader.readAsDataURL(file);
+    });
+  }
+
+  function canvasDataUrl(bitmap, orientation) {
+    const turn = orientation >= 5 && orientation <= 8;
+    const canvas = document.createElement("canvas");
+    canvas.width = turn ? bitmap.height : bitmap.width;
+    canvas.height = turn ? bitmap.width : bitmap.height;
+    const context = canvas.getContext("2d");
+    context.fillStyle = "#ffffff";
+    context.fillRect(0, 0, canvas.width, canvas.height);
+    const transforms = {
+      2: [-1, 0, 0, 1, bitmap.width, 0],
+      3: [-1, 0, 0, -1, bitmap.width, bitmap.height],
+      4: [1, 0, 0, -1, 0, bitmap.height],
+      5: [0, 1, 1, 0, 0, 0],
+      6: [0, 1, -1, 0, bitmap.height, 0],
+      7: [0, -1, -1, 0, bitmap.height, bitmap.width],
+      8: [0, -1, 1, 0, 0, bitmap.width],
+    };
+    if (transforms[orientation]) context.setTransform(...transforms[orientation]);
+    context.drawImage(bitmap, 0, 0);
+    return canvas.toDataURL("image/png");
+  }
+
+  async function decodeTiff(buffer, name) {
+    try {
+      const ifds = UTIF.decode(buffer);
+      if (!ifds.length) throw new Error("no pages");
+      UTIF.decodeImage(buffer, ifds[0], ifds);
+      const rgba = UTIF.toRGBA8(ifds[0]);
+      const canvas = document.createElement("canvas");
+      canvas.width = ifds[0].width;
+      canvas.height = ifds[0].height;
+      canvas.getContext("2d").putImageData(
+        new ImageData(new Uint8ClampedArray(rgba), ifds[0].width, ifds[0].height),
+        0,
+        0
+      );
+      return createImageBitmap(canvas);
+    } catch {
+      throw new Error(`${name} is a TIFF this browser couldn't read.`);
+    }
+  }
+
+  async function prepareImage(file) {
+    const buffer = await file.arrayBuffer();
+    const bytes = new Uint8Array(buffer);
+    const metadata = Pages.parseRasterMetadata(bytes);
+    const tiff = Pages.isTiff(bytes);
+    let bitmap;
+    try {
+      bitmap = tiff
+        ? await decodeTiff(buffer, file.name)
+        : await createImageBitmap(file, { imageOrientation: "from-image" });
+    } catch {
+      throw new Error(`${file.name} isn't an image this browser can read.`);
+    }
+
+    const page = Pages.imagePage(
+      metadata.width || bitmap.width,
+      metadata.height || bitmap.height,
+      metadata.dpiX,
+      metadata.dpiY,
+      metadata.orientation || 1
+    );
+    const simpleJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && (metadata.orientation || 1) === 1;
+    const simplePng = bytes.length > 8 && String.fromCharCode(...bytes.slice(1, 4)) === "PNG";
+    const src = simpleJpeg || simplePng
+      ? await fileToDataUrl(file)
+      : canvasDataUrl(bitmap, tiff ? metadata.orientation || 1 : 1);
+    bitmap.close();
+    return { src, page };
+  }
+
+  function addImagePage(doc, image) {
+    doc.addPage({ size: [image.page.width, image.page.height], margin: 0 });
+    doc.image(image.src, 0, 0, {
+      width: image.page.width,
+      height: image.page.height,
+      ignoreOrientation: true,
+    });
+  }
+
+  function addTextPage(doc) {
+    doc.addPage({ size: "A4", layout: "portrait", margin: 54 });
+    doc.font("NotoSans").fontSize(11).fillColor(INK);
+  }
+
+  function renderSpans(doc, spans, options) {
+    const logical = spans.map((span) => span.text).join("");
+    if (RTL_RE.test(logical)) {
+      doc.font(fontFor(logical, spans.some((span) => span.bold), false));
+      addLogicalText(doc, logical, options);
+      return;
+    }
+    const usable = spans.filter((span) => span.text);
+    usable.forEach((span, index) => {
+      doc.font(span.code ? "NotoSans" : fontFor(span.text, span.bold, span.italic));
+      doc.fillColor(span.link ? ACCENT : options?.color || INK);
+      doc.text(span.text, {
+        continued: index !== usable.length - 1,
+        link: span.link || undefined,
+        underline: Boolean(span.link),
+        strike: Boolean(span.strike),
+        lineGap: options?.lineGap,
+      });
+    });
+    doc.fillColor(INK);
+  }
+
+  function renderTable(doc, rows, widths) {
+    if (!rows.length) return;
+    const data = rows.map((row, rowIndex) =>
+      row.map((raw) => {
+        const cell = typeof raw === "object" && raw !== null ? raw : { text: String(raw ?? "") };
+        const header = rowIndex === 0 || cell.header;
+        return {
+          text: cell.text || "",
+          colSpan: cell.colSpan || 1,
+          rowSpan: cell.rowSpan || 1,
+          type: header ? "TH" : "TD",
+          scope: header ? "Column" : undefined,
+          backgroundColor: header ? "#eceef1" : undefined,
+          textColor: INK,
+          borderColor: LINE,
+          padding: 4,
+          font: { src: fontFor(cell.text || "", header, false), size: 8 },
+          textOptions: { lineGap: 1 },
+        };
+      })
+    );
+    doc.table({
+      maxWidth: doc.page.contentWidth,
+      columnStyles: widths || Array.from({ length: Math.max(...rows.map((row) => row.length)) }, () => "*"),
+      defaultStyle: { border: 0.5, borderColor: LINE, padding: 4 },
+      data,
+    });
+    doc.moveDown(0.75);
+  }
+
+  function renderBlocks(doc, blocks) {
+    for (const block of blocks) {
+      if (block.type === "heading") {
+        const sizes = [0, 25, 20, 16, 14, 12, 11];
+        const text = block.spans.map((span) => span.text).join("");
+        doc.moveDown(block.level === 1 ? 0.3 : 0.55);
+        doc.font(fontFor(text, true, false)).fontSize(sizes[block.level] || 12).fillColor(INK);
+        addLogicalText(doc, text, { lineGap: 2 });
+        doc.moveDown(0.25);
+      } else if (block.type === "paragraph") {
+        doc.fontSize(11).fillColor(INK);
+        renderSpans(doc, block.spans, { lineGap: 3 });
+        doc.moveDown(0.55);
+      } else if (block.type === "list") {
+        const joined = block.items.join(" ");
+        doc.font(fontFor(joined, false, false)).fontSize(11).fillColor(INK);
+        doc.list(block.items, {
+          listType: block.ordered ? "numbered" : "bullet",
+          indent: 18,
+          textIndent: 10,
+          bulletIndent: 2,
+          lineGap: 3,
+        });
+        doc.moveDown(0.55);
+      } else if (block.type === "quote") {
+        const x = doc.x;
+        doc.save().strokeColor(ACCENT).lineWidth(2).moveTo(x, doc.y).lineTo(x, doc.y + 34).stroke().restore();
+        doc.x += 14;
+        doc.font(fontFor(block.text, false, true)).fontSize(10.5).fillColor(MUTED);
+        addLogicalText(doc, block.text, { lineGap: 3 });
+        doc.x = x;
+        doc.fillColor(INK).moveDown(0.65);
+      } else if (block.type === "code") {
+        const available = doc.page.contentWidth;
+        doc.font(/[^-\u007f]/.test(block.text) ? "NotoSans" : "Courier").fontSize(9);
+        const height = Math.min(doc.heightOfString(block.text, { width: available - 20 }) + 16, 500);
+        if (doc.y + height > doc.page.height - doc.page.margins.bottom) doc.addPage();
+        const top = doc.y;
+        doc.save().fillColor(SURFACE).roundedRect(doc.x, top, available, height, 5).fill().restore();
+        doc.fillColor(INK).text(block.text, doc.x + 10, top + 8, { width: available - 20, lineGap: 2 });
+        doc.y = top + height + 8;
+      } else if (block.type === "rule") {
+        doc.moveDown(0.4);
+        doc.save().strokeColor(LINE).lineWidth(1).moveTo(doc.x, doc.y).lineTo(doc.x + doc.page.contentWidth, doc.y).stroke().restore();
+        doc.moveDown(0.7);
+      } else if (block.type === "table") {
+        renderTable(doc, block.rows);
+      } else if (block.type === "image") {
+        try {
+          doc.image(block.src, { fit: [doc.page.contentWidth, 430], align: "center" });
+          doc.moveDown(0.65);
+        } catch {
+          doc.font("NotoSansItalic").fontSize(10).fillColor(MUTED).text(block.alt || "Image");
+          doc.moveDown(0.5);
+        }
+      }
+    }
+  }
+
+  function columnGroups(sheet, availableWidth) {
+    const columns = sheet.rows.reduce((max, row) => Math.max(max, row.length), 0);
+    const widths = Array.from({ length: columns }, (_, column) => {
+      if (sheet.widths?.[column]) return Math.max(42, Math.min(160, sheet.widths[column]));
+      const longest = sheet.rows.slice(0, 250).reduce(
+        (max, row) => Math.max(max, String(row[column] || "").length),
+        0
+      );
+      return Math.max(42, Math.min(160, longest * 4.5 + 12));
+    });
+    const groups = [];
+    let group = [];
+    let used = 0;
+    widths.forEach((width, index) => {
+      if (group.length && used + width > availableWidth) {
+        groups.push(group);
+        group = [];
+        used = 0;
+      }
+      group.push({ index, width: Math.min(width, availableWidth) });
+      used += width;
+    });
+    if (group.length) groups.push(group);
+    return groups;
+  }
+
+  function columnName(index) {
+    let value = index + 1;
+    let result = "";
+    while (value) {
+      value -= 1;
+      result = String.fromCharCode(65 + (value % 26)) + result;
+      value = Math.floor(value / 26);
+    }
+    return result;
+  }
+
+  function sheetRows(sheet, group) {
+    const mergeAt = new Map();
+    const covered = new Set();
+    for (const merge of sheet.merges || []) {
+      const groupStart = group[0].index;
+      const groupEnd = group[group.length - 1].index;
+      const mergeEnd = merge.startColumn + merge.colSpan - 1;
+      if (merge.startColumn < groupStart || mergeEnd > groupEnd) continue;
+      mergeAt.set(`${merge.startRow},${merge.startColumn}`, merge);
+      for (let row = merge.startRow; row < merge.startRow + merge.rowSpan; row += 1) {
+        for (let column = merge.startColumn; column <= mergeEnd; column += 1) {
+          if (row !== merge.startRow || column !== merge.startColumn) covered.add(`${row},${column}`);
+        }
+      }
+    }
+    return sheet.rows.map((row, rowIndex) => {
+      const output = [];
+      for (const { index } of group) {
+        if (covered.has(`${rowIndex},${index}`)) continue;
+        const merge = mergeAt.get(`${rowIndex},${index}`);
+        output.push({
+          text: String(row[index] || ""),
+          header: rowIndex === 0,
+          colSpan: merge?.colSpan || 1,
+          rowSpan: merge?.rowSpan || 1,
+        });
+      }
+      return output;
+    });
+  }
+
+  function sheetTitle(doc, title, continued) {
+    doc.font(fontFor(title, true, false)).fontSize(16).fillColor(INK)
+      .text(title + (continued ? " · continued" : ""));
+    doc.moveDown(0.55).fontSize(8).font("NotoSans");
+  }
+
+  function estimatedRowHeight(doc, row, widths) {
+    let height = 0;
+    row.forEach((cell, index) => {
+      const text = typeof cell === "object" ? cell.text : String(cell || "");
+      doc.font(fontFor(text, Boolean(cell?.header), false)).fontSize(8);
+      height = Math.max(height, doc.heightOfString(text || " ", {
+        width: Math.max(8, (widths[index] || 42) - 8),
+        lineGap: 1,
+      }) + 8);
+    });
+    return Math.min(500, Math.max(17, height));
+  }
+
+  function renderPaginatedSheet(doc, title, rows, widths) {
+    const header = rows[0] || [];
+    let pageRows = [header];
+    let continued = false;
+
+    function newPage() {
+      doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
+      sheetTitle(doc, title, continued);
+      continued = true;
+      pageRows = [header];
+    }
+
+    newPage();
+    let used = estimatedRowHeight(doc, header, widths);
+    for (const row of rows.slice(1)) {
+      const height = estimatedRowHeight(doc, row, widths);
+      const available = doc.page.height - doc.page.margins.bottom - doc.y;
+      if (pageRows.length > 1 && used + height > available) {
+        renderTable(doc, pageRows, widths);
+        newPage();
+        used = estimatedRowHeight(doc, header, widths);
+      }
+      pageRows.push(row);
+      used += height;
+    }
+    renderTable(doc, pageRows, widths);
+  }
+
+  function renderSheets(doc, sheets) {
+    for (const sheet of sheets) {
+      const available = Pages.A4_PORTRAIT[1] - 60;
+      const groups = columnGroups(sheet, available);
+      if (!groups.length) {
+        doc.addPage({ size: "A4", layout: "landscape", margin: 30 });
+        doc.font("NotoSansBold").fontSize(17).fillColor(INK).text(sheet.name);
+        doc.moveDown(0.6).font("NotoSans").fontSize(10).fillColor(MUTED).text("This sheet is empty.");
+        continue;
+      }
+      groups.forEach((group, groupIndex) => {
+        const suffix = groups.length > 1
+          ? ` · columns ${columnName(group[0].index)}–${columnName(group[group.length - 1].index)}`
+          : "";
+        renderPaginatedSheet(
+          doc,
+          sheet.name + suffix,
+          sheetRows(sheet, group),
+          group.map((column) => column.width)
+        );
+        if (groupIndex < groups.length - 1) doc.moveDown(0.1);
+      });
+    }
+  }
+
+  async function build(items, onProgress) {
+    if (typeof PDFDocument !== "function" || typeof blobStream !== "function") {
+      throw new Error("The offline PDF engine did not load. Refresh and try again.");
+    }
+    const doc = new PDFDocument({
+      autoFirstPage: false,
+      bufferPages: true,
+      compress: true,
+      tagged: true,
+      pdfVersion: "1.5",
+      info: { Title: "MakeItPDF conversion", Creator: "MakeItPDF" },
+    });
+    const stream = doc.pipe(blobStream());
+    await registerFonts(doc, items);
+    const warnings = [];
+
+    for (let index = 0; index < items.length; index += 1) {
+      const item = items[index];
+      onProgress?.(index, items.length, `Converting ${item.file.name}`);
+      if (item.kind === "image") addImagePage(doc, await prepareImage(item.file));
+      else if (item.kind === "xlsx" || item.kind === "csv") renderSheets(doc, item.sheets);
+      else {
+        addTextPage(doc);
+        renderBlocks(doc, item.blocks);
+      }
+      warnings.push(...(item.warnings || []).map((warning) => `${item.file.name}: ${warning}`));
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    }
+    const pageCount = doc.bufferedPageRange().count;
+    onProgress?.(items.length, items.length, "Finishing PDF");
+    doc.end();
+    const blob = await new Promise((resolve, reject) => {
+      stream.on("finish", () => resolve(stream.toBlob("application/pdf")));
+      stream.on("error", reject);
+    });
+    return { blob, pageCount, warnings };
+  }
+
+  return { build, scriptsIn, visualRtl, columnGroups };
+});
diff --git a/docs/style.css b/docs/style.css
index 5811adf..0671281 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -1,5 +1,33 @@
-/* MakeItPDF -- white ground, one typeface, one accent.
-   The red appears in exactly two places: the logo and the button you press. */
+/* MakeItPDF -- white paper, black ink, one action red. */
+
+@font-face {
+  font-family: "Figtree";
+  src: url("vendor/fonts/figtree-400.woff2") format("woff2");
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Figtree";
+  src: url("vendor/fonts/figtree-500.woff2") format("woff2");
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Figtree";
+  src: url("vendor/fonts/figtree-600.woff2") format("woff2");
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+}
+@font-face {
+  font-family: "Figtree";
+  src: url("vendor/fonts/figtree-700.woff2") format("woff2");
+  font-style: normal;
+  font-weight: 700;
+  font-display: swap;
+}
 
 :root {
   --bg: #ffffff;
@@ -162,6 +190,7 @@ ol { padding: 0; list-style: none; }
 }
 
 .link:hover { color: var(--ink); }
+.link:disabled { opacity: 0.45; cursor: default; }
 
 /* ---- Dropzone ----------------------------------------------------------- */
 
@@ -185,6 +214,38 @@ ol { padding: 0; list-style: none; }
   transition: border-color 130ms ease, background 130ms ease;
 }
 
+.format-spine {
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  min-height: 2.6rem;
+  margin-bottom: 1.05rem;
+}
+
+.format-spine span {
+  display: grid;
+  place-items: center;
+  width: 3.15rem;
+  height: 2.2rem;
+  margin-left: -0.35rem;
+  border: 1px solid var(--line);
+  border-radius: 5px 5px 2px 2px;
+  background: #fff;
+  color: var(--muted);
+  font-size: 0.62rem;
+  font-weight: 700;
+  letter-spacing: 0.035em;
+  transform: rotate(calc((var(--i, 0) - 2) * 2deg));
+  transform-origin: 50% 110%;
+  box-shadow: 0 3px 10px rgb(16 17 19 / 4%);
+}
+
+.format-spine span:nth-child(1) { --i: 0; }
+.format-spine span:nth-child(2) { --i: 1; }
+.format-spine span:nth-child(3) { --i: 2; color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
+.format-spine span:nth-child(4) { --i: 3; }
+.format-spine span:nth-child(5) { --i: 4; }
+
 .dropzone.is-target {
   border-color: var(--accent);
   background: color-mix(in srgb, var(--accent) 5%, var(--bg));
@@ -207,7 +268,8 @@ ol { padding: 0; list-style: none; }
 }
 
 .composer.has-pages .dropzone__hint,
-.composer.has-pages .dropzone__formats { display: none; }
+.composer.has-pages .dropzone__formats,
+.composer.has-pages .format-spine { display: none; }
 
 .composer.has-pages .btn--lg { padding: 0.6rem 1.3rem; font-size: 0.95rem; }
 
@@ -227,7 +289,7 @@ ol { padding: 0; list-style: none; }
 
 .grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
+  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
   gap: 0.9rem;
 }
 
@@ -255,14 +317,39 @@ ol { padding: 0; list-style: none; }
   border-radius: 2px;
 }
 
-/* TIFF has no <img> decoder either, so its card shows a blank sheet. */
-.card__frame--blank::after {
-  content: "";
-  width: 62%;
-  height: 76%;
-  border: 1px solid var(--line);
-  border-radius: 2px;
+.card__paper { display: none; }
+
+.card__frame--document .card__paper {
+  position: relative;
+  display: grid;
+  place-items: end center;
+  width: 60%;
+  height: 73%;
+  padding-bottom: 0.9rem;
+  border: 1px solid #d9dde1;
+  border-radius: 3px;
   background: #fff;
+  box-shadow: 0 5px 14px rgb(16 17 19 / 7%);
+}
+
+.card__frame--document .card__paper::before {
+  content: "";
+  position: absolute;
+  top: -1px;
+  right: -1px;
+  width: 1.25rem;
+  height: 1.25rem;
+  background: linear-gradient(225deg, var(--surface) 49%, #d9dde1 50%, #d9dde1 53%, #fff 54%);
+}
+
+.card__type {
+  padding: 0.14rem 0.42rem;
+  border-radius: 4px;
+  background: color-mix(in srgb, var(--accent) 9%, #fff);
+  color: var(--accent);
+  font-size: 0.64rem;
+  font-weight: 700;
+  letter-spacing: 0.035em;
 }
 
 .card__num {
@@ -336,6 +423,17 @@ ol { padding: 0; list-style: none; }
   white-space: nowrap;
 }
 
+.card__meta {
+  display: block;
+  margin-top: 0.08rem;
+  color: var(--muted);
+  font-size: 0.7rem;
+  opacity: 0.8;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 /* ---- Progress ----------------------------------------------------------- */
 
 .progress {
@@ -376,6 +474,13 @@ ol { padding: 0; list-style: none; }
 .done__tick { width: 2.6rem; height: 2.6rem; color: var(--accent); }
 .done__title { margin-top: 0.7rem; font-size: 1.4rem; font-weight: 700; letter-spacing: -0.02em; }
 .done__meta { color: var(--muted); font-size: 0.92rem; }
+.done__notes {
+  max-width: 29rem;
+  margin-top: 0.65rem;
+  color: var(--muted);
+  font-size: 0.82rem;
+  line-height: 1.45;
+}
 .done #download, .done .btn { margin-top: 1.1rem; }
 .done .link { margin-top: 0.9rem; }
 
diff --git a/docs/tests/browser.test.js b/docs/tests/browser.test.js
new file mode 100644
index 0000000..51b4f85
--- /dev/null
+++ b/docs/tests/browser.test.js
@@ -0,0 +1,182 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const http = require("node:http");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const os = require("node:os");
+const zlib = require("node:zlib");
+const { chromium } = require("playwright-core");
+const XLSX = require("xlsx");
+const JSZip = require("jszip");
+
+const ROOT = path.resolve(__dirname, "..");
+
+function crc32(buffer) {
+  let crc = 0xffffffff;
+  for (const byte of buffer) {
+    crc ^= byte;
+    for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
+  }
+  const output = Buffer.alloc(4);
+  output.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
+  return output;
+}
+
+function pngChunk(type, data) {
+  const name = Buffer.from(type);
+  const length = Buffer.alloc(4);
+  length.writeUInt32BE(data.length);
+  return Buffer.concat([length, name, data, crc32(Buffer.concat([name, data]))]);
+}
+
+function makePng(width, height) {
+  const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+  const header = Buffer.alloc(13);
+  header.writeUInt32BE(width, 0);
+  header.writeUInt32BE(height, 4);
+  header.set([8, 6, 0, 0, 0], 8);
+  const row = Buffer.alloc(width * 4 + 1);
+  for (let x = 0; x < width; x += 1) row.set([244, 64, 45, 255], 1 + x * 4);
+  const pixels = Buffer.concat(Array.from({ length: height }, () => row));
+  return Buffer.concat([
+    signature,
+    pngChunk("IHDR", header),
+    pngChunk("IDAT", zlib.deflateSync(pixels)),
+    pngChunk("IEND", Buffer.alloc(0)),
+  ]);
+}
+
+async function makeDocx() {
+  const zip = new JSZip();
+  zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+    <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+      <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+      <Default Extension="xml" ContentType="application/xml"/>
+      <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+    </Types>`);
+  zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+      <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
+    </Relationships>`);
+  zip.folder("word").file("document.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+    <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+      <w:body><w:p><w:r><w:t>Searchable Word phrase</w:t></w:r></w:p><w:sectPr/></w:body>
+    </w:document>`);
+  zip.folder("word").folder("_rels").file("document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`);
+  return zip.generateAsync({ type: "nodebuffer" });
+}
+
+function makeXlsx() {
+  const workbook = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([
+    ["Product", "Amount"],
+    ["Searchable workbook phrase", 42],
+  ]), "Visible sheet");
+  XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([["Must stay hidden"]]), "Hidden sheet");
+  workbook.Workbook = { Sheets: [
+    { name: "Visible sheet", Hidden: 0 },
+    { name: "Hidden sheet", Hidden: 1 },
+  ] };
+  return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
+}
+
+function mimeFor(filename) {
+  const extension = path.extname(filename);
+  return {
+    ".html": "text/html; charset=utf-8",
+    ".js": "text/javascript; charset=utf-8",
+    ".css": "text/css; charset=utf-8",
+    ".woff2": "font/woff2",
+    ".ttf": "font/ttf",
+    ".ttc": "font/collection",
+  }[extension] || "application/octet-stream";
+}
+
+async function startServer() {
+  const server = http.createServer(async (request, response) => {
+    try {
+      const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
+      const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
+      const filename = path.resolve(ROOT, relative);
+      if (!filename.startsWith(ROOT + path.sep)) throw new Error("outside root");
+      const body = await fs.readFile(filename);
+      response.writeHead(200, { "content-type": mimeFor(filename), "cache-control": "no-store" });
+      response.end(body);
+    } catch {
+      response.writeHead(404).end("Not found");
+    }
+  });
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  return { server, origin: `http://127.0.0.1:${server.address().port}` };
+}
+
+test("the browser converts a mixed local queue into a searchable PDF", { timeout: 120000 }, async () => {
+  const { server, origin } = await startServer();
+  const browser = await chromium.launch({
+    executablePath: "/usr/bin/google-chrome",
+    headless: true,
+    args: ["--no-sandbox", "--disable-dev-shm-usage"],
+  });
+  const temp = await fs.mkdtemp(path.join(os.tmpdir(), "makeitpdf-browser-"));
+  const page = await browser.newPage({ acceptDownloads: true });
+  const externalRequests = [];
+  const pageErrors = [];
+  page.on("request", (request) => {
+    if (!request.url().startsWith(origin) && !request.url().startsWith("blob:")) {
+      externalRequests.push(request.url());
+    }
+  });
+  page.on("pageerror", (error) => pageErrors.push(error.message));
+
+  try {
+    await page.goto(origin, { waitUntil: "networkidle" });
+    await page.setInputFiles("#picker", [
+      { name: "original-size.png", mimeType: "image/png", buffer: makePng(100, 50) },
+      { name: "sample.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", buffer: await makeDocx() },
+      { name: "sample.xlsx", mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buffer: makeXlsx() },
+      { name: "notes.md", mimeType: "text/markdown", buffer: Buffer.from(
+        "# Notes\n\nSearchable Markdown phrase.\n\nمرحبا بالعالم\n\nשלום עולם\n\nनमस्ते दुनिया\n\n中文\n\n日本語\n\n한국어"
+      ) },
+      { name: "records.csv", mimeType: "text/csv", buffer: Buffer.from("Name,Value\nSearchable CSV phrase,7") },
+    ]);
+    await page.getByText("5 files", { exact: false }).waitFor();
+    await page.click("#build");
+    await page.locator("#done:not([hidden])").waitFor({ timeout: 90000 });
+    const [download] = await Promise.all([page.waitForEvent("download"), page.click("#download")]);
+    const pdfPath = path.join(temp, "mixed.pdf");
+    await download.saveAs(pdfPath);
+
+    assert.deepEqual(pageErrors, []);
+    assert.deepEqual(externalRequests, []);
+    const pdfData = new Uint8Array(await fs.readFile(pdfPath));
+    const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
+    const pdf = await pdfjs.getDocument({ data: pdfData, disableWorker: true }).promise;
+    assert.ok(pdf.numPages >= 5);
+    const firstPage = await pdf.getPage(1);
+    const viewport = firstPage.getViewport({ scale: 1 });
+    assert.ok(Math.abs(viewport.width - 75) < 0.1, `expected 75pt, got ${viewport.width}`);
+    assert.ok(Math.abs(viewport.height - 37.5) < 0.1, `expected 37.5pt, got ${viewport.height}`);
+
+    let extracted = "";
+    for (let index = 1; index <= pdf.numPages; index += 1) {
+      const content = await (await pdf.getPage(index)).getTextContent();
+      extracted += content.items.map((item) => item.str).join(" ") + "\n";
+    }
+    assert.match(extracted, /Searchable Word phrase/);
+    assert.match(extracted, /Searchable workbook phrase/);
+    assert.match(extracted, /Searchable Markdown phrase/);
+    assert.match(extracted, /Searchable CSV phrase/);
+    assert.match(extracted, /[\u0600-\u06ff]{5}/);
+    assert.match(extracted, /[\u0590-\u05ff]{4}/);
+    assert.match(extracted, /नमस्ते/);
+    assert.match(extracted, /中文/);
+    assert.match(extracted, /日本語/);
+    assert.match(extracted, /한국어/);
+    assert.doesNotMatch(extracted, /Must stay hidden/);
+  } finally {
+    await browser.close();
+    await new Promise((resolve) => server.close(resolve));
+    await fs.rm(temp, { recursive: true, force: true });
+  }
+});
diff --git a/docs/tests/documents.test.js b/docs/tests/documents.test.js
new file mode 100644
index 0000000..4f4b327
--- /dev/null
+++ b/docs/tests/documents.test.js
@@ -0,0 +1,42 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const Documents = require("../documents.js");
+
+test("safe links only allow web and mail targets", () => {
+  assert.equal(Documents.safeUrl("https://example.com/a"), "https://example.com/a");
+  assert.equal(Documents.safeUrl("mailto:[email protected]"), "mailto:[email protected]");
+  assert.equal(Documents.safeUrl("javascript:alert(1)"), null);
+  assert.equal(Documents.safeUrl("file:///etc/passwd"), null);
+});
+
+test("CSV delimiter detection handles comma, tab, semicolon, and pipe", () => {
+  assert.equal(Documents.parseCsv("a,b\n1,2").delimiter, ",");
+  assert.equal(Documents.parseCsv("a\tb\n1\t2").delimiter, "\t");
+  assert.equal(Documents.parseCsv("a;b\n1;2").delimiter, ";");
+  assert.equal(Documents.parseCsv("a|b\n1|2").delimiter, "|");
+});
+
+test("CSV parsing preserves quoted commas, escaped quotes, and newlines", () => {
+  const parsed = Documents.parseCsv('name,note\n"Ada, A.","line 1\nline 2"\nBob,"said ""hi"""');
+  assert.deepEqual(parsed.rows, [
+    ["name", "note"],
+    ["Ada, A.", "line 1\nline 2"],
+    ["Bob", 'said "hi"'],
+  ]);
+  assert.equal(parsed.cells, 6);
+});
+
+test("CSV rows are padded to a stable table width", () => {
+  const parsed = Documents.parseCsv("a,b,c\n1,2");
+  assert.deepEqual(parsed.rows[1], ["1", "2", ""]);
+});
+
+test("unclosed CSV quotes produce a useful error", () => {
+  assert.throws(() => Documents.parseCsv('a,"broken'), /unclosed quoted field/);
+});
+
+test("UTF-8 decoding strips a BOM and rejects invalid input", () => {
+  const valid = Uint8Array.from([0xef, 0xbb, 0xbf, 0x68, 0x69]);
+  assert.equal(Documents.decodeUtf8(valid), "hi");
+  assert.throws(() => Documents.decodeUtf8(Uint8Array.from([0xc3, 0x28])), /valid UTF-8/);
+});
diff --git a/docs/tests/pages.test.js b/docs/tests/pages.test.js
index f542b32..1cfc52d 100644
--- a/docs/tests/pages.test.js
+++ b/docs/tests/pages.test.js
@@ -111,3 +111,84 @@ test("other formats are not mistaken for TIFF", () => {
   assert.equal(Pages.isTiff(new Uint8Array([0x49, 0x49])), false, "truncated");
   assert.equal(Pages.isTiff(null), false, "nothing");
 });
+
+test("image pages use embedded physical resolution instead of A4", () => {
+  const page = Pages.imagePage(1200, 600, 300, 300, 1);
+  assert.equal(page.width, 288);
+  assert.equal(page.height, 144);
+  assert.equal(page.capped, false);
+});
+
+test("images without usable DPI metadata fall back to 96 DPI", () => {
+  const page = Pages.imagePage(960, 480, undefined, 0, 1);
+  assert.equal(page.width, 720);
+  assert.equal(page.height, 360);
+  assert.equal(page.dpiX, 96);
+  assert.equal(page.dpiY, 96);
+});
+
+test("EXIF rotations swap pixel dimensions and resolution axes", () => {
+  const page = Pages.imagePage(1200, 600, 300, 150, 6);
+  assert.equal(page.pixelWidth, 600);
+  assert.equal(page.pixelHeight, 1200);
+  assert.equal(page.width, 288);
+  assert.equal(page.height, 288);
+});
+
+test("oversized physical pages are capped proportionally", () => {
+  const page = Pages.imagePage(40000, 20000, 1, 1, 1);
+  assert.equal(page.width, Pages.MAX_PAGE_PT);
+  assert.equal(page.height, Pages.MAX_PAGE_PT / 2);
+  assert.equal(page.capped, true);
+  assert.ok(page.scale < 1);
+});
+
+test("PNG pHYs metadata is converted from pixels per metre to DPI", () => {
+  const bytes = new Uint8Array(54);
+  bytes.set([137, 80, 78, 71, 13, 10, 26, 10]);
+  const view = new DataView(bytes.buffer);
+  view.setUint32(8, 13, false);
+  bytes.set([73, 72, 68, 82], 12);
+  view.setUint32(16, 1200, false);
+  view.setUint32(20, 600, false);
+  view.setUint32(33, 9, false);
+  bytes.set([112, 72, 89, 115], 37);
+  view.setUint32(41, 11811, false); // approximately 300 DPI
+  view.setUint32(45, 5906, false); // approximately 150 DPI
+  bytes[49] = 1;
+  const metadata = Pages.parseRasterMetadata(bytes);
+  assert.equal(metadata.width, 1200);
+  assert.equal(metadata.height, 600);
+  assert.ok(Math.abs(metadata.dpiX - 300) < 0.02);
+  assert.ok(Math.abs(metadata.dpiY - 150) < 0.02);
+});
+
+test("BMP pixels-per-metre metadata is read", () => {
+  const bytes = new Uint8Array(46);
+  bytes.set([66, 77]);
+  const view = new DataView(bytes.buffer);
+  view.setInt32(18, 800, true);
+  view.setInt32(22, 400, true);
+  view.setInt32(38, 3780, true);
+  view.setInt32(42, 3780, true);
+  const metadata = Pages.parseRasterMetadata(bytes);
+  assert.equal(metadata.width, 800);
+  assert.equal(metadata.height, 400);
+  assert.ok(Math.abs(metadata.dpiX - 96) < 0.02);
+});
+
+test("JPEG JFIF density and dimensions are read", () => {
+  const bytes = new Uint8Array([
+    0xff, 0xd8,
+    0xff, 0xe0, 0x00, 0x10,
+    0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x01, 0x2c, 0x00, 0x96, 0x00, 0x00,
+    0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x04, 0xb0,
+    0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00,
+    0xff, 0xd9,
+  ]);
+  const metadata = Pages.parseRasterMetadata(bytes);
+  assert.equal(metadata.width, 1200);
+  assert.equal(metadata.height, 600);
+  assert.equal(metadata.dpiX, 300);
+  assert.equal(metadata.dpiY, 150);
+});
diff --git a/docs/vendor/bidi.min.js b/docs/vendor/bidi.min.js
new file mode 100644
index 0000000..bd0ca4d
--- /dev/null
+++ b/docs/vendor/bidi.min.js
@@ -0,0 +1 @@
+!function(r,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(r="undefined"!=typeof globalThis?globalThis:r||self).bidi_js=e()}(this,(function(){"use strict";return function(){return function(r){var e={R:"13k,1a,2,3,3,2+1j,ch+16,a+1,5+2,2+n,5,a,4,6+16,4+3,h+1b,4mo,179q,2+9,2+11,2i9+7y,2+68,4,3+4,5+13,4+3,2+4k,3+29,8+cf,1t+7z,w+17,3+3m,1t+3z,16o1+5r,8+30,8+mc,29+1r,29+4v,75+73",EN:"1c+9,3d+1,6,187+9,513,4+5,7+9,sf+j,175h+9,qw+q,161f+1d,4xt+a,25i+9",ES:"17,2,6dp+1,f+1,av,16vr,mx+1,4o,2",ET:"z+2,3h+3,b+1,ym,3e+1,2o,p4+1,8,6u,7c,g6,1wc,1n9+4,30+1b,2n,6d,qhx+1,h0m,a+1,49+2,63+1,4+1,6bb+3,12jj",AN:"16o+5,2j+9,2+1,35,ed,1ff2+9,87+u",CS:"18,2+1,b,2u,12k,55v,l,17v0,2,3,53,2+1,b",B:"a,3,f+2,2v,690",S:"9,2,k",WS:"c,k,4f4,1vk+a,u,1j,335",ON:"x+1,4+4,h+5,r+5,r+3,z,5+3,2+1,2+1,5,2+2,3+4,o,w,ci+1,8+d,3+d,6+8,2+g,39+1,9,6+1,2,33,b8,3+1,3c+1,7+1,5r,b,7h+3,sa+5,2,3i+6,jg+3,ur+9,2v,ij+1,9g+9,7+a,8m,4+1,49+x,14u,2+2,c+2,e+2,e+2,e+1,i+n,e+e,2+p,u+2,e+2,36+1,2+3,2+1,b,2+2,6+5,2,2,2,h+1,5+4,6+3,3+f,16+2,5+3l,3+81,1y+p,2+40,q+a,m+13,2r+ch,2+9e,75+hf,3+v,2+2w,6e+5,f+6,75+2a,1a+p,2+2g,d+5x,r+b,6+3,4+o,g,6+1,6+2,2k+1,4,2j,5h+z,1m+1,1e+f,t+2,1f+e,d+3,4o+3,2s+1,w,535+1r,h3l+1i,93+2,2s,b+1,3l+x,2v,4g+3,21+3,kz+1,g5v+1,5a,j+9,n+v,2,3,2+8,2+1,3+2,2,3,46+1,4+4,h+5,r+5,r+a,3h+2,4+6,b+4,78,1r+24,4+c,4,1hb,ey+6,103+j,16j+c,1ux+7,5+g,fsh,jdq+1t,4,57+2e,p1,1m,1m,1m,1m,4kt+1,7j+17,5+2r,d+e,3+e,2+e,2+10,m+4,w,1n+5,1q,4z+5,4b+rb,9+c,4+c,4+37,d+2g,8+b,l+b,5+1j,9+9,7+13,9+t,3+1,27+3c,2+29,2+3q,d+d,3+4,4+2,6+6,a+o,8+6,a+2,e+6,16+42,2+1i",BN:"0+8,6+d,2s+5,2+p,e,4m9,1kt+2,2b+5,5+5,17q9+v,7k,6p+8,6+1,119d+3,440+7,96s+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+75,6p+2rz,1ben+1,1ekf+1,1ekf+1",NSM:"lc+33,7o+6,7c+18,2,2+1,2+1,2,21+a,1d+k,h,2u+6,3+5,3+1,2+3,10,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,g+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+g,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,k1+w,2db+2,3y,2p+v,ff+3,30+1,n9x+3,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,r2,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+5,3+1,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2d+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,f0c+4,1o+6,t5,1s+3,2a,f5l+1,43t+2,i+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,gzhy+6n",AL:"16w,3,2,e+1b,z+2,2+2s,g+1,8+1,b+m,2+t,s+2i,c+e,4h+f,1d+1e,1bwe+dp,3+3z,x+c,2+1,35+3y,2rm+z,5+7,b+5,dt+l,c+u,17nl+27,1t+27,4x+6n,3+d",LRO:"6ct",RLO:"6cu",LRE:"6cq",RLE:"6cr",PDF:"6cs",LRI:"6ee",RLI:"6ef",FSI:"6eg",PDI:"6eh"},f={},a={};f.L=1,a[1]="L",Object.keys(e).forEach((function(r,e){f[r]=1<<e+1,a[f[r]]=r})),Object.freeze(f);var n=f.LRI|f.RLI|f.FSI,i=f.L|f.R|f.AL,v=f.B|f.S|f.WS|f.ON|f.FSI|f.LRI|f.RLI|f.PDI,o=f.BN|f.RLE|f.LRE|f.RLO|f.LRO|f.PDF,t=f.S|f.WS|f.B|n|f.PDI|o,u=null;function l(r){return function(){if(!u){u=new Map;var r=function(r){if(e.hasOwnProperty(r)){var a=0;e[r].split(",").forEach((function(e){var n=e.split("+"),i=n[0],v=n[1];i=parseInt(i,36),v=v?parseInt(v,36):0,u.set(a+=i,f[r]);for(var o=0;o<v;o++)u.set(++a,f[r])}))}};for(var a in e)r(a)}}(),u.get(r.codePointAt(0))||f.L}var c,d,b,s="14>1,1e>2,u>2,2wt>1,1>1,1ge>1,1wp>1,1j>1,f>1,hm>1,1>1,u>1,u6>1,1>1,+5,28>1,w>1,1>1,+3,b8>1,1>1,+3,1>3,-1>-1,3>1,1>1,+2,1s>1,1>1,x>1,th>1,1>1,+2,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,4q>1,1e>2,u>2,2>1,+1",h="6f1>-6dx,6dy>-6dx,6ec>-6ed,6ee>-6ed,6ww>2jj,-2ji>2jj,14r4>-1e7l,1e7m>-1e7l,1e7m>-1e5c,1e5d>-1e5b,1e5c>-14qx,14qy>-14qx,14vn>-1ecg,1ech>-1ecg,1edu>-1ecg,1eci>-1ecg,1eda>-1ecg,1eci>-1ecg,1eci>-168q,168r>-168q,168s>-14ye,14yf>-14ye";function k(r,e){var f,a=0,n=new Map,i=e&&new Map;return r.split(",").forEach((function r(v){if(-1!==v.indexOf("+"))for(var o=+v;o--;)r(f);else{f=v;var t=v.split(">"),u=t[0],l=t[1];u=String.fromCodePoint(a+=parseInt(u,36)),l=String.fromCodePoint(a+=parseInt(l,36)),n.set(u,l),e&&i.set(l,u)}})),{map:n,reverseMap:i}}function m(){if(!c){var r=k(s,!0),e=r.map,f=r.reverseMap;c=e,d=f,b=k(h,!1).map}}function j(r){return m(),c.get(r)||null}function g(r){return m(),d.get(r)||null}function p(r){return m(),b.get(r)||null}var q=f.L,w=f.R,y=f.EN,x=f.ES,_=f.ET,M=f.AN,z=f.CS,I=f.B,L=f.S,S=f.ON,R=f.BN,O=f.NSM,E=f.AL,N=f.LRO,T=f.RLO,A=f.LRE,D=f.RLE,P=f.PDF,W=f.LRI,B=f.RLI,F=f.FSI,U=f.PDI;var C;function G(r){return function(){if(!C){var r=k("14>1,j>2,t>2,u>2,1a>g,2v3>1,1>1,1ge>1,1wd>1,b>1,1j>1,f>1,ai>3,-2>3,+1,8>1k0,-1jq>1y7,-1y6>1hf,-1he>1h6,-1h5>1ha,-1h8>1qi,-1pu>1,6>3u,-3s>7,6>1,1>1,f>1,1>1,+2,3>1,1>1,+13,4>1,1>1,6>1eo,-1ee>1,3>1mg,-1me>1mk,-1mj>1mi,-1mg>1mi,-1md>1,1>1,+2,1>10k,-103>1,1>1,4>1,5>1,1>1,+10,3>1,1>8,-7>8,+1,-6>7,+1,a>1,1>1,u>1,u6>1,1>1,+5,26>1,1>1,2>1,2>2,8>1,7>1,4>1,1>1,+5,b8>1,1>1,+3,1>3,-2>1,2>1,1>1,+2,c>1,3>1,1>1,+2,h>1,3>1,a>1,1>1,2>1,3>1,1>1,d>1,f>1,3>1,1a>1,1>1,6>1,7>1,13>1,k>1,1>1,+19,4>1,1>1,+2,2>1,1>1,+18,m>1,a>1,1>1,lk>1,1>1,4>1,2>1,f>1,3>1,1>1,+3,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,6>1,4j>1,j>2,t>2,u>2,2>1,+1",!0),e=r.map;r.reverseMap.forEach((function(r,f){e.set(f,r)})),C=e}}(),C.get(r)||null}function H(r,e,f,a){var n=r.length;f=Math.max(0,null==f?0:+f),a=Math.min(n-1,null==a?n-1:+a);var i=[];return e.paragraphs.forEach((function(n){var v=Math.max(f,n.start),o=Math.min(a,n.end);if(v<o){for(var u=e.levels.slice(v,o+1),c=o;c>=v&&l(r[c])&t;c--)u[c]=n.level;for(var d=n.level,b=1/0,s=0;s<u.length;s++){var h=u[s];h>d&&(d=h),h<b&&(b=1|h)}for(var k=d;k>=b;k--)for(var m=0;m<u.length;m++)if(u[m]>=k){for(var j=m;m+1<u.length&&u[m+1]>=k;)m++;m>j&&i.push([j+v,m+v])}}})),i}function J(r,e,f,a){for(var n=H(r,e,f,a),i=[],v=0;v<r.length;v++)i[v]=v;return n.forEach((function(r){for(var e=r[0],f=r[1],a=i.slice(e,f+1),n=a.length;n--;)i[f-n]=a[n]})),i}return r.closingToOpeningBracket=g,r.getBidiCharType=l,r.getBidiCharTypeName=function(r){return a[l(r)]},r.getCanonicalBracket=p,r.getEmbeddingLevels=function(r,e){for(var f=new Uint32Array(r.length),a=0;a<r.length;a++)f[a]=l(r[a]);var u=new Map;function c(r,e){var a=f[r];f[r]=e,u.set(a,u.get(a)-1),a&v&&u.set(v,u.get(v)-1),u.set(e,(u.get(e)||0)+1),e&v&&u.set(v,(u.get(v)||0)+1)}for(var d=new Uint8Array(r.length),b=new Map,s=[],h=null,k=0;k<r.length;k++)h||s.push(h={start:k,end:r.length-1,level:"rtl"===e?1:"ltr"===e?0:Fe(k,!1)}),f[k]&I&&(h.end=k,h=null);for(var m=D|A|T|N|n|U|P|I,C=function(r){return r+(1&r?1:2)},G=function(r){return r+(1&r?2:1)},H=0;H<s.length;H++){var J=[{i:(h=s[H]).level,v:0,o:0}],K=void 0,Q=0,V=0,X=0;u.clear();for(var Y=h.start;Y<=h.end;Y++){var Z=f[Y];if(K=J[J.length-1],u.set(Z,(u.get(Z)||0)+1),Z&v&&u.set(v,(u.get(v)||0)+1),Z&m)if(Z&(D|A)){d[Y]=K.i;var $=(Z===D?G:C)(K.i);$<=125&&!Q&&!V?J.push({i:$,v:0,o:0}):Q||V++}else if(Z&(T|N)){d[Y]=K.i;var rr=(Z===T?G:C)(K.i);rr<=125&&!Q&&!V?J.push({i:rr,v:Z&T?w:q,o:0}):Q||V++}else if(Z&n){Z&F&&(Z=1===Fe(Y+1,!0)?B:W),d[Y]=K.i,K.v&&c(Y,K.v);var er=(Z===B?G:C)(K.i);er<=125&&0===Q&&0===V?(X++,J.push({i:er,v:0,o:1,t:Y})):Q++}else if(Z&U){if(Q>0)Q--;else if(X>0){for(V=0;!J[J.length-1].o;)J.pop();var fr=J[J.length-1].t;null!=fr&&(b.set(fr,Y),b.set(Y,fr)),J.pop(),X--}K=J[J.length-1],d[Y]=K.i,K.v&&c(Y,K.v)}else Z&P?(0===Q&&(V>0?V--:!K.o&&J.length>1&&(J.pop(),K=J[J.length-1])),d[Y]=K.i):Z&I&&(d[Y]=h.level);else d[Y]=K.i,K.v&&Z!==R&&c(Y,K.v)}for(var ar=[],nr=null,ir=h.start;ir<=h.end;ir++){var vr=f[ir];if(!(vr&o)){var or=d[ir],tr=vr&n,ur=vr===U;nr&&or===nr.i?(nr.u=ir,nr.l=tr):ar.push(nr={h:ir,u:ir,i:or,k:ur,l:tr})}}for(var lr=[],cr=0;cr<ar.length;cr++){var dr=ar[cr];if(!dr.k||dr.k&&!b.has(dr.h)){for(var br=[nr=dr],sr=void 0;nr&&nr.l&&null!=(sr=b.get(nr.u));)for(var hr=cr+1;hr<ar.length;hr++)if(ar[hr].h===sr){br.push(nr=ar[hr]);break}for(var kr=[],mr=0;mr<br.length;mr++)for(var jr=br[mr],gr=jr.h;gr<=jr.u;gr++)kr.push(gr);for(var pr=d[kr[0]],qr=h.level,wr=kr[0]-1;wr>=0;wr--)if(!(f[wr]&o)){qr=d[wr];break}var yr=kr[kr.length-1],xr=d[yr],_r=h.level;if(!(f[yr]&n))for(var Mr=yr+1;Mr<=h.end;Mr++)if(!(f[Mr]&o)){_r=d[Mr];break}lr.push({m:kr,j:Math.max(qr,pr)%2?w:q,g:Math.max(_r,xr)%2?w:q})}}for(var zr=0;zr<lr.length;zr++){var Ir=lr[zr],Lr=Ir.m,Sr=Ir.j,Rr=Ir.g,Or=1&d[Lr[0]]?w:q;if(u.get(O))for(var Er=0;Er<Lr.length;Er++){var Nr=Lr[Er];if(f[Nr]&O){for(var Tr=Sr,Ar=Er-1;Ar>=0;Ar--)if(!(f[Lr[Ar]]&o)){Tr=f[Lr[Ar]];break}c(Nr,Tr&(n|U)?S:Tr)}}if(u.get(y))for(var Dr=0;Dr<Lr.length;Dr++){var Pr=Lr[Dr];if(f[Pr]&y)for(var Wr=Dr-1;Wr>=-1;Wr--){var Br=-1===Wr?Sr:f[Lr[Wr]];if(Br&i){Br===E&&c(Pr,M);break}}}if(u.get(E))for(var Fr=0;Fr<Lr.length;Fr++){var Ur=Lr[Fr];f[Ur]&E&&c(Ur,w)}if(u.get(x)||u.get(z))for(var Cr=1;Cr<Lr.length-1;Cr++){var Gr=Lr[Cr];if(f[Gr]&(x|z)){for(var Hr=0,Jr=0,Kr=Cr-1;Kr>=0&&(Hr=f[Lr[Kr]])&o;Kr--);for(var Qr=Cr+1;Qr<Lr.length&&(Jr=f[Lr[Qr]])&o;Qr++);Hr===Jr&&(f[Gr]===x?Hr===y:Hr&(y|M))&&c(Gr,Hr)}}if(u.get(y))for(var Vr=0;Vr<Lr.length;Vr++){var Xr=Lr[Vr];if(f[Xr]&y){for(var Yr=Vr-1;Yr>=0&&f[Lr[Yr]]&(_|o);Yr--)c(Lr[Yr],y);for(Vr++;Vr<Lr.length&&f[Lr[Vr]]&(_|o|y);Vr++)f[Lr[Vr]]!==y&&c(Lr[Vr],y)}}if(u.get(_)||u.get(x)||u.get(z))for(var Zr=0;Zr<Lr.length;Zr++){var $r=Lr[Zr];if(f[$r]&(_|x|z)){c($r,S);for(var re=Zr-1;re>=0&&f[Lr[re]]&o;re--)c(Lr[re],S);for(var ee=Zr+1;ee<Lr.length&&f[Lr[ee]]&o;ee++)c(Lr[ee],S)}}if(u.get(y))for(var fe=0,ae=Sr;fe<Lr.length;fe++){var ne=Lr[fe],ie=f[ne];ie&y?ae===q&&c(ne,q):ie&i&&(ae=ie)}if(u.get(v)){for(var ve=w|y|M,oe=ve|q,te=[],ue=[],le=0;le<Lr.length;le++)if(f[Lr[le]]&v){var ce=r[Lr[le]],de=void 0;if(null!==j(ce)){if(!(ue.length<63))break;ue.push({char:ce,seqIndex:le})}else if(null!==(de=g(ce)))for(var be=ue.length-1;be>=0;be--){var se=ue[be].char;if(se===de||se===g(p(ce))||j(p(se))===ce){te.push([ue[be].seqIndex,le]),ue.length=be;break}}}te.sort((function(r,e){return r[0]-e[0]}));for(var he=0;he<te.length;he++){for(var ke=te[he],me=ke[0],je=ke[1],ge=!1,pe=0,qe=me+1;qe<je;qe++){var we=Lr[qe];if(f[we]&oe){ge=!0;var ye=f[we]&ve?w:q;if(ye===Or){pe=ye;break}}}if(ge&&!pe){pe=Sr;for(var xe=me-1;xe>=0;xe--){var _e=Lr[xe];if(f[_e]&oe){var Me=f[_e]&ve?w:q;pe=Me!==Or?Me:Or;break}}}if(pe){if(f[Lr[me]]=f[Lr[je]]=pe,pe!==Or)for(var ze=me+1;ze<Lr.length;ze++)if(!(f[Lr[ze]]&o)){l(r[Lr[ze]])&O&&(f[Lr[ze]]=pe);break}if(pe!==Or)for(var Ie=je+1;Ie<Lr.length;Ie++)if(!(f[Lr[Ie]]&o)){l(r[Lr[Ie]])&O&&(f[Lr[Ie]]=pe);break}}}for(var Le=0;Le<Lr.length;Le++)if(f[Lr[Le]]&v){for(var Se=Le,Re=Le,Oe=Sr,Ee=Le-1;Ee>=0;Ee--){if(!(f[Lr[Ee]]&o)){Oe=f[Lr[Ee]]&ve?w:q;break}Se=Ee}for(var Ne=Rr,Te=Le+1;Te<Lr.length;Te++){if(!(f[Lr[Te]]&(v|o))){Ne=f[Lr[Te]]&ve?w:q;break}Re=Te}for(var Ae=Se;Ae<=Re;Ae++)f[Lr[Ae]]=Oe===Ne?Oe:Or;Le=Re}}}for(var De=h.start;De<=h.end;De++){var Pe=d[De],We=f[De];if(1&Pe?We&(q|y|M)&&d[De]++:We&w?d[De]++:We&(M|y)&&(d[De]+=2),We&o&&(d[De]=0===De?h.level:d[De-1]),De===h.end||l(r[De])&(L|I))for(var Be=De;Be>=0&&l(r[Be])&t;Be--)d[Be]=h.level}}return{levels:d,paragraphs:s};function Fe(e,a){for(var i=e;i<r.length;i++){var v=f[i];if(v&(w|E))return 1;if(v&(I|q)||a&&v===U)return 0;if(v&n){var o=Ue(i);i=-1===o?r.length:o}}return 0}function Ue(e){for(var a=1,i=e+1;i<r.length;i++){var v=f[i];if(v&I)break;if(v&U){if(0==--a)return i}else v&n&&a++}return-1}},r.getMirroredCharacter=G,r.getMirroredCharactersMap=function(r,e,f,a){var n=r.length;f=Math.max(0,null==f?0:+f),a=Math.min(n-1,null==a?n-1:+a);for(var i=new Map,v=f;v<=a;v++)if(1&e[v]){var o=G(r[v]);null!==o&&i.set(v,o)}return i},r.getReorderSegments=H,r.getReorderedIndices=J,r.getReorderedString=function(r,e,f,a){var n=J(r,e,f,a),i=[].concat(r);return n.forEach((function(f,a){i[a]=(1&e.levels[f]?G(r[f]):null)||r[f]})),i.join("")},r.openingToClosingBracket=j,Object.defineProperty(r,"p",{value:!0}),r}({})}}));
diff --git a/docs/vendor/blob-stream.js b/docs/vendor/blob-stream.js
new file mode 100644
index 0000000..2bbfee2
--- /dev/null
+++ b/docs/vendor/blob-stream.js
@@ -0,0 +1,4684 @@
+!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.blobStream=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
+(function (global){
+/**
+ * Create a blob builder even when vendor prefixes exist
+ */
+
+var BlobBuilder = global.BlobBuilder
+  || global.WebKitBlobBuilder
+  || global.MSBlobBuilder
+  || global.MozBlobBuilder;
+
+/**
+ * Check if Blob constructor is supported
+ */
+
+var blobSupported = (function() {
+  try {
+    var a = new Blob(['hi']);
+    return a.size === 2;
+  } catch(e) {
+    return false;
+  }
+})();
+
+/**
+ * Check if Blob constructor supports ArrayBufferViews
+ * Fails in Safari 6, so we need to map to ArrayBuffers there.
+ */
+
+var blobSupportsArrayBufferView = blobSupported && (function() {
+  try {
+    var b = new Blob([new Uint8Array([1,2])]);
+    return b.size === 2;
+  } catch(e) {
+    return false;
+  }
+})();
+
+/**
+ * Check if BlobBuilder is supported
+ */
+
+var blobBuilderSupported = BlobBuilder
+  && BlobBuilder.prototype.append
+  && BlobBuilder.prototype.getBlob;
+
+/**
+ * Helper function that maps ArrayBufferViews to ArrayBuffers
+ * Used by BlobBuilder constructor and old browsers that didn't
+ * support it in the Blob constructor.
+ */
+
+function mapArrayBufferViews(ary) {
+  for (var i = 0; i < ary.length; i++) {
+    var chunk = ary[i];
+    if (chunk.buffer instanceof ArrayBuffer) {
+      var buf = chunk.buffer;
+
+      // if this is a subarray, make a copy so we only
+      // include the subarray region from the underlying buffer
+      if (chunk.byteLength !== buf.byteLength) {
+        var copy = new Uint8Array(chunk.byteLength);
+        copy.set(new Uint8Array(buf, chunk.byteOffset, chunk.byteLength));
+        buf = copy.buffer;
+      }
+
+      ary[i] = buf;
+    }
+  }
+}
+
+function BlobBuilderConstructor(ary, options) {
+  options = options || {};
+
+  var bb = new BlobBuilder();
+  mapArrayBufferViews(ary);
+
+  for (var i = 0; i < ary.length; i++) {
+    bb.append(ary[i]);
+  }
+
+  return (options.type) ? bb.getBlob(options.type) : bb.getBlob();
+};
+
+function BlobConstructor(ary, options) {
+  mapArrayBufferViews(ary);
+  return new Blob(ary, options || {});
+};
+
+module.exports = (function() {
+  if (blobSupported) {
+    return blobSupportsArrayBufferView ? global.Blob : BlobConstructor;
+  } else if (blobBuilderSupported) {
+    return BlobBuilderConstructor;
+  } else {
+    return undefined;
+  }
+})();
+
+}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
+},{}],2:[function(require,module,exports){
+(function (global){
+var WritableStream = require('stream').Writable;
+var util = require('util');
+var Blob = require('blob');
+var URL = global.URL || global.webkitURL || global.mozURL;
+
+function BlobStream() {
+  if (!(this instanceof BlobStream))
+    return new BlobStream;
+    
+  WritableStream.call(this);
+  this._chunks = [];
+  this._blob = null;
+  this.length = 0;
+}
+
+util.inherits(BlobStream, WritableStream);
+
+BlobStream.prototype._write = function(chunk, encoding, callback) {
+  // convert chunks to Uint8Arrays (e.g. Buffer when array fallback is being used)
+  if (!(chunk instanceof Uint8Array))
+    chunk = new Uint8Array(chunk);
+    
+  this.length += chunk.length;
+  this._chunks.push(chunk);
+  callback();
+};
+
+BlobStream.prototype.toBlob = function(type) {
+  type = type || 'application/octet-stream';
+  
+  // cache the blob if needed
+  if (!this._blob) {
+    this._blob = new Blob(this._chunks, {
+      type: type
+    });
+    
+    this._chunks = []; // free memory
+  }
+  
+  // if the cached blob's type doesn't match the requested type, make a new blob
+  if (this._blob.type !== type)
+    this._blob = new Blob([this._blob], { type: type });
+  
+  return this._blob;
+};
+
+BlobStream.prototype.toBlobURL = function(type) {
+  return URL.createObjectURL(this.toBlob(type));
+};
+
+module.exports = BlobStream;
+
+}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
+},{"blob":1,"stream":22,"util":25}],3:[function(require,module,exports){
+/*!
+ * The buffer module from node.js, for the browser.
+ *
+ * @author   Feross Aboukhadijeh <[email protected]> <http://feross.org>
+ * @license  MIT
+ */
+
+var base64 = require('base64-js')
+var ieee754 = require('ieee754')
+var isArray = require('is-array')
+
+exports.Buffer = Buffer
+exports.SlowBuffer = Buffer
+exports.INSPECT_MAX_BYTES = 50
+Buffer.poolSize = 8192 // not used by this implementation
+
+var kMaxLength = 0x3fffffff
+
+/**
+ * If `Buffer.TYPED_ARRAY_SUPPORT`:
+ *   === true    Use Uint8Array implementation (fastest)
+ *   === false   Use Object implementation (most compatible, even IE6)
+ *
+ * Browsers that support typed arrays are IE 10+, Firefox 4+, Chrome 7+, Safari 5.1+,
+ * Opera 11.6+, iOS 4.2+.
+ *
+ * Note:
+ *
+ * - Implementation must support adding new properties to `Uint8Array` instances.
+ *   Firefox 4-29 lacked support, fixed in Firefox 30+.
+ *   See: https://bugzilla.mozilla.org/show_bug.cgi?id=695438.
+ *
+ *  - Chrome 9-10 is missing the `TypedArray.prototype.subarray` function.
+ *
+ *  - IE10 has a broken `TypedArray.prototype.subarray` function which returns arrays of
+ *    incorrect length in some situations.
+ *
+ * We detect these buggy browsers and set `Buffer.TYPED_ARRAY_SUPPORT` to `false` so they will
+ * get the Object implementation, which is slower but will work correctly.
+ */
+Buffer.TYPED_ARRAY_SUPPORT = (function () {
+  try {
+    var buf = new ArrayBuffer(0)
+    var arr = new Uint8Array(buf)
+    arr.foo = function () { return 42 }
+    return 42 === arr.foo() && // typed array instances can be augmented
+        typeof arr.subarray === 'function' && // chrome 9-10 lack `subarray`
+        new Uint8Array(1).subarray(1, 1).byteLength === 0 // ie10 has broken `subarray`
+  } catch (e) {
+    return false
+  }
+})()
+
+/**
+ * Class: Buffer
+ * =============
+ *
+ * The Buffer constructor returns instances of `Uint8Array` that are augmented
+ * with function properties for all the node `Buffer` API functions. We use
+ * `Uint8Array` so that square bracket notation works as expected -- it returns
+ * a single octet.
+ *
+ * By augmenting the instances, we can avoid modifying the `Uint8Array`
+ * prototype.
+ */
+function Buffer (subject, encoding, noZero) {
+  if (!(this instanceof Buffer))
+    return new Buffer(subject, encoding, noZero)
+
+  var type = typeof subject
+
+  // Find the length
+  var length
+  if (type === 'number')
+    length = subject > 0 ? subject >>> 0 : 0
+  else if (type === 'string') {
+    if (encoding === 'base64')
+      subject = base64clean(subject)
+    length = Buffer.byteLength(subject, encoding)
+  } else if (type === 'object' && subject !== null) { // assume object is array-like
+    if (subject.type === 'Buffer' && isArray(subject.data))
+      subject = subject.data
+    length = +subject.length > 0 ? Math.floor(+subject.length) : 0
+  } else
+    throw new TypeError('must start with number, buffer, array or string')
+
+  if (this.length > kMaxLength)
+    throw new RangeError('Attempt to allocate Buffer larger than maximum ' +
+      'size: 0x' + kMaxLength.toString(16) + ' bytes')
+
+  var buf
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    // Preferred: Return an augmented `Uint8Array` instance for best performance
+    buf = Buffer._augment(new Uint8Array(length))
+  } else {
+    // Fallback: Return THIS instance of Buffer (created by `new`)
+    buf = this
+    buf.length = length
+    buf._isBuffer = true
+  }
+
+  var i
+  if (Buffer.TYPED_ARRAY_SUPPORT && typeof subject.byteLength === 'number') {
+    // Speed optimization -- use set if we're copying from a typed array
+    buf._set(subject)
+  } else if (isArrayish(subject)) {
+    // Treat array-ish objects as a byte array
+    if (Buffer.isBuffer(subject)) {
+      for (i = 0; i < length; i++)
+        buf[i] = subject.readUInt8(i)
+    } else {
+      for (i = 0; i < length; i++)
+        buf[i] = ((subject[i] % 256) + 256) % 256
+    }
+  } else if (type === 'string') {
+    buf.write(subject, 0, encoding)
+  } else if (type === 'number' && !Buffer.TYPED_ARRAY_SUPPORT && !noZero) {
+    for (i = 0; i < length; i++) {
+      buf[i] = 0
+    }
+  }
+
+  return buf
+}
+
+Buffer.isBuffer = function (b) {
+  return !!(b != null && b._isBuffer)
+}
+
+Buffer.compare = function (a, b) {
+  if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b))
+    throw new TypeError('Arguments must be Buffers')
+
+  var x = a.length
+  var y = b.length
+  for (var i = 0, len = Math.min(x, y); i < len && a[i] === b[i]; i++) {}
+  if (i !== len) {
+    x = a[i]
+    y = b[i]
+  }
+  if (x < y) return -1
+  if (y < x) return 1
+  return 0
+}
+
+Buffer.isEncoding = function (encoding) {
+  switch (String(encoding).toLowerCase()) {
+    case 'hex':
+    case 'utf8':
+    case 'utf-8':
+    case 'ascii':
+    case 'binary':
+    case 'base64':
+    case 'raw':
+    case 'ucs2':
+    case 'ucs-2':
+    case 'utf16le':
+    case 'utf-16le':
+      return true
+    default:
+      return false
+  }
+}
+
+Buffer.concat = function (list, totalLength) {
+  if (!isArray(list)) throw new TypeError('Usage: Buffer.concat(list[, length])')
+
+  if (list.length === 0) {
+    return new Buffer(0)
+  } else if (list.length === 1) {
+    return list[0]
+  }
+
+  var i
+  if (totalLength === undefined) {
+    totalLength = 0
+    for (i = 0; i < list.length; i++) {
+      totalLength += list[i].length
+    }
+  }
+
+  var buf = new Buffer(totalLength)
+  var pos = 0
+  for (i = 0; i < list.length; i++) {
+    var item = list[i]
+    item.copy(buf, pos)
+    pos += item.length
+  }
+  return buf
+}
+
+Buffer.byteLength = function (str, encoding) {
+  var ret
+  str = str + ''
+  switch (encoding || 'utf8') {
+    case 'ascii':
+    case 'binary':
+    case 'raw':
+      ret = str.length
+      break
+    case 'ucs2':
+    case 'ucs-2':
+    case 'utf16le':
+    case 'utf-16le':
+      ret = str.length * 2
+      break
+    case 'hex':
+      ret = str.length >>> 1
+      break
+    case 'utf8':
+    case 'utf-8':
+      ret = utf8ToBytes(str).length
+      break
+    case 'base64':
+      ret = base64ToBytes(str).length
+      break
+    default:
+      ret = str.length
+  }
+  return ret
+}
+
+// pre-set for values that may exist in the future
+Buffer.prototype.length = undefined
+Buffer.prototype.parent = undefined
+
+// toString(encoding, start=0, end=buffer.length)
+Buffer.prototype.toString = function (encoding, start, end) {
+  var loweredCase = false
+
+  start = start >>> 0
+  end = end === undefined || end === Infinity ? this.length : end >>> 0
+
+  if (!encoding) encoding = 'utf8'
+  if (start < 0) start = 0
+  if (end > this.length) end = this.length
+  if (end <= start) return ''
+
+  while (true) {
+    switch (encoding) {
+      case 'hex':
+        return hexSlice(this, start, end)
+
+      case 'utf8':
+      case 'utf-8':
+        return utf8Slice(this, start, end)
+
+      case 'ascii':
+        return asciiSlice(this, start, end)
+
+      case 'binary':
+        return binarySlice(this, start, end)
+
+      case 'base64':
+        return base64Slice(this, start, end)
+
+      case 'ucs2':
+      case 'ucs-2':
+      case 'utf16le':
+      case 'utf-16le':
+        return utf16leSlice(this, start, end)
+
+      default:
+        if (loweredCase)
+          throw new TypeError('Unknown encoding: ' + encoding)
+        encoding = (encoding + '').toLowerCase()
+        loweredCase = true
+    }
+  }
+}
+
+Buffer.prototype.equals = function (b) {
+  if(!Buffer.isBuffer(b)) throw new TypeError('Argument must be a Buffer')
+  return Buffer.compare(this, b) === 0
+}
+
+Buffer.prototype.inspect = function () {
+  var str = ''
+  var max = exports.INSPECT_MAX_BYTES
+  if (this.length > 0) {
+    str = this.toString('hex', 0, max).match(/.{2}/g).join(' ')
+    if (this.length > max)
+      str += ' ... '
+  }
+  return '<Buffer ' + str + '>'
+}
+
+Buffer.prototype.compare = function (b) {
+  if (!Buffer.isBuffer(b)) throw new TypeError('Argument must be a Buffer')
+  return Buffer.compare(this, b)
+}
+
+// `get` will be removed in Node 0.13+
+Buffer.prototype.get = function (offset) {
+  console.log('.get() is deprecated. Access using array indexes instead.')
+  return this.readUInt8(offset)
+}
+
+// `set` will be removed in Node 0.13+
+Buffer.prototype.set = function (v, offset) {
+  console.log('.set() is deprecated. Access using array indexes instead.')
+  return this.writeUInt8(v, offset)
+}
+
+function hexWrite (buf, string, offset, length) {
+  offset = Number(offset) || 0
+  var remaining = buf.length - offset
+  if (!length) {
+    length = remaining
+  } else {
+    length = Number(length)
+    if (length > remaining) {
+      length = remaining
+    }
+  }
+
+  // must be an even number of digits
+  var strLen = string.length
+  if (strLen % 2 !== 0) throw new Error('Invalid hex string')
+
+  if (length > strLen / 2) {
+    length = strLen / 2
+  }
+  for (var i = 0; i < length; i++) {
+    var byte = parseInt(string.substr(i * 2, 2), 16)
+    if (isNaN(byte)) throw new Error('Invalid hex string')
+    buf[offset + i] = byte
+  }
+  return i
+}
+
+function utf8Write (buf, string, offset, length) {
+  var charsWritten = blitBuffer(utf8ToBytes(string), buf, offset, length)
+  return charsWritten
+}
+
+function asciiWrite (buf, string, offset, length) {
+  var charsWritten = blitBuffer(asciiToBytes(string), buf, offset, length)
+  return charsWritten
+}
+
+function binaryWrite (buf, string, offset, length) {
+  return asciiWrite(buf, string, offset, length)
+}
+
+function base64Write (buf, string, offset, length) {
+  var charsWritten = blitBuffer(base64ToBytes(string), buf, offset, length)
+  return charsWritten
+}
+
+function utf16leWrite (buf, string, offset, length) {
+  var charsWritten = blitBuffer(utf16leToBytes(string), buf, offset, length)
+  return charsWritten
+}
+
+Buffer.prototype.write = function (string, offset, length, encoding) {
+  // Support both (string, offset, length, encoding)
+  // and the legacy (string, encoding, offset, length)
+  if (isFinite(offset)) {
+    if (!isFinite(length)) {
+      encoding = length
+      length = undefined
+    }
+  } else {  // legacy
+    var swap = encoding
+    encoding = offset
+    offset = length
+    length = swap
+  }
+
+  offset = Number(offset) || 0
+  var remaining = this.length - offset
+  if (!length) {
+    length = remaining
+  } else {
+    length = Number(length)
+    if (length > remaining) {
+      length = remaining
+    }
+  }
+  encoding = String(encoding || 'utf8').toLowerCase()
+
+  var ret
+  switch (encoding) {
+    case 'hex':
+      ret = hexWrite(this, string, offset, length)
+      break
+    case 'utf8':
+    case 'utf-8':
+      ret = utf8Write(this, string, offset, length)
+      break
+    case 'ascii':
+      ret = asciiWrite(this, string, offset, length)
+      break
+    case 'binary':
+      ret = binaryWrite(this, string, offset, length)
+      break
+    case 'base64':
+      ret = base64Write(this, string, offset, length)
+      break
+    case 'ucs2':
+    case 'ucs-2':
+    case 'utf16le':
+    case 'utf-16le':
+      ret = utf16leWrite(this, string, offset, length)
+      break
+    default:
+      throw new TypeError('Unknown encoding: ' + encoding)
+  }
+  return ret
+}
+
+Buffer.prototype.toJSON = function () {
+  return {
+    type: 'Buffer',
+    data: Array.prototype.slice.call(this._arr || this, 0)
+  }
+}
+
+function base64Slice (buf, start, end) {
+  if (start === 0 && end === buf.length) {
+    return base64.fromByteArray(buf)
+  } else {
+    return base64.fromByteArray(buf.slice(start, end))
+  }
+}
+
+function utf8Slice (buf, start, end) {
+  var res = ''
+  var tmp = ''
+  end = Math.min(buf.length, end)
+
+  for (var i = start; i < end; i++) {
+    if (buf[i] <= 0x7F) {
+      res += decodeUtf8Char(tmp) + String.fromCharCode(buf[i])
+      tmp = ''
+    } else {
+      tmp += '%' + buf[i].toString(16)
+    }
+  }
+
+  return res + decodeUtf8Char(tmp)
+}
+
+function asciiSlice (buf, start, end) {
+  var ret = ''
+  end = Math.min(buf.length, end)
+
+  for (var i = start; i < end; i++) {
+    ret += String.fromCharCode(buf[i])
+  }
+  return ret
+}
+
+function binarySlice (buf, start, end) {
+  return asciiSlice(buf, start, end)
+}
+
+function hexSlice (buf, start, end) {
+  var len = buf.length
+
+  if (!start || start < 0) start = 0
+  if (!end || end < 0 || end > len) end = len
+
+  var out = ''
+  for (var i = start; i < end; i++) {
+    out += toHex(buf[i])
+  }
+  return out
+}
+
+function utf16leSlice (buf, start, end) {
+  var bytes = buf.slice(start, end)
+  var res = ''
+  for (var i = 0; i < bytes.length; i += 2) {
+    res += String.fromCharCode(bytes[i] + bytes[i + 1] * 256)
+  }
+  return res
+}
+
+Buffer.prototype.slice = function (start, end) {
+  var len = this.length
+  start = ~~start
+  end = end === undefined ? len : ~~end
+
+  if (start < 0) {
+    start += len;
+    if (start < 0)
+      start = 0
+  } else if (start > len) {
+    start = len
+  }
+
+  if (end < 0) {
+    end += len
+    if (end < 0)
+      end = 0
+  } else if (end > len) {
+    end = len
+  }
+
+  if (end < start)
+    end = start
+
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    return Buffer._augment(this.subarray(start, end))
+  } else {
+    var sliceLen = end - start
+    var newBuf = new Buffer(sliceLen, undefined, true)
+    for (var i = 0; i < sliceLen; i++) {
+      newBuf[i] = this[i + start]
+    }
+    return newBuf
+  }
+}
+
+/*
+ * Need to make sure that buffer isn't trying to write out of bounds.
+ */
+function checkOffset (offset, ext, length) {
+  if ((offset % 1) !== 0 || offset < 0)
+    throw new RangeError('offset is not uint')
+  if (offset + ext > length)
+    throw new RangeError('Trying to access beyond buffer length')
+}
+
+Buffer.prototype.readUInt8 = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 1, this.length)
+  return this[offset]
+}
+
+Buffer.prototype.readUInt16LE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 2, this.length)
+  return this[offset] | (this[offset + 1] << 8)
+}
+
+Buffer.prototype.readUInt16BE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 2, this.length)
+  return (this[offset] << 8) | this[offset + 1]
+}
+
+Buffer.prototype.readUInt32LE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+
+  return ((this[offset]) |
+      (this[offset + 1] << 8) |
+      (this[offset + 2] << 16)) +
+      (this[offset + 3] * 0x1000000)
+}
+
+Buffer.prototype.readUInt32BE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+
+  return (this[offset] * 0x1000000) +
+      ((this[offset + 1] << 16) |
+      (this[offset + 2] << 8) |
+      this[offset + 3])
+}
+
+Buffer.prototype.readInt8 = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 1, this.length)
+  if (!(this[offset] & 0x80))
+    return (this[offset])
+  return ((0xff - this[offset] + 1) * -1)
+}
+
+Buffer.prototype.readInt16LE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 2, this.length)
+  var val = this[offset] | (this[offset + 1] << 8)
+  return (val & 0x8000) ? val | 0xFFFF0000 : val
+}
+
+Buffer.prototype.readInt16BE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 2, this.length)
+  var val = this[offset + 1] | (this[offset] << 8)
+  return (val & 0x8000) ? val | 0xFFFF0000 : val
+}
+
+Buffer.prototype.readInt32LE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+
+  return (this[offset]) |
+      (this[offset + 1] << 8) |
+      (this[offset + 2] << 16) |
+      (this[offset + 3] << 24)
+}
+
+Buffer.prototype.readInt32BE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+
+  return (this[offset] << 24) |
+      (this[offset + 1] << 16) |
+      (this[offset + 2] << 8) |
+      (this[offset + 3])
+}
+
+Buffer.prototype.readFloatLE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+  return ieee754.read(this, offset, true, 23, 4)
+}
+
+Buffer.prototype.readFloatBE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 4, this.length)
+  return ieee754.read(this, offset, false, 23, 4)
+}
+
+Buffer.prototype.readDoubleLE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 8, this.length)
+  return ieee754.read(this, offset, true, 52, 8)
+}
+
+Buffer.prototype.readDoubleBE = function (offset, noAssert) {
+  if (!noAssert)
+    checkOffset(offset, 8, this.length)
+  return ieee754.read(this, offset, false, 52, 8)
+}
+
+function checkInt (buf, value, offset, ext, max, min) {
+  if (!Buffer.isBuffer(buf)) throw new TypeError('buffer must be a Buffer instance')
+  if (value > max || value < min) throw new TypeError('value is out of bounds')
+  if (offset + ext > buf.length) throw new TypeError('index out of range')
+}
+
+Buffer.prototype.writeUInt8 = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 1, 0xff, 0)
+  if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value)
+  this[offset] = value
+  return offset + 1
+}
+
+function objectWriteUInt16 (buf, value, offset, littleEndian) {
+  if (value < 0) value = 0xffff + value + 1
+  for (var i = 0, j = Math.min(buf.length - offset, 2); i < j; i++) {
+    buf[offset + i] = (value & (0xff << (8 * (littleEndian ? i : 1 - i)))) >>>
+      (littleEndian ? i : 1 - i) * 8
+  }
+}
+
+Buffer.prototype.writeUInt16LE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 2, 0xffff, 0)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value
+    this[offset + 1] = (value >>> 8)
+  } else objectWriteUInt16(this, value, offset, true)
+  return offset + 2
+}
+
+Buffer.prototype.writeUInt16BE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 2, 0xffff, 0)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = (value >>> 8)
+    this[offset + 1] = value
+  } else objectWriteUInt16(this, value, offset, false)
+  return offset + 2
+}
+
+function objectWriteUInt32 (buf, value, offset, littleEndian) {
+  if (value < 0) value = 0xffffffff + value + 1
+  for (var i = 0, j = Math.min(buf.length - offset, 4); i < j; i++) {
+    buf[offset + i] = (value >>> (littleEndian ? i : 3 - i) * 8) & 0xff
+  }
+}
+
+Buffer.prototype.writeUInt32LE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 4, 0xffffffff, 0)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset + 3] = (value >>> 24)
+    this[offset + 2] = (value >>> 16)
+    this[offset + 1] = (value >>> 8)
+    this[offset] = value
+  } else objectWriteUInt32(this, value, offset, true)
+  return offset + 4
+}
+
+Buffer.prototype.writeUInt32BE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 4, 0xffffffff, 0)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = (value >>> 24)
+    this[offset + 1] = (value >>> 16)
+    this[offset + 2] = (value >>> 8)
+    this[offset + 3] = value
+  } else objectWriteUInt32(this, value, offset, false)
+  return offset + 4
+}
+
+Buffer.prototype.writeInt8 = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 1, 0x7f, -0x80)
+  if (!Buffer.TYPED_ARRAY_SUPPORT) value = Math.floor(value)
+  if (value < 0) value = 0xff + value + 1
+  this[offset] = value
+  return offset + 1
+}
+
+Buffer.prototype.writeInt16LE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 2, 0x7fff, -0x8000)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value
+    this[offset + 1] = (value >>> 8)
+  } else objectWriteUInt16(this, value, offset, true)
+  return offset + 2
+}
+
+Buffer.prototype.writeInt16BE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 2, 0x7fff, -0x8000)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = (value >>> 8)
+    this[offset + 1] = value
+  } else objectWriteUInt16(this, value, offset, false)
+  return offset + 2
+}
+
+Buffer.prototype.writeInt32LE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000)
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = value
+    this[offset + 1] = (value >>> 8)
+    this[offset + 2] = (value >>> 16)
+    this[offset + 3] = (value >>> 24)
+  } else objectWriteUInt32(this, value, offset, true)
+  return offset + 4
+}
+
+Buffer.prototype.writeInt32BE = function (value, offset, noAssert) {
+  value = +value
+  offset = offset >>> 0
+  if (!noAssert)
+    checkInt(this, value, offset, 4, 0x7fffffff, -0x80000000)
+  if (value < 0) value = 0xffffffff + value + 1
+  if (Buffer.TYPED_ARRAY_SUPPORT) {
+    this[offset] = (value >>> 24)
+    this[offset + 1] = (value >>> 16)
+    this[offset + 2] = (value >>> 8)
+    this[offset + 3] = value
+  } else objectWriteUInt32(this, value, offset, false)
+  return offset + 4
+}
+
+function checkIEEE754 (buf, value, offset, ext, max, min) {
+  if (value > max || value < min) throw new TypeError('value is out of bounds')
+  if (offset + ext > buf.length) throw new TypeError('index out of range')
+}
+
+function writeFloat (buf, value, offset, littleEndian, noAssert) {
+  if (!noAssert)
+    checkIEEE754(buf, value, offset, 4, 3.4028234663852886e+38, -3.4028234663852886e+38)
+  ieee754.write(buf, value, offset, littleEndian, 23, 4)
+  return offset + 4
+}
+
+Buffer.prototype.writeFloatLE = function (value, offset, noAssert) {
+  return writeFloat(this, value, offset, true, noAssert)
+}
+
+Buffer.prototype.writeFloatBE = function (value, offset, noAssert) {
+  return writeFloat(this, value, offset, false, noAssert)
+}
+
+function writeDouble (buf, value, offset, littleEndian, noAssert) {
+  if (!noAssert)
+    checkIEEE754(buf, value, offset, 8, 1.7976931348623157E+308, -1.7976931348623157E+308)
+  ieee754.write(buf, value, offset, littleEndian, 52, 8)
+  return offset + 8
+}
+
+Buffer.prototype.writeDoubleLE = function (value, offset, noAssert) {
+  return writeDouble(this, value, offset, true, noAssert)
+}
+
+Buffer.prototype.writeDoubleBE = function (value, offset, noAssert) {
+  return writeDouble(this, value, offset, false, noAssert)
+}
+
+// copy(targetBuffer, targetStart=0, sourceStart=0, sourceEnd=buffer.length)
+Buffer.prototype.copy = function (target, target_start, start, end) {
+  var source = this
+
+  if (!start) start = 0
+  if (!end && end !== 0) end = this.length
+  if (!target_start) target_start = 0
+
+  // Copy 0 bytes; we're done
+  if (end === start) return
+  if (target.length === 0 || source.length === 0) return
+
+  // Fatal error conditions
+  if (end < start) throw new TypeError('sourceEnd < sourceStart')
+  if (target_start < 0 || target_start >= target.length)
+    throw new TypeError('targetStart out of bounds')
+  if (start < 0 || start >= source.length) throw new TypeError('sourceStart out of bounds')
+  if (end < 0 || end > source.length) throw new TypeError('sourceEnd out of bounds')
+
+  // Are we oob?
+  if (end > this.length)
+    end = this.length
+  if (target.length - target_start < end - start)
+    end = target.length - target_start + start
+
+  var len = end - start
+
+  if (len < 1000 || !Buffer.TYPED_ARRAY_SUPPORT) {
+    for (var i = 0; i < len; i++) {
+      target[i + target_start] = this[i + start]
+    }
+  } else {
+    target._set(this.subarray(start, start + len), target_start)
+  }
+}
+
+// fill(value, start=0, end=buffer.length)
+Buffer.prototype.fill = function (value, start, end) {
+  if (!value) value = 0
+  if (!start) start = 0
+  if (!end) end = this.length
+
+  if (end < start) throw new TypeError('end < start')
+
+  // Fill 0 bytes; we're done
+  if (end === start) return
+  if (this.length === 0) return
+
+  if (start < 0 || start >= this.length) throw new TypeError('start out of bounds')
+  if (end < 0 || end > this.length) throw new TypeError('end out of bounds')
+
+  var i
+  if (typeof value === 'number') {
+    for (i = start; i < end; i++) {
+      this[i] = value
+    }
+  } else {
+    var bytes = utf8ToBytes(value.toString())
+    var len = bytes.length
+    for (i = start; i < end; i++) {
+      this[i] = bytes[i % len]
+    }
+  }
+
+  return this
+}
+
+/**
+ * Creates a new `ArrayBuffer` with the *copied* memory of the buffer instance.
+ * Added in Node 0.12. Only available in browsers that support ArrayBuffer.
+ */
+Buffer.prototype.toArrayBuffer = function () {
+  if (typeof Uint8Array !== 'undefined') {
+    if (Buffer.TYPED_ARRAY_SUPPORT) {
+      return (new Buffer(this)).buffer
+    } else {
+      var buf = new Uint8Array(this.length)
+      for (var i = 0, len = buf.length; i < len; i += 1) {
+        buf[i] = this[i]
+      }
+      return buf.buffer
+    }
+  } else {
+    throw new TypeError('Buffer.toArrayBuffer not supported in this browser')
+  }
+}
+
+// HELPER FUNCTIONS
+// ================
+
+var BP = Buffer.prototype
+
+/**
+ * Augment a Uint8Array *instance* (not the Uint8Array class!) with Buffer methods
+ */
+Buffer._augment = function (arr) {
+  arr.constructor = Buffer
+  arr._isBuffer = true
+
+  // save reference to original Uint8Array get/set methods before overwriting
+  arr._get = arr.get
+  arr._set = arr.set
+
+  // deprecated, will be removed in node 0.13+
+  arr.get = BP.get
+  arr.set = BP.set
+
+  arr.write = BP.write
+  arr.toString = BP.toString
+  arr.toLocaleString = BP.toString
+  arr.toJSON = BP.toJSON
+  arr.equals = BP.equals
+  arr.compare = BP.compare
+  arr.copy = BP.copy
+  arr.slice = BP.slice
+  arr.readUInt8 = BP.readUInt8
+  arr.readUInt16LE = BP.readUInt16LE
+  arr.readUInt16BE = BP.readUInt16BE
+  arr.readUInt32LE = BP.readUInt32LE
+  arr.readUInt32BE = BP.readUInt32BE
+  arr.readInt8 = BP.readInt8
+  arr.readInt16LE = BP.readInt16LE
+  arr.readInt16BE = BP.readInt16BE
+  arr.readInt32LE = BP.readInt32LE
+  arr.readInt32BE = BP.readInt32BE
+  arr.readFloatLE = BP.readFloatLE
+  arr.readFloatBE = BP.readFloatBE
+  arr.readDoubleLE = BP.readDoubleLE
+  arr.readDoubleBE = BP.readDoubleBE
+  arr.writeUInt8 = BP.writeUInt8
+  arr.writeUInt16LE = BP.writeUInt16LE
+  arr.writeUInt16BE = BP.writeUInt16BE
+  arr.writeUInt32LE = BP.writeUInt32LE
+  arr.writeUInt32BE = BP.writeUInt32BE
+  arr.writeInt8 = BP.writeInt8
+  arr.writeInt16LE = BP.writeInt16LE
+  arr.writeInt16BE = BP.writeInt16BE
+  arr.writeInt32LE = BP.writeInt32LE
+  arr.writeInt32BE = BP.writeInt32BE
+  arr.writeFloatLE = BP.writeFloatLE
+  arr.writeFloatBE = BP.writeFloatBE
+  arr.writeDoubleLE = BP.writeDoubleLE
+  arr.writeDoubleBE = BP.writeDoubleBE
+  arr.fill = BP.fill
+  arr.inspect = BP.inspect
+  arr.toArrayBuffer = BP.toArrayBuffer
+
+  return arr
+}
+
+var INVALID_BASE64_RE = /[^+\/0-9A-z]/g
+
+function base64clean (str) {
+  // Node strips out invalid characters like \n and \t from the string, base64-js does not
+  str = stringtrim(str).replace(INVALID_BASE64_RE, '')
+  // Node allows for non-padded base64 strings (missing trailing ===), base64-js does not
+  while (str.length % 4 !== 0) {
+    str = str + '='
+  }
+  return str
+}
+
+function stringtrim (str) {
+  if (str.trim) return str.trim()
+  return str.replace(/^\s+|\s+$/g, '')
+}
+
+function isArrayish (subject) {
+  return isArray(subject) || Buffer.isBuffer(subject) ||
+      subject && typeof subject === 'object' &&
+      typeof subject.length === 'number'
+}
+
+function toHex (n) {
+  if (n < 16) return '0' + n.toString(16)
+  return n.toString(16)
+}
+
+function utf8ToBytes (str) {
+  var byteArray = []
+  for (var i = 0; i < str.length; i++) {
+    var b = str.charCodeAt(i)
+    if (b <= 0x7F) {
+      byteArray.push(b)
+    } else {
+      var start = i
+      if (b >= 0xD800 && b <= 0xDFFF) i++
+      var h = encodeURIComponent(str.slice(start, i+1)).substr(1).split('%')
+      for (var j = 0; j < h.length; j++) {
+        byteArray.push(parseInt(h[j], 16))
+      }
+    }
+  }
+  return byteArray
+}
+
+function asciiToBytes (str) {
+  var byteArray = []
+  for (var i = 0; i < str.length; i++) {
+    // Node's code seems to be doing this and not & 0x7F..
+    byteArray.push(str.charCodeAt(i) & 0xFF)
+  }
+  return byteArray
+}
+
+function utf16leToBytes (str) {
+  var c, hi, lo
+  var byteArray = []
+  for (var i = 0; i < str.length; i++) {
+    c = str.charCodeAt(i)
+    hi = c >> 8
+    lo = c % 256
+    byteArray.push(lo)
+    byteArray.push(hi)
+  }
+
+  return byteArray
+}
+
+function base64ToBytes (str) {
+  return base64.toByteArray(str)
+}
+
+function blitBuffer (src, dst, offset, length) {
+  for (var i = 0; i < length; i++) {
+    if ((i + offset >= dst.length) || (i >= src.length))
+      break
+    dst[i + offset] = src[i]
+  }
+  return i
+}
+
+function decodeUtf8Char (str) {
+  try {
+    return decodeURIComponent(str)
+  } catch (err) {
+    return String.fromCharCode(0xFFFD) // UTF 8 invalid char
+  }
+}
+
+},{"base64-js":4,"ieee754":5,"is-array":6}],4:[function(require,module,exports){
+var lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+;(function (exports) {
+	'use strict';
+
+  var Arr = (typeof Uint8Array !== 'undefined')
+    ? Uint8Array
+    : Array
+
+	var PLUS   = '+'.charCodeAt(0)
+	var SLASH  = '/'.charCodeAt(0)
+	var NUMBER = '0'.charCodeAt(0)
+	var LOWER  = 'a'.charCodeAt(0)
+	var UPPER  = 'A'.charCodeAt(0)
+
+	function decode (elt) {
+		var code = elt.charCodeAt(0)
+		if (code === PLUS)
+			return 62 // '+'
+		if (code === SLASH)
+			return 63 // '/'
+		if (code < NUMBER)
+			return -1 //no match
+		if (code < NUMBER + 10)
+			return code - NUMBER + 26 + 26
+		if (code < UPPER + 26)
+			return code - UPPER
+		if (code < LOWER + 26)
+			return code - LOWER + 26
+	}
+
+	function b64ToByteArray (b64) {
+		var i, j, l, tmp, placeHolders, arr
+
+		if (b64.length % 4 > 0) {
+			throw new Error('Invalid string. Length must be a multiple of 4')
+		}
+
+		// the number of equal signs (place holders)
+		// if there are two placeholders, than the two characters before it
+		// represent one byte
+		// if there is only one, then the three characters before it represent 2 bytes
+		// this is just a cheap hack to not do indexOf twice
+		var len = b64.length
+		placeHolders = '=' === b64.charAt(len - 2) ? 2 : '=' === b64.charAt(len - 1) ? 1 : 0
+
+		// base64 is 4/3 + up to two characters of the original data
+		arr = new Arr(b64.length * 3 / 4 - placeHolders)
+
+		// if there are placeholders, only get up to the last complete 4 chars
+		l = placeHolders > 0 ? b64.length - 4 : b64.length
+
+		var L = 0
+
+		function push (v) {
+			arr[L++] = v
+		}
+
+		for (i = 0, j = 0; i < l; i += 4, j += 3) {
+			tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3))
+			push((tmp & 0xFF0000) >> 16)
+			push((tmp & 0xFF00) >> 8)
+			push(tmp & 0xFF)
+		}
+
+		if (placeHolders === 2) {
+			tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4)
+			push(tmp & 0xFF)
+		} else if (placeHolders === 1) {
+			tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2)
+			push((tmp >> 8) & 0xFF)
+			push(tmp & 0xFF)
+		}
+
+		return arr
+	}
+
+	function uint8ToBase64 (uint8) {
+		var i,
+			extraBytes = uint8.length % 3, // if we have 1 byte left, pad 2 bytes
+			output = "",
+			temp, length
+
+		function encode (num) {
+			return lookup.charAt(num)
+		}
+
+		function tripletToBase64 (num) {
+			return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F)
+		}
+
+		// go through the array every three bytes, we'll deal with trailing stuff later
+		for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) {
+			temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2])
+			output += tripletToBase64(temp)
+		}
+
+		// pad the end with zeros, but make sure to not forget the extra bytes
+		switch (extraBytes) {
+			case 1:
+				temp = uint8[uint8.length - 1]
+				output += encode(temp >> 2)
+				output += encode((temp << 4) & 0x3F)
+				output += '=='
+				break
+			case 2:
+				temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1])
+				output += encode(temp >> 10)
+				output += encode((temp >> 4) & 0x3F)
+				output += encode((temp << 2) & 0x3F)
+				output += '='
+				break
+		}
+
+		return output
+	}
+
+	exports.toByteArray = b64ToByteArray
+	exports.fromByteArray = uint8ToBase64
+}(typeof exports === 'undefined' ? (this.base64js = {}) : exports))
+
+},{}],5:[function(require,module,exports){
+exports.read = function(buffer, offset, isLE, mLen, nBytes) {
+  var e, m,
+      eLen = nBytes * 8 - mLen - 1,
+      eMax = (1 << eLen) - 1,
+      eBias = eMax >> 1,
+      nBits = -7,
+      i = isLE ? (nBytes - 1) : 0,
+      d = isLE ? -1 : 1,
+      s = buffer[offset + i];
+
+  i += d;
+
+  e = s & ((1 << (-nBits)) - 1);
+  s >>= (-nBits);
+  nBits += eLen;
+  for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8);
+
+  m = e & ((1 << (-nBits)) - 1);
+  e >>= (-nBits);
+  nBits += mLen;
+  for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8);
+
+  if (e === 0) {
+    e = 1 - eBias;
+  } else if (e === eMax) {
+    return m ? NaN : ((s ? -1 : 1) * Infinity);
+  } else {
+    m = m + Math.pow(2, mLen);
+    e = e - eBias;
+  }
+  return (s ? -1 : 1) * m * Math.pow(2, e - mLen);
+};
+
+exports.write = function(buffer, value, offset, isLE, mLen, nBytes) {
+  var e, m, c,
+      eLen = nBytes * 8 - mLen - 1,
+      eMax = (1 << eLen) - 1,
+      eBias = eMax >> 1,
+      rt = (mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0),
+      i = isLE ? 0 : (nBytes - 1),
+      d = isLE ? 1 : -1,
+      s = value < 0 || (value === 0 && 1 / value < 0) ? 1 : 0;
+
+  value = Math.abs(value);
+
+  if (isNaN(value) || value === Infinity) {
+    m = isNaN(value) ? 1 : 0;
+    e = eMax;
+  } else {
+    e = Math.floor(Math.log(value) / Math.LN2);
+    if (value * (c = Math.pow(2, -e)) < 1) {
+      e--;
+      c *= 2;
+    }
+    if (e + eBias >= 1) {
+      value += rt / c;
+    } else {
+      value += rt * Math.pow(2, 1 - eBias);
+    }
+    if (value * c >= 2) {
+      e++;
+      c /= 2;
+    }
+
+    if (e + eBias >= eMax) {
+      m = 0;
+      e = eMax;
+    } else if (e + eBias >= 1) {
+      m = (value * c - 1) * Math.pow(2, mLen);
+      e = e + eBias;
+    } else {
+      m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen);
+      e = 0;
+    }
+  }
+
+  for (; mLen >= 8; buffer[offset + i] = m & 0xff, i += d, m /= 256, mLen -= 8);
+
+  e = (e << mLen) | m;
+  eLen += mLen;
+  for (; eLen > 0; buffer[offset + i] = e & 0xff, i += d, e /= 256, eLen -= 8);
+
+  buffer[offset + i - d] |= s * 128;
+};
+
+},{}],6:[function(require,module,exports){
+
+/**
+ * isArray
+ */
+
+var isArray = Array.isArray;
+
+/**
+ * toString
+ */
+
+var str = Object.prototype.toString;
+
+/**
+ * Whether or not the given `val`
+ * is an array.
+ *
+ * example:
+ *
+ *        isArray([]);
+ *        // > true
+ *        isArray(arguments);
+ *        // > false
+ *        isArray('');
+ *        // > false
+ *
+ * @param {mixed} val
+ * @return {bool}
+ */
+
+module.exports = isArray || function (val) {
+  return !! val && '[object Array]' == str.call(val);
+};
+
+},{}],7:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+function EventEmitter() {
+  this._events = this._events || {};
+  this._maxListeners = this._maxListeners || undefined;
+}
+module.exports = EventEmitter;
+
+// Backwards-compat with node 0.10.x
+EventEmitter.EventEmitter = EventEmitter;
+
+EventEmitter.prototype._events = undefined;
+EventEmitter.prototype._maxListeners = undefined;
+
+// By default EventEmitters will print a warning if more than 10 listeners are
+// added to it. This is a useful default which helps finding memory leaks.
+EventEmitter.defaultMaxListeners = 10;
+
+// Obviously not all Emitters should be limited to 10. This function allows
+// that to be increased. Set to zero for unlimited.
+EventEmitter.prototype.setMaxListeners = function(n) {
+  if (!isNumber(n) || n < 0 || isNaN(n))
+    throw TypeError('n must be a positive number');
+  this._maxListeners = n;
+  return this;
+};
+
+EventEmitter.prototype.emit = function(type) {
+  var er, handler, len, args, i, listeners;
+
+  if (!this._events)
+    this._events = {};
+
+  // If there is no 'error' event listener then throw.
+  if (type === 'error') {
+    if (!this._events.error ||
+        (isObject(this._events.error) && !this._events.error.length)) {
+      er = arguments[1];
+      if (er instanceof Error) {
+        throw er; // Unhandled 'error' event
+      }
+      throw TypeError('Uncaught, unspecified "error" event.');
+    }
+  }
+
+  handler = this._events[type];
+
+  if (isUndefined(handler))
+    return false;
+
+  if (isFunction(handler)) {
+    switch (arguments.length) {
+      // fast cases
+      case 1:
+        handler.call(this);
+        break;
+      case 2:
+        handler.call(this, arguments[1]);
+        break;
+      case 3:
+        handler.call(this, arguments[1], arguments[2]);
+        break;
+      // slower
+      default:
+        len = arguments.length;
+        args = new Array(len - 1);
+        for (i = 1; i < len; i++)
+          args[i - 1] = arguments[i];
+        handler.apply(this, args);
+    }
+  } else if (isObject(handler)) {
+    len = arguments.length;
+    args = new Array(len - 1);
+    for (i = 1; i < len; i++)
+      args[i - 1] = arguments[i];
+
+    listeners = handler.slice();
+    len = listeners.length;
+    for (i = 0; i < len; i++)
+      listeners[i].apply(this, args);
+  }
+
+  return true;
+};
+
+EventEmitter.prototype.addListener = function(type, listener) {
+  var m;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events)
+    this._events = {};
+
+  // To avoid recursion in the case that type === "newListener"! Before
+  // adding it to the listeners, first emit "newListener".
+  if (this._events.newListener)
+    this.emit('newListener', type,
+              isFunction(listener.listener) ?
+              listener.listener : listener);
+
+  if (!this._events[type])
+    // Optimize the case of one listener. Don't need the extra array object.
+    this._events[type] = listener;
+  else if (isObject(this._events[type]))
+    // If we've already got an array, just append.
+    this._events[type].push(listener);
+  else
+    // Adding the second element, need to change to array.
+    this._events[type] = [this._events[type], listener];
+
+  // Check for listener leak
+  if (isObject(this._events[type]) && !this._events[type].warned) {
+    var m;
+    if (!isUndefined(this._maxListeners)) {
+      m = this._maxListeners;
+    } else {
+      m = EventEmitter.defaultMaxListeners;
+    }
+
+    if (m && m > 0 && this._events[type].length > m) {
+      this._events[type].warned = true;
+      console.error('(node) warning: possible EventEmitter memory ' +
+                    'leak detected. %d listeners added. ' +
+                    'Use emitter.setMaxListeners() to increase limit.',
+                    this._events[type].length);
+      if (typeof console.trace === 'function') {
+        // not supported in IE 10
+        console.trace();
+      }
+    }
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+
+EventEmitter.prototype.once = function(type, listener) {
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  var fired = false;
+
+  function g() {
+    this.removeListener(type, g);
+
+    if (!fired) {
+      fired = true;
+      listener.apply(this, arguments);
+    }
+  }
+
+  g.listener = listener;
+  this.on(type, g);
+
+  return this;
+};
+
+// emits a 'removeListener' event iff the listener was removed
+EventEmitter.prototype.removeListener = function(type, listener) {
+  var list, position, length, i;
+
+  if (!isFunction(listener))
+    throw TypeError('listener must be a function');
+
+  if (!this._events || !this._events[type])
+    return this;
+
+  list = this._events[type];
+  length = list.length;
+  position = -1;
+
+  if (list === listener ||
+      (isFunction(list.listener) && list.listener === listener)) {
+    delete this._events[type];
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+
+  } else if (isObject(list)) {
+    for (i = length; i-- > 0;) {
+      if (list[i] === listener ||
+          (list[i].listener && list[i].listener === listener)) {
+        position = i;
+        break;
+      }
+    }
+
+    if (position < 0)
+      return this;
+
+    if (list.length === 1) {
+      list.length = 0;
+      delete this._events[type];
+    } else {
+      list.splice(position, 1);
+    }
+
+    if (this._events.removeListener)
+      this.emit('removeListener', type, listener);
+  }
+
+  return this;
+};
+
+EventEmitter.prototype.removeAllListeners = function(type) {
+  var key, listeners;
+
+  if (!this._events)
+    return this;
+
+  // not listening for removeListener, no need to emit
+  if (!this._events.removeListener) {
+    if (arguments.length === 0)
+      this._events = {};
+    else if (this._events[type])
+      delete this._events[type];
+    return this;
+  }
+
+  // emit removeListener for all listeners on all events
+  if (arguments.length === 0) {
+    for (key in this._events) {
+      if (key === 'removeListener') continue;
+      this.removeAllListeners(key);
+    }
+    this.removeAllListeners('removeListener');
+    this._events = {};
+    return this;
+  }
+
+  listeners = this._events[type];
+
+  if (isFunction(listeners)) {
+    this.removeListener(type, listeners);
+  } else {
+    // LIFO order
+    while (listeners.length)
+      this.removeListener(type, listeners[listeners.length - 1]);
+  }
+  delete this._events[type];
+
+  return this;
+};
+
+EventEmitter.prototype.listeners = function(type) {
+  var ret;
+  if (!this._events || !this._events[type])
+    ret = [];
+  else if (isFunction(this._events[type]))
+    ret = [this._events[type]];
+  else
+    ret = this._events[type].slice();
+  return ret;
+};
+
+EventEmitter.listenerCount = function(emitter, type) {
+  var ret;
+  if (!emitter._events || !emitter._events[type])
+    ret = 0;
+  else if (isFunction(emitter._events[type]))
+    ret = 1;
+  else
+    ret = emitter._events[type].length;
+  return ret;
+};
+
+function isFunction(arg) {
+  return typeof arg === 'function';
+}
+
+function isNumber(arg) {
+  return typeof arg === 'number';
+}
+
+function isObject(arg) {
+  return typeof arg === 'object' && arg !== null;
+}
+
+function isUndefined(arg) {
+  return arg === void 0;
+}
+
+},{}],8:[function(require,module,exports){
+if (typeof Object.create === 'function') {
+  // implementation from standard node.js 'util' module
+  module.exports = function inherits(ctor, superCtor) {
+    ctor.super_ = superCtor
+    ctor.prototype = Object.create(superCtor.prototype, {
+      constructor: {
+        value: ctor,
+        enumerable: false,
+        writable: true,
+        configurable: true
+      }
+    });
+  };
+} else {
+  // old school shim for old browsers
+  module.exports = function inherits(ctor, superCtor) {
+    ctor.super_ = superCtor
+    var TempCtor = function () {}
+    TempCtor.prototype = superCtor.prototype
+    ctor.prototype = new TempCtor()
+    ctor.prototype.constructor = ctor
+  }
+}
+
+},{}],9:[function(require,module,exports){
+module.exports = Array.isArray || function (arr) {
+  return Object.prototype.toString.call(arr) == '[object Array]';
+};
+
+},{}],10:[function(require,module,exports){
+// shim for using process in browser
+
+var process = module.exports = {};
+
+process.nextTick = (function () {
+    var canSetImmediate = typeof window !== 'undefined'
+    && window.setImmediate;
+    var canMutationObserver = typeof window !== 'undefined'
+    && window.MutationObserver;
+    var canPost = typeof window !== 'undefined'
+    && window.postMessage && window.addEventListener
+    ;
+
+    if (canSetImmediate) {
+        return function (f) { return window.setImmediate(f) };
+    }
+
+    var queue = [];
+
+    if (canMutationObserver) {
+        var hiddenDiv = document.createElement("div");
+        var observer = new MutationObserver(function () {
+            var queueList = queue.slice();
+            queue.length = 0;
+            queueList.forEach(function (fn) {
+                fn();
+            });
+        });
+
+        observer.observe(hiddenDiv, { attributes: true });
+
+        return function nextTick(fn) {
+            if (!queue.length) {
+                hiddenDiv.setAttribute('yes', 'no');
+            }
+            queue.push(fn);
+        };
+    }
+
+    if (canPost) {
+        window.addEventListener('message', function (ev) {
+            var source = ev.source;
+            if ((source === window || source === null) && ev.data === 'process-tick') {
+                ev.stopPropagation();
+                if (queue.length > 0) {
+                    var fn = queue.shift();
+                    fn();
+                }
+            }
+        }, true);
+
+        return function nextTick(fn) {
+            queue.push(fn);
+            window.postMessage('process-tick', '*');
+        };
+    }
+
+    return function nextTick(fn) {
+        setTimeout(fn, 0);
+    };
+})();
+
+process.title = 'browser';
+process.browser = true;
+process.env = {};
+process.argv = [];
+
+function noop() {}
+
+process.on = noop;
+process.addListener = noop;
+process.once = noop;
+process.off = noop;
+process.removeListener = noop;
+process.removeAllListeners = noop;
+process.emit = noop;
+
+process.binding = function (name) {
+    throw new Error('process.binding is not supported');
+};
+
+// TODO(shtylman)
+process.cwd = function () { return '/' };
+process.chdir = function (dir) {
+    throw new Error('process.chdir is not supported');
+};
+
+},{}],11:[function(require,module,exports){
+module.exports = require("./lib/_stream_duplex.js")
+
+},{"./lib/_stream_duplex.js":12}],12:[function(require,module,exports){
+(function (process){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+// a duplex stream is just a stream that is both readable and writable.
+// Since JS doesn't have multiple prototypal inheritance, this class
+// prototypally inherits from Readable, and then parasitically from
+// Writable.
+
+module.exports = Duplex;
+
+/*<replacement>*/
+var objectKeys = Object.keys || function (obj) {
+  var keys = [];
+  for (var key in obj) keys.push(key);
+  return keys;
+}
+/*</replacement>*/
+
+
+/*<replacement>*/
+var util = require('core-util-is');
+util.inherits = require('inherits');
+/*</replacement>*/
+
+var Readable = require('./_stream_readable');
+var Writable = require('./_stream_writable');
+
+util.inherits(Duplex, Readable);
+
+forEach(objectKeys(Writable.prototype), function(method) {
+  if (!Duplex.prototype[method])
+    Duplex.prototype[method] = Writable.prototype[method];
+});
+
+function Duplex(options) {
+  if (!(this instanceof Duplex))
+    return new Duplex(options);
+
+  Readable.call(this, options);
+  Writable.call(this, options);
+
+  if (options && options.readable === false)
+    this.readable = false;
+
+  if (options && options.writable === false)
+    this.writable = false;
+
+  this.allowHalfOpen = true;
+  if (options && options.allowHalfOpen === false)
+    this.allowHalfOpen = false;
+
+  this.once('end', onend);
+}
+
+// the no-half-open enforcer
+function onend() {
+  // if we allow half-open state, or if the writable side ended,
+  // then we're ok.
+  if (this.allowHalfOpen || this._writableState.ended)
+    return;
+
+  // no more data can be written.
+  // But allow more writes to happen in this tick.
+  process.nextTick(this.end.bind(this));
+}
+
+function forEach (xs, f) {
+  for (var i = 0, l = xs.length; i < l; i++) {
+    f(xs[i], i);
+  }
+}
+
+}).call(this,require('_process'))
+},{"./_stream_readable":14,"./_stream_writable":16,"_process":10,"core-util-is":17,"inherits":8}],13:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+// a passthrough stream.
+// basically just the most minimal sort of Transform stream.
+// Every written chunk gets output as-is.
+
+module.exports = PassThrough;
+
+var Transform = require('./_stream_transform');
+
+/*<replacement>*/
+var util = require('core-util-is');
+util.inherits = require('inherits');
+/*</replacement>*/
+
+util.inherits(PassThrough, Transform);
+
+function PassThrough(options) {
+  if (!(this instanceof PassThrough))
+    return new PassThrough(options);
+
+  Transform.call(this, options);
+}
+
+PassThrough.prototype._transform = function(chunk, encoding, cb) {
+  cb(null, chunk);
+};
+
+},{"./_stream_transform":15,"core-util-is":17,"inherits":8}],14:[function(require,module,exports){
+(function (process){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+module.exports = Readable;
+
+/*<replacement>*/
+var isArray = require('isarray');
+/*</replacement>*/
+
+
+/*<replacement>*/
+var Buffer = require('buffer').Buffer;
+/*</replacement>*/
+
+Readable.ReadableState = ReadableState;
+
+var EE = require('events').EventEmitter;
+
+/*<replacement>*/
+if (!EE.listenerCount) EE.listenerCount = function(emitter, type) {
+  return emitter.listeners(type).length;
+};
+/*</replacement>*/
+
+var Stream = require('stream');
+
+/*<replacement>*/
+var util = require('core-util-is');
+util.inherits = require('inherits');
+/*</replacement>*/
+
+var StringDecoder;
+
+util.inherits(Readable, Stream);
+
+function ReadableState(options, stream) {
+  options = options || {};
+
+  // the point at which it stops calling _read() to fill the buffer
+  // Note: 0 is a valid value, means "don't call _read preemptively ever"
+  var hwm = options.highWaterMark;
+  this.highWaterMark = (hwm || hwm === 0) ? hwm : 16 * 1024;
+
+  // cast to ints.
+  this.highWaterMark = ~~this.highWaterMark;
+
+  this.buffer = [];
+  this.length = 0;
+  this.pipes = null;
+  this.pipesCount = 0;
+  this.flowing = false;
+  this.ended = false;
+  this.endEmitted = false;
+  this.reading = false;
+
+  // In streams that never have any data, and do push(null) right away,
+  // the consumer can miss the 'end' event if they do some I/O before
+  // consuming the stream.  So, we don't emit('end') until some reading
+  // happens.
+  this.calledRead = false;
+
+  // a flag to be able to tell if the onwrite cb is called immediately,
+  // or on a later tick.  We set this to true at first, becuase any
+  // actions that shouldn't happen until "later" should generally also
+  // not happen before the first write call.
+  this.sync = true;
+
+  // whenever we return null, then we set a flag to say
+  // that we're awaiting a 'readable' event emission.
+  this.needReadable = false;
+  this.emittedReadable = false;
+  this.readableListening = false;
+
+
+  // object stream flag. Used to make read(n) ignore n and to
+  // make all the buffer merging and length checks go away
+  this.objectMode = !!options.objectMode;
+
+  // Crypto is kind of old and crusty.  Historically, its default string
+  // encoding is 'binary' so we have to make this configurable.
+  // Everything else in the universe uses 'utf8', though.
+  this.defaultEncoding = options.defaultEncoding || 'utf8';
+
+  // when piping, we only care about 'readable' events that happen
+  // after read()ing all the bytes and not getting any pushback.
+  this.ranOut = false;
+
+  // the number of writers that are awaiting a drain event in .pipe()s
+  this.awaitDrain = 0;
+
+  // if true, a maybeReadMore has been scheduled
+  this.readingMore = false;
+
+  this.decoder = null;
+  this.encoding = null;
+  if (options.encoding) {
+    if (!StringDecoder)
+      StringDecoder = require('string_decoder/').StringDecoder;
+    this.decoder = new StringDecoder(options.encoding);
+    this.encoding = options.encoding;
+  }
+}
+
+function Readable(options) {
+  if (!(this instanceof Readable))
+    return new Readable(options);
+
+  this._readableState = new ReadableState(options, this);
+
+  // legacy
+  this.readable = true;
+
+  Stream.call(this);
+}
+
+// Manually shove something into the read() buffer.
+// This returns true if the highWaterMark has not been hit yet,
+// similar to how Writable.write() returns true if you should
+// write() some more.
+Readable.prototype.push = function(chunk, encoding) {
+  var state = this._readableState;
+
+  if (typeof chunk === 'string' && !state.objectMode) {
+    encoding = encoding || state.defaultEncoding;
+    if (encoding !== state.encoding) {
+      chunk = new Buffer(chunk, encoding);
+      encoding = '';
+    }
+  }
+
+  return readableAddChunk(this, state, chunk, encoding, false);
+};
+
+// Unshift should *always* be something directly out of read()
+Readable.prototype.unshift = function(chunk) {
+  var state = this._readableState;
+  return readableAddChunk(this, state, chunk, '', true);
+};
+
+function readableAddChunk(stream, state, chunk, encoding, addToFront) {
+  var er = chunkInvalid(state, chunk);
+  if (er) {
+    stream.emit('error', er);
+  } else if (chunk === null || chunk === undefined) {
+    state.reading = false;
+    if (!state.ended)
+      onEofChunk(stream, state);
+  } else if (state.objectMode || chunk && chunk.length > 0) {
+    if (state.ended && !addToFront) {
+      var e = new Error('stream.push() after EOF');
+      stream.emit('error', e);
+    } else if (state.endEmitted && addToFront) {
+      var e = new Error('stream.unshift() after end event');
+      stream.emit('error', e);
+    } else {
+      if (state.decoder && !addToFront && !encoding)
+        chunk = state.decoder.write(chunk);
+
+      // update the buffer info.
+      state.length += state.objectMode ? 1 : chunk.length;
+      if (addToFront) {
+        state.buffer.unshift(chunk);
+      } else {
+        state.reading = false;
+        state.buffer.push(chunk);
+      }
+
+      if (state.needReadable)
+        emitReadable(stream);
+
+      maybeReadMore(stream, state);
+    }
+  } else if (!addToFront) {
+    state.reading = false;
+  }
+
+  return needMoreData(state);
+}
+
+
+
+// if it's past the high water mark, we can push in some more.
+// Also, if we have no data yet, we can stand some
+// more bytes.  This is to work around cases where hwm=0,
+// such as the repl.  Also, if the push() triggered a
+// readable event, and the user called read(largeNumber) such that
+// needReadable was set, then we ought to push more, so that another
+// 'readable' event will be triggered.
+function needMoreData(state) {
+  return !state.ended &&
+         (state.needReadable ||
+          state.length < state.highWaterMark ||
+          state.length === 0);
+}
+
+// backwards compatibility.
+Readable.prototype.setEncoding = function(enc) {
+  if (!StringDecoder)
+    StringDecoder = require('string_decoder/').StringDecoder;
+  this._readableState.decoder = new StringDecoder(enc);
+  this._readableState.encoding = enc;
+};
+
+// Don't raise the hwm > 128MB
+var MAX_HWM = 0x800000;
+function roundUpToNextPowerOf2(n) {
+  if (n >= MAX_HWM) {
+    n = MAX_HWM;
+  } else {
+    // Get the next highest power of 2
+    n--;
+    for (var p = 1; p < 32; p <<= 1) n |= n >> p;
+    n++;
+  }
+  return n;
+}
+
+function howMuchToRead(n, state) {
+  if (state.length === 0 && state.ended)
+    return 0;
+
+  if (state.objectMode)
+    return n === 0 ? 0 : 1;
+
+  if (n === null || isNaN(n)) {
+    // only flow one buffer at a time
+    if (state.flowing && state.buffer.length)
+      return state.buffer[0].length;
+    else
+      return state.length;
+  }
+
+  if (n <= 0)
+    return 0;
+
+  // If we're asking for more than the target buffer level,
+  // then raise the water mark.  Bump up to the next highest
+  // power of 2, to prevent increasing it excessively in tiny
+  // amounts.
+  if (n > state.highWaterMark)
+    state.highWaterMark = roundUpToNextPowerOf2(n);
+
+  // don't have that much.  return null, unless we've ended.
+  if (n > state.length) {
+    if (!state.ended) {
+      state.needReadable = true;
+      return 0;
+    } else
+      return state.length;
+  }
+
+  return n;
+}
+
+// you can override either this method, or the async _read(n) below.
+Readable.prototype.read = function(n) {
+  var state = this._readableState;
+  state.calledRead = true;
+  var nOrig = n;
+  var ret;
+
+  if (typeof n !== 'number' || n > 0)
+    state.emittedReadable = false;
+
+  // if we're doing read(0) to trigger a readable event, but we
+  // already have a bunch of data in the buffer, then just trigger
+  // the 'readable' event and move on.
+  if (n === 0 &&
+      state.needReadable &&
+      (state.length >= state.highWaterMark || state.ended)) {
+    emitReadable(this);
+    return null;
+  }
+
+  n = howMuchToRead(n, state);
+
+  // if we've ended, and we're now clear, then finish it up.
+  if (n === 0 && state.ended) {
+    ret = null;
+
+    // In cases where the decoder did not receive enough data
+    // to produce a full chunk, then immediately received an
+    // EOF, state.buffer will contain [<Buffer >, <Buffer 00 ...>].
+    // howMuchToRead will see this and coerce the amount to
+    // read to zero (because it's looking at the length of the
+    // first <Buffer > in state.buffer), and we'll end up here.
+    //
+    // This can only happen via state.decoder -- no other venue
+    // exists for pushing a zero-length chunk into state.buffer
+    // and triggering this behavior. In this case, we return our
+    // remaining data and end the stream, if appropriate.
+    if (state.length > 0 && state.decoder) {
+      ret = fromList(n, state);
+      state.length -= ret.length;
+    }
+
+    if (state.length === 0)
+      endReadable(this);
+
+    return ret;
+  }
+
+  // All the actual chunk generation logic needs to be
+  // *below* the call to _read.  The reason is that in certain
+  // synthetic stream cases, such as passthrough streams, _read
+  // may be a completely synchronous operation which may change
+  // the state of the read buffer, providing enough data when
+  // before there was *not* enough.
+  //
+  // So, the steps are:
+  // 1. Figure out what the state of things will be after we do
+  // a read from the buffer.
+  //
+  // 2. If that resulting state will trigger a _read, then call _read.
+  // Note that this may be asynchronous, or synchronous.  Yes, it is
+  // deeply ugly to write APIs this way, but that still doesn't mean
+  // that the Readable class should behave improperly, as streams are
+  // designed to be sync/async agnostic.
+  // Take note if the _read call is sync or async (ie, if the read call
+  // has returned yet), so that we know whether or not it's safe to emit
+  // 'readable' etc.
+  //
+  // 3. Actually pull the requested chunks out of the buffer and return.
+
+  // if we need a readable event, then we need to do some reading.
+  var doRead = state.needReadable;
+
+  // if we currently have less than the highWaterMark, then also read some
+  if (state.length - n <= state.highWaterMark)
+    doRead = true;
+
+  // however, if we've ended, then there's no point, and if we're already
+  // reading, then it's unnecessary.
+  if (state.ended || state.reading)
+    doRead = false;
+
+  if (doRead) {
+    state.reading = true;
+    state.sync = true;
+    // if the length is currently zero, then we *need* a readable event.
+    if (state.length === 0)
+      state.needReadable = true;
+    // call internal read method
+    this._read(state.highWaterMark);
+    state.sync = false;
+  }
+
+  // If _read called its callback synchronously, then `reading`
+  // will be false, and we need to re-evaluate how much data we
+  // can return to the user.
+  if (doRead && !state.reading)
+    n = howMuchToRead(nOrig, state);
+
+  if (n > 0)
+    ret = fromList(n, state);
+  else
+    ret = null;
+
+  if (ret === null) {
+    state.needReadable = true;
+    n = 0;
+  }
+
+  state.length -= n;
+
+  // If we have nothing in the buffer, then we want to know
+  // as soon as we *do* get something into the buffer.
+  if (state.length === 0 && !state.ended)
+    state.needReadable = true;
+
+  // If we happened to read() exactly the remaining amount in the
+  // buffer, and the EOF has been seen at this point, then make sure
+  // that we emit 'end' on the very next tick.
+  if (state.ended && !state.endEmitted && state.length === 0)
+    endReadable(this);
+
+  return ret;
+};
+
+function chunkInvalid(state, chunk) {
+  var er = null;
+  if (!Buffer.isBuffer(chunk) &&
+      'string' !== typeof chunk &&
+      chunk !== null &&
+      chunk !== undefined &&
+      !state.objectMode) {
+    er = new TypeError('Invalid non-string/buffer chunk');
+  }
+  return er;
+}
+
+
+function onEofChunk(stream, state) {
+  if (state.decoder && !state.ended) {
+    var chunk = state.decoder.end();
+    if (chunk && chunk.length) {
+      state.buffer.push(chunk);
+      state.length += state.objectMode ? 1 : chunk.length;
+    }
+  }
+  state.ended = true;
+
+  // if we've ended and we have some data left, then emit
+  // 'readable' now to make sure it gets picked up.
+  if (state.length > 0)
+    emitReadable(stream);
+  else
+    endReadable(stream);
+}
+
+// Don't emit readable right away in sync mode, because this can trigger
+// another read() call => stack overflow.  This way, it might trigger
+// a nextTick recursion warning, but that's not so bad.
+function emitReadable(stream) {
+  var state = stream._readableState;
+  state.needReadable = false;
+  if (state.emittedReadable)
+    return;
+
+  state.emittedReadable = true;
+  if (state.sync)
+    process.nextTick(function() {
+      emitReadable_(stream);
+    });
+  else
+    emitReadable_(stream);
+}
+
+function emitReadable_(stream) {
+  stream.emit('readable');
+}
+
+
+// at this point, the user has presumably seen the 'readable' event,
+// and called read() to consume some data.  that may have triggered
+// in turn another _read(n) call, in which case reading = true if
+// it's in progress.
+// However, if we're not ended, or reading, and the length < hwm,
+// then go ahead and try to read some more preemptively.
+function maybeReadMore(stream, state) {
+  if (!state.readingMore) {
+    state.readingMore = true;
+    process.nextTick(function() {
+      maybeReadMore_(stream, state);
+    });
+  }
+}
+
+function maybeReadMore_(stream, state) {
+  var len = state.length;
+  while (!state.reading && !state.flowing && !state.ended &&
+         state.length < state.highWaterMark) {
+    stream.read(0);
+    if (len === state.length)
+      // didn't get any data, stop spinning.
+      break;
+    else
+      len = state.length;
+  }
+  state.readingMore = false;
+}
+
+// abstract method.  to be overridden in specific implementation classes.
+// call cb(er, data) where data is <= n in length.
+// for virtual (non-string, non-buffer) streams, "length" is somewhat
+// arbitrary, and perhaps not very meaningful.
+Readable.prototype._read = function(n) {
+  this.emit('error', new Error('not implemented'));
+};
+
+Readable.prototype.pipe = function(dest, pipeOpts) {
+  var src = this;
+  var state = this._readableState;
+
+  switch (state.pipesCount) {
+    case 0:
+      state.pipes = dest;
+      break;
+    case 1:
+      state.pipes = [state.pipes, dest];
+      break;
+    default:
+      state.pipes.push(dest);
+      break;
+  }
+  state.pipesCount += 1;
+
+  var doEnd = (!pipeOpts || pipeOpts.end !== false) &&
+              dest !== process.stdout &&
+              dest !== process.stderr;
+
+  var endFn = doEnd ? onend : cleanup;
+  if (state.endEmitted)
+    process.nextTick(endFn);
+  else
+    src.once('end', endFn);
+
+  dest.on('unpipe', onunpipe);
+  function onunpipe(readable) {
+    if (readable !== src) return;
+    cleanup();
+  }
+
+  function onend() {
+    dest.end();
+  }
+
+  // when the dest drains, it reduces the awaitDrain counter
+  // on the source.  This would be more elegant with a .once()
+  // handler in flow(), but adding and removing repeatedly is
+  // too slow.
+  var ondrain = pipeOnDrain(src);
+  dest.on('drain', ondrain);
+
+  function cleanup() {
+    // cleanup event handlers once the pipe is broken
+    dest.removeListener('close', onclose);
+    dest.removeListener('finish', onfinish);
+    dest.removeListener('drain', ondrain);
+    dest.removeListener('error', onerror);
+    dest.removeListener('unpipe', onunpipe);
+    src.removeListener('end', onend);
+    src.removeListener('end', cleanup);
+
+    // if the reader is waiting for a drain event from this
+    // specific writer, then it would cause it to never start
+    // flowing again.
+    // So, if this is awaiting a drain, then we just call it now.
+    // If we don't know, then assume that we are waiting for one.
+    if (!dest._writableState || dest._writableState.needDrain)
+      ondrain();
+  }
+
+  // if the dest has an error, then stop piping into it.
+  // however, don't suppress the throwing behavior for this.
+  function onerror(er) {
+    unpipe();
+    dest.removeListener('error', onerror);
+    if (EE.listenerCount(dest, 'error') === 0)
+      dest.emit('error', er);
+  }
+  // This is a brutally ugly hack to make sure that our error handler
+  // is attached before any userland ones.  NEVER DO THIS.
+  if (!dest._events || !dest._events.error)
+    dest.on('error', onerror);
+  else if (isArray(dest._events.error))
+    dest._events.error.unshift(onerror);
+  else
+    dest._events.error = [onerror, dest._events.error];
+
+
+
+  // Both close and finish should trigger unpipe, but only once.
+  function onclose() {
+    dest.removeListener('finish', onfinish);
+    unpipe();
+  }
+  dest.once('close', onclose);
+  function onfinish() {
+    dest.removeListener('close', onclose);
+    unpipe();
+  }
+  dest.once('finish', onfinish);
+
+  function unpipe() {
+    src.unpipe(dest);
+  }
+
+  // tell the dest that it's being piped to
+  dest.emit('pipe', src);
+
+  // start the flow if it hasn't been started already.
+  if (!state.flowing) {
+    // the handler that waits for readable events after all
+    // the data gets sucked out in flow.
+    // This would be easier to follow with a .once() handler
+    // in flow(), but that is too slow.
+    this.on('readable', pipeOnReadable);
+
+    state.flowing = true;
+    process.nextTick(function() {
+      flow(src);
+    });
+  }
+
+  return dest;
+};
+
+function pipeOnDrain(src) {
+  return function() {
+    var dest = this;
+    var state = src._readableState;
+    state.awaitDrain--;
+    if (state.awaitDrain === 0)
+      flow(src);
+  };
+}
+
+function flow(src) {
+  var state = src._readableState;
+  var chunk;
+  state.awaitDrain = 0;
+
+  function write(dest, i, list) {
+    var written = dest.write(chunk);
+    if (false === written) {
+      state.awaitDrain++;
+    }
+  }
+
+  while (state.pipesCount && null !== (chunk = src.read())) {
+
+    if (state.pipesCount === 1)
+      write(state.pipes, 0, null);
+    else
+      forEach(state.pipes, write);
+
+    src.emit('data', chunk);
+
+    // if anyone needs a drain, then we have to wait for that.
+    if (state.awaitDrain > 0)
+      return;
+  }
+
+  // if every destination was unpiped, either before entering this
+  // function, or in the while loop, then stop flowing.
+  //
+  // NB: This is a pretty rare edge case.
+  if (state.pipesCount === 0) {
+    state.flowing = false;
+
+    // if there were data event listeners added, then switch to old mode.
+    if (EE.listenerCount(src, 'data') > 0)
+      emitDataEvents(src);
+    return;
+  }
+
+  // at this point, no one needed a drain, so we just ran out of data
+  // on the next readable event, start it over again.
+  state.ranOut = true;
+}
+
+function pipeOnReadable() {
+  if (this._readableState.ranOut) {
+    this._readableState.ranOut = false;
+    flow(this);
+  }
+}
+
+
+Readable.prototype.unpipe = function(dest) {
+  var state = this._readableState;
+
+  // if we're not piping anywhere, then do nothing.
+  if (state.pipesCount === 0)
+    return this;
+
+  // just one destination.  most common case.
+  if (state.pipesCount === 1) {
+    // passed in one, but it's not the right one.
+    if (dest && dest !== state.pipes)
+      return this;
+
+    if (!dest)
+      dest = state.pipes;
+
+    // got a match.
+    state.pipes = null;
+    state.pipesCount = 0;
+    this.removeListener('readable', pipeOnReadable);
+    state.flowing = false;
+    if (dest)
+      dest.emit('unpipe', this);
+    return this;
+  }
+
+  // slow case. multiple pipe destinations.
+
+  if (!dest) {
+    // remove all.
+    var dests = state.pipes;
+    var len = state.pipesCount;
+    state.pipes = null;
+    state.pipesCount = 0;
+    this.removeListener('readable', pipeOnReadable);
+    state.flowing = false;
+
+    for (var i = 0; i < len; i++)
+      dests[i].emit('unpipe', this);
+    return this;
+  }
+
+  // try to find the right one.
+  var i = indexOf(state.pipes, dest);
+  if (i === -1)
+    return this;
+
+  state.pipes.splice(i, 1);
+  state.pipesCount -= 1;
+  if (state.pipesCount === 1)
+    state.pipes = state.pipes[0];
+
+  dest.emit('unpipe', this);
+
+  return this;
+};
+
+// set up data events if they are asked for
+// Ensure readable listeners eventually get something
+Readable.prototype.on = function(ev, fn) {
+  var res = Stream.prototype.on.call(this, ev, fn);
+
+  if (ev === 'data' && !this._readableState.flowing)
+    emitDataEvents(this);
+
+  if (ev === 'readable' && this.readable) {
+    var state = this._readableState;
+    if (!state.readableListening) {
+      state.readableListening = true;
+      state.emittedReadable = false;
+      state.needReadable = true;
+      if (!state.reading) {
+        this.read(0);
+      } else if (state.length) {
+        emitReadable(this, state);
+      }
+    }
+  }
+
+  return res;
+};
+Readable.prototype.addListener = Readable.prototype.on;
+
+// pause() and resume() are remnants of the legacy readable stream API
+// If the user uses them, then switch into old mode.
+Readable.prototype.resume = function() {
+  emitDataEvents(this);
+  this.read(0);
+  this.emit('resume');
+};
+
+Readable.prototype.pause = function() {
+  emitDataEvents(this, true);
+  this.emit('pause');
+};
+
+function emitDataEvents(stream, startPaused) {
+  var state = stream._readableState;
+
+  if (state.flowing) {
+    // https://github.com/isaacs/readable-stream/issues/16
+    throw new Error('Cannot switch to old mode now.');
+  }
+
+  var paused = startPaused || false;
+  var readable = false;
+
+  // convert to an old-style stream.
+  stream.readable = true;
+  stream.pipe = Stream.prototype.pipe;
+  stream.on = stream.addListener = Stream.prototype.on;
+
+  stream.on('readable', function() {
+    readable = true;
+
+    var c;
+    while (!paused && (null !== (c = stream.read())))
+      stream.emit('data', c);
+
+    if (c === null) {
+      readable = false;
+      stream._readableState.needReadable = true;
+    }
+  });
+
+  stream.pause = function() {
+    paused = true;
+    this.emit('pause');
+  };
+
+  stream.resume = function() {
+    paused = false;
+    if (readable)
+      process.nextTick(function() {
+        stream.emit('readable');
+      });
+    else
+      this.read(0);
+    this.emit('resume');
+  };
+
+  // now make it start, just in case it hadn't already.
+  stream.emit('readable');
+}
+
+// wrap an old-style stream as the async data source.
+// This is *not* part of the readable stream interface.
+// It is an ugly unfortunate mess of history.
+Readable.prototype.wrap = function(stream) {
+  var state = this._readableState;
+  var paused = false;
+
+  var self = this;
+  stream.on('end', function() {
+    if (state.decoder && !state.ended) {
+      var chunk = state.decoder.end();
+      if (chunk && chunk.length)
+        self.push(chunk);
+    }
+
+    self.push(null);
+  });
+
+  stream.on('data', function(chunk) {
+    if (state.decoder)
+      chunk = state.decoder.write(chunk);
+
+    // don't skip over falsy values in objectMode
+    //if (state.objectMode && util.isNullOrUndefined(chunk))
+    if (state.objectMode && (chunk === null || chunk === undefined))
+      return;
+    else if (!state.objectMode && (!chunk || !chunk.length))
+      return;
+
+    var ret = self.push(chunk);
+    if (!ret) {
+      paused = true;
+      stream.pause();
+    }
+  });
+
+  // proxy all the other methods.
+  // important when wrapping filters and duplexes.
+  for (var i in stream) {
+    if (typeof stream[i] === 'function' &&
+        typeof this[i] === 'undefined') {
+      this[i] = function(method) { return function() {
+        return stream[method].apply(stream, arguments);
+      }}(i);
+    }
+  }
+
+  // proxy certain important events.
+  var events = ['error', 'close', 'destroy', 'pause', 'resume'];
+  forEach(events, function(ev) {
+    stream.on(ev, self.emit.bind(self, ev));
+  });
+
+  // when we try to consume some more bytes, simply unpause the
+  // underlying stream.
+  self._read = function(n) {
+    if (paused) {
+      paused = false;
+      stream.resume();
+    }
+  };
+
+  return self;
+};
+
+
+
+// exposed for testing purposes only.
+Readable._fromList = fromList;
+
+// Pluck off n bytes from an array of buffers.
+// Length is the combined lengths of all the buffers in the list.
+function fromList(n, state) {
+  var list = state.buffer;
+  var length = state.length;
+  var stringMode = !!state.decoder;
+  var objectMode = !!state.objectMode;
+  var ret;
+
+  // nothing in the list, definitely empty.
+  if (list.length === 0)
+    return null;
+
+  if (length === 0)
+    ret = null;
+  else if (objectMode)
+    ret = list.shift();
+  else if (!n || n >= length) {
+    // read it all, truncate the array.
+    if (stringMode)
+      ret = list.join('');
+    else
+      ret = Buffer.concat(list, length);
+    list.length = 0;
+  } else {
+    // read just some of it.
+    if (n < list[0].length) {
+      // just take a part of the first list item.
+      // slice is the same for buffers and strings.
+      var buf = list[0];
+      ret = buf.slice(0, n);
+      list[0] = buf.slice(n);
+    } else if (n === list[0].length) {
+      // first list is a perfect match
+      ret = list.shift();
+    } else {
+      // complex case.
+      // we have enough to cover it, but it spans past the first buffer.
+      if (stringMode)
+        ret = '';
+      else
+        ret = new Buffer(n);
+
+      var c = 0;
+      for (var i = 0, l = list.length; i < l && c < n; i++) {
+        var buf = list[0];
+        var cpy = Math.min(n - c, buf.length);
+
+        if (stringMode)
+          ret += buf.slice(0, cpy);
+        else
+          buf.copy(ret, c, 0, cpy);
+
+        if (cpy < buf.length)
+          list[0] = buf.slice(cpy);
+        else
+          list.shift();
+
+        c += cpy;
+      }
+    }
+  }
+
+  return ret;
+}
+
+function endReadable(stream) {
+  var state = stream._readableState;
+
+  // If we get here before consuming all the bytes, then that is a
+  // bug in node.  Should never happen.
+  if (state.length > 0)
+    throw new Error('endReadable called on non-empty stream');
+
+  if (!state.endEmitted && state.calledRead) {
+    state.ended = true;
+    process.nextTick(function() {
+      // Check that we didn't get one last unshift.
+      if (!state.endEmitted && state.length === 0) {
+        state.endEmitted = true;
+        stream.readable = false;
+        stream.emit('end');
+      }
+    });
+  }
+}
+
+function forEach (xs, f) {
+  for (var i = 0, l = xs.length; i < l; i++) {
+    f(xs[i], i);
+  }
+}
+
+function indexOf (xs, x) {
+  for (var i = 0, l = xs.length; i < l; i++) {
+    if (xs[i] === x) return i;
+  }
+  return -1;
+}
+
+}).call(this,require('_process'))
+},{"_process":10,"buffer":3,"core-util-is":17,"events":7,"inherits":8,"isarray":9,"stream":22,"string_decoder/":23}],15:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+// a transform stream is a readable/writable stream where you do
+// something with the data.  Sometimes it's called a "filter",
+// but that's not a great name for it, since that implies a thing where
+// some bits pass through, and others are simply ignored.  (That would
+// be a valid example of a transform, of course.)
+//
+// While the output is causally related to the input, it's not a
+// necessarily symmetric or synchronous transformation.  For example,
+// a zlib stream might take multiple plain-text writes(), and then
+// emit a single compressed chunk some time in the future.
+//
+// Here's how this works:
+//
+// The Transform stream has all the aspects of the readable and writable
+// stream classes.  When you write(chunk), that calls _write(chunk,cb)
+// internally, and returns false if there's a lot of pending writes
+// buffered up.  When you call read(), that calls _read(n) until
+// there's enough pending readable data buffered up.
+//
+// In a transform stream, the written data is placed in a buffer.  When
+// _read(n) is called, it transforms the queued up data, calling the
+// buffered _write cb's as it consumes chunks.  If consuming a single
+// written chunk would result in multiple output chunks, then the first
+// outputted bit calls the readcb, and subsequent chunks just go into
+// the read buffer, and will cause it to emit 'readable' if necessary.
+//
+// This way, back-pressure is actually determined by the reading side,
+// since _read has to be called to start processing a new chunk.  However,
+// a pathological inflate type of transform can cause excessive buffering
+// here.  For example, imagine a stream where every byte of input is
+// interpreted as an integer from 0-255, and then results in that many
+// bytes of output.  Writing the 4 bytes {ff,ff,ff,ff} would result in
+// 1kb of data being output.  In this case, you could write a very small
+// amount of input, and end up with a very large amount of output.  In
+// such a pathological inflating mechanism, there'd be no way to tell
+// the system to stop doing the transform.  A single 4MB write could
+// cause the system to run out of memory.
+//
+// However, even in such a pathological case, only a single written chunk
+// would be consumed, and then the rest would wait (un-transformed) until
+// the results of the previous transformed chunk were consumed.
+
+module.exports = Transform;
+
+var Duplex = require('./_stream_duplex');
+
+/*<replacement>*/
+var util = require('core-util-is');
+util.inherits = require('inherits');
+/*</replacement>*/
+
+util.inherits(Transform, Duplex);
+
+
+function TransformState(options, stream) {
+  this.afterTransform = function(er, data) {
+    return afterTransform(stream, er, data);
+  };
+
+  this.needTransform = false;
+  this.transforming = false;
+  this.writecb = null;
+  this.writechunk = null;
+}
+
+function afterTransform(stream, er, data) {
+  var ts = stream._transformState;
+  ts.transforming = false;
+
+  var cb = ts.writecb;
+
+  if (!cb)
+    return stream.emit('error', new Error('no writecb in Transform class'));
+
+  ts.writechunk = null;
+  ts.writecb = null;
+
+  if (data !== null && data !== undefined)
+    stream.push(data);
+
+  if (cb)
+    cb(er);
+
+  var rs = stream._readableState;
+  rs.reading = false;
+  if (rs.needReadable || rs.length < rs.highWaterMark) {
+    stream._read(rs.highWaterMark);
+  }
+}
+
+
+function Transform(options) {
+  if (!(this instanceof Transform))
+    return new Transform(options);
+
+  Duplex.call(this, options);
+
+  var ts = this._transformState = new TransformState(options, this);
+
+  // when the writable side finishes, then flush out anything remaining.
+  var stream = this;
+
+  // start out asking for a readable event once data is transformed.
+  this._readableState.needReadable = true;
+
+  // we have implemented the _read method, and done the other things
+  // that Readable wants before the first _read call, so unset the
+  // sync guard flag.
+  this._readableState.sync = false;
+
+  this.once('finish', function() {
+    if ('function' === typeof this._flush)
+      this._flush(function(er) {
+        done(stream, er);
+      });
+    else
+      done(stream);
+  });
+}
+
+Transform.prototype.push = function(chunk, encoding) {
+  this._transformState.needTransform = false;
+  return Duplex.prototype.push.call(this, chunk, encoding);
+};
+
+// This is the part where you do stuff!
+// override this function in implementation classes.
+// 'chunk' is an input chunk.
+//
+// Call `push(newChunk)` to pass along transformed output
+// to the readable side.  You may call 'push' zero or more times.
+//
+// Call `cb(err)` when you are done with this chunk.  If you pass
+// an error, then that'll put the hurt on the whole operation.  If you
+// never call cb(), then you'll never get another chunk.
+Transform.prototype._transform = function(chunk, encoding, cb) {
+  throw new Error('not implemented');
+};
+
+Transform.prototype._write = function(chunk, encoding, cb) {
+  var ts = this._transformState;
+  ts.writecb = cb;
+  ts.writechunk = chunk;
+  ts.writeencoding = encoding;
+  if (!ts.transforming) {
+    var rs = this._readableState;
+    if (ts.needTransform ||
+        rs.needReadable ||
+        rs.length < rs.highWaterMark)
+      this._read(rs.highWaterMark);
+  }
+};
+
+// Doesn't matter what the args are here.
+// _transform does all the work.
+// That we got here means that the readable side wants more data.
+Transform.prototype._read = function(n) {
+  var ts = this._transformState;
+
+  if (ts.writechunk !== null && ts.writecb && !ts.transforming) {
+    ts.transforming = true;
+    this._transform(ts.writechunk, ts.writeencoding, ts.afterTransform);
+  } else {
+    // mark that we need a transform, so that any data that comes in
+    // will get processed, now that we've asked for it.
+    ts.needTransform = true;
+  }
+};
+
+
+function done(stream, er) {
+  if (er)
+    return stream.emit('error', er);
+
+  // if there's nothing in the write buffer, then that means
+  // that nothing more will ever be provided
+  var ws = stream._writableState;
+  var rs = stream._readableState;
+  var ts = stream._transformState;
+
+  if (ws.length)
+    throw new Error('calling transform done when ws.length != 0');
+
+  if (ts.transforming)
+    throw new Error('calling transform done when still transforming');
+
+  return stream.push(null);
+}
+
+},{"./_stream_duplex":12,"core-util-is":17,"inherits":8}],16:[function(require,module,exports){
+(function (process){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+// A bit simpler than readable streams.
+// Implement an async ._write(chunk, cb), and it'll handle all
+// the drain event emission and buffering.
+
+module.exports = Writable;
+
+/*<replacement>*/
+var Buffer = require('buffer').Buffer;
+/*</replacement>*/
+
+Writable.WritableState = WritableState;
+
+
+/*<replacement>*/
+var util = require('core-util-is');
+util.inherits = require('inherits');
+/*</replacement>*/
+
+var Stream = require('stream');
+
+util.inherits(Writable, Stream);
+
+function WriteReq(chunk, encoding, cb) {
+  this.chunk = chunk;
+  this.encoding = encoding;
+  this.callback = cb;
+}
+
+function WritableState(options, stream) {
+  options = options || {};
+
+  // the point at which write() starts returning false
+  // Note: 0 is a valid value, means that we always return false if
+  // the entire buffer is not flushed immediately on write()
+  var hwm = options.highWaterMark;
+  this.highWaterMark = (hwm || hwm === 0) ? hwm : 16 * 1024;
+
+  // object stream flag to indicate whether or not this stream
+  // contains buffers or objects.
+  this.objectMode = !!options.objectMode;
+
+  // cast to ints.
+  this.highWaterMark = ~~this.highWaterMark;
+
+  this.needDrain = false;
+  // at the start of calling end()
+  this.ending = false;
+  // when end() has been called, and returned
+  this.ended = false;
+  // when 'finish' is emitted
+  this.finished = false;
+
+  // should we decode strings into buffers before passing to _write?
+  // this is here so that some node-core streams can optimize string
+  // handling at a lower level.
+  var noDecode = options.decodeStrings === false;
+  this.decodeStrings = !noDecode;
+
+  // Crypto is kind of old and crusty.  Historically, its default string
+  // encoding is 'binary' so we have to make this configurable.
+  // Everything else in the universe uses 'utf8', though.
+  this.defaultEncoding = options.defaultEncoding || 'utf8';
+
+  // not an actual buffer we keep track of, but a measurement
+  // of how much we're waiting to get pushed to some underlying
+  // socket or file.
+  this.length = 0;
+
+  // a flag to see when we're in the middle of a write.
+  this.writing = false;
+
+  // a flag to be able to tell if the onwrite cb is called immediately,
+  // or on a later tick.  We set this to true at first, becuase any
+  // actions that shouldn't happen until "later" should generally also
+  // not happen before the first write call.
+  this.sync = true;
+
+  // a flag to know if we're processing previously buffered items, which
+  // may call the _write() callback in the same tick, so that we don't
+  // end up in an overlapped onwrite situation.
+  this.bufferProcessing = false;
+
+  // the callback that's passed to _write(chunk,cb)
+  this.onwrite = function(er) {
+    onwrite(stream, er);
+  };
+
+  // the callback that the user supplies to write(chunk,encoding,cb)
+  this.writecb = null;
+
+  // the amount that is being written when _write is called.
+  this.writelen = 0;
+
+  this.buffer = [];
+
+  // True if the error was already emitted and should not be thrown again
+  this.errorEmitted = false;
+}
+
+function Writable(options) {
+  var Duplex = require('./_stream_duplex');
+
+  // Writable ctor is applied to Duplexes, though they're not
+  // instanceof Writable, they're instanceof Readable.
+  if (!(this instanceof Writable) && !(this instanceof Duplex))
+    return new Writable(options);
+
+  this._writableState = new WritableState(options, this);
+
+  // legacy.
+  this.writable = true;
+
+  Stream.call(this);
+}
+
+// Otherwise people can pipe Writable streams, which is just wrong.
+Writable.prototype.pipe = function() {
+  this.emit('error', new Error('Cannot pipe. Not readable.'));
+};
+
+
+function writeAfterEnd(stream, state, cb) {
+  var er = new Error('write after end');
+  // TODO: defer error events consistently everywhere, not just the cb
+  stream.emit('error', er);
+  process.nextTick(function() {
+    cb(er);
+  });
+}
+
+// If we get something that is not a buffer, string, null, or undefined,
+// and we're not in objectMode, then that's an error.
+// Otherwise stream chunks are all considered to be of length=1, and the
+// watermarks determine how many objects to keep in the buffer, rather than
+// how many bytes or characters.
+function validChunk(stream, state, chunk, cb) {
+  var valid = true;
+  if (!Buffer.isBuffer(chunk) &&
+      'string' !== typeof chunk &&
+      chunk !== null &&
+      chunk !== undefined &&
+      !state.objectMode) {
+    var er = new TypeError('Invalid non-string/buffer chunk');
+    stream.emit('error', er);
+    process.nextTick(function() {
+      cb(er);
+    });
+    valid = false;
+  }
+  return valid;
+}
+
+Writable.prototype.write = function(chunk, encoding, cb) {
+  var state = this._writableState;
+  var ret = false;
+
+  if (typeof encoding === 'function') {
+    cb = encoding;
+    encoding = null;
+  }
+
+  if (Buffer.isBuffer(chunk))
+    encoding = 'buffer';
+  else if (!encoding)
+    encoding = state.defaultEncoding;
+
+  if (typeof cb !== 'function')
+    cb = function() {};
+
+  if (state.ended)
+    writeAfterEnd(this, state, cb);
+  else if (validChunk(this, state, chunk, cb))
+    ret = writeOrBuffer(this, state, chunk, encoding, cb);
+
+  return ret;
+};
+
+function decodeChunk(state, chunk, encoding) {
+  if (!state.objectMode &&
+      state.decodeStrings !== false &&
+      typeof chunk === 'string') {
+    chunk = new Buffer(chunk, encoding);
+  }
+  return chunk;
+}
+
+// if we're already writing something, then just put this
+// in the queue, and wait our turn.  Otherwise, call _write
+// If we return false, then we need a drain event, so set that flag.
+function writeOrBuffer(stream, state, chunk, encoding, cb) {
+  chunk = decodeChunk(state, chunk, encoding);
+  if (Buffer.isBuffer(chunk))
+    encoding = 'buffer';
+  var len = state.objectMode ? 1 : chunk.length;
+
+  state.length += len;
+
+  var ret = state.length < state.highWaterMark;
+  // we must ensure that previous needDrain will not be reset to false.
+  if (!ret)
+    state.needDrain = true;
+
+  if (state.writing)
+    state.buffer.push(new WriteReq(chunk, encoding, cb));
+  else
+    doWrite(stream, state, len, chunk, encoding, cb);
+
+  return ret;
+}
+
+function doWrite(stream, state, len, chunk, encoding, cb) {
+  state.writelen = len;
+  state.writecb = cb;
+  state.writing = true;
+  state.sync = true;
+  stream._write(chunk, encoding, state.onwrite);
+  state.sync = false;
+}
+
+function onwriteError(stream, state, sync, er, cb) {
+  if (sync)
+    process.nextTick(function() {
+      cb(er);
+    });
+  else
+    cb(er);
+
+  stream._writableState.errorEmitted = true;
+  stream.emit('error', er);
+}
+
+function onwriteStateUpdate(state) {
+  state.writing = false;
+  state.writecb = null;
+  state.length -= state.writelen;
+  state.writelen = 0;
+}
+
+function onwrite(stream, er) {
+  var state = stream._writableState;
+  var sync = state.sync;
+  var cb = state.writecb;
+
+  onwriteStateUpdate(state);
+
+  if (er)
+    onwriteError(stream, state, sync, er, cb);
+  else {
+    // Check if we're actually ready to finish, but don't emit yet
+    var finished = needFinish(stream, state);
+
+    if (!finished && !state.bufferProcessing && state.buffer.length)
+      clearBuffer(stream, state);
+
+    if (sync) {
+      process.nextTick(function() {
+        afterWrite(stream, state, finished, cb);
+      });
+    } else {
+      afterWrite(stream, state, finished, cb);
+    }
+  }
+}
+
+function afterWrite(stream, state, finished, cb) {
+  if (!finished)
+    onwriteDrain(stream, state);
+  cb();
+  if (finished)
+    finishMaybe(stream, state);
+}
+
+// Must force callback to be called on nextTick, so that we don't
+// emit 'drain' before the write() consumer gets the 'false' return
+// value, and has a chance to attach a 'drain' listener.
+function onwriteDrain(stream, state) {
+  if (state.length === 0 && state.needDrain) {
+    state.needDrain = false;
+    stream.emit('drain');
+  }
+}
+
+
+// if there's something in the buffer waiting, then process it
+function clearBuffer(stream, state) {
+  state.bufferProcessing = true;
+
+  for (var c = 0; c < state.buffer.length; c++) {
+    var entry = state.buffer[c];
+    var chunk = entry.chunk;
+    var encoding = entry.encoding;
+    var cb = entry.callback;
+    var len = state.objectMode ? 1 : chunk.length;
+
+    doWrite(stream, state, len, chunk, encoding, cb);
+
+    // if we didn't call the onwrite immediately, then
+    // it means that we need to wait until it does.
+    // also, that means that the chunk and cb are currently
+    // being processed, so move the buffer counter past them.
+    if (state.writing) {
+      c++;
+      break;
+    }
+  }
+
+  state.bufferProcessing = false;
+  if (c < state.buffer.length)
+    state.buffer = state.buffer.slice(c);
+  else
+    state.buffer.length = 0;
+}
+
+Writable.prototype._write = function(chunk, encoding, cb) {
+  cb(new Error('not implemented'));
+};
+
+Writable.prototype.end = function(chunk, encoding, cb) {
+  var state = this._writableState;
+
+  if (typeof chunk === 'function') {
+    cb = chunk;
+    chunk = null;
+    encoding = null;
+  } else if (typeof encoding === 'function') {
+    cb = encoding;
+    encoding = null;
+  }
+
+  if (typeof chunk !== 'undefined' && chunk !== null)
+    this.write(chunk, encoding);
+
+  // ignore unnecessary end() calls.
+  if (!state.ending && !state.finished)
+    endWritable(this, state, cb);
+};
+
+
+function needFinish(stream, state) {
+  return (state.ending &&
+          state.length === 0 &&
+          !state.finished &&
+          !state.writing);
+}
+
+function finishMaybe(stream, state) {
+  var need = needFinish(stream, state);
+  if (need) {
+    state.finished = true;
+    stream.emit('finish');
+  }
+  return need;
+}
+
+function endWritable(stream, state, cb) {
+  state.ending = true;
+  finishMaybe(stream, state);
+  if (cb) {
+    if (state.finished)
+      process.nextTick(cb);
+    else
+      stream.once('finish', cb);
+  }
+  state.ended = true;
+}
+
+}).call(this,require('_process'))
+},{"./_stream_duplex":12,"_process":10,"buffer":3,"core-util-is":17,"inherits":8,"stream":22}],17:[function(require,module,exports){
+(function (Buffer){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+// NOTE: These type checking functions intentionally don't use `instanceof`
+// because it is fragile and can be easily faked with `Object.create()`.
+function isArray(ar) {
+  return Array.isArray(ar);
+}
+exports.isArray = isArray;
+
+function isBoolean(arg) {
+  return typeof arg === 'boolean';
+}
+exports.isBoolean = isBoolean;
+
+function isNull(arg) {
+  return arg === null;
+}
+exports.isNull = isNull;
+
+function isNullOrUndefined(arg) {
+  return arg == null;
+}
+exports.isNullOrUndefined = isNullOrUndefined;
+
+function isNumber(arg) {
+  return typeof arg === 'number';
+}
+exports.isNumber = isNumber;
+
+function isString(arg) {
+  return typeof arg === 'string';
+}
+exports.isString = isString;
+
+function isSymbol(arg) {
+  return typeof arg === 'symbol';
+}
+exports.isSymbol = isSymbol;
+
+function isUndefined(arg) {
+  return arg === void 0;
+}
+exports.isUndefined = isUndefined;
+
+function isRegExp(re) {
+  return isObject(re) && objectToString(re) === '[object RegExp]';
+}
+exports.isRegExp = isRegExp;
+
+function isObject(arg) {
+  return typeof arg === 'object' && arg !== null;
+}
+exports.isObject = isObject;
+
+function isDate(d) {
+  return isObject(d) && objectToString(d) === '[object Date]';
+}
+exports.isDate = isDate;
+
+function isError(e) {
+  return isObject(e) &&
+      (objectToString(e) === '[object Error]' || e instanceof Error);
+}
+exports.isError = isError;
+
+function isFunction(arg) {
+  return typeof arg === 'function';
+}
+exports.isFunction = isFunction;
+
+function isPrimitive(arg) {
+  return arg === null ||
+         typeof arg === 'boolean' ||
+         typeof arg === 'number' ||
+         typeof arg === 'string' ||
+         typeof arg === 'symbol' ||  // ES6 symbol
+         typeof arg === 'undefined';
+}
+exports.isPrimitive = isPrimitive;
+
+function isBuffer(arg) {
+  return Buffer.isBuffer(arg);
+}
+exports.isBuffer = isBuffer;
+
+function objectToString(o) {
+  return Object.prototype.toString.call(o);
+}
+}).call(this,require("buffer").Buffer)
+},{"buffer":3}],18:[function(require,module,exports){
+module.exports = require("./lib/_stream_passthrough.js")
+
+},{"./lib/_stream_passthrough.js":13}],19:[function(require,module,exports){
+var Stream = require('stream'); // hack to fix a circular dependency issue when used with browserify
+exports = module.exports = require('./lib/_stream_readable.js');
+exports.Stream = Stream;
+exports.Readable = exports;
+exports.Writable = require('./lib/_stream_writable.js');
+exports.Duplex = require('./lib/_stream_duplex.js');
+exports.Transform = require('./lib/_stream_transform.js');
+exports.PassThrough = require('./lib/_stream_passthrough.js');
+
+},{"./lib/_stream_duplex.js":12,"./lib/_stream_passthrough.js":13,"./lib/_stream_readable.js":14,"./lib/_stream_transform.js":15,"./lib/_stream_writable.js":16,"stream":22}],20:[function(require,module,exports){
+module.exports = require("./lib/_stream_transform.js")
+
+},{"./lib/_stream_transform.js":15}],21:[function(require,module,exports){
+module.exports = require("./lib/_stream_writable.js")
+
+},{"./lib/_stream_writable.js":16}],22:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+module.exports = Stream;
+
+var EE = require('events').EventEmitter;
+var inherits = require('inherits');
+
+inherits(Stream, EE);
+Stream.Readable = require('readable-stream/readable.js');
+Stream.Writable = require('readable-stream/writable.js');
+Stream.Duplex = require('readable-stream/duplex.js');
+Stream.Transform = require('readable-stream/transform.js');
+Stream.PassThrough = require('readable-stream/passthrough.js');
+
+// Backwards-compat with node 0.4.x
+Stream.Stream = Stream;
+
+
+
+// old-style streams.  Note that the pipe method (the only relevant
+// part of this class) is overridden in the Readable class.
+
+function Stream() {
+  EE.call(this);
+}
+
+Stream.prototype.pipe = function(dest, options) {
+  var source = this;
+
+  function ondata(chunk) {
+    if (dest.writable) {
+      if (false === dest.write(chunk) && source.pause) {
+        source.pause();
+      }
+    }
+  }
+
+  source.on('data', ondata);
+
+  function ondrain() {
+    if (source.readable && source.resume) {
+      source.resume();
+    }
+  }
+
+  dest.on('drain', ondrain);
+
+  // If the 'end' option is not supplied, dest.end() will be called when
+  // source gets the 'end' or 'close' events.  Only dest.end() once.
+  if (!dest._isStdio && (!options || options.end !== false)) {
+    source.on('end', onend);
+    source.on('close', onclose);
+  }
+
+  var didOnEnd = false;
+  function onend() {
+    if (didOnEnd) return;
+    didOnEnd = true;
+
+    dest.end();
+  }
+
+
+  function onclose() {
+    if (didOnEnd) return;
+    didOnEnd = true;
+
+    if (typeof dest.destroy === 'function') dest.destroy();
+  }
+
+  // don't leave dangling pipes when there are errors.
+  function onerror(er) {
+    cleanup();
+    if (EE.listenerCount(this, 'error') === 0) {
+      throw er; // Unhandled stream error in pipe.
+    }
+  }
+
+  source.on('error', onerror);
+  dest.on('error', onerror);
+
+  // remove all the event listeners that were added.
+  function cleanup() {
+    source.removeListener('data', ondata);
+    dest.removeListener('drain', ondrain);
+
+    source.removeListener('end', onend);
+    source.removeListener('close', onclose);
+
+    source.removeListener('error', onerror);
+    dest.removeListener('error', onerror);
+
+    source.removeListener('end', cleanup);
+    source.removeListener('close', cleanup);
+
+    dest.removeListener('close', cleanup);
+  }
+
+  source.on('end', cleanup);
+  source.on('close', cleanup);
+
+  dest.on('close', cleanup);
+
+  dest.emit('pipe', source);
+
+  // Allow for unix-like usage: A.pipe(B).pipe(C)
+  return dest;
+};
+
+},{"events":7,"inherits":8,"readable-stream/duplex.js":11,"readable-stream/passthrough.js":18,"readable-stream/readable.js":19,"readable-stream/transform.js":20,"readable-stream/writable.js":21}],23:[function(require,module,exports){
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+var Buffer = require('buffer').Buffer;
+
+var isBufferEncoding = Buffer.isEncoding
+  || function(encoding) {
+       switch (encoding && encoding.toLowerCase()) {
+         case 'hex': case 'utf8': case 'utf-8': case 'ascii': case 'binary': case 'base64': case 'ucs2': case 'ucs-2': case 'utf16le': case 'utf-16le': case 'raw': return true;
+         default: return false;
+       }
+     }
+
+
+function assertEncoding(encoding) {
+  if (encoding && !isBufferEncoding(encoding)) {
+    throw new Error('Unknown encoding: ' + encoding);
+  }
+}
+
+// StringDecoder provides an interface for efficiently splitting a series of
+// buffers into a series of JS strings without breaking apart multi-byte
+// characters. CESU-8 is handled as part of the UTF-8 encoding.
+//
+// @TODO Handling all encodings inside a single object makes it very difficult
+// to reason about this code, so it should be split up in the future.
+// @TODO There should be a utf8-strict encoding that rejects invalid UTF-8 code
+// points as used by CESU-8.
+var StringDecoder = exports.StringDecoder = function(encoding) {
+  this.encoding = (encoding || 'utf8').toLowerCase().replace(/[-_]/, '');
+  assertEncoding(encoding);
+  switch (this.encoding) {
+    case 'utf8':
+      // CESU-8 represents each of Surrogate Pair by 3-bytes
+      this.surrogateSize = 3;
+      break;
+    case 'ucs2':
+    case 'utf16le':
+      // UTF-16 represents each of Surrogate Pair by 2-bytes
+      this.surrogateSize = 2;
+      this.detectIncompleteChar = utf16DetectIncompleteChar;
+      break;
+    case 'base64':
+      // Base-64 stores 3 bytes in 4 chars, and pads the remainder.
+      this.surrogateSize = 3;
+      this.detectIncompleteChar = base64DetectIncompleteChar;
+      break;
+    default:
+      this.write = passThroughWrite;
+      return;
+  }
+
+  // Enough space to store all bytes of a single character. UTF-8 needs 4
+  // bytes, but CESU-8 may require up to 6 (3 bytes per surrogate).
+  this.charBuffer = new Buffer(6);
+  // Number of bytes received for the current incomplete multi-byte character.
+  this.charReceived = 0;
+  // Number of bytes expected for the current incomplete multi-byte character.
+  this.charLength = 0;
+};
+
+
+// write decodes the given buffer and returns it as JS string that is
+// guaranteed to not contain any partial multi-byte characters. Any partial
+// character found at the end of the buffer is buffered up, and will be
+// returned when calling write again with the remaining bytes.
+//
+// Note: Converting a Buffer containing an orphan surrogate to a String
+// currently works, but converting a String to a Buffer (via `new Buffer`, or
+// Buffer#write) will replace incomplete surrogates with the unicode
+// replacement character. See https://codereview.chromium.org/121173009/ .
+StringDecoder.prototype.write = function(buffer) {
+  var charStr = '';
+  // if our last write ended with an incomplete multibyte character
+  while (this.charLength) {
+    // determine how many remaining bytes this buffer has to offer for this char
+    var available = (buffer.length >= this.charLength - this.charReceived) ?
+        this.charLength - this.charReceived :
+        buffer.length;
+
+    // add the new bytes to the char buffer
+    buffer.copy(this.charBuffer, this.charReceived, 0, available);
+    this.charReceived += available;
+
+    if (this.charReceived < this.charLength) {
+      // still not enough chars in this buffer? wait for more ...
+      return '';
+    }
+
+    // remove bytes belonging to the current character from the buffer
+    buffer = buffer.slice(available, buffer.length);
+
+    // get the character that was split
+    charStr = this.charBuffer.slice(0, this.charLength).toString(this.encoding);
+
+    // CESU-8: lead surrogate (D800-DBFF) is also the incomplete character
+    var charCode = charStr.charCodeAt(charStr.length - 1);
+    if (charCode >= 0xD800 && charCode <= 0xDBFF) {
+      this.charLength += this.surrogateSize;
+      charStr = '';
+      continue;
+    }
+    this.charReceived = this.charLength = 0;
+
+    // if there are no more bytes in this buffer, just emit our char
+    if (buffer.length === 0) {
+      return charStr;
+    }
+    break;
+  }
+
+  // determine and set charLength / charReceived
+  this.detectIncompleteChar(buffer);
+
+  var end = buffer.length;
+  if (this.charLength) {
+    // buffer the incomplete character bytes we got
+    buffer.copy(this.charBuffer, 0, buffer.length - this.charReceived, end);
+    end -= this.charReceived;
+  }
+
+  charStr += buffer.toString(this.encoding, 0, end);
+
+  var end = charStr.length - 1;
+  var charCode = charStr.charCodeAt(end);
+  // CESU-8: lead surrogate (D800-DBFF) is also the incomplete character
+  if (charCode >= 0xD800 && charCode <= 0xDBFF) {
+    var size = this.surrogateSize;
+    this.charLength += size;
+    this.charReceived += size;
+    this.charBuffer.copy(this.charBuffer, size, 0, size);
+    buffer.copy(this.charBuffer, 0, 0, size);
+    return charStr.substring(0, end);
+  }
+
+  // or just emit the charStr
+  return charStr;
+};
+
+// detectIncompleteChar determines if there is an incomplete UTF-8 character at
+// the end of the given buffer. If so, it sets this.charLength to the byte
+// length that character, and sets this.charReceived to the number of bytes
+// that are available for this character.
+StringD

[Diff truncated. Use the raw view or local clone for the full content.]