Inkscapeから縦書きテキストを含んだPDFを出力する苦行

## [.JPD001] 縦書きPDFも出せないこんな世の中じゃ

「えっ、縦書きテキストの入ったPDFを作りたいだけなのに、どうしてこんなに苦労しなきゃいけないの……？」
と、思われる方も多いでしょうね。

まず最初に、少し振り返ってみましょう。文明社会に生きている殆どの人が知っていると思われるPDF（Portable Document Format）が生まれたのは1993年、つまり今から**31年前**のことです。Adobeによって開発されたこのフォーマットは、以来、電子文書の標準フォーマットとして進化を続け、あらゆる場面で活用されてきました。

そして今は**2024年の年末**――。技術は飛躍的に進歩し、AIや自動化が日常的になりつつある時代です。縦書きPDFの作成なんて、もはやワンクリックで終わる？

ですが、現実はそう甘くありません。オープンソースツールの雄であるInkscapeを使う場合、やや面倒です。

**この時点で面倒だと思ったらAdobeを使うが良い**

そんな苦行を笑い飛ばしながらも（心の中では涙を流しつつ）、縦書きPDFの作成プロセスを**絶対できる前提で楽しみたい方**のみお進み下さい。

## [.JPD002] About 
### [.JPD002-1] ようこそ CRUISEBASKET #中央ちゃんぽん線 へ

この縦書きPDF問題の爆心地となったのが「CRUISEBASKET #中央ちゃんぽん線」（#も含めて正式な表記）

1964年 国連調査団報告（ワイズマンレポート）に基づく[太平洋新国土軸](http://t-kokudojiku.jp/kokudo1/index.htm)+αを、今の時代なら**コンクリートではなくデザインやネットで実現できるのではないか？**

そんな試作理論から産まれ、私ソロでやっている横断的な地理フォーマットプロジェクトです。

コアとなるのが超横長でA4×12ページも及ぶ路線図。新宿から軍艦島を、中央線・飯田線を筆頭とした中央構造線沿いに存在する現実の公共交通機関33パートを勝手に抜き出して並べ、ナンバリングしています。

※ナンバリングは**もちろん3桁**・PDFはご自由にダウンロード頂けます

https://cruisebasket.jp


:::note info
空想地図・空想路線図ライクではありますが、現実にある交通機関をベースにしているため、移動手段や土地そのものは架空のものではありません。
:::

現在のところ **登場する交通事業者・自治体・その他お店超非公認** ですが、管轄やブロックが異なる公共サービスと地理的・文化的要素を広域かつ限定して表現する統合デザインインフラはあるようで無い。

なので、「**無ければ作ればいいのよ！**」と言わんばかりに現地に通いつつ、身体と舌で研究。

かつて[南海汐見橋駅などで見られたような](https://commons.wikimedia.org/wiki/File:南海高野線汐見橋駅050812b.jpg) 山・川・池沼・海・用水も描かれたアイコニックな路線図の実用的復権を目指し、単なる地理情報を伝えるだけでなく、地域や自然の特徴を視覚的に表現します。

12ページA4サイズのPDFは、出力後に**ユーザーがハサミとのりを駆使して貼り合わせることで、「本」「巻物」「掲示物」といった異なるものに変形。**

やや掲示に合わせているため、本として使うと線はかなり太く、文字が大きいと感じるかもです。


### [.JPD002-2] どう活用していくか

**みんなの経◯新聞ネットワークとデ◯リーポータルZを足して12で割った地域メディア**を作りたい
**あわよくば実際にこの経路の片道MaaS乗車券**や
**特産物同士をかけ合わせたPBが実現**したらいいなぁ、、
↑
なんて考えています。

_そのためには、とにかく新しくなければならない。_
_新聞と同じく毎日最新版が発行される。_
_例えばデジタル紙と媒体両方の路線図に桜前線が反映される、など。_
_**路線図自体が新たなメディアとならにゃならんのです。**_

_Webだけではなく**コンビニ複合機でNigtly Buildが出力できるプロジェクトってなんかかっこよくない？**_

ということで、機械的な出力と将来Javascriptフレームワークに長けいていそうな~~気がした~~SVGを採用。なら**デザインツールはInkscape一択だな。** 

そんな軽いノリでやってきました。

### [.JPD002-3] 建設規模

**A4 297mm*12ページ = 3,564mm**
**総要素数: 56,594個**
**元ファイルサイズ: 28.3MB**
※いずれも2024/12/10現在のデータ

#### 本
![本.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/60515c91-4b7c-db00-af9f-98aad5aae55d.jpeg)
#### 巻物
![巻物傾きGF.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/d4b9442f-b192-ff78-3758-ac6d1875a1bd.jpeg)
#### 掲示
![壁.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/21613633-f594-938c-2dd2-6994f378f90c.jpeg)



[伊勢湾口道路](https://ja.wikipedia.org/wiki/三遠伊勢連絡道路)+[紀淡海峡インフラ](https://www.pref.wakayama.lg.jp/bcms/prefg/020500/kosokuinfra/index.html)+[四国新幹線](https://www.shikoku-shinkansen.jp)+[豊予海峡トンネル](https://www.city.oita.oita.jp/o0169/machizukuri/kotsu/documents/hoyo1.pdf)+[九州横断新幹線](https://ja.wikipedia.org/wiki/九州横断新幹線)+[島原天草長島連絡道路](http://www.3ken-kakyou.jp/index.html)を整備するよりかは、**遥かに低コストで済みます。**

とんでもないサイズPDF生成はもちろん手動なわけがなく **viewBox値の書き換え→Inkscapeのコマンドライン実行（×12ページ分）→結合**をやってくれるスクリプトを作りました。

さぁ、本題に戻ろう！！
## [.JPD003] ごく普通の真面目な子だと思っていた（文字を選択するまでは…）


PDF出力後のテキストが問題ないことを確認するため、PDFビューアで開いてみます。  

```[.JP192]渥美ショップ前（4/33 豊鉄バス伊良湖本線）```
![スクリーンショット 2024-12-09 20.05.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/e293d6c5-cbf9-4189-72f8-97764e11bab5.png)

見た目には問題なく、普通に縦書きテキストに見えますね。  
「問題なさそうじゃん？」と思うことでしょう。  
**そう、ここからが真の試練なのです。**

カーソルで選択してみると…

![スクリーンショット 2024-12-09 19.59.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/fef7cd96-9de1-eea2-8bab-0b9e17cb97cc.png)

**ナンカアヤシイゾ**

これがメモに貼り付けた結果

![スクリーンショット 2024-12-09 20.02.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/b9d23b7e-df44-6059-b25d-c7fd88c5e1dd.png)


正:
```渥美ショップ前```

現実:
```
渥
美
シ


プ
前
```
**まさかの１行ごとに改行**
**ちっちゃい「ョ」「ッ」抹殺**

Inkscapeに頼んだ私の期待は、まさに真面目でお利口さんな挙動をしてくれると思ってました。
だって、HTML/CSSの[```writing-mode:```](https://developer.mozilla.org/ja/docs/Web/CSS/writing-mode)はSVGにもあるジャマイカ！

Inkscapeさんが悪いのか、Cairoさんが悪いのかは知りません。

Googleさんは横書きPDFの改行悪あがきでもそれなりに引っかかってくれるし、画像にしてOCRすれば（精度はそれなりに高いと思うけど）解決する部分もあるでしょう。

**オジサンはSEO・PDFの操作性・Plain-SVG 全3方向全て完璧に動くSVGしかいらんのだよ！**

## [.JPD004] 縦の糸と横の糸

### [.JPD004-1] ベタな解決策

その時ふと、僕の脳内に**この曲が流れました。**

https://music.apple.com/jp/album/%E7%B3%B8/1654000186?i=1654000188

縦書きテキストを**パス化→上から90°回転した横書き透明テキストを被せればいいのでは？**

### [.JPD004-2] まずは手動でやってみた

まずは単純に手動で


1. テキストを選択
2. 複製
3. 複製されたテキストをパス化
4. 元のオブジェクトを選択
5. 横書き ```writing-mode:lr-tb``` に変更
6. 90°回転
7. フィル・ストローク共に透明化
8. PDF出力

その結果は！？

![スクリーンショット 2024-12-11 11.51.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/7c833145-234e-a2d1-2f3e-355e8b439755.png)

あれ、、選択できないぞ **渥美ショップ前**
これでは[ショッピングセンター レイ](https://atsumi-lei.com)を愛する田原市民に叱られます。

### [.JPD004-3]「透明テキスト」だからダメなんじゃね！？
もしかして

![スクリーンショット 2024-12-11 11.40.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/e16c0e8a-694e-9b57-1273-771aa9eff55b.png)
![atsumishopOK.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/0b7ce16f-37b3-7457-388c-0f613de1547c.png)
![渥美ショップ前（正）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/c8e5637f-a924-933d-3fb8-219f8e2d0a7c.png)

選択できたぞ **渥美ショップ前**
いろいろ試した結果

❌️```fill=none;```
❌️```fill-opacity: 0;```
✅️```fill-opacity: 0.01;```

そう、、

**透明なものはPDF出力できない。**

**肉眼では見えないけど完全に透明では無いテキスト**はOKな~~面倒くさい子の~~ようです。

## [.JPD005] GUI操作を自動化してくれる「Inkscape Action」

### [.JPD005-1] なんとかしてくれる仕組み、あります！

万単位のオブジェクトをこんなふうにイチイチ手動操作するわけにはいきません。

XML的に全てを解決することも検討しました。それに越したことはない。

ただ、座標の指定とかいろいろオブジェクトによって違って超めんどう。
将来避けられない課題ではあるけれども今は見送ります。

ここで登場するのがInkscapeの**コマンドライン実行**

https://wiki.inkscape.org/wiki/Using_the_Command_Line

そして **Inkscape Action**

https://wiki.inkscape.org/wiki/Action

### [.JPD005-2] とりあえずなんかやってみよう
「肉眼では見えないけど完全に透明」な状態にする前に、横書き→90°時計回りに回転させたテキストを元々のテキストに被せたいとします。

いくつかやり方はありますがスナップを使わないとすれば、**移動させたいオブジェクト**を先に選択し、**位置を合わせる基準となるオブジェクト**を選択。

**整列と配置** で **基準:最後の選択部分** にセット

**中心を垂直軸に合わせます** を実行
![整列と配置1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/75c8df2c-c255-0600-751d-e5094bf3146b.png)
**水平軸の中心に揃えます** を実行
![整列と配置2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/ee2080f3-44f7-21ba-1073-8fd3dbad3afe.png)
**結果: ぴったんこ**
![整列と配置3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/45a12ee3-72ec-4a6b-330a-c43676f60791.png)

これをInkscape Actionで実現しようとすると

```bash:
$ inkscape --batch-process --actions="object-set-attribute:id,<移動させたいオブジェクトのid>,<位置を合わせる基準となるオブジェクトのid>;object-align:last hcenter;object-align:last vcenter;select-clear" old.svg -o new.svg
```
こんな感じで、コマンドラインでGUIとほぼ同感覚の操作ができちゃうのです。

詳細は[Inkscape Wiki](https://wiki.inkscape.org/wiki/Action) もしくは
```bash:
inkscape --batch-process --action-list
```
でご覧いただけますが…

```usage      ``` **（沈黙）**

になっている項目は**ソースを読むしかありません。**

例えば「整列と配置」なら
https://inkscape.gitlab.io/inkscape/doxygen/actions-object-align_8cpp_source.html

```bash:
LANG=C inkscape
```
英語環境のInkscape(GUI)を起動して、操作名をチェックするとやりやすいでしょう。

さぁ、次はいよいよPythonを用いてActionを構築し、**56,594個**の要素に挑んでいきます。

## [.JPD006] Pythonへの読み込み
Action自体はinkscapeコマンドがファイル操作を行いますが、要素のidを知らないと要素を選択するActionを作れません。

InkscapeでSVGをよみこむ前に、PythonでもSVGを読み込む必要があります。

### [.JPD006-1] Treeの構築
ここではlxmlを使用し、treeを構築します。
```python
#SVGファイルを読み込み
with open(input_file, 'r', encoding='utf-8') as f:
    svg_input = f.read() 


# SVGをlxmlのtreeに
svg_tree = etree.fromstring(svg_input.encode('utf-8'))
```

###  [.JPD006-2] 要素の取り出し
次にXPathで```flowRoot```と```text```を要素ごと取り出して処理していきましょう。

```python
#縦書きと認識するwriting-mode
vertical_modes = {"tb", "tb-rl", "tb-lr", "vertical-rl", "vertical-lr"} 

#flowRoot と text オブジェクトを対象に
xpath_query= (
    "//svg:flowRoot | //svg:text"
)

#要素別処理
for elem in svg_tree.xpath(xpath_query, namespaces=NAMESPACES):
    
    id = elem.get('id') #オブジェクトのidを取得
    
    if id:
        #スタイルを取得
        style = elem.get('style') 

        vertical = False
        
        #writing-mode:を取り出し
        writing_mode_match = re.search(r'writing-mode\s*:\s*([^;]+)', style) 
        
        
        if writing_mode_match:
            writing_mode = writing_mode_match.group(1).strip()
            
            #writing-modeが縦書きの場合
            if writing_mode in vertical_modes:
                vertical = True 
```
```elem```に該当```text```or```flowRoot```以下の要素や属性が入っています。```id```と```style```属性値を取り出していますね。縦書き横書きの判定は、取り出した```style```の属性値からさらに``writing-mode:``の値を取りだしてます。

### [.JPD006-3] 取り出した要素の判定
```elem```が```text```なのか```flowRoot```なのかは、こんな感じで判定します。
```python:
if elem.tag == '{http://www.w3.org/2000/svg}flowRoot': 
```


## [.JPD007] Actionを作っていく上での注意点


### [.JPD007-1] テキストには2種類あります

はじめに言ってなかったけど**ブラウザ用 Plain（標準）SVGも**ついでに作りたい！

なので、縦書きテキストは
* 元要素 **\<text\>**
* パス化したテキスト **\<path\>**
* 肉眼では見えないけど完全に透明では無いテキスト **\<text\>**

の3つを用意し
* PDF出力の際に縦書きのみ元要素 **\<text\>**を削除
* Plain SVG出力の際に縦書きのみパス化したテキスト **\<path\>**を削除

したいと思います。

ところでテキストInkscape SVGのテキストは**2種類の全く違う要素**があります。

#### テキストオブジェクト
上位要素 ```text```
```text``` > ```tspan``` （```style```・テキスト本体）

* SVGの標準仕様
* **ブラウザでレンダリングされる**
* 縦書き変換する際に配置が乱れることがある

#### フローテキスト(Flowed Text)
上位要素 ```flowRoot```
```flowRoot``` > ```flowPara```（テキスト本体）
```flowRoot``` > ```flowRegion``` > ```rect``` （```style```・図形）

* Inkscape独自拡張
* 図形の上にテキストが乗っかっている
* **ブラウザではレンダリングされない**
* 縦書き変換する際に配置が乱れることがある


ちゅうことで、、
**横書きを含め**```flowRoot```**要素は**```text```**要素に変換せねば。**

また、```flowRoot```要素における縦書き用の「肉眼では見えないけど完全に透明では無いテキスト」の配置が乱れないようにするためには、以下の順番を踏む必要があります。


1. ```rect```要素のサイズを適正化する
縦書きテキストを横書きに変えるわけですが、```rect```の横幅がテキストが収まるサイズでなければ段落ちします。一律```rect```の```width```を拡大する処理を入れておくのが無難です。
```python
#flowRoot かつ縦書きの場合
if vertical:
    #子 flowRegion の 子 Rect要素を選択
    rect = elem.find('./svg:flowRegion/svg:rect', NAMESPACES)
    if rect is not None:
                    
        #新しい幅を 従来の幅+従来の高さ に定義（適当）
        new_width = float(rect.get("width")) + float(rect.get("height"))
                    
        #新しい幅を適用
        actions.extend([
            f"select-by-id:{rect.get("id")}",
            f"object-set-attribute:width,{new_width}",
            "select-clear"
            ])
```

2. convert-textする前に横書きに変換
後述するtext要素の問題を避けるため、先に横書き横書きに変換しておきます。



### [.JPD007-2] 既に```text```要素になっているものも厄介（xsltプロセッサで処理）
今度は**愛媛**にやってきました。
```[.JP492]いよ立花（20E/33 伊予鉄道 横河原線）```
![スクリーンショット 2024-12-11 16.03.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/b3987487-f918-4798-7808-20fa5011ee11.png)
この text 要素を横書きにしてみると…
![いよ立花tspan.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/f6a1c0c4-50fb-a445-71dc-5ae20ac2d177.png)
あれま、孫要素のtspanが「いよ」と「立花」に分割され、そのまま段落ちしていますね。
```xml:RouteMaster.svg
<text xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:3.33599px;line-height:0.165293px;font-family:'Mgen+ 1c';-inkscape-font-specification:'Mgen+ 1c';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:tb-rl;text-anchor:start;display:inline;fill:#221e1f;fill-opacity:1;stroke:none;stroke-width:0.04;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;enable-background:new"
           x="685.85638"
           y="81.272537"
           id="text2295">
        <tspan
             sodipodi:role="line"
             x="685.85638"
             y="87.944527"
             id="tspan2295"
             style="stroke-width:0.04">
               <tspan
               x="685.85638"
               y="81.272537"
               id="tspan2293"
               style="stroke-width:0.04">いよ</tspan>
               <tspan
               x="685.85638"
               y="87.944527"
               id="tspan2294"
               style="stroke-width:0.04">立花</tspan>
        </tspan>
</text>
```

```text```要素の文字列は1行ごとに子要素の```tspan```要素の子（```text```からみたら孫）の```tspan```要素に格納されます。

孫の```tspan```は
* 英
* 数
* 全角英字
* 全角数字
* カタカナ
* ひらがな
* 漢字
* ヶノ

などなど、文字種が変わる度に```tspan```要素が分割して格納される仕様なのです。

ところが**縦横変換時にはそれぞれの**```tspan```**要素全てが行になってしまいます。**

そこで、id名が```*--cleartext```な```text```要素の孫```tspan```要素に収納されている文字列を1個目の要素にまとめ、残りは消し去る処理が必要です。

**アイツを消す・隠す 暗部的な処理** は既にxslt2.0とSaxon-HEを使用していたので、今まで通りinkscape → Saxon-HE →（パイプ）inkscapeと渡して処理することにしました。とってもスマートに書けて便利です。

ついでに子の```flowPara```要素が空っぽの```flowRoot```も全部消しちゃいましょう。

```xslt inkscape-edit.xslt
<xsl:stylesheet version="2.0" 
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:saxon="http://icl.com/saxon"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
extension-element-prefixes="saxon"
>

<xsl:output method="xml" 
	version="1.0" 
	encoding="UTF-8"
	standalone="no"
  saxon:character-representation="native;entity"
  media-type="image/svg"
  indent="yes"/>


 <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()"/>
    </xsl:copy>
  </xsl:template>

<!-- id が '-cleartext' で終わる <svg:text> 要素を対象に処理 -->
<xsl:template match="svg:text[ends-with(@id, '-cleartext')]">
  <xsl:copy>
    <xsl:apply-templates select="@*"/>
    <!-- 属性をコピー -->
    <xsl:apply-templates select="svg:tspan"/>
    <!-- 直下の tspan を処理 -->
  </xsl:copy>
</xsl:template>

<!-- 子 <svg:tspan> を処理：最初の子のテキストに統合 -->
<xsl:template match="svg:text[ends-with(@id, '-cleartext')]/svg:tspan">
  <xsl:copy>
    <xsl:apply-templates select="@*"/>
    <!-- 属性をコピー -->
    <xsl:apply-templates select="svg:tspan[1]"/>
    <!-- 最初の子を処理 -->
  </xsl:copy>
</xsl:template>

<!-- 子 <svg:tspan> の最初の子を処理してテキストを統合 -->
<xsl:template match="svg:text[ends-with(@id, '-cleartext')]/svg:tspan/svg:tspan[1]">
  <xsl:copy>
    <xsl:apply-templates select="@*"/>
    <!-- 属性をコピー -->

    <!-- 自身のテキストと兄弟要素のテキストを結合 -->
    <xsl:for-each select="../svg:tspan">
      <xsl:value-of select="."/>
    </xsl:for-each>
  </xsl:copy>
</xsl:template>

<!-- 2番目以降の <svg:tspan> 要素を削除 -->
<xsl:template match="svg:text[ends-with(@id, '-cleartext')]/svg:tspan/svg:tspan[position() > 1]"/>

<!-- flowRoot 要素をチェック -->
<xsl:template match="svg:flowRoot">
  <!-- 子 flowPara に非空のテキストノードがあればコピー -->
  <xsl:if test="svg:flowPara[normalize-space(.) != '']">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:if>
  <!-- 非空の flowPara がなければ何もしない (削除) -->
</xsl:template>

<!-- 以下あんま関係ないけど、、-->

<!-- こんなふうに特定の属性を変えたり -->
<!--Set width 297mm-->
<xsl:template match="svg:svg/@width">
  <xsl:attribute name="width">297mm</xsl:attribute>
</xsl:template>

<!-- レイヤを隠したり -->
<!--Hide A4 Layer-->

<xsl:template match="svg:svg/svg:g[@inkscape:groupmode='layer' and @inkscape:label='A4']/@style">
  <xsl:attribute name="style">display:none</xsl:attribute>
</xsl:template>

<xsl:template match="svg:svg/svg:g[@inkscape:groupmode='layer' and @inkscape:label='A4']/svg:g[@inkscape:groupmode='layer']/@style">
  <xsl:attribute name="style">display:none</xsl:attribute>
</xsl:template>

<!-- レイヤを丸ごと消したり -->
<!--Remove Memo Layer-->

<xsl:template match="svg:g[@inkscape:groupmode='layer' and ends-with(@inkscape:label, '_Memo')]">
</xsl:template>

<!-- 「見られちゃいやーん！」な部分もついでに消せるよ -->
<!--Remove Memo Layer-->

<xsl:template match="@inkscape:export-ydpi | @inkscape:export-xdpi | @inkscape:export-filename | @inkscape:connector-curvature">
</xsl:template>

</xsl:stylesheet>
```
### [.JPD007-3] 透明化

#### これだけでは透明にならないことがある
```python
if vertical:
    actions.extend([
        f"select-by-id:{id}-cleartext",
        "object-set-property:fill-opacity,0.01", 
        "object-set-property:stroke,none", 
        "select-clear"
```
#### \<tspan\> の中まで焼きましょう
子の```tspan``で属性が定義されてしまっていることがあるので、そちらにも魔法をかけましょう。
Inkscape Actionにおける要素の選択はCSSセレクタも使えるよ！
```python
if vertical:
    actions.extend([
        f"select-by-id:{id}-cleartext",
        "object-set-property:fill-opacity,0.01", 
        "object-set-property:stroke,none", 
        "select-clear",
        #text要素内のtspan要素にも同様の処理
        f"select-by-selector:#{id}-cleartext tspan", #CSSセレクタで要素を選択
        "object-set-property:fill-opacity,0.01", 
        "object-set-property:stroke,none", 
        "select-clear" #選択解除
        ])
```
#### 幸せの黄色いデバッグ
なんだけど、いきなり透明だとデバッグや確認がしづらいです。
動作確認が完了するまでは**黄ペン先生**にしとくといいでしょう。
```python
if vertical:
    actions.extend([
        f"select-by-id:{id}-cleartext",
        #"object-set-property:fill-opacity,0.01", 
        "object-set-property:fill-opacity,1",  # for debug
        "object-set-property:fill,#fff111",  # for debug
        "object-set-property:stroke,none", 
        "select-clear",
        #text要素内のtspan要素にも同様の処理
        f"select-by-selector:#{id}-cleartext tspan", #CSSセレクタで要素を選択
        #"object-set-property:fill-opacity,0.01", 
        "object-set-property:fill-opacity,1",  # for debug
        "object-set-property:fill,#fff111",  # for debug
        "object-set-property:stroke,none", 
        "select-clear" #選択解除
        ])
```
![2024-12-08 10.37の画像.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/efd799d2-2bec-439e-560c-cded1186efbf.jpeg)






### [.JPD007-4] シェルは千手観音を想定していない
多分、やってるとこんなエラーに遭遇する筈。

```python: actions.py
subprocess.run(["inkscape",
              "old.svg",
              "--batch-process",
              "--actions",actions_str,
              "old.svg",
              "-o","new.svg"
              ])
```
```bash:
OSError: [Errno 7] Argument list too long: '/bin/bash'
```
![スクリーンショット 2024-12-11 14.13.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/4ed1699b-b80e-30b9-7b7e-b1866bc011d6.png)

そりゃシェルにこんな大量の命令を打ち込むなんて**人外だよね。**

大丈夫。inkscapeはテキストファイルからActionを読み込むことができます。

```python: actions.py
actions_file_path = "actions.txt"

    with open(actions_file_path, "w") as file:
        file.write(actions_str)
        
subprocess.run(["inkscape",
              "old.svg",
              "--batch-process",
              f"--actions-file={actions_file_path}",
              "old.svg",
              "-o","new.svg"
              ])
```
デバッグにも使えて一石二鳥です。


## [.JPD008] 繋げていきましょう
### [.JPD009-1] ここまでを踏まえた全体の処理
こんな感じになります。
![20241211XSLT.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341503/64cd0711-804f-a617-5ca9-1ab11ce4b275.jpeg)

**あまりにも汚かったので妻に清書してもらいました**

### [.JPD009-2] 「既に完成しているものがこちらです」
**↑一度言ってみたかった**
先ほどのxsltと組み合わせて実行です。
（細かい部分は必要があれば加筆します）

```python:isvg2pdfsvg.py
import os
import subprocess
import re
from lxml import etree   

input_file = "input.svg"
output_pdf_file = "output.pdf"
output_psvg_file = "output.plain.svg"

phase1_svg = os.getcwd() + "phase1.svg"
phase2_svg = os.getcwd() + "phase2.svg"

inkscape_bin_path = "/Applications/Inkscape.app/Contents/MacOS/inkscape" #Macの場合

NAMESPACES = {
    'svg': 'http://www.w3.org/2000/svg',
    'inkscape': 'http://www.inkscape.org/namespaces/inkscape'
}

#外部コマンド実行
def run_command(command, check=True, timeout=2400):
    """シェルコマンドを実行し、出力を返す"""
    try:
        print(f"コマンド実行中: {command}")
        result = subprocess.run(command, shell=True, check=check, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
        return result.stdout.strip()
    except subprocess.CalledProcessError as e:
        print(f"エラー: コマンド実行失敗: {command}\n{e.stderr}")
        raise
    except subprocess.TimeoutExpired as e:
        print(f"エラー: タイムアウト: {command}")
        raise
    
    
print("Phase1.svgを読み込み")

#SVGファイルを読み込み
with open(input_file, 'r', encoding='utf-8') as f:
    svg_input = f.read() 


# SVGをlxmlのtreeに
svg_tree = etree.fromstring(svg_input.encode('utf-8'))


#actions
actions = []
#縦書き要素のid
vertical_text_ids = []
#縦書きパステキスト要素のid
vertical_pathtext_ids = []
#縦書きクリアテキスト要素のid
vertical_cleartext_ids = []
#位置調整が必要な横書きテキストのid
horizontal_text_ids = []
#削除するパステキスト要素のid
horizontal_pathtext_ids = []


#縦書きと認識するwriting-mode
vertical_modes = {"tb", "tb-rl", "tb-lr", "vertical-rl", "vertical-lr"} 

#flowRoot と text オブジェクトを対象に
xpath_query= (
    "//svg:flowRoot | //svg:text"
)

#要素別処理
for elem in svg_tree.xpath(xpath_query, namespaces=NAMESPACES):
    
    id = elem.get('id') #オブジェクトのidを取得
    
    if id:
        #スタイルを取得
        style = elem.get('style') 
        
        vertical = False
        
        #writing-mode:を取り出し
        writing_mode_match = re.search(r'writing-mode\s*:\s*([^;]+)', style) 
        
        
        if writing_mode_match:
            writing_mode = writing_mode_match.group(1).strip()
            
            #writing-modeが縦書きの場合
            if writing_mode in vertical_modes:
                vertical = True 
                vertical_text_ids.append(id) #縦書きテキストのidリストに追加
                vertical_cleartext_ids.append(f"{id}-cleartext") #縦書き用クリアテキストのidリストに追加
                vertical_pathtext_ids.append(f"{id}-pathtext") #縦書き用パステキストのidリストに追加
        
        #flowRoot オブジェクトの処理
        if elem.tag == '{http://www.w3.org/2000/svg}flowRoot': 
            # 縦書きも横書きも関係なく 複製→ <元要素のid>-pathtext を生成（横書きの場合は位置調整のみに使用し、パス化せず削除）
            actions.extend([
                f"select-by-id:{id}", #元要素を選択
                "duplicate",  # 複製
                f"object-set-attribute:id,{id}-pathtext", 
                "select-clear"]) #選択解除
            
            #flowRoot かつ縦書きの場合
            if vertical:
                #子 flowRegion の 子 Rect要素を選択
                rect = elem.find('./svg:flowRegion/svg:rect', NAMESPACES)
                if rect is not None:
                    
                    #新しい幅を 従来の幅+従来の高さ に定義（適当）
                    new_width = float(rect.get("width")) + float(rect.get("height"))
                    
                    #新しい幅を適用
                    actions.extend([
                        f"select-by-id:{rect.get("id")}",
                        f"object-set-attribute:width,{new_width}",
                        "select-clear"
                    ])
                    
                #元のflowRootを複製→横書きに変更→テキストアンカーをstartに→textオブジェクトに変換→idを <元要素のid>-cleartext に設定    
                actions.extend([
                    f"select-by-id:{id}", 
                    "duplicate",
                    "object-set-property:writing-mode,lhorizontal-tb", #横書きに変更
                    "object-set-property:text-anchor,start", #テキストアンカーをstartに
                    "text-convert-to-regular", #textオブジェクトに変換
                    f"object-set-attribute:id,{id}-cleartext"
                    ])
            else:
                #横書きflowRootを位置調整が必要な横書きテキストリストに入れる
                horizontal_text_ids.append(id)
                horizontal_pathtext_ids.append(f"{id}-pathtext")
            
            #元のflowRootをText要素に変換
            actions.extend([
                f"select-by-id:{id}",
                "text-convert-to-regular",#テキストに変換
                "object-set-property:text-anchor,start",
                f"object-set-attribute:id,{id}", #テキストに変換するとidが変わるのでidを復元
                "select-clear" 
            ])
                
            
                    
        #テキストオブジェクトかつ縦書きの場合、要素を複製 元要素はテキスト要素へ変換 複製した方をpath生成用・位置調整用オブジェクトに
        elif vertical == True:
            # print("Textの縦書き")
            actions.extend([
                f"select-by-id:{id}", #元要素を選択
                "duplicate", #複製
                f"object-set-attribute:id,{id}-pathtext",  #複製側のIDを <元要素のID-pathtext> に
                "select-clear", #選択解除
                f"select-by-id:{id}", #元要素を選択
                "duplicate", #複製
                f"object-set-attribute:id,{id}-cleartext", 
                "object-set-property:writing-mode,lhorizontal-tb", #横書きに変更
                "object-set-property:text-anchor,start"
                
            ])
        
        
        #縦書き要素の場合、クリアテキストを生成(flowRoot/text 要素共通)
        if vertical == True:
            actions.extend([
                f"select-by-id:{id}-cleartext",
                "object-set-property:fill-opacity,0.01", # フィルを肉眼では見えないけど完全に透明では無くする
                "object-set-property:stroke,none", #ストロークを無効化
                "object-rotate-90-cw", # 90度時計回りに回転
                "select-clear",
                #text要素内のtspan要素にも同様の処理
                f"select-by-selector:#{id}-cleartext tspan", #CSSセレクタで要素を選択
                "object-set-property:writing-mode,lhorizontal-tb",
                "object-set-property:fill-opacity,0.01", 
                "object-set-property:stroke,none", 
                "select-clear" #選択解除
            ])


#縦書き要素のidを,区切りのstrに
vertical_pathtext_ids_str = ",".join(vertical_pathtext_ids)
#縦書きパステキスト要素のidを,区切りのstrに
vertical_cleartext_ids_str = ",".join(vertical_cleartext_ids)
#位置調整が必要な横書きテキストのidを,区切りのstrに
horizontal_pathtext_ids_str = ",".join(horizontal_pathtext_ids)
#削除するパステキスト要素のidを,区切りのstrに
vertical_text_ids_str = ",".join(vertical_text_ids)


#縦書きpathtextのパス化
actions.extend([
    f"select-by-id:{vertical_pathtext_ids_str}", # 縦書き要素の <元要素のid-pathtext>を全て選択
    "object-to-path",  # 同時にパス化（同時にパス化しないとずれる可能性あり・id <元要素のid-pathtext> は維持される）
    "select-clear" #選択解除
])


    
print("[Phase1] Total Action: "+ str(len(actions)))

#[Phase1] actionファイルの作成
actions_str = ";".join(actions)

actions_file_path = f"{phase1_svg}.actions.txt"

with open(actions_file_path, "w") as file:  # "w"は書き込みモード
    file.write(actions_str)
print(actions_file_path)

#[Phase1] コマンドの実行
command =(
    f"{inkscape_bin_path} "
    f"{input_file} "
    f"--actions-file={actions_file_path} "
    "--batch-process "
    "--vacuum-defs "
    f"-o {phase1_svg}"
)      

run_command(command)


print("Phase2.svgを作成中")


actions= [] #actionを空に

#縦書きテキストの個別位置合わせ（cleartext・元要素をpathtextに合わせる）
for id in vertical_text_ids:
    actions.extend([
        f"select-by-id:{id}-cleartext,{id},{id}-pathtext", #cleartext pathtext 元要素 合計3つを選択
        "object-align:last vcenter", #cleartext・元要素をpathtextの縦位置に合わせる
        "object-align:last hcenter", #cleartext・元要素をpathtextの横位置に合わせる
        "select-clear"
    ])
    
#位置調整が必要な横書きテキストの個別位置合わせ（元要素をpathtextに合わせる）
for id in horizontal_text_ids:
    actions.extend([
        f"select-by-id:{id},{id}-pathtext",# 元要素 pathtext 合計2つを選択
        "object-align:last vcenter", #元要素をpathtexの縦位置に合わせる
        "object-align:last hcenter", #元要素をpathtexの横位置に合わせる
        "select-clear" #選択解除
    ])

#位置合わせが終わって要なしになった横要素の pathtext（パス化されていない）を一括削除
actions.extend([
    f"select-by-id:{horizontal_pathtext_ids_str}", 
    "delete-selection", 
    "select-clear"
])
    

print("[Phase2] Total Action: "+ str(len(actions)))

#[Phase2] actionファイルの作成
actions_str = ";".join(actions)

actions_file_path = f"{phase2_svg}.actions.txt"

with open(actions_file_path, "w") as file: 
    file.write(actions_str)


#[Phase2] コマンドの実行
command = (
    f"saxon {phase1_svg} inkscape-edit.xslt" 
    f"| {inkscape_bin_path} --pipe  --batch-process --actions-file={actions_file_path} -o {phase2_svg}"
)

run_command(command)


print("PDFを作成中")

#[Phase3 (for PDF)] 目に見える縦書きはパスであって欲しいので、元要素を削除
actions = [
    f"select-by-id:{vertical_text_ids_str}",
    "delete-selection" #削除
]

print("[Phase3 (for PDF)] Total Action: "+ str(len(actions)))

#[Phase3 (for PDF)] actionファイルの作成
actions_str = ";".join(actions)

actions_file_path = f"{output_pdf_file}.actions.txt"

with open(actions_file_path, "w") as file:
    file.write(actions_str)


#[Phase3 (for PDF)] コマンドの実行
command =(
    f"{inkscape_bin_path} "
    f"{phase2_svg} "
    f"--actions-file={actions_file_path} "
    "--batch-process -p -C --vacuum-defs --export-pdf-version=1.5 -d 600 --export-type=pdf"
    f"-o {output_pdf_file}"
)

run_command(command)


print("Plain SVGを作成中")

#[Phase3 (for Plain SVG)] 目に見える縦書きは元要素（text要素）であって欲しいので、パスを削除
actions = [
    f"select-by-id:{vertical_pathtext_ids_str},{vertical_cleartext_ids_str}",
    "delete-selection" 
]
print("[Phase3(for Plain SVG)] Total Action: "+ str(len(actions)))

#[Phase3 (for Plain SVG)] actionファイルの作成
actions_str = ";".join(actions)

actions_file_path = f"{output_psvg_file}.actions.txt"

with open(actions_file_path, "w") as file:
    file.write(actions_str)

#[Phase3 (for Plain SVG)] コマンドの実行
command = (
            f" {inkscape_bin_path} --pipe --batch-process --export-plain-svg --actions-file={actions_file_path} -o {output_psvg_file}"
        )

run_command(command)
```

さぁ、実行！
```bash: 
python isvg2pdfsvg.py
```

### [.JPD009-3]  結果
私のM1 MacbookAirでの処理時間は

最速7分30秒 
2/33 _飯田線 大嵐→水窪の所要時間に相当_
最長13分 （同時にYoutube観てる時）
26/33 _豊肥本線 波野→宮地の所要時間に相当_

全フェーズで **113,007個もの手動操作**（xsltは含まず）が**自動化**されました！

実際には上のコードに加え
* ```viewBox```の値を変えて各ページごとにPDFを出力
* それをマージしてPDFのメタデータを編集
* Plain SVGのOptimizationと圧縮

これを手動でやるとするなら **阿蘇のカルデラを自分の足で超えるよりキツい** のは明白です。

## [.JPD010]  ToDo
* ビルド→アップロード→公開の自動化

* やきたてのお知らせをSNSで配信

* PWA完成まではせめて沿線施設や店舗にリンク貼りたい
→Inkscape 1.4でPDF内リンクに対応してから、**外部リンクは全てエラーに…**
（1.3までは見た目自体がおかしくなることが多い）

* オブジェクトのライブラリ化（クローンの使用）やスタイルシートでダイエット
→すんません **逝っとけで作ってます**

* 作者自信のダイエット
→**コレが１番難しい**

## [.JPD011] さいごに
本来私は重度のASDのため圧倒的にワーキングメモリが足りません。RS-232Cでグラボや大容量SSDを繋いでいるPCを思い浮かべて下さい。コードや文をマトモに書き上げることができませんでした。

漢字読めても書けない。
そして一応にもそれなりに広範囲の技術的知識を持っていて、コードが多少読めても書けない。

今回、コーディングと記事作成の多くはChatGPTの力を借りています。

39年の人生の中で初めて自分が自分になったように感じました。
大げさに感じるかもしれませんが、異世界転生そのものです。

と、同時に、思ったことがあります。
恐らく眼鏡が生まれるまでは、弱視の人も発信力が無いに等しく、実力以下の処遇を受けたり、いない人扱いされていたのでは、、と。

Qiitaはじめ小学生でもアクセスできる平等な情報源やオープンソースコミュニティ、テクノロジーに対し、以前よりジーンとすることが増えた気がします。

公式サイトからご感想・ツッコミ・タレコミ・バグ報告など頂けると嬉しいです。