🌊

GAS を用いた名刺管理をやってみた

15 min read

GAS を用いた名刺管理をやってみた

動機

世の中に名刺管理ソフトはいっぱいあるんですが、どれもお金取ることに積極的過ぎて、一度アップロードした名刺画像をダウンロードすることが出来なかったり、そもそも名刺を画像で登録自体が出来なかったりと、ベンダーロックやら使い勝手の点で納得いくものがなかったので、 Google Drive 上に名刺スキャン画像を置いたまま管理できないかということを考えました。

基本的なアイデア

スキャンした名刺画像は Google Drive の特定のフォルダ以下に配置しておくことにします。また、各名刺スキャン画像を Google Cloud Vision API を使って文字起こしを行い、それらの文字列を Google Spreadsheets に記録しておくことによって、後からお目当ての名刺を検索できるようにしておきます。

スクリプト

メインとなる GAS は以下のコードです。 Google Spreadsheet を新規に作成し、そのスクリプトエディタ内にコピペしてください。その際に、 FOLDER_ID に名刺ファイルを保存したフォルダの ID を、 GOOGLE_API_KEY に Cloud Vision API で使用する API KEY を設定してください。

// プロパティに関する情報
const FOLDER_ID = '';
const GOOGLE_API_KEY = '';

function main() {
  getImgFileFromDrive(FOLDER_ID, "");
}

// Googleドライブからファイル取得
function getImgFileFromDrive(folder_id, folder_path) {
  // var spreadsheet = SpreadsheetApp.openById(SHEET_ID);
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(folder_path + "/");
  if (sheet == null) {
    sheet = spreadsheet.insertSheet();
    sheet.setName(folder_path + "/");
    var row = [
      "表ID", "向き", "表データ", "FileName",
      "裏ID", "向き", "裏データ", "FileName"];
    sheet.getRange(1, 1, 1, 8).setValues([row]);
  }
  const folder = DriveApp.getFolderById(folder_id);
  console.log(folder.getName());
  // 再帰的にフォルダを検索
  const folders = folder.getFolders();
  while (folders.hasNext()) {
    var folder1 = folders.next();
    var folderName = folder1.getName();
    if (folderName.endsWith("_done")) {
      continue;
    }
    if (folderName.endsWith("_work")) {
      continue;
    }
    getImgFileFromDrive(folder1.getId(), folder_path + "/" + folderName);
  }
  // フォルダ内の全ファイルを OCR
  // const files = folder.getFiles();
  const files = folder.getFilesByType("image/png");
  while (files.hasNext()) {
    let file = files.next();
    let row = findRow(sheet, file.getId(), 1);
    if (row > 0) {
      continue;
    }
    row = findRow(sheet, file.getId(), 1 + 4);
    if (row > 0) {
      continue;
    }
    setOCRtoCell(sheet, file, folder_path);
  }
}

function setOCRtoCell(sheet, file, folder_path) {
  let fileName = folder_path + "/" + file.getName();
  var fileMainName = fileName;
  if (fileName.lastIndexOf('.') >= 0) {
    fileMainName = fileName.substring(0, fileName.lastIndexOf('.'));
  }
  var row = Math.max(getLastRow(sheet, 1), getLastRow(sheet, 1+3)) + 1;
  var column = 0;
  if (fileMainName.endsWith("_N1")) {
    // 表
    column = 0;
    let anotherFileName = fileName.replace("_N1.", "_N2.");
    let anotherRow = findRow(sheet, anotherFileName, 8);
    if (anotherRow > 0) {
      row = anotherRow;
    }
  } else if (fileMainName.endsWith("_N2")) {
    // 裏
    column = 4;
    let anotherFileName = fileName.replace("_N2.", "_N1.");
    let anotherRow = findRow(sheet, anotherFileName, 4);
    if (anotherRow > 0) {
      row = anotherRow;
    }
  }
  // console.log(row);
  if (sheet.getMaxRows() < row + 5) {
    sheet.insertRowAfter(row);
  }

  let blob = file.getBlob();
  let content_type = blob.getContentType();
  let base64EncodedFile = Utilities.base64Encode(blob.getBytes());
  // images.push(base64EncodedFile);

  console.log(fileName);
  let result = analyzeImage(base64EncodedFile);
  // Logger.log(result);
  if (result == null) {
    sheet.getRange(row, column + 2).setValue(0);
    sheet.getRange(row, column + 3).setValue("");
    sheet.getRange(row, column + 4).setValue(fileName);
    sheet.getRange(row, column + 1).setValue(file.getId());
    return;
  }
  let orientation = GetExifOrientation(result[1]);
  // console.log(orientation);
  sheet.getRange(row, column + 2).setValue(orientation);
  sheet.getRange(row, column + 3).setValue(result[0].description);
  sheet.getRange(row, column + 4).setValue(fileName);
  sheet.getRange(row, column + 1).setValue(file.getId());
}

function findRow(sheet, val, col){
  var dat = sheet.getDataRange().getValues(); //受け取ったシートのデータを二次元配列に取得

  for(var i = 1; i < dat.length; i++){
    if(dat[i][col-1] === val){
      return i+1;
    }
  }
  return 0;
}

function getLastRow(sheet, column) {
  //処理①A列の最終行のセルを取得(デフォルト1,000行)
  var maxRng = sheet.getRange(sheet.getMaxRows(), column);
  // Logger.log(maxRng.getA1Notation()); //A1000
  
  //処理②maxRngのセル(=A1000セル)で[Ctrl+↑]を押したときに選択されるセルを取得
  var lastRng = maxRng.getNextDataCell(SpreadsheetApp.Direction.UP);
  // Logger.log(lastRng.getA1Notation()); //A8
  
  //処理③lastRngの行番号を取得
  var lastRow = lastRng.getRow();
  // Logger.log(lastRow);

  return lastRow;
}

// Vision APIで画像を解析して結果を取得
function analyzeImage(image) {
  const apiKey = GOOGLE_API_KEY;
  const url = 'https://vision.googleapis.com/v1/images:annotate?key=' + apiKey;

  // 画像からテキストの検出
  let body = {
    "requests": [
      {
        "image": {
          "content": image
        },
        "features": [
          {
            "type": "DOCUMENT_TEXT_DETECTION",
          }
        ],
        "imageContext": {
          "languageHints": ["jp-t-i0-handwrit"]
        }
      }
    ]
  };


  let head = {
    "method": "post",
    "contentType": "application/json",
    "payload": JSON.stringify(body),
    "muteHttpExceptions": true
  };

  let response = UrlFetchApp.fetch(url, head);
  let obj = JSON.parse(response.getContentText());
  // console.log(obj);
  if (obj.responses.length > 0 && obj.responses[0].textAnnotations) {
    let result = obj.responses[0].textAnnotations;
    return result;
  }
  return null;
}

function doGet() {
  // 表示したいHTMLのファイル名を指定(拡張子は記載しない)
  return HtmlService.createTemplateFromFile('index').evaluate();
}

function doPost(e) {
  // スプレッドシートのデータ挿入後、元の画面に戻す
  var temp = HtmlService.createTemplateFromFile('index');
  temp.data = getData();
  return temp.evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE);
}

function h(str) {
  str = str.split('\n').join('<br />');
  return str;
}

function getData(word) {
  // 指定したシートからデータを取得
  // var sheet = SpreadsheetApp.openById(SHEET_ID);
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var regexp = new RegExp(word, 'i');
  var result = [];
  var sheetArray = spreadsheet.getSheets();
  for (var j = 0; j < sheetArray.length; j++) {
    var sheet = sheetArray[j];
    var values = sheet.getDataRange().getValues();
    for (var i = 1; i < values.length; i++) {
      // console.log(i);
      // console.log(values[i][1]);
      // console.log(values[i][1+3]);
      if (String(values[i][2]).match(regexp)) {
        result.push(values[i]);
      } else if (String(values[i][2+4]).match(regexp)) {
        result.push(values[i]);
      }
    }
  }

  return result;
}

/**
 *
 * @param ea  The input EntityAnnotation must be NOT from the first EntityAnnotation of
 *            annotateImageResponse.getTextAnnotations(), because it is not affected by
 *            image orientation.
 * @return Exif orientation (1 or 3 or 6 or 8)
 */
function GetExifOrientation(result) {
    var vertexList = result.boundingPoly.vertices;
    // Calculate the center
    var centerX = 0, centerY = 0;
    for (var i = 0; i < 4; i++) {
        centerX += vertexList[i].x;
        centerY += vertexList[i].y;
    }
    centerX /= 4;
    centerY /= 4;

    var x0 = vertexList[0].x;
    var y0 = vertexList[0].y;

    if (x0 < centerX) {
        if (y0 < centerY) {
            //       0 -------- 1
            //       |          |
            //       3 -------- 2
            return 1; // EXIF_ORIENTATION_NORMAL;
        } else {
            //       1 -------- 2
            //       |          |
            //       0 -------- 3
            return 6; // EXIF_ORIENTATION_270_DEGREE;
        }
    } else {
        if (y0 < centerY) {
            //       3 -------- 0
            //       |          |
            //       2 -------- 1
            return 8; // EXIF_ORIENTATION_90_DEGREE;
        } else {
            //       2 -------- 3
            //       |          |
            //       1 -------- 0
            return 3; // EXIF_ORIENTATION_180_DEGREE;
        }
    }
}

実行方法

main 関数を呼び出すと、 Google Drive の指定したフォルダ以下にある名刺スキャン画像を、再帰的にフォルダを検索しながら、 Cloud Vision API を使用して文字起こしを行っていきます。その際に、 *_N1.png を名刺の表面の画像、 *_N2.png を裏面の画像と見なして、スプレッドシートに書き込んでいきます。また、名刺画像がたくさんあると、 GAS の実行制限時間内にすべての名刺画像の文字起こしを行うのは難しいので、途中で停止させられると思います。その場合には、続きから文字起こしが出来ますので、終了するまで何度も実行してください。

Web インターフェース

以下のコードをスクリプトエディタ内で index.html として追加してください。そして、スクリプトを deploy してブラウザ等でアクセスすると、 Web 上から名刺を検索し、表示させることが出来ます。

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <meta charset="UTF-8">
    <title>名刺管理</title>
    <meta name="viewport" content="width=device-width,
      initial-scale=1.0,user-scalable=yes" />
    <style>
      body {
        background: #dddddd;
        text-align: center;
      }
      input {
        font-size: 3.0em;
      }
      button {
        font-size: 3.0em;
      }
      table {
        border: solid 1px #000000;
        border-collapse: collapse;
        width: 100%;
        font-size: 0.6em;
      }
      th {border: solid 1px #000000}
      td {border: solid 1px #000000}
    </style>
  </head>
  <body>
    <h2>Business Cards</h2>
    <label>word:
      <input type="text" id="word" name="word" onkeyup="onKeyUp();">
    </label>
    <button onclick="doAction();">検索</button>
    <table id="result"></table>
    <script>
      function onKeyUp() {
        var e = event;
        if ( e.keyCode !== 13 ||
          ( e.keyCode === 13 &&
            (e.shiftKey === true || e.ctrlKey === true || e.altKey === true) )) {
          // Enterキー除外
          return false;
        }
        // ここに処理をかく
        doAction();
      }
      function doAction(){
        var word = document.getElementById("word").value;
        var elem = document.getElementById('result');
        while (elem.firstChild) {
          elem.removeChild(elem.firstChild);
        }
        google.script.run.withSuccessHandler(onSuccess).withFailureHandler(onFailure).getData(word);
      }
      function onSuccess(res){
        // console.log(res);
        var elem = document.getElementById('result');
        var image_width = document.body.clientWidth * 0.28;
        {
          var row = document.createElement('tr');
          var th = document.createElement('th');
          th.setAttribute("width", image_width + "px");
          th.innerText = "表面イメージ";
          row.appendChild(th);
          var image_tag = th;
          th = document.createElement('th');
          th.setAttribute("width", "20%");
          th.innerText = "表面情報";
          row.appendChild(th);
          th = document.createElement('th');
          th.setAttribute("width", image_width + "px");
          th.innerText = "裏面イメージ";
          row.appendChild(th);
          th = document.createElement('th');
          th.setAttribute("width", "20%");
          th.innerText = "裏面情報";
          row.appendChild(th);
          elem.appendChild(row);
          // image_width = parseInt(window.getComputedStyle(image_tag).width);
        }
        for (var i = 0; i < res.length; i++) {
          var result = res[i];
          // console.log(result);
          var row = document.createElement('tr');

          // 表
          var id = document.createElement('td');
          if (result[0+0]) {
            id.innerHTML = '<a href="https://drive.google.com/file/d/'+ result[0+0] + '/view"'
              + ' target="_blank">'
              + result[3+0] + '</a><br />';
            var div = document.createElement('img');
            div.setAttribute("src", "https://drive.google.com/uc?id=" + result[0+0]);
            var style = rotateStyle(result[1+0], image_width);
            div.setAttribute("style", style);
            id.appendChild(div);
          }
          row.appendChild(id);

          var content = document.createElement('td');
          content.innerText = result[2];
          row.appendChild(content);

          // 裏
          id = document.createElement('td');
          if (result[0+4]) {
            id.innerHTML = '<a href="https://drive.google.com/file/d/'+ result[0+4] + '/view"'
              + ' target="_blank">'
              + result[3+4] + '</a><br />';
            var div = document.createElement('img');
            div.setAttribute("src", "https://drive.google.com/uc?id=" + result[0+4]);
            var style = rotateStyle(result[1+4], image_width);
            div.setAttribute("style", style);
            id.appendChild(div);
          }
          row.appendChild(id);

          content = document.createElement('td');
          content.innerText = result[2+4];
          row.appendChild(content);

          elem.appendChild(row);
        }
      }
      function onFailure(res){
        console.log(res);
      }
      function rotateStyle(orientation, image_width) {
        style = "object-fit: contain; position: relative; margin-left: auto; margin-right: auto;";
        style += " width: " + image_width + "px; height: " + image_width + "px;";
        if (orientation === 1) {
          style += " transform: rotate(0deg);"
        } else if (orientation === 6) {
          style += " transform: rotate(90deg);"
        } else if (orientation === 3) {
          style += " transform: rotate(180deg);"
        } else if (orientation === 8) {
          style += " transform: rotate(270deg);"
        } else {
          style += " transform: rotate(0deg);"
        }
        return style;
      }
    </script>
  </body>
</html>

工夫した点

名刺スキャン画像内の名刺の向きがどんな方向であっても、 Cloud Vision API は自動で文字起こしをしてくれるのですが、それだけだと Web 上で名刺の向きがばらばらになってしまいます。少し調べてみると、 Stack Over Flow の この投稿 にあるように、いくらかは推定可能みたいなので、文字起こしの際に名刺の向きもスプレッドシートに書き込み、表示の際に回転しています。

まとめ

単純に GAS を使って名刺を Google Drive で管理するようにしただけですが、私の用途ではこれだけで激しく便利になりました。名刺スキャンには一般的なドキュメントスキャナを使用しているのですが、それだけだと名刺画像が少し傾くことが多く、几帳面(自己主張)な私にはそれが我慢ならなかったので、今は画像処理を施して、名刺スキャン画像を台形補正することに取り組んでいます。まとまったら、またご報告させていただきたいと思います。