v3.0.0

<bmx-document-scanner>

Scans paper with a phone or laptop camera, or from photographs: receipts, invoices, contracts, forms, ID-free paperwork of every kind - for expense apps, onboarding, claims, field service and back offices.

12 properties · 4 events · 16 methods · 5 parts

Example

Try it in confidence: the camera picture, and any photo you add, is worked on by your browser, on your device. Nothing is uploaded or recorded anywhere, no copy reaches us or anyone else, and the camera stops when you leave this page.

Use your camera on a sheet of paper - the page is outlined as it is found and, held still, taken by itself - or add photographs of documents. Each page is straightened and cleaned up; drag its corners if they need it, choose how it looks, and save the pages as one PDF.

Show markup
<bmx-document-scanner id="ex-ds" label="Scan receipts and invoices" file-name="expenses" locale="en-GB"></bmx-document-scanner>
<p class="note" id="ex-ds-out" role="status">Use your camera on a sheet of paper - the page is outlined as it is found and, held still, taken by itself - or add photographs of documents. Each page is straightened and cleaned up; drag its corners if they need it, choose how it looks, and save the pages as one PDF.</p>

<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
  <button type="button" id="ex-ds-sample">Add a sample photo of an invoice</button>
  <button type="button" id="ex-ds-receipt">Add a sample photo of a receipt</button>
</div>

<script type="module">
  await customElements.whenDefined('bmx-document-scanner');
  const el = document.getElementById('ex-ds');
  const out = document.getElementById('ex-ds-out');

  /** A photograph of a page lying at an angle on a wooden desk, drawn for the example. */
  function samplePhoto(kind) {
    const c = document.createElement('canvas');
    c.width = 1600;
    c.height = 1200;
    const g = c.getContext('2d');
    // The desk: warm wood with grain.
    const wood = g.createLinearGradient(0, 0, 1600, 1200);
    wood.addColorStop(0, '#6b4a2f');
    wood.addColorStop(1, '#4a3220');
    g.fillStyle = wood;
    g.fillRect(0, 0, 1600, 1200);
    for (let i = 0; i < 90; i++) {
      g.strokeStyle = `rgba(30, 18, 8, ${0.08 + (i % 5) * 0.03})`;
      g.lineWidth = 1 + (i % 3);
      g.beginPath();
      const y = i * 14 + (i % 7) * 3;
      g.moveTo(0, y);
      g.bezierCurveTo(500, y + 25, 1100, y - 25, 1600, y + 10);
      g.stroke();
    }
    // The page, turned a little, with a soft shadow under it.
    const receipt = kind === 'receipt';
    const pw = receipt ? 420 : 760;
    const ph = receipt ? 900 : 1000;
    g.save();
    g.translate(800, 610);
    g.rotate(receipt ? 0.12 : -0.07);
    g.shadowColor = 'rgba(0, 0, 0, 0.45)';
    g.shadowBlur = 30;
    g.shadowOffsetX = 12;
    g.shadowOffsetY = 16;
    const paper = g.createLinearGradient(-pw / 2, 0, pw / 2, 0);
    paper.addColorStop(0, '#fbfaf6');
    paper.addColorStop(1, '#d9d4c7');
    g.fillStyle = paper;
    g.fillRect(-pw / 2, -ph / 2, pw, ph);
    g.shadowColor = 'transparent';
    g.fillStyle = '#23262b';
    const left = -pw / 2 + 40;
    let y = -ph / 2 + 70;
    if (receipt) {
      g.font = 'bold 30px monospace';
      g.fillText('HARBOUR CAFE', left + 60, y);
      g.font = '20px monospace';
      y += 50;
      for (const [item, price] of [['Flat white', '3.40'], ['Oat latte', '3.80'], ['Croissant', '2.90'], ['Granola pot', '4.25'], ['Sparkling water', '1.95'], ['Banana bread', '3.10']]) {
        g.fillText(item, left, y);
        g.fillText(price, left + 270, y);
        y += 38;
      }
      y += 20;
      g.font = 'bold 24px monospace';
      g.fillText('TOTAL   GBP 19.40', left, y);
      g.font = '18px monospace';
      g.fillText('Card ****4471  17/10/2026', left, y + 50);
      g.fillText('Thank you!', left + 90, y + 110);
    } else {
      g.font = 'bold 44px sans-serif';
      g.fillText('INVOICE', left, y);
      g.font = '20px sans-serif';
      g.fillText('Fernhill Consulting Ltd', left + 380, y - 20);
      g.fillText('No. 2026-1043', left + 380, y + 10);
      y += 80;
      g.fillText('Bill to: Northwind Rail', left, y);
      g.fillText('Date: 17 October 2026', left + 380, y);
      y += 60;
      g.fillStyle = '#d7dce4';
      g.fillRect(left, y - 26, pw - 80, 36);
      g.fillStyle = '#23262b';
      g.font = 'bold 20px sans-serif';
      g.fillText('Description', left + 10, y);
      g.fillText('Amount', left + 540, y);
      g.font = '20px sans-serif';
      for (const [d, a] of [['Discovery workshops (6 days)', '£9,600'], ['Timetable data model', '£14,400'], ['Feed mapping', '£7,200'], ['Travel', '£640']]) {
        y += 46;
        g.fillText(d, left + 10, y);
        g.fillText(a, left + 540, y);
      }
      y += 70;
      g.font = 'bold 24px sans-serif';
      g.fillText('Total due: £31,840', left + 330, y);
      g.font = '18px sans-serif';
      for (let i = 0; i < 6; i++) g.fillText('Payment within 30 days to account 12-34-56 01234567. Thank you for your business.'.slice(0, 60 + i), left, y + 90 + i * 30);
    }
    g.restore();
    return c.toDataURL('image/jpeg', 0.9);
  }

  document.getElementById('ex-ds-sample').addEventListener('click', () => el.addImages([samplePhoto('invoice')]));
  document.getElementById('ex-ds-receipt').addEventListener('click', () => el.addImages([samplePhoto('receipt')]));

  el.addEventListener('bmxCapture', e => {
    const { index, page } = e.detail;
    out.textContent = `Page ${index + 1} added ${page.cornersFrom === 'detected' ? 'with its edges found' : '- drag its corners to the page'}.`;
  });
  el.addEventListener('bmxExport', e => {
    out.textContent = `Saved ${e.detail.fileName}: ${e.detail.pages} pages, ${Math.round(e.detail.bytes.length / 1024)} KB.`;
  });
  el.addEventListener('bmxError', e => {
    out.textContent = e.detail.message;
  });
</script>

WHAT IT DOES

  • The camera's view shows the page it finds, outlined, as you hold the phone over it; held still, the picture is taken by itself (or with the button, Space or Enter). A light for dark rooms and a switch between cameras where the device has them.
  • Pictures can also be added from files, dropped, or pasted.
  • Each page's edges are found, the page straightened as if photographed from straight above, and cleaned up: colour with the shadows and lamplight taken out, greyscale, crisp black and white for text, or as photographed. Corners can be dragged (or moved with the keys) where the detection was not right; pages rotate, reorder and delete.
  • Saves every page as one PDF (A4, Letter, Legal or each picture's own size), the pages as JPEGs in a .zip, or one page as a JPEG; or hands the bytes to the page (bmxExport) to upload where it likes.

Everything happens in the browser: the camera's pictures and the files added never leave the page unless the page sends them itself. The camera is only asked for when the user chooses to use it.

Properties

PropertyAttributeTypeDefaultDescription
autoCapture auto-capture boolean true Take the picture by itself once a page has been held still in the camera's view.
dpi dpi number 200 For page-size="fit": the resolution the PDF's sheets assume, in dots an inch.
facing facing 'environment' | 'user' 'environment' Which camera to start with: environment (the back one) or user.
fileName file-name string 'scan' The name saved files get, without the extension.
filter filter BmxScanFilter 'color' How new pages look: color, grayscale, bw or original.
label label string — The element's accessible name.
locale locale string — The locale for numbers. Unset: the page's lang.
maxPages max-pages number 0 The most pages a scan may have. 0: no limit.
pageSize page-size BmxScanPageSize 'a4' The paper the PDF is laid out on: a4, letter, legal, or fit (each picture's own size).
quality quality number 0.85 JPEG quality of saved pages, 0.3 to 1.
resolution resolution number 2200 The longer side of a saved page in pixels (2200 is about 190 dots an inch on A4).
strings strings Partial<BmxDocumentScannerStrings> | string — Replacements for any of the wording (an object, or its JSON).

Events

EventDetailDescription
bmxCapture BmxDocumentScannerCaptureDetail Fired when a page is added from the camera or a file.
bmxChange BmxDocumentScannerChangeDetail Fired when pages are added, removed, moved, cropped, rotated or given another look.
bmxError BmxDocumentScannerErrorDetail Fired when the camera cannot be used or a file cannot be read.
bmxExport BmxDocumentScannerExportDetail Fired with the bytes before a PDF, .zip or picture is downloaded. Cancelable.

Methods

MethodSignatureDescription
addImages addImages(images: (Blob | string)[] | FileList) => Promise<BmxDocumentScannerPage[]> Adds pictures (files, blobs or data URLs) as pages; each page's edges are found. Resolves with the pages added.
capture capture() => Promise<BmxDocumentScannerPage | null> Takes a picture with the camera now.
clear clear() => Promise<void> Removes every page.
detect detect(page?: number | string) => Promise<BmxScanDetection | null> Finds a page's edges again; resolves with the detection (null when none was found, and the corners are left).
getPageImage getPageImage(page?: number | string, type?: "image/jpeg" | "image/png") => Promise<Blob | null> A finished page as a picture (JPEG unless type says image/png), at resolution.
getPages getPages() => Promise<BmxDocumentScannerPage[]> The pages (without their pictures).
movePage movePage(from: number, to: number) => Promise<void> Moves a page to another place.
removePage removePage(page: number | string) => Promise<void> Removes a page (by index or id).
rotatePage rotatePage(page: number | string, degrees: number) => Promise<void> Rotates a page by a quarter turn (90 clockwise, -90 the other way) or to an angle.
saveImages saveImages() => Promise<void> Saves every page as a JPEG, together in a .zip.
savePdf savePdf() => Promise<void> Saves every page as one PDF: bmxExport first, then a download unless the event is cancelled.
setCorners setCorners(page: number | string, corners: BmxScanQuad) => Promise<boolean> Sets a page's corners (fractions of the photograph, clockwise from the top left).
setFilter setFilter(page: number | string, filter: BmxScanFilter) => Promise<void> Gives a page another look: color, grayscale, bw or original.
startCamera startCamera() => Promise<boolean> Starts the camera (the browser asks the user first). False when it cannot be used.
stopCamera stopCamera() => Promise<void> Stops the camera and goes back to the pages.
toPdf toPdf() => Promise<Uint8Array> Every page as one PDF.

CSS shadow parts

PartDescription
camera The camera's controls.
pages The list of pages.
stage The page shown large, the camera's view or the corner editor.
status The page count.
toolbar The scanning tools.

CSS custom properties

PropertyDescription
--bmx-document-scanner-height Height of the scanner.
--bmx-document-scanner-outline Colour of the page's outline and corners.
--bmx-document-scanner-pages-width Width of the list of pages.
--bmx-document-scanner-radius Corner radius of the frame.
--bmx-document-scanner-stage Background behind the page and the camera's view.