ブラウザのクライアントサイドのみでバイナリを編集して結果を保存する

以前、日本語を含む Keynote を slideshare にアップロードするというエントリーを書いたんですが、普段ターミナルを使わない方でも変換できるように、ブラウザに Drag & Drop すれば変換された PDF を保存できるようにしました。
Chrome 60.0.3112.90, Safari 10.1.1, Firefox 54.0.1 では動きましたが、ちょっと古いバージョンや IE だと動かないかもしれません。

お使いのブラウザは対応していません

デモ

demo

技術的な解説

HTML を見ればわかりますが、次のような処理をしています。

  1. FileReader#readAsArrayBuffer で Drag & Drop されたファイルを読み込む
  2. Uint8Array に変換してバイナリを編集する
  3. URL.createObjectURL で URL を生成して、別タブで開く

具体的には次のような処理をしています。
今回は対象が PDF でしたが、画像であれば変換処理後に blob を作成する箇所で { type: 'image/png' } を指定すれば別タブで PNG を開くことができます。コンテキストメニューで「Save Image As…」を選択すれば保存もできます。

const FROM_CHAR_CODES = Array.from('/Registry (Adobe) /Ordering (Japan1) /Supplement ').map((c) => c.charCodeAt(0));
const TO_CHAR_CODES   = Array.from('/Registry(Adobe) /Ordering(Identity) /Supplement ').map((c) => c.charCodeAt(0));

function matchArray(targetArray, searchValues, fromIdx) {
  for (let i = 0, len = searchValues.length; i < len; ++i) {
    if (targetArray[fromIdx + i] !== searchValues[i]) {
      return false;
    }
  }
  return true;
}

//    "/Registry (Adobe) /Ordering (Japan1) /Supplement [0-9]"
// => "/Registry(Adobe) /Ordering(Identity) /Supplement 0"
function convertPdf(arrayBuffer) {
  const bytes = new Uint8Array(arrayBuffer);

  let currentIdx = 0;
  let maxIdx = bytes.length - FROM_CHAR_CODES.length - 1;
  while (currentIdx <= maxIdx) {
    let lastByte = bytes[currentIdx + FROM_CHAR_CODES.length];
    if (matchArray(bytes, FROM_CHAR_CODES, currentIdx) && lastByte >= 48 && lastByte <= 57) {
      for (let i = 0, len = TO_CHAR_CODES.length; i < len; ++i) {
        bytes[currentIdx + i] = TO_CHAR_CODES[i];
      }
      bytes[currentIdx + TO_CHAR_CODES.length] = 48;
      currentIdx += TO_CHAR_CODES.length + 1;
    } else {
      ++currentIdx;
    }
  }

  return bytes;
}

function appendConvertedPdfLink(file) {
  const reader = new FileReader();
  reader.addEventListener("load", async (evt) => {
    const bytes = convertPdf(reader.result);
    const div = document.createElement('div');
    const a = document.createElement('a');
    a.innerHTML = `Open converted ${file.name}`;
    // cf. https://stackoverflow.com/questions/31246204/convert-canvas-to-image-then-download
    a.href = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));;
    a.target = '_blank';
    div.appendChild(a);
    document.getElementById('converted-pdfs').appendChild(div);
  });
  reader.readAsArrayBuffer(file);
}

function onDrop(evt) {
  evt.preventDefault();
  const files = evt.dataTransfer.files;
  for (let i = 0, len = files.length; i < len; ++i) {
    appendConvertedPdfLink(files[i]);
  }
}

document.addEventListener('DOMContentLoaded', (evt) => {
  const dropArea = document.getElementById('droparea');
  dropArea.innerHTML = 'ここにドラッグ & ドロップ';
  dropArea.addEventListener('dragover', evt => evt.preventDefault());
  dropArea.addEventListener('drop', onDrop);
});

変換した画像を保存するみたいなことにも応用できそうですね!