🖨️

レシートプリンターを改造して搭乗券を作れるツールを、Tauriでいい感じにつくった話

に公開

経緯

空港で見た搭乗券用のプリンターF9860くんが、NECのMultiCoder 300S2DCUくんとそっくりだったので、ちょっと改造すればおうちで飛行機のチケットを作れるんじゃないかと思った

試行錯誤

ESC/POS

まず、このプリンターを制御するためにESC/POSで制御を行おうと考えた。ファンフォールド用紙向けのESC/LABELではないが、レシート用でもだいたい雰囲気で制御すれば行けるやろと考えた。

(ESC/POSでもブラックマークがあれば制御は簡単だが、一般的な航空系のチケットにブラックマークはないのでこれは美学に反する。)

印刷サイズをいい感じに処理することで対策した。

印字内容

航空券のフォントは結構独特なのだが、これがどうも呼び出せないようだ。Font機能は事実上の文字サイズのようで、思っている文字は設定できない。仕方がないので、それっぽいフォントを埋め込んでそれっぽくすることとした。

さらに、レイアウトを作るのが面倒だったので、svgを正規表現で書き換え、それをビットマップ画像化することとした。過去にcanvasに直描画するのも試したのだが、レイアウトの修正がかなりめんどくさかったのでsvg万歳、となった。

発行ソフトの作成

雰囲気を出すためにGDSごと自作したくなってきたので、いったんDCSモドキをtauriでつくった。といっても、搭乗券の発券と予約の作成しかできないのだが。

native usbが便利なので、これを使って以下のコマンドで処理ができるようにした。rusbを使う方法もあるのだが、なぜか権限周りが正しく動いてくれなかったのでnative usbを採用した。Windowsで使うときにドライバ周りが少しめんどくさくなるが、開発環境はMacなので目を瞑ることとした。

use escpos::driver::*;
use escpos::printer::Printer;
use escpos::utils::*;

#[derive(Debug, Clone, serde::Serialize)]
struct DeviceInfo {
    vendor_id: u16,
    device_id: u16,
    name: String,
}

#[tauri::command]
fn get_usb_devices() -> Vec<DeviceInfo> {
    let mut devices: Vec<DeviceInfo> = vec![];
    
    if let Ok(device_list) = nusb::list_devices() {
        for device in device_list {
            let vendor_id = device.vendor_id();
            let device_id = device.product_id();
            let name = device.product_string().unwrap_or_default();
            devices.push(DeviceInfo {
                vendor_id,
                device_id,
                name: name.to_string(),
            });
        }
    }
    
    devices
}

#[tauri::command]
fn pass_print(vendor_id: u16, device_id: u16, bcbp_data: String, image_data: Vec<u8>) -> Result<(), String> {

    let driver = NativeUsbDriver::open(vendor_id, device_id).map_err(|e| e.to_string())?;
    let protocol = Protocol::default();
    let mut printer = Printer::new(driver, protocol, None);
    let printer = printer.init().map_err(|e| e.to_string())?;    
    printer.bit_image_from_bytes_option(
            &image_data,
            BitImageOption::new(Some(1600), Some(1800), BitImageSize::Normal).unwrap(),
        )
        .and_then(|p: &mut Printer<NativeUsbDriver>| p.feeds(4))
        .and_then(|p: &mut Printer<NativeUsbDriver>| p.pdf417(&bcbp_data))
        .and_then(|p: &mut Printer<NativeUsbDriver>| p.feeds(1))
        .and_then(|p: &mut Printer<NativeUsbDriver>| p.print())
        .map_err(|e| e.to_string())?;
    
    Ok(())
}

フロントエンド

ちなみに、フロントエンドはreactで作っているが、実態としてはviteで一般的なSPAを作っているだけである。そのため、ルーティングはTanstack Routerに丸投げしてファイルベースの管理しやすいものが仕上がった。(ちなみにNext.jsはTauri v1で保守切れ。)

ついでに、これまでの記述とプログラムを見てもらったら大方察するだろうが、券面事項はフロントエンドで作成し、バーコードの文字列と券面の画像を送り込んでいる。本来はPECTABなどの規格で一括で送り込むべきなのだが、今回はESC/POS縛りになっているのでESC/POSでがんばるのである。

svgを特定サイズの画像に落とし込み、それをPNGのUnit8Arrayに変換させるプログラムである。正直もっときれいにできそうな気がするものの、リファクタするのも面倒なのでそのうちAIにやらせようと思っている。

    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload = () => {
        // キャンバスを作成してSVGを描画(印刷に適したサイズに設定)
        const canvas = document.createElement("canvas");
        const ctx = canvas.getContext("2d");

        if (!ctx) {
          reject(new Error("Canvas context の取得に失敗しました"));
          return;
        }

        // 印刷に適したサイズを設定(搭乗券として十分な解像度)
        const targetWidth = 640;
        const targetHeight = 1300;

        canvas.width = targetWidth;
        canvas.height = targetHeight;

        // 背景を白色で塗りつぶし
        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // SVGをキャンバスに適切にスケールして描画
        const scaleX = targetWidth / (img.width || targetWidth);
        const scaleY = targetHeight / (img.height || targetHeight);
        const scale = Math.min(scaleX, scaleY); // アスペクト比を保持

        const scaledWidth = img.width * scale;
        const scaledHeight = img.height * scale;
        const x = (targetWidth - scaledWidth) / 2;
        const y = (targetHeight - scaledHeight) / 2;

        ctx.drawImage(img, x, y, scaledWidth, scaledHeight);

        // キャンバスからBlobを作成
        canvas.toBlob(async (blob) => {
          if (blob) {
            const arrayBuffer = await blob.arrayBuffer();
            resolve(new Uint8Array(arrayBuffer));
          } else {
            reject(new Error("Blobの作成に失敗しました"));
          }
        }, "image/png");
      };

      img.onerror = () => {
        reject(new Error("SVG画像の読み込みに失敗しました"));
      };

      // SVGをdata URLとして設定
      const svgBlob = new Blob([svgContent], { type: "image/svg+xml" });
      const url = URL.createObjectURL(svgBlob);
      img.src = url;
    });

BCBPの実装

搭乗券についているバーコードはIATAのBCBPという規格で定まっている。内容の文字列はもちろん、使用可能なバーコード形式(QRとか、DataMatrixとか)まで細かく定められている。

今回はもっともメジャーなPDF417をバーコードリーダが吐けるとのことだったので、文字列だけ送り込めばよさそうである。

BCBPの実装方法は BCBP Implementation Guide や関連の勧告書を参考に作成することとなる。また、npmパッケージbcbpをはじめとする、仕様を先に実装してくれたツールもあるため、それをもとに文字列を実装した。 PNRGOVとはまた違って、構造化されたテキストではないので自力実装は結構面倒。

物理的な必要品の調達

ファンフォールド紙は結構雰囲気を出すために大事。

いい感じの商品があったアリババ(アリエクではなく)で購入した。

実際の様子

https://www.nicovideo.jp/watch/sm45079516

↑こちらの動画を参照されたい。

まとめ

結構めんどくさくはあったが、いい感じのものが出来上がったと自負している。

歴史ある制御系のコマンドはギッチギチに文字列が詰め込まれているが、先人たちの書いてくれたプログラムによってほぼ雰囲気だけで実装することができた。

USB周りはさらなる研究がかなり必要である。今のところLinuxをDCSのPCに採用しているところは見たことがないので、Windowsでちゃんと動いてほしいなあという気持ちを持っておきたい。可能ならば実際の航空業界仕様に合わせて実装したいものだ。

Discussion