Jinjaテンプレートエンジン用のエクステンション入門

 Pythonで利用できるテンプレートエンジンの1つにJinjaというものがあります。一般的にはWebページのレンダリング等で使われることが多いですが、近年ではAI系ツールでもよく使われている、とても多機能なテンプレートエンジンです。

 このJinjaには、独自のテンプレートタグを追加できる「Extensions」という機能があります。このExtensionsを利用することで、Jinjaにおける標準的なテンプレートのルールにとらわれない、柔軟なテンプレート処理が実現できます。

 しかし、このExtensions機能はあまり使われていないようで、ネットを検索してもあまり情報がありません。そもそも公式ドキュメントではExtensionsを実装するためのAPIについての記載はあるものの、最低限のサンプルコードしかなく、チュートリアル的なものもほとんどありません。実際のところ、JinjaはExtensionsを使わなくてもテンプレート内から簡単にPythonの関数を呼び出すことができるため、Extensionsをわざわざ実装しなくてもほとんどのユースケースに対応できます。そのためExtensionsを実装する需要は少なく、結果として利用例が少ないのだと思われます。

 いっぽうで、ほかのテンプレートエンジンでは利用できるがJinjaでは利用できないような機能を使用したい場合、このExtensions機能を使って実装することでその機能をJinjaに追加することができます。これは、他のテンプレートエンジンでの利用を想定して作成されていたテンプレートをJinja向けに移植する際などに有用です。自分もそういった目的でJinjaのExtensionsを作ってみようとしたのですが、AIにコーディングさせようとしたところ、あまりにも情報が少なすぎるのか、まともに動作するコードを生成できませんでした。ということで、ここでJinja向けのExtensionsについての簡単な解説を記しておきます。

 なお、本記事ではJinjaの基本的な使い方や用語について理解していることを前提としています。Environmentやテンプレートの文法、テンプレートにおけるStatementExpressionなどについての説明は、別途Jinjaのドキュメント等をご参照ください。

前提情報:Jinjaの仕組みとExtensions

 Jinjaでは、テンプレートをまずAST(Abstract Syntax Tree)と呼ばれる形式の表現に変換します。ASTはHTMLにおけるDOMのようなもので、テンプレートに記載されているコンテンツをツリー構造のオブジェクトに変換したものです。テンプレートは「Parser」と呼ばれるものでASTに変換され、続いて「Compiler」と呼ばれるものでASTがPythonの実行可能コードに変換されます。ここで得られた実行可能コードにレンダリングするパラメータを与えて実行することで、最終的な出力が得られます。レンダリングするパラメータ(変数)はコンテキスト(Context)と呼ばれるもので管理されており、レンダリング時に変数とその値をContextに渡せるほか、テンプレート内からContextに変数や値を追加したり、変数の値を変更したりすることができます。

Jinjaでのレンダリングの仕組み

 Extensionsでは、このテンプレートの処理プロセスに介入する仕組みが提供されています(ドキュメント)。Extensionはjinja2.ext.Extensionクラスを継承して実装するようになっており、JinjaのEnvironmentに対し実装したExtension派生クラスを登録することで、そのExtensionsが利用できるようになります。

 たとえばSomeExtensionというExtensionを実装した場合、次のようにするとそのExtensionが利用できるようになります。

import jinja2
from jinja2 import Environment
from some_extension import SomeExtension

env = Environment(extensions=[SomeExtension])

# 下記のような書き方でもOK
# env = Environment()
# env.add_extension(SomeExtension)

 EnvironmentにExtensionを登録すると、そのEnvironmentが引数として与えられてExtensionのコンストラクタが呼び出されます。また、以下のタイミングでExtensionの次のメソッドが呼び出されるようになります。

  • テンプレートの読み込み時:preprocess()メソッド
  • テンプレートのパース時:filter_stream()メソッド
  • Parserが指定したステートメント(カスタムタグ)を読み込んだとき:parse()メソッド

 これによって下記のようなことが実現できます。

  • Extensionの登録時にEnvironmentに対してパラメータの変更・追加やメソッドの追加といった操作を行う
  • Jinjaがテンプレートを読み込む際にそのテンプレートに対してなんらかの前処理を実行する
  • パースされたテンプレートを操作する
  • テンプレート内で独自のステートメント({% ... %})を利用できるようにする

parse()メソッドの実装

 上記の4つのうち、最初の3つ(コンストラクタ、preprocess()filter_stream())ではそれぞれのメソッドに対して引数として渡されたEnvironment変数やパース前のテンプレートのソース(文字列)、パースされてトークンに分割されたテンプレートなどを操作する処理を記述できます。それぞれのメソッドの戻り値についても、preprocess()はテンプレートの文字列、filter_stream()はトークンを返すイテレータを返せば良いため、メソッドの実装は「渡された入力に対し何らかのメソッドを実行して操作を行ってそれを返す」という、直観的で分かりやすい内容になります。

 一方、カスタムタグを実装するために使用するparse()メソッドでは引数としてテンプレートの内容を読み出すためのParserオブジェクトが渡され、これを使ってテンプレートの中身をパースしてAST(Nodeオブジェクト)を生成したり、必要に応じて独自にNodeオブジェクトを作成したりして、最終的にそれらを戻り値として返すという、やや分かりにくい処理を実装することになります。下記ではこの実装に必要となるASTおよびParserについてまず解説します。

Parserの内部処理

 前述のように、Parserドキュメント)はテンプレートをASTに変換する処理を担当するクラスです。ここでは次のような手順でテンプレートをASTに変換していきます。

Parserの内部処理1:テンプレートのトークン化(tokenize)

 Parserはまず渡されたテンプレート(str形式)をTokenと呼ばれる単位に分割します。

 たとえば、<a href="{{ url }}">{{ link_text }}</a>というテンプレートが渡された場合、Parserはこれを次のように9つのトークンに分割します。

テンプレートのトークン化

 このようなテンプレートからトークンへの変換処理は、Lexerというクラスで実装されています(実装コード)。Token自体を表現するTokenクラス(ドキュメント)やそのタイプなども同じジュールで定義されています。

 変換されたトークンはTokenStreamドキュメント)というイテレータブルなオブジェクトに格納されます。Parserオブジェクトからは、そのstreamプロパティでTokenStreamにアクセスできます。

Parserの内部処理2:トークンのパース

 続いてParserはこのTokenStreamからトークンを順に取り出して、ASTのオブジェクト(Nodeクラスを継承したクラスのオブジェクト)に変換していきます(実装コード)。たとえばtypedataのトークンが取り出されたら、その値を格納したTemplateDataクラスのオブジェクトを生成します。また、typevariable_beginのトークンが取り出されたら、Parserクラスのparse_tupleというメソッド(実装)を実行し、そこでtypevariable_endのトークンに到達するまでのトークンを処理します。typenameのトークンが取り出されたら、Nameクラスのオブジェクトを生成します。

トークンのAST化

 また、ASTのオブジェクトにはノードのコンテンツを出力するOutputクラスや、Pythonの関数やメソッドを実行するCallクラスといった、なんらかの処理を実行するためのものも用意されています。上記の例では、生成されたオブジェクトを最後にOutputクラスのオブジェクトに格納することで、テンプレートの中身が出力されます。

 なお、Parserクラスにはparse_*という名称でさまざまなタイプのトークンをパースしてASTに変換するためのメソッドが実装されており、Parserは必要に応じてそれらを使ってさまざまなオブジェクトを生成します。また、前述のようにASTを生成するまでがParserの役割で、そこから出力を実際に生成するための処理はCodeGeneratorという別のクラス(実装コード)で実装されています。Extensionはこの部分の処理には介入できないため、本稿ではここから先の処理についての詳細は割愛しますが、CodeGeneratorではASTを順に走査し、その内容に応じたPythonコードを出力する、といった処理を実行しています。ここで出力されたPythonコードはPythonの中間コードに変換され、Jinjaのテンプレートレンダリングメソッドが呼び出されるたびにそのコードが実行されてテンプレートがレンダリングされます。

Extensionのparser()メソッドが呼び出されるタイミング

 Parserはトークンのパース時にステートメント開始トークン(typelinestatement_beginのトークン)に遭遇した場合、parse_statement()というメソッドを実行してそれをパースしようとします(実装)。ここでステートメントの値({%%}で囲まれた部分の最初のキーワード)がJinjaの組み込みステートメントと一致した場合、それをパースするためのメソッドが実行されます。一致しなかった場合は、Environmentに登録されているExtensionのtagsプロパティを順にチェックし、ステートメントの値と一致するキーワードを持つExtensionが見つかれば、Parserオブジェクトを引数として与えてそのExtensionのparse()メソッドを実行します。

 Extensionのparse()メソッドが実行されたタイミングでは、与えられたParserオブジェクトのstreamプロパティ(Tokenを返すイテレータ)はそのステートメントの値を格納するName型のトークンを指した状態になっています。Extensionではこのstreamプロパティ、もしくはParserオブジェクトのメソッドを使ってTokenを走査し、ASTのノードを生成してそれらを戻り値として返す処理を実装することになります。

ASTの生成

 ASTのノードはjinja2.nodesモジュールのクラスとして実装されており、一般的なクラスと同じように生成できます。たとえば、テンプレート内の定数文字列を表すTemplateDataノードは次のように作成できます。

from jinja2 import nodes

templateDataNode = nodes.TemplateData(data)

 なお、ノードによって生成時に与えられる引数は異なりますが、lineno=およびenvironment=以外のキーワード引数を与えることはできません。多くのNodes派生クラスは独自のコンストラクタ(__init__()メソッド)を持たず、Nodesクラスのコンストラクタを使用する形で実装されており、そこでこれら以外のキーワード引数が与えられた際はTypeError例外を生成するように実装されているためです(実装)。つまり、次のような記述はできません。

templateDataNode = nodes.TemplateData(data=data)

Extensionの実装例

 以上をまとめると、Jinjaで{% some_statement %}という独自タグ(独自のステートメント)を実装するには、次のような手順を踏めば良いということになります。

  1. jinja2.ext.Extensionクラスを継承したSomeExtensionクラスを定義する
  2. SomeExtensionクラスのtagsプロパティに{ "some_statement" }をセットする
  3. SomeExtensionクラスのparse()メソッドに処理を記述する

 これを踏まえて、以下ではparse()メソッドに実装する処理の実例をいくつか紹介していきます。

固定文字列を出力する

 まずはシンプルに「hello world」という固定文字列を出力する{% helloworld %}タグを実装してみましょう。この独自タグを追加するHelloWorldExtensionクラスは次のようになります。

class HelloWorldExtension(Extension):
    tags = {"helloworld"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        return nodes.Output([nodes.Const("hello world")], lineno=lineno)

 前述のように、parse()メソッドが呼び出された時点では引数として渡されたParserクラスのオブジェクト(parser)のstreamプロパティはステートメント名を格納するTokenを指しているので、next()関数でまずこのトークンを取り出しています。Token型オブジェクトのlinenoプロパティにはそのトークンがテンプレート中の何行目に記述されているかを示す数値が入っており、Nodeクラス派生オブジェクトのコンストラクタにこれを与えることで、エラー発生時にこの行番号を表示してくれるようになります。これはテンプレートのデバッグ時に役立ちます。

引数として与えた値を出力する

 次の例は、ステートメントに続く値を引数として解釈し、それを出力するタグを実装したものです。たとえば{% hello "world" %}をレンダリングするとhello, worldという文字列が得られます。

 ここでは、Parserが持つstreamイテレータが指しているトークンを取り出し、それに対応するNodeを生成するparse_expression()メソッド(ドキュメント)を使って引数部分に対応するノードを生成しています。このメソッドはトークンが定数の場合はConstノードを返します。また、トークンが定数ではない文字列(たとえばworldのような"等で囲まれていない文字列)の場合はそれを変数名と解釈し、コンテキストから対応する値を取り出すNameノードを生成します。つまり、この{% hello %}タグでは定数と変数の両方を引数として与えることができます。

class HelloExtension(Extension):
    tags = {"hello"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        next_token = parser.stream.look()
        argNode = parser.parse_expression()
        return nodes.Output([nodes.Const("hello, "), argNode], lineno=lineno)

Extensionのメソッドを呼び出す

 レンダリング時にExtentionのメソッドを呼び出すことも可能です。次の例は、Extensionの_get_now()というメソッドを呼び出すものです。たとえば{% now "%y-%m-%d %H:%M:%S" %}というテンプレートをレンダリングすると、現在時刻を示す26-07-27 00:19:25のような文字列が出力されます。また、引数を与えなかった場合は、ISO形式の文字列で現在時刻が出力されます。

 メソッドを呼び出すにはExtentionsクラスのcall_method()メソッドを利用します(ドキュメント)。注意したいのが、このメソッドは指定したメソッドをその場で実行するのではなく、レンダリング時に指定したメソッドを実行するCallノードを生成して返すという点です。

 call_method()は引数としてメソッドの実行時に与える引数を格納したリストを受け取るのですが、このリストにはNode派生クラスのオブジェクトをそのまま格納できます。つまり、parse_expression()メソッドでパースしたASTをそのまま引数として渡せます。指定したメソッドが実行される際には、ASTのノードは適切な値に変換されてから引数として渡されます。また、メソッドの戻り値も適切なASTのノードに自動変換されます。

 {% now %}タグに変数が渡されているかどうかは、Parserクラスのstreamプロパティが指しているトークンの種類で判別できます。このトークンがblock_endであれば引数は存在しないので、引数を与えずに_get_now()メソッドを実行します。

class NowExtension(Extension):
    tags = {"now"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        
        # parser.streamが指しているトークンがblock_endなら引数は存在しない
        if parser.stream.current.type == "block_end":
            # 引数がないのでメソッド呼び出し時にも引数を与えない
            arg = []
        else:
            arg = [parser.parse_expression()]

        node = self.call_method("_get_now", arg)
        return nodes.Output([node], lineno=lineno)
        
    def _get_now(self, fmt=""):
        if fmt:
            return datetime.now().strftime(fmt)
        else:
            return datetime.now().isoformat()

終了タグを使ったカスタムタグ

 Jinjaでは、{% if ... %}{% endif %}のような、endで始まる終了タグと組み合わせて使用するステートメントがあります。Extensionsでもこのような終了タグと組み合わせて使用するカスタムタグを実装できます。次の例は、{% wrap %}タグと{% endwrap %}タグに囲まれた部分を<div></div>で囲んで出力するExtensionを実装したものです。

 このように終了タグを使用する場合、tagsプロパティにendwrapを追加するのではなく、Parserクラスのparse_statements()メソッド(ドキュメント)を使用するのがポイントです。このメソッドは、第1引数で指定した条件に合致するトークンまでを取得し、それをAST(Node派生クラスの配列)に変換したものを返します。なお、drop_needle引数は終了タグ部分のトークンを破棄するかどうかを指定する引数です。終了タグに対して特に追加処理を行わないのであれば、Trueを指定します。

class WrapExtension(Extension):
    tags = {"wrap"}

    def parse(self, parser):
        token  = next(parser.stream)
        lineno = token.lineno
        body = parser.parse_statements(["name:endwrap"], drop_needle=True)
        
        # 先頭と末尾にDIV開始タグと終了タグを出力するノードを追加する
        body.insert(0, nodes.Output([nodes.Const("<div>")]))
        body.append(nodes.Output([nodes.Const("</div>")]))
        
        return body

まとめ

 ここでは基本的なExtensionの実装例のみを紹介しましたが、Extensionのparse()メソッドではテンプレートのトークンに直接アクセスできるため、他のタグ(ステートメント)に相当するトークンがパースされる前にそれを乗っ取って挙動を変えたり、本来のJinjaテンプレートではエラーになるような文法を実装する、といったことも可能になります。いっぽうで、あくまでトークンをASTに変換するといった処理しかできないため、コンテキストの値に応じて動的に内容が変わるような処理を実装したい場合は工夫が必要です。また、単にメソッドを実行してその結果をレンダリングしたいのであれば、コンテキストにメソッドを追加して実行させるほうが簡単な場合もあります。実現したい処理に応じて、ケースバイケースで利用するのが良さそうです。

Windows環境でAMD GPUでllama.cppを動かす

 ネット上にWindows環境でAMD Radeon GPUを使ってllama.cppを動かす情報が少なく、若干ハマったので簡単にメモを残しておきます。なお、この分野は日進月歩で進んでいるため今後このドキュメントの内容は使い物にならなくなっている場合がありますのでご注意ください。本記事の執筆日時は2025年12月27日です。

検証環境

 本記事の内容は下記の環境で検証しています。

  • OS:Windows 11 Pro 24H2
  • CPU:AMD Ryzen 6 7600X
  • GPU:AMD Radeon RX 9060 XT(16GB)
  • メインメモリ:32GB

llama.cppバイナリの入手

 llama.cppのGitHubリポジトリ上にあるinstall.mdではWingetを使えと書いてあります。この場合、llama.cppのGitHubリポジトリのReleasesページで公開されている最新バイナリの「Vulkan」版がインストールされるようです。

 いっぽうで、リリースページには「HIP」版のバイナリも公開されています。HIPはAMD GPU上で各種処理を行うための「ROCm」というソフトウェアスタックを内包するWindows向けライブラリです。こちらは単にビルド済みバイナリをZIP形式で圧縮しただけのもので、インストーラは付属していません。そのため、適当な場所に展開した後、手動でパスを通す設定などを行う必要があります。

 なお、HIPを利用するためのHIP SDKはAMD HIP SDK for Windowsページからインストーラをダウンロードできます。ただし、llama.cppのリリースページで公開されているバイナリにはHIP関連のDLLも同梱されているため、おそらくHIP SDKを別途ダウンロードする必要は無いと思います(自分は先に自前でllama.cppをビルドするためにインストールしてしまっていたので詳細は不明)。

 ちなみに、前述のようにHIPは内部的にROCmを内包しているため、HIPを利用するビルドではGPUデバイスが「ROCm0」「ROCm1」……といったデバイスIDで表示されます。

統合GPUを無効化する

 統合GPUを備えたAMDのCPUとAMDの単体GPUを組み合わせて使用している場合、HIP版のllama.cppではllama_cli.exellama_server.exeの実行時に下記のようなエラーが出て強制終了することがあります。

ggml_cuda_compute_forward: MUL_MAT failed
ROCm error: invalid device function

 AMDの統合GPUと単体GPUは両方を有効にした状態で利用できるのですが、その場合どうも単体GPUで実行すべき処理を統合GPUで実行しようとしてしまい、その結果このエラーが発生しているような雰囲気です。この場合、BIOS(EFI)設定で統合GPUを無効にすることでこの問題を解決できます。

Vulcan版とHIP版どっちが高速?

 VulcanとHIPはそれぞれ異なるライブラリのため、実行速度としてはどちらが優れているのかが気になるところです。ということで、Vulcan版とHIP版のllama.cppを使って日本語文章の英訳処理を実行し、処理速度を比較してみました。使用したモデルは16GBのデバイスで実行できるとされているgpt-oss-20bです。

 まずwingetでインストールしたVulcan版ですが、実行時間は下記のようになりました。

prompt eval time =    1735.51 ms /  1188 tokens (    1.46 ms per token,   684.53 tokens per second)
       eval time =   31974.28 ms /  2622 tokens (   12.19 ms per token,    82.00 tokens per second)
      total time =   33709.78 ms /  3810 tokens

 タスクマネージャで確認した実行時のメモリや演算リソースの消費量は以下のような感じです。

Vulcan版llama.cpp実行時のリソース消費

 一方、HIP版は下記のような結果になりました。

prompt eval time =     656.10 ms /  1188 tokens (    0.55 ms per token,  1810.71 tokens per second)
       eval time =   44236.82 ms /  3047 tokens (   14.52 ms per token,    68.88 tokens per second)
      total time =   44892.92 ms /  4235 tokens

HIP版llama.cpp実行時のリソース消費

 プロンプトの処理はHIP版のほうが3倍近く速いのですが、出力の生成はVulcan版のほうが1割ほど速いという感じです。また、HIP版のほうが若干多くGPUのメモリや演算リソースを使用する雰囲気がありますが、これについては誤差レベル程度の違いしかなさそうです。

 ということで、今回の検証ではVulcan版のほうが若干ではあるが高速という結論となりました。ただ、処理内容によってもどちらが高速かは異なるという話もあるので、引き続き検証していきたいところです。

Windows 11におけるStore版PowerShellのショートカットの作り方

 思い立ったときにすぐに、開発用のディレクトリを作業ディレクトリにした状態でPowerShellを起動できると便利なのですが、それを実現する方法を調べたところマトモな結果が出てこなかったのでここでまとめておきます。

前提条件:Windows PowerShell 5.1とPowerShell 7は違う

 Windows 11にはWindows PowerShell 5.1が標準で提供されています。いっぽう、PowerShellの(本記事執筆時の)最新版はPowerShell 7系(手元の環境では7.5.4)です。そのため、最新版のPowerShellを利用したい場合、別途何らかの方法でPowerShellをインストールする必要があります(MicrosoftのPowerShellインストールに関するドキュメント)。

 自分はMicrosoft Storeからインストールを行ったのですが、ここで混乱を招くのが、PowerShell 7系をインストールしても、Windows 11で標準提供されているWindows PowerShell 5.1はそのまま残される点です。

Microsoft StoreのPowerShellページ

色々なところで紹介されているPowerShellのショートカット作成方法は古い

 PowerShellのショートカットを作成する方法としてGoogle検索でよく見つかるのが、コントロールパネルの「Windowsツール」からショートカットを作成する、というものです。確かに、「Windowsツール」内のWindows PowerShellのコンテキストメニューには「ショートカットの作成」という項目があり、ここからショートカットを作成できます。しかし、このWindows PowerShell項目は、古いバージョン(Windowsで標準提供されているバージョン)のWindows PowerShell 5.1を起動する項目なのです。つまり、ここからショートカットを作っても、最新のPowerShell 7系は起動できません。

「Windowsツール」内の「Windows PowerShell」項目からショートカットは作成できるが、これではPowerShell 7系は起動できない

 PowerShell 7系のショートカットを作るには、まずPowerShell 7系の本体(つまり.exeファイル)がどこにあるのかを調べる必要があります。ここで面倒なのが、Microsoft Store経由でインストールされたソフトウェアはファイルシステム上隠蔽されており、そのファイルには直接アクセスできないという点です。

 たとえば、スタートメニューからPowerShellのコンテキストメニューを表示させても、そこには「ファイルの場所を開く」項目はありません。このコンテキストメニューからショートカットを作成することもできません。

スタートメニューからPowerShellのコンテキストメニューを開いてもショートカットは作成できない

 実は、Microsoft Store経由でダウンロードしたソフトウェアは、(システムドライブがC:の場合)通常は「C:\Program Files\WindowsApps」ディレクトリ以下にインストールされます。しかし、このディレクトリはエクスプローラーでは表示されず、またパスをエクスプローラーに入力して開こうとしても、デフォルトでは管理者権限を持っていてもアクセスできないよう制限がかかっています。単にショートカットを作成するためだけにこの設定を変更するのも微妙ですよね……。

PowerShell 7系を起動するショートカットの作り方

 ということで試行錯誤して得られた手順は次のようになります。

 まず、Windowsターミナルを起動し、タイトルバーの何もない場所を右クリックしてコンテキストメニューを開いて「設定」をクリックします。

PowerShellのタイトルバーを右クリックし、続いて「設定」をクリックする

 するとWindowsターミナルの設定画面が表示されるので、ここで「プロファイル」項目内にある「PowerShell」をクリックします。そこに「コマンドライン」という項目があり、ここからPowerShellを起動するためのコマンドライン文字列を確認できます。この文字列を選択し、コピーしておきます。

PowerShellのプロファイルからPowerShellを起動するコマンドラインを取得できる

 続いてエクスプローラーでショートカットを作成したいフォルダを開き、コンテキストメニューの「新規作成」-「ショートカット」を選択して「ショートカットの作成」ウィザードを開きます。「項目の場所を入力してください」というテキストボックスがあるので、そこで先ほどコピーしておいたPowerShellを起動するためのコマンドライン文字列をペーストします。あとは「次へ」をクリックしてウィザードを進めれば、ショートカットが作成されます。

ショートカット作成ウィザード

 ショートカットが作成できたら、そのアイコンを右クリックし、「プロパティ」をクリックしてプロパティを開きます。ここで「ショートカット」タブ内に「作業フォルダー」という項目があるので、ここでPowerShell起動時に作業フォルダーに設定したいパスを指定し、「OK」をクリックすれば完了です。

ショートカットのプロパティで作業フォルダーを指定する

「ターミナルで開く」で良いのでは?

 エクスプローラーのコンテキストメニューには「ターミナルで開く」という項目があり、これを選択することでそのディレクトリを作業ディレクトリにした状態で、Windowsターミナルで「既定のプロファイル」で選択したプロファイルを開くことができます。つまり、PowerShell 7系を「既定のプロファイル」に設定しておけば、今回のようにわざわざショートカットを作成せずとも、簡単にPowerShellを起動できます。ただ、既定のプロファイルを別のものにしている場合や、ショートカットを格納したディレクトリ以外を作業ディレクトリにして起動したい場合などには、今回紹介した内容が役立つのではないでしょうか。

Lenovo Yoga Tab(11.1インチ、2025)レビュー

 今まで使っていたiPad Air(第三世代)からの置き換えとしてLenovo Yoga Tab(11.1インチ、2025年モデル)を購入しました。初めてのAndroidタブレットと言うことで若干の不安があったのですが、結果としては大きな不満点はなく良いデバイスでした。ということで以下簡単なレビューです。

購入経路とパッケージ、本体デザイン

 今回はLenovo公式の直販サイトで購入しました。価格は税込みで6万3,800円。ちょうどキャンペーンで「1年間 アクシデント・ダメージ・プロテクション」が99円だったのでこちらも追加しています。こちらはAppleの「Apple Care+」に相当するようなサービスで、画面破損等についても無料修理の対象になるようです。発売直後の9月26日に注文したためか出荷まで1週間程度という表示でしたが、結果としてはおよそ6日後の10月2日に手元に届きました。

 パッケージはこのような簡素なものですが、デジタル製品としては十分な感じです。

Lenovo Yoga Tab 11.1のパッケージ

 パッケージを開けるとまずは本体が入っており、その下にペン、充電器、保証書、USB type-Cケーブルが収められています。

パッケージ内部にはまず本体が収められている

付属品は本来の下に収められている

 充電器は最大45W出力(5V/3A、9V/3A、12V/3A、5~11V/4.5A)とのことで、高速充電に対応しています。

付属充電器は45W出力対応

 ペンは高級感のあるデザインです。ペン先の交換も可能で、予備のペン先が1つ付属していました。

ペンも標準で付属 ]

 Apple Pencil(初代)と比べると少し短く、重量も若干軽いため取り回しやすそうです。有線での充電には対応しておらず、第2世代Apple Pencilのように本体にマグネットで取り付けることで充電します。

Apple Pencil(初代)との比較

 本機は画面解像度が3200×2000ドット(16:10)ということもあってiPadよりも縦長なフォルムです。ただし縦持ちした場合の高さはiPadとほぼ同じで、その分幅が狭くなっています。

本体はやや縦長

 背面にはカメラ×2とフラッシュ(LED)を搭載。技適マークやシリアルナンバーがシールで貼られている点はデザイン的にやや残念なところです。

背面には技適マーク等がシールで貼り付けられている

使用感

 OSはAndroid 15ですが、UIはカスタマイズされた「Lenovo ZUI」というものが採用されています。初回起動時に「17」という表示が出ますが、これはAndroidのバージョンではなくZUIのバージョンです。

起動時に「17」との表示が出るが、こちらはAndroidバージョンではなくZUIのバージョン

 Androidにおけるメーカー独自のUIカスタマイズについてはさまざまな意見があるかと思いますが、このZUIについてはホーム画面下にiPadOSのDockのようなもの(ランチャー)が設置されていたり、設定画面は2ペインデザインになっていたりと、かなりiPadOSを意識したようなものになっています。

ホーム画面下にはDock風のランチャーを搭載

設定画面はiPadOS風

 マルチウィンドウや分割画面にも対応しており、画面上部に表示されている「…」をタップするとウィンドウを分割したりフローティングしたりするためのメニューが表示される点もiPadOSにそっくりです(iPadOS 26ではこのUIは廃止されてしまいましたが)。もちろんiPadOSとは設定項目やカスタマイズできる部分などが異なるため、iPadそのままの使い勝手というわけではないのですが、iPadからの乗換えでもほとんど違和感なく操作できるかと思います。

マルチウィンドウにも対応

 それに加えて、画面端スワイプでの戻る操作といったAndroidのジェスチャーナビゲーションもそのまま使えるので、Androidスマホと併用している場合でも特に違和感なさそうです。UI操作へのレスポンスも良好で、操作感においてはiPadと比べても十分満足できるものでした。

 なお、デフォルトでは画面リフレッシュレート設定が「インテリジェント」になっており、状況に応じてリフレッシュレートを自動で調節するようになっています。ただ、この場合Webブラウザなどでの画面スクロール時にやや文字がブレるような感覚がありました。そのため、リフレッシュレート設定を「標準」(60Hz)もしくは「最大」(144Hz)に設定することをおすすめします。

 画面サイズに関しては、前述のように縦長になるように持った場合iPadよりもやや幅が狭くなります。WebやSNSの閲覧においてはほとんど困ることはないですが、横長になるように持った状態でWebマンガなどを見開きで閲覧する場合、Webブラウザのヘッダー領域の分だけページを表示する領域の高さが小さくなるため、全体としてページが小さめに表示されてしまいます。全画面表示にすればおおむね問題ないのですが、全画面表示をサポートしていないWebマンガサイトも一部存在するため、その点では若干不便です。こちらは強制的に全画面表示を可能にするブラウザ拡張機能等を利用することで解決できるかもしれません。

アプリの対応

 Androidにおいてはタブレット専用のアプリというものはほぼ存在せず、基本的にはAndroidスマートフォン向けのアプリはすべて動作します。ただし、UIがタブレットに最適化されておらず、単に画面を大きく引き伸ばされただけのUIになってしまうものも多いです。Googleの提供しているアプリについては基本的にタブレット向けのUIを備えているため、使い勝手の面では困ることは基本的にありませんが、たとえばAndroid版のXアプリにはそのような気の利いた仕組みはなく、単純に画面脇に大きめの余白があるデザインでコンテンツが表示されます。

 なお、自分はメインのWebブラウザとしてFirefoxを使用しているのですが、Android版gのFirefoxはタブレット向けに最適化されたUIを備えており、タブレットでも快適に利用できます。拡張機能も利用可能ですし、基本設定で動画の自動再生をブロックすることも可能です。自分は個人的にWeb閲覧中に突然音が流れるのが大変ストレスで、そのためiPadでは基本的に音量を0にしておいて、必要な時だけ音量を上げ、コンテンツを見終わったらまた下げる、という作業を繰り返していたのですが、これが不要になるだけでもAndroidタブレットにした価値があると思っています。

生体認証の利用

 本機は指紋認証機能を搭載しておらず、生体認証は顔認証のみとなっています。ロック解除時などの顔認証のスピードは十分速く、精度としてもiPhoneのFaceIDと同程度のようです。ただ、カメラは長辺部、つまり横長になるように持ったときの上側に設置されているため、縦長になるように持った際には持ち方によっては指でカメラが覆われてしまうことがある点には注意が必要でした。

タッチペンの利用

 自分は絵を描くわけではなく、ペンでのメモ等もほとんど使わないのでペンについては未知数です。ひとまず普通に使えることは確認しています。Apple Pencil Proのようなペン軸を押し込んで操作する(スクイーズ)機能はないようですが、ペンの軸をタップしたりスワイプしたりしての操作には対応しており、たとえばペンを握った状態で、人差し指で軸を撫でるような操作でコピー&ペーストが行えます。この際に操作に応じてペンが振動し、コピーやペーストが行えたことが分かる仕組みになっています。

保護ケース

 AndroidデバイスはiPhoneやiPadと比べてケースや画面保護フィルムの選択肢が少ないという弱点があります。ただ、本機に関しては中国では「Xiaoxin Pad Pro GT 11.1」という名称で販売されており、このXiaoxin Pad Pro GT 11.1向けのケースや保護フィルムがそのまま利用できます。AliExpress等の中国系通販サイトで簡単に購入できますので、(そういったサイトが利用できれば)ケースや保護フィルムの入手性は高いです。最近は円安のため「激安」ではないものの、国内で流通しているiPad向けのケースとさほど変わらないか、少し安いくらいの価格で購入できます。たとえば自分が購入したこちらのケースは1,318円でした。

AliExpressで購入したケース AliExpressで購入したケース(背面)

 ちなみにこちらのケース、ペンも一緒に収納できるのですが、その分幅が大きくなるのと、収納した状態ではペンが充電ができないという非常にアレな罠がありました……。それ以外はまったく問題なく、むしろスイッチ等も問題なく押せるような構造になっていてよくできている感じなので余計残念でした。

コストパフォーマンス面での考察

 昨今では(円安がその大きな要因の1つかとは思いますが)Apple製品は販売価格の上昇が激しく、iPad miniですら現行モデルは7万8,800円から、iPad Airは9万8,800円からとだいぶお高くなっています。無印iPadは現在ではエントリーモデルという位置付けになっていることもあって5万8,800円からと購入しやすいお値段ではありますが、搭載するA16プロセッサは3年前(2022年9月)に発売されたiPhone 14 Proやその翌年に発売されたiPhone 15に搭載されていたものであり、それ以外のスペックについてもFaceIDやApple Intelligenceには非対応など、まさに「廉価版」というものになっています。

 一方のLenovo Yoga Tab (11.1インチ、2025)はiPad(128GBモデル)よりも5,000円ほど高い(6万3,800円)ですが、メモリやストレージはiPadの倍で、タッチペンも標準装備されています。プロセッサに関しても、Snapdragon 8 Gen 3は2年前にリリースされたものではありますが、単純な処理性能ではiPadのA16やiPan miniのA17 Proと比較してほぼ同等以上となっています(シングルスレッド性能はA17 Proよりも若干劣るがコア数が多いため総合性能としてはA17 Proよりも高い数値になる傾向が高い)。絶対的な性能でいうと当然iPad AirやiPad Proのほうが高いのですが、動画編集や重いグラフィック処理などを行わないのであれば本機でも十分ではないかと思います。

PDFがCMYKで出力されているのを無課金で調べる

 私事で印刷業者にちょっとしたものを印刷してもらう用事ができたのですが、その入稿データとしてCMYKで出力されたPDFを要求されました。CMYKはシアン、マゼンタ、イエロー、黒の4色を混ぜ合わせてカラー原稿を表現する方式で、印刷業界で標準的に使われている方式です。いっぽう、PCでは一般的にRGB(レッド、グリーン、ブルー)の3色を混ぜ合わせて色を表現するのが一般的で、多くの画像編集ツールやデジタルカメラなどではこちらのRGBを使った形式でファイルを出力することが多いです。そのため、CMYKを取り扱うにはCMYK出力をサポートしたソフトウェアが必要になります。

 といっても、CMYKでPDFを出力すること自体はInkscape等のフリーソフトウェアでも可能です。とはいえ、特にCMYKでの出力は初めてということもあって、そこで出力したものが本当に正しくCMYKで出力されているのか、入稿前に確認しようと考えました。

 軽く調べたところ、Acrobatの有償版(Acrobat Pro)で利用できる「印刷プレビュー」機能を利用すれば、使用されている色やRGBデータの有無を確認できるようです。ただ、単にデータを確認するだけに安くない金額を払うのにはとても抵抗感があります。また、他にも印刷向けにPDFの情報をチェックできるソフトウェアが存在するようですが、見つけたものはいずれも有償のものでした。

 ということで、連休で時間があるというのもあってPDFフォーマットを分析して自力でPDFでCMYKが使用されているかを調べる方法を調べることにしました。

参考資料

 PDFのフォーマット仕様は標準規格として公開されており、その仕様書はAdobeのオープンソースサイト(https://opensource.adobe.com/dc-acrobat-sdk-docs/acrobatsdk/)で公開されています。ただし英語で分量も多いため、これをいきなり読むのはハードでしょう。

 日本語の情報としては、下記が有用でした。

PDFがCMYKで出力されているかを調べるために必要な情報の要約

 以上の資料を読んだうえで、後述するPDFを取り扱うライブラリを使っていくつかのPDFを確認したところ、以下のような理解になりました。

  • PDFはオブジェクトと呼ばれるデータ要素の組み合わせで構成されている
  • PDFには必ずルートオブジェクトがあり、ページ内のコンテンツはルートオブジェクトからたどれる/Pagesオブジェクトに入っている
    • ルートオブジェクトにはそのほか画面表示のためのプロパティやメタデータなどが含まれていることがあるが、これらはPDFが含む色には関係ない
      • PDF作成ツールによってはメタデータに使用している色空間の情報が入っていることもあるようだ
  • PDFには「Indirect Object」という別のオブジェクトを参照する仕組みがあり、各オブジェクトの子オブジェクトとして実際のオブジェクトを指定する代わりにIndirect Objectを使って別のオブジェクトを参照させることもできる
  • /Pagesオブジェクトはページを表現する/Pageオブジェクトを子オブジェクトとして保持する/Kidsオブジェクトを含んでいる
  • /Pagesオブジェクトや/Pageオブジェクトには各ページで使われているコンテンツや各種設定を含むリソースやページサイズ、印刷サイズといった情報が含まれている
  • PDFは1ファイル中にさまざまな色空間を混在させることができる
    • 色空間としてはグレイスケール、RGB、CMYKを取り扱える。さらにそれぞれカラーマネージメント(デバイスや出力装置に応じて適切に補正を行うことで色の再現性を高める仕組み)あり/なしの両方をサポートする
  • ページ内に描画するコンテンツは各/Pageオブジェクト内の/Contentsオブジェクトに格納されている
  • PDFはテキストベースで記述されているが、/Contentsオブジェクトにはテキストを圧縮したバイナリデータが含まれていることがある。その場合、/Contentsオブジェクト内のデータにアクセスするには指定された方式でバイナリを展開する必要がある
  • /Contentsオブジェクトには「<オペランド(パラメータ)> <オペレータ(命令)>」という形式で描画命令が格納されている。用意されている命令には直線や図形、文字などを描画するものや領域を塗るといったものに加えて、色や描画パラメータを変更するものや、描画するオブジェクトをグルーピング(レイヤー分け)するものなどが含まれている
    • 色や塗り潰しパラメータはグレイスケール/RGB/CMYKで指定できるほか、あらかじめ定義しておいた色(インデックスカラーもしくは特色)やパターンを選択することもできる
    • 色や塗り潰し、描画のパラメータは直接記述することもできるし、/Pagesや/Pageオブジェクト内の/Resourcesオブジェクトに格納しておいてそれを参照することもできる
      • あらかじめ定義しておいた色やパターンは/ColorSpaceというキー、また描画パラメータについては/ExtGStateというキーにひも付けられて格納されている

 まとめと言いながら長々と書いてしまいましたが、まず重要なポイントとしてはPDF内では複数の色空間を同時に扱えるという点です。そのため、「CMYKで出力されている」かどうかは、「グレイスケールもしくはRGBが使われていない」で判断することになりそうです。

 また、インデックスカラーや特色、塗りつぶしパターンは/Resourceオブジェクトに格納されていることもありますが、グレイスケール/RGB/CMYKの色指定は/Contentsオブジェクト内に記述された命令で行われています。そのため、グレイスケール/RGB/CMYKのどれが使われているかは/Contentsオブジェクト内で使われている色指定命令をすべて調べる必要があるようです。

pypdfでPDFの内部データにアクセスする

 PDFではIndirect Objectを使った参照が頻繁に使われるほか、バイナリデータを保持する/Contentsオブジェクトなども存在するため、内部構造を解析するだけでも大変です。そのため、今回はPython向けのPDFライブラリであるpypdfを利用して内部データにアクセスすることにしました。

 pypdfでは、次のようにpypdf.PdfReaderクラスを使用することでファイルオブジェクトなどからPDFデータを読みだすことができます。

with open(fn, "rb") as fp:
    r = pypdf.PdfReader(fp)

 ルートオブジェクトは、pypdf.PdfReaderオブジェクトのroot_objectプロパティに格納されています。たとえば次のようなコードでルートオブジェクトに格納されているオブジェクトとそのクラスを確認できます。

print("ROOT OBJECTS:")
for k in r.root_object:
    print(k, r.root_object[k].__class__)

 たとえば、とあるPDFのルートオブジェクトを確認した結果、次のような出力になりました。

ROOT OBJECTS:
/Type <class 'pypdf.generic._base.NameObject'>
/Pages <class 'pypdf.generic._data_structures.DictionaryObject'>
/ViewerPreferences <class 'pypdf.generic._data_structures.DictionaryObject'>
/Lang <class 'pypdf.generic._base.TextStringObject'>
/OCProperties <class 'pypdf.generic._data_structures.DictionaryObject'>
/Metadata <class 'pypdf.generic._data_structures.DecodedStreamObject'>

 pypdfではPDFで使われる各オブジェクトの形式に応じたクラスが定義されており、PDF内のオブジェクトは自動的に対応するpypdfのオブジェクトに変換されて木構造が構築されます。

ページ内のコンテンツを確認する

 前述のように、PDFの各ページはルートオブジェクトから参照される/Pagesオブジェクトに格納されています。このオブジェクトの情報は、次のようなコードで確認できます。

print("PAGES:")
print(p.root_object["/Pages"])

 実行結果は下記のようになりました。これは、PDFのページ数が1で、また/Pagesオブジェクトにはその実態となるオブジェクトの代わりにそのオブジェクトへの参照(IndirectObject)が含まれていることを意味します。

{'/Type': '/Pages', '/Count': 1, '/Kids': [IndirectObject(7, 0, 1219641424528)]}

 pypdfでは、get_object()メソッドでそのIndirect Objectが参照するオブジェクトの実態を取得できます。つまり、この例の場合、PDF内に含まれる唯一のページに対応するオブジェクトは次のようにして取得できます。

page = pages["/Kids"][0].get_object()

 /Pageオブジェクトには、/Contentsオブジェクトとしてそのページ内に描画されるデータが含まれています。また、get_data()メソッドでオブジェクトからその生バイナリデータを取得できます。

raw_content = page["/Contents"].get_data()

 PDFの描画データ(描画命令)はテキスト形式で表現できますが、/Contentsオブジェクトに含まれているデータはその生データ(バイナリデータ)なので、テキストとして扱いたい場合はデコードする必要があります。たとえばデコードしたデータを出力するには次のようにします。

print(raw_content.decode("ascii"))

 次の例は、このコードで実際に抜き出した描画データの一部です。

/OC/oc2 BDC
0.721569 0.678431 0.670588 0.882353 k
5.58236 48.749 m
8.19161 48.749 l
8.57899 48.749 8.86249 48.6587 9.04211 48.4782 c
9.22174 48.2979 9.31155 48.0342 9.31155 47.6872 c
9.31155 45.7844 l
9.31155 45.5798 9.27192 45.4135 9.19267 45.2857 c
9.11355 45.1579 8.99586 45.0742 8.83961 45.0346 c
8.99586 44.9937 9.11355 44.9182 9.19267 44.8081 c
9.27192 44.698 9.31155 44.5414 9.31155 44.3384 c
9.31155 42.2962 l
9.31155 41.9492 9.22174 41.6854 9.04211 41.5049 c
8.86249 41.3245 8.57899 41.2344 8.19161 41.2344 c
5.58236 41.2344 l
h

 最初の「/OC/oc2 BDC」はこれから描画するコンテンツがどのレイヤーに所属しているかを示すものです。「/oc2」は/Pageオブジェクトの/Resources内で定義されているプロパティで、このプロパティにはレイヤー名やそのレイヤーに関する情報が含まれています。

 次に「0.721569 0.678431 0.670588 0.882353 k」という命令が続きます。この「k」は次のような形でCMYK形式で色を指定する命令です。

<c> <m> <y> <k> k

つまり、これに続く命令はCMYKで指定された色で描画を行うことになります。

なお、CMYK形式の場合は「k」もしくは「K」ですが、グレイスケールの場合は「g」もしくは「G」、RGBの場合は「rg」もしくは「RG」で色を指定します。大文字の場合ストロークの色を、小文字の場合はストローク以外の塗りつぶし等の色を指定することになります。

 色指定にはそのほか「sc」「SC」や「scn」「SCN」という命令もありますが、これらは色空間を指定する「cs」「CS」命令と組み合わせて使うもので、csもしくはCS命令で指定した色空間に応じてパラメータを指定することになります。

描画に使用されている色空間を判断する

 さて、本記事のテーマである色空間の判別ですが、まとめると以下のようになります。

  • 描画データ中に「g」や「G」命令がある場合:グレイスケールが使われている
  • 描画データ中に「rg」や「RG」命令がある場合:RGBが使われている
  • 描画データ中に「k」や「K」命令がある場合:CMYKが使われている
  • 描画データ中に「sc」や「SC」、「scn」、「SCN」命令がある場合:その時点で最後に実行された「cs」「CS」で指定されている色空間が使われている

 この中で厄介なのが「sc」や「SC」、「scn」、「SCN」命令のパターンで、この場合まずその前に実行されている「cs」や「CS」命令を探し、そこで指定されている色空間がグレイスケール/RGB/CMYKのどれなのかを調べる必要があります。「cs」や「CS」命令ではパターンや特色を指定することもでき、その場合そのパターンや特色がどの色空間で指定されているかも調べる必要があります。

 これを踏まえて問題のPDFのデータを確認したところ、描画中には「k」と「K」命令しか含まれていませんでした。つまり、このPDFは無事CMYKで出力されていた、と言うことになります。

 ちなみに確認は上記で挙げたコード例のようなコードでページのコンテンツをテキストファイルに出力し、それをテキストエディタで開いて「g」や「rg」、「k」、「sc」という文字列を検索して目視で確認するという原始的な方法で行いました。今回は1ページだけのPDFファイルで、かつラスター画像を含まないものだったのでこれで十分でしたが、ページ数が増えた場合はプログラムを書いてちゃんと分析しないと大変そうです。