最近3Dプリントで作ったもの紹介

友人が3Dプリンターで色々作っている様子を見ていて、以前から興味は持っていたのですが、 Scott Yu-JanさんのYouTube動画 を色々見るようになってからまた3Dプリントに興味がでてきたので、最近ようやくFusion 360を触り始めました。今回は3D CADの勉強方法と作ったものを紹介します。

3D CADの勉強と3Dプリントサービスでの注文

3D CADは id:puhitaku も使っているFusion 360を使い始めました。Fusion 360はかなりよくできていて、コンピュータにある程度慣れている人ならYouTube動画を1本見ればある程度使い始められると思います。自分は↓の動画を見たのですが、動画なので操作に迷うこともなく進められてかなりおすすめです。

youtu.be

3Dプリンターは持っていないので、外部の3Dプリントサービスを利用してプリントします。金額感はあまりわかっていなかったのですが、試しにこのチュートリアル動画で作成したケースを国内の某3Dプリントサービスで注文見積もりをすると11,822円となりました。このチュートリアルでは3.5mm厚でケースを作っているのですが、かなり分厚いようでその分料金も高くなっています。料金を下げるには、ケースの厚みを薄くしたりしながら体積を減らす必要があります。

自分は 3Dプリントサービスのガイドライン を見ながら、1.2-1.5mmぐらいの厚さでモデルを作成しました。また、強度をできるだけ保ちつつさらに価格を下げる方法としてHoneycomb構造にする方法や小さい部品は接続してまとめてプリントする方法があり、これらを取り入れながら料金を下げて印刷しています。

とはいえモデルに色々工夫を入れても注文を躊躇してしまう金額だったので、他のサービスを調べてみました。最終的にJLCPCBがかなり安くこちらで印刷しています。そもそも素材が違ったりするのでフェアな比較はできませんが、最も安い素材同士で比較すると1/3ぐらいの金額で印刷できるという印象です。同じ理由で注文をためらっていたという方はぜひご検討ください。

jlcpcb.com

作ったもの紹介

ここからは作ったものを紹介していきます。

Raspberry Pi Zero 2 Wケース

Raspberry Pi Zero 2 W Case

Raspberry Pi 4Bのケースは結局既製品をAli Expressで買ったのですが、Raspberry Pi Zero 2 Wのケースは2つ作成しました。1つは大きめのヒートシンクと一緒に使えるもの(画像左)、もうひとつは小型のLCDディスプレイや電源用のボタンスイッチ、LED(赤・白)をつけれるようにしたもの(画像右) です。Raspberry Pi Zero 2 Wのケースは小さくプリント費用も送料もかなり安いので3Dプリントの入門にはちょうどよいかもしれません。為替レートによって変動しますが、白い方が133.27円と黒い方が352.94円でした。このサイズなら複数個注文しても送料はおそらく200円台なので、トータル費用で比べてもかなり安くプリントができます。

白いケースはオフホワイトのレジン(8111X Resin)、黒いケースはSLS Nylon (3201PA-F Nylon)で印刷しました。電子機器のケースは耐熱温度を考えると基本的にNylonを選択したほうがよいと思いますが、デザイン的にオフホワイトを使ってみたかったのと、大きめのヒートシンクを積んでいるのでそんなに温度が上がらないことを願ってResinで印刷しています。

基本的に寸法もピッタリで反りも発生せず綺麗に印刷できていますが、右側のケースは蓋の表面にいれていた文字が読めませんでした。原因は自分が0.05mmで彫っていたので、プリント精度の問題なのか肉眼では識別できないぐらい彫りが浅いのかわかりませんが、さすがに厳しかったようです。

Raspberry Pi Pico Wケース

Raspberry Pi Pico Wとブレッドボードを取り付けて壁掛けできるケースです。 用途としては、自宅サーバーで動かしているHome Assistantと連携して何かしらのセンサーデータを送ったり、赤外線LEDをつけてHome Assistant経由で家電を制御したりしようと思っていたのですが、取り付けるセンサーや配置を決めきるのが難しそうだったので、ブレッドボードとRaspberry Pi Pico Wのデバッグ基盤ごと壁掛けできるようにしました。

ブレッドボードにLEDやLCDディスプレイなどを取り付けても見れるように、前面にはアクリル板を差し込みました。間違えてアクリル板の厚みである2mmピッタリの溝を彫ってしまっていたのですが、問題なくアクリル板は差し込めました。アクリル板の保護フィルムを付けた状態だと固くて入らなかったのでプリント精度の高さに助けられました。ただ結構反りが出てしまっていて、上下のパーツを連結するためのスナップがうまく機能せず蓋が簡単に外れます。ドライヤーで少し調整しましたがまだあまりピタッとハマる感じにはなっていません。スナップ部分は失敗しても固定できるようなプランを設計時に作っておくと良い気がします。

壁掛けディスプレイ

DELLのモニターが余っていたのですが、VESA非対応で使い道が無かったので分解して壁掛けディスプレイにしました。 モニターコントローラー部分は高電流が流れることもあり、専用ケースを設計してちゃんと固定できるようにしています。

こちらも先程のRaspberry Pi Pico Wのケースと同様、スナップで上下のパーツを固定する仕組みにしています。スナップが失敗したときのためにマジックテープで固定できるような穴を用意していたのですが、幸いにもスナップが機能して使わずに済みました。大きなパーツなので薄さを考えるとRaspberry Pi Pico Wケースよりも反りが出やすそうな気がしていたのですがうまくいったみたいです (反りの感覚はまだよくわかっておらず難しいですね...)

肝心の壁掛けディスプレイはAtmoph Window みたいにできるといいなと思っていたのですが、同じクオリティーにはならなそうな気がしています。フレームの設計で一部失敗した部分もありどう修正するかまだ考え中なのですが、時間がかかりそうだったので先にこのブログを公開しました。無事にできたらまた追記するか新しい記事を出そうと思います。

デスク周りの色々

テーブルグロメット

50mmのホールソーが家にあるので、机に50mmの穴をあけて机に充電器とかをさせるようにテーブルグロメットを作ってみました。ちゃんと伝わっているか自信がありませんが、イメージとしてはコンセント部分を挟んで固定するイメージです。今回印刷した中で唯一ネジの機構を付けたものでした。

結論としてはちょっと失敗していて、ネジを回そうとすると摩擦でかなり硬くなっています。ネジとネジ穴をピッタリのサイズで作ってしまっていたのが原因で、余白が必要でした。気が向いたときにやすりがけしてみます。

扇風機のマウントとカバー

普段の開発デスクは80cm x 60cmと結構小さめであまりスペースがありません (MTGが被ったときにキッチンスペースに机を移動させたりする関係で今は小さめのデスクを使用しています)。夏場はキャンプ用の三脚の扇風機とかも使っているのですが、机に置くと結構邪魔なのでマウントを作りました。

Before After

ついでにファンのカバーパーツがグレーで、自分の部屋に置くなら白いほうがいいかなと思い自作しました。構造的にピッタリ合わせるの難しいかなと思っていたのですが、印刷精度も高かったおかげでピッタリはまりました。このパーツに限らず円柱の直径を測ったり、丸みを帯びた物体の幅を調べるのは定規だとかなり難しかったので、Ali Expressで300円弱で購入したデジタル式のノギスで測りました。プラスチック製なのでもしかするとしなって測定誤差に繋がっていたりはしたのかもですが、使っている感じは問題なく概ね0.1mm単位で測れている感じがしました。

素材はJLCPCBで一番安いLEDO 6060 Resinを使いました。時間帯や光の当たり方によっては黄色みがかっているのでインテリアを考えるならやっぱり少し値段を払ってオフホワイトの8111X Resinを選ぶとよさそうです。

デスク周りその他

寸法を間違えてしまったのと構造的にはあまり工夫のないパーツなので詳細は省略しますが、他にもいくつか作っていました。

  • デスク横に通勤カバンをかけるためのパーツ
    • 特に失敗はしておらずむしろうまくできた気がするのですが、構造的には何も面白みがないので省略します。
  • Google NEST MiniとNature Remoを机の下に固定するパーツ
    • Nature Remoの寸法を測り間違えてしまい、Google NEST Miniしか固定できませんでした。Nature Remoは無理やり固定して使っています。
  • 机の裏にティッシュ固定するためのパーツ
    • これまで無印のダンボール製のティッシュケースを両面テープで机の下に固定していたのですが、白に変えたかったので自作。一応使っていますが、すこし小さく作りすぎました。
  • MagSafe充電スタンド
    • AnkerのMagsafe充電器をはめ込んで使うものなのですが、充電器とスマホの間に距離ができすぎてしまい充電はできるのですがスマホを固定できるほどの磁力がありませんでした。
    • また一度つけたMagsafe充電器を外す際、めちゃくちゃ固くて強くやったら割れました。色々と失敗...

おわりに

以上、作ったものの紹介でした。 紹介しきれていないものもあるのですが、残りのアイテムも構造上は特に工夫がないのでこの記事での紹介はここまでにしようと思います。

最後の件に限らず失敗の可能性が高そうなアイテムは発注段階で細かくチェックしてメールで確認してくれるので、サポートとは頻繁にやりとりをしました。 システムのバグ (e.g. 注文ページで金額の表示がおかしかったり、通貨設定をJPYからUSDに変更してもJPYで表示が続いたり) にもいくつか遭遇して報告していましたが、返信も早く助かりました。

JLCPCBは本当におすすめで、今後も作りたいものが思いついたら注文を続けると思います。 公式TwitterアカウントにDMすると初回登録クーポンが多めにもらえるのでみなさんもぜひ!

twitter.com

PDFMinerコードリーディングメモ ① Indirect Object ReferenceのTokenize処理

しばらくこちらのブログを更新していなかったのですが、2023年からはもう少しGitHub以外のアウトプットも増やしてみようかなということで、試しに今回はコードリーディング時のメモとかも残していってみます。

ここ数日PDFの仕様書を読み進めているのですが、仕様書からは読み取りきれないところとかパーサーを書く際に設計で悩みそうなところがいくつかあったので、Shimizukawaさんも挙げられているPDFMinerのコードを読んでみました。

PDFParser クラスと PDFDocument クラス

PDFMinerでは次のように PDFParserクラスとPDFDocumentクラスを使ってまずCross-Reference TableやTrailer Dictionaryをパースしています。 まずはそれぞれの役割抑えてみます。

from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfparser import PDFParser


with open("simple.pdf", 'rb') as f:
    parser = PDFParser(f)
    doc = PDFDocument(parser, password="")

PDFParser はファイルオブジェクトを受け取ってファイルから必要な情報を抜き出します。例えばPDFファイルをopenしたら最初にやるのは末尾から1行ずつ読みこんでCross-Reference Tableのファイル内オフセットを取得します。Pythonのファイルライクオブジェクトに後ろから1行ずつ読み込む方法は存在しませんが、 PDFParser().revreadlines() のようにPDFParserクラス (厳密にはこのようなファイルオブジェクトのちょっとしたラッパー程度の処理はPDFParserの親クラスであるPSBaseParserクラスに実装されています) が提供します。

# https://github.com/pdfminer/pdfminer.six/blob/5114acdda61205009221ce4ebf2c68c144fc4ee5/pdfminer/psparser.py#L272-L295
    def revreadlines(self) -> Iterator[bytes]:
        """Fetches a next line backword.
        This is used to locate the trailers at the end of a file.
        """
        self.fp.seek(0, 2)
        pos = self.fp.tell()
        buf = b""
        while 0 < pos:
            prevpos = pos
            pos = max(0, pos - self.BUFSIZ)
            self.fp.seek(pos)
            s = self.fp.read(prevpos - pos)
            if not s:
                break
            while 1:
                n = max(s.rfind(b"\r"), s.rfind(b"\n"))
                if n == -1:
                    buf = s + buf
                    break
                yield s[n:] + buf
                s = s[:n]
                buf = b""
        return

その一方、PDFを開いたあとに startxref やCross-Reference Tableの中身の解析を担当するのは PDFDocument クラスのようです。このクラスの初期化時に、 PDFDocument.find_xref(parser) でCross-Reference Tableのファイル内オフセット(int)を取得し、 xrefs: list[PDFBaseXRef] = []; PDFDocument.read_xref_from(parser, startxref, xrefs) を実行すると xrefs にCross Reference Tableのエントリー一覧をappendします。

# https://github.com/pdfminer/pdfminer.six/blob/5114acdda61205009221ce4ebf2c68c144fc4ee5/pdfminer/pdfdocument.py#L965-L1016
    # find_xref
    def find_xref(self, parser: PDFParser) -> int:
        """Internal function used to locate the first XRef."""
        # search the last xref table by scanning the file backwards.
        prev = None
        for line in parser.revreadlines():
            line = line.strip()
            log.debug("find_xref: %r", line)
            if line == b"startxref":
                break
            if line:
                prev = line
        else:
            raise PDFNoValidXRef("Unexpected EOF")
        log.debug("xref found: pos=%r", prev)
        assert prev is not None
        return int(prev)

    # read xref table
    def read_xref_from(
        self, parser: PDFParser, start: int, xrefs: List[PDFBaseXRef]
    ) -> None:
        """Reads XRefs from the given location."""
        parser.seek(start)
        parser.reset()
        try:
            (pos, token) = parser.nexttoken()
        except PSEOF:
            raise PDFNoValidXRef("Unexpected EOF")
        log.debug("read_xref_from: start=%d, token=%r", start, token)
        if isinstance(token, int):
            # XRefStream: PDF-1.5
            parser.seek(pos)
            parser.reset()
            xref: PDFBaseXRef = PDFXRefStream()
            xref.load(parser)
        else:
            if token is parser.KEYWORD_XREF:
                parser.nextline()
            xref = PDFXRef()
            xref.load(parser)
        xrefs.append(xref)
        trailer = xref.get_trailer()
        log.debug("trailer: %r", trailer)
        if "XRefStm" in trailer:
            pos = int_value(trailer["XRefStm"])
            self.read_xref_from(parser, pos, xrefs)
        if "Prev" in trailer:
            # find previous xref
            pos = int_value(trailer["Prev"])
            self.read_xref_from(parser, pos, xrefs)
        return

PDFParserにはもう一つ役割があり、むしろこちらがメインの役割だと思うのですが、PDFオブジェクトのTokenizeを担当します。 今回はTokenizeで不明点があったのでそこを調べてみました。

PDF Dictionary ObjectとIndirect Reference

今回調べたかったのはDictionary Object(辞書オブジェクト)のパースです。PDFを開いて最初にパースする辞書オブジェクトは基本的にはPDFの末尾に配置されているTrailer辞書オブジェクトというものになるかと思いますがそれは次のような形式です。

<<
  /Size 6
  /Root 1 0 R
>>

辞書オブジェクトはKey-value形式で、Keyは名前オブジェクト(/ から始まる文字列のような型)であることが仕様上定められています。このTrailer辞書オブジェクトは /Size/Root という2つのKeyとそれに対するValueが存在しているようです。次にもう一つ別の例を見てみます。

 << /Type /Example
      /Subtype /DictionaryExample
      /Version 0.01
      /IntegerItem 12
      /StringItem (a string)
      /Subdictionary << /Item1 0.4
          /Item2 true
          /LastItem (not!)
          /VeryLastItem (OK)
     >>
     /Foo [1 2 3]
>>

この例では /Type というKeyに対して、 /Example という名前オブジェクトをValueとして指定されています。 ここでの疑問はパースの方法です。2つめの例を見るに、1つのKeyに対して1つのValueを持つという前提がなければ /Type /Example /SubType /DictionaryExample というバイト列はどれがKeyでどれがValueかを特定できません。そこでナイーブにKeyとValueが1:1であることを前提にパーサーを実装すると1つめの辞書の /Root に対するValue1 (整数型) のみとなるはずです。

仕様書を読み返すと先程の /Root 1 0 R1 0 R はIndirect ReferenceというIndirect Objectへの参照を表すようです。

The object may be referred to from elsewhere in the file by an indirect reference. Such indirect references shall consist of the object number, the generation number, and the keyword R (with white space separating each part): 12 0 R

整数オブジェクト2つとキーワードRを並べているのですが、特別なシンボルでまとめられていたりはしないため、うまくパースする方法が思いつかなかったので処理をもう少し追ってみます。

PDFMinerでの実装

このようなTrailer辞書オブジェクトのパースを担当するのは PDFParser.nextobject() メソッドです。

# https://github.com/pdfminer/pdfminer.six/blob/5114acdda61205009221ce4ebf2c68c144fc4ee5/pdfminer/psparser.py#L600-L675

    def nextobject(self) -> PSStackEntry[ExtraT]:
        """Yields a list of objects.
        Arrays and dictionaries are represented as Python lists and
        dictionaries.
        :return: keywords, literals, strings, numbers, arrays and dictionaries.
        """
        while not self.results:
            (pos, token) = self.nexttoken()
            if isinstance(token, (int, float, bool, str, bytes, PSLiteral)):
                # normal token
                self.push((pos, token))
            elif token == KEYWORD_ARRAY_BEGIN:
                # begin array
                self.start_type(pos, "a")
            elif token == KEYWORD_ARRAY_END:
                # end array
                try:
                    self.push(self.end_type("a"))
                except PSTypeError:
                    if settings.STRICT:
                        raise
            elif token == KEYWORD_DICT_BEGIN:
                # begin dictionary
                self.start_type(pos, "d")
            elif token == KEYWORD_DICT_END:
                # end dictionary
                try:
                    (pos, objs) = self.end_type("d")
                    if len(objs) % 2 != 0:
                        error_msg = "Invalid dictionary construct: %r" % objs
                        raise PSSyntaxError(error_msg)
                    d = {
                        literal_name(k): v
                        for (k, v) in choplist(2, objs)
                        if v is not None
                    }
                    self.push((pos, d))
                except PSTypeError:
                    if settings.STRICT:
                        raise
            elif token == KEYWORD_PROC_BEGIN:
                # begin proc
                self.start_type(pos, "p")
            elif token == KEYWORD_PROC_END:
                # end proc
                try:
                    self.push(self.end_type("p"))
                except PSTypeError:
                    if settings.STRICT:
                        raise
            elif isinstance(token, PSKeyword):
                log.debug(
                    "do_keyword: pos=%r, token=%r, stack=%r", pos, token, self.curstack
                )
                self.do_keyword(pos, token)
            else:
                log.error(
                    "unknown token: pos=%r, token=%r, stack=%r",
                    pos,
                    token,
                    self.curstack,
                )
                self.do_keyword(pos, token)
                raise
            if self.context:
                continue
            else:
                self.flush()
        obj = self.results.pop(0)
        try:
            log.debug("nextobject: %r", obj)
        except Exception:
            log.debug("nextobject: (unprintable object)")
        return obj

このメソッドは PDFParser.nexttoken() メソッドを呼び出してtokenを取得しながら辞書オブジェクトを構築していきます。Indirect Object Referenceを含む << /Size 6 /Root 1 0 R >> のような辞書を入力に対して PDFParser.nexttoken() を何度も呼び出していくとやはり次のようなToken列に分割されます。

  1. << トーク
  2. /Size 名前トーク
  3. 6 整数トーク
  4. /Root 名前トーク
  5. 1 整数トーク
  6. 0 整数トーク
  7. R キーワードトーク

ここでは 1 0 R とやはりまとめられてはおらず困るように思えましたが、Tokenize時にキーワードトークンを受け取った際は次のような処理を入れて対応しているようです。

# https://github.com/pdfminer/pdfminer.six/blob/master/pdfminer/pdfparser.py#L74-L84
class PDFParser(PSStackParser[Union[PSKeyword, PDFStream, PDFObjRef, None]]):
    ...
    KEYWORD_R = KWD(b"R")
    KEYWORD_NULL = KWD(b"null")
    KEYWORD_ENDOBJ = KWD(b"endobj")
    KEYWORD_STREAM = KWD(b"stream")
    KEYWORD_XREF = KWD(b"xref")
    KEYWORD_STARTXREF = KWD(b"startxref")

    def do_keyword(self, pos: int, token: PSKeyword) -> None:
        """Handles PDF-related keywords."""

        if token in (self.KEYWORD_XREF, self.KEYWORD_STARTXREF):
            self.add_results(*self.pop(1))

        elif token is self.KEYWORD_ENDOBJ:
            self.add_results(*self.pop(4))

        elif token is self.KEYWORD_NULL:
            # null object
            self.push((pos, None))

        elif token is self.KEYWORD_R:
            # reference to indirect object
            if len(self.curstack) >= 2:
                try:
                    ((_, objid), (_, genno)) = self.pop(2)
                    (objid, genno) = (int(objid), int(genno))  # type: ignore[arg-type]
                    assert self.doc is not None
                    obj = PDFObjRef(self.doc, objid, genno)
                    self.push((pos, obj))
                except PSSyntaxError:
                    pass

Rキーワードが来たらトークン一覧から2つpopしてPDFObjRefトークンを詰め直すということをやっています。 Tokenを一度に読み出す必要はありますが、辞書オブジェクトの中でしかでてこないならひとまずこれが一番シンプルで合理的に思えます。 先読みも一応可能ですが、整数オブジェクトが見つかった際に常にそれがIndirect Referenceかをチェックするのは、整数オブジェクトのほうが数が多いのでパフォーマンス上もデメリットがありそうです。

まだPDFのオブジェクトとドキュメントのパース処理しか読めてないのでほんの一部ですが、設計の意図が読み取りやすいライブラリでした。 PDFパーサー書いてる間は何度もコードを読んで見ることになりそうなので、また疑問点出てきたらメモ残していこうと思います。

著書『実践Django Pythonによる本格Webアプリケーション開発』が本日から発売されます。

Twitterでは以前から告知を行っていましたが、著書『実践Django Pythonによる本格Webアプリケーション開発』が本日から発売になります。 購入を検討している方は、Amazonもしくはお近くの書店で探してみてください(エキスパートPythonプログラミング 改訂3版も今月末発売予定です)。

f:id:nwpct1:20210717141541j:plain

初の著書であり単著ということもあり大変なこともありましたが、満足の行く一冊が出来上がってホッとしています。

追記: 単行本派データベースカテゴリでベストセラー1位、Kindle版もNPONGOカテゴリでベストセラー1位になっていました。購入してくださった方、Twitterで拡散してくださった方ありがとうございました!

本書の対象読者

本書の対象読者やおすすめの読み方については、レビューをお手伝いいただいた中川さんが素晴らしい記事を書いてくださいました。 本書がアピールしたいことや、どういうふうに読んでほしいかをまさに汲み取ってまとめていただいているのでぜひこちらを先に読んでみてください。

shinyorke.hatenablog.com

"Djangoをやる方はもちろん, Djangoを抜きにしてもWebアプリケーション開発をされる方にめちゃくちゃオススメしたい!" と思いました, レビューさせてもらったときからすごく良かったんですよ, それぐらい興奮しました ※1

※1: レビューしながら, 「そうだよ!そこが大事なのよ!!」とか家で一人声を出しながら食い気味に読ませていただきました苦笑. 発売前に原稿が読めて幸せでした(感謝)

中川さんもこうおっしゃってくださっていて嬉しいです。

例えばデータベースのインデックスチューニングやHTTPまわりの基礎知識、ユニットテストを書くときの考え方、認証認可まわりの注意点などは、Djangoに限らず必要な知識だと思います。こういったフレームワークに依存しない知識は、Django公式ドキュメントで扱っていないことも多く、初学者の方は勉強のきっかけを掴めないことも多いと思います。

でも、むしろそういったところにWeb開発者として長く役に立つ知識が詰まっているとも思っています。 他のPython Web関連の書籍に比べて高度な内容も多く扱っていると思いますが、詳しく解説しすぎるとどんどん難しくなる話もちょうどよいボリュームでまとめるよう心がけて執筆したので、ぜひ多くの方の手にとっていただきたいです。

サンプルコードや目次

サンプルコードや目次はこちらのGitHubリポジトリで公開しています。

github.com

Amazonレビューのお願い

すでに本書を読んでくださったという方もいらっしゃると思いますが、もしよければAmazonでレビューをつけていただけると嬉しいです。

(素晴らしい書籍であるにも関わらず、残念なレビューがついてしまう悲しい話 も見たことがあるので、どんな評価であれできるだけ多くの方がAmazonレビューをつけていただけると嬉しいです...!)

最後に

Djangoを触り始めてもう7年くらい経ちますが、今見てもこれだけ多くの機能を見通しよく設計し使いやすく提供できていることに驚きます。 たまにパッチを書いてコア開発者の方々からレビューをもらったときには、彼らの設計能力の高さやデータベースまわりの知識の深さに圧倒されます。 Djangoに限らないとは思いますが、世界中のWebシステムの開発に使われるフレームワークを開発している人たちのさまざまなドメインに対する知識の深さは頭がいくつも抜けていて、自分はいまだに全然届かないなと思いました。 彼らがDjangoの開発を続けてくれていることにとても感謝しています。

GitHub SponsorsこちらのページDjango Software Foundationへ寄付ができます (自分はGitHub Sponsors経由にしています)。 本書をきっかけに少しでもDjangoの採用事例が増え、寄付をする人が増えたら嬉しいなと思っています。ぜひ検討してみてください。