設計メモ: 判断の理由と実装方針
spec.md が「何を作るか」、このファイルが「なぜそうしたか」と「どう作るか」。
1. PDF と何が違うのか
| BDF | ||
|---|---|---|
| 描画モデル | PostScript 由来。独自のグラフィックス状態、フォント機構、色空間 | Canvas 2D そのもの。ブラウザが持っていない概念は入れない |
| フォント | 埋め込み形式が多数(Type1/CFF/TrueType/Type3)。ラスタライザが必要 | WOFF2 を FontFace に渡すだけ。テキスト描画はブラウザ任せ |
| 画像 | 独自フィルタ(DCT/Flate/CCITT/JBIG2…) | ブラウザがデコードできる形式をそのまま格納 |
| 圧縮 | Flate 等を自前実装 | DecompressionStream |
| 構造 | xref テーブル、オブジェクトグラフ | 内容アドレスの Part 群 + JSON manifest |
| 共有 | Form XObject | Object(内容アドレスで自動 dedup) |
| 無限平面 | なし | sheet View + Tile |
| 部分取得 | Linearized PDF(複雑、対応まちまち) | Part 単位の Range / 個別ファイル |
pdf.js が数万行かけてやっていること(フォントラスタライズ、フィルタ、パーサ)をブラウザの標準 API に肩代わりさせるのが狙い。デコーダは「バイト列を読んで ctx を呼ぶループ」だけになる。
2. Go と wasm について
結論: サーバー側エンコーダ・変換 CLI は Go、ブラウザ側ビューアは TypeScript。ビューアで Go/wasm は使わない(少なくとも v1 では)。
理由:
- デコーダは軽い: フォーマットを Canvas API に 1:1 で寄せたので、デコーダは DataView を読むループで数百行に収まる。wasm で速くする対象がない。
- 境界コストが支配的: 描画のホットパスは Canvas API 呼び出し。Go の wasm から
syscall/js経由でctx.fillTextなどを呼ぶのは、TS から直接呼ぶより遅く、アロケーションも増える。命令 1 つごとに JS↔wasm を往復する構造は最悪のパターン。 - バイナリサイズ: Go 標準ツールチェーンの wasm は最小でも 2MB 級。プレビュー用ビューアの初期ロードとして重い。TinyGo で縮むが、GC・リフレクション・goroutine の制約と引き換えになる。
- ブラウザ API への依存:
DecompressionStream、OffscreenCanvas、FontFace、createImageBitmapはすべて JS 側の非同期 API。wasm で包んでも JS の薄い層が必ず残る。
wasm が意味を持つケース:
- クライアントで変換したいとき(例: ユーザーがドロップした xlsx をブラウザ内で BDF に落として描く)。エンコーダ本体を Go で書いておけば、そのまま wasm にして使い回せる。これが「Go で書く」ことの本当の利点で、ビューア側の高速化ではない。
- Path2D 生成やテキスト抽出のような、純粋なデータ処理で JS より速くしたい部分が見つかった場合。プロファイルしてから判断する。
したがって:
- Go:
bdfパッケージ(Writer / Object builder / 各 Part のエンコード)と変換 CLI。エンコーダはio.Writerに対して決定的に出力する。 - TypeScript:
@bdfkit/core(デコード・型定義)、@bdfkit/render(Canvas バックエンド、Worker)、@bdfkit/viewer(UI)。 - 両者の契約はワイヤフォーマットとフィクスチャ。Go でエンコードしたテストファイルを TS がデコードし、ブラウザで描画・検索・テキスト選択・文書構造を確かめる。ビューアの描画は TS だけが持つ。Go の
imagebdfはサーバーでサムネイルやページの画像を作るための別の描き手で(§3.25)、各描画命令の画素・寸法・不正入力を直接検証する。
ブラウザ内変換(cmd/bdfwasm)
変換器は GOOS=js GOARCH=wasm でそのままビルドでき、testdata の PDF・Word・PowerPoint・Excel・CSV・Visio はネイティブと同じバイト列に変換される。cmd/bdfwasm はページから渡されたバイト列を変換し、単一ファイル形式の BDF を返す wasm モジュールである(API はパッケージのコメントを参照)。デモサイト(site/build.mjs、GitHub Pages で公開)はこれを Worker で動かし、結果を {kind: "buffer"} としてレンダラの Worker に渡す。
- モジュールを分ける: 全形式を 1 つにすると約 26 MB(gzip で約 8.8 MB)になり、その半分以上は pdfcpu とその依存である。
-tags pdfonly/officeonlyで PDF 用(約 20 MB、gzip 6.9 MB)と Office 系用(Word・PowerPoint・Excel・CSV・Parquet・Visio・draw.io・DXF・Jw_cad・SXF・CGM・HP-GL/2・Gerber・KiCad・楽譜・フォント・メタファイル・Photoshop・画像。約 26.6 MB、gzip 7.8 MB。Parquet で約 1.1 MB、KiCad で約 1 MB(gzip 0.3 MB)増えた。Parquet の Brotli の伸張は外す(§3.26))に分け、ページはファイルの先頭 1 KiB に%PDF-があるかどうかでどちらかを読み込む。HTML、Markdown、EPUB(§3.16、§3.24)はwebonlyの 3 つ目のモジュール(約 23.5 MB、gzip 6.0 MB。goldmark、go-readability、組版エンジン、数式エンジン)にし、ファイルの拡張子(.html、.mhtml、.md、.epub など)で読み込むので、Office 系のモジュールは大きくならない(EPUB は先頭の mimetype でも見分ける)。ページは変換にファイル名も渡し(name)、中身で判別できない入力(Markdown、1 行の CSV、HTML の断片)は拡張子で決まる。画像(§3.19)はそのまま格納するだけなので、imageonlyの小さなモジュール(約 7 MB、gzip 1.9 MB)にし、ページは画像の署名(draw.io の PNG・SVG 書き出しは除く)を見てこれを読み込む。音声ファイル(§3.31)もカバーと音声をそのまま格納してタグを読むだけなので同じモジュールに入れ、ページはタグと署名(ID3、fLaC、OggS、RIFF/WAVE、FORM/AIFF、M4A のブランド)か拡張子で見分ける。サムネイルと検索用テキスト(§3.25)は、変換器を持たないpreviewonlyのモジュールにした。ブラウザからはファイルの隣の画像は読めず、ネットワークの画像は CORS を許すサーバーのものだけが取得できる(取れない画像は代替テキストになる)。bdf_noconvで WebP と WOFF2 のエンコーダも外す。変換したその場で描く文書は小さくしても得がないので、Part の圧縮も最速にしている。 - フォントは fs.FS で渡す: ブラウザにはフォントのディレクトリが無い。
converter.Options.FontFSで任意のfs.FSをフォントの探索元にできるようにし(FontDirsより先に探す)、wasm 側では Web 上のディレクトリをそれとして実装した。index.jsonにファイル名、サイズと、フォントの走査と文字カバレッジ判定が読む範囲(テーブルディレクトリと name・OS/2・post・cmap テーブル)を書いておき、cmap が 16 MiB 以下なら最初の変換でその範囲も並列に Range 取得する。フォールバック探索は候補フェイスの cmap で文字の有無を確かめてから全体を読むため、文書が使う文字を持つフェイスだけが全体取得の対象になる。16 MiB を超える cmap は範囲を先読みせず、既存の全体読み込みで判定する。取得した範囲と全体データはモジュールが生きている間保持する(2 回目以降の変換は通信しない)。サイトのフォントは CI が Ubuntu のパッケージから集める: Liberation(Arial、Times New Roman、Courier New の代替)、Carlito(Calibri)、Caladea(Cambria)、IPAex(日本語)、DejaVu(記号)。 - pdfcpu の設定: 変換器は
model.NewStatelessConfiguration()で組み込みの既定値から独立した設定を作る。ユーザーの設定ファイル、pdfcpu 用のフォントや証明書ストアは読まない。
フォールバック探索では、指定フォントに無い文字について各候補の cmap を調べてからフォント全体を取得する。どのフォントにも無い文字(絵文字など)を探す場合でも cmap の先読みデータだけで探索が終わり、候補フォント全体の連続取得を避けられる。
ページ単位の変換(converter.OpenStream)
文書を丸ごと変換してから渡すと、最初のページが見えるまで全ページの変換を待つことになる。Chrome で作った 237 ページの PDF(テキスト主体、1.8 MB)は wasm で丸ごと約 3.5 秒かかる。converter.Stream はページ単位の変換で、ビューアはページの大きさだけの outline をすぐ開き、変換できたページから描く。同じ PDF で outline まで 82 ms、どのページでも最初の 1 ページは約 110 ms、全ページで 1.4 秒、そのあと完成した文書を作るのに 1.4 秒かかる(Node の wasm で計測)。
- outline とページ文書:
Outline()はメタデータと、全ページの大きさだけを持ちレイヤーが空の View である。Page(i)はそのページを変換し、View もページも 1 つだけの単一ファイル形式(ページ文書)を返す。ページ文書にはそれまでのページ文書が返していない Part だけが入り、オブジェクトは前に返した Part(共有 Form XObject、画像、フォント)を参照してよい。読み手は Part を足し合わせ、ページを outline の同じ位置に置く(BdfDocument.addPage、レンダラの Worker ではaddPage要求)。単体では完結しないので、ページ文書は変換器とビューアの間のやりとりであり、保存する文書ではない。 - フォントの版: 完成した文書ではフォントは全ページで使うグリフの subset 1 つだが、ページ単位では後のページがグリフを増やす。ページ文書ごとに、そのオブジェクトを作ったページが使ったコードを集め、それを全部持つ版を返したことがあればそれを使い、なければ作り直す。新しい版には、直前の版が 512 コード未満ならそのコードも入れる。欧文のようにグリフの種類が少ない文書では版がすぐ飽和して後のページは同じ版を共有し(上の PDF で 237 ページに 30 版)、和文のように増え続ける文書ではページごとの小さな版になる。版は元のフォントを壊さずに作る(
PruneGlyphsは複製にかける)。埋め込みはできても subset を禁止するフォント(fsType)は版ごとにフォント全体になるので、そういう文書ではメモリを多く使う。 - 完成した文書:
Finish()は残りのページを変換し、共通プレフィックスの共有、フォントの確定、テキスト索引を作ってConvertと同じ文書にする。Convert自体がNewStreamとFinishなので、ページを先頭から順に変換すればConvertと同じバイト列になる。順序を変えると PUA の割り当てと Part の並びが変わるが、描かれるものと抽出されるテキストは変わらない(converter/pdfとtest/site.mjsのテストで確かめている)。 - ビューア: デモのビューアは outline を開いたら、表示中のページ(IntersectionObserver で追う)、その下、その上と外側へ向かって 1 ページずつ変換を頼み、次のページの変換とレンダラへの追加を並行させる。届いていないページは「converting…」を出して
aria-busyにしておき、ビットマップとテキスト層はページが届いてから作る。途中の検索は変換済みのページが対象で、ページが増えていれば次の検索で引き直す。全ページが揃ったらFinish()の文書をダウンロード用にし、レンダラの文書もそれに差し替える(replace。実行中の要求は古い文書のまま終わり、古い文書のフォントと画像は要求がなくなってから捨てる)。別のファイルを開くと、変換中のストリームは閉じる。 - 対象: 今は PDF だけ(
Format.Streamを持つ形式)。それ以外はOpenStreamが丸ごと変換し、Pages()が 0 のストリームとして返す。PowerPoint と Visio もページが独立しているので同じ形にできるが、フォントの subset を文書全体で決めている点は PDF と同じ扱いが要る。多ページの TIFF はページが画像 1 枚ずつでフォントも持たないので、いちばん素直に移せる。Word はレイアウトしないとページが決まらないので、先頭から順にしか出せない。
3. 変換パイプライン(現実的な順序)
Office ファイルを直接 BDF にするには Word 相当のレイアウトエンジンが要る。ここが一番重いので、段階を踏む。
- PDF → BDF(最初のターゲット)
- PDF の内容ストリーム演算子(
m l c re f S q Q cm Tf Tj Do …)は BDF の命令にほぼ 1:1 で落ちる。 - LibreOffice headless で Office → PDF にしてから BDF 化すれば、それだけで PowerPoint / Word のプレビューが高忠実度で成立する。
- フォントは PDF 埋め込みフォントを抽出して WOFF2 に再パッケージ(CFF/TrueType ならほぼ機械的。Type3 はアウトライン化)。
- 難点: PDF ではマスター共有が失われている(各ページに同じ命令が展開される)。同一バイト列になる Object は自動 dedup されるが、微妙に違うページでは効かない。ページ内の「先頭の共通プレフィックス」を切り出して共有 Object にするヒューリスティックで補う(§3.1)。
- PDF の内容ストリーム演算子(
- XLSX → BDF(直接変換)
- PDF 経由だと無限シートが失われるので、こちらは直接。セルのレイアウトは行列の格子なので、文書レイアウトほど難しくない。実装済み(§3.6)。
- 図形・画像・グラフは PowerPoint と同じ DrawingML なので、PowerPoint の変換器と描画処理を共有する(画像化はしない)。
- CSV・TSV は値だけの表として同じシートの描画に渡す(§3.10)。Parquet も値を文字にして同じように渡す(§3.26)。
- PPTX / DOCX → BDF(直接変換)
- PPTX は絶対配置なので DOCX より先に手が届く(テキストボックス内の折り返しは必要)。実装済み(§3.4)。
- マスター・レイアウト・スライドの継承構造が BDF の共有 Object にそのまま対応するので、直接変換するとサイズ面の効果が最も大きい。
- DOCX は Word 相当のレイアウトエンジンを変換側に持って実装した(§3.9)。紙面のページと、ページを持たずにレイアウトし直した 1 枚の長い面の 2 つの View を作る。
- HTML / Markdown → BDF(リーダー表示)
- ページの CSS は捨て、内容を固定のスタイルシートで DOCX と同じレイアウトエンジンに組ませる。Markdown は HTML にしてから同じ道を通す。実装済み(§3.16)。
3.1 PDF → BDF 変換器(converter/pdf)の構造
converter/pdf パッケージは PDF オブジェクト層に pdfcpu を使い、内容ストリームの解釈は自前で行う。CLI は bdf generate(入力の形式は中身から判別する)。
- 命令の対応:
q/Q→ SAVE/RESTORE、cm→ TRANSFORM、パス演算子 → Path、W n→ CLIP_PATH、Do(Form)→ 共有 Object + USE、Do(Image)→ IMAGE、shと shading パターン → Paint、tiling パターン → セル Object を USE_AT で敷き詰め、ExtGState のca/CA/BM→ ALPHA/BLEND、透明グループ → GROUP、ExtGState の/SMask→ GROUP と MASK(下記)。座標は PDF のユーザー空間をそのまま TRANSFORM で写す(ページ先頭で y 反転と回転を 1 回かける)。 - 状態の遅延出力: 色・線・アルファなどは描画命令の直前に、前回出力した値と違うときだけ書く。SAVE/RESTORE で出力済み状態のスタックも巻き戻す。
- テキスト: 行列(Tm/Td/T*)ごとに SAVE + TRANSFORM のブロックを開き、その中で x オフセットだけで FILL_TEXT を並べる。
advanceには PDF の幅を入れる。Skia のように 1 グリフずつTdで位置決めする PDF は、同じ行で筆記位置が連続していれば同じ run に結合する(カーニング分は run の advance に吸収)。Tz/Tsは変換行列、Tcは letterSpacing、Twはスペースを独立 run にして advance 補正で広げる。不可視テキスト(Tr 3)は透明色で描いて検索可能にする。/ActualTextは run の文字列に使う。 - 縦書き(WMode 1:
Identity-V、-Vの定義済み CMap、/WMode 1の埋め込み CMap): グリフは送りW2/DW2(既定[880 −1000])の w1 でテキスト空間の下へ進み、位置ベクトル v の分だけ横書きの原点からずらして置く。TJの数値は下への移動になる。行(次に run を切るまでの縦の並び)を 1 つの子 Object に描き、親では行の起点に 90° 回した座標系でALT_TEXT(行の文字列)+USE_ATを置く(PowerPoint・Word の縦書きと同じ形。spec §7.8)ので、抽出・検索・選択では 1 行 1 run になる。子 Object の中では 1 字ずつ回転を戻して正立させる。埋め込みフォントは縦書き用のグリフ(CID)をそのまま描く。システムフォントで描くときは、CID が縦書き用の字形なら Unicode の縦書き用字形(︑ ︵ ﹁ など)を描き、それがない長音・波ダッシュなどは行と一緒に回したまま描く。1 字ずつTdで置く PDF も、同じ列で筆記位置が続いていれば同じ行にまとめる。 - フォント: 埋め込み TrueType/CFF/OpenType(
FontFile2、FontFile3のType1C/CIDFontType0C/OpenType)と Type1(FontFile、CFF に変換してから同じ扱い)は、使われたコードごとに (Unicode, GID) を集め、Unicode → GID の cmap を合成して TTF/OTF に組み直す(name、OS/2、post も生成し、ブラウザのサニタイザを通す)。同じ Unicode に別のグリフが割り当たる場合や合字(ToUnicode が複数文字)は私用領域 U+E000〜 に逃がし、ALT_TEXTで本来の文字列を持つ。組み直したフォントは WOFF2 にして格納する(§3.3)。サブセット化(-no-subsetで無効化)の方法は形式で違う。- TrueType は使ったグリフ(合成グリフの構成要素を含む)以外のアウトラインを空にし、
hmtxの値も 0 にする。GID は付け替えない。locaの長さはグリフ数のままだが、WOFF2 はlocaを送らずに復元させ、空グリフと 0 の並びは Brotli でほぼ消える。フルフォント埋め込みの PDF で 611KB → 56KB 程度(WOFF2 前)、IPA ゴシック(6.2MB、12,728 グリフ)から 20 字なら WOFF2 で 3.9KB。 - CFF(素の CFF と OpenType の
CFF表、名前キー・CID キーとも)は使ったグリフ(seac のアクセント部品を含む)だけを残して GID を詰め直し、cmap とhmtxもそれに合わせる。CFF の CharStrings INDEX はグリフごとにオフセットを持ち、WOFF2 にもlocaのような変換がないので、空にするだけでは CJK フォントでオフセット表が残る(57,000 グリフで 170KB)。サブルーチンは番号が変わると呼び出し側の書き換えが要るので番号は保ち、残したグリフが呼ばないものをreturnだけにする。どれが呼ばれるかは Type 2 charstring をスタックとステム数(hintmask の長さが決まる)だけ追う小さな解釈器で調べ、算術演算子などで追えないときは全サブルーチンを残す。charset はグリフ名/CID を保ち、FDSelect は作り直し、独自 Encoding は旧 GID を指すので StandardEncoding に置き換える(ブラウザは cmap しか見ない)。Unifont JP(CID キー、4.8MB)から 22 字なら CFF は 1KB 弱になる。CFF の読み取りとサブセット化はinternal/cffにあり、Office 系の変換器のフォント埋め込み(fontdb、§3.4)も同じものを使う。 - Type1 は名前キーの CFF に変換する。eexec と charstring の暗号を解き(PFB の区切りと hex の eexec も読む)、charstring は命令を 1 つずつ置き換えるのではなく解釈して絶対座標の輪郭を作り、Type 2 の rmoveto/rlineto/rrcurveto/endchar で書き直す。サブルーチンは展開し、flex(OtherSubrs 0–2)は 2 本の曲線に、seac はアクセントを
sbx + adx − asbだけずらした基底文字との合成輪郭にする(ラスタライザの seac 対応に頼らない)。ヒントとヒント置換(OtherSubrs 3)は捨てる(pdf.js も同じ。画面ではアンチエイリアスで描くので差は小さい)。組み込みエンコーディングは CFF には書かず、PDF 側のグリフ引きと Unicode の推定(ToUnicode がないとき)に使う。マルチプルマスター(OtherSubrs 14–18)や解釈できないグリフは空にして警告する。Bitstream Charter/Courier の 8 書体 1,832 グリフで、fontTools の Type1 解釈と輪郭の点が一致することを確かめた(seac 合成 448 グリフを含む)。 - 縦書きメトリクス(
vhea/vmtx)と GSUB/GPOS は落とす(Canvas 2D は横書きだけで、グリフは合成 cmap で直接指す)。 - 定義済み CMap:
Identity-H/-Vに加えて、Adobe-Japan1・Japan2・GB1・CNS1・Korea1 の符号化 CMap 153 個(90ms-RKSJ-H、EUC-H、UniJIS-UCS2-H、UniGB-UTF16-V、KSCms-UHC-Hなど)をconverter/internal/cjkcmapに持つ。Adobe の cmap-resources(BSD 3 条項)からtools/gen-cmaps.pyで作り、gzip で 197KB。UCS-2・UTF-16・UTF-8 の Unicode CMap は UTF-32 のものと同じ符号位置 → CID の対応なので、デコード方法と差分だけを持つ(UCS-2 の違いは数十件)。似た CMap(GBKp-EUC-HとGBK-EUC-Hなど)も差分で持つ。埋め込み CMap のusecmapと/UseCMapもこれを親にできる。 - ToUnicode がない(または対応しないコードの)CID フォントの文字は、Unicode CMap ならコードそのもの、そうでなければ CIDSystemInfo(なければ CMap)の文字集合の CID → Unicode で決める。この表は各文字集合の UTF-32 CMap(横書き、JIS2004、JIS X 0213、半角の
UniJIS-UCS2-HW、そして縦書き)を読み込み時に逆引きして作り、1 つの CID に複数の文字があれば ASCII、通常の文字、縦書き用字形、互換漢字・部首、私用領域の順に選ぶ(縦書きの CID は、のように横書きの文字になり、縦書き用字形︑は描画用に別に持つ)。埋め込みでない CJK フォント(リュウミン、中ゴシック BBB、STSong など)はシステムの serif / sans-serif で描く。 - CFF2 は変換せずシステムフォントに落とす。非埋め込みフォントは名前とフラグから serif/sans-serif/monospace と太さ・斜体を決める。Type3 はグリフ手続きを Object にして USE する。
- TrueType は使ったグリフ(合成グリフの構成要素を含む)以外のアウトラインを空にし、
- フォントのライセンス: フォントが機械可読な形で持つ許諾は OS/2 の
fsType(埋め込み許諾)だけで、TrueType/OpenType にはあるが素の CFF と Type1 にはない(Top DICT の Notice/Copyright は文字列)。PDF 側にもフォントの許諾を書く場所はない。fsTypeは次のように扱う。- bit 0–3 は最も緩いものが効く。Restricted License(0x0002 だけ)と Bitmap embedding only(0x0200)はシステムフォントに落として警告する。No subsetting(0x0100)はサブセット化せず全グリフを埋め込む(警告)。Installable(0)、Preview & Print(0x0004)、Editable(0x0008)はそのまま埋め込む。BDF は閲覧専用の表示リストで、PDF と同じく「文書に埋め込まれたフォントでその文書を表示・印刷する」使い方なので、Preview & Print の範囲に収まる。
- 組み直したフォントの OS/2 には元の
fsTypeを書く(元になければ Installable ではなく Preview & Print)。name 表は作り直すが、著作権・商標・製造者・デザイナー・ライセンス説明/URL(nameID 0, 7, 8, 9, 11–14)は元のフォントから引き継ぎ、素の CFF と Type1 は Copyright/Notice を nameID 0/7 に入れる。ビューアはbdf-<hash>という名前で読み込み、cmap は文書が使う文字だけなので、PDF の埋め込みサブセットと同様に他へ流用しにくい。 - PDF の作成ソフトが
fsTypeを守らずに埋め込んでいることもある(テスト用 PDF を作った WeasyPrint は Restricted のフォントも埋め込む)。権利を確認済みなら-ignore-fstype(Options.IgnoreFSType)で埋め込める。 - 許諾の本体は使用許諾契約(EULA)にあるが、これは機械的に判定できないので、変換器が見るのは
fsTypeだけとする。BDF は元文書(PDF、Office ファイル)をブラウザで表示するための中間形式という位置づけで、フォントは文書と一緒に配られ、その文書の描画にだけ使われる(pdf.js が PDF のフォントを OpenType に組み直して FontFace で読むのと同じ)。
- 画像: DCT はそのまま JPEG、それ以外はデコードして PNG(SMask/ステンシルマスクはアルファに合成、ImageMask は塗り色で PNG 化)。格納前に
imgconv(§3.2)を通す。ブラウザが JPEG 2000 と JBIG2 を描けないので、この 2 つは変換器でデコードする。どちらも純 Go の自前実装で、ブラウザ内変換の wasm(TinyGo)でも動き、壊れた入力でパニックしないよう長さと大きさを検査する。- JPXDecode(
converter/internal/jpx、ITU-T T.800 Part 1): 素のコードストリームと JP2/JPX のボックス(colr、pclr + cmap、cdef)。タイルとタイルパート、画像・タイルのオフセット、成分のサブサンプリング、5 つのプログレッション順と POC、プリシンクト、PPM/PPT、SOP/EPH、コードブロックのスタイルすべて、3 種の量子化、ROI(maxshift)、5/3 と 9/7 のウェーブレット、RCT/ICT。途中で切れたコードストリームは読めた分だけデコードする。色空間は画像辞書の/ColorSpaceを優先し、なければ JP2 の colr(sRGB・グレー・sYCC・CMYK)か成分数で決める。/SMaskInDataなら不透明度の成分をソフトマスクにする(2と cdef の乗算済みは戻す)。OpenJPEG のopj_compressで作った 169 通りの符号化(全プログレッション順、コードブロックの大きさとスタイル、プリシンクト、タイルとオフセット、ROI、多層、12/16 ビット、サブサンプリング)でopj_decompressと比べ、可逆は完全一致、9/7 は ±1 以内。9/7 の定数は規格どおり(OpenJPEG は 2/K の近似値を使うので 12/16 ビットの非可逆で最大 3 違う)。arm64 の積和の融合で結果が変わらないよう、9/7 と色変換では丸めを明示する。2000×1500 の非可逆で 0.12 秒程度。 - JBIG2Decode(
converter/internal/jbig2、ITU-T T.88): PDF に埋め込まれた形(ファイルヘッダなしのシーケンシャル)と/JBIG2Globals。ジェネリック領域(算術符号のテンプレート 0〜3、AT 画素、TPGDON、MMR、長さ不明の即時領域)、リファインメント領域、シンボル辞書(算術・Huffman、リファインメント/集約、高さクラスの一括ビットマップ、カスタム Huffman 表)、テキスト領域(全参照角、転置、合成演算子、ストリップ)、パターン辞書とハーフトーン領域、ストライプで高さ不明のページ。MMR は読んだバイト数が要るので T.6 のデコーダも自前で持つ。フィルタの出力は 1 が白(JBIG2 の 1 は黒)なので反転して 1 ビットの画像として続きを通す(ImageMask にも使える)。テスト用の符号化器を書いて 24 通りのストリームを作り、Ghostscript(jbig2dec)と画素単位で一致することを確かめた(jbig2dec の不具合で読めない 4 通りは自前の参照描画と比べる)。A4 300dpi のテキストページで 5 ms 程度。
- JPXDecode(
- 注釈: リンクは LINK 命令、外観ストリームは Form として描く。LINK の宛先は URI、
/GoTo、明示・名前付きの宛先(/Names /Destsと旧式の/Dests)で、ページは変換したページの中の番号(#page=N)に直す。注釈は既定のユーザー空間にあるが、Chrome の内容はどのqにも入らないcmを残すので、注釈の前にそれを打ち消す変換を置く。 - オプショナルコンテンツ(レイヤー):
/OCPropertiesの既定の設定(/DのBaseState・ON・OFF)で各グループの表示・非表示を決め、非表示のグループに属するものは描かない。BDF の文書には表示を切り替える仕組みがないので、ビューアが文書を開いたときに見える状態に固定する。対象はBDC /OCの marked content(OCG と、/Pの方針や/VEの論理式を持つ OCMD)と、/OCを持つ XObject と注釈。非表示の中身でもグラフィックス状態とクリップは効き、テキストは描かずに送りだけ進める(PDF 32000-1 §8.11.3.2)。Illustrator は .ai の PDF 部分でレイヤーをこの形で書き、非表示のレイヤーも含める(§3.17)。/Intent、/Usageと/ASによる自動の切り替え、印刷用の状態は見ない。 - ページの枠: ページは既定でクロップボックス(ビューアが表示する範囲)。
-param box=media|bleed|trim|art(Options.Box)でほかの枠にできる。ページがその枠を持たなければクロップボックスを使う。 - タグ付き PDF の構造(読み上げ用、spec §7.8):
/StructTreeRootの ParentTree(なければ/Kと/Pg)で MCID から構造要素を引き、RoleMap で標準の型に直す。H1〜H6 は HEADING、P 類は PARAGRAPH、L / LI は LIST / LIST_ITEM、Table は TABLE、TH / TD は構造順に並べた格子から求めたセル参照(RowSpan / ColSpan、Scope)の CELL、Figure は/Altの FIGURE、/Langは LANG、カタログの/Langは(文書情報に言語が無ければ)meta.dc.languageにする。MARK は BDC では出さず、テキストの run の直前(と図に属する描画の直前)に、出力済みの構造との差分として出す。背景や罫線だけの MCID、Artifact、MCID の無い内容は構造を変えない。描画の順は変えないので、内容の順と構造の順が違う文書では構造が分かれることがある。タグの無い PDF では従来どおり marked content のタグ名から段落の区切りだけを推定する。 - ページをまたぐ共通プレフィックスの共有: PDF はマスター(ヘッダ・ロゴ・フッタ)を各ページの内容ストリームに展開してしまうので、変換後の各ページ Object の先頭から一致するバイト列を切り出して共有 Object にする(
bdf.SharePrefixes)。切れる位置は「深さ 0 の命令境界」に限る(SAVE/RESTORE と GROUP が釣り合っていて、それより前の深さ 0 に CLIP/SHADOW/FILTER がなく、直前が MARK でない)。共有部分は USE で呼ぶが USE は暗黙の save/restore を持つので、残り部分の先頭で切断時点の状態(正味の変換行列、最後に出力した塗り・線・アルファ・フォントなど)を書き直してから続きを出す。候補の鍵は命令列と参照リソース(Path、Paint、フォント、画像、子 Object)の内容の累積ハッシュで、同じ鍵を持つページの組を「(ページ数 − 1) × 切り出すバイト数」の大きい順に貪欲に採用する(3 ページで共有できる短い接頭辞を、2 ページだけで共有できる長い接頭辞より優先する)。512 バイト未満の接頭辞は Part のオーバーヘッドの方が大きいので共有しない。-no-shareで無効化。 - ソフトマスク(ExtGState の
/SMask): マスクが設定されてから最初の描画命令の前に GROUP_BEGIN を開き、マスクが変わるか、設定した深さのQで閉じる。閉じる直前に MASK_BEGIN 〜 MASK_END(spec §7.6)でマスクのグループ XObject を、gsのときの CTM に戻して描く。/S /Alphaはアルファ、/S /Luminosityは/BCの色を背景にした輝度、/TRは 256 段の表にする(Chrome は{1 exch sub}で反転したマスクを使う)。グループの範囲はマスクの BBox とクリップの共通部分(マスクの外の値が 0 でないときはクリップだけ)。PDF では描画命令ごとにマスクが掛かるが、グループは同じ深さの描画をまとめて 1 回掛ける。重なった半透明の描画が同じマスクの下にあると違いが出るが、q /GS gs … Do Qの形(Chrome、Illustrator、Office など)では同じになる。より深いqの中でマスクが変わると外側のマスクも掛かったままになる。 - 関数: Type 0(サンプル)、2(指数)、3(つなぎ合わせ)、4(PostScript 計算機)を評価する。Type 4 は
if/ifelseを条件ジャンプにした命令列にコンパイルし、スタックで実行する(特色の tint 変換や Chrome のソフトマスクの/TR、繰り返しグラデーションに使われる)。入力 1 つの関数は画像の画素ごとに呼ばれるので結果をキャッシュする。 - 大きな画像: サンプルの画像(無圧縮、Flate、LZW、RunLength、CCITT、JBIG2)は、サンプルを 1 GiB(行 × 1 行のバイト数)、1 辺 65,535 画素まで読み、それより大きいものは警告して除く。
imgconv.MaxDecodePixels(約 1 億画素)より画素の多い画像は断らずに、サンプルを画素にしながら縮小する。⌈w/k⌉ × ⌈h/k⌉ が上限に収まる最小の整数 k で、結果の 1 画素は元の k × k 画素の平均(色は sRGB に直してから、不透明度で重みを付けて平均する。インデックスは平均しない)。1 ビットのスキャンはグレーになり、ステンシルマスクは塗りの色に、その区画を塗る割合の不透明度を掛けたものになる。元の大きさの画素は持たない。画像は同じ単位正方形に描くので、命令と変換は変わらない。マスクは自分の係数で縮小する。文書ごとに 1 度、縮小したことを警告する。A0 を 600 dpi でスキャンした 19866 × 28087 画素の図面は 6622 × 9363 画素で格納する(1 ビットの Flate で 5.7 秒、最大 730 MB)。マスクのない JPEG はそのまま格納する。マスクのある JPEG、JPEG 2000、JBIG2 は、それぞれのデコーダの上限のまま(JBIG2 は 2^28 画素。JPEG 2000 は、コードストリーム全体でタイルとコンポーネントの解像度レベルを 2^22 個まで配置する)。上限内の画像は今までと同じバイト列になる。マスクのマスクは読まない(自分自身を/SMaskに指す画像で終わらなくなっていた)。マスクは、JPEG のものと非可逆で格納されうるものを除いて、PNG にしてから読み戻すのではなく、デコードした画素から取る(chrome-masks.pdf の変換が 1.10 秒から 0.62 秒になった)。 - 壊れたファイルへの備え: 値は使うところで確かめ、上限を超えたものは警告して除き、残りを変換する。pdfcpu やデコーダのパニックは、変換のエラー(「the file is damaged」)にする(
readContext、NewStream、Stream.Page、Stream.Finish)。デコードしたストリームは 1 つ 256 MiB まで(フィルタの間でも、ページの内容ストリームの合計でも)。予測器は Columns が 1〜2^24、Colors が 1〜32、BitsPerComponent が 1・2・4・8・16 のときだけ使い、データの 1032 倍と 64 バイトより長い行は断る。RunLength は自前のループで読む。ページツリーは変換器が 1 度だけたどる(pdfcpu のPageDictはページごとに根からたどり、根の/Countを信じ、自分の子の中に自分がいるノードで終わらない。2 万ページで 199 秒かかっていたのが 0.09 秒になった)。2 度目に出会ったノードと、64 段より深いノードは除く。同じページを何度も並べたツリーは、2 度目以降のページを文書全体で 16 枚まで変換し、残りは警告して除く(ページへの参照は 6 バイトで、1 つごとにページ 1 枚分の変換が増える)。ページの内容も変換器が読み、/Countは使わない。字句解析の配列と辞書の入れ子は 32 段、qは 1,024 段(それより深いqと対のQは無視する)、関数は 8 段で部品 4,096 個、色空間は 8 段、オプショナルコンテンツの/VEは式 4,096 個、別の構造の下で変換し直すフォームは文書全体で 65,536 回まで。名前付きの宛先の木は 1 度だけ読み、フォームの参照とコード → CID の対応は表で引く(リンク 2 万個で 23 秒かかっていたのが 0.1 秒になった)。XMP(画像と Photoshop の変換器も使う)は要素の入れ子 512 段まで。 - PDF パーサーの上限: pdfcpu v0.16.1 の資源制限を使う。符号化されたストリームとフィルタが展開するデータは各 256 MiB、オブジェクト番号と xref の件数は各 2^20、配列・辞書などの再帰は既定の 100 段まで。巨大な
/Length・/Size・/Indexと深い入れ子を読み手の内部で制限する。これらは資源ごとの上限なので、信頼しない入力を変換するサービスではプロセス全体のメモリと実行時間も制限する。 - 未対応(警告を出して無視): メッシュ系シェーディング(平均色で代用)、関数型シェーディング(type 1、中央の色で代用)。
テスト用 PDF は Chromium(Skia)と reportlab で生成し、CJK の定義済み CMap と JPEG 2000・JBIG2 の画像のものは標準ライブラリだけの Python で書く(npm run test:pdf:gen。画像は opj_compress を使う)、変換結果は testdata/pdf/ に置く。
3.2 画像の格納と変換(imgconv)
画像の扱いは「そのまま」と「変換あり」の 2 モードで、pdf.Options.Images / pptx.Options.Images(CLI は -images keep|convert)で選ぶ。今後の Office 変換器も同じパッケージを使う。
- Keep: 元ファイルにある JPEG/PNG などをそのまま Part にする。デコードした画素は PNG にする。ブラウザ内で変換を走らせる場合はこちらを使う。
- Convert: サーバー側の事前変換用。時間は掛けてよい前提で、次の順に決める。
- WebP / AVIF / SVG はそのまま。
- JPEG は WebP 非可逆(既定 q80, method 6)を試す。
- PNG / GIF / BMP と、PDF からデコードした画素は WebP 可逆を試す。色数が多く写真らしい画像(標本で 4096 色超)は非可逆も試す。
- 元より小さくなった候補のうち最小のものを採用し、小さくならなければ元のまま。
デコードした画素の格納は、任意の画像型を受け取る EncodePixels でも行える。Keep では、可逆の元の画素は色数に合わせた最小の PNG(二色なら 1 ビットのパレット、グレー、256 色までのパレット)に、JPEG など非可逆の元の画素は JPEG(Quality)にする。Convert では、それより小さければ WebP にする。二値のスキャンの 1 ページ(A4 300dpi)は、RGB の PNG では 131KB、1 ビットの PNG では 67KB(元の G4 は 58KB)、WebP 可逆では 53KB になる。
デコードする画像の大きさ: 画像のヘッダーは大きさを自分で言い、デコーダは画素を読む前にその分のメモリを確保する。100 バイトの PNG が 20000 × 20000 画素だと言えば、それだけで 380 MiB になる。imgconv.Decode はヘッダーを先に読み、MaxDecodePixels(1 億 485 万 7600 画素、RGBA で 400 MiB)を超える画像はデコードしない(ErrTooLarge)。Optimize はそういう画像を変換せずにそのまま格納し、文書の中の画像をデコードする変換器(メタファイルの中の PNG・JPEG、Visio のビットマップ、マスクの付いた PDF の JPEG、色を変える Office の画像)とラスタライザも同じ上限に従う。デコーダのパニックはエラーにする。
JPEG のスキャンの数: JPEG のデコーダは、スキャン 1 つごとに画像の全ブロックを 1 周する。スキャンがどれだけ小さくてもそうなので、スキャンだけを並べたファイルは、大きさのわりに桁違いの時間がかかる。8192 × 8192 画素でスキャンが 500 個のファイルは 65 KB しかないのに、デコードに 12 秒かかった(Go の image/jpeg、Apple M 系)。1 MB なら数分になる。imgconv.Decode は JPEG を 最初の MaxJPEGScans(100)個のスキャンまで 読む(LimitJPEGScans がその後ろを切って画像の終わりを付ける)。断るのではなく、そこまでのスキャンが描く絵にする。プログレッシブ JPEG を読み込み途中で表示するブラウザと同じ見え方で、エンコーダが書くスキャンは 10 個前後(プログレッシブでない JPEG は多くて 4 個)なので、普通の画像は何も変わらない。スキャンの位置は image/jpeg がセグメントの間でマーカーを探すのと同じ規則で数える(セグメントの中、たとえば EXIF のサムネイルのスキャンは数えない)。ラスタライザは切ったときに警告を出す。
解像度の上限: ページ上の大きさが分かっているラスター入力(TIFF のページ、Photoshop の文書のページ)は、Options.MaxDPI(既定 192dpi)と MaxPixels(既定 3840 × 3840 画素)の両方を超えないよう縮小する(FitSize が大きさを決め、Resize が縮小する)。Keep と Convert のどちらでも効き、負の値で上限をなくす。考え方と実測は §3.15。
コーデックは libwebp(エンコーダのみ)を wasi-sdk で wasm にし、shibukawa/wasm2go-fork(pgmem ブランチ)で純 Go に変換したもので、cgo も wasm ランタイムも使わない。生成物は image/imgconv/internal/webpw(スカラー、約 5MB、44 ファイル)と image/imgconv/internal/webpwsimd(SIMD、GOEXPERIMENT=simd 専用、後述)で、tools/gen-codecs.sh で再生成する。フォークの -symbol-names(関数名を wasm の name セクションから付ける)、-group-files(vp8_enc.go のように主題ごとのファイルに分ける)、-addr-consts(静的データのアドレスを名前付き定数にする)を使い、libwebp を更新しても差分が小さく収まるようにしている。gen2brain 同梱の wasm は name セクションが落とされているので、自前でビルドしている。
ビルドタグ
| タグ | 効果 |
|---|---|
| (なし) | WebP 可逆・非可逆を同梱 |
bdf_noconv |
コーデックを一切リンクしない(WOFF2 の Brotli エンコーダも含む。§3.3)。Convert を指定しても Keep として動き、ErrNotAvailable を返す。tinygo でブラウザ向けに wasm 化するときはこれを使う |
測定(1024×768、純 Go、4 コアのコンテナ)
| 画像 | PNG | JPEG q80 | WebP 可逆 | WebP q80 m6 | AVIF q60 s10 | AVIF q60 s6 | AVIF 可逆 |
|---|---|---|---|---|---|---|---|
| 図版(少色数) | 6.7 KB | 64 KB | 0.3 KB / 87 ms | 14 KB / 150 ms | 28 KB / 155 ms | 3.2 KB / 1.0 s | 63 KB / 0.2 s |
| 写真風 | 1463 KB | 90 KB | 1405 KB / 0.5 s | 43 KB / 0.35 s | 41 KB / 0.16 s | 39 KB / 1.35 s | 1422 KB / 0.4 s |
フォークで変換したコードは、gen2brain 同梱の Go 化コードより WebP で 1.6〜2 倍、wazero 実行の AVIF より 3〜4 倍速く、出力はバイト単位で同一だった。
3.3 フォントの格納(woff2)
woff2 パッケージが組み直した TrueType/OpenType を WOFF2 にする。仕様書の既定どおり glyf/loca には WOFF2 の glyf 変換をかけ(輪郭の点・フラグ・命令・合成グリフを別々のストリームに分け、点は差分を 1〜4 バイトの三つ組で書き、bbox は点から計算できるものは省き、loca は送らない)、それ以外の表はそのまま並べて全体を Brotli で圧縮する。Brotli は純 Go の andybalholm/brotli。変換で表せないグリフ(3 次曲線の glyf、壊れたグリフ)があるフォントは glyf/loca を無変換で入れる。loca は長形式で復元させ(送らないので大きさは変わらない)、head.indexToLocFormat も 1 にそろえる。
- 品質 11 は 9 より 20〜30 倍遅いのに数 % しか縮まないので、表の合計が 1MiB を超えるとき(サブセット化しないフォント)は 9 にする。6MB の CJK フォントで 23 秒 → 2 秒。
- 出力は fontTools と同じ大きさ(DejaVu Sans 760KB → 259KB、fontTools は 259KB)。テストでは変換した glyf を逆変換して全グリフの点・フラグ・命令・bbox が元と一致することを確かめ、fontTools と Chromium(OTS)で読めることも確認した。
- PDF のフィクスチャでは、フォントが大半を占める文書で .bdf 全体が chrome-doc 104KB → 35KB、chrome-slides 127KB → 47KB になった。
bdf_noconvと-no-woff2(Options.NoWOFF2)では TTF/OTF のまま格納する。コンテナは WOFF/WOFF2 以外のフォント Part を deflate-raw で圧縮するので、その場合も無圧縮にはならない。
同梱の wasm はスカラーでビルドしている(測定の結果)。wasm SIMD を有効にした 2 種類のビルド(clang の自動ベクトル化のみ、および emscripten の SSE 互換ヘッダで libwebp 自身の SSE2/SSE4.1 経路を有効化したもの)を、wasm2go の純 Go モードと asm バックエンド(-fuse-loops、SIMD 命令 10,866 箇所をインライン展開)で変換して比べたが、いずれもスカラーより遅かった。v128 を [2]uint64 の組で運ぶこれらのバックエンドでは、4×4〜16×16 ブロック単位の短い SIMD カーネルの命令 1 つごとに汎用レジスタとの往復が入り、ベクトル化の利得を上回る。
| 1024×768 写真風 q80 m6 | スカラー | 自動ベクトル化 | SSE2/SSE4.1 経路 |
|---|---|---|---|
| 純 Go モード(GOAMD64=v2) | 0.35 s | 0.75 s | 2.4 s |
| asm バックエンド | 0.35 s | 0.39 s | 0.52 s |
この往復をなくすため、フォークに -simd=go127 を追加した。Go 1.27 の GOEXPERIMENT=simd で入る simd/archsimd の 128 bit ベクトル型で v128 を運び、SIMD 命令ごとにインライン展開される base.Simd_g_* メソッド連鎖を生成する(amd64 は AVX/AVX2、arm64 は NEON)。v128 に触れる関数は [2]uint64 版と archsimd 版の 2 本が出力され、goexperiment.simd && go1.27 && !go1.28 && (amd64 || arm64) のビルドタグで切り替わる。archsimd は互換性保証がなく 1.26 と 1.27 で API が変わったため、対象の Go リリースを明示し、生成コード本体は base.V128 と base.Simd_g_* だけを参照する。移植性を重視した simd パッケージも検討したが、ベクトル長が実行時に決まり(AVX2 機で 256 bit)、shuffle や narrow などの命令が無く、simd 型を使う関数が 4 本にクローンされるため、128 bit 固定の v128 には使えなかった。
SSE 経路の libwebp を -simd=go127 で変換し、GOEXPERIMENT=simd でビルドして測った結果(各 5 回、出力はスカラー版とバイト単位で同一):
| スカラー(同梱) | SSE 経路 + archsimd | ネイティブ C(SIMD なし → あり) | |
|---|---|---|---|
| 写真風 q80 m6 | 0.33〜0.35 s | 0.30〜0.31 s | 226 → 185 ms |
| 図版 q80 m6 | 0.15 s | 0.11〜0.12 s | 88 → 62 ms |
| 図版 可逆 | 38〜41 ms | 32〜41 ms | 25 → 23 ms |
| 写真風 可逆 | 0.48〜0.53 s | 0.46〜0.50 s | 305 → 248 ms |
非可逆は 1.1〜1.3 倍速くなり、可逆は同等である。最初の計測では可逆が 1.5 倍遅かったが、原因は libwebp の可逆 SSE 経路ではなく(ネイティブ C では SSE ありの方が可逆も速い)、Go 1.27 側の 2 つの挙動の組み合わせだった。gc はベクトル型のゼロ値をレガシー SSE の MOVUPS X15, Xn で作り、ランタイムの非同期プリエンプションは AVX-512 機でレジスタを VMOVDQU64 Z0..Z31 で復元して VZEROUPPER を実行しないため、最初のプリエンプション以降はレガシー SSE 命令 1 つごとに SSE/AVX 状態遷移(実測で約 300 サイクル)が発生する。可逆の予測器はピクセルごとに i8x16.narrow_i16x8_u を呼び、そのエミュレーション中のゼロ値がこれに当たっていた(GODEBUG=asyncpreemptoff=1 で 30 倍速くなることで確認)。ヘルパーからゼロ値ベクトルをなくして解消したが、gc がスピル/リロードに使う MOVUPS は残る(生成コード中に約 1 万箇所)。この libwebp では影響は小さかったが、AVX-512 機で GOEXPERIMENT=simd を使う場合は //go:debug asyncpreemptoff=1 を検討すること。なお i8x16.narrow_i16x8_u(VPACKUSWB)自体は Go 1.27 の archsimd が AVX の範囲で公開しておらず 7 命令でエミュレートしているので、ここも将来の改善余地である。
同じ wasm から出る [2]uint64 版は 2.4 s のもので、GOEXPERIMENT=simd を付けない通常のビルドが 7 倍遅くなる。そのため 2 つのパッケージを同梱し、ビルドタグで切り替えている。image/imgconv/internal/webpw はスカラーの libwebp を [2]uint64 で運ぶ版(約 5MB)、image/imgconv/internal/webpwsimd は SSE 経路の libwebp を archsimd で運ぶ版で、goexperiment.simd && go1.27 && !go1.28 && (amd64 || arm64) のときだけコンパイルされる([2]uint64 側の関数は落としてあり約 8.6MB、git 上は gzip で 1.3MB)。imgconv の webp_scalar.go / webp_simd.go が同じタグで束ね直し、imgconv.SIMD() がどちらが入ったかを返す。利用者は Go 1.27 で GOEXPERIMENT=simd go build するだけで SIMD 版になる(amd64 は実行時に AVX2 が必要で、無ければ init で panic する)。再生成は tools/gen-codecs.sh(スカラー)と SIMD=1 tools/gen-codecs.sh(SIMD)。AVIF も評価したが採用しなかった。可逆は図版で WebP の 200 倍大きく、非可逆は speed 6 で図版に効くものの 3〜4 倍遅く、生成コードが 47MB になるためである。
3.4 PowerPoint → BDF 変換器(converter/pptx)の構造
converter/pptx は .pptx(OPC の zip)を読み、DrawingML を直接 BDF の命令に落とす。Office から PDF を経由する経路と比べると、マスター・レイアウトの共有とテキストの構造(段落・箇条書き・行の継ぎ目)が残る。XML は構造体にデコードせず汎用の要素木として読む(DrawingML は省略可能な要素と多段の継承が多いため)。mc:AlternateContent は Fallback を使う(Word は Choice を選ぶ、§3.9)。OPC パッケージ(パート、リレーションシップ)と要素木の読み込みは converter/internal/ooxml にあり、Excel・Word・Visio の変換器も同じものを使う。
以下のうち DrawingML の描画(テーマと配色、図形、テキスト、表、グラフ、画像、SmartArt の描画パート、メタファイル)は converter/internal/ooxml/drawingml にあり、Word・Excel の図形やグラフにも使える。文書の種類に依存するもの、つまりプレースホルダーの継承元、マスターのテキストスタイルと既定のテキストスタイル、スライド間のリンク(#page=N)、スライド番号のフィールドは、drawingml.Host インターフェースとして converter/pptx が与える。スライド・レイアウト・マスターの構成と背景(p:bg)の探索も pptx 側にある。描画先の Object は converter/internal/canvas で組み立てる。フォントの参照は文書全体のレイアウトが終わってサブセットが決まるまで仮のもので、最後にまとめて解決する。
- レイヤー構成: 各ページは
background(スライド → レイアウト → マスターの順で最初に見つかった背景)、master(マスターのプレースホルダー以外の図形)、master(レイアウトの同様の図形)、body(スライドの図形)の 4 レイヤー。マスターとレイアウトのレイヤーはスライドの配色マップで描くので、同じマスター・レイアウトを使うスライドどうしでは同一の Object になり、内容アドレスで 1 回だけ格納される。showMasterSp="0"で隠されたものは載せず、描くもののないレイヤーは省く。 - プレースホルダーの継承: スライドのプレースホルダーはレイアウトのものに idx → type の順で、レイアウトのものはマスターのものに type(
ctrTitle→title、subTitle/obj→body)で対応付け、位置(xfrm の off と ext は別々に)、形状、塗り・線・効果、bodyPr(属性単位)、lstStyle(レベル単位)を継承する。マスターとレイアウトのプレースホルダーそのものは描かない。 - テキストの書式: 段落の
pPr→ 図形のlstStyle→(図形スタイルp:styleのfontRef、表スタイルのtcTxStyle)→ 継承元プレースホルダーのlstStyle→ マスターのtxStyles(タイトル/本文/その他)またはプレゼンテーションのdefaultTextStyleの順に、属性ごとに最初に見つかったものを使う。+mj-ltなどのテーマフォントとschemeClrはスライドの配色マップで解決し、色の修飾(lumMod/lumOff、tint/shadeは線形 RGB で、alphaなど)を順に適用する。 - 図形: プリセット図形 187 種は ECMA-376 Part 1 の
presetShapeDefinitions.xmlを圧縮して埋め込み(presets.xml.gz、tools/gen-presets.pyで生成、30 KB)、ガイド式を評価してパスにする。arcToの角度は楕円の見かけの角度なので Canvas の媒介変数角に直す。custGeomも同じ評価器を通す。塗りは単色・グラデーション(線形は角度とscaled、パスは放射状で近似)・画像(伸縮、srcRectの切り抜き、タイル)・パターン(8×8 の PNG を作ってパターン Paint に)、パスごとのlighten/darken。線は幅・端・結合・破線・矢印(三角・ステルス・菱形・楕円・開いた矢印)、外側の影は SHADOW。グループは子の配置をスライド座標に畳み込み(変換行列を入れ子にしない)、回転・反転は図形ごとの TRANSFORM で表す。テキストは反転させない(上下反転は 180° 回転)。 - テキストレイアウト: BDF に再レイアウトはないので行分割は変換側で行う。空白の後・和文の文字間・和文と欧文の間・語中のハイフンの後で改行でき、閉じ括弧・句読点・小書きの仮名・長音の前と開き括弧の後では改行しない(禁則)。インデントと箇条書き(記号、Wingdings/Symbol の文字は Unicode に置き換え、自動番号)、タブ、行間(割合・固定)、段落前後の間隔(先頭段落の前は取らない)、左・中央・右・両端揃え(欧文は語間、和文は字間を TEXT_STYLE の letterSpacing で広げる)、上・中央・下の配置、自動調整(保存されている
fontScale/lnSpcReduction)、上付き・下付き、下線・取り消し線・ハイライト、段組み(列の高さを超えた行を次の列へ)、縦書き(vert/vert270は枠の回転、eaVertは漢字・仮名を 1 文字ずつ正立させ、句読点は縦書き用字形 U+FE10〜 に、括弧・長音・欧文は回転したまま)を扱う。行の高さは Office と同じくフォントの Windows 用メトリクス(OS/2 winAscent + winDescent)に行間を掛けたもの。 - フォント:
internal/fontdbがフォントディレクトリ(既定でシステムのもの、-font-dirで追加、-no-system-fontsで限定)の name テーブルから索引を作り、要求されたファミリーを実物 → 計量互換の代替(Calibri → Carlito、Arial → Liberation Sans、游ゴシック → Noto Sans CJK JP / IPAexGothic など)→ 同系統の汎用フォント(serif / sans-serif / monospace、和文は明朝/ゴシック)→ 任意のフォント の順に解決する。文字ごとに欧文は latin、和文は ea のフォントを使い、グリフがなければもう一方、さらにフォールバック列から探す(どこにもなければ警告)。この文字ごとの選択・計測と、使った文字の記録・埋め込みはconverter/internal/fontsetが文書単位で受け持ち、他の Office 変換器と共有する。計測したフォントは使った文字だけのサブセット(TrueType はグリフを詰め直し、CFF は PDF と同じ方法で詰め直す(§3.1)。詰め直せない CFF は 2 MB までならそのまま)にして埋め込むので、閲覧側でも計測と同じ字幅で描かれる。ブラウザに登録する FontFace は太さ・斜体の記述子を持たないので、太字のフェイスそのものを埋め込んだときは FONT の weight を 400 にし、フェイスがなく合成が必要なときだけ 700 や italic を指定する。-fonts systemでは埋め込まずにファミリー名(要求名、代替名、汎用名の順)で参照し、advance補正で行幅を保つ。ライセンス(OS/2 fsType)が埋め込みを禁じるフォントも名前で参照し、サブセット化を禁じるフォントは丸ごと埋め込む(-ignore-fstypeで無視できる)。埋め込むフォントは PDF と同じく元のfsTypeと著作権・ライセンスの文字列(§3.1)を引き継ぎ、WOFF2 にして格納する(§3.3、-no-woff2で TTF/OTF のまま)。 - テキストの構造: テキスト枠ごとに MARK BOX、段落と
a:brに PARAGRAPH、折り返しに LINE(和文どうしの折り返しは区切りを入れない WRAP、spec §7.8)、箇条書きの記号の後に LINE を置く。書式が同じで分割されているだけの run(スペルチェックや言語タグ)は 1 つの FILL_TEXT にまとめる。縦書きの行は子 Object に描いて ALT_TEXT に行の文字列を持たせるので、検索では横書きと同じく 1 行 1 run になる。子 Object の bbox の横幅を行の長さにし、ALT_TEXT の前に行のフォントを設定しておくので、ヒットの矩形も行の上に描ける。ハイパーリンク(外部 URL、スライドへのジャンプは#page=N)は LINK。 - 読み上げ用の構造(spec §7.8): タイトル(title / ctrTitle)の段落は HEADING、箇条書きの段落は
lvlで入れ子にした LIST / LIST_ITEM、表はセルごとの CELL(結合は範囲、firstRow / firstCol は見出し)を TABLE で囲む。画像・グラフ・グループ・図形の塗りと線はcNvPrのdescr(なければtitle)を代替テキストにした FIGURE で囲む(装飾指定のものは除く)。meta.dc.languageはコアプロパティのdc:language、無ければ既定のテキストスタイルのlang。描画のまとまりごとに言語を決めて変わるところで LANG を出す。和文を含むまとまりは東アジアのlang/altLang、なければかな・ハングルから推定する(テンプレートの既定lang="en-US"を受け継いだ和文を英語として読ませないため)。 - 表:
tblGridと行から格子を作り、結合セル、セルの余白・配置、塗り・罫線(セルのtcPrが表スタイルより優先)を描く。表スタイルはtableStyles.xmlから全体・縞模様の行/列・先頭/末尾の行/列・角のセルの順に重ね、ファイルにない既定スタイル(Medium Style 2 - Accent 1)は内蔵する。行の高さはセルのテキストに合わせて伸ばす。 - グラフ: グラフパートにキャッシュされた値(
numCache/strCache)から、縦棒・横棒(集合・積み上げ・100%)、折れ線、面、円・ドーナツ、散布図を描く。軸の範囲と目盛の単位は Office と同じ規則(データが 0 から遠くなければ 0 を含め、端に 5% の余白を取り、1・2・5×10^n の単位で目盛の数を図の大きさに合わせる)。系列の色はアクセント色の循環で、凡例、タイトル(単一系列なら系列名)、データラベル、数値の書式コード(%、桁区切り、小数桁)を扱う。3-D グラフは平面で描く。 - SmartArt: PowerPoint がデータモデルと一緒に保存している描画パート(
diagrams/drawingN.xml)の図形を、グラフィックフレームを枠とするグループとして描く(テキストはtxXfrmの枠に置き、その回転は図形の回転に足す)。 - EMF/WMF: ブラウザは Windows メタファイルを表示できないので、画像として格納せずに GDI の状態機械(マップモード、ワールド変換、ペン・ブラシ・フォント、クリップ、パス、保存と復元)で記録を再生し、パス・テキスト・画像(DIB は PNG に)の命令にする。EMF はヘッダーの frame、WMF は placeable ヘッダーの範囲(なければ最初のウィンドウの原点と大きさ)を図の枠に合わせる。テキストは出力空間で正立させ、
dxの文字送りと文字揃えに従う(WMF の ANSI 文字列は文字セットに応じて Shift_JIS などとして読む)。コメントに埋め込まれた EMF+ の記録は読まず、Office が並べて書く EMF の記録を使う。OLE オブジェクトのプレビューの多くはこれで描ける。再生はconverter/internal/metafileにあり、描き込む先(Object とフォント・画像・言語の登録)とピクチャの色変更を渡せば他の変換器からも使える。 - 画像の色効果: 色の変更(
clrChange、透明色の指定)、単色化(clrRepl)、複色(duotone)、二値化(biLevel)、グレースケール、明るさ・コントラスト(lum、PowerPoint と同じく明るさの半分をコントラストの前、半分を後に掛ける)を文書順に画素へ適用し、画像を作り直して格納する(clrChangeの許容差は LibreOffice と同じく JPEG 15、PNG・TIFF 1、BMP 0、その他 9)。メタファイルでは記録の色と DIB に同じ効果を掛ける。 - 数式: PowerPoint は数式を
mc:AlternateContentの Choice(Requires="a14")に Office Math(段落の中のa14:m)として書き、Fallback には数式を描いた画像を置く。a14をまとめて選ぶと数式以外の拡張の描き方まで変わるので、a14:mを含む Choice だけを選び(ooxml.MathChoice、ooxml.Package.Choose)、ほかの Choice は従来どおり Fallback にする。数式は数式エンジン(§3.23)が段落を読むときに組み、行の中の数式の幅の item にする。行の高さは数式の高さと深さで広がり、m:oMathParaは数式ごとに行を分けて、段落が数式だけならm:jc(既定は中央)で揃える。run の書式はa:rPrなので、その大きさ(自動調整のfontScaleを掛けたもの)と色(テーマ色を含む)はテキストの書式と同じく継承をたどって解決し(equation.OMML.RunStyle)、数式の大きさは最初の run の、run が色を付けない部分(括弧、分数の線)は段落の色になる。PowerPoint はm:tに文字を数学用英数字(𝑥)で書くので、元の文字と書体に戻す(抽出・検索では x になる)。Excel の図形の数式も同じ(Fallback は数式の線形表記の文字列)。 - その他: 非表示スライドは既定で除く(
-hiddenで含める)。OLE オブジェクトはプレビュー画像を描く。画像はimgconv(§3.2)を通す。TIFF の画像はconverter/internal/tiff(§3.15)で先頭のページを読み、ストリップを 1 本につなげる JPEG はそのまま、ほかはデコードして PNG(JPEG のページは JPEG)にしてから同じくimgconvを通す。色効果を掛けるときも同じ読み取りでデコードする。構造の壊れたスライドで描画が失敗した場合は空のページにして警告する。 - 壊れたファイルへの備え: OPC のパートの XML は要素の入れ子が 20,000 段までで、それより深いパートは壊れたパートとして読まない(知られている最も深い文書は、表を 5,000 段入れ子にした Apache POI のテストファイルで、15,000 段。木を作るときに止めるので、後の再帰はどれもこの深さで収まる)。図形のグループの入れ子は 100 段までで、SmartArt の描画パートをたどる分も数える。グラフの系列が言う点の数(
ptCountとptのidx)は 1,048,576 まで。箇条書きの英字の番号(A、AA、…)は 1,000 文字を超えるところから数字にする。 - 未対応(警告を出す): EMF+ だけで書かれたメタファイル、レーダー・バブル・等高線グラフ、光彩・反射・ぼかしなどの効果、インク、旧形式(VML のみ)の OLE プレビュー、リンクされた(埋め込まれていない)画像、描画パートのない SmartArt。
テスト用のデッキは python-pptx で生成し(npm run test:pptx:gen、test/pptx/gen.py。数式のデッキ math.pptx は PowerPoint と同じ形の XML を差し込む)、変換結果は testdata/pptx/ に置く。フォントは converter/pptx/testdata/fonts の M PLUS 1p のサブセット(数式には STIX Two Math のサブセット)だけを使うので、出力は実行環境に依存しない。開発中は Apache POI のテストデータ(PowerPoint で作られた実ファイル約 90 本)でも変換を確かめ、LibreOffice の描画(PPTX → PDF → BDF)と見比べた。lumMod/lumOff と alpha を併用した色、グラデーションの線、縦書きなどでは LibreOffice の方が崩れる。
3.5 入力形式の登録と EMF/WMF 変換器(converter/emf)
入力形式は static plugin 方式で登録する。converter パッケージが形式の登録簿を持ち、各変換器のパッケージは init で converter.Register に名前・拡張子・判別関数・変換関数・形式固有のオプション(Params)を渡す。プログラムは import したパッケージの形式だけを扱えるので、PDF だけのサーバーは pptx やフォント処理をリンクせずに済む(全部なら converter/all)。converter.Detect は登録された形式の判別関数を名前順に試し(先頭 1 KiB と入力全体を渡す)、どの形式も中身から判別できない入力は、ファイル名(Options.FileName)の拡張子で決める(1 行の CSV、どんなテキストでもありうる Markdown、HTML の断片)。ほかの形式の特殊な場合である形式(PDF である Illustrator の .ai、画像である draw.io の PNG・SVG 書き出し)は Format.Refines にその形式を書き、先に試される。converter.Options は各形式に共通の設定(ページ指定、画像、フォント、入力の隣のファイルを読むディレクトリ Dir)と、名前で引く形式固有の設定(PDF の kind・box・no-share、Illustrator の box、Photoshop の artboards、PowerPoint の hidden、Word の views、HTML と Markdown の width・remote など)を持つ。CLI の bdf generate はこの登録簿で形式を判別・選択し、-h で登録された形式とその -param を一覧する。各パッケージの Convert と Options はそのまま直接使える。
converter/emf は Windows メタファイル(.emf、.wmf)を 1 ページの文書にする。再生は Office 文書の中の図と同じ converter/internal/metafile(§3.4 の EMF/WMF)。ページの大きさは EMF ならヘッダーの frame(0.01 mm 単位)、placeable WMF なら範囲と 1 インチあたりの単位数から求め、単位の分からない WMF は 96 dpi のピクセルとみなす。EMF ヘッダーの説明文字列にある図の名前を題名にする。テキストは PowerPoint と同じくフォントを解決してレイアウトし、サブセットを埋め込む(-font-dir、-fonts system なども同じ)。
メタファイルのレコードが言う数は信用しない。点の数はレコードに入っている分まで読み、深さが 1・2・4・8・16・24・32 ビットでないビットマップは確保する前に断る。有効なクリップは 64 個、書き出すクリップの点はメタファイル全体で 400 万個(描画のグループごとに、有効なクリップをすべて書き直すので、長いクリップと多くの図形の積になる)、保存する状態(SAVEDC)は 65,536 個までで、超えたら警告して続ける。保存した状態はクリップの並びを共有し、WMF のオブジェクト表の空きはヒープで持つ(どちらも、レコードの数の 2 乗の時間やメモリを使っていた)。
3.6 Excel → BDF 変換器(converter/xlsx)の構造
converter/xlsx は .xlsx(OPC の zip)を読み、ワークシートごとに sheet View を作る。セルの並び・書式・表示形式をこちらで解釈してレイアウトし(BDF に再レイアウトはない)、Tile に描く。セルの値は保存されているもの(数式の計算結果のキャッシュ)を使い、数式は計算しない。図形・画像・グラフは PowerPoint と同じ converter/internal/ooxml/drawingml で描き、フォントの選択・計測・サブセット埋め込み(fontset)と Object の組み立て(canvas)も共有する。
- 読み込み: ワークシートは大きくなりうるので、
sheetDataは XML をストリームで読んで行とセルの小さな構造体にし、それ以外の要素(列、結合、条件付き書式、ハイパーリンク、表示設定)はooxml.ReadFromで要素木にする。ストリームの読み手は tinygodriver のencoding/xmlro(ooxml.NewReader)で、通り過ぎるトークンに何も確保しない。共有文字列の表も同じ読み方で、siごとに木にする。共有文字列(リッチテキストの run を含む)、styles.xml(フォント・塗り・罫線・セル書式・差分書式・インデックス色・テーブルスタイル)、テーマ(drawingmlのテーマをそのまま使う)、workbookPrの 1904 年基準を読む。日付型のセル(t="d")はシリアル値に直す。値が保存されていない数式セル(Excel で保存されていないブック)は空にして警告する。セルに表示するのは Excel と同じく先頭の 1,024 文字まで(共有文字列の巨大な文字列を何万ものセルが参照するファイルでもレイアウトが終わるように)。 - 列幅と行の高さ: Excel の列幅は既定のフォント(
fonts[0])の最大の数字の幅(MDW、96 dpi のピクセル)を単位にした文字数で、ピクセル幅はtrunc(((256·width + trunc(128/MDW)) / 256)·MDW)(ECMA-376 §18.3.1.13)。MDW はフォントの数字の送り幅を整数の ppem で測って丸める(Windows のラスタライザと同じ。Calibri はヒンティングで 1 ピクセル狭くなるので丸めない ppem で測る。Calibri 11 で 7、MS P ゴシック 11 で 8)。defaultColWidthがなければbaseColWidth文字に余白 5 ピクセルを足して 8 ピクセル単位に切り上げる(Calibri 11 で 64 ピクセル)。単位は pt(1 ピクセル = 0.75 pt)。行の高さはht(pt)、なければdefaultRowHeight。高さの書かれていない行は、既定より大きいフォント・折り返し・回転のあるセルがあれば内容に合わせて広げる(テキストの高さをピクセルに切り上げて 2 ピクセル足す。結合セルは含めない。Excel が開いたときに自動調整するのと同じ)。非表示の行・列は大きさ 0。View の広さは使われている範囲に少し余白を足したもので、グリッド線の有無と固定ペイン(pane state="frozen"のxSplit/ySplit)を manifest に書く。 - セルの書式: セルの
s→ 行の書式(customFormat)→ 列の書式の順で書式を決める(値のないセルにも塗りや罫線がある)。テーマ色は SpreadsheetML の番号(0 と 1、2 と 3 の明暗が配色の順と逆)で引き、tintは HLS の輝度に掛ける。インデックス色は既定の 64 色(indexedColorsで上書き可)。ARGB のアルファは Excel と同じく無視する。 - 表示形式: 書式コードを
;で最大 4 セクション(正・負・0・文字列、または[>=100]などの条件)に分け、字句に分けて整形する。桁のプレースホルダー(0#?、?は数字幅の空き)、小数点、桁区切りと末尾の,による 1000 分の 1、%、指数(##0.0E+0は指数を整数部の桁数の倍数に)、分数(# ?/?、分母固定?/8)、"文字列"・\x・_x(x の幅の空き)・*x(セルの幅まで x を繰り返す)、@、色([Red]、[Color10])、通貨と言語([$€-407])、[DBNum1-3](漢数字・大字・全角)。日付と時刻は 1900 年基準(Lotus 1-2-3 以来の存在しない 1900-02-29 を含む)と 1904 年基準のシリアル値から、mが月か分かを前後の時・秒で判断し、表示する最小の単位で丸めて整形する(経過時間[h]、AM/PM、秒の小数、曜日ddd/aaa、和暦ggge(令和・平成・昭和・大正・明治)、仏暦)。Generalは 11 文字に収まる桁数で丸め、収まらなければ指数表記にする。組み込みの書式 ID は既定のフォントが日本語なら日本語版の定義(14 はyyyy/m/d、27〜58 の和暦、通貨は ¥)を使う(ブックには言語の記録がないため)。 - セルのテキスト: 左 2 ピクセル・右 3 ピクセル(グリッド線)を除いたセルの中に置く。標準の配置は数値が右、真偽値とエラーが中央、文字列が左。上・中央・下(既定)・両端揃え・均等割り付けの縦位置、左・中央・右・繰り返し・両端揃え・均等割り付け・選択範囲内で中央の横位置、インデント(1 段 = MDW + 3 ピクセル)、折り返し(DrawingML と同じ改行規則と禁則。
drawingml.CanBreak)、縮小して全体を表示、文字の回転(1〜90 は反時計回り、91〜180 は時計回り、回転したテキストの外接矩形を配置に従って置く)、縦書き(255、1 文字ずつ縦に並べる)を扱う。フォントは名前・大きさ・太字・斜体・下線(二重、会計用)・取り消し線・上付き・下付き・色で、リッチテキストの run はセルのフォントの上に重ねる。テーマのフォント(scheme="minor")はテーマの欧文フォントと、和文には既定のフォントから推定した用字のフォント(Jpanなど)を使う。折り返さないテキストは、はみ出す向き(左揃えなら右、右揃えなら左、中央揃えなら両側)の空いたセル(値がなく結合されていない)の上に延び、空いていなければその境で切る。数値ははみ出さない。Generalの数値は収まる桁数に丸め、それでも収まらない数値は#で埋める(Excel と同じ)。行の高さに収まらないテキストは上下も切る。 - 塗りと罫線: 単色の塗りは行ごとに同じ塗りの続くセルを 1 つの矩形にまとめ、次の行が同じ矩形を続けるなら縦にも延ばす。パターン塗り(18 種)は 8×8 のビットマップを 2 色の PNG にして 1 ピクセル = 0.75 pt のパターン Paint で敷き、グラデーション塗り(線形の角度と、四辺の比率で中心を決める放射状)はセルごとに描く。罫線は格子の線の上に中心を合わせて描き(細線 1 ピクセル、中線 2、太線 3、二重線は 1 ピクセルの線 2 本、点線・破線・一点鎖線などは破線)、隣り合うセルの辺に別々の罫線があれば重い方を使う。同じ線種・色で一直線に続く辺は 1 本の線にし、Tile ごとに線種・色ごとに 1 つのパスで描く(実線は角がつながるよう端を四角にする)。結合セルは左上のセルの塗りと値を範囲全体に描き、範囲の内側の罫線は描かない。斜めの罫線も描く。
- 条件付き書式: 保存されている値で評価する。規則を優先順位の順に適用し、差分書式(フォントの太字・斜体・下線・取り消し線・色、塗り、罫線、表示形式)は各属性を最も優先順位の高い規則が決め、
stopIfTrueでそのセルの評価を止める。セルの値(cellIs)、数式(expression)、文字列の包含・前方一致・後方一致、空白・エラー、上位・下位(件数・割合)、平均より上・下(標準偏差の倍数)、重複・一意、カラースケール(2・3 色、最小・最大・数値・割合・パーセンタイル・数式の閾値)、データバー(長さの範囲、グラデーション、枠線、負の値は Excel 2010 の拡張があれば軸の左に赤で)、アイコンセット(20 種、逆順、値を隠す、Excel 2010 のカスタムアイコン)を扱う。アイコンはパスで描く。値を隠す指定のセルのテキストは透明色で描く(検索・選択・読み上げには残す)。Excel 2010 の拡張(x14:conditionalFormatting)は元の規則の拡張としても、単独の規則(数式はxm:f、差分書式は直書き)としても読む。数式は小さな評価器で計算する: 四則・べき乗・文字列連結・比較・パーセント、セル参照と範囲(相対参照は規則の範囲の左上から評価するセルへずらす、列全体・行全体、他のシート)、論理(AND、OR、NOT、IF、IFERROR)、情報(ISBLANK、ISNUMBER、ISTEXT、ISERROR、ISNA、ISEVEN、ISODD など)、ROW、COLUMN、数学(MOD、ABS、INT、ROUND 類)、文字列(LEN、LEFT、RIGHT、MID、UPPER、LOWER、TRIM、EXACT、SEARCH、FIND、VALUE)、集計(SUM、AVERAGE、MIN、MAX、COUNT、COUNTA、COUNTBLANK、ワイルドカードと比較の条件付きの COUNTIF)、日付(YEAR、MONTH、DAY、WEEKDAY、HOUR、MINUTE)。開いた日時で結果の変わるもの(TODAY、NOW、timePeriodの規則)とそれ以外の関数は評価せず、規則を適用しないで警告する。 - テーブル: テーブルパート(ListObject)の範囲・見出し行・集計行と
tableStyleInfo(最初の列・最後の列・縞模様の行/列)から、テーブルスタイルの要素(全体、縞模様の列、縞模様の行、最後の列、最初の列、見出し行、集計行、角のセル)をこの順に重ね、セル自身の書式の下に敷く(塗りはセルに塗りがなければ、フォントはセルが既定のフォントなら、罫線は辺ごとに)。要素の外周の罫線は領域の辺に、内側の罫線(vertical/horizontal)は領域内のセルの間に置く。組み込みの 60 種(Light 1〜21、Medium 1〜28、Dark 1〜11)は ECMA-376 のpresetTableStyles.xmlから抜き出して埋め込み(tablestyles.xml.gz、tools/gen-tablestyles.pyで生成、3 KB。dxfIdはこのファイルでは 1 始まり)、ブックの独自スタイルはstyles.xmlから読む。見出し行にはオートフィルターのボタンを描く(シートのオートフィルターも)。 - 図形・画像・グラフ: 描画パートのアンカー(2 つのセルの隅、1 つのセルの隅と大きさ、絶対位置。オフセットは EMU)を列幅・行の高さから sheet 座標に直し、図形をその枠に描く(
drawingml.Drawing.DrawAnchored。アンカーが図形自身のxfrmの位置と大きさに優先し、回転と反転は残る。グラフの枠はxfrmが空のことが多い)。図形は 1 つずつ子 Object に 1 回だけ描き、重なる Tile からUSE_ATで使う(canvas.Share)。子 Object の中の LANG はUSEの後も続くので、Tile 側の言語の状態を子の最後の言語に合わせる(canvas.Used)。グラフの値のキャッシュがない系列(openpyxl などが書くブック)は、系列の参照('Sheet 1'!$B$2:$B$9)の先のセルから値を補ってから描く(数値の参照が文字列のセルを指す項目は文字列の項目にする)。グラフシートはグラフを 1 ページにしたfixedView にする。数式の図形は PowerPoint と同じく Office Math から組む(§3.4、§3.23)。 - その他: メモのあるセルには右上に赤い三角を描く(メモの本文は描かない)。外部 URL(http、https、mailto)へのハイパーリンクはセル範囲に LINK を置く(ブック内の場所へのリンクは置かない)。非表示のシートは既定で除く(
-hiddenで含める)。ページの選択(-pages)はシートの番号(グラフシートを含むブック内の順)。zip の中のパス区切りが\のファイルも読む(ooxml.Open)。 - 読み上げ用の構造(spec §7.8): 値のあるセルごとに MARK CELL(セル参照、結合セルは範囲)を置き、テキスト層はシートのセルを 1 つの表にする。テーブルの見出し行のセルには
colを付けて列見出しにする(固定した行・列はビューアが見出しにする)。折り返した行は LINE(和文どうしは WRAP)、縦書きの文字の間は WRAP で結ぶ。画像・図形・グラフは PowerPoint と同じくdescr(なければtitle)を代替テキストにした FIGURE で囲む。Excel はブックに言語を記録しないので、meta.dc.languageはコアプロパティにあるときだけ書き、セルごとにかな・ハングルから言語を推定して LANG を出す(漢字だけのセルは既定のフォントから推定したブックの東アジアの言語。游ゴシックなら日本語)。 - 壊れたファイルへの備え: 行の高さは 409.5 pt、列の幅は 255 文字まで(Excel 自身の上限。
ht="Inf"のような有限でない値は捨てる。自動の行の高さも同じ上限)。結合セルは、合わせて 2^22 セルまでなら View を広げ、結合として扱う(データより広い列にまたがる見出しはそのまま。シート全体の結合は View を広げず、View で切る。それを超えた分は結合しないで警告する)。セルに見せる文字は改行も数えて 1,024 文字まで(改行だけの値が、改行の数だけ行を作っていた)。表示形式は 1 セクション 512 トークンまで。条件付き書式の数式は、構文の入れ子も評価も 128 段までで、それより深い数式は評価しないで警告する(括弧を 600 万重ねた数式がスタックを使い切っていた)。COUNTIF などのワイルドカードは、長さに比例する時間で照合する(*a*a*a*a*a*bのようなパターンで指数的に遅くなっていた)。条件付き書式が読むセルは 1 シート 2^22 個まで。図形(画像、図形、グラフ)は、1 シートで合わせて 16,384 個のタイルまでに描く(図形は 1 つの Object だが、触れるタイルがそれぞれ参照し、どこまで届くかはファイルが決める)。それを超える図形は警告して除き、View も広げない。遠くのセルに留めた小さな図形はそのまま描き、View はセルと同じくそこまで届く。View と行・列の大きさを決めてから図形のタイルを数え、除いた図形があれば View を決め直す。座標が数でない矩形はどのタイルにも触れない。行と列の書式は列の並び(run)ごとにたどるので、広い View の 1 行は列の数ではなく run の数だけの手間になる(16,384 列すべてに表示形式があり XFD1048576 にセルが 1 つあるシートで、1 行 2.7 ms、全体で約 47 分かかっていた)。ファイルにあるセルが持つものは、ファイルの大きさで決まるので切らない。ファイルにないセルについて数バイトが言うことには、シートごとの予算を置き、超えたらその飾りなしで描いて 1 度警告する: 行と列の書式が、ファイルにないセルに与える罫線は 1,048,576 辺まで(ファイルにあるセル、テーブル、条件付き書式の罫線は数えない)。テーブルの、ファイルにないセルは 1,048,576 個まで(超えるテーブルはスタイル・フィルターボタン・列見出しなしで描き、View を広げない。セルがすべてファイルにあるテーブルは大きさによらずスタイルを付ける。CSV と Parquet のテーブルは範囲がデータそのものなので数えない)。範囲に掛けたリンクは、触れるタイルのうち 2 つ目以降を合わせて 16,384 個まで(超えるリンクは最初のタイルにだけ置く)。 - 未対応: 右から左のシート(左から右に描く)、
timePeriodなどの評価しない条件付き書式、マクロシート・ダイアログシート(ここまでは警告を出す)、ピボットテーブルのスタイル(値はセルとして描く)、スパークライン、フォームコントロール、旧形式(VML)の図形、セル内の画像、データの入力規則のドロップダウン。印刷の設定は、下の「印刷のページ」を作るときだけ読む。旧形式の .xls は読めない(暗号化されたブックは §3.7 のとおり復号してから読む)。
テスト用のブックは openpyxl で生成し(npm run test:xlsx:gen、test/xlsx/gen.py。openpyxl が書かない DrawingML の図形は後から描画パートに足す)、変換結果は testdata/xlsx/ に置く。フォントは PowerPoint のテストと同じ M PLUS 1p のサブセットだけを使う。開発中は Apache POI のテストデータ(Excel などで作られた実ファイル 355 本)をすべて変換し、壊れた zip と暗号化されたファイル以外が内部エラーなしに変換できること、条件付き書式や表示形式の見本が期待どおりに描かれることを確かめた。
印刷のページ(print.go、headerfooter.go): Options.Views(-param views=print|both)で、シートを Excel が印刷するページの flow View にも組む。帳票として作られたブックの本来の見た目で、テンプレートの穴埋め(§3.34)の出力でもある。
- 読むもの: ブックの定義名
_xlnm.Print_Area・_xlnm.Print_Titles(localSheetIdでシートに結ぶ)と、ワークシートのsheetPr/pageSetUpPr・pageSetup・pageMargins・printOptions・rowBreaks・colBreaks・headerFooter。用紙がないシートは、日本語のブックなら A4、ほかは Letter(Options.Paperで変えられる。Excel は既定のプリンタの用紙を使うので、ファイルからは決まらない)。 - ページに切る: 印刷範囲(なければ A1 からシートが使う範囲。最後のセルからはみ出すテキストは、そのセルを 1 度レイアウトして届く列まで含める)の行と列を、用紙から余白を引いた大きさを拡大率で割った長さに順に詰める。手動の改ページの前と、次の行・列が入らないところで切る。印刷タイトルは、それより後の行・列で始まるページの上と左に置き、その分だけ詰められる長さが減る。「ページに合わせる」は 10〜100% の整数の拡大率を二分探索する。全部の列・行を 1 つずつ見るので、試す範囲には上限(合わせて 4,194,304)を置き、1 シートのページ数は 16,384 まで(超えた分は警告して除く。
$A$2:$C$1048576を 400 個並べた定義名がそれだけのページを作っていた)。列全体・行全体の印刷範囲($A:$H)は使っている範囲で切る。 - 描き方: セルの描画はタイルのときと同じコードで、描く先がタイルの代わりに領域になる(
sheetCtx.tilesInが印刷のときはprintPlan.regionsInを返す)。領域はページが見せるセルの矩形で、ページの本体(行の帯 i × 列の帯 j)、印刷タイトルの行 × 列の帯 j、行の帯 i × 印刷タイトルの列、その角の 4 種類。それぞれがシート座標(領域の左上が原点)の Object で、ページのbodyレイヤーは自分の領域をSAVE・CLIP_RECT・TRANSFORM(拡大率と用紙上の位置)・USE・RESTOREで置く。同じ列の帯のページが見せる印刷タイトルは同じ Object なので 1 回だけ格納される。USE の前には LANG を文書の既定に戻す(子 Object は既定の言語で始まると仮定して書かれている)。 - 領域の 3 層: 罫線は格子線の上に中心を合わせて描くので、領域の縁の罫線は半分が領域の外に出る。領域を矩形で切ると表の外枠が半分の太さになり、切る矩形を広げると隣の列の塗りが筋になって見える。そこで領域を「罫線の下(枠線・塗り・条件付き書式・テキスト・リンク)」「罫線」「罫線の上(図形)」の 3 つの Object に分け、罫線だけは切らずに置く。代わりに罫線の線分を 1 本ずつ領域の閉じた矩形で切る(
clipSegment)ので、縁の上の線は全幅で残り、領域の外へ続く線は縁で止まる。枠線(printOptionsのgridLines)も同じ切り方で、シートが使う範囲の行と列の間に、結合セルの内側を除いて、塗りの下に引く。 - ヘッダーとフッター: 書式コードの文字列を左・中央・右の区画の行に分け、セルのテキストと同じ計測と描画(
item・lineMetrics・emitLine)で、ヘッダーは上端からヘッダー余白の位置に、フッターは下端からフッター余白の位置に置く。&D・&TはOptions.PrintTimeがあるときだけ文字にする(変換の出力を実行の時刻で変えないため)。ページごとに描くが、ページ番号を含まないものは同じ Object になる。 - 未対応: ヘッダーとフッターの画像(
&G、警告を出す)、エラー値の印刷方法、メモの印刷、テキストがはみ出したセルの間の枠線を消すこと。Excel は印刷のときに行の高さと列幅をプリンタの画素に丸めるので、自動の改ページは 1 行ずれうる。
sheet View の出力はこの追加で変わらない(testdata/xlsx の既存の 3 文書はバイト単位で同じ)。both では 2 つの View を別々にレイアウトして描く(セルは共有し、sheet View が行ごとにセルを手放す最適化は止める)。
3.7 パスワードで保護された入力
保護された入力は、変換のときだけパスワードで開き、出力の BDF を同じパスワードで暗号化する(spec §3.5)。パスワードを知っている人だけが読める、という元のファイルの性質をプレビューでも保つためで、サーバーはパスワードを保存しない。表示のときはブラウザが入力されたパスワードで復号するので、サーバーは閲覧時にパスワードに関わらず、暗号化した BDF をそのまま配ればよい。
- Office: 暗号化された .pptx/.docx/.xlsx は ZIP ではなく、複合ファイル(CFB)の中に
EncryptionInfo(鍵の導き方)とEncryptedPackage(暗号化された ZIP)を持つ。converterが形式の判別より前にこれを見つけて復号し(converter/internal/cfb、converter/internal/offcrypto)、復号した ZIP で形式を判別する。暗号化の方式は Agile(Office 2010 以降。既定は AES-256、SHA-512 を 10 万回)と Standard(Office 2007。AES-128、SHA-1 を 5 万回)。Agile の HMAC(dataIntegrity)が合わないときは、ZIP 自身にも CRC があるので変換は続けて警告を出す。権利管理(IRM)と証明書による保護、Extensible 暗号化、AES 以外の暗号は扱わない。テスト用のファイルは msoffcrypto-tool で作り(test/pptx/gen_encrypted.py)、msoffcrypto-tool で復号できることを確かめてから保存している。 - PDF: pdfcpu に復号させる。まずパスワードなしで開き、ユーザーパスワードが要るときだけ
Options.Passwordで開き直す(オーナーパスワードでも開ける)。オーナーパスワードだけの PDF はパスワードなしで開けるので、保護されたものとは扱わない。 - API:
converter.Options.Passwordで開き、Result.Protectedが「パスワードがなければ開けなかった」ことを表す。これが立っていたらbdf.NewPasswordLockで同じパスワードのロックを作り、Document.Lockに設定して書き出す。パスワードの過不足はconverter.ErrPasswordRequired/ErrWrongPassword。converter.CheckPasswordは変換せずにパスワードを確かめるので、アップロードを受けたときにすぐ答えを返せる。 - CLI:
bdf generateはパスワードを-password-file(-で標準入力)か$BDF_PASSWORDから読む(コマンドライン引数はほかのユーザーから見えるので受け付けない)。出力は-encrypt auto(既定。入力が保護されていたとき)、always、neverで暗号化する。ls・manifest・disasm・extractは$BDF_PASSWORDで暗号化した文書を開き、split・joinは封印された Part をそのままコピーするのでパスワードが要らない。encrypt・decryptは既存の BDF を暗号化・復号する。 - ビューア:
BdfDocument.open(source, { password })がパスワードを受け取り、なければBdfPasswordError("required")、違えば("wrong")を投げる。Worker は開けなかったソースを持ったままunlockを待つので、パスワードを聞き直してもファイルを取り直さない。
暗号化した文書はサーバー側の全文検索の対象にしない(索引が平文になるため)。文書の中の検索は、テキスト索引 Part も暗号化されて BDF の中にあるので、復号した後にブラウザでこれまでどおり動く。サムネイルのように平文が要るものは、サーバーが変換のとき(パスワードと平文を持っている間)に作るしかない。ただしサムネイルも 1 ページ目の中身を見せるので、bdf コマンドは暗号化した文書のサムネイルと検索用テキストを、頼まれたとき(-allow-plaintext)だけ書く(§3.25)。
3.8 Visio → BDF 変換器(converter/visio)の構造
converter/visio は Visio 2013 以降のパッケージ(.vsdx、.vsdm、.vstx、.vstm。OPC の zip で [MS-VSDX])を読む。Visio 2003〜2010 の XML 図面(.vdx、.vtx)と旧バイナリの .vsd は読まない(.vsdx に保存し直してもらう。.xls を読まないのと同じ。古い形式を残すと、読み込むコードと攻撃面が増えるだけで得るものが少ない)。シート(図形・マスターの図形・スタイルシート・ページシート)はセル(計算済みの値 V、式 F、表示単位 U)と、行を持つセクションからなる。Cell・Section・Row 要素をそのまま読む。テキストは文字と書式のマーカーが交互に並ぶ混在内容なので、ooxml.Node が文字と子要素の順序を覚えるようにした(Segments)。
- 継承: 図形は持たないセルを、インスタンス元のマスター図形(グループの子は
MasterShapeで対応付け)、スタイルシートの順に探す。スタイルはLineStyle・FillStyle・TextStyleがそれぞれ線・塗りと効果・テキストのセルを受け持ち、親スタイルをたどってルートの "No Style" に至る。ジオメトリはセクションをIXで、行をIXで重ね、Delの付いた行とセクションは除く。複数の図形からなるマスターのインスタンスはそれらを子図形として持ち、子図形を書いていないインスタンスはマスターの子図形をそのまま持つ。壊れた文書の循環は深さで打ち切る。 - 式: Visio が保存した計算済みの値を使い、式は評価しない。ただし Visio 以外のプログラムが作った図面には、マスターのインスタンスの大きさを変えたのに、その大きさに合ったジオメトリを書いていないものがある。そのため、インスタンスが継承したセルのうち式が
Width・Heightの四則演算だけのものは、インスタンスの大きさで計算し直す。Visio の図面はこれらの値をインスタンスに持つので、結果は変わらない。テキストブロックの幅がTEXTWIDTH(TheText)のもの(ラベルやコネクタの文字)は、Visio が自分のフォントで測った幅なので、折り返さずに置く。 - テーマ([MS-VSDX] §2.2.7.4): Visio 2013 以降のテーマ由来のセルは
V="Themed"と書かれていて、値を持たない。これを図形の QuickStyle セルとテーマから解決する。色の添字は、0〜8 がテーマの dk1・lt1・accent1〜6・背景色、100〜106 と 200〜206 がページの選んだバリエーションの色(varColor1〜7)を指す。線・塗り・効果・フォントの添字 1〜6 は書式スキームのリストを、100〜103 はバリエーションのスタイルスキームを経由してリストを指す。コネクタ("Connector" という名前のスタイルを継承する図形、またはQuickStyleType3)はコネクタ用の書式スキームを使う。線はlnStyleLst(幅・色・端・破線)と Visio 拡張のlineStyles(矢印・角の丸め・線種。添字 0 の項目から始まる)から、塗りはfillStyleLst(phClrをテーマ色に置き換えた単色・グラデーション)とfillStylesから、影はeffectStyleLstのouterShdwから、文字の色とスタイルはfontStylesGroupから求める。色の解決(schemeClr、lumMod・shadeなどの修飾)はdrawingml.ResolveColorを使う。QuickStyleVariationの、背景と明るさが近い色を差し替える規則も実装した。ThemeIndexが 0(テーマなし)の図形はルートのスタイルシートの値を使う。これらの解決は、Visio が保存したサムネイルの色(200 番台のバリエーション色、非単色のバリエーション)と突き合わせて確かめた。 - ページとレイヤー: 前景ページごとに用紙の大きさ(
PageWidth×PageScale/DrawingScale)のページを作り、背景ページ(BackPageの連鎖)をbackgroundレイヤー、ページの図形をbodyレイヤーにする。背景ページはどのページでも同じ Object になるので、内容アドレスで 1 回だけ格納される。座標は Visio の内部単位(インチ、y 軸が上向き)で、図形の局所座標はPinX/PinY・LocPinX/LocPinY・Angle・FlipX/FlipYでグループ(DisplayModeで自分の描画をメンバーの前か後ろに置く)、ページの順に写す。レイヤーのVisibleが 0 のレイヤーにだけ属する図形は描かない。 - ジオメトリ:
MoveTo・LineTo・ArcTo(弓の高さが正なら進行方向の右に膨らむ)・EllipticalArcTo(楕円を円に戻す空間で 3 点を通る円弧を求める)・Ellipse(共役な半径 2 本)・PolylineTo・NURBSTo・SplineStart/SplineKnot(Visio が格納する節点を両端で固定した節点ベクトルに補って標本化する)・相対座標のRel*行をパスにする。MoveToで始まらないセクションは最後の点から始める。閉じた図形だけを(セクションごとに偶奇規則で)塗り、NoFill・NoLine・NoShowに従う。 - 塗り・線・影: 塗りは単色(透明度つき)、パターン 2〜24([MS-VSDX] の図から起こした 8×8 のタイル)、グラデーション(
FillGradientの分岐点。線形はFillGradientAngle、放射状と矩形は 13 通りの起点。矩形は円で近似)、旧版のグラデーション(パターン 25〜40)。線は幅、色と透明度、線種 2〜23(仕様の図から測った破線で、線幅に比例させる)、端(丸・角・延長)、Rounding(多角形の角を丸める)、矢印 45 種を描く。矢印の形は仕様の図から自前で定義した。先端を覆う矢印の下では線を切り詰める。影はShdwPattern、ShapeShdwShow、オフセット(種類 0 はページの既定の影)、ぼかしを SHADOW で描く。 - 線の飛び越し: Visio は飛び越しを保存せず、ページを描くときにコネクタの交差から置く([MS-VSDX] でも関係するセルは「式の評価にだけ使う」とされている)。同じことをページを描く前に行う。ルーティング可能な 1-D 図形(
ObjType)の経路をページ座標で集め(曲線は折れ線にして交差の相手にだけ使う)、交点ごとに飛ぶ側を決める。ページのLineJumpCodeは水平な線(既定)、垂直な線、上または下に描かれた線(最後に経路を引いた線は上の線で代える)を選ぶ。コネクタのConLineJumpCodeは常に飛ぶ、飛ばない、相手が飛ぶ、どちらも飛ばないを選び、ページの規則より優先される。形はLineJumpStyle(コネクタのConLineJumpStyleが優先)の円弧・切れ目・四角・2〜7 辺の多角形、幅はLineJumpFactorX×LineToLineX(垂直な線は Y)、高さは幅の半分とする。向きは水平な線が上か下(ConLineJumpDirX、PageLineJumpDirX)、垂直な線が左か右。重なる飛び越しは 1 つにまとめ、切れ目は図形を分ける。角の近くで幅が収まらない交差は飛ばない。 - 画像と埋め込み:
ForeignDataの PNG・JPEG・GIF・BMP・TIFF をImgOffsetXなどの矩形に置き、図形の枠で切り抜く。TIFF は PowerPoint と同じくconverter/internal/tiffで先頭のページを読む(§3.4)。EMF/WMF はconverter/internal/metafileで再生し、OLE オブジェクトはプレビュー画像(埋め込みパートからの画像リレーションシップ)を描く。 - テキスト:
Text要素のcp・pp・tpマーカーが Character・Paragraph・Tabs の行を選び、fldは最後に表示された文字列を持つ。\nで段落、U+2028 で段落内の改行とする。テキストは DrawingML のテキスト本体(bodyPrの余白・垂直位置・TextDirection1 のeaVert、pPrの揃え・インデント・行間・段落前後の間隔・箇条書き・タブ、rPrの大きさ・太字・斜体・下線・取り消し線・大文字化・上付き・下付き・字間・色・フォント・言語)に写し、drawingml.Drawing.LayoutTextでレイアウトする。そのため禁則、フォントの解決と埋め込み、構造の MARK は PowerPoint と同じになる。行間のSpLineは、負ならその段落で最大の文字の大きさの倍数として扱う。テキストブロックはTextXFormのセルで図形の中に置く。文字は鏡像にしない(FlipYは上下逆さになる)。TextBkgndは行の範囲(TextBody.Bounds)を塗る。言語はLangID(古い図面の Windows ロケール ID も読む)、図面の言語はコアプロパティ、なければルートのスタイルの言語とする。ハイパーリンクは図形の範囲の LINK にする。Addressの URL(http、https、mailto)と、SubAddressのページ名(#page=N)を扱う。 - 未対応: .vsd、インク(警告を出す)。面取り・光彩・反射・ぼかし・3D・スケッチの効果、線のグラデーション、文字の横幅の拡大縮小(
FontScale)、右インデント、右揃え・中央揃え・小数点揃えのタブは描かない。
テスト用の図面は test/visio/gen.py(標準ライブラリだけ)で 1 つのモデルから書き出す(npm run test:visio:gen)。shapes.vsdx はテーマ、ジオメトリの各行、塗り・線・矢印、テキスト、線の飛び越しを含む。flow.vsdx はテーマなしのフローチャートで、マスター・スタイル・画像・背景ページを含む。変換結果は testdata/visio/ に置く(フォントは PowerPoint と同じ M PLUS 1p のサブセット)。開発中は Apache POI と libvisio のテストデータでも変換を確かめた。比べる相手には、Visio が図面に保存するサムネイル(docProps/thumbnail.emf)を converter/emf で描いたものを使った。ただし手で編集されたテストファイルには、中身と合わない古いサムネイルもあった。
3.9 Word → BDF 変換器(converter/docx)の構造
converter/docx は .docx を読み、WordprocessingML を変換側でレイアウトする。BDF に再レイアウトはないので、Word がファイルを開くたびに行っている組版(行分割とページ分割)を変換時に済ませる。図(画像・図形・グループ・描画キャンバス・グラフ・SmartArt・メタファイル)は drawingml の Drawing.DrawGraphic が wp:inline / wp:anchor の枠に描き、Word の図形のテキストボックス(wps:txbx の w:txbxContent)は drawingml.TextBoxHost として docx 側が段落をレイアウトして描く。Word は DrawingML の図形を mc:AlternateContent の Choice(wps、wpg …)に、VML を Fallback に書くので、docx では対応する名前空間の Choice を選ぶ(ooxml.Package.Supported)。フォントの選択・計測・埋め込みは fontset、禁則は pptx と共通の converter/internal/linebreak。レイアウトエンジンは HTML・Markdown・EPUB の変換器と共有するので converter/internal/wordproc にあり(WordprocessingML の読み込みも同じパッケージ)、converter/docx は公開 API の薄い層になっている(§3.16)。
- 2 つの View: 紙面の
flowView(pages)と、ページを持たないscrollView(scroll、spec §4.1)を作る。scroll View は同じ段落と表を用紙の高さを無限にしてもう一度レイアウトしたもので、Word の下書き・Web レイアウト表示にあたる。ヘッダー・フッター・段組み・改ページ・セクション区切りはなく(セクションごとの本文幅は保つ)、縦書きのセクションも横書きで組み(Word の下書き表示と同じ)、脚注と文末脚注は末尾に並べ、ページを基準にした図の位置は段落と段を基準にする。高さおよそ 1024 pt ごとに、どの行も横切らない位置まで上げて帯(strip)に切り、帯をまたぐ図・塗り・罫線は両方の帯に描く(読み手が帯の矩形で切る)。フォントと画像は 2 つの View で共有されるので、増えるのは命令列だけ。-param views=pages/scrollで片方だけにできる。 - レイアウトの流れ: 文書を読むときに、段落は文字・タブ・改行・インライン図・フィールドの item の列にしておく(フォントを選んで字幅を測ったもの)。レイアウトはこれを行に分けて段・ページに置き、描く内容を「縦の範囲・テキストかどうか・読み上げ用の構造」を持つ op の列にする。op を Object に書き出すのは最後で、ページの本文、帯、表のセル、テキストボックスで同じ書き出し方を使う。帯の切り方と行の分割位置は op の縦の範囲から決める。
- 書式の解決: 段落は docDefaults → 表スタイル(全体と条件付き書式)→ 段落スタイルの basedOn の連鎖 → 番号付けのレベル → 直接書式の順に重ねる。run は同じ順に文字スタイルの連鎖と直接書式を重ね、文字スタイルのトグル属性(太字・斜体・大文字・取り消し線など)は段落側の値を反転させる(ECMA-376 の規則)。フォントは
rFontsの ascii / hAnsi / eastAsia を文字ごとに使い分け(hint="eastAsia"のときは記号類も和文フォント)、テーマフォントはthemeFontLangの文字体系で解決する。色はテーマ色(clrSchemeMappingを通す)とthemeTint/themeShade(HSL の輝度)、autoの文字は暗い網掛けの上で白にする。 - フィールド:
fldCharとfldSimpleを文書順に追う。PAGE、NUMPAGES、SECTIONPAGES、PAGEREF は描くページが決まってから数字を入れ(\* ROMANなどの書式、セクションのページ番号書式と開始番号)、ほかは保存された結果をそのまま描く(目次の PAGEREF はレイアウトした結果のページ番号になる)。HYPERLINK フィールドとw:hyperlinkはリンクにし、ブックマークへのリンクはそのブックマークのあるページ(scroll View では帯)への#page=Nにする。変更履歴は反映後の文面(挿入は描き、削除は描かない)、隠し文字は描かない。 - 行分割: pptx と同じ禁則に加え、句読点のぶら下げ(
overflowPunct)、和文と欧文・数字の間の四分アキ(autoSpaceDE/autoSpaceDN。字間ではなく run の間に空けるので、FILL_TEXT のadvance補正で字形が伸びない)、タブ(左・中央・右・小数点揃えとリーダー、ぶら下げインデントの暗黙のタブ位置、defaultTabStop)、番号・記号とその後の区切り(タブ・空白・なし)、両端揃え(欧文は語間、和文は字間。欧文の語の後に来る字間は語を分けないよう run の間に空ける。改行で終わる行も Word と同じく広げ、doNotExpandShiftReturnで止める)、均等割り付け、文字単位のインデント(leftCharsなど)、上付き・下付き・文字位置・文字幅(w:w)を扱う。行の高さはフォントの Windows 用メトリクスに行間(倍数・固定・最小値)を掛けたもので、文書グリッド(docGridのlinesなど)では行ピッチの整数倍に切り上げて中央に置く(1.5 行はピッチの 1.5 倍、固定値はグリッドを無視)。文字数を指定したグリッド(linesAndChars、和文の文書に多い)では、和文の文字が既定のフォントサイズにcharSpaceを足した升目を整数個占める(snapToCharsではすべての文字)。升目の余りは字間として描くので、1 行の文字数が Word と同じになる。 - ページ分割: 行を単位に段とページへ流し込む。段落前後の間隔(前の段落の後と次の段落の前の和、
contextualSpacing、ページ先頭でも前の間隔は残す)、改ページ・改段(改ページだけの行はページに入らなくても前のページに置き、空白のページを作らない)、セクション区切り(次のページ・連続・奇数/偶数ページ。連続で段数が変わるときは最も下まで埋まった段の下から)、pageBreakBefore、keepNext(連鎖が段をまたぐなら連鎖ごと次の段へ)、keepLines、孤立行の制御(widowControl: 先頭の 1 行だけが残るなら段落ごと次へ、最後の 1 行だけが送られるならもう 1 行送る、3 行の段落なら段落ごと)は、置いてから結果を調べ、状態のスナップショットに戻してやり直す方式で実装した。浮動する図(wp:anchor)はページ・余白・段・段落・文字を基準に置き、四角形・外周・内部の折り返しでは行の幅を狭め(広い側に置く)、上下の折り返しでは図の下へ送る。背面の図は本文の前、それ以外は後に描き、段落が次の段へ送られると図も一緒に移る。脚注は参照する行を置くときにページ下部の高さを確保し、区切り線とともにページの下に描く(表のセルの中の参照も同じ)。文末脚注は最後のセクションの後に置く。 - 表:
tblGridの列幅(なければ等分)、tblIndと配置(互換モード 15 未満では最初のセルの左余白だけ左へずらす)、セル余白、罫線(セル → 表スタイルの条件付き書式 → 表の外枠・内側の線)、網掛け、縦・横の結合、行の高さ(最小値・固定)、縦位置を扱う。表スタイルの条件付き書式は全体 → 縞模様の列・行 → 先頭/末尾の列・行 → 角のセルの順に重ね、tblLookと縞の幅に従う。段落と run の書式にも効く。セルの中身は幅を決めて高さ無限で先にレイアウトし、行を単位にページへ流す。入らない行は(cantSplitでなければ)どのセルの行も横切らない位置で分けて次のページへ続け、見出し行(tblHeader)は各ページの先頭で繰り返す。 - ヘッダー・フッター: セクションごとに既定・先頭ページ(
titlePg)・偶数ページ(evenAndOddHeaders)を選び、定義のない種類は前のセクションから引き継ぐ。PAGE などのフィールドのためにページごとに描くが、同じ内容なら同じ Object になって 1 回だけ格納される。本文の上端・下端は余白とヘッダー・フッターの高さの大きい方(余白が負なら固定)。ページのレイヤーはbackground(ページの色、ページ罫線)、header、footer、bodyの順で、body矩形は本文の範囲(flow View の連続モードはこれを積む)。 - 縦書き:
textDirectionがtbRlのセクションのページは、横書きのページを時計回りに 90° 回したものとしてレイアウトする。流し込みの座標は、x が本文の上端から下へ(行の中)、y が本文の右端から左へ(行の並び)で、行の長さは本文の高さ、段組みは上下に並ぶ段になる。行分割・ページ分割・表・脚注(本文の左端に来る)は横書きと同じ処理で、本文レイヤーの命令列を TRANSFORM で回して描く。ヘッダー・フッターは横書きのまま、浮動する図はページ座標で正立させて置き(文字列の折り返しの範囲は回した座標に直す)、インラインの図も行の中で正立させる。行は pptx の縦書き(§3.4)と同じく子 Object に描き、漢字・仮名を 1 文字ずつ正立させ(文字グリッドの升目の中央に)、句読点は縦書き用字形(フォントになければ右上へずらす)、小書きの仮名は右上へ寄せ、欧文・数字・括弧・長音は行と一緒に回したままにする。縦中横(eastAsianLayoutのvert)の run は 1 字分の升目に横書きのまま正立させ、vertCompressなら升目の幅に縮める。傍点と傍線は行の右側。親には行の文字列を ALT_TEXT として USE_AT の前に置くので、抽出では 1 行 1 run になり、子 Object の bbox を行の長さにしておくことで検索のヒットも行の上に描ける(spec §7.8)。リンクとインラインの図は親に置く(抽出は ALT_TEXT の付いた子 Object の中を見ないため)。テキストボックスのeaVertも同じ描き方をする。 - 読み上げ用の構造(spec §7.8): アウトラインレベル(見出しスタイル)の段落は HEADING、番号付き段落は
numIdとレベルで入れ子にした LIST / LIST_ITEM(見出しの番号はリストにしない)、表は TABLE と CELL(結合は範囲、見出し行は列見出し)、図はwp:docPrのdescr(なければtitle)を代替テキストにした FIGURE、テキストボックスは BOX。op が持つ構造と、書き出し中に開いている構造の差分で MARK を出すので、ページや帯の境目(Object の先頭)で表やリストが開き直される。LANG は run のw:lang(和文はeastAsia)で、meta.dc.languageはコアプロパティ、なければ既定の run の言語(本文の多くが和文ならeastAsiaの方)。 - 数式: Office Math(
m:oMath、m:oMathPara)は数式エンジン(§3.23)が組み、行の中のインラインオブジェクトにする。数式フォントはm:mathPrのm:mathFont(既定は Cambria Math)。 - 壊れたファイルへの備え: 表の列は 16,384 まで(
gridSpanやgridBeforeがそれを超える分は最後の列にまとめる。Word の表は 63 列まで)。段落のタブ位置は 200 個まで。リストの英字の番号は 1,000 文字を超えるところから数字にする。keepNextの段落が文書の終わりまで続くときは、続く相手がいないのでそのまま置く(段落ごとに終わりまで調べ直さない)。XML の入れ子の上限は §3.4。 - 未対応(警告を出す): 割注(
eastAsianLayoutのcombine、普通の文字列として描く)、行番号、埋め込み文書(altChunk)、VML のテキストボックス、ルビ(親文字だけを描く)、連続セクション区切りの前の段の高さの均等化、ページをまたぐ縦結合セル(結合の最初の行に描く)。
テスト用の文書は test/docx/gen.py が WordprocessingML を直接書いて作り(npm run test:docx:gen、標準ライブラリのみ)、変換結果は testdata/docx/ に置く。フォントは converter/docx/testdata/fonts の M PLUS 1p のサブセットだけを使う。開発中は Apache POI のテストデータ(Word などで作られた実ファイル約 130 本)がすべて変換できること(暗号化・破損したファイルを除く)と、描画が文書の内容どおりであることを確かめた。5,000 段入れ子の表のような極端な文書では、入れ子の深さに比例して op を写すため時間がかかる(10 秒程度)。
3.10 CSV・TSV → BDF 変換器(converter/csv)の構造
converter/csv はテキストの表を読み、Excel で開いたときと同じ見た目の sheet View を 1 枚作る。レイアウトと描画は converter/xlsx の ConvertGrid に任せる(書式のない値の表 xlsx.Grid を新規ブックの既定の書式で描く。セルのレイアウト、はみ出し、折り返し、Tile 化、読み上げ用の MARK は §3.6 と同じもの)。このパッケージが受け持つのは、ファイルに書かれていないことの推定である。推定の結果はどれも -param で上書きでき、変換の要約(Result、CLI の最終行)に出る。
- 文字コード: BOM(UTF-8、UTF-16LE/BE、UTF-32LE/BE)があればそれに従う(ブラウザと同じく
-param charsetより優先)。なければ先頭 64 KiB から推定する。ゼロバイトが 2 バイトの片側に偏っていれば BOM なしの UTF-16(CSV にはどの行にも区切り文字と改行という U+0100 未満の文字がある。ほかの文字コードのテキストにゼロバイトはなく、バイナリでは両側にある)。7 ビットで ISO-2022-JP のエスケープシーケンスがあれば ISO-2022-JP。UTF-8 として正しければ UTF-8(多バイト文字 20 個につき 1 バイトまでの不正なバイトは U+FFFD にして許す。一部だけ別の文字コードで編集されたファイル)。残りは Shift_JIS(Windows-31J。Excel が日本語の CSV を書く文字コード)、EUC-JP、Windows-1252 で復号し、ASCII 以外の文字の「文字らしさ」の平均が最も高いものを選ぶ。ひらがな・カタカナ・和文の約物・JIS 第 1 水準の漢字は高く、半角カナと第 2 水準の漢字は低く、C1 制御文字・私用領域・U+FFFD は負。Windows-1252 のアクセント付きの文字は ASCII の文字に挟まれているときだけ高い(連続していれば、東アジアの文字の 2 バイトを 1 バイトずつ読んだもの)。中国語・韓国語の旧来の文字コード(GB18030、Big5、EUC-KR)は漢字の頻度表なしには日本語と区別できないので推定せず、-param charset=で指定する(WHATWG のラベルとcp932などの Windows のコードページ名)。 - 方言: RFC 4180 を寛容に読む。区切り文字、CRLF・LF・CR の改行、クオートで囲んだフィールド(区切り・改行・クオートを含められる。クオートは二重にする。バックスラッシュでエスケープする書き出しもある)。開きクオートの前と閉じクオートの後の空白は捨て、規則に合わないクオートは文字とする。区切り文字(カンマ・タブ・セミコロン・縦棒)とクオート(ダブル・なし・シングル、それぞれ二重化かバックスラッシュ)の組ごとに先頭 64 KiB(最大 2,000 レコード。途中で切れた最後のレコードは除く)を読み、最も多くのレコードでフィールド数がそろう組を選ぶ。規則に合わないクオートと閉じられないクオートは減点する。そろい方が同程度なら、フィールドが多い方(値にカンマを含む TSV)、数値のフィールドが多い方(小数点がカンマの数値をセミコロンで区切る欧州の CSV)、よく使われる方の順に選ぶ。
- 値: Excel が数値として読むもの(符号、桁区切り、小数点のカンマ、指数、パーセント、通貨記号、負数の括弧)と日付・時刻(
2024-01-02、2024/1/2、1/2/2024、2-Jan-2024、2024年1月2日、令和6年1月2日、R6.1.2、9:30、ISO 8601 の日時)は右に、ほかは左に揃える。表記は書かれたまま(Excel は1.50を1.5に、2024/1/2を日付の表示形式に変える)。先頭が 0 の整数(007、郵便番号、コード)は Excel と違って文字列のままにし、そういう値を含む数字だけの列はほかの整数も文字列にして揃える。数値は隣のセルにはみ出さない(列に収まらなければ###)。 - 見出し行: 先頭レコードの値とその下の値(最大 1,000 レコード)を列ごとに比べて点数を付ける。数値・日付・真偽値・メールアドレス・URL の列の上の文字列は見出し(+2)、同じ種類の値はデータ(−2。ただし 1900〜2100 の 4 桁の整数がほかの数値の上にあれば年の見出し)。文字列の列では、下にも現れる値はデータ(−1)、長さのそろったコードの上の違う長さの名前は見出し(+1)。先頭レコード自体では、空の値(左上の角を除く)、数値、重複する値を減点し、すべて異なる文字列なら少し加点する(文字列だけのファイルにも多くは見出しがある)。合計が正なら見出し行とする。
- 見た目(
xlsx.Grid): フォントは新規ブックの既定の 11 pt(日本語なら游ゴシック、韓国語は맑은 고딕、中国語は等线・新細明體、ほかは Calibri)。言語は日本語・韓国語・中国語の文字コードか、かな・ハングルの有無から決め、漢字だけのセルの言語にもする。枠線を表示し、列幅は値の幅に左右の余白と 1 ピクセルずつを足した幅に合わせる(既定の幅より狭くはせず、文字列は数字 50 文字分まで。それより長い文字列は空いた隣のセルにはみ出すか、切れる)。改行を含む値は折り返して行を高くする。見出し行は太字にして固定し(ウィンドウ枠の固定)、MARK CELL にcolを付けて列見出しにする。-param table=TableStyleMedium2などで組み込みのテーブルスタイルの表にもできる(縞模様の行、見出し行のフィルターボタン。ボタンの分だけ見出しの列を広げる。テーマ色は Office の既定のテーマ)。シート名は Excel と同じくファイル名から拡張子を除いたもの(converter.Options.FileName)。 - 形式の判別: BOM か推定した文字コードで先頭 64 KiB を復号し、制御文字がほとんどなく、2 レコード以上がそろって 2 つ以上のフィールドに分かれれば CSV とする(PDF、zip、XML・HTML は除く)。1 行だけ・1 列だけのファイルは中身から判別できないので、
converter.Convertは中身から判別できなかった入力の形式をOptions.FileName(ConvertFileが設定する)の拡張子(.csv、.tsv、.tab)から決める。 - 上限: Excel と同じく 1,048,576 行・16,384 列まで(超えた分は警告して除く)。ファイルは全体をメモリに読み、UTF-8 に直してから分割する。
テスト用のファイル(converter/csv/testdata/: Excel の「CSV UTF-8」と同じく BOM と CRLF の付いた basic.csv と、Shift_JIS の japanese.tsv)の変換結果は testdata/csv/ に置く。フォントは Excel と同じく PowerPoint のテストのものだけを使う。
3.11 draw.io → BDF 変換器(converter/drawio)の構造
converter/drawio は draw.io(diagrams.net)の図を読み、mxGraphModel の XML から直接 BDF の命令を作る。draw.io 自身の SVG・PDF 出力を経由しないのは、HTML ラベルが SVG の foreignObject(HTML)で出力され、そのままでは Canvas に描けないことと、ページ・レイヤー・リンク・テキストの構造を残すため。
入力:
.drawio/.xml(<mxfile>の<diagram>がページ。中身は<mxGraphModel>要素か、Graph.compress で圧縮した文字列=URL エンコードした XML を raw deflate して base64)、裸の<mxGraphModel>、図を埋め込んだ SVG(ルート要素のcontent属性)と PNG(mxfileまたはmxGraphModelという名前の tEXt / zTXt チャンク)。converter.Detectは中身からこれらを判別する。ページ → View: ページごとに
fixedView(ページ 1 枚)を作る。View のidは図のid(なければpageN)、titleはページ名。ビューアは View をシート見出しのようなタブで切り替えるので(spec §4.1)、Excel のシートと同じ操作感で複数ページを行き来できる。図の中のページへのリンク(data:page/id,…)は#view=IDの LINK にする(spec §7.7)。http / https / mailto 以外のリンク(data:action/…など)は捨てる。ページの大きさと座標: ページは描いたものの外接矩形に余白(既定 10px、
-param border=)を足した大きさ。draw.io の座標は CSS px なので、各レイヤー Object の先頭で 0.75 倍(pt)と原点の移動を 1 回かけ、以降は draw.io の座標のまま命令を出す。背景色(background)はbackgroundレイヤー、draw.io のレイヤー(ルートの子)はそれぞれbodyレイヤーの Object にし、非表示のレイヤー・セル、折りたたんだコンテナの子は描かない。shadow="1"のページはすべての図形に影を付ける。スタイル: スタイル文字列は mxStylesheet と同じ規則で読む(
key=valueの上書き、名前だけの項目は draw.io のstyles/default.xmlの名前付きスタイルを合成、noneはキーを消す、先頭の;は既定スタイルを使わない)。defaultの色はライトテーマの色(塗りは白、線と文字は黒)に、light-dark(a, b)はaにする。セルの配置(mxGraphView): 入れ子のジオメトリ(コンテナの子は親の原点から、
relativeなジオメトリは親の大きさやエッジ上の位置の割合から)を絶対座標にし、ラベルの位置(labelPosition、verticalLabelPosition)を足す。描く順は draw.io と同じくモデルの深さ優先の順で、セルごとに図形、ラベルの順。エッジの経路: 端点の固定(
exitX/entryXなどの接続制約、ポート)、エッジスタイル(直交・エルボー・ER・セグメント・ループなど mxEdgeStyle)、端点の浮動(図形の外周との交点、mxPerimeter と draw.io の外周関数)を mxGraph と同じ手順で計算する。draw.io は計算した経路をファイルに保存しないので、ここが描画の見た目を大きく左右する。jumpStyleのあるエッジは、モデルの順で前にあるエッジとの交差にジャンプ(弧・隙間・段差・線)を入れる(Graph.js の updateLineJumps と mxConnector.paintLine)。図形: mxAbstractCanvas2D と同じ API(パスは変換側で平行移動・拡大、回転と反転は TRANSFORM、save/restore は SAVE/RESTORE)の Go のキャンバスを用意し、mxGraph と draw.io の Shapes.js の図形・矢印をほぼ行単位で移植した。ステンシル(
mxgraph.flowchart.*など XML で定義された図形と、スタイルに埋め込まれたstencil(…))は mxStencil の解釈器で描く。ライブラリは draw.io のstencils/*.xmlから flowchart・basic・arrows・AWS(aws4、1,037 アイコン)・bpmn・networks・eip・lean_mapping・floorplan・rack・Cisco・旧 Azure・電気回路・P&ID を選び、//go:embedして、名前が引かれたときにファイル単位で展開する。tools/gen-drawio-stencilsが接続点とコメントを落とし、座標をステンシルの大きさの 2000 分の 1 未満の誤差で丸め、パスの各ステップをd属性の短い表記(M44 11L44 9C…、読み込むときに元の要素に戻す)にしてから gzip にする。数値が中身の 4 割を占めるので効きが大きく、aws4 は 1.06 MB が 0.67 MB に、ほかのライブラリは 266 KB が 216 KB になり(合計約 0.89 MB)、ステンシルのテストの描画はピクセル単位で変わらない。ステンシルには Apache 2.0 に加えて draw.io の追加条件(Atlassian 製品や Atlassian Marketplace で配布される製品に組み込むには draw.io の書面による明示的な許可が要る。利用者が作った図の出力=書き出した画像や文書は対象外)があり、converter/drawio/stencils/NOTICEに原文を載せる。JavaScript で定義されたmxgraph.*図形のうち、Basic(mxgraph.basic.*、mxBasic.js)、Arrows(mxgraph.arrows2.*、mxArrows.js)、BPMN(mxgraph.bpmn.*、mxBpmnShape2.js)、AWS(mxgraph.aws4.resourceIconなどアイコンとグループ枠、mxAWS4.js)のライブラリも移植した。古い世代の AWS アイコン(mxgraph.aws.*、aws2、aws3、aws3d)はライブラリを埋め込まず、現行の aws4 の対応するアイコンで描く。対応表(aws_legacy_table.go、718 名のうち 659 名に対応先)はtools/gen-drawio-awsmapが draw.io のサイドバー(旧パレットと現行パレットの項目の題名・タグ・名前)を照らし合わせて作り、改名されたサービスやグループ枠は手で補正する(overrides.txt)。変換の前にスタイルを書き換え、図形・アイコン・色は現行パレットのものに、ラベルと配置のキーはセルのものを残す。アイコンは元の枠の中央に対応先の縦横比(サービスアイコンなら正方形)で収め、エッジと子セルはその位置に追従させる。対応先のないもの(SimpleDB、Mechanical Turk、SWF など)は矩形と警告にする。そのほかの JavaScript の図形とサイズの大きいステンシルライブラリ(GCP、Office など)は対象外で、矩形と警告にする。draw.io の影は図形全体に掛かる CSS の drop-shadow なので、図形を GROUP で描き、合成するときに SHADOW を掛けて同じ見た目にする。グラデーションは SVG の objectBoundingBox と同じく塗る範囲の外接矩形に合わせる。ラベル: draw.io は HTML ラベルを foreignObject の中の HTML(line-height 1.2 の inline-block を flex で配置、mxSvgCanvas2D.createCss)として描くので、その CSS レイアウトを再現する。ラベルの位置(mxCellRenderer.getLabelBounds、rotateLabelBounds、mxText.getSpacing)を移植し、HTML は許容的なパーサで読んで、ラベルが使う範囲の CSS(ブロックと余白の相殺、リスト(記号は Blink と同じく図形で描く)、見出し、インラインの太字・斜体・下線・色・大きさ・フォント・背景、
<br>、空白の畳み込み、white-space)を扱う。行分割は空白の後と和文の文字間(禁則つき)で行い、word-wrap: normalなので長い語ははみ出す。行の高さは Blink と同じく、フォントのアセント・ディセントを整数 px に丸め、半行送りを切り捨てて上に足し、残りを下にする。macOS の Chrome は Helvetica・Times・Courier のアセントを高さの 15% 増やす(Windows の Arial などに合わせるため)ので、それにも合わせる。HTML でないラベルは SVG の text と同じく改行で分けた行を 1.2 倍の行送りで置く。構造は PowerPoint と同じく、ラベルごとに BOX、段落と<br>に PARAGRAPH、折り返しに LINE / WRAP、見出しに HEADING、リストに LIST / LIST_ITEM / END を出す。フォント: Office 系の変換器と同じ
fontset(fontdbで解決・計測し、使った文字だけのサブセットを WOFF2 で埋め込む)とcanvas(フォントの参照は全ページのレイアウトが済んでから確定する)を使う。ラベルのフォントは CSS の font-family リストなので、先頭のフォントにない文字はリストの残りから、なければ先頭のフォントの総称ファミリーの代替フォントから探す(fontset.Set.FaceForFamilies)。draw.io の既定の Helvetica は、ない環境では Liberation Sans / Arimo / Arial で置き換わる。画像: スタイルの
image=の data URI(base64、URL エンコードした SVG)は Part に格納する。URL で参照する画像はネットワークに依存しないよう取得せず、警告を出して描かない。数式: モデルに
math="1"のあるページでは、draw.io が MathJax で組むのと同じ区切り($$ … $$と\[ … \]はディスプレイ、\( … \)は行内)の LaTeX を数式エンジン(§3.23)で組む。区切りはラベルのテキストノードの中で探し(preの中は組まない)、数式は数式の幅の item になって語と同じく前後で折り返せ、行の高さは数式の高さと深さで広がる。HTML ラベルのディスプレイの数式は行を分ける。行の間隔が決まっているプレーンなラベルでも行内に置き、数式の高さの分だけ行の間を空ける(数式のない行の位置は変わらない)。MathJax と同じく数式の x の高さを文字に合わせ(最大 1.25 倍)、色は文字の色(textOpacityを含む)。バッククォートの AsciiMath は文字列のまま描く。未対応(警告を出す): 手書き風(
sketch=1、rough.js の塗り)は通常の描画にする。縦書き(textDirection=vertical-*)は横書きで描く。AsciiMath(math=1のバッククォート)、HTML ラベル内の画像、JavaScript で定義されたmxgraph.*の図形の多く。壊れたファイルへの備え: 圧縮された図(deflate、PNG の zTXt)とスタイルに埋め込まれたステンシルは、展開して 64 MiB まで読む。親をたどると自分に戻るセル(根から子をたどって届かないセル)は親を外し、親をさかのぼる処理が終わらないことをなくす。色の参照(
inherit、swimlane)は 32 段、ステンシルの要素とラベルの HTML の入れ子は 256 段、数式の LaTeX は 20,000 バイトまで。プレースホルダーが置き換える文字列はラベルごとに 1 MiB まで(属性の値が長く、それを何度も置くラベルは、長さの 2 乗に膨らむ)。線のジャンプは、辺を前の辺すべてと突き合わせるので辺の数の 2 乗で増える。突き合わせは 1 ページ 2^27 回、見つけた交点は 2^18 個までとし、超えた後の辺はジャンプなしで描く。模様の塗りの線、ジグザグの歯、破線の矢印の線は、図形ごとに 10 万本に加えて、1 ページで 100 万本まで。どれも超えたところで 1 度警告して、残りを描く。
テスト用の図は converter/drawio/testdata/ にあり(数式のラベルは math.drawio)、draw.io デスクトップ版のコマンドライン書き出し(draw.io -x -f svg|png)の結果と見比べて調整した。エッジの経路は、書き出した SVG(testdata/route/*.svg)のパスと 15 の図の 684 本で比べ、最大の差は 0.007px(Loop スタイルで draw.io 自身の結果が表示位置に依存する 2 本を除く)。ラベルの行の位置は Chrome で同じ HTML をレイアウトした結果と比べた。移植したコードの出典は converter/drawio/NOTICE。
3.12 DXF → BDF 変換器(converter/dxf)の構造
converter/dxf は AutoCAD の DXF(テキスト形式とバイナリ形式、R12〜2018)を読む。ネイティブの DWG は仕様が公開されておらず、読めるライブラリ(LibreDWG は GPLv3、ODA は有償の会員制)もこのリポジトリのライセンスでは使えないので扱わない(DXF に保存し直してもらう)。CAD 図面を描く部分は、続く JWW・SXF の変換器と共有するため converter/internal/cad に分けた。読み手は図面の座標(y 上向き、float64)のまま線・塗り・文字・クリップのグループを cad.Drawing に入れ、cad.Plotter がページの座標に写して Object に書く。座標を float32 にするのはページに写すときだけなので、平面直角座標のような大きな座標でも精度が落ちない。円弧・楕円・膨らみのある線分はベジェ曲線にして入れるので、非一様な拡大を含むどんなアフィン変換でも形が崩れない。線の太さは縮尺によらず用紙上の太さ(pt)で、プロッタが描くのと同じになる。
- 読み込み: グループコードと値の組(タグ)に分け、グループコードの範囲で値の型(文字列・実数・整数・真偽・バイナリ)を決める。バイナリ形式はセンチネルの後に、R12 は 1 バイト、R13 以降は 2 バイトのグループコードが続く(
$ACADVERの後のバイト列で見分ける)。文字列は AutoCAD 2007(AC1021)以降は UTF-8、それより前は$DWGCODEPAGEのコードページで読む。ただし UTF-8 として正しい非 ASCII 文字列はコードページによらず UTF-8 とし、コードページが無いか既定の ANSI_1252 のままで、Shift_JIS として正しく読めて仮名か漢字が 2 文字以上出るものは Shift_JIS とする(日本語の図面を既定のコードページのまま書くプログラムがある。半角カナだけでは欧文の文字化けと区別できないので数えない)。コードページが ANSI_932 なら文書の言語をjaにする(936、949、950 はそれぞれzh-Hans、ko、zh-Hant)。\U+XXXXと、古い図面の\M+nXXXX(コードページの番号とその 2 バイト)はここで文字に戻す。タグはグループコード 0 で区切ってエンティティにし、POLYLINE には VERTEX、INSERT には ATTRIB を子として付ける。サブクラス(100)ごとに同じグループコードが別の意味を持つもの(LAYOUT の 330 など)はサブクラスを指定して読む。 - View とページ: モデル空間を 1 ページの View(
model、題名 Model)にし、図面の範囲を A3 の長辺(420 mm)に収める縮尺で描く。CAD のモデル空間の表示にならって暗い背景(AutoCAD の既定の 33,40,48)に描き、ACI 7 は白、暗い色は AutoCAD 2020 と同じく明るくした色で描く(-param background=lightなら白地に紙の配色で描く)。ペーパー空間のレイアウトは、自分のビューポート(ID 1)のほかに何かが載っているものだけを、タブの順に 1 ページの View(layout1、layout2…、題名はレイアウト名)にする。用紙は PLOTSETTINGS の大きさと印刷の回転(90° と 270° では縦横を入れ替え、余白も回す)で決め、ペーパー空間の原点は印刷可能領域の左下(余白と印刷のオフセットの分)に置く。用紙単位がインチならペーパー空間の単位もインチとし、ユーザー定義の縮尺(142/143)も掛ける。R12 の図面はレイアウトを持たないので$PLIMMIN/$PLIMMAXを用紙にする。-param views=model|layoutsで片方だけにでき、-pagesはタブの順(モデル空間が 1)で選ぶ。 - ビューポート: 上から見たビューポートだけを描く(視線の向きが Z 軸でないものは警告)。モデル空間からペーパー空間への変換は、ビューポートの中心と高さ、モデル空間でのビューの中心(DCS)と高さ、注視点、ねじれ角から
T(中心 − ビューの中心 × 縮尺) · R(ねじれ角) · S(縮尺) · T(−注視点)で求め、ビューポートの矩形(非矩形のクリップが指定されていればその境界の図形)で切り抜く。R12 の図面はビューの値をコードではなく拡張データ(ACAD の MVIEW のリスト:注視点、視線の向き、ねじれ角、高さ、中心、凍結した画層)に持つので、そこから読む。モデル空間はビューポートごとに描き直し、ビューポートで凍結した画層(331)を除き、$PSLTSCALEが 1 なら線種の長さをペーパー空間の単位にする。ビューポートの中身を先に描き、ペーパー空間の図形を上に重ねる。 - 画層と属性: 非表示(色が負)と凍結の画層の図形は描かず、レイアウトでは印刷しない画層も除く。Defpoints(寸法の定義点の画層)は常に描かない。色は ACI(1〜255)、トゥルーカラー(420)、BYLAYER、BYBLOCK、透過(440。画層の透過は拡張データの AcCmTransparency)を解決する。ブロックの中の画層 0 の図形は挿入の画層の属性を受け継ぎ、挿入の画層が非表示ならそれらだけが消える。線種は LTYPE の要素(正が線分、負が空白、0 が点)を
cad.DashPatternで Canvas の破線(線分から始まる線分と空白の交互)とその開始位置に直し、$LTSCALE、図形の線種尺度(48)、挿入の拡大率を掛ける。線種の中の文字や図形は描かず、その分を空白として残す。線の太さ(370、1/100 mm)は用紙上の太さとして使い、既定は 0.25 mm、0 は 0.1 mm にする。模様が線の太さより細かければ実線で描く。 - 座標: 2D の図形(円、円弧、ポリライン、文字、挿入、SOLID、HATCH)は押し出し方向(210)の OCS を任意軸アルゴリズムで WCS に写し、上から見た XY に落とす(押し出し方向が (0, 0, −1) の鏡像の図形もこれで正しく描ける)。
- 図形: LINE、XLINE・RAY(図面の範囲で切る)、POINT(
$PDMODE・$PDSIZE。0 以下はビューの高さに対する割合)、CIRCLE、ARC、ELLIPSE、LWPOLYLINE と POLYLINE(膨らみの円弧。一定の幅は幅のある線として、変わる幅は区間ごとの帯として塗り、スプラインフィットの枠の点は除く)、ポリフェイスメッシュ・ポリゴンメッシュ・MESH(辺を描く)、SPLINE・HELIX、SOLID・TRACE、3DFACE(見えない辺を除く)、WIPEOUT(背景色で塗る)、MLINE(要素ごとの折れ線。色は MLINESTYLE から)。SPLINE は、非有理で次数 3 以下のクランプされた B スプラインならノット挿入(The NURBS Book の A5.6)で厳密なベジェ曲線にし、それ以外は標本化する。制御点が無くフィット点だけのものは、弦長をパラメータにした C2 の 3 次スプラインで補間する(端の接線があれば使う)。 - ブロック: INSERT は
T(挿入点) · OCS · R(回転) · T(配列の間隔) · S(尺度) · T(−基点)で描き、MINSERT の配列も展開する(1 万個を超えるものは 1 つだけ描く)。属性(ATTRIB。AutoCAD 2018 の複数行の属性を含む)と、定数の属性定義(ATTDEF)も描く。寸法(DIMENSION など)と表(ACAD_TABLE)は、AutoCAD が書いた匿名ブロック(*D…、*T…)を描く。寸法のブロックは寸法の OCS にあり、挿入点(12)の分だけずらす。外部参照は図面に含まれないので警告する。 - 文字: 文字の高さは AutoCAD と同じく大文字の高さとして扱い、フォントの cap height(OS/2 の sCapHeight、無ければ 0.7 em)で em の大きさに直す(そのために
sfnt・fontdb・fontsetに cap height を足した)。SHX フォント(txt.shx、romans.shx など)は既定のゴシック体(sans-serif)で、ビッグフォント(extfont2.shx など)は MS ゴシック相当の和文フォントで代用し、TrueType のフォントは拡張データのファミリー名か、ファイル名(msgothic.ttc → MS Gothic など)から選ぶ。TEXT は幅係数、斜体の角度、回転、鏡像(生成フラグ)と揃え(無いコードは既定値の 1、0、0 で、文字スタイルの値ではない。スタイルの値は CAD で新しく書く文字の既定値にすぎない)(左・中央・右・中心(Middle)・両端揃え(Aligned。高さが変わる)・フィット(Fit。幅が変わる)と、ベースライン・下・中・上)を解決し、%%d・%%p・%%c・%%nnnと、下線・上線・取り消し線を切り替える%%u・%%o・%%kを扱う。MTEXT は書式コード(\P、\L、\O、\K、\f、\H、\W、\Q、\T、\C、\c(青・緑・赤の順)、\Sの分数(/、^、#)、\pの揃えとインデント、{}、^Jなどのキャレット記法)を読み、参照矩形の幅で折り返し(和文の間ではlinebreakの禁則で、それ以外は空白で)、行送りを大文字の高さの 5/3 倍に行間係数を掛けたものとし、9 つの基準点と背景マスク(90、45)を扱う。幅係数は変換行列ではなく FILL_TEXT のadvance(読み手が字形をそれに合わせて伸縮する)で表し、変換行列には回転・斜体・鏡像だけを入れる。変換行列に横方向の伸縮があると、テキスト抽出が run の位置(変換後)と字送り(変換前)を比べて、run の間に空白を推定してしまうからである。 - ハッチング: 境界パス(ポリラインか、線分・円弧・楕円弧・スプラインの辺。時計回りの円弧と楕円弧は角度を反転して保存されている)を 1 つのパスにまとめて偶奇規則で塗る。スタイル 1 と 2(外側だけ、島を無視)は外側の境界だけを使う。模様は、HATCH に回転と尺度を済ませた形で入っている線の族(角度、基点、次の線への変位、破線)を境界の外接矩形を覆う本数だけ引き、境界でクリップする(
cad.Drawing.Hatch)。各線の破線の開始位置を基点から周期の整数倍の点に揃えるので、1 つの族を 1 つのパスで描ける。2 万本を超える模様は、色を薄くした塗りで代える(警告を出す)。グラデーションは LINEAR を線形、CYLINDER を中央が 2 色目の線形、SPHERICAL・HEMISPHERICAL・CURVED を放射状で近似し、INV 付きは色を入れ替える。1 色のグラデーションは濃淡(462)で黒か白に寄せた色を 2 色目にする。 - 引出線: LEADER は寸法スタイルの矢印の大きさ(DIMASZ × DIMSCALE。拡張データ DSTYLE の上書きを含む)と矢印のブロック(閉じた塗り矢印、
_DOT、_OPEN、_ARCHTICKなど)で描き、スプラインの引出線は補間する。MULTILEADER はCONTEXT_DATA{・LEADER{・LEADER_LINE{の入れ子を追って引出線・ドッグレッグ・矢印を描き、文字は MTEXT として、ブロックは挿入として描く。 - 壊れたファイルへの備え: 値はそのまま信用しない。角度は 1 周に収まるように直し、無限大や NaN の角度の図形は描かない。自分自身を(間接的にでも)挿入するブロックは内側の挿入を描かず、MINSERT の列と行は 32767 で、HATCH の個数(ノット、模様の線、破線、元の図形)はデータが尽きたところで打ち切る。1 つの View に描く図形はブロックの中身も含めて 500 万個まで、その点は 1000 万個までとする(長い折れ線やハッチングを持つブロックを配列で挿入すると、図形の数は少なくても点は多くなる)。ハッチングの模様の基点が境界から遠く、線の番号が 10^15 を超える模様の線は描かない(番号を 1 ずつ数えられない)。MLINE の要素の数(73)は頂点が持つパラメータの数まで、ポリゴンメッシュの M と N(71、72)は続く頂点の数までとする。AutoCAD 2010 より前のスプラインの辺はフィット点の数(97)を持たないので、後ろに続く 97 は、フィット点(11)が続くときだけフィット点の数として読む(境界の元の図形の数と取り違えない)。
- 未対応(警告を出す): DWG、ACIS の立体・領域・面(3DSOLID、REGION、BODY、SURFACE)、ラスター画像(IMAGE は図面の外のファイルを参照する)、OLE オブジェクト、上から見ていないビューポート、縦書きの MTEXT(横書きで描く)、MPOLYGON、TOLERANCE などのここに挙げていない図形、線種の中の文字と図形。
テスト用の図面は test/dxf/gen.py が ezdxf で作る(npm run test:dxf:gen、要 ezdxf。ezdxf の固定のメタデータで書くので毎回同じファイルになる)。shapes.dxf は各種の図形・文字・寸法・ハッチング・ブロックを、layout.dxf は平面図と A3 のレイアウト(表題欄と縮尺の違う 2 つのビューポート、片方で凍結した画層)を、r12-sjis.dxf は Shift_JIS の R12 図面を持つ。shapes-bin.dxf は shapes.dxf のバイナリ形式で、Go のテストは両者が同じ Object になることを確かめる。変換結果は testdata/dxf/ に置く(フォントは PowerPoint と同じ M PLUS 1p のサブセット)。開発中は、公開されている AutoCAD 2004 形式の図面(Shift_JIS の日本語、寸法、ブロック、マルチ引出線を含む)でも変換を確かめた。
3.13 Jw_cad → BDF 変換器(converter/jww)の構造
converter/jww は Jw_cad の図面ファイル(.jww)を読む。形式は作者が公開している「Jw_cad のデータ形式」(Jw_cad 7.02 の説明、jwdatafmt.txt)による。DOS 版の .jwc と、図形ファイルの .jws・.jwk は読まない。描くのは DXF と同じ converter/internal/cad である。
- 読み込み: .jww は MFC の CArchive でシリアライズしたもので、数値はリトルエンディアン、文字列は長さを前に置いた Shift_JIS(長さ 0xFF の後に 16 bit の長さ、その 0xFFFF の後に 32 bit の長さ。0xFFFE は UTF-16 の印)である。ヘッダーは仕様書の順に読み、後の版で増えた項目(Ver.2.23、2.25、2.30、3.00、4.20 以降など)はその版以降のファイルでだけ読む。図形データとブロック定義は CObList で、要素はクラスのタグ(初出は 0xFFFF・スキーマ・クラス名、以後は 0x8000 と番号)の後に続き、クラスとオブジェクトが同じ番号の列を共有する(MFC TN002)。寸法の中の線と文字のように Serialize を直接呼んだメンバーはタグを持たない。ブロック定義の CTime は Jw_cad のビルドによって 4 バイトか 8 バイトなので、両方を試す。読めない図形のクラスがあったり途中で切れていたりするファイルは、読めたところまでを描いて警告する。
- 座標とページ: JWW の座標は用紙上の mm(図寸)で、原点は用紙の中心、y が上向きである。レイヤグループの縮尺は寸法値(ファイルには文字として入っている)にしか効かないので、描画には使わない。ページは図面サイズ(A0〜A4、2A〜5A、10m・50m・100m)の用紙で、用紙の外に図形があればそれを含むまで広げる。文書の言語は
ja、ファイルメモは Dublin Core の description にする。 - 色と線: 線色 1〜9(9 はグレー)と SXF の拡張線色(100 番以降)の色を、既定ではファイルに保存された画面の表示色で、画面の背景色の上に描く(Jw_cad の画面の見え方。
-param colors=printでプリンタ出力色を白地に、colors=monoで白地に黒で描く)。線幅はプリンタ出力の線幅(線色ごと。Ver.3.51 以降は図形ごとの線幅が優先)で、「線幅を 1/100 mm 単位とする」設定なら 1/100 mm、そうでなければ 300 dpi(Ver.6.00 以降のファイルが 600 dpi と言っていれば 600 dpi)のドットとする。線種 2〜9 と倍長線種は、1 ユニットのビット数だけのビットパターンの 1 ビットをプリンタ出力ピッチのドットとして破線に直し、SXF の拡張線種は線分と空白の長さ(mm)をそのまま使う。ランダム線は直線で描く。補助線色と補助線種の図形、仮点、非表示のレイヤグループとレイヤの図形は描かない(Jw_cad も印刷しない)。 - 図形: 線、円・円弧(扁平率と傾き角の楕円を含む)、実点(プリンタ出力の点半径)、文字、寸法(線と値の文字、Ver.4.20 以降の補助線と端点)、ソリッド(四角形、任意色、円・扇形・弓形・円環・円周のソリッド)、ブロック(基準点・倍率・回転角で、入れ子も)を描く。
- 文字: Jw_cad の文字は固定ピッチで、全角の文字(Shift_JIS で 2 バイトの文字)は文字の幅、半角の文字はその半分の幅を取り、文字間隔を足したものが文字列の長さになる(ファイルに入っている終点もそう計算されている)。これを
cad.Textのセル(文字ごとの送り幅)と字間で表し、フォントが同じ文字の並びごとにそのセルの幅へ伸縮して描く(FILL_TEXT のadvanceと TEXT_STYLE の letterSpacing)。1 文字ずつ位置を決めて描くと、テキスト抽出が字形とセルの隙間を空白と推定してしまうので、並びごとに 1 つの run にする。文字の大きさの縦を em にし、基点は文字の枠の左下(ベースラインは 0.14 em 上)とする。斜体と太字は文字種に足された 10000 と 20000 から、フォントは名前(全角の「MS ゴシック」なども NFKC で正規化して)から選ぶ。縦字は、文字の枠を基点の周りに時計回りに 90° 回したものとして、文字を 1 字ずつ立てて子 Object に描き、ALT_TEXT と USE_AT で 1 つの run にする(Office の縦書きと同じ方法、§3.4)。 - 未対応(警告を出す): 画像(「^@BM」で始まる文字列と、Ver.7.00 以降のファイルに同梱された画像)、点コードの付いた点(矢印・ポイントマーカー。点として描く)、ランダム線の形、2.5D、.jwc・.jws・.jwk。
テスト用の図面は test/jww/gen.py が仕様書どおりに CArchive を書いて作る(npm run test:jww:gen、標準ライブラリのみ)。shapes.jww は Jw_cad 7 の形式(データ版数 700。SXF の拡張線色・線種、図形ごとの線幅、8 バイトの CTime)で、各種の図形・線色・線種・文字・寸法・ソリッド・入れ子のブロックと、非表示のレイヤグループとレイヤ、補助線を持つ。old.jww は Jw_cad 3 の形式(版数 300、4 バイトの CTime)である。変換結果は testdata/jww/ に置く。開発中は、公開されている JWW の図面(版数 220 で書かれたもの)でも変換を確かめ、同じ図面の DXF 版と図形と文字の位置が合うことを見た。
3.14 SXF → BDF 変換器(converter/sxf)の構造
converter/sxf は電子納品の CAD データ交換標準 SXF(Ver.2〜Ver.3.1)の図面を読む。仕様書は電子納品に関する要領・基準の Web サイト(cals-ed.go.jp)で公開されている「SXF Ver.3.1 仕様書・同解説」(フィーチャ仕様編、附属書 SFC 編、STEP AP202 サブセット編、共通既定義要素編)による。SXF には 2 つの書き方があり、納品に使う STEP AP202 の Part 21 ファイル(.p21。ZIP 圧縮したものが .p2z)と、CAD 間の受け渡しに使うフィーチャコメントのファイル(.sfc)である。どちらも読み、同じ converter/internal/cad で描く。属性ファイル(.saf)と、図面に添える画像(.tif など)は読まない。
- SFC: STEP の DATA 節に、
/*SXFとSXF*/で囲んだコメントとしてフィーチャ(#10 = line_feature('1','8','1','1',…))が並ぶ。引数は'…'で囲んだ数値、\'…\'で囲んだ文字列(Shift_JIS)、'(…)'で囲んだ数値の並びである。複合曲線(composite_curve_org_feature)と複合図形(sfig_org_feature)と用紙(drawing_sheet_feature)は、その前の構造要素から後に書かれた要素をまとめたもので、ファイルの順から組み立てる。色・線種・線幅・文字フォント・レイヤは、既定義のもの(色 1〜16、線種 1〜15、線幅 1〜9)は番号で、ユーザ定義のものは定義の順の番号(色は 17、線種は 17、線幅は 11 から)で参照する。 - P21: ISO 10303-21 の書き方(単純と複合のエンティティインスタンス、文字列の
\X2\・\X4\・\X\・\S\の指示)を読み、DRAWING_SHEET_REVISIONの要素から描く。要素はSTYLED_ITEM(ANNOTATION_CURVE_OCCURRENCE・TEXT_OCCURRENCE・SYMBOL_OCCURRENCE・FILL_AREA_OCCURRENCE・SUBFIGURE_OCCURRENCE)で、スタイル(CURVE_STYLE・TEXT_STYLE・SYMBOL_STYLE・FILL_AREA_STYLE)と形(LINE・POLYLINE・CIRCLE・ELLIPSEとそのTRIMMED_CURVE、BEZIER_CURVE・B_SPLINE_CURVE_WITH_KNOTS・COMPOSITE_CURVE・CLOTHOID、TEXT_LITERAL_WITH_EXTENT、DEFINED_SYMBOL、ANNOTATION_FILL_AREA、MAPPED_ITEM)を持つ。寸法・引出し線・バルーンはDRAUGHTING_CALLOUTの中身を描く。非表示はINVISIBILITYが指す要素と、そこが指すレイヤ(PRESENTATION_LAYER_ASSIGNMENT)の要素である。 - SCADEC の書き方: SXF に対応した CAD の多くは、SXF の書き読みを OCF の SCADEC ライブラリで行う。SCADEC が書いた P21 ファイル(ヘッダーの FILE_NAME に SCADEC とある)は、同じ図面の SFC と比べると仕様書と違うところがあり、SCADEC が読み戻すとおりに読む。文字の配置点は、仕様書では文字列配置基点(名前の
$$SXF_topline leftなどが 9 つの基点のどれかを言う)だが、SCADEC は下段の基点なら文字の高さの半分だけ上に、中段なら半分、上段なら高さだけ下にずらして書く。引出し線の矢印の向きは、引出し線から離れる向きではなく引出し線に向かう向きに書くので、引出し線の端から決める。角度寸法の円弧は、仕様書では反時計回り(.T.)に固定だが.F.と書くので、反時計回りに描く。SCADEC 以外が書いたファイルは仕様書のとおりに読む。 - 座標とページ: 座標は用紙上の mm で、y が上向きである。ページは用紙(A0〜A4 の縦横と、自由な大きさ)で、用紙の外に図形があればそれを含むまで広げる。背景色は、図面が属性(
$$ATRU$$…$$背景色$$色$$R_G_Bという名前の空のグループ)で言っていればその色、なければ SXF のビューアと同じ黒にする(-param background=lightで白地に。白の線と文字は黒で描く)。背景色のグループ自体は描かない。文書の言語はja、図面名(SFC の図面表題、P21 のDRAUGHTING_TITLE)はタイトルにする。 - 色と線: 既定義の色は SXF の表の RGB、線種は SXF の表のピッチ(線幅 0.5 mm のときの長さで、線幅に比例させる)、線幅は mm のとおりに描く。ユーザ定義の線種は線分と空白の長さのとおりである。
- 図形: 点マーカ、線分、折線、円、円弧、楕円、楕円弧、スプライン(3 次のベジェ曲線をつないだもの)、クロソイド(数値積分で点列にする)、文字、複合図形(配置点・回転角・尺度で。入れ子も、測地座標系の部分図は x と y を入れ替えて)、直線寸法・角度寸法・弧長寸法・半径寸法・直径寸法(寸法線、補助線、矢印 11 種、寸法値)、引出し線とバルーン、塗りつぶし(色、ハッチング 4 本まで、穴も)、背景色で塗る
Area_controlを描く。自分自身を配置する複合図形は 1 度だけ描く(警告を出す)。 - 文字: SXF の文字は、文字範囲の幅と高さ(全角の文字が半角の 2 倍の幅を取る固定ピッチ)の枠を、9 つの配置基点のどれかで置いたもので、JWW と同じく
cad.Textのセルと字間で表す。文字範囲の高さを em にし、ベースラインは枠の下から 0.12 em 上とする。回転角、スラント角、縦書き(文字を 1 字ずつ立てて子 Object に描く、§3.13)にも従う。 - 壊れたファイルへの備え: 描く要素は複合図形の中身も含めて 500 万個、その点は 1000 万個、たどる要素は 2000 万個まで。複合曲線は 100 万点、塗りの輪郭と様式は 1000 個までとする。塗りの輪郭は同じ曲線を何度でも名指しできるので、輪郭をつなぐ間も点を数え、残りの予算に収まらない塗りは組み立てない。
- 未対応(警告を出す): 既定義シンボル(
externally_defined_symbol)、既定義ハッチング(Area_controlのほかの名前のもの)、パターンのハッチング(fill_area_style_tiles)、画像。
テスト用の図面は test/sxf/gen.py が作る(npm run test:sxf:gen、標準ライブラリのみ)。1 つの図面を SFC(shapes.sfc)と、SCADEC の書き方をまねた P21(shapes.p21)と、それを ZIP にした P2Z(shapes.p2z)で書き、既定義の線種・色・線幅とユーザ定義のもの、各種の図形、9 つの配置基点・回転・スラント・字間・縦書きの文字、複合図形(回転と尺度、入れ子、測地座標系)、寸法・引出し線・バルーン、塗りつぶし・穴のあるハッチング・Area_control、非表示のレイヤ、背景色の属性を持つ。Go のテストは SFC と P21 が同じ cad.Drawing になることを確かめる。変換結果は testdata/sxf/ に置く。開発中は、国土交通省の CAD 製図基準に載っている図面作成例(SFC と P21 の両方がある縦断図、標準横断図、平面図、中間対傾構図)でも変換を確かめ、SFC と P21 で文字と線が同じ位置に描かれることを見た。
3.15 TIFF → BDF 変換器(converter/tiff)の構造
converter/tiff は TIFF のページごとに fixed View のページを作り、解像度から決まる大きさの画像 1 枚で描く。多ページの TIFF の多くはスキャナの出力と FAX の受信ファイルで、中身は画像だけ(テキストはない)。読み取りは converter/internal/tiff に自前で書いた。golang.org/x/image/tiff は先頭の IFD しか読まず、JPEG 圧縮も YCbCr も読まない。FAX ソフトの既定である G3 の 2 次元符号と詰め物ビットも読めず、BlackIsZero の CCITT データは白黒を反転して読む。Office 文書と Visio 図面の中の TIFF の画像も、同じ読み取りで先頭のページを読む(tiff.Picture。Orientation タグは掛けず、解像度の上限も掛けない)。
- ページ: IFD のチェーンを先頭からたどり、NewSubfileType が縮小版(bit 0)かマスク(bit 2)のものを除いた IFD をページにする。SubIFDs と PageNumber タグは見ない。チェーンが途中で壊れていれば、そこまでのページで変換して警告する。循環するチェーンと、ファイルの外を指すオフセットは検出する。DNG は変換を断り、Canon の CR2 は形式判別で除く。
- 大きさ: 画素数を XResolution と YResolution で割って pt にする。単位が cm なら換算する。単位なし(ResolutionUnit 1)の値は、50 以上なら dpi とみなし(ImageMagick は 300dpi のスキャンをこう書く。tiff2pdf と同じ扱い)、50 未満なら画素の縦横比だけに使う。解像度のないページは 96dpi とする(CSS の 1px が 1 画素になる。
-param dpi=で変える)。204 × 98 dpi の FAX のように縦横で解像度が違うページも、IMAGE の幅と高さで伸ばして描くだけでよい。Orientation タグ(2〜8)は画素を並べ替えず、画像を描く前の TRANSFORM にする(5〜8 ではページの幅と高さを入れ替える)。 - 解像度の上限: ページの画素が
imgconv.Optionsの MaxDPI と MaxPixels の両方を超えないよう縮小する(FitSize)。既定の MaxDPI は 192 で、CSS の 96dpi の 2 倍にあたり、Retina の画面で実寸表示したとき 1 画素が 1 デバイス画素になる。既定の MaxPixels は 3840 × 3840(4K 画面の幅の正方形、RGBA で約 56MB)。dpi は軸ごとに、画素数は縦横同じ比率で抑え、拡大はしない。A4 のカラースキャンを 300dpi から 200dpi にすると、WebP は 373KB から 203KB に、ブラウザでデコードした後のメモリは 35MB から 15MB になる(写真とスキャンのノイズを含むページでの実測)。上限はラスター画像の入力に共通の設定で、CLI では-max-dpiと-max-pixelsで変え、0 で上限をなくす。 - 縮小: 面積平均(box フィルタ)で縮小する。目標の画素が覆う元画素の重なりを 1/m 画素単位で数えると整数になるので、重み付きの和を整数のまま計算し、最後に 1 回だけ丸める。このため出力はアーキテクチャによらず同じバイト列になる。二値の画像はグレーに縮小すると PNG が 4〜5 倍になるので、二値のまま縮小する。インク(少ない方の色)が 2/5 以上覆う画素をインクにする(1/2 では細い線が切れやすく、1/3 では文字が太る)。
- 格納: 縮小しない JPEG のページは、ストリップを再エンコードせずに 1 本の JPEG につなぐ(
IFD.JPEG)。TIFF の JPEG はストリップごとに独立した JPEG で、量子化表とハフマン表は JPEGTables タグにある。各ストリップは DC 予測を新しく始め、バイト境界で終わるので、リスタート区間と同じ形をしている。そこで、表を戻し、SOF の高さをページの高さに書き換え、DRI でリスタート区間をストリップ 1 本分にして、ストリップの間に RST マーカーを入れる。これで 1 本のベースライン JPEG になる(tiff2pdf と同じ方法)。そのためにはストリップの行数が MCU の高さの倍数でなければならない。RGB のページは JFIF マーカーを除き、成分名が R・G・B でなければ Adobe マーカー(transform 0)を足して、YCbCr として読まれないようにする。つなげないページ(タイル、表の違うストリップ、プログレッシブ)と縮小するページはデコードし、imgconv.EncodePixelsで格納する(§3.2)。 - デコーダ: classic TIFF と BigTIFF、ストリップとタイル、chunky と planar を読む。圧縮は無圧縮・PackBits・LZW(
x/image/tiff/lzw)・Deflate・JPEG・CCITT で、predictor 2 と FillOrder 2 も扱う。画素は 1〜16 ビットの二値・グレー・パレット・RGB・CMYK と alpha(associated / unassociated)を読む。libtiff は CMYK の JPEG を Adobe マーカーなしで書くので、マーカーを足して読み、Go のデコーダが行う反転を戻す。CMYK は色管理をせずに RGB にし、警告する。デコード後に 1GiB を超えるページは読まない。壊れたストリップは空白にしてページを変換し、警告する。 - CCITT: デコーダを自前で書いた(符号表は x/image/ccitt のもの)。T.4 の 1 次元と 2 次元(EOL の前の詰め物ビットの有無を問わない)、T.6(G4)、Modified Huffman(圧縮 2)を読む。EOL は 11 個以上の 0 に続く 1 として探すので、詰め物の有無を区別しなくてよい。T.4 で壊れた行は白のまま残し、次の EOL から続ける(FAX の受信機と同じ)。黒の符号は 1 のビットにし、Photometric で解釈する(libtiff と同じで、WhiteIsZero なら 1 が黒)。
- 壊れたファイルへの備え: ページは、言っている大きさが持っている画素データに見合うときだけデコードする。データ 1 バイトがデコード後の 2048 バイトまで(deflate は 1032、LZW は 1362 まで届く。FAX のデータは、1 行が 1 ビットで済むので行のビット数がそれより多ければそちら)に 64 KiB を足した分までで、その中に収まる途中で切れたページは、今までどおり欠けた部分を空白にして変換する。126 バイトのファイルが 32768 × 32768 画素だと言っても、2 GiB を確保することはない。1 つのファイルから読むのは大きさの 8 倍と 1 MiB、デコードするのは大きさの 2048 倍までで、データを共有するページやストリップ(同じ位置を指すストリップを何万も並べたもの)はこれに当たり、その後のページは 1 度の警告とともに除く。タグの値は必要な数だけ読み(1 つの値を読むのに、ファイルと同じ長さの配列を読まない)、64 KiB を超える JPEGTables は読まない。Office 文書と Visio 図面の中の TIFF(
tiff.Picture)はimgconv.MaxDecodePixels画素まで。FAX の壊れた行の後の EOL の探索は、0 の並びを 1 度ずつしか見ない(0 だけのストリップで長さの 2 乗かかっていた)。 - メタデータ: 先頭のページの DocumentName を
title、ImageDescription をdescription、Artist(;で分ける)をcreator、Copyright をrights、DateTime をmodifiedにする(DocumentName 以外は XMP の対応と同じ)。 - テキスト: ページは画像だけなので、テキスト索引を作らず、検索と選択の対象もない(OCR はしない)。
テスト用のファイルは test/tiff/gen.sh が ImageMagick と libtiff のツールで作る(npm run test:tiff:gen)。converter/internal/tiff/testdata には、同じ絵を格納方法を変えて各ページに入れたファイルと、ImageMagick が libtiff で読んだ参照 PNG を置く。可逆のページはすべて参照と画素単位で一致する。converter/tiff/testdata にはスキャン、FAX、8 通りの向きのファイルを置き、変換結果を testdata/tiff/ に置く。向きは Go のテストでも確かめる。保存された画像をページの行列で紙面に写し、1 ページ目と比べる。
3.16 HTML・Markdown → BDF 変換器(converter/html、converter/markdown)の構造
ブラウザのリーダー表示と同じく、ページのデザイン(作者の CSS)は捨てて内容だけを固定のスタイルシートで組む。一般の CSS を解釈しないので、必要なのは HTML の要素を段落・表・画像に対応づける読み手と、それを組むレイアウトエンジンだけになる。BDF は再レイアウトしないので、出力は決まった幅(既定は本文の 36 字分、432 pt)の scroll View で、-param views=pages で A4 のページ、both で両方を作る。
- エンジンの共有: Word の変換器(§3.9)のレイアウトエンジンを
converter/internal/wordprocに移し、WordprocessingML の読み手と HTML の読み手(html.go)が同じモデル(字幅を測った item の段落、表)を作る。HTML は CSS の規則で組むので、エンジンにcssの指定を足した: 隣り合うマージンの相殺(Word は足し算)、領域(段、ページ、セル)の先頭のマージンを落とす、行の高さを line-height × 行の最大のフォントサイズにして余りを上下に等分、表と箱の前後のマージン、hr の rule ブロック、行より広ければ行幅に縮む画像(inlineObj.fit)、描画関数を持つインラインオブジェクト(画像、チェックボックス)。Word の文書はこれらを使わないので、docx の出力はバイト単位で変わらない(testdata と、システムフォントでの変換結果で確認した)。行の途中の改行(<br>、w:br)の直後の空白が前の行に取り込まれて次の行の字下げが消える不具合は、Word 側でも直した。 - 要素の対応: 段落と見出し(HEADING、次の段落と離さない)、リスト(ol の
start・type・reversed・li のvalue、ul の記号は入れ子の深さで •・◦・▪、チェックボックスで始まる項目は記号の代わりにチェックボックスを描く。LIST / LIST_ITEM)、引用(左に線のある箱、文字を淡く)、pre(網掛けの箱。空白を保ち、タブは 4 桁ごと、長い行は折り返す)、表、figure と figcaption(中央寄せ。alt 属性のない img は図の説明を代替テキストにする)、hr、dl、details / summary(開いた状態で描く)、インライン要素(強調、コード、kbd、mark、sup / sub、del / ins、small、q、br、wbr)、リンク、lang属性(LANG)。hidden属性、display: none、script・style・template・フォーム部品(チェックボックスを除く)・iframe・動画・音声は描かない。作者のスタイルで読むのはtext-align(align属性)、float、画像のwidth/heightだけ。 - 空白: CSS の
white-space: normalと同じく連続する空白を 1 つにし、段落の先頭と末尾の空白を落とす。ソースの改行が和文の文字の間にあるときは空白にしない(CSS Text の segment break transformation。日本語の Markdown に多い書き方)。pre では空白と改行を保つ。 - 箱と表: 引用と pre は 1 行 1 セルの構造を持たない表として作る。表と同じくセルの中身を先にレイアウトしてから行としてページに流すので、ページをまたぐ引用やコードは行の間で分かれる。HTML の表は行と列の結合をスロットに割り当てて Word の表のモデル(縦の結合は continue のセル)にし、列の幅は CSS の自動レイアウトで決める: 各セルの中身の最小幅(折り返せない最も長い部分)と最大幅(折り返さない行の幅)を列ごとに集め(結合セルは足りない分を均等に配る)、表の幅に最大幅が収まれば最大幅、最小幅も収まらなければ最小幅を縮め、その間なら余りを(最大幅 − 最小幅)に比例して配る。thead の行と、先頭で th だけの行は見出し行としてページごとに繰り返し、th は列見出し、ほかの行の th は行見出しにする。
- 画像: ファイルの隣(
ConvertFileは入力のディレクトリを基準にする。..とシンボリックリンクを含めて入力ディレクトリ内に制限し、絶対パスは読まない)、MHTML の部品、data: URL、ネットワークから読む。ネットワークの画像は既定で取得する(Markdown や HTML は Word と違って画像を抱えていないのが普通なので)。8 並列で先に取得し、30 秒のタイムアウトと 50 MiB の上限を付ける。-param remote=falseで取らない。取得先は公開アドレスに限り、ホスト自身・プライベートネットワーク・リンクローカル(クラウドのメタデータサービスが答える)のアドレスは、名前解決やリダイレクトの先であっても接続しない(-param private=true/Options.AllowPrivateで許可。環境変数でプロキシが指定されているときはプロキシに任せ、検査しない。TinyGo ではブラウザの規則に任せる)。信頼しない入力を変換するサーバーは、remote=false にするか、Options.Fetchで到達先を制限する。大きさは CSS ピクセル(0.75 pt)で、width / height 属性と style に従い、行より広ければ行幅に縮める。align="left"/"right"と float は Word の浮動する図と同じく文字を回り込ませる。描けない画像は、ブラウザと同じく代替テキストを淡い色で描く。SVG は描き直さずにそのまま格納し、ビューアが描く(§3.19。ビューアはページの画像要素で SVG を描き、Worker にはページが描いて渡す)。img が指す SVG ファイルはバイト列のまま、ページの中の svg 要素は独立した SVG 文書にする(名前空間を補い、currentColor のためにまわりの文字色をcolorに、ページのほかの場所から id で参照している SVG の要素=スプライトの symbol や共有のグラデーションを defs に写し、image 要素の画像を data: URL にする。画像として描かれる SVG 文書は外部の資源を読まないため。script と on… 属性、宣言のない接頭辞の属性(svg 要素に付いたepub:typeなど)、ほかの語彙の要素(Inkscape のsodipodi:namedviewのようなエディタのメタデータ)は、文書が XML として壊れるので落とす。組み立てはconverter/internal/webdocのSVGDocumentで、EPUB の固定レイアウトのページも使う(§3.24))。大きさは Chrome で確かめたブラウザの規則に従う。幅と高さ(属性か style。片方なら viewBox の縦横比でもう片方)、SVG ファイルは自身の width・height(同様)、どれもなく viewBox だけなら行の幅いっぱい(inlineObj.fill。行ごとに行幅へ拡大縮小する)、何もなければ 300 × 150 px。幅か高さが 0 の svg 要素(スプライトの入れ物)と、定義だけで何も描かない svg 要素は描かない。代替テキストは aria-label、title 子要素、図の見出しの順で、aria-hidden と role="presentation" は装飾。 - フォント: 既定では埋め込まずに名前で参照する。本文は
sans-serif、コードはインストールされている等幅のファミリー(なければmonospace)で、変換時に解決したフォントで字幅を測り、ビューアのフォントをその字幅に合わせて描く(advance 補正、§4)。Web ページと同じくビューアのフォントで読めればよく、埋め込むとファイルが大きくなるため。-fonts embed(Options.EmbedFonts)でサブセットを埋め込む。システムフォントの参照では、sans-serifなどの総称ファミリーを引用符で囲まないようにした(Office の文書では現れない)。 - HTML: 文字コードは BOM、Content-Type、meta 要素の順で決め、どれもなくて UTF-8 として正しければ UTF-8(Shift_JIS や EUC-JP のページも meta で読める)。XML 宣言で始まる文書と Content-Type が XHTML の文書は XML として読み、整形式でなければ HTML として読む(
converter/internal/webdoc、§3.24)。HTML の構文解析はencoding/htmlroで行う: golang.org/x/net/html のトークナイザと木構築(HTML5 の tree construction)を関数単位で移植し、トークナイザだけを xmlro と同じ方針のゼロコピーの読み手(名前・属性・テキストは自分のバッファへのスライス、定常状態で確保なし)に置き換えたもので、作る木は x/net のhtml.Nodeそのものなので go-readability と組版の読み手はそのまま使える。x/net/html は v0.55 の脆弱性修正以降、属性 1 つにつき 7〜10 回のヒープ確保をし(重複属性の検出用の複製と文字列、NUL 置換のための無条件の複製)、HTML 変換の確保オブジェクトの 45% を占めていた。htmlro では実ページ 133 件の構文解析が 167 万回から 69 万回の確保、114 MB から 92 MB になる。html5lib の木構築スイート、x/net のトークナイザ用例と html5lib のトークナイザ入力の x/net との突き合わせ、実ページ 267 件の木と Render の一致、x/net との差分を探す fuzz で、木が同じであることを確かめている(encoding/htmlro/README.md)。同じ役割のコードを二重にリンクしないよう、HTML を読むほかの場所も htmlro に寄せた: 数式の MathML(formula.ParseMathML)、draw.io のラベルのエンティティ復号(htmlro.UnescapeString。標準ライブラリのhtml.UnescapeStringの表も要らなくなる)、文字コード検出の meta 要素の走査(webdoc.DetermineEncoding。x/net のcharset.DetermineEncodingは内部で x/net のトークナイザを使うので、同じ手順を htmlro の読み手で移植し、x/net のcharsetはLookupだけを使う)。これで x/net/html から残るのはhtml.Node・atom・Renderだけになる。記事の取り出しは go-readability(Mozilla Readability.js の移植、codeberg.org/readeck/go-readability/v2)で、-param extract=auto(既定)は Readability の isProbablyReaderable が真のページだけ、articleは常に、noneはしない。取り出したときはリーダー表示と同じく、題名(h1)、署名・サイト名・公開日の行、区切り線を先頭に置く。メタデータは title、meta(author、description、keywords、Open Graph、article:published_time など、DC.*/dcterms.*)、canonical のリンク、html の lang から読み、記事を取り出したときは Readability の結果(サイト名を除いた題名など)を優先する。リンクは#idを要素のある帯(ページ)への#page=Nにし、ほかは基準 URL(-param base、<base>、MHTML の保存元)で解決した http / https / mailto の絶対 URL だけを残す。MHTML(Chrome の「ウェブページ、1 つのファイルのみ」)は multipart/related の部品を読み、Content-Location とcid:で引く。 - Markdown: goldmark で HTML にし(CommonMark、GitHub の拡張の表・タスクリスト・取り消し線・自動リンク、脚注、定義リスト)、HTML の変換器で組む(記事は取り出さない)。raw HTML はそのまま通すので、README によくある
<p align="center">や<details>も組める(スクリプトは実行しないので危険はない)。見出しには GitHub と同じ id(小文字にし、句読点を除き、空白をハイフンに、重複には -1、-2…)を付けるので、[…](#見出し)のリンクが帯に飛ぶ。YAML(---)/ TOML(+++)の front matter の title、author、date、lang、description、tags などはDC.*の meta 要素として head に書き、HTML と同じ読み方で Dublin Core にする。UTF-8 でない文書は Shift_JIS、EUC-JP、Windows-1252 の順に試す。 - 数式: MathML の math 要素は数式エンジン(§3.23)が組む。
display="block"は中央に置いた段落、それ以外は行の中。KaTeX、MathJax、Wikipedia が MathML の横に置く独自の描画は、記事を取り出す前に取り除く(§3.23)。Markdown では GitHub と同じ$…$、$$…$$、言語 math のコードブロックを LaTeX の数式にする。 - 形式の判別: HTML は、空白・コメント・XML 宣言の後に doctype か html / head 要素で始まる文書全体と、MHTML。
<p align="center">で始まる README のような断片は中身では HTML にしない。Markdown はどんなテキストでもありうるので中身からは判別せず、ほかの形式が中身で判別できない入力をファイルの拡張子(.md など、Options.FileName)で Markdown にする(.html の断片も拡張子で HTML になる)。 - 壊れたファイルへの備え: ネットワークの画像は、文書ごとに 256 枚、合わせて 256 MiB、取得の全体で 120 秒まで(1 枚 30 秒と 50 MiB は今までどおり)。描かない要素(
hidden、display: none)の画像は先に取得せず、svg 要素の中の image は先に取得する。上限を超えた画像は代替テキストにして 1 度警告する。警告と HTTP のエラーには、URL のユーザー名とパスワードを出さない。scroll View の高さは 1024 pt の帯 10 万本まで(height="1e300"の画像で、帯を切る処理が終わらなかった)、長さは 100 万 pt まで。XHTML の要素の入れ子は HTML と同じ 512 段までで、それより深い要素は再帰せずにたどる。表のセルの結合(rowspan・colspan)が取る場所は文書全体で 2^18 個まで(1,000 個のセルが下の行すべてにまたがると、行 1 つ 9 バイトで 1,000 個の場所が増える)。インラインの svg 要素から作る SVG 文書は、文書全体で 256 MiB まで(借りてくる要素と base64 にした画像が、svg 要素ごとに写される)。go-readability が名前を変えた見出しの要素(名前が h1〜h6 でないもの)はブロックとして読む。Markdown の見出しの id は、同じ見出しが続いても、前に付けた番号の次から探す(同じ見出し 2 万個で 64 秒かかっていた)。行の分割は、段落の残り全部ではなく、まず 128 個の項目の中で行の終わりを探し、見つからなければ倍にする(タブがあれば残り全部)。分割の位置は変わらず、260 KB のpreが 41.6 秒から 0.11 秒に、2 MB の HTML の変換が 67 秒から 5.7 秒になった。Word の変換でも同じである。 - 未対応: 作者の CSS(色、フォント、配置)、コードブロックの図(Mermaid などはコードとして描く)、ルビ(親文字だけ)、縦書き(
writing-mode)、ダークテーマ(色は命令列に焼き込まれる)、画面の幅に応じた再レイアウト(幅の違う scroll View を複数持ってビューアが選ぶ形なら、フォントと画像を共有したまま実現できる)。
テスト用の文書は converter/markdown/testdata/basic.md、SVG の converter/markdown/testdata/svg.md(ページ幅の svg 要素、SVG ファイル、スプライトの symbol)、数式の math.md と converter/html/testdata/article.html・math.html(画像は test/markdown/gen_images.py が標準ライブラリだけで書く PNG)で、変換結果は testdata/markdown/・testdata/html/ に置く。Word のテスト用フォントの字幅で組み、フォントは名前で参照し、ブラウザにあるフォントで描く。
3.17 Illustrator → BDF 変換器(converter/ai)
converter/ai は Illustrator 9 以降の .ai を読む。この .ai は PDF に Illustrator 独自のデータ(ページの /PieceInfo /Illustrator の下の非公開ストリーム。ネイティブ形式そのもので、新しい版は Zstandard で圧縮する)を添えたもので、「PDF 互換ファイルを作成」(既定でオン)で保存すると PDF 部分のページがアートボードになる。変換は PDF 部分を converter/pdf で描き、独自データは読まない。
- 判別: PDF としては、Illustrator から「Illustrator の編集機能を保持」で PDF に保存したファイル(同じ独自データを持つ)と区別がつかない。そこで .ai が先頭近くに書く XMP の
<illustrator:Type>Document</illustrator:Type>で見分ける(PDF に保存したものには無い)。無ければ PDF として変換する。Format.Refinesで PDF より先に判別する。 - アートボード = ページ: ページのメディアボックス(とブリードボックス)は文書の裁ち落としを含み、トリムボックスがアートボードそのもの。既定ではトリムボックスをページにする(
-param box=bleedで裁ち落としを含める)。ページの順は PDF のページの順(アートボードの順)。アートボードの名前は独自データにしかないので使わない(BDF のページは名前を持たない)。 - レイヤー: Illustrator のレイヤーは PDF のオプショナルコンテンツ(レイヤーごとの OCG と
/OC /MCn BDC)で、非表示のレイヤーは/D /OFFに入る。PDF 変換器がそれに従うので、非表示のレイヤーは描かれない(§3.1)。 - 変換しないもの: 「PDF 互換ファイルを作成」をオフにして保存したファイルの PDF 部分は、その旨を伝える 1 ページだけ(その文言で見分けて
ErrNoPDFContent)。Illustrator 8 以前の PostScript ベースの .ai(%!PS-Adobeと%%Creator: Adobe Illustrator)も PDF を持たない(ErrPostScript)。どちらも独自データを解釈しないと描けないので、エラーにして保存し直してもらう。
テスト用の .ai は test/ai/gen.py が Illustrator の書き方をまねた PDF を直接書いて作り(npm run test:ai:gen、標準ライブラリのみ)、変換結果は testdata/ai/ に置く。開発中は Illustrator CC 2015 と 2024 で保存された実ファイルで、非表示のレイヤーが消えることと、描画が Ghostscript のトリムボックスでの描画(-dUseTrimBox)と合うことを確かめた。
3.18 Photoshop → BDF 変換器(converter/psd)
converter/psd は Photoshop の文書(.psd と、大きな文書の形式 .psb)を読み、Photoshop が合成した画像をページの画像にする。レイヤーの構造は BDF の命令に写さない。見た目は合成済みの画像で決まっており、テキストやベクトルのレイヤーも Photoshop が描いたピクセルとして持っているからである。
- ページ: アートボードの無い文書はカンバス全体を 1 ページにする。大きさは解像度(画像リソース 1005)から求め、72 ppi なら 1 ピクセルが 1 pt。アートボード(レイヤーグループの追加情報
artb・artd・abddの記述子にあるartboardRect)のある文書は、表示されているアートボードごとに 1 ページにし、合成画像から切り出す。順はレイヤーパネルの下から(ファイルに記録された順で、ふつうは追加した順)。画像の入力に共通の解像度の上限(§3.15)より細かいページは、上限まで縮小する。非表示のアートボードは合成画像に描かれていないので飛ばす。-param artboards=falseでカンバス全体を 1 ページにする。 - 合成画像: 画像データ部(RAW、RLE、ZIP、予測付き ZIP)をチャンネルごとにデコードする。16 bit は 8 bit に、32 bit(リニア)は sRGB の曲線で 8 bit に、1 bit は白黒にする。CMYK(インクの量を反転して格納)はプロファイルを使わない単純な式で、Lab は D50 から sRGB に変換し、インデックスカラーはカラーテーブルと透明色のインデックス(画像リソース 1047)で、ダブルトーンとマルチチャンネルは最初のチャンネルをグレーとして描く。カラープロファイルは適用しない。レイヤー数が負のとき(16・32 bit では
Mt16・Mt32・Mtrnがあるとき)は最初の余分なチャンネルが合成画像の透明度で、色は白の上に合成して格納されているので元に戻す。 - 合成画像がないとき: 「互換性を優先」をオフにして保存したファイル(画像リソース 1057 の
hasRealMergedDataが 0)は合成画像を持たないので、変換器がレイヤーを合成し、警告を出す。描くのはピクセルレイヤー(テキストとスマートオブジェクトのレイヤーも描画済みのピクセルを持つ)とベタ塗りの塗りつぶしレイヤーで、不透明度、塗り、描画モード(W3C の合成とブレンド。ソフトライトは Photoshop の式)、レイヤーマスク(マスクチャンネルに描かれたベクトルマスクも)、クリッピングマスク、グループ(通過と分離)、アートボードの背景と切り抜きを扱う。調整レイヤー、レイヤー効果、グラデーションとパターンの塗りつぶし、マスクチャンネルに描かれていないベクトルマスクは描かず、警告する。 - 合成の解像度: 変換器が自分でレイヤーを合成するときは、ページを格納する解像度で合成する。まず、変換するページ(カンバス、または選ばれたアートボード)がそれぞれ
MaxDPI(既定 192)とMaxPixels(既定 3840 × 3840)の下でどの大きさで格納されるかをFitSizeで求め、軸ごとに、縮小の最も少ないページに合わせてカンバスを縮小した大きさで合成する。上限内の文書は今までどおりカンバスの大きさで合成し、出力は変わらない。カンバス・グループ・塗りつぶしレイヤーのバッファは縮小した大きさで持つので、A1 を 300 dpi にした文書(約 7000 × 9900 画素)も、合成のメモリは上限の側で決まる(-max-dpiと-max-pixelsで変えられる)。レイヤーの画素とマスクは格納されたままの大きさでデコードし、ブレンドする前に面積平均で縮小後の位置に置く(整数の計算で、結果はどのプラットフォームでも同じ)。矩形(レイヤー、マスク、アートボードとその背景、ページ)は辺ごとに、縮小後のキャンバスの最も近い画素の境界に置くので、文書の中で接しているものは隙間も重なりもなく接する。縮小後の 1 画素より細いレイヤーは、その画素に、細さの分だけ薄く描く。合成してから縮小した結果とは、縮小後の 1 画素にあたる画素の間で透明度・マスク・背景が違うところで差が出る(レイヤーやマスクの縁は平均されずに鋭く、通常以外の描画モードは平均をブレンドする)。ページはこの合成から切り出し、さらに小さく格納するページはそこで縮小する。ファイルに格納された合成画像は今までどおり使う。 - 壊れたファイルへの備え: ZIP と RLE のデータは、デコード後の大きさを確保する前に、持っているデータで足りるかを確かめる(deflate は 1 バイトが 1032 バイトまで、PackBits は 64 バイトまで)。合成の上限は縮小後のキャンバスに掛かる: キャンバスは 2^26 画素(バッファ 1 GiB)まで(超えるのは、解像度の上限を両方とも外したときなど)、同時に持つバッファ(キャンバスと、自分のバッファに描いているグループ)は合わせて 2^28 画素(4 GiB)まで、合成に使うバッファは文書全体で 2^32 画素まで。グループの入れ子は 16 段と、同時に持つバッファの上限が許す段数の小さい方まで(4000 × 3000 のキャンバスで 16 段、最大のキャンバスで 3 段)。レイヤーとマスクは格納された大きさで 2^26 画素まで。ページは合わせてカンバス 4 枚分の画素まで(重なったアートボードは、カンバスを何度も変換させる)。超えた分は警告して除く。
- メタデータ: XMP(画像リソース 1060)の Dublin Core(
dc:titleなど。作成・更新の日時はphotoshop:DateCreated・xmp:CreateDate・xmp:ModifyDateなどから W3CDTF に揃える)をmeta.dcにする。XMP の読み手は画像の変換器(§3.19)と共有するconverter/internal/xmp。
テスト用の文書は test/psd/gen.py が Photoshop の書き方で直接書いて作る(npm run test:psd:gen、標準ライブラリのみ)。合成画像はスクリプトの中の小さな合成器で求め、Go のテストは合成画像が無いことにしたときの変換器の合成をそれと比べる。開発中は Photoshop などで保存された実ファイル(CMYK、グループ、クリッピングマスク、スマートオブジェクト、82 レイヤーのスプライトシートなど)で、変換器の合成が Photoshop の合成画像と各サンプル 2/255 以内で一致することを確かめた。
3.19 画像 → BDF 変換器(converter/image)の構造
converter/image は、ブラウザが画像要素でそのまま表示できる画像(PNG・APNG、JPEG、GIF、WebP、AVIF、BMP、ICO、SVG)を、画像の大きさのページ 1 枚の文書にする。画像はパススルーで、デコードも再エンコードもせずに Image Part に格納し(-images convert でも変換しない)、ページは IMAGE 命令 1 つでそれを描く。ブラウザはファイルを開いたときと同じデコーダで描くので、見た目はブラウザでファイルを表示したときと同じになる。変換器が読むのは大きさとメタデータだけで、メタデータはほかの形式の文書のプロパティと同じく manifest の Dublin Core にする(spec §4.3)。こうすると、画像もほかの文書と同じ形(View・ページ・meta・図の代替テキスト)で扱え、ビューア・検索・一覧の側に画像専用の経路を作らずに済む。
- ページ: 画像の CSS px を 96 dpi として 1 px = 0.75 pt(draw.io と同じ)。命令は
MARK FIGURE(代替テキストは説明、なければ題名、なければファイル名)、SMOOTHING(high。縮小表示が画像要素と同じくらいきれいになる。SVG には付けない)、IMAGE、MARK END。テキスト索引は作らない。 - 大きさ: PNG は IHDR、JPEG は最初の SOF、GIF は論理画面、WebP は VP8X(なければ VP8・VP8L のフレームヘッダ)、BMP は DIB ヘッダ(負の高さはトップダウン)、ICO はブラウザが描く最大のエントリ、AVIF は HEIF の主アイテムの
ispe(irotが 90°・270° なら縦横を入れ替える。主アイテムのない画像シーケンスはトラックのtkhd)。SVG はルート要素のwidth・height・viewBoxから、画像要素での大きさの規則で決める(spec §6.2。mm・pt などの絶対単位は px に直す)。 - 向き: ブラウザは JPEG と PNG(
eXIf)の EXIF の Orientation を適用して描くが、WebP の EXIF の Orientation は適用しない(Chrome でcreateImageBitmapと画像要素の大きさを確かめた)。AVIF はirotを適用する。ページはブラウザが描く向きの大きさにし、WebP の向きは警告する。 - メタデータ: XMP、EXIF、IPTC、形式自身の情報の順に、要素ごとに先にあるものを使う(XMP は EXIF・IPTC と同期させて書かれることが多く、Unicode で多言語の値を持てるので先)。どこから何を読むかは spec §4.3 の表のとおり。
- XMP: JPEG の APP1、PNG の iTXt
XML:com.adobe.xmp、WebP のXMPチャンク、GIF のアプリケーション拡張XMP DataXMP、AVIF のmime(application/rdf+xml)アイテム。SVG のmetadata要素の RDF(Inkscape の文書のプロパティ。cc:Workのdc:*で、人はcc:Agentのdc:title)も同じ読み方をする。rdf:Altは既定の言語(x-default)の値を使う。XMP の読み手は Photoshop の変換器(§3.18)と共有するconverter/internal/xmpで、SVG の metadata もその RDF の読み方で読む。 - EXIF: JPEG の APP1、PNG の
eXIf、WebP のEXIFチャンク、AVIF のExifアイテム、ImageMagick のRaw profile type exif(16 進のテキスト)。ASCII の値は UTF-8 として正しければ UTF-8、そうでなければ Latin-1 として読み、Windows のタグ(XPTitleなど)は UTF-16LE。カメラが既定で書く説明(OLYMPUS DIGITAL CAMERAなど)は使わない。日付はOffsetTime*があれば時差を付ける。 - IPTC: JPEG の APP13(Photoshop の画像リソース 0x0404)。文字コードは記録 1 の CodedCharacterSet が UTF-8 なら UTF-8、そうでなければ UTF-8 として正しいかどうかで決める。
- 形式自身: PNG のテキストチャンク(
Title、Author、Description、Copyright、Creation Time、Comment。tEXt と zTXt は Latin-1、iTXt は UTF-8)とtIME、GIF のコメント、SVG のルートのtitle・desc要素とxml:lang。
- XMP: JPEG の APP1、PNG の iTXt
- 壊れたファイルへの備え: AVIF の Exif と XMP の項目は、合わせてファイルの大きさまで読む(項目の表は、ファイル全体を指す項目を何千も並べられる)。GIF の XMP のパケットは、その拡張ブロックの中だけで探す。SVG の内部サブセットの実体は、参照をすべて置き換えた文字列が 1 MiB を超えるなら置き換えずに警告する(50 KiB の実体を 1 万回参照する 90 KB の SVG が、650 MB の題名になっていた)。この上限は
xmltree.Openにあり、XML を読むほかの形式(draw.io、EPUB、MusicXML、XHTML、XMP)にも同じにかかる。 - アニメーション: APNG(
acTL)、アニメーション GIF・WebP、AVIF の画像シーケンスは、ブラウザがデコードする最初のフレームを描き、警告する。 - 判別: 署名(SVG はルート要素が
svgであること。XML 宣言・コメント・文書型宣言の後。8 MiB までのファイルは、ルート要素の後に空白・コメント以外が続かないことも確かめる。SVG の絵で始まる Markdown はルート要素の後に本文が続くので SVG ではない。ブラウザもそういう SVG ファイルは拒む)で判別する。PNG と SVG は draw.io の書き出しでもあるので、draw.io がRefines: "image"で先に判別する(§3.5)。 - 解像度の上限(§3.2、§3.15)は掛けない。画素は 96dpi でページに対応するので MaxDPI(192)には届かず、MaxPixels を超える大きな写真も、ブラウザがファイルを開いたときと同じくそのまま描く。縮小するには再エンコードが要り、パススルーでなくなる。デコードした画像のメモリはレンダラの予算(§5.1)で抑える。SVG のラスタは画像ごとに数と大きさを抑える(下記)。
- 格納: 画像の Part は圧縮済みなので
identityだが、SVG(テキスト)と BMP、BMP を含む ICO は deflate が効くので圧縮する(ほかの変換器が格納する画像も同じ)。
SVG の描画(@bdfkit/render): createImageBitmap は SVG の Blob をデコードしない(Chrome で Worker でもページでも失敗する。仕様上、Blob からはビットマップ画像だけ)。画像要素で読み込んだ SVG を Canvas に drawImage すればビットマップにできるが、Worker には画像要素がない。そこで ResourceCache は SVG を VectorImage として持ち、描くときに現在の変換から 1 画素が覆うデバイス画素を求めて、その大きさのラスタを要求する。ラスタはページ(BdfWorkerClient)が画像要素と OffscreenCanvas で描いて ImageBitmap を Worker に転送する。描画は同期なので、1 回目の描画で足りなかった大きさを集め、ページに描かせてから描き直す(ラスタがそろっていれば 1 回)。ラスタは必要な大きさの 1.25 倍までは使い回し、画像ごとに最近使った 4 つを残し、4096×4096 画素を上限にする。拡大してもぼけず、ズームを変えるたびに SVG をブラウザが描き直す。SVG は画像要素の中で描かれるので、スクリプトは動かず、外部の資源も読み込まれない(ブラウザで画像として開いたときと同じ)。これで draw.io や PowerPoint の文書に含まれる SVG の画像も描けるようになった(以前は createImageBitmap の失敗でそのページの描画が失敗していた)。
createImageBitmap(img, {resizeWidth, resizeHeight}) は大きさを持たない SVG(viewBox だけ)を描かないので、使わない。SVG をパスに変換して命令にする方法もあるが、SVG の描画系(CSS、テキスト、フィルタ、マスク)をもう 1 つ実装することになり、ブラウザが描けるものをそのまま渡すという狙いに反する。
テスト用の画像(converter/image/testdata/)は test/image/gen.sh(ImageMagick、exiftool、cwebp、avifenc)で作り、変換結果を testdata/image/ に置く。EXIF の向きや SVG の描画は、各変換器・レンダラの機能テストで確かめる。
3.20 CGM → BDF 変換器(converter/cgm)の構造
converter/cgm は ISO/IEC 8632 の Computer Graphics Metafile(CGM、バージョン 1〜4)を読む。CAD のプロッタ出力や、技術図(S1000D・ATA の図、WebCGM)の交換に使われる形式で、バイナリ符号化(Part 3)とクリアテキスト符号化(Part 4)の両方を読み、gzip で圧縮したもの(.cgz)も読む。文字符号化(Part 2)は 1999 年版で廃止されたので読まない。描くのは DXF・JWW・SXF と同じ converter/internal/cad である。
- 読み込み: バイナリは 16 bit の要素ヘッダ(クラス 4 bit、要素番号 7 bit、パラメータ長 5 bit。31 なら 15 bit の長さと続きの印を持つパーティションが続く)で要素に分け、パラメータは要素を解釈するときに、その時点の精度(整数・索引・色・色索引・名前の精度、実数の固定小数点と浮動小数点の 32/64 bit、VDC の型と精度)で読む。クリアテキストは要素名(
_と$は無視する)と、セミコロンかスラッシュまでのパラメータ(数値、基数付きの数値、引用符の文字列、列挙の語)に分ける。解釈器は 1 つで、パラメータの読み手を符号化ごとに差し替える。点の列を相対座標で書く INCR の要素も読む。SCALING MODE の縮尺は Part 3 では実数精度によらず浮動小数点だが、実数精度で書く書き手もあるので、もっともらしい値になる方の読み方をとる。METAFILE DEFAULTS REPLACEMENT の要素は、ピクチャの始めごとに既定値の後で適用する。 - ピクチャとページ: ピクチャごとに
fixedView の 1 ページにする。大きさは、SCALING MODE が metric なら VDC 範囲に縮尺(1 VDC 単位の mm)を掛けたもの、DEVICE VIEWPORT がミリメートルで指定されていればその大きさ、どちらでもない abstract のピクチャは長辺を 297 mm(A4 の長辺)に合わせる。VDC 範囲の最初の点が左下で、y が下向きの範囲(最初の点の y が大きい)もそのまま写す。背景は BACKGROUND COLOUR。-pagesはピクチャを選ぶ。ページの中は既定でクリップ矩形(VDC 範囲)で切られるので、ページより小さい CLIP RECTANGLE だけをクリップのグループにする。 - 属性: 線・マーカー・文字・塗り・縁の属性、束(LINE REPRESENTATION など)と ASPECT SOURCE FLAGS、索引色(COLOUR TABLE。設定されていない 0 は背景色、1 は黒)と直接色(COLOUR VALUE EXTENT で正規化する。CMYK も)、SAVE / RESTORE PRIMITIVE CONTEXT を扱う。線幅と大きさの指定モードは absolute(VDC)・scaled・fractional・mm で、scaled の基準は用紙上の 0.25 mm(線と縁)と 2.5 mm(マーカー)、fractional は VDC 範囲の長辺に対する割合とする。線種 2〜5(破線、点線、一点鎖線、二点鎖線)は、0.35 mm までの線では長さ 3 mm の線分の用紙上の模様とし、それより太い線では太さに比例させる。LINE AND EDGE TYPE DEFINITION の負の線種は、線分と空白の比を周期の長さに合わせる。端点と角の形(LINE CAP、LINE JOIN)の unspecified は、プロッタのペンと同じく丸にする。
- 図形: POLYLINE、DISJOINT POLYLINE、POLYMARKER(5 種)、POLYGON、POLYGON SET(見えない辺と穴)、RECTANGLE、CIRCLE、3 点の円弧と中心の円弧(逆回りも)とそれを扇形か弓形に閉じたもの、ELLIPSE と楕円弧(共役直径の端点で与えられるので、その 2 つのベクトルを軸とする媒介変数表示の弧をそのままベジェ曲線にする)、双曲線の弧(標本化)、放物線の弧(2 次のベジェ曲線)、POLYBEZIER、NON-UNIFORM B-SPLINE と NURBS(媒介変数の範囲が定義域全体なら DXF と同じ厳密なベジェ曲線に、一部なら標本化)を描く。閉じた図形(BEGIN FIGURE〜END FIGURE)は、途中の開いた図形をつないで 1 つの境界にし(NEW REGION で次の領域に移る)、偶奇規則で塗る。複合線(BEGIN COMPOUND LINE)は 1 本のパスとして引く。塗りは内部様式に従い、hollow(塗りの色の細線で境界を描く)、solid、hatch(6 つの標準のハッチング(水平、垂直、±45°、2 種の格子)を用紙上 2 mm 間隔で。HATCH STYLE DEFINITION の定義も)、pattern(PATTERN TABLE の画像を FILL REFERENCE POINT から PATTERN SIZE ごとに繰り返す)、interpolated(平行は線形、楕円は放射状のグラデーション)、empty を描き分ける。縁は EDGE VISIBILITY が on のときに描く。
- 文字: 文字の高さは CGM の定義どおりベースラインからキャップラインまでで、フォントの cap height で em の大きさに直す(DXF と同じ、§3.12)。CHARACTER ORIENTATION の上向きと基線の 2 つのベクトルの向きに文字を置き(直交しなければ文字が傾き、長さの比は文字の幅の比になる)、CHARACTER EXPANSION FACTOR、CHARACTER SPACING、TEXT PATH(右、左、上、下。上下は文字を立てた縦の列にする、§3.13)、TEXT ALIGNMENT(左・中央・右、上・キャップ・中央・ベースライン・下と、連続値)に従う。RESTRICTED TEXT は RESTRICTED TEXT TYPE に従って箱に収める(basic は大きすぎるときだけ縮め、boxed は縦横別に、isotropic は縦横同じ比で伸縮し、justified は字間で箱の幅を埋める)。APPEND TEXT は直前の TEXT の続きとして、色やフォントを変えて同じ行に描く。フォントは FONT LIST の名前から選ぶ。Helvetica・Times・Courier などの PostScript のフォントはその系統のフォントに、Hershey などの CGM のストロークフォントは既定のゴシック体に、明朝とゴシックの和文フォントは和文のフォントにする。
- 文字コード: 文字列は CHARACTER SET LIST と CHARACTER SET INDEX(G0)・ALTERNATE CHARACTER SET INDEX(G1)で読む。7 bit の符号では SO と SI で、8 bit の符号では上位の半分で G1 に切り替える。94 文字集合(ASCII、JIS X 0201)、96 文字集合(ISO 8859 の各部)、94² 文字集合(JIS X 0208、JIS X 0212、GB 2312、KS X 1001)と、WebCGM の UTF-8・UTF-16(complete code の指示)を扱う。文字集合の宣言がない(または ASCII と Latin-1 だけの)メタファイルの文字列の上位バイトは Latin-1 として読むが、全部が UTF-8 として正しければ UTF-8、Shift_JIS として読めて仮名か第 1 水準の漢字が 2 文字以上出れば Shift_JIS とする(日本の CAD が書く CGM に多い。判定は DXF と同じ、§3.12)。そのために、描く前にメタファイル全体の文字列を集める。JIS X 0208 を宣言するか Shift_JIS と判定したメタファイルの言語は
jaにする(GB 2312 はzh-Hans、KS X 1001 はko)。 - ラスター: CELL ARRAY は P・Q・R の平行四辺形に置く画像にし、補間せずにセルのまま描く。セルの色は、行ごとに語境界から始まる詰めた色か、ランレングスで読む(行を語境界に揃えない書き手のものは、要素の長さで見分ける)。TRANSPARENT CELL COLOUR の色は透明にする。タイル配列(BEGIN TILE ARRAY)の TILE と BITONAL TILE は、JPEG と PNG はそのまま格納し、CCITT の T.4・T.6(TIFF の変換器と同じデコーダ、§3.15)と非圧縮のタイルは復号して格納する。
- 構造: セグメント(BEGIN SEGMENT)は描きながら記録し、COPY SEGMENT の変換行列で複写する。WebCGM のアプリケーション構造(BEGIN APPLICATION STRUCTURE)のうち、visibility 属性が off のものは描かない。
- 壊れたファイルへの備え: パラメータの足りない要素は無視して警告し、途中で切れたファイルはそこまでを描く。数バイトの要素が大量の描画を生まないよう、描く点(ハッチングの線とセグメントの複写が足す分も数える)は 1000 万個まで、セル配列とタイルは 1 つ 4096 × 4096 セルまで・メタファイル全体で 6400 万セルまで、パターンは 65536 セルまで、ピクチャは 1 万枚まで、ピクチャごとに適用し直す METAFILE DEFAULTS REPLACEMENT は 1024 要素まで、ページは 1 辺 5080 mm までとする。パターンの画像は、パターンと色表が変わるまで使い回す。両方の読み手と解釈器には Go の fuzz テスト(
FuzzInterp)がある。 - 未対応(警告を出す): シンボル(POLYSYMBOL)、GDP、幾何パターン(塗りの色で塗る)、三角形の補間内部様式、セグメントの変換(SEGMENT TRANSFORMATION)、登録された線種(6 以降。実線で描く)、ランレングスや LZW などのタイルの圧縮。文字の精度(string / character / stroke)は区別しない。保護領域、クリップの継承、ハイライト、ピック識別子は描画に関係しないので読み捨てる。
テスト用のメタファイルは test/cgm/gen.py が作る(npm run test:cgm:gen、標準ライブラリのみ)。要素を型付きのパラメータの列として 1 度書き、バイナリとクリアテキストに書き出す。shapes.cgm(とそのクリアテキスト shapes-text.cgm)は A3 の CAD 風の図面で、線種・線幅・端点、5 種のマーカー、各内部様式の塗り、穴と見えない辺のある POLYGON SET、各種の円弧と曲線、穴のある閉じた図形、9 つの揃えの文字と、回転・拡幅・字間・縦書き・箱に収める文字、APPEND TEXT、JIS X 0208 の和文、セル配列、クリップ、セグメントの複写を持つ。Go のテストは両者が同じ cad.Drawing になることを確かめる。illustration.cgm は WebCGM 風の図(実数の VDC で y が下向き、abstract、直接色、UTF-8、非表示のアプリケーション構造、タイル、2 つのピクチャ)、sjis.cgm は宣言のない Shift_JIS の文字を持つ A4 の図面である。変換結果は testdata/cgm/ に置く。符号化の細部(SCALING MODE の縮尺、タイル配列のパラメータ、SDR の形、セル配列の行の揃え)は、公開されている実装(jcgm)の読み方とも照らし合わせた。
3.21 Gerber・Excellon → BDF 変換器(converter/gerber)の構造
converter/gerber はプリント基板の製造データを読む。Gerber は Ucamco の「The Gerber Layer Format Specification」(拡張 Gerber=RS-274X と X2 属性)による。旧版の非推奨の命令(G54、G70/G71、G90/G91、G74、%IP・%IR・%MI・%OF・%SF・%AS、末尾のゼロの省略、増分座標)も読む。アパーチャの定義が別ファイルにある RS-274-D は描けない。穴あけデータは Excellon で、KiCad・Altium・Eagle などが書く方言(数値の書式、ヘッダーのコメントの属性、長穴とルーティング)を読む。1 枚の基板は層ごとのファイルと穴あけファイルの組で、製造業者には ZIP で渡すのが普通なので、ZIP に入ったファイルの組を 1 つの基板として変換する。1 つのファイルだけでも変換でき、その層の View になる。
- 形式の判別: Gerber は拡張命令(
%FS・%MO・%AD)を持ち、Gerber の命令で始まるテキスト(先頭のコメントが長いファイルがあるので 64 KiB まで読む)。Excellon はコメントと巻き戻し停止(%)の後にヘッダーのM48が来るテキスト。ZIP は、Office 文書([Content_Types].xml)と SXF(.p21・.sfc)でなく、Gerber か Excellon のファイルを持つもの。ヘッダーの無い穴あけファイルは、拡張子(.drl など)と、行の大半が工具(T01)と座標なことで見分ける。形式名はどれもgerberである。 - 図形の読み込み: 座標を mm の float64 に直し、極性ごとの「ラン」に分けて持つ。ダーク(LPD)のランは図形を足すだけ、クリア(LPC)のランは取り除くだけなので、ランの中では描く順を並べ替えてよい。丸いアパーチャで引いた線(D01)は幅ごとに 1 本のパスにまとめ(続いている線は折れ線にする)、丸い端と角でストロークする。円のアパーチャを掃引した形は、Canvas の丸い端と角の線と同じだからである。長さ 0 の線は点(円)として塗る。矩形などのアパーチャで引いた線は、始点と終点に置いたアパーチャの形の凸包を塗る(凸な形なら掃引と一致する)。円弧は複数象限(G75。始点と終点が同じなら全円)と単一象限(G74。I・J の符号は 90° 以内の弧になるものを選ぶ)の両方を扱う。領域(G36/G37)の輪郭は外側を反時計回りにそろえて非ゼロ規則で塗る。輪郭どうしは和になり、カットインでつないだ穴は逆回りのまま抜ける。
- アパーチャ: 標準アパーチャ(C・R・O・P、穴つきも)とマクロ(AM)。マクロの式(
$n、四則演算と括弧、変数の定義)は AD のときに評価し、プリミティブ(円 1、ベクトル線 20(旧 2)、中心線 21、左下線 22、アウトライン 4、多角形 5、モアレ 6、サーマル 7)を輪郭にする。プリミティブの回転はマクロの原点まわり。露光オフのプリミティブを持つマクロは、それより前の部分から取り除く必要があるので 1 つの子 Object にし(露光オフは destination-out で塗る)、フラッシュごとに GROUP で分けて使う。アパーチャの変換(LM・LR・LS。鏡映、回転、拡大の順)、ブロックアパーチャ(AB)、ステップ&リピート(SR)も扱う。ブロックは置くたびに中身を写し、クリアの極性で置いたブロックは中の極性を反転する。 - Object: ファイルごとの画像を 1 つの Object にする。ページの座標(pt)で書き、色は指定しない。
USEした子 Object は親の塗りと線の色を受け継ぐ(spec §8)ので、1 つの Object を基板の表示でもレイヤーの表示でも別の色で使える。フラッシュはアパーチャごとのパスを並べるFILL_PATH_RUNにまとめる(グリフと同じ使い方で、パッドがいくつあってもパスはアパーチャの種類の数だけ)。クリアのランはSAVE・BLEND destination-out・RESTOREで囲み、そうした Object はほかの描画を消さないように GROUP の中で使う。円弧は Canvas のellipseのまま書き、Bézier 曲線に直さない。 - 層の判別: 各ファイルが何の層か(表・裏・内層の銅箔、ソルダーマスク、シルク、ペースト、外形、穴)は、ジョブファイル(.gbrjob)の
FilesAttributes、X2 属性のTF.FileFunction(KiCad の古い版がコメントに書くG04 #@! TF.…も)、ファイル名の順に決める。ファイル名は、KiCad の層名(-F_Cu、-In1_Cu、-Edge_Cutsなど)、Protel と Altium の拡張子(.GTL、.GBS、.GKO、.G1、.GP1 など。EasyEDA も使う)、Eagle の拡張子(.cmp、.sol、.stc など)と CAM ジョブの名前(copper_topなど)、OrCAD の拡張子、単語(top・bottom、mask、silk、paste、outline など)で見分ける。穴あけファイルのメッキ・非メッキは、属性、Altium の;TYPE=、ファイル名(NPTH)で見分ける。 - View とページ: ファイルが 2 つ以上あり、銅箔・マスク・シルクのどれかがあれば、基板の表(
top)と裏(bottom。裏から見るので左右を反転する)を実物の見た目で描き、続けてファイルごとの View を上の層から順に置く(-param views=boardかviews=layersで片方だけ)。ページはどれも基板の範囲(外形、無ければ各層の描く範囲)に余白を足したもので、A3 の長辺に収まる最大の整数倍に拡大する(A3 より大きい基板は縮めて収める)。ファイルごとの View は KiCad の既定の配色で暗い背景に描き、外形を薄く重ねる。ドリルマップのように基板の外まで描く図面は、基板のページの 3 倍までそのページだけを広げる。 - 基板の見た目: 外形の層の線(ばらばらの順と向きで引かれた線と円弧)を端点でつないで閉じた輪郭にし(0.02 mm、線が太ければその半分、0.1 mm までの隙間を閉じる。領域で描いた外形はそのまま使う)、最も大きい輪郭の外にあるものを除いて偶奇規則で塗ったものを基板の形とする。内側の輪郭は切り抜きになる。基板の表示はこの形でクリップする。外形が閉じなければ、各層の範囲の矩形を基板の形にする。描く順は、マスクの下の基材と銅箔、マスクの開口から見えるもの(むき出しの基材と、仕上げの色の銅箔。これを描いた GROUP に、開口の画像をソフトマスク(alpha)として掛ける)、シルク(開口の部分は除く)、穴(背景の色)。マスクの下にあるのは一様な色の基材と銅箔だけなので、半透明のマスクを重ねる代わりに、マスクの色を混ぜた色で塗る(GROUP の不透明度で重ねると、メインスレッドと Worker の Canvas で丸めが 1 ずつ違い、基板の全面の画素がずれた)。ネガの銅箔(
%IPNEG、または極性が Negative の銅箔の層=プレーン)は、基板の形から画像を除いて描く。マスクとシルクの色、銅箔の仕上げはジョブファイルのMaterialStackup(SolderMask と Legend の Color)とGeneralSpecs.Finish(ENIG なら金、HAL なら銀)に従い、-param mask=・silkscreen=・finish=で変えられる。既定は緑のマスク、白のシルク、金の仕上げである。 - メタデータ: 題名はジョブファイルかファイルの
TF.ProjectIdのプロジェクト名、作成日(created)はCreationDate。Gerber には文字が無い(シルクの文字も線で描かれている)のでテキスト索引は作らない。各ページの描画は figure の構造マークで囲み、代替テキストに View の名前を入れる。 - 壊れたファイルへの備え: 線分・輪郭・フラッシュは、SR と AB の写しも含めて 500 万個まで。SR の繰り返しは 10 万回まで、AB の入れ子は 32 段、マクロの式の括弧は 64 段、アウトラインの頂点は 65,536 までとし、数値は有限で大きすぎないものだけを使う。ZIP は 1,000 ファイル、展開して 1 GiB まで読む。ファジングテスト(
FuzzGerber)で確かめる。 - 未対応(警告を出す): アパーチャ定義の無い RS-274-D、ブロックアパーチャで引いた線、円でないアパーチャで引いた円弧(丸い線で描く)、露光 2(反転)のマクロプリミティブ(露光オンで描く)、軸ごとに違う倍率の
%SF(平均で描く)、Excellon の M97・M98 の文字あけ。部品や配線の属性(TO・TA)は描画に関わらないので読まない。
テスト用のデータは test/gerber/gen.py が作る(npm run test:gerber:gen、標準ライブラリのみ)。board.zip は KiCad 7 の出力をまねた 2 層基板(X2 属性の Gerber 8 枚、PTH と NPTH のドリルファイル、ジョブファイル)で、角の丸い外形と切り抜き、SOIC とチップ部品の角丸パッド(KiCad の RoundRect マクロ)、ピンヘッダーと長穴のパッド、取り付け穴、円弧の配線、カットインで穴をあけた銅箔、裏面のベタとクリアランス(クリアの極性)とサーマルのマクロ、表と裏のシルクを持つ。features.gbr は仕様の要素(標準アパーチャと穴、全プリミティブと式、G74・G75 の円弧、全円、点、大きさ 0 のアパーチャ、領域とカットイン、極性、SR、AB と LM・LR・LS、G54)を 1 ファイルに並べたもの。変換結果は testdata/gerber/ に置く。
3.22 HP-GL/2 → BDF 変換器(converter/hpgl)
converter/hpgl は HP のプロッタとプリンタのプロットファイル(.plt など)を読む。仕様は「The HP-GL/2 and HP RTL Reference Guide」(Hewlett-Packard、第 2 版、1996)による。HP-GL/2 と、それ以前のペンプロッタやカッティングプロッタの HP-GL(HP-GL/2 で廃止された目盛りの XT・YT・TL も)を読み、大判プロッタや PCL プリンタに送るファイルに付く PJL のジョブと PCL・HP RTL のエスケープシーケンスもたどって、HP RTL のラスター画像も描く。描くのは他の CAD 変換器と同じ converter/internal/cad で、ラスター画像と RF のパターン塗りには CGM の変換器(§3.20)と同じ cad.Image と cad.Fill.Pattern を使い、線の留め継ぎ限度(cad.Pen.Miter)を足した。
- 判別: HP-GL には署名がない。先頭(1 KiB)が既知の 2 文字の命令とその引数だけの並び(3 命令以上。数値の引数が 1 つもなければ 6 命令以上)か、PJL・PCL のジョブで HP-GL/2 か HP RTL に入るもの(PJL の
ENTER LANGUAGE=HPGL2・RTL、ESC % # B、ラスター画像の設定)を HP-GL とする(converter/internal/hpglsniff)。命令はセミコロンで終わるので、CSV の判別器もこれを見て HP-GL を CSV とみなさない。中身で判別できず拡張子(.plt など)で選ばれた入力は、HP-GL でなければ変換せずにエラーにする(.plt は gnuplot のスクリプトにも使われる)。 - 読み込み: 入力を 2 つの文脈で読む。HP-GL/2 の命令(2 文字のニーモニック。大文字小文字を問わず、数値の引数はカンマ・空白・符号で区切る)と、エスケープシーケンス(PCL・HP RTL。値と終端文字の組を小文字の終端でつなげたもので、W で終わるものは値のバイト数のデータが続く)で、ESC % # B・ESC % # A・ESC E・UEL(ESC %-12345X)で切り替わる。UEL の後の PJL の行からはジョブ名(タイトルの候補。BP の図の名前が優先)、
ENTER LANGUAGE、解像度を読む。LB・DT・SM の文字、CO・BP の引用符の文字列、PE の符号化された座標は命令ごとに読む。シリアル接続のプロッタの装置制御(ESC . …)は飛ばす。 - ページ: PG、BP、ESC E(PCL プリンタでは用紙送りの文字と ESC E)で、何か描かれていればページが終わる。プロットの座標はプロッタ単位(0.025 mm)の用紙上の位置なので、ページはプロットサイズ(PS。長い辺が x 軸)を外に描かれたものも入るように広げたもの、PS がなければ描かれたものの範囲に 5 mm の余白を付けたものである。PS がないときに P1・P2 などに要るハードクリップの範囲は A4 横とみなす。ページは、描き終えたときの RO で回した座標系で見せる。大判プロッタの用紙はロールで、PS の長さが送りの向き、HP-GL/2 の x 軸は長い辺に沿う(HP RTL の画像の行は送りの向きに並ぶ)。縦長のページを描くドライバは RO 90 で座標系を用紙に合わせてその中で正立に描き、横長の図面を描く CAD は RO 0 のまま長い辺を x にして描く。RO で回した座標系で見れば、どちらも正立する。ラスター画像だけのページは、画像の行が上から下に並ぶ向きに見せる。PJL か ESC %-1B でプロッタのジョブとわかるもの以外で、PCL のページを設定した(ESC & l # A など)ジョブは PCL プリンタのものとし、PCL の用紙(大きさと向き)の 1 ページの、ピクチャーフレーム(既定は論理ページの幅で、上下の余白の間)に HP-GL/2 を置き、HP-GL/2 のプロットサイズ(ESC * c # K・# L)との比で拡大縮小する。
- 座標系: IP・IR(P1・P2)、SC(異方性、left・bottom で余りを配分する等方性、点係数)、RO(90° ずつ回して原点を移す。ペンは用紙上の位置を保ち、IW の窓と多角形バッファは座標を保って用紙の上で回る)、IW(切り抜きのグループ)を扱う。ペンの位置は描画の座標で持ち、相対移動はユーザ単位の変位を写す。円弧・円・扇形はユーザ単位で形を求めてから写すので、異方性のスケールでは楕円になる。
- ペンと線: パレット(NP、PC と CR の色の範囲、既定の 8 色。パレットより大きいペン番号はラスター機器と同じ剰余で選ぶ)、PW(mm、WU 1 では P1–P2 の対角線に対する %)、LA(線端、角、留め継ぎ限度。三角の線端と角は丸で代える)、LT(固定の線種 1〜8 とそれに由来する適応線種、UL のユーザ定義。パターン長は P1–P2 の対角線の % か mm、LT99 で戻す)、SV(網掛けのベクトルは透明度で)を扱う。ペンを下ろしたまま続けて動いた線は 1 つのパスにまとめ、破線の模様が角をまわって続くようにする。適応線種はベクトルごとに整数個の模様が入るように縮め、実線の途中から始まり途中で終わる。LT 0 は端点に点だけを打つ。SP を一度も送らないプロット(カッティングプロッタのファイルなど)はペン 1 で描く(GhostPCL も同じ)。ペン 0 は白。
- ベクトル: PA・PR・PU・PD、AA・AR・AT・RT、CI、BZ・BR、PE(base 64 と 7 ビットの base 32、座標の符号、小数ビット、ペンの選択。フラグの値も座標と同じく符号付きで読む。リファレンスの例とは食い違うが、本文と GhostPCL に合わせた)。円弧と円はベジェ曲線にするが、既定(5°)より粗い弦角を指定されたものはプロッタと同じく弦で描く(
CI100,60は六角形)。 - 多角形と塗り: PM(PM1 の後の最初の移動は辺にしない。PM2 はペンの位置と上げ下げを PM0 の前に戻す。閉じる辺は PM1・PM2 のときペンが下りていれば描く)、EP、FP(偶奇と非ゼロ)、EA・ER・RA・RR、EW・WG を扱い、多角形モードの CI は独立した部分多角形にする。塗りの種類(FT)は、単色、平行線と交差線のハッチング(間隔は既定で P1–P2 の対角線の 1 %。角度は RO で回った x 軸から、AC の点を基準にし、固定の線種なら 1 本おきに半周期ずらす)、網掛け(TR 1 では透明度で、TR 0 では白と混ぜる)、RF のラスターパターン(1 画素を 300 dpi の 1 ドットとする画像パターン)、PCL の定義済みハッチング(FT 21)である。
- ラベル: LB の文字は文書のフォントのテキストにする。プロッタの既定のスティックフォント(タイプフェイス 48)は固定ピッチの線のフォントなので固定ピッチのフォント(monospace)で描き、文字の並びごとにセルの幅へ伸縮する(JWW の文字と同じ)。大きさは SI(cm。文字幅 w と大文字の高さ h から、送りは 1.5 w、行送りは 2 h。HP-GL/2 の仕様のとおりで、LaserJet を模した GhostPCL の約 1.25 w とは違う)、SR(P1–P2 に対する %)、どちらもなければ SD・AD のピッチ(文字/インチ)と高さ(ポイント。スティックフォントの大文字の高さは 0.67 倍)による。比例間隔と、タイプフェイスの番号(Univers、CG Times、Courier など)は sans-serif・serif・monospace の選択に、ストロークの太さと姿勢は太字と斜体に写す。DI・DR(方向)、SL(斜体)、SI・SR の負の値(鏡像)、DV(右・下・左・上の 4 つの進む向き。下向きの文字送りは GhostPCL の測定に合わせてポイントサイズの 0.96 倍)、LO(9 つの位置と、ポイントサイズの 1/3(スティックフォント)か 1/4 だけ離す 11〜19。行ごとに外接矩形を合わせる)、ES、CP、改行・復帰・後退・タブ・SO/SI の制御文字(SO・SI の後の文字はラベルの終わりまでそのフォントの大きさで描く。GhostPCL と同じ)と復帰点(carriage-return point)の規則、ラベルが続けて書ける LO と DV の組み合わせでのペンの位置を扱う。文字セットは Roman-8(既定)、ISO 8859-1、PC-8、PC-850、Windows Latin 1 を読む。8 ビットのラベルの上位のバイトがどれも Shift_JIS の 2 バイト文字になり、そのうちに仮名か JIS 第 1 水準の漢字があれば Shift_JIS として読む(Roman-8 のアクセント付きの文字と英字の並びも第 2 水準の漢字や半角カナになるので、それだけでは Shift_JIS としない)。LM 1(16 ビット)のラベルと漢字の文字セットは JIS X 0208 の 2 バイトとして読む。SM(記号モード)は各頂点に文字の中心を合わせて描く。
- HP RTL の画像: ESC * r(開始と終了、ソースの幅と高さ、単純色)、ESC * b(圧縮方式、行と面の転送、Y オフセット)、ESC * t(解像度、スケールするときの出力の幅と高さ)、ESC * v(画像データの構成、パレットの RGB と割り当て、ソースの透明)、ESC * p・ESC & a(カーソル)を扱う。圧縮方式は 0(非圧縮)、1(ランレングス)、2(PackBits)、3(デルタ行)、5(適応。空行と重複行も)、9(PCL の置換デルタ行)を読み、4・6〜8(CCITT)・10 は警告を出して描かない。画素のインデックス(面ごと、画素ごと)とダイレクトカラー(面ごと、画素ごと)を、既定のパレット(白黒、3 ビットの RGB)、単純色(RGB、CMY、KCMY)と、HP-GL/2 と共有するパレット(プロッタでは文脈を移るたびに受け渡す)で色にする。ソースの透明(既定)では白の画素を透明にする。解像度の上限(既定 192 dpi)より細かい画像は受け取りながら整数分の 1 に縮小し(白黒の画像は白黒のまま、インクが 2/5 以上の区画をインクにする)、最後に画素数の上限に収めるので、A0 を 600 dpi で送るジョブでも画像の全体は持たない。画像を置く場所は、プロッタでは RTL の座標(x が PS の幅、y が長さに沿う)を HP-GL/2 の座標系に写したところ、PCL プリンタでは論理ページの左端と上の余白から測ったカーソルの位置である。ESC % 1 A と ESC % 1 B でペンとカーソルの位置を受け渡す。
- 壊れたファイルへの備え: 値はそのまま信用しない。NaN・無限大・極端な値の座標は捨て、終端のないラベルは 4 KiB で切る。ソースの幅のないラスターの行は HP RTL の上限の 65,535 画素で切る(ランレングスやデルタ行は小さなデータから長い行を作れるので)。描く要素は文書全体で 500 万個、ページは 1 万ページ、RF のパターンは 256 × 256 画素、ラスター画像は縮小した後で 256 MiB までとする。ファズテスト(
FuzzConvert)で落ちないことを確かめている。 - 未対応(警告を出す): PCL の文字の印刷、FI・FN(ID によるフォントの選択。既定のフォントで描く)、MC(マージ制御。後から描いたものが上に重なる)、DL(ダウンロード文字)、FT 22(PCL のユーザ定義パターン。単色で塗る)、SV のパターン(半分の網掛けで描く)、HP RTL の圧縮方式 4・6〜8・10、面ごとに重ね刷りする画像(plane-by-plane)。
テスト用のプロットは test/hpgl/gen.py が HP のリファレンスどおりに書く(npm run test:hpgl:gen、標準ライブラリのみ)。shapes.plt は A3 のプロットサイズにほぼすべての命令(パレット、線種、線端、円弧、粗い弦角の円、ベジェ曲線、PE、穴のある多角形、塗りの種類、パイチャート、網掛けのベクトル、ユーザ単位とウィンドウ、記号モード、ラベルの大きさ・方向・斜体・原点・進む向き・フォント、Shift_JIS と 16 ビット JIS の日本語)を描く。job.plt は大判プロッタのジョブ(PJL、ESC %-1B、RO 90 で縦長の A4 にしたページに置換デルタ行の 24 ビットの画像、2 ページ目は適応圧縮の白黒の画像だけ)、hpgl1.plt はペンプロッタの HP-GL(装置制御、P1・P2、ユーザ単位、SI と DI のラベル、目盛り)である。変換結果は testdata/hpgl/ に置く。開発中は GhostPCL(gpcl6)で同じ HP-GL/2 を PCL のページに包んで描き、線・円・円弧・ハッチング・線種・多角形・回転・スケール・ウィンドウ・PE・ラスター画像(画素単位で一致)が重なることを確かめた(ラベルは上に書いた違いのほかは合う)。GNU plotutils の出力(graph -T hpgl、-T pcl、plotfont)でも変換を確かめた。
3.23 数式(internal/mathlayout、converter/internal/equation、formula)
Word・PowerPoint・Excel の Office Math(OMML)、HTML の MathML、Markdown と draw.io(math=1)の LaTeX を 1 つのエンジンで組む。3 つの記法を同じ木(presentation MathML の形: トークン、行、分数、根号、添字、上下の極限、表、囲み)に読み、TeX と MathML Core の組み方で、数式フォントの OpenType MATH 表の定数とグリフの異体字を使って組む。BDF に数式の命令はなく、組んだ結果は文字と線の描画になる。
読み手: OMML(分数の 4 種、添字と前置添字、根号、n 項演算子(極限の位置の既定は
m:mathPrのnaryLimとintLim)、関数、極限、伸びる区切り、行列、&で揃える数式配列、アクセント、上下の線、グループ文字、囲み、ファントム、普通のテキスト(m:nor)、run の文字の大きさと色)。MathML(presentation の要素すべてと、廃止された mfenced、mstyle の属性、semantics の TeX の注釈)。LaTeX(amsmath・amssymb の記号約 400、\fracの類、\sqrt、\left…\rightと\bigの類、アクセント、\overbraceの類、\xrightarrowの類、フォントの指定、\text、\color、\boxedと\cancel、\phantomの類、環境の matrix の類・cases・aligned・gathered・array。知らない命令は名前を文字列のまま残す)。組版: 原子の種類(Ord、Op、Bin、Rel、Open、Close、Punct、Inner)の間の空き(TeXbook の表。添字の中では細い空きだけ)と、演算する相手のない二項演算子を普通の原子にする規則で並べる。字は数学用英数字(𝑥、𝐯、ℝ)で描き分け、1 文字の変数はイタリック(大文字のギリシャ文字は立体)。字の高さと深さはグリフの輪郭から取る(TrueType は glyf のヘッダーの bbox、CFF は Type 2 charstring を解釈する。
sfnt.Outlines)。分数・根号・添字・極限・上下の線の位置は MATH 表の定数で MathML Core の算法どおりに決め、イタリック補正と上に付くアクセントの位置も MATH 表から取る。大型演算子はディスプレイで DisplayOperatorMinHeight 以上の異体字にし、数式の軸に中心を合わせる。∑ などと極限を持つ関数名(lim、max、\operatorname*、MathML のmovablelimitsの mo)は、ディスプレイでは極限を上下に、それ以外では添字の位置に置く(Office のm:limLow/m:limUppは常に上下)。伸びるグリフ: 区切り(TeX の
\left、OMML のm:d、MathML の伸びる mo)は同じ行の他の要素の高さと深さまで伸びる。異体字に十分大きいものがなければ、部品(端と継ぎ)を MinConnectorOverlap 以上重ねて積む(継ぎの繰り返しは最小の回数に、重なりは均等に)。TeX の\leftと\rightは TeX と同じく少し短くする(delimiterfactor 901、delimitershortfall 5pt)。根号も同じく伸ばし、横に伸びる括弧・矢印・広いアクセントは横に同じことをする。描画: 数式を子 Object に描き、親には線形表記(Office の linear format に近い
x=(−b±√(b^2−4ac))/(2a))の ALT_TEXT と USE_AT で置く。抽出・検索・コピーでは数式が 1 つの run になり、子 Object の bbox の幅に文字列が広がる(縦書きの行と同じ方式、§3.9)。字は FILL_TEXT、線は FILL_RECT で描く。符号位置のない異体字と部品は私用領域の U+F0000 + GID の文字で描き、埋め込むサブセットの cmap にその文字 → グリフを足す(fontset.Set.GlyphRune)。子 Object は使われる場所の状態を引き継ぐので、先頭で TEXT_STYLE を初期値に戻す。同じ数式は同じ Object になって 1 回だけ格納される。数式フォント: 文書が求めるもの(Word は
m:mathFont、既定は Cambria Math。HTML と Markdown は指定なし)を MATH 表を持つフェイスに解決する: そのもの → STIX Two Math(Cambria Math に最も近い Times 系の字形)、XITS Math、Latin Modern Math、Libertinus Math、TeX Gyre の Math、Noto Sans Math など → MATH 表を持つ任意のフェイス(fontdb.DB.ResolveMath。MATH 表の有無は索引を作るときテーブルディレクトリから読むので、デモサイトのフォントの読み取り範囲は変わらない)。数式フォントにない字(和文など)は周りの文字のフォントで描く。数式フォントがなければ本文のフォントと TeX の既定値(Computer Modern の値)で組み、根号と伸びる括弧・中括弧は線で描き、ほかの伸びる字は拡大する(警告を出す)。HTML と Markdown の本文はフォントを名前で参照するが、数式フォントは埋め込む(配置がそのフォントの字形で決まり、異体字は私用領域の文字で描くため。fontset.Set.Pin)。Web の数式は本文の x の高さに合わせて最大 1.25 倍にする(MathJax と同じ考え方)。デモサイトは STIX Two Math(OFL)を公開する。CFF のサブセット化: 数式フォントの多く(STIX Two Math、Latin Modern Math、macOS の STIXTwoMath.otf)は CFF なので、PDF の変換器の CFF サブセット化(§3.1)を
internal/cffに移し、フォントの埋め込み(fontdb)でも使うようにした。STIX Two Math(838 KB)は math.docx で 31 KB の WOFF2 になる。Office 系の変換器でも、CFF の OpenType フォント(和文の OTF など)がサブセットで埋め込まれるようになった。パッケージの分け方: 木・3 つの読み手・組版は
internal/mathlayoutにあり、フォントはFace(fontdb.Loadedと、使う側が自分のフォントを引くためのHost)、字幅の記録と符号位置のない異体字の文字はFontsのコールバックで受け取る。fontsetにもcanvasにも依存しないので、変換器の外からも使える。組んだ結果(Box)はItems()でグリフ(フェイスとグリフ番号)・線・ストロークの並びとして読める。converter/internal/equationは変換器用の薄い層で、文書のfontset.Setをフォントとして渡し(サブセットに入れる字を記録し、異体字には私用領域の字を割り当てる)、上の「描画」のとおり埋め込みフォントのテキストとしてcanvasに描く。OMML の読み手が受け取る XML の木(ooxml.Node)は、変換器の外から参照できるようinternal/xmltreeに移し、ooxmlは別名で公開する。単体の数式(
formula): 公開パッケージformulaは同じエンジンを、同梱の STIX Two Math(go:embed、838 KB。Optionsで差し替え・\text{}用のフォントを追加できる)で動かし、結果をパスだけの Object にする(Layout.Object、Layout.Draw)。グリフはその大きさの輪郭を Object のパス表に 1 つずつ持ち、色の続く範囲ごとに FILL_PATH_RUN で置く。線は FILL_RECT、囲みや取り消し線は STROKE_PATH。フォントの Part を参照しないので、文書に入れずに描ける: 画像はimagebdf.Object、Ebitengine はraster/ebitenginebdf、ブラウザはLayout.Document(線形表記の ALT_TEXT を持つ 1 ページの文書)を@bdfkit/renderで描く。文書の中の数式がテキストのままなのは、文書全体でグリフを共有できるからである。Ebitengine の描き手(
raster/ebitenginebdf): Ebitengine への依存を BDF 本体に持ち込まないよう、独自のgo.modを持つ別モジュールにした。raster/internal/shapesが Object の命令列を図形の並びに展開し(変換を座標に適用し、矩形・楕円・arcTo・角丸矩形を直線と 3 次ベジェにし、状態の色とアルファを図形ごとに確定する。imagebdfで元の Object と同じ絵になることをテストする)、ebitenginebdfはそれをvector.Pathにしてvector.FillPath・vector.StrokePathで描く。描けるのは単色のパスだけで、テキスト・画像・グラデーション・クリップ・グループ・マスク・破線はUnsupportedErrorにする。文書のページはこれらを含むので、Ebitengine ではimagebdfで画像にしてから表示する。行の中の数式:
wordprocのインラインオブジェクトにベースラインより下の深さを足し、行の高さを数式の高さと深さで広げる。OMML のm:oMathParaは数式ごとに行を分け、段落が数式だけならm:jc(既定は中央)で揃える。MathML のdisplay="block"と Markdown の$$は中央に置いた段落になる。PowerPoint と Excel の図形のテキスト(DrawingML のテキストエンジン)と draw.io のラベルでは、数式は数式の幅の item になり、同じ規則で行に置く(§3.4、§3.11)。Office の文書ごとの違い: Word は run の書式を
w:rPrに、m:tに文字を普通に書く。PowerPoint と Excel はmc:AlternateContentの Choice のa14:mに数式を書き(Fallback は画像か線形表記の文字列)、run の書式は継承のあるa:rPr(読み手にOMML.RunStyleを渡して解決する)、文字は数学用英数字(𝑥)で書く。読み手は数学用英数字を元の文字と書体(𝑥 → イタリックの x)に戻すので、どれも同じ木になる。Web ページの数式: KaTeX(
katex-mathmlと見た目用のkatex-html)、MathJax 3(mjx-assistive-mml)、Wikipedia(隠した MathML と数式の画像)は、MathML の横に独自の描画を置いてスタイルシートで片方を隠す。リーダー表示はスタイルシートを読まないので、記事を取り出す前に(Readability はクラス名を消す)MathML だけを残す。MathJax 2 のscript type="math/tex"と GitHub のmath-rendererは、LaTeX を TeX の注釈として持つ math 要素にする。書き換えはconverter/internal/webdocのNormalizeMathで、EPUB も使う(§3.24)。Markdown: GitHub と同じ
$…$、$`…`$、$$…$$(段落の中でも、独立した行でも)、言語 math のコードブロック。開きの$の直後と閉じの$の直前は空白でなく、閉じの直後は数字でないもの(pandoc と同じ規則)で、さらに閉じにならない$が先に来たら数式にしないので、「$5 と $10」のような文は数式にならない。数式は TeX の注釈を持つ math 要素として HTML に書き、HTML の変換器が組む。検索: 線形表記はマイナスに U+2212 を使うので、読み手の検索の正規化で U+2212 をハイフンマイナスに寄せる(NFKC では変わらない)。
b^2-4acと打てば数式が見つかる。未対応: 長い数式の行分割(Word の自動改行、MathML の linebreak)、MATH 表の数式カーニング(MathKern。読むが使わない)と GSUB の ssty(添字用の字形)、draw.io の AsciiMath、Equation Editor 3.0 の OLE(プレビューの画像を描く)、MathML の mglyph と maligngroup、LaTeX のマクロ定義(
\newcommand)と\tagの番号。入れ子の上限: LaTeX・MathML・Office Math の読み手は、入れ子を 200 段まで読む。それより深い中身は、入れ子にせずに平らに読む(
{を 100 万個並べた数式がスタックを使い切り、10 万個で 40 秒かかっていた)。draw.io は、これとは別に 20,000 バイトを超える数式を文字として描く。
テストは、TeX・MathML・OMML の読み手を線形表記で、組版を STIX Two Math のサブセット(converter/docx/testdata/fonts、MATH 表を保って fontTools で作ったもの、OFL)で確かめ、MATH 表とグリフの境界(TrueType と CFF)を fontTools の値と比べる。converter/docx/testdata/math.docx(test/docx/gen.py)、converter/pptx/testdata/math.pptx(test/pptx/gen.py)、converter/xlsx/testdata/math.xlsx(test/xlsx/gen.py)、converter/drawio/testdata/math.drawio、converter/markdown/testdata/math.md、converter/html/testdata/math.html の変換結果を testdata/ に置き、ブラウザのテストで数式の検索を確かめる。
3.24 EPUB → BDF 変換器(converter/epub)の構造
EPUB は ZIP に入った XHTML と CSS と画像なので、リフロー型の本は HTML の変換器(§3.16)と同じリーダー表示で組める。HTML と違うのは 4 点。本は複数の文書(spine)からなる。読み手が求めるのは Web ページの帯ではなく本のページ。日本語の本の多くは縦書き。マンガや写真集は文字を組まない固定レイアウトで作られている。
- パッケージ:
META-INF/container.xmlが指すパッケージ文書(OPF。なければ最初の .opf)から、metadata、manifest、spine を読む。読む順は spine の順で、linear="no"の文書(リンクでだけ行く注など)は後ろに回す。独自のメディアタイプの項目はfallbackをたどる。spine に直接置かれた画像は、その画像だけのページにする。ZIP の中の名前は大文字小文字の違いを許して引き、コンテナの外に出る参照は読まない。META-INF/encryption.xmlが、フォントの難読化(IDPF と Adobe の方式)以外の暗号化を spine の文書に使っていれば DRM とみなしてエラーにする(ErrDRM)。難読化されたフォントはほかの埋め込みフォントと同じく使わない。メタデータの写し方は spec §4.3。 - XHTML は XML として読む(
converter/internal/webdoc): EPUB の文書は XHTML で、HTML のパーサーに通すと空要素の書き方で壊れる。<a id="p5"/>(ページ位置の目印)は開いたままになって後ろの段落を中に取り込み、<script src="…"/>や<title/>は文書の残りを丸ごとスクリプトや題名にしてしまう。そこで XML として読み(tinygodriver のencoding/xmlro)、HTML のパーサーと同じ形の木(golang.org/x/net/htmlの Node)を作る。XHTML の要素は HTML の要素、SVG と MathML はsvg/mathの名前空間、接頭辞付きの属性は HTML のパーサーと同じくepub:typeのようなキー、xml:langはlangも付ける。文字コードは XML 宣言から。整形式でない文書は警告を出して HTML として読む。xmlro が確かめない整形式の条件(UTF-8 であること、制御文字、XHTML にない実体や単独の&、属性値の中の<)は webdoc が確かめ、HTML のパーサーのほうがうまく読める文書をそちらに回す。epub:switchは、描ける名前空間(MathML、SVG、XHTML)をrequired-namespaceに求める最初のepub:caseの中身に、なければepub:defaultの中身に置き換える。HTML の変換器も、XML 宣言で始まる文書と Content-Type が XHTML の文書をこれで読む。文字コードの判定と data: URL も html パッケージからここに移した。 - 章: 組版エンジン(§3.9)の
HTMLDocument.Chaptersに文書を 1 つずつ渡し、それぞれを Word のセクションと同じ「次のページから」のセクションにする。page View では章ごとに改ページし、scroll View では続けて組む。文書をまたぐリンクのために、要素の id を「文書のパス#id」という本の中で一意なブックマークに書き換え(章ごとにfn1のような id が重なるのが普通なので。SVG と MathML の要素の id は、url(#grad)や<use href="#icon">が参照するので書き換えない)、ch2.xhtml#secへのリンクをそのブックマークへの#…に、文書そのものへのリンクを文書の先頭に向ける。組まない文書へのリンクは外し、http / https / mailto は残す。画像の参照はコンテナの中のパスにしておき、読み込みはエンジンのコールバックが ZIP から行う。EPUB は外部の画像を認めていないので、ネットワークからは取らない。 - 作者の CSS から読むもの: 見た目はリーダー表示の固定のスタイルシートのままにするが、本の中身に関わるいくつかのプロパティだけは作者の CSS から読む。縦書き(
writing-mode、-epub-/-webkit-付きも)、縦中横(text-combine-upright、-epub-text-combineなど)、傍点(text-emphasis-styleなど。点で描く)、行揃え(text-align)、display: none、画像のwidth/height(外字の画像を 1em にする電書協のimg.gaijiなど。% は読まない)。対象は複合セレクター 1 つだけのルール(.vrtl、span.tcy、html、img.gaiji、#id、:root)で、結合子、属性セレクター、:root以外の疑似クラスを含むルールは使わない。linkとstyleから文書の順に集め、@importをたどり(深さ 8 まで、同じシートは 1 回)、@mediaや@supportsの中は条件を見ずに使い、@font-faceなどは飛ばす。詳細度と出現順で勝ったものを要素の style 属性の前に書き足すので、要素自身の style が勝ち、エンジンはインラインの style として読む。 - 縦書き: 章の書字方向は body の、なければ html の writing-mode で決める(電書協の
html class="vrtl"、横組みの奥付のhltr)。どのシートも書字方向を書いていない本は、Kindle のprimary-writing-modeに従い、それもなければ言語が日本語・中国語・韓国語でpage-progression-directionが rtl のときに縦書きとする。縦書きの章は page View で Word の縦書きのセクション(§3.9)と同じく組む。漢字・仮名の正立、句読点の縦書き用字形、欧文の回転、縦中横(升目に収まらない桁は升目の幅に縮める)、傍点(行の右)が使える。scroll View は Word の下書き表示と同じく横書き。縦書きの章では段落の前後の空きをなくす。和文の本は段落を空けずに続け、字下げは本文の全角空白で表すため。vertical-lr(モンゴル文字)は横書きで組む。ルビは Word と同じく親文字だけを描く。 - 本のページ: page View が既定で、用紙は A5(
-param paper=a4|a6|b5|b6|letter|幅x高さ(mm))。余白は左右が幅の 10 %、上下が高さの 9 %。文字の大きさと書体は HTML と同じ(12 pt、フォントは名前で参照)。ページ番号を下の余白の中央に淡い色で描く(footer のレイヤー、番号は表紙から数える)。画像だけの章(表紙、挿絵)はページ番号を付けずに中央に置き、画像 1 つだけなら余白のないページに、ページに収まる大きさで上下左右の中央に置く。表紙によくある「svg の viewBox に image を 1 つ収める」書き方も、HTML と同じく独立した SVG 文書として描き(§3.16)、viewBox だけの svg は行の幅いっぱいに広げてからページに収める。ページより大きい画像は幅だけでなく高さもページに収まるよう縮める(lineCtx.maxObj。縦書きでは正立した画像の幅が行の並ぶ方向の長さになる)。-param views=scrollで HTML と同じ固定幅の scroll View だけに、bothで両方にする。 - 固定レイアウト:
rendition:layoutがpre-paginated(本全体の meta か itemref のrendition:layout-pre-paginated。Kindle のfixed-layoutも)で、spine の順に読む文書がどれも絵 1 つだけのページ(img、svg 要素、SVG の文書、spine の画像)なら、組版エンジンを使わずに fixed View を作る。画像を 1 つ viewBox に収めるだけの svg(マンガのページによくある書き方)はその画像をじかに描き、ほかの svg は独立した SVG 文書(§3.16 のwebdoc.SVGDocument)に、imgが指す SVG ファイルはそのまま格納する。ページの大きさは文書の viewport の meta(width=1200, height=1600、CSS ピクセル)、なければ svg の viewBox、なければ画像の大きさで、画像は縦横比を保ってページに収め中央に置く(SVG のxMidYMid meet)。代替テキストがあれば FIGURE にする。見開きの指定(page-spread-leftなど)は写さない。文字を組んだページが混ざる本は、警告を出してリフロー型として組む(絶対配置の CSS を解釈しないため)。 - 数式: MathML は HTML と同じく数式エンジン(§3.23)で組む。KaTeX や MathJax が MathML の横に置く独自の描画は、HTML と同じく落として MathML だけにする(
webdoc.NormalizeMath。html パッケージから移した)。縦書きの行の数式は横書きのまま正立させ、行の中央にそろえる(CSS の直交するフロー、MathML の math 要素は横書きなので縦書きの中では中央のベースラインにそろう。画像は欧文のベースラインから行の上側に立つ)。数式フォントはフォントを名前で参照するときも埋め込む(§3.23)。 - ページの向き: spine の
page-progression-directionが rtl の本は、fixed / flow の View のdirectionをrtlにする(spec §4.1)。右綴じで、見開きでは右から左へページが進む。デモのビューアはページを縦に積むだけなので、今は使っていない。 - 未対応: ルビ(親文字だけ)、上に挙げた以外の作者の CSS(書体、色、字下げ、余白、float、絶対配置)、html・body 以外の要素の書字方向(縦書きの中の横組みの表など)、音声・動画・スクリプト・メディアオーバーレイ、見開き、文字を含む固定レイアウトのページ(リフローで組む)、右から左へ書く文字(アラビア文字など)、DRM で保護された本。目次(nav)は spine にあれば本文として組み、なければ組まない。
- 壊れたファイルへの備え: 内容文書・スタイルシート・パッケージ文書は 1 つ 16 MiB、画像は 64 MiB まで。読むファイルは、伸張して合わせて「出版物の大きさ + 256 MiB」まで(格納しただけの画像が多い大きなコミックは読める)。内容文書のタグは合わせて 400 万個まで(要素が読み込みのメモリを決める。
<b/>だけの章を並べた 450 KB の EPUB が 18 GB を使っていた)。スタイルシートは 1 度だけ解析し、同じセレクタの規則はまとめ、セレクタの違う規則は 1 章 1 万個まで使う。固定レイアウトのページの SVG 文書は、合わせて「2 ×(出版物の大きさ + 256 MiB)」まで(画像を base64 で書き込むので)。超えた分は警告して除く。
テスト用の本は test/epub/gen.py(npm run test:epub:gen、標準ライブラリのみ)が書く 3 冊。basic.epub(英語。表紙、spine の中の目次、見出し・リスト・引用・図・表・コード・脚注、章をまたぐリンク、空要素のページ位置、CSS のクラスで隠す段落と中央寄せ、SVG ファイルと、隠したスプライトのグラデーションとシンボルを使うインラインの SVG、spine の外の注)、vertical.epub(電書協の作り方の縦書きの本。@import した CSS の vrtl / hltr、縦中横、傍点、ルビ、外字の画像)、fixed.epub(右綴じの固定レイアウトのマンガ。SVG の文書の表紙、画像のページ、インラインの SVG と SVG ファイルのページ)。変換結果は testdata/epub/ に置く。和文は Word のテスト用フォントのサブセットにある漢字だけで書いた。開発中は、市販の技術書(日本語と英語、数百ページ)が変換でき、ページの内容が元の本どおりであることも確かめた(1 冊 0.2 秒ほど)。
3.25 サーバー側のサムネイルと検索用テキスト(imagebdf、thumbnail、SearchText)
文書の一覧にはサムネイルが、サーバー側の全文検索には文書のテキストが要る。どちらも BDF を作るのと同時に作れば、元のファイルを読み直さずに済む。パスワード付きの入力なら、平文を持っているのは変換の間だけでもある(§3.7)。
描き方: ヘッドレス Chromium で @bdfkit/render を動かせば、ビューアと 1 画素も違わない画像が得られる(手動描画用の test/render.mjs がそうしている)。しかしそれにはサーバーに Chromium(数百 MB)を入れ、プロセスを起動・維持・隔離しなければならない。LibreOffice をやめる理由(README の Why BDF)と同じ重さを、サムネイルのために背負い直すことになる。そこで純 Go のラスタライザ imagebdf を書いた。変換器と同じく cgo も外部プログラムも使わず、変換と同じプロセスで、変換した *bdf.Document をそのまま描く。§2 の「ビューアで Go/wasm は使わない」は変わらない。ビューアは TS のままで、imagebdf はサーバーで使う第 2 の描き手である(デモサイトは、サーバーが作るものを見せるためにこれも wasm で動かす。後述)。
命令の解釈は TS の CanvasRenderer と同じ状態機械にした。SAVE・RESTORE のスタック、USE の暗黙の save/restore、トップレベルの Object(ページのレイヤー、シートのタイル)は変換とクリップ以外を初期状態から始めること、グループの一時キャンバスは変換と塗り・線・線幅・フォントだけを引き継ぐこと、ソフトマスク(輝度・アルファ・転送関数)、影の大きさを設定時の変換の拡大率で決めること(spec §7.2)、グループを囲む状態の影をグループ全体の影として合成のときに掛けること(Canvas が一時キャンバスを drawImage するときの影。draw.io の図形はこれで 1 つの影を落とす。影が届く分だけ、キャンバスの外にあるグループの中身も描く)。どれも TS の実装を写した。
- 塗り: 1 画素を 16 本の走査線で標本化し、横方向は区間の被覆を正確に積む。nonzero と evenodd の両方を扱う。軸に平行な矩形(FILL_RECT と CLIP_RECT が多い)は走査せず、被覆を直接計算する。曲線は出力の画素で 0.1 画素以内に折れ線にする。
- 合成: 乗算済みの float32 で持ち、Canvas の合成演算とブレンドモード(W3C Compositing and Blending)をすべて実装した。
source-in、destination-in、copyなどの、描いたものの外も変える演算は、Canvas と同じくクリップの全体に効かせる。 - 線: 輪郭をユーザー空間で作ってから変換する。非一様な変換でも Canvas と同じ太さになる。結合(マイター・丸・ベベル)、端、破線(サブパスごとにやり直し)を扱う。1 画素より細い線は 1 画素の幅で、太さの分だけ薄く描く。Skia のヘアラインと同じで、CAD の細線が縮小しても消えない。
- 画像:
imgconvのデコーダ(PNG、JPEG、GIF、BMP、WebP)で読み、JPEG と PNG は EXIF の向きに回す(ブラウザがそうする。imgconv.Orientation)。大きく縮小するときはミップマップを作り、描く大きさに近い段からバイリニアで取る。ブラウザの既定(imageSmoothingQualityが low)はミップマップなしのバイリニアだが、ページ全体を 256 画素に縮めるサムネイルではエイリアスが目立つので、ここは描き方を変えた。AVIF は純 Go のデコーダがないので描かない。1 億画素を超える画像も描かない。 - テキスト: 埋め込みフォントの WOFF2 は
woff2.Decodeで戻す(glyf・loca・hmtx の変換を逆にする。エンコーダと対にした)。グリフの輪郭はinternal/sfntから取る。TrueType は glyf(複合グリフを含む)を読み、CFF はサブセット化のために持っていた Type 2 charstring の解釈器を、境界だけでなく輪郭も出せるようにした。名前で参照するフォント(HTML、Markdown、EPUB、-fonts system)はfontdbで探す。変換器と同じ解決(代替フォントと総称ファミリー)を使うため、fontdb・sfnt・cffをconverter/internal/からモジュール直下のinternal/に移した。ブラウザに登録する FontFace は太さと斜体の記述子を持たない(§3.4)。そこで FONT の weight が 600 以上なら太字を、italic なら斜体を合成する。太字は Skia と同じく字の大きさの 1/24〜1/32 だけ輪郭を太らせ、斜体は 1/4 傾ける。字形のない字は、ファミリーのリストの残りと総称ファミリーの代替から探す。advance補正、揃え、ベースライン(Blink と同じく em ボックスから求める)、字間はビューアと同じにした。シェーピングはしないので、カーニング・合字・アラビア文字の字形変化はない。右から左の文字はランを反転するだけにした(Unicode の双方向アルゴリズムの全部ではない)。縦書きは変換器が 1 字ずつ回して置くので、そのまま描ける。 - SVG の画像: ビューアではブラウザが描くが(§3.19)、Go には SVG を描くものがない。EPUB の表紙の多くは画像を包んだ SVG なので、これを描かないと EPUB のサムネイルが白紙になる。そこでプレビューに出てくる範囲の SVG を描く小さなレンダラを書いた。読むところは公開パッケージ
image/svgにあり、単独でも使える: 要素の木、<style>の適用、色・長さ・変換の値を読む。パスデータ(d属性)だけはラスタライザが自分のパスに直接読むので、image/svgは文字列のまま渡す。図形とパス(円弧を含む)、塗りと線、線形・放射グラデーション(hrefの継承、objectBoundingBox)、変換、useとsymbol、入れ子のsvg、viewBoxとpreserveAspectRatio、クリップパス、不透明度、<style>の型・クラス・ID・子孫セレクタ、currentColor、テキスト、data: URL の画像(入れ子の SVG を含む)を扱う。マスク、フィルター、パターン、マーカーは描かない。大きさはビューアと同じく SVG の自然な大きさ(ルートの幅・高さ、なければ viewBox、なければ 300 × 150)で測り、描く大きさのラスターを作って(√2 刻み)画像として描く。useで要素が指数的に増える SVG に備え、1 枚で描く要素を 20 万までにした。 - 単体の Object:
imagebdf.Objectは、文書に入れていない*bdf.Object(他の Part を参照しないもの)を描く。画像のBounds()を Object の原点からの画素にするので、数式ならベースラインの位置が画像からわかる。背景は指定しなければ透明。 - 大きさの上限: 描く画像は 64 M 画素まで(
imagebdf.MaxPixels)。キャンバスは 1 画素 16 バイトで持つ。 - 信頼しない文書への備え: ラスタライザは利用者がアップロードした文書を描くので、文書が決める数と大きさには上限を置く。超えた分は警告して描かず、残りを描く。描いている途中のパニックはエラーにする(
Page、Region、thumbnail.Make)。開いているグループとソフトマスクは合わせて 64 個、SAVE はキャンバスごとに 1,024 段まで。グループ・マスク・クリップが同時に使うメモリは、描く画像 8 枚分(少なくとも 64 MiB)まで。USEの入れ子は 64 段(bdf.MaxUseDepth)、1 回の描画で描き直す Object から読む命令は 2^24 個まで(1 回目に読む命令は文書の大きさで決まるので数えない)。パスは、1 つの命令で展開後の点と走査する辺を各 2^20 個、走査線で訪れる辺を累計 2^24 回までとし、超過した描画は警告して省略する。線は、1 つの命令で 2^20 個の破線、入力の点 1 つにつき 16 個と 2^20 個の輪郭の点まで。影のぼかしは 1,024 デバイス画素まで。ページの大きさと拡大率は 0 より大きいこと(大きさのないページはエラー)。シートのタイルは 1 単位以上、グリッド線は軸ごとに 2^16 本まで。デコードした画像と SVG のラスタは合わせて 2.56 億画素まで持ち、古い描画のものから捨てる。見えないところに描かれる画像はデコードしない。SVG は、要素の入れ子 256 段、要素 2^20 個、スタイルの規則と要素の照合 2^22 回(読むときの上限で、image/svgのOptionsの既定値)、半透明の要素のレイヤーは同時に 8 枚まで。幅か高さが 1 画素に満たない SVG のラスタは 2 × 4096² 画素に収める。 - 速さ: サムネイルは 84 文書で 1 枚あたり約 13 ms から 3 ms に、2 倍の解像度のページは約 80 ms から 32 ms になった(フォントの読み込みの共有を含む。描画の近道だけでは約 3 割)。描く範囲の外にあるパスと文字は、制御点の外接矩形で判定して辺を作る前に捨てる。走査線の交点は 16 個を超えたら pdqsort で並べ、辺をその順で持ち越す(右から左へ描かれた細い図形 2,000 個で、挿入ソートが 130 秒かかっていた。同じ位置の交点の順は結果に関係しない)。辺・点・行のバッファは描画をまたいで使い回し、単色の塗りは画素ごとの関数呼び出しをしない。グラデーションの表はペイントごとに 1 度だけ作る。これらの近道は画素を変えない:
TestShortcutsChangeNoPixelが、testdata の全文書を近道ありとなし(パッケージ変数plain)で描いてバイト単位で比べる。
確かめ方: Go のテストは、各描画命令の画素・寸法・クリップ・色・透明度、フォント読み込み、資源制限と不正入力を直接検証する。キャッシュの有無と並列描画は、現在の実装の各経路が同じ結果になることを確かめる。ブラウザ側は描画の成立、検索、テキスト選択、文書構造を機能テストで検証する。
サムネイルのレイアウト(thumbnail)は文書の種類で決める。種類は変換器が書く Meta.Source(既存の BDF からでも分かる)と View の種類で見る。
| 種類 | レイアウト |
|---|---|
| Word、HTML、Markdown | 1 ページ目(scroll View は先頭)の左上から、ページの幅の正方形(Crop) |
| 音声(MP3、M4A、AAC、FLAC、Ogg、WAV、AIFF) | カードの上端の正方形: ページの幅いっぱいのカバーアート(なければプレースホルダー)(Crop) |
| Excel、CSV、Parquet | 最初のシートの A1 から、サムネイルの大きさを 72 dpi(1 単位が 1 画素)で描く正方形。96〜480 単位で、シートより大きくはしない。既定の行の高さ(15 単位)なら 64 画素で 6 行、128 画素で 8 行、256 画素で 17 行、512 画素以上で 32 行ほど。枠線はビューアと同じく描く |
| PDF、TIFF | 縦長のページは文書として Crop、横長のページはスライドとして全体(Fit) |
| PowerPoint、Visio、draw.io、CAD、プリント基板、Illustrator、Photoshop、画像、EPUB | 1 ページ目の全体を、長辺が指定の大きさになるように(Fit)。EPUB は 1 ページ目が表紙 |
サムネイルが見せるのは最初の View の 1 ページ目だけなので、サムネイルのためだけに変換するなら 1 ページ目だけを変換すればよい(converter.Options{Pages: converter.PageList(1)})。bdf thumbnail に BDF でない入力を渡したとき、デモサイトのサムネイルのページ、light-server はそうしている。全形式のテストデータと手元の実データで、全体を変換したときとサムネイルが画素まで同じになることを確かめた。545 ページの PDF で 17 秒が 30 ミリ秒になる。
-thumbnail-mode crop|fit(Options.Mode)で指定もできる。シートの範囲を初めはシートの短い辺(480 単位まで)にしていたが、256 画素のサムネイルでは 1 行が 8 画素ほどになり字が潰れた。行や列の数ではなく解像度で決めれば、小さいサムネイルほど狭い範囲を、同じ大きさの字で見せられる。下限は 64 画素でも表に見えるだけの行と列(既定の列幅で 1 列半)を残すため、上限は大きいサムネイルでも A1 の角を見せるためである。解像度は -thumbnail-sheet-dpi(Options.SheetDPI)で変えられる。Fit の出力は正方形にしない(縦横比を保つ)。並べるときは CSS の object-fit: contain で収めればよい。出力は PNG、JPEG、WebP(imgconv の純 Go のエンコーダ)。
検索用テキスト(Document.SearchText)はテキスト索引 Part(§4、spec §7.9)から作る。ビューアの検索と同じものなので、変換器が MARK で付けた区切り(行・段落・セル)がそのまま使える。索引のない View は Object から取り出す。ページ番号(1 始まり)は検索のヒットから #page=N で開くのに使える。シートはシート全体で 1 つにする。Word・Markdown・EPUB は同じ本文を page View と scroll View の両方に組むことがあるので、flow View がある文書では scroll View を除く。正規化(NFKC、かな)は検索エンジンに任せ、テキストはそのまま出す。
パスワード付きの入力: サムネイルもテキストも暗号化されない。サムネイルは 1 ページ目の中身を見せるので、テキストと同じ扱いにした。bdf generate は、暗号化して書き出す文書(既定ではパスワード付きの入力のもの)について、-allow-plaintext がなければどちらも書かない。bdf thumbnail・text・render も、暗号化された BDF は $BDF_PASSWORD と -allow-plaintext の両方がなければ描かない。ライブラリの API(thumbnail.Make、SearchText)は復号した文書を受け取るだけなので、作るかどうかは呼び出し側が Result.Protected や Document.Lock を見て決める。
wasm の引数: cmd/bdfwasm のメソッドは、引数が Uint8Array でないときや、引数を読む間にパニックしたときに、Promise を reject する(以前は Go のプログラムが終了し、その後の呼び出しがすべて失敗していた)。
デモサイト: cmd/bdfwasm を -tags previewonly でビルドした 5 つ目のモジュール(bdf-preview.wasm、約 9.5 MB、gzip 2.8 MB)は変換器を持たず、imagebdf・thumbnail・SearchText で単一ファイル形式の BDF のサムネイルとテキストを作る。ビューアの「Thumbnail and text」を開くと、表示中の文書(変換したもの、開いた BDF、?src= の単一ファイル)を渡し、そのとき初めてモジュールを読み込む。大きさ(64・128・256・512)、レイアウト(auto・crop・fit)、形式を選べる。ビューアの TS の描画で作らないのは、サーバーが作るのと同じものを見せるためである。形式は PNG と JPEG だけにした。WebP のエンコーダを入れるとモジュールが gzip で約 1 MB 増えるので、ほかのモジュールと同じく bdf_noconv でビルドしている。ブラウザにはシステムのフォントがないので、名前で参照するフォント(HTML・Markdown・EPUB)はサイトのフォントのディレクトリ(imagebdf.Options.FontFS)から探す。パスワード付きの入力から変換した文書と暗号化した BDF のものも作る(パスワードを入れて開いた人が自分で頼むのが -allow-plaintext にあたる)が、暗号化されないことをパネルに書く。暗号化した BDF にはビューアが開いたときのパスワードを渡す。
PDF の保存とフォームのページ: 同じモジュールに pdf(vector/pdfbdf)を足し、デモサイトのビューアは「Download PDF」で表示中の文書を PDF にする(約 1 MB、gzip 0.26 MB の増加)。押したときに初めてモジュールを読み込む。このモジュールだけは WOFF2 の伸張を入れてビルドする(タグ bdf_woff2dec。Brotli の伸張だけで約 1.8 MB、gzip 0.4 MB)。bdf コマンドが作った文書のフォントは WOFF2 で、これがないと PDF とサムネイルから文字が抜けるためである。ブラウザの中で変換した文書のフォントは圧縮していないので、ほかのモジュールには要らない。暗号化した BDF には PDF の保存を出さない(平文の PDF になるため)。
フォームのページ(site/form)は、帳票のテンプレートを試すためのもので、6 つ目のモジュール(-tags formonly、bdf-form.wasm、約 26 MB、gzip 7.0 MB)を使う。Word・Excel・PowerPoint・PDF の変換器と form・pdfbdf だけを入れ、fields(タグやフィールドの一覧と JSON Schema、形式、テンプレートが持つサンプル)・fill・pdf を出す。PDF フォームと pdfme の土台の PDF を読むために PDF の変換器が入り、それまでの約 18 MB(gzip 5.1 MB)から増えた。Office 系のモジュールに足さなかったのは、ビューアの利用者の読み込みを増やさないためと、フォームのページだけを開く人が 30 MB のモジュールを読まずに済むようにするためである。ページは、スキーマから入力欄を組み立てる(オブジェクトは枠、値だけの項目の配列は表、入れ子の配列は項目ごとの枠、真偽値はチェックボックス、日付は日付の入力、画像はファイルの選択、enum は選択肢。default があれば最初の値にし、maxLength は入力の上限にする)。値の型が決まっていない欄(["string", "number"])は、JSON の数値の書き方なら数値として渡す(Excel の数式がその値で計算できるように。007 は文字列のまま)。入力が止まって 0.25 秒後に穴埋めし直し、描いてあるページは新しい絵ができるまで残す(ちらつかない)。サンプルは 3 つの形態 × 3 つの形式に、PDF フォーム 1 つと pdfme のテンプレート 2 つを足したもので、テストのテンプレートとデータをそのまま使う。PDF は、通常のもの、タグ付き、PDF/A(2b・2u・2a)から選んで保存できる。
3.26 Parquet → BDF 変換器(converter/parquet)の構造
Parquet は分析用のデータを列ごとに圧縮して持つ形式で、pandas・Polars・DuckDB・Spark が書き、Hugging Face のデータセットもこの形で配られる。中身は 1 つの表なので、CSV(§3.10)と同じく xlsx.Grid にして sheet View を 1 枚作る。CSV と違って推定するものはない。型はファイルに書いてあり、値をどう文字にするかを決めればよい。
- 読み込みは自前で書く: Go の既存の実装(apache/arrow-go の parquet、parquet-go/parquet-go)は、wasm にすると何もしないプログラムの 2.6 MB に対して 22〜34 MB になり、Office 系のモジュール(§2)がほぼ倍になる。仕様(parquet-format の parquet.thrift、Encodings.md、LogicalTypes.md、VariantEncoding.md、VariantShredding.md)から、表示に要る部分だけを書いた。フッターとページヘッダーは Thrift の compact protocol で、知らないフィールドは読み飛ばす(
thrift.go)。依存は伸張だけで、Zstandard はgithub.com/klauspost/compress/zstd(速い。Polars と Databricks の既定の圧縮)、Brotli はすでに使っているandybalholm/brotli(wasm のビルドでは外す。パッケージが表を初期化するだけで Office 系のモジュールが 1.7 MB 増え、Brotli で圧縮した Parquet は少ない。ブラウザではその列を空にして警告を出す。brotli_js.go)、gzip は標準ライブラリ。Snappy(pyarrow・DuckDB・Spark の既定)と LZ4 のブロックは短いので自前で書いた(klauspost の s2 は Snappy の伸張だけでも wasm を 1.3 MB 増やす)。 - 読む範囲: 末尾の
PAR1の前の長さからフッター(FileMetaData)を読み、スキーマの木、行グループ、列チャンクの位置を得る。列チャンクはページのヘッダーとページを 1 つずつ読み、表示する行に達したらやめる(入れ子でない列は値 1 つが 1 行、入れ子の列は繰り返しレベル 0 が行の始まり)。ファイル全体をメモリに読むことはない。dictionary_page_offsetが 0 のファイル、辞書ページのヘッダーを列チャンクの大きさに数えなかった parquet-mr 1.2.8 までのファイルも読む。 - ページとエンコーディング: 辞書ページと、データページの v1(レベルと値をまとめて圧縮)と v2(値だけを圧縮。
is_compressedが false なら無圧縮、値の部分が空なら伸張しない)。PLAIN、RLE/ビットパックのハイブリッド(レベル、辞書の添字、RLE の真偽値)、非推奨の BIT_PACKED(レベル)、PLAIN_DICTIONARY と RLE_DICTIONARY、DELTA_BINARY_PACKED、DELTA_LENGTH_BYTE_ARRAY、DELTA_BYTE_ARRAY、BYTE_STREAM_SPLIT。どれも値を 1 つずつ取り出すストリームにしたので、「数十億の値がある」と書いた小さなページも、読まれるまでは何も作らない。非推奨の LZ4 は書き手によって枠組みが違うので、Arrow と同じく Hadoop の枠組み(大きさが 2 つ並ぶブロックの列)、LZ4 のフレーム形式、生のブロックの順に試す。 - 入れ子の値: 葉の列の値ごとの繰り返しレベルと定義レベルから行の値を組み立てる(
nested.go)。定義レベルは経路のどこまでが存在するか(null か空のリストか)、繰り返しレベルはどの繰り返しフィールドで新しい要素が始まるかを表す。構造体の葉は同じ値を要素ごとに埋めていく。LIST の注釈は LogicalTypes.md の後方互換の規則 5 つ(2 段のリスト、arrayや…_tupleという名前、要素の名前を問わない)で要素を決め、MAP_KEY_VALUEは MAP として、値のない MAP は Arrow と同じくキーのリストとして、注釈のない repeated のフィールドもリストとして読む。表示は JSON の書き方にそろえる(リストは[1, 2]、構造体とマップは{"x": 1}、文字列・日付・時刻・UUID は引用符で囲み、数値と真偽値はそのまま)。数字でないマップのキーは{1: "a"}のように書く。 - Variant: 値の型が行ごとに違う列(Spark 4、Iceberg v3、DuckDB が書く)。metadata(フィールド名の辞書)と value(型の付いたバイナリ)を読んで JSON にする。分解保存(shredding)された列は、typed_value の型付きの列(プリミティブ、フィールドごとの value と typed_value のグループ、要素ごとの value と typed_value のリスト)と value の残りから値を組み立て直す(VariantShredding.md の
construct_variant)。typed_value のフィールドと同じ名前が value にもある不正なファイルは、Iceberg と同じく型付きの方を採る。注釈のない、binary のmetadataとvalueだけのグループ(注釈ができる前の Spark)も Variant として読む。 - 値の文字: 整数と decimal は全桁(decimal は INT32・INT64・固定長・可変長のどれでも。32 桁を超えても丸めない)。浮動小数点数は元の精度で読み戻せる最短の表記で、JavaScript と同じく 1e21 以上と 1e-6 未満だけ指数表記(NaN、Infinity)。FLOAT16 も半精度で最短。日付は
2026-09-27、時刻とタイムスタンプは2026-09-27 12:00:00(isAdjustedToUTC のものは UTC。時差はファイルにない)で、秒の小数は列の値が要る桁数(0・3・6・9 桁、単位の精度まで)に列全体をそろえる(すべて整数の秒なら小数を付けない。右寄せの列で桁がそろう)。INT96(Spark と Impala の古いタイムスタンプ)はユリウス日とその日のナノ秒から。UUID は標準の表記、INTERVAL は ISO 8601 の期間(P1Y2M3DT4H5M6.5S)。文字列の注釈がないバイト列は、UTF-8 の文字列として正しく制御文字を含まなければ文字列として(注釈なしで文字列を書いた古い書き手)、そうでなければ 16 進(0x…、32 バイトまで。長いものはバイト数を添える)で示す。BSON は 16 進。ジオメトリ(GEOMETRY と GEOGRAPHY の注釈、GeoParquet のメタデータgeoが WKB と言う列)は WKB を WKT にする(ISO の Z・M・ZM と PostGIS の EWKB のフラグと SRID、空のジオメトリ、GeometryCollection)。数値・日付・時刻・期間は Excel と同じく右に、ほかは左に揃える。1 つのセルは Excel と同じく 32,767 バイトまでで、長い値は切って…を付ける。 - 見た目: 1 行目に列名(上位のフィールド。構造体は開かずに JSON の 1 列)、2 行目に型(
int64、decimal(10, 2)、timestamp[ms, UTC]、list<string>、struct<w: int32, h: int32>、variant、geometryなど)を置き、2 行を見出しとして固定する。型の行はxlsx.Grid.SubHeaderで太字でなく灰色(Office の「テキスト 1、白 + 基本色 50%」)にする。論理型が物理型に合わない列(INT32 に UUID など)は、値と同じく物理型の名前にする。-param types=falseで型の行を外し、-param table=で CSV と同じくテーブルスタイルを付ける。シート名はファイル名から拡張子を除いたもの。フォントと言語は CSV と同じ(かな・ハングルを列名と値から数える)。 - 行数の上限: 既定では先頭の 10,000 行(
-param rows=で指定、allでシートの上限の 1,048,574 行まで)。読むのは速いが(100 万行・8 列で 0.6 秒)、シートの組版が 10 万行で 3 秒・690 MB、100 万行で 45 秒・2.7 GB かかるため(CSV も同じだが、Parquet は数百万行が普通)。既定では 400 万セルも上限にし、列の多い表は行を減らす。列はシートと同じく 16,384 列まで。超えた分は警告を出す。 - 壊れたファイルへの備え: シートのセルは、
rows=の指定にかかわらず 2^24 個まで(行グループが言う行数と列の数から、ページを読む前にセルを確保するので)。ページの大きさ(512 MiB まで)やヘッダーが言う伸張後の大きさを信じてメモリを確保しない(Snappy と LZ4 は入力が伸びうる大きさまで、Zstandard・gzip・Brotli は伸張しながら確保する)。Thrift の入れ子、スキーマの深さ、1 行の 1 つのリストの要素数(100 万)、入れ子の値の書き出し(128 段)に上限がある。読めない列(未対応の圧縮やエンコーディング、壊れたページ)は空にして警告を出し、ほかの列は読む。テストには、テストファイルの各バイトを変えたものと途中で切ったものの読み込みと、fuzz テスト(1 入力 1 秒以内)がある。 - 形式の判別: 先頭と末尾の
PAR1。フッターを暗号化したファイル(両端がPARE)も Parquet と判別し、未対応のエラーにする(ErrEncrypted)。フッターが平文で一部の列を暗号化したファイルは、その列を空にして警告を出す。 - 未対応: 暗号化(Parquet Modular Encryption。鍵はパスワードではなく AES の鍵そのもので、KMS から取り出すか呼び出し側が渡す)、ブラウザ版の Brotli、LZO、parquet-format 2.14 で試行扱いの ALP エンコーディング、ほかのファイルにある列チャンク(
file_path)、Arrow のスキーマ(ARROW:schema)が持つ時差や辞書の情報。ページインデックス、ブルームフィルター、統計は値の表示に要らないので読まない。
Parquet 以外の列指向の形式も調べた(2026-09)。Arrow IPC(Feather)、ORC、Avro は読む側の仕様がそろっている。Lance はデータセットがディレクトリで、ファイル形式(2.0、2.1、2.2、不安定な 2.3)が数週間ごとに変わり、2.0 のエンコーディングは文書がなく Rust の実装が仕様になっている。Vortex は 1.0 前で、ファイルの互換は新しい版が古いファイルを読むことしか保証されず、30 余りのエンコーディングに言語によらない仕様がない。Nimble は安定性を保証しておらず、仕様もなく、Meta の中の圧縮で書いたファイルは外では読めない。いずれも見送った。
テスト用のファイルは test/parquet/gen.py(npm run test:parquet:gen。pyarrow 25.0.1 と DuckDB 1.5.5)が書く 9 つ。basic(pyarrow の既定の出力: Snappy、辞書、v1。英語と日本語、改行を含む値、null、decimal、日付、ミリ秒のタイムスタンプ、リスト、構造体)、types(Zstandard。整数の端の値、float16、NaN と無限大、INT32・INT64・固定長の decimal、UUID、JSON、全単位の時刻とタイムスタンプ、辞書の文字列、null の列)、nested(空・null・null を含むリスト、リストのリスト、構造体のリスト、マップ、固定長のリスト)、encodings(v2 のデータページ、gzip。各エンコーディングの列と同じ値の PLAIN の列)、codecs(同じ値を圧縮ごとの列に)、legacy(INT96、準拠しない要素名のリスト)、duckdb(分解保存された Variant、UUID、INTERVAL、GEOMETRY、PLAIN_DICTIONARY)、variant(オブジェクトとリストに分解保存された Variant)、geo(GeoParquet の WKB)。今の書き手が作らない構造(2 段のリスト、MAP_KEY_VALUE、BIT_PACKED のレベル、Hadoop の LZ4)は、テストの中の小さな書き出し器で作る。開発中は、これらと Apache の parquet-testing(77 ファイル、約 23 万セル)の値を pyarrow が読む値とセルごとに突き合わせ(pyarrow 自身が読めない列と、int64 に収まらない INT96 を除いてすべて一致)、Variant は parquet-testing の分解保存の 138 例がすべて一致することも確かめた。変換結果は testdata/parquet/ に置く(CI が変換し直して比べる)。フォントは CSV と同じく PowerPoint のテストのものだけを使う。
3.27 楽譜と演奏(converter/mml、converter/midi、converter/musicxml、converter/internal/music)
MML、MIDI、MusicXML を五線譜のページにし、View にその音楽を持たせてビューアで演奏する。BDF に音楽の命令はなく、楽譜は変換器が組んだ線・パス・文字の描画になる。音楽は描画とは別の Part(seq、Standard MIDI File)と、演奏の時刻とページの上の位置を結ぶ cue の索引で、View.play に置く(spec §4.4)。
- 2 つの形: 組版エンジン
converter/internal/musicは音楽を 2 つの形で受け取る。Performanceは演奏されたとおりの音楽(トラックごとの時刻と長さのある音、テンポ・拍子・調の変化、歌詞、音色とコントローラー)で、MML と MIDI の読み手が作る。Scoreは書かれたとおりの音楽(小節、パートと段、声部の音符・和音・休符と音価、連桁、連符、記号)で、MusicXML の読み手が作り、NotateがPerformanceから作る。BuildがScoreを組んで文書にする。位置と長さは 4 分音符 960 tick で数える。 - 記譜(Notate): 拍子(ファイルのもの、なければ 4/4、
-param time=で指定)で小節に分け、拍ごとに音の頭に最も合う格子(8 分、3 連の 8 分、16 分、3 連の 16 分、32 分)を選んで量子化する(合うものがなければ誤差の和が最小のもの)。同じ時刻に始まって終わる音は和音、重なる音は 2 つ目の声部にし、小節ごとに高い方を第 1 声部にする。わずかに次の音に重なるレガートは次の音の頭で切る。音価への分割は、拍の頭から始まる音は拍をまたいでよく(付点は拍の単位で)、拍の途中から始まる音は拍の中に収め、休符は自身の長さに揃える(複合拍子の付点 4 分休符だけ付点を許す)。小節線と拍をまたぐ音はタイでつなぐ。3 連の拍の中は 3:2 の連符にし、他の声部の 3 連に合わせて 16 分の格子に乗る部分はそのまま書く。調はファイルのもの(シーケンサーが既定で書く C 長調だけのときは使わない)か、音の長さの音高クラスの分布から Krumhansl–Schmuckler で推定したもの(-param key=で指定)。音の綴りは調の音階の音はその綴り、それ以外は五度圏で調の中心に近い方(短調は導音と上行の第 6 音が♯側に寄るように中心をずらす)、MML のc+・d-の書き方が与えられればそれを優先する。段は音域で選ぶ。鍵盤楽器(GM のピアノ・クロマチックパーカッション・オルガン・ハープ)が中央ハの上下を 15% 以上ずつ使い 2 オクターブ以上にわたれば大譜表(中央ハで分ける)、ギターは 8 下げのト音記号、他は中央値が A3 より下ならヘ音記号。チャンネル 10 は打楽器譜で、GM の打楽器を一般的なドラム譜の位置と符頭(シンバル類は × の符頭、足は第 2 声部)に置き、打った音は同じ声部の次の音まで(拍の終わりまで)の長さにする。MIDI の歌詞は近くで始まる音符に付ける。テンポの変化は 1 拍以上続き、最後に示したものと 3% 以上違うものだけを、1 小節に 1 つまで速度記号にする。 - 組版(Build): 単位は譜線の間隔(staff space、既定 1.75 mm、7 mm の五線)で、A4 縦のページに組む(段が多く 1 ページに入らないときは間隔を小さくする)。記号は SMuFL の参照フォント Bravura(SIL OFL 1.1)の約 130 の字形で、
tools/gen-smuflが Bravura の CFF の輪郭(internal/sfntで読む)、送り幅、外接矩形、SMuFL のアンカー(符幹・旗の付く点)と Bravura の engraving defaults(線の太さ)を Go の表に書き出す(music/smufl)。字形は文書に 1 つのパス集合 Part にして、各ページからFILL_PATH_ATで使う(同じ字形は文書全体で 1 回だけ格納される)。- 横方向: 小節ごとに全パートの音の頭(slot)を集め、次の slot までの長さから理想の間隔(小節の最短の長さを基準に
2.6 + 1.6 log2(長さ/最短)spaces)と、臨時記号・付点・旗・歌詞がぶつからない最小の間隔を求める。小節を理想の幅で詰めて段に分け(MusicXML の段の区切りも守る)、段の幅になるまで理想の間隔を一律に伸ばす(最小の間隔は下回らない)。最後の段は 8 割に満たなければ伸ばさない。段の頭には音部記号・調号(最初の段と変わった小節では拍子記号)を置き、途中の変化は小節の頭に置く(調号が C やもう一方の側に変わるときは本位記号で打ち消す)。 - 段の中: 符頭(2 度の和音は符幹の反対側に出す)、加線、臨時記号(3 spaces 以内で重なるものは列を分ける。自動のものは調号と小節内の前の音から決め、タイで続く音には付けない)、符幹(中央の線から遠い音で向きを決め、声部が 2 つなら第 1 声部を上、3.5 spaces で、五線の外の音は中央の線まで伸ばす)、旗、連桁(拍ごと。4/4 で 8 分だけなら半小節ごと。連符は全体で 1 つ。傾きは両端の差で最大 1 space、中の音が端より外に出るときは水平。2 本目以降は部分的な連桁も)、付点、アーティキュレーション(符頭の側。アクセントとマルカートは五線の外)、フェルマータと装飾記号(五線の上)、タイ(和音の上半分は上へ、他は符幹の反対側)、スラー(符幹が揃っていれば符頭の側、間の音を越える高さ)、連符の数字と括弧(1 つの連桁でまとまっていれば数字だけ)、強弱記号とヘアピン、文字、コードネーム、ペダル、オクターブ線、セーニョとコーダ、歌詞(節ごとに 1 行、語の中はハイフン、メリスマは延長線)。段をまたぐタイとスラーは段の端で切って続ける。上下の記号は、それまでに描いたものの輪郭(skyline)を避けて置く。
- 段の上に速度記号(太字の文字とメトロノーム記号)、リハーサルマーク(枠付き)、繰り返しの括弧、2 段目以降の小節番号を置く。複数のパートは段の左に括弧、大譜表は中括弧でつなぎ、小節線はパートの段を通して引く(繰り返し記号の点、複縦線、終止線も)。最初の段の左にパート名(2 段目以降は略名)。
- 縦方向: 段の間は、上の段の下にはみ出したものと下の段の上にはみ出したものがぶつからない距離(パートの中は 5.5、パートの間は 6.5 spaces 以上)。段の組は 8 spaces 以上離し、入らなければ次のページへ。6 割以上埋まったページは段の組の間を広げてページに散らす。1 ページ目の上に題名・副題・作曲者(右)・作詞者(左)・編曲者。
- テキスト(題名、歌詞、文字、コードネーム)はセリフのフォント(Times New Roman を求め、ない字は和文などのフォールバック)で測って埋め込む。歌詞は段落・行・語の中の折り返し(WRAP)の MARK を付けるので、検索とコピーで歌詞が文として読める。段は FIGURE(代替テキストは「Measures 1–4」)で囲む。
- 横方向: 小節ごとに全パートの音の頭(slot)を集め、次の slot までの長さから理想の間隔(小節の最短の長さを基準に
- 演奏(
View.play): MIDI はファイルをそのまま(壊れていれば切り詰めて直したもの、フォーマット 2 は 1 にしたもの)格納し、MML と MusicXML はPerformance.SMFがフォーマット 1 の SMF(指揮トラックに題名・拍子・調・テンポ、トラックごとに名前・音色・音量・パンとコントローラー、歌詞)を書く。cue は段ごとの矩形(上下に 1 space 広げた段)と、小節の各 slot と小節線の x を、演奏の順(Score.PlayOrder。MusicXML の繰り返しを展開したもの。なければ小節の順に 1 回ずつ)に SMF の tick で並べる。MIDI の cue は元のファイルの分解能に直す。 - ビューア:
@bdfkit/coreのparseSmfが SMF を読み(テンポの変化を秒に直す)、decodeCuesが cue を読む。@bdfkit/renderのMusicPlayerがメインスレッドで Web Audio API で鳴らす。30 ms ごとに 150 ms 先までの音を予約し、GM のプログラムの系統ごとに波形と包絡を選ぶ(ピアノは三角波の減衰、弦は立ち上がりの遅いのこぎり波、など)。MML の音色は GM のプログラムに写す: 矩形波は 80(Square Lead。デューティー比は CC 70)、のこぎり波は 81、三角波は 82、正弦波は 79、ノイズは 122。チャンネル 10 は合成した打楽器(バスドラムは正弦波の音程の下降、スネアはノイズと音、ハイハットは高域のノイズ)。ベロシティと CC 7・11 は GM の 2 乗の曲線、CC 64 のサステイン、±2 半音のピッチベンド、64 声まで。デモビューアは View にplayがあるとツールバーに再生・停止と時刻を出し、演奏中は段の高さの縦線のカーソルを描き、段が見えなくなればスクロールし(見開きではページをめくり)、段をクリックするとそこから演奏する。スペースキーで再生と一時停止。 - MML(
converter/mml): 3 つの方言を中身から判別する(-param dialect=)。汎用(FlMML、MSX・N88 の PLAY 文。;でトラックを区切り、#TITLEなどの見出し、/* */と//の注釈、$a=…;のマクロ、/:n … / … :/のループ)、マビノギ(MML@旋律,和音1,和音2;)、ファミコンの MCK・PPMCK(行頭のトラック名 A〜、ABCは複数のトラック、;の注釈、@v0 = { … }などの定義は読み飛ばす、#OCTAVE-REV)。共通の命令は音名と+ # -、長さと付点、r、o<>、l、t、v、@、&(同じ高さはタイ)、^、n、%(FlMML は全音符 384、PPMCK は 192 の単位)、[ … | … ]nのループ、{ … }nの連符。<と>の向きは方言の既定で、汎用の方言では両方の向きで読んで音程の跳躍が小さい方を選ぶ(-param octave=)。qは記譜に関係しないので無視し、音符の長さは書かれた長さ。vはベロシティにする(v0の音は休符)。Shift_JIS も読む。署名のない MML は判別できないので、判別できるのはMML@、見出し(#TITLEなど)と MML の行、PPMCK のトラック行の 2 行以上で、ほかは拡張子(.mml)による。 - MIDI(
converter/midi): SMF のフォーマット 0・1・2 と RIFF の RMID、ランニングステータス、SMPTE の分解能(120 BPM とみなして秒で読む)を読む。トラック(MTrk とチャンネルの組)ごとにパートにし、名前はトラック名、なければ GM の音色名。文字列は UTF-8、Shift_JIS、Windows-1252 をファイルごとに判別する。題名は指揮トラックの名前(Tempo Trackなどの仮の名前は除く)かカラオケの@T。歌詞は meta 05 か .kar の文字で、最も合うトラックの音符に付ける(語の中の音節は「-」でつなぐ)。 - MusicXML(
converter/musicxml): partwise と timewise、圧縮した .mxl(META-INF/container.xml の rootfile)、UTF-16 を読む。書かれたとおりにScoreにする: パートと段、声部(backup・forward)、和音、音価と付点、連符(tuplet の開始と終了、なければ time-modification の並び)、装飾音、連桁、符幹、臨時記号(要素がなければ付けない。臨時記号の要素が 1 つもないファイルは自動)、タイ、スラー、アーティキュレーション、装飾記号、トレモロ、歌詞(節、音節、延長線)、強弱記号、ヘアピン、文字、ペダル、オクターブ線、コードネーム(harmony の根音・種類・度・ベース音から「Cm7」「G7/B」)、音部記号・調号・拍子の変更、小節線、繰り返しと括弧、速度記号、リハーサルマーク、段とページの区切り。オクターブ線の下の音は MusicXML では実音で書かれているので、線の分だけずらして記譜し、演奏は書かれた音のまま。小節番号はファイルのもの、小節の長さは拍子のもの(中身が短い・長い小節は中身のもの)。演奏は繰り返しと括弧(入れ子も。何回目に演奏するか、番号のない括弧は並びの順)、D.C.・D.S.・Fine・To Coda(sound 要素、なければ文字と記号)を展開し(跳んだ後は繰り返さず最後の括弧を演奏する)、テンポは sound の tempo かメトロノーム記号、強さは sound の dynamics か強弱記号(sf などとアクセントは強める)、音色は midi-instrument、移調楽器は transpose で実音にする。装飾音は主音の前に最大 16 分音符の長さで主音から時間を借りて鳴らし、スタッカートは半分の長さ。歌詞は繰り返しの k 回目に k 番の節を付ける。 - 判別: CSV の判別は行ごとの区切り文字の数による推測なので、
Format.Guessを付けて最後に聞き、ファイル名に他の形式の拡張子があればそちらにする(;でトラックを区切った MML や、,で声部を区切ったマビノギの MML が CSV に見える)。MIDI はMThdか RIFF のRMID、MusicXML は先頭の<score-partwiseか<score-timewise(DOCTYPE も)、または .mxl の ZIP の container.xml。 - 壊れたファイルへの備え: 譜表は 4,096 段、譜表 × 小節は 100 万まで(どのパートも、どの小節にも少なくとも休符を書くので、201 トラック × 1 万小節の 410 バイトの MML が 800 MiB になっていた。記譜はトラック × 小節を、組版は譜表 × 小節を、書く前に確かめてエラーにする)。音符は今までどおり 200 万個まで。記譜は 2 万小節で止めて警告し、音符がすべてその後にあるトラックはパートにしない。演奏位置の cue は 400 万個まで(繰り返しは小節を、つまり cue を何度も演奏する)。書き出す SMF では、デルタタイムの 4 バイト(2^28 − 1 tick)を超える待ち時間を、空のテキストイベントで分ける。MML は音符のあるトラックを 1,024 まで。MusicXML は、パートの 1 小節の音符・スラー・歌詞・指示・音部記号の変更を合わせて 1 万個、速度記号を 1 小節 64 個、歌詞を 99 番、ボルタの番号を 100 個までとし、演奏は 4 分音符 100 万個分、テンポの変更 100 万回、歌詞 100 万音節で止める(今までの 1 万小節、400 万音符に加えて)。超えた分は警告して除く。次の段に続くスラー・松葉・オクターブ線は番号の順に描く(マップの順だったので、同じ入力から 2 通りの出力が出ることがあった)。fuzz テスト(
FuzzParse)は、読んだ結果の記譜と組版まで行う。 - 未対応: 声部を 3 つ以上に分けること(MIDI・MML では 2 つ目の声部に入らない音は和音に入れるか前の音を切る)、段をまたぐ連桁と符幹、複数小節の休符をまとめること、タブ譜と打楽器の符頭の多く、グリッサンド・アルペジオ・トリル線、MusicXML の画面上の位置(default-x などは使わず組み直す)とページのレイアウトの指定、SMF の音色バンク(GS・XG)とサウンドフォント。
テストは、記譜(Notate)の結果を音符の並びの文字列で、Build の cue(時刻の順、段の矩形の中、繰り返しの展開と分解能の変換)と SMF の書き出しを確かめ、読み手ごとに MML・MIDI・MusicXML の小さな入力(パブリックドメインの曲を手で書いたもの。converter/musicxml/testdata/gen.py)から Performance と Score を確かめる。converter/mml/testdata/frere.mml(PPMCK の 4 声の輪唱)、ode.mml(マビノギ)、converter/midi/testdata/twinkle.kar(歌詞)、converter/musicxml/testdata/minuet.musicxml(大譜表)、ode_to_joy.mxl の変換結果を testdata/music/ に置く。
3.28 フォントファイル → BDF 変換器(converter/font)の構造
フォントファイル(TrueType・OpenType、そのコレクション、WOFF・WOFF2)を、フォントビューアのように見せる。どの文字とグリフがあり、どの OpenType フィーチャーが何をするかが分かる文書にする。問題は BDF が Canvas 2D の上にあることだ。fillText はブラウザの既定のフィーチャー(liga、kern など)しか適用せず、文書が font-feature-settings を指定する手段がない。フィーチャーの働きはシェーピングした文ではなく、グリフの前後として描く。
- View: フォントごとに scroll View(§4.1)を 4 つ作る。概要(overview)・文字(characters)・グリフ(glyphs)・フィーチャー(features)で、フィーチャーは GSUB も GPOS もなければ作らない。コレクションはフォントの数だけ組を作り、id に
-1、-2… を付け、題にフォント名を冠する。-param font=2,3で選べ、指定しなければ先頭から合計 262,144 グリフまでにする(65,535 グリフの CJK フォントを 10 本束ねたコレクションもある)。どの View も幅 720 pt の段に行を積み、1,024 pt ほどごとに行の境目で帯に切る。見出しは次の行と同じ帯に置く。目次(Unicode ブロックの一覧、フィーチャーの一覧)は#page=Nのリンクで該当の帯に飛ぶ。 - 2 つの埋め込みフォント: グリフはフォント自身を埋め込んで描く。1 つ目のグリフ用フォントは、レイアウトテーブル(GSUB、GPOS、GDEF、BASE、JSTF、MATH、kern、AAT の morx・kerx ほか、Graphite)と縦書きのメトリクスを除き、cmap を差し替えたものだ。新しい cmap はグリフ g を私用領域の文字 U+F0000+g に割り当てる。最後の 2 つのグリフ ID は非文字になるので面 16 に回す。グリフのセルはこの文字 1 つの FILL_TEXT で、テキスト抽出には ALT_TEXT で割り当てられた文字(なければ空)を与える。ブラウザはヒンティングもカラーグリフ(COLR/CPAL、sbix、CBDT、SVG)もそのまま描く。レイアウトテーブルを除くのは、単独の文字でもシェーパーが置換しうるからだ。
caltの語末形やccmpの分解がその例で、ID で指したグリフでないものが描かれてしまう。グリフ 0(.notdef)は cmap で割り当てられない(グリフ 0 に割り当てた文字はブラウザにとって「ない文字」)ので、パスで描く。sbix は 1 つのストライク(64 ppem 以上で最小のもの)だけを残す。絵文字のフォントは十数の大きさのビットマップを持ち、Apple Color Emoji は 180 MB あるが、残せば 20 MB になる。コレクションでグリフのデータ(glyf・CFF・hmtx・カラーテーブルなど)を共有するフォントは、グリフ用フォントも共有する。2 つ目の見本用フォントは、レイアウトテーブルを残して見本文に要るグリフだけにしたものだ。見本文はこれで描くので、ブラウザがアプリケーションと同じようにシェーピングする(アラビア文字の連結、インド系文字の結合、既定のカーニングと合字)。FILL_TEXT の期待幅は 0 で、幅はシェーピングが決める。要るグリフは、見本文の文字のグリフから GSUB の全置換を文脈を見ずにたどった閉包で、それ以外を番号を保ったまま空にする。TrueType は glyf を空にして loca を書き直し(sfnt.PruneGlyphs)、CFF は charstring を endchar に替えて使われないサブルーチンを空にする(cff.Prune、sfnt.PruneCFF)。CFF の CharStrings INDEX はグリフごとにオフセットを持つので、CJK フォントでは空のグリフも数百 KB になる。それでも元の 16 MB に比べれば小さい。置換をたどれない AAT・Graphite のフォントと CFF2 は、4 MB までなら丸ごと埋め込む。それより大きいフォントやカラービットマップの大きいフォントには見本用フォントを作らず、見本文をグリフ用フォントで 1 グリフずつ(シェーピングなしで)描く。 - 埋め込みが許されないフォント: OS/2 の fsType が埋め込みを禁じるフォント(Restricted License、Bitmap embedding only)は埋め込まない(ユーザーの判断)。グリフは
sfnt.Outlinesの輪郭をパスにして描き(仕様 §6.1 の方針)、Object ごとに使うグリフのパスを持つ。見本文は 1 グリフずつで、シェーピングもヒンティングもない。輪郭のないグリフ(カラービットマップ)は描けない。-ignore-fstype(Options.IgnoreFSType)で埋め込む。 - GSUB・GPOS の読み取り(internal/otlayout): ScriptList・FeatureList・LookupList を読む。拡張ルックアップはほどき、スタイリスティックセットと文字異体の FeatureParams(UI 名と対象の文字)も読む。ルックアップごとに次のものを取り出す。GSUB は単一(両形式)・複数・代替・合字・逆連鎖の置換、文脈と連鎖文脈(3 形式)のルール数と適用するルックアップ。GPOS はペア(個別の形式と、クラスの形式。後者のペア数はクラスの大きさの積)、単一調整、カーシブ、マークの基底字とマークへのアンカー、合字へのマークの数。GDEF はグリフのクラス、マークの付くクラス、マークの集合。フォントは信用しない入力なので、個数は表の大きさで切り、カバレッジとクラスの展開は 65,536 グリフまでにする。クラスの形式のペア表はレコードが表に収まらなければ読まない。値の 0 のペアは列挙の前に除く(クラスが多く値がほぼ 0 の表で、カバレッジ × クラスの空回りが数十億回になった)。macOS のフォント 14 本(ラテン、日本語と中国語のコレクション、アラビア文字、インド系、ミャンマー文字、絵文字)とテスト用フォントについて、ルックアップの種類・置換の数・ルールと入れ子のルックアップ・ペアの数・マークと基底字の数を fontTools と照合した。読み取りと変換にファズテストがある。
- フィーチャーの見せ方: 同じタグの FeatureRecord(スクリプト・言語ごとにある)を 1 つにまとめ、使うスクリプトと言語、ルックアップを並べる。ブラウザ(HarfBuzz)が指定なしに適用するかどうか(常に、スクリプトのシェーパーが、縦書きで)も添える。例は次のように描く。GSUB はルックアップの置換を前 → 後で並べる。代替は選択肢として、文脈ルックアップは文脈そのものは描かずにルールが適用するルックアップの置換を、1 フィーチャーに 200 まで(
-param examples=)。カーニングは見本文の隣り合う文字の組とルックアップのペアから値の大きい順に、値を添えて。単一調整は送り幅の箱の前後で。マークは各クラスのマークを見本文の字母などに付けた前後で描く。フィーチャーを適用した見本文は描かない(シェーピングエンジンが要る。ユーザーの判断で見送った)。 - 見本文: スクリプトごとのパングラムや定番の一節(ラテン、日本語のいろは歌、簡体字・繁体字の千字文、ハングル、キリル、ギリシャ、アラビア、ヘブライ、タイ、デーヴァナーガリー、ベンガル、タミル、アルメニア、ジョージア)を、フォントが全文字を持つものだけ並べる。漢字の見本は 1 つだけにする。大きな見本とサイズ別の見本文(8〜72 pt)に使う主のスクリプトは、meta テーブルの設計言語(dlng)が最優先で、次に欧州のアルファベット以外で最初に持つもの(デーヴァナーガリーのフォントもラテン文字は持つ)、最後にラテンとする。
-param text=はサイズ別の見本文を差し替える。どれも持たない記号のフォントは、最初の文字を並べる。右から左の文字は右揃えで描く。 - 概要の表: name テーブルは全プラットフォームを読む。Mac の Roman・日本語・中国語・韓国語、Windows の旧来の東アジアのエンコーディングも含み、言語 ID を BCP 47 に直して、ファミリー名とフルネームは各言語で並べる。ほかに OS/2(ウェイト、幅、fsSelection、PANOSE、ベンダー、埋め込み許可、光学サイズ)、head(改版、作成・更新日。Dublin Core の created・modified にもする)、post、hhea のメトリクス、fvar の軸と名前付きインスタンス、カラーの形式(COLR v0/v1、CPAL のパレット、sbix・CBDT のストライク、SVG の文書数)、埋め込みビットマップ、cmap format 14 の異体字シーケンスの数、meta の設計言語と対応言語、テーブルの一覧を載せる。
- 文字: Unicode のブロック(Go の unicode パッケージと同じ 17.0。
tools/gen-unicode-blocks.pyが Blocks.txt から生成)ごとに、割り当て済みの文字(制御文字とサロゲートを除く)に対する被覆率を出す。Unicode のコード表の形で、行の見出しとセル 16 個を並べる。割り当てのない行は省き、省いたところに印を置く。ない文字と未割り当てのコードポイントは灰色の濃さで分ける。全コードポイントを割り当てる Last Resort のようなフォントに備え、コード表は 150,000 文字までにする。format 13 の cmap は読まない。 - サムネイル: 概要は名前と字形見本から始まるので、先頭の正方形を切り出す(§3.25)。Go のラスタライザはシェーピングもカラーグリフも CFF2 の輪郭も描かないので、それらのフォントのサムネイルは貧しい。
- ブラウザ: Office 系のモジュールに入れた(+0.9 MB)。wasm のビルドは Brotli を外しているので、WOFF2 は読めない(入れると 1.7 MB 増える)。WOFF と、コレクションでない TrueType・OpenType は読める。
- 壊れたファイルへの備え: フォントは信頼しない入力で、レコードは同じバイト列を共有できる(65,535 個の補助表が 1 つのカバレッジを指す、コレクションの 64 個のフォントが 1 つの表の並びを指す)ので、上限は 1 つずつではなく合計に置く。WOFF2 と WOFF は、表の大きさの合計が 256 MiB を超えたら伸張する前にエラーにする(
woff2.ErrTooLarge)。WOFF2 の glyf の復元は、ストリームが尽きたグリフで止める。合成グリフは、構成要素をたどった先まで合わせて 2^18 個の点と構成要素まで(6 個の構成要素を 9 段重ねた 672 バイトのフォントが 3,000 万回の描画になっていた)。CFF のサブセットは、フォント全体で32 × 大きさ + 65,536個のオペランドと演算子、スタックは 8,208 個まで(cff.ErrTooLong)。DICT の実数のオペランドは ±2^40 に切り、数でないものは負にする(1E300 のオフセットで arm64 だけがパニックしていた)。FDArray が空の CID キーの CFF と、グリフのないフォントはエラー。cmap は、同じオフセットの補助表を 1 度だけ読み、表全体で 4 × 0x110000 文字まで(超えたらsfnt.Parseのエラー)。name は、通知に使うレコードを合わせて 4 MiB まで読み、概要の表に出す分は 4 MiB で打ち切って警告する。GSUB・GPOS は、スクリプト・言語・フィーチャー・ルックアップ・補助表を合わせて 2^20 個まで読み(otlayout.ErrTooLarge)、ルックアップの中身の列挙は、表の大きさ n に対して min(2^22 + 32n, 2^28) 個で止める(Table.Truncated)。GDEF のマークグリフセットは合わせて 2^20 グリフまで。コレクションのフォントは、見せるものだけを読み、同じバイト列のレイアウト表は 1 度だけ読む。internal/fontdbは、コレクションのファイルを、その中のフォントのために 1 度だけ開く。Unix と Windows ではファイルをメモリにマップし(contrib/otf)、読んだ表とグリフのページだけが常駐する。和文フォントは 2 万以上のグリフのうち文書が使うのは数百で、19 MB のコレクションを読み込んでいたときは変換のメモリの半分がフォントだった。索引を作るときの post 表は isFixedPitch の 16 バイトだけ読み、fontdbが読み込むフォントは post のグリフ名を解釈しない(PDF とフォントのプレビューだけが使う)。 - 可変フォント: Canvas 2D では font-variation-settings を指定できないので、既定のインスタンスで描き、軸と名前付きインスタンスを一覧にする(ユーザーの判断。インスタンスごとの描画は後の課題)。埋め込むときは TrueType の gvar などを除き(既定の輪郭は glyf にある)、CFF2 は charstring の blend を読むために fvar と avar を残す。
3.29 KiCad → BDF 変換器(converter/kicad)の構造
converter/kicad は KiCad 6 以降の回路図(.kicad_sch)と基板(.kicad_pcb)を読む。どちらも S 式のテキストで、ファイルの version(日付)が 20211014 より前のもの(KiCad 5 の S 式の基板)と KiCad 5 以前の独自形式(.sch・.lib)は「KiCad 6 以降で開いて保存し直す」ように案内して断る。入力は回路図、基板、プロジェクトファイル(.kicad_pro。JSON で、隣の同名の回路図と基板を読む)、プロジェクトを入れた ZIP のどれでもよい。色は KiCad の既定のテーマ、文字は KiCad の描き方を再現する。
- 参照するファイル: 階層の回路図はシートごとに別ファイルで、プロジェクトファイル(テキスト変数、図枠ファイルの指定、描画の設定)や図枠(.kicad_wks)も別にある。これらは
converter.OptionsのFiles(fs.FS。サーバーはアップロードされたファイルをconverter.FileMapで列挙して渡す)とDir(CLI とConvertFileは入力のディレクトリ。bdf generate -withでファイルを足せる)からOptions.ReadRefで読む。参照は参照するファイルの位置からの相対パスで解決し、Filesに同じパスがなければ同じ名前のファイルを探す(アップロードではディレクトリが失われるため)。絶対パスは読まない。ZIP(ウェブのデモの入力)は、中の ZIP 自体をFilesとして同じ仕組みで読み、アーカイブの最も浅いプロジェクトファイル、なければほかのシートから参照されない回路図を根とする。どのファイルも一度だけ読み、1 ファイル 256 MiB、合わせて 1 GiB で打ち切る。形式ごとに何を読むかはFormat.Filesに書く。 - 判別: 先頭(BOM と空白の後)が
(kicad_schか(kicad_pcb、または .kicad_sch・.kicad_pcb・.kicad_pro を持つ ZIP。KiCad のプロジェクトの ZIP には製造用の Gerber が入っていることが多いので、Gerber の ZIP の判別はこれらを持つ ZIP を除く。.kicad_pro は拡張子で判別する。 - 回路図の View:
schematicの 1 つの fixed View で、ページはシートのインスタンスである。同じファイルを 2 か所で使えば 2 ページになり、部品の番号(R101 と R201 など)は KiCad 7 以降はシンボルのinstancesのパス、KiCad 6 は根のsymbol_instancesから引く。ページの順はページ番号の順(数なら数の順)で、番号の重複と欠けは KiCad と同じく直す(階層の順に最初のシートが番号を取り、後のものには空いている最小の番号を振る)。シートの箱はそのページへのリンク(#page=N)になる。用紙は KiCad 10 の表(A5 は 210×148 mm を mil に丸めたもの)、縦置き、User。各ページは、用紙と図枠の変わらない部分(同じ大きさのページで共有される)を background のレイヤー、変数を含む図枠の文字と回路を body のレイヤーにする。 - 図枠: 既定の図枠(KiCad の既定と同じ S 式を埋め込む)か、プロジェクトが指定する .kicad_wks(旧名 page_layout も)を解釈する。四隅からの位置、繰り返し(増分と番号の増加。200 回まで)、1 ページ目だけ・以外、線・矩形・文字・多角形・画像。文字の変数は題名欄(
${TITLE}など)、ページ番号と総数(${#}・${##})、${SHEETNAME}・${SHEETPATH}・${FILENAME}、プロジェクトのテキスト変数で、${KICAD_VERSION}は保存した KiCad の版(generator_version)にする。旧式の%Tなどの記号は変数に直す。 - プロジェクトの設定: 回路図の描画の設定(既定の線幅、ラベルとピンの文字の離れ(text_offset_ratio)、グローバルラベルの枠の余白(label_size_ratio)、ピンの記号の大きさ、接合点の大きさ(Default のネットクラスの配線の幅の倍数)、破線の長さと間隔)と、Default のネットクラスの配線とバスの幅を読む。古いデモでは text_offset_ratio が 0.3 のものもあり、ラベルの位置が変わる。
- 回路図の描画: KiCad の描く順(画像、シートと部品の背景、図形・テキストボックス・表、部品、シート、配線、接合点、ラベル、フィールド、DNP の印)で、1 つの
cad.Drawingに描いて Canvas の Object にする。部品はファイルに埋め込まれたライブラリ(lib_symbols)から、ユニットとボディスタイル(De Morgan)と代替ピン名を選び、回転と鏡映は KiCad の TRANSFORM の合成(反時計回り、時計回り、鏡映の順)で置く。ピンの形(反転、クロック、立ち下がり、アクティブロー、非論理、未接続)と名前・番号はプロッタ(SCH_PIN::PlotPinType・PlotPinTexts)の位置に描く。ラベルは読み込み時に角度を正立させ、位置揃えから向き(spin)を決める KiCad の規則に従い、グローバル・階層ラベルの枠、ネットクラスの旗を描く。フィールドは部品の変換の中で文字の箱を求め、その中心に水平か上向きに描く(部品以外のフィールドは自分の位置揃えのまま)。図形の塗りは KiCad 10 のハッチ(線幅の半分の線を 40 倍の間隔、100 本まで)も描く。DNP の部品は色を薄め赤い × を重ねる。 - 文字: KiCad の線の字体 newstroke を使う。KiCad の版は GPL なので、vovanium の CC0 の版を
tools/gen-newstrokeが KiCad のfontconv.awkと同じ形に変換してnewstroke.txt.gz(11,232 字形、ラテン・ギリシャ・キリル・記号)として埋め込む。この版にない CJK の字は TrueType のフォント(sans-serif の代替)で KiCad の字送りの枠の中央に描く。配置は KiCad 10 のFONT::getLinePositionsとEDA_TEXT::GetTextBoxを再現する(字形の原点の補正、行間、線幅による補正、上付き・下付き・上線の記法^{}・_{}・~{}、タブ、{slash}などのエスケープ、末尾の改行は行にしない)。線で描いた文字は 1 行を子 Object にし、ALT_TEXTで文字列を持たせるので、検索と選択ができる(cad.StrokeText)。フォントを指定した文字((face …))は fontset の TrueType で、KiCad と同じ大きさ(高さの 1.4 倍の em)で組む。KiCad のデモ 16 件のすべての文字(約 74,000)の線の範囲が kicad-cli の SVG 出力と 5 µm 以内で一致することを確かめた。 - 基板の View:
board-front(表から見た、全層の重ね描き)、board-back(裏から見た、鏡映)、使われている層ごとのlayer-<名前>(-param layers=boardで前の 2 つだけ、layersで層だけ)。層ごとの描画を 1 つの Object にし、各 View で共有する(canvas.Share)。倍率は Gerber と同じ(A3 に収まる)で、範囲はすべての図形の和、外形(Edge.Cuts)の 4 倍を超える範囲なら外形の 1.2 倍にする。パッド(円、矩形、長円、台形、角丸と面取り、カスタムのプリミティブ)、穴(長穴、メッキなし)、ビア(貫通・ブラインド・マイクロの色)、配線と円弧の配線、ゾーン(塗り潰しの多角形を 6 割の不透明度で)、図形、文字(TrueType の文字はrender_cacheの多角形を塗り、検索用に見えない文字を重ねる。フットプリントの文字は正立させる)、寸法、表、テキストボックス、画像を描く。 - オプション:
-param views=all|schematic|board、-param layers=all|board|layers、-pages(回路図のページ)。サマリーはシート数、基板、埋め込んだフォント数。 - 上限: シートの入れ子 32 段、インスタンス 2,000 まで、自分を含むシートは除く(KiCad も開かない)。多数のインスタンスや繰り返しの多い図枠で描く量が 1 GiB 相当(ファイルの大きさと図形の数による見積もり)を超えると、残りのページは白紙にして警告する。
- 未対応: KiCad 5 以前の形式、3D の表示、ネットクラスの色で配線を塗ること(接続の解析が要る)、シート間参照(
${INTERSHEET_REFS}は空にする)、ライブラリのファイルからシンボルを引くこと(ファイルに埋め込まれたものだけ使う)、シミュレーションの結果の表示。
テストは、S 式、記法、シンボルの変換、ラベルの向き、ReadRef と FileMap(fstest.TestFS)の単体テストと、KiCad の出力から取った座標(KiCad のデモの文字と自作の小さな回路図)で文字の線の範囲を確かめるテスト、test/kicad/gen.py が書くテスト用のプロジェクト(2 回使う階層のシート、全種類のピンとラベル、ハッチ、表、テキストボックス、日本語、4 層の基板、独自の図枠)の変換、数値を極端な値に置き換えたファイルと自分を含むシートの変換、fuzz である。
3.30 保護モード: 区間ごとに封印して配る(segment、ログインした読者向け)
ログインした読者に本や PDF を読ませるサイトでは、文書をファイルとして配りたくない。§3.5 の暗号化をファイル全体にかけても、鍵を渡した時点で読者の手元に「いつでも開ける暗号文と鍵」が揃い、通信を記録した人も後で鍵が漏れれば全部開ける。そこで、サーバーは変換した BDF を手元に置いたまま、読者が開いたあたりの数ページ(既定で 10 ページ、区間)だけを、要求ごとに読者が作った使い捨ての鍵で封印して渡す(spec §3.6、§3.5 の ecdh スロット)。
守るもの
| 後で漏れたもの | 記録された通信(封印された区間) |
|---|---|
| 読者のパスワード、セッションの Cookie | 開けない。新しい区間を頼めるだけで、権利と速さはサーバーが確かめる |
| サーバーの TLS の秘密鍵、TLS を終端する CDN・WAF のログ | 開けない。区間を開ける鍵は要求ごとの鍵の合意からしか出てこない |
| ログアウト後・タブを閉じた後の端末 | 何も残っていない(Cache-Control: no-store、鍵は Worker のメモリの中だけ) |
| 読者が閲覧中に抜き出した鍵 | その区間 1 つにしか効かない |
守らないもの: サーバーのディスク(本は平文で置く。変換は 1 回だけで、封印は要求のたびにする)、閲覧中の画面とメモリ(描けるものは写せる)、TLS を終端して応答を書き換えられる者(ビューアのスクリプト自体を差し替えられる)。オフラインの閲覧はしない(端末に鍵を残すことになり、前方秘匿性と両立しない)。
鍵: ブラウザの Worker が要求ごとに P-256 の鍵ペアを extractable: false で作り、公開鍵だけを送る。サーバーは区間ごとに自分の鍵ペアを作って ECDH で鍵を合意し、区間のコンテンツ鍵をラップして自分の秘密鍵を捨てる(bdf.NewECDHLock)。Worker は deriveKey で同じ鍵を導いて区間を開き、秘密鍵への参照を持たない(ECDH の結果は HKDF の基底鍵のまま WebCrypto の中にあり、JavaScript に出てこない)。ラチェットは使わない。要求ごとに両側が新しい鍵を作れば、どの応答も単独で前方秘匿になり、順序の食い違いや再送を考えなくてよい。サーバーの長期の鍵で状態を包むこともしない(その鍵が漏れれば過去が開く)。HPKE(RFC 9180)は WebCrypto にないので、共有秘密を JavaScript のバイト列として扱うことになるため採らなかった。
区間の中身(Reader.WriteSegment): 輪郭(すべての View とページの大きさ、メタデータ)と、区間のページのレイヤー、そのページが使う Part。格納バイト列(圧縮済み)をそのままコピーして封印するので、要求のたびの仕事は読み出しと封印だけで、圧縮し直さない。どのページがどの Part を使うかは、ページごとに一度だけ Object をデコードして調べ、Reader が覚えておく(並行に呼んでよい)。読者は持っている区間を要求に書き(have)、サーバーはそれらのページが使う Part を省く。フォントや共有の Object(PDF の Form XObject、スライドのマスター)を区間ごとに送り直さないためで、サーバーは読者ごとの状態を持たない(読者が嘘をついても、困るのは読者自身)。テキスト索引は View 全体の本文なので区間には入れない。本の中の検索は受け取ったページだけが対象になる。シートと演奏のある View は区間にしない。
通信の形(Go の segment.Handler と @bdfkit/core の SegmentLoader): POST、Content-Type: application/json(フォームと違い、他のサイトからは CORS の問い合わせなしには送れない)で、本体は次の形。
{ "key": "<base64: 読者の公開鍵、非圧縮の点 65 バイト>",
"view": "pages", // 省略時は最初の View
"page": 37, // この要求で欲しいページ(0 始まり)
"have": [ { "view": "pages", "from": 0, "to": 10 } ] } // 持っている区間
応答はそのページを含む区間(ページ ⌊page/10⌋·10 から 10 ページ)の単一ファイル形式で、Cache-Control: no-store。ログインと権利はアプリケーションの Open(どの文書か、読んでよいか)と Allow(区間ごとの規則: 立ち読みは最初の区間だけ、読む速さの上限)が決め、エラーは *segment.StatusError の状態(未ログインの 401、速すぎる 429)、fs.ErrNotExist(404)、fs.ErrPermission(403)で返す。SegmentLoader は 1 つの区間を同時に 2 度頼まない(区間の大きさは最初の満ちた区間から知る)、頼まれたページの 3 ページ先を先読みする。レンダラの Worker は { kind: "segments", url } で開いた文書について、ページの描画・テキスト・連続表示の範囲を処理する前に要るページを取りに行くので、ビューアは他の文書と同じように使える。サーバーが拒んだ区間は Worker のエラーコード not-allowed・rate-limited になる。
費用(Apple M 系の 1 コア、WriteSegment と鍵の合意を合わせて): 実物の本 2 冊(EPUB から、439 ページと 362 ページ)で 1 区間 0.6 ms 前後(最大 1.1 ms)、ページごとの Part の表を作る最初の一巡が本全体で 30〜50 ms。大半は輪郭の JSON の圧縮で、鍵の合意は 50 µs、AES-GCM は 10〜15 µs。区間の大きさは中央値 25〜50 KB、最大 0.5 MB(大きな図のあるページ)。封印したものは読者ごとに違うので CDN の共有キャッシュは使えないが、本 1 冊が数 MB なら配信の負荷としては小さい。
サンプル: examples/secure-reader はこれを使ったログイン付きの PDF 閲覧サイトである(パスワードは PBKDF2 で持ち、セッションの Cookie は HttpOnly・SameSite=Strict でサーバーはそのハッシュだけを持つ、http.CrossOriginProtection、CSP、立ち読みと読む速さの上限)。
確かめたこと: Go のテスト(鍵の往復、別の鍵とパスワードを拒むこと、区間のページと Part、have で省かれること、シートを拒むこと、ハンドラの状態)、Go が封印した区間を TS が開いて合わせると元の文書のページと Part に一致するテスト(testdata/segments/、テスト専用の鍵で封印)、SegmentLoader が同じ区間を 1 度だけ頼み have を付けるテスト。ブラウザでは Chrome 153 と WebKit 26 でサンプルの本の 3 つ目の区間まで描けること、立ち読みの境界で 403、21 区間目で 429、ログアウト後に 401 になることを確かめた(Firefox は手元の Playwright で起動しないため未確認)。
3.31 音声ファイル → BDF 変換器(converter/audio)
音声ファイル(MP3、MP4 の AAC つまり M4A、生の ADTS の AAC、FLAC、Ogg Vorbis・Opus、WAV、AIFF)を 1 ページの「カード」の文書にする。音声ファイルにページはないが、ファイル一覧・サムネイル・検索に要るものはある。カバーアートとタグだ。ページの上端にカバーをページの幅いっぱいに置き(なければ音符を描いたプレースホルダー)、その下に題名・アーティスト・アルバム、ほかの項目の表、歌詞、章を組む。View は play(spec §4.4)に音声ファイルそのものを持ち、ビューアはブラウザの <audio> で鳴らす。歌詞が歌われる時刻を持っていれば cue にして、歌っている行をカードの上で示す。
- なぜカード 1 枚か: サムネイル(§3.25)は最初の View の 1 ページ目の左上の正方形を切る(Crop)。カバーをページの幅で上端に置けば、正方形のカバー(アルバムアートはほぼ正方形)がそのままサムネイルになり、ビューアではページをめくらずに題名も読める。
thumbnailのcropSourcesに音声のsourceを加えた。サムネイルのためだけならconverter.Options{Pages: converter.PageList(1)}の近道もそのまま効く(ページは 1 枚しかない)。 - 読むもの: MP3 は先頭の ID3v2(2.2・2.3・2.4)と末尾の ID3v1、
APICのカバー、フレームから長さ・ビットレート・サンプリング周波数・チャンネル。M4A はmoovのmvhd、最初のsounトラックのstsd(mp4aのesdsの OTI と AOT で AAC・HE-AAC・MP3、alac、fLaC、Opus、PCM。drmsは保護された音声)、udta/meta/ilst(©namなどと自由形式の----、covr、trkn、gnre)、QuickTime のkeys/mdtaとudta直下のテキストアトム、Nero のchpl、uuidの XMP。FLAC はSTREAMINFO・VORBIS_COMMENT・PICTURE。Ogg は最初の音声ストリーム(Vorbis・Opus・FLAC・Speex)の識別ヘッダーとコメント(METADATA_BLOCK_PICTUREと古いCOVERART、CHAPTERxxx)。WAV はfmt、dataの大きさ(RF64 のds64も)、LIST INFO、bext、id3チャンク。AIFF はCOMM(80 ビット浮動小数点のサンプリング周波数)、NAME・AUTH・(c)・ANNO、ID3チャンク。生の AAC は ADTS のフレーム(先頭に ID3v2 があれば読む)。 - タグの正規化: 形式ごとの読み手は Vorbis コメント流のキー(
TITLE、ARTIST、ALBUMARTIST、COMPOSER、DATE、TRACKNUMBER…)のtagsに写し、1 つの対応表で Dublin Core(spec §4.3)とカードの項目にする。アルバムは Dublin Core に要素がないのでrelation(isPartOf の意味)に、ジャンルはsubjectに、作曲者などはcontributor(アーティストと同じ人は除く)に写す。typeはSound、formatはメディアタイプ。優先順位は形式自身のタグが先で、FLAC・WAV・AIFF に付いた ID3 タグ、MP3 の ID3v1、ユーザー定義フィールド(TXXX、----)は足りない項目を補う(fill)。 - 文字コード: ID3v2 の encoding 0(「ISO-8859-1」)、ID3v1、RIFF INFO、AIFF のテキストはファイル単位で推定する。すべてが正しい UTF-8 なら UTF-8、すべてが Shift_JIS なら Shift_JIS、そうでなければ Windows-1252(§3.27 の MIDI と同じ)。UTF-16 は BOM で、なければ encoding 2 は BE、1 は LE。
- ID3v2 の細部: 2.2 の 3 文字の ID、2.3 のタグ全体の非同期化と 2.4 のフレーム単位の非同期化、拡張ヘッダー、フッター、データ長指示子、zlib 圧縮フレーム(16 MiB まで)、暗号化フレームは読み飛ばし、2.4 なのに syncsafe でない大きさを書く書き手(次のフレームに辿り着くほうの大きさを採る)、複数の値(2.4 の NUL 区切りと ";" 区切り)、
TCONの番号参照((17)Rock、RX)、TYER+TDAT+TIMEから 1 つの日付、COMM(iTunNORMなどプログラム自身のものは除き、説明のないものを優先)、USLT(なければSYLTの文字)、APIC(type 3 の表紙を優先)、CHAPの題名と開始時刻。 - MP4: 最上位のボックスは
converter/internal/isobmffのWalkでヘッダーだけ読み、mdatを読まない(moovが末尾にある非 faststart のファイルのため)。moovは 64 MiB まで。hdlrがvideのトラックがあれば動画で、この変換器は受け付けない(Detect が false を返す)。metaは MP4 では FullBox、QuickTime では plain な箱なので、最初の子で見分ける。AVIF の読み手(§3.19)の箱の分割をこのパッケージに移して共有した。 - Ogg: ページを順に読み、最初の音声ストリームのパケットを識別ヘッダーとコメントまで集める(FLAC は last フラグのブロックまで。64 MiB、1 万ページまで)。長さは末尾 256 KiB の中の、同じシリアルの最後のページの granule から(Opus は pre-skip を引いて 48 kHz)。Theora などの動画ストリームが先にあっても音声は読み、警告する。
- MP3・ADTS の長さ: Xing/Info/VBRI ヘッダーがあればそのフレーム数から、なければ全フレームのヘッダーを辿って数える(
bufioで読む。1 フレーム数百バイトの走査なので 100 MB でも一瞬)。先頭と途中のゴミは 64 KiB まで飛ばす。CBR はヘッダーの公称ビットレート(フレームはバイトに切り捨てられるので、バイト数から求めると少しずれる)、VBR は平均。末尾の ID3v1 と APE タグは走査から除く。 - カードの文字:
fontsetを使わず、SystemFont("sans-serif")を名前で参照する(HTML・Markdown の既定と同じ)。幅は文字種ごとの概算(ラテン小文字 0.53 em、数字 0.56 em、全角 1 em …)で折り返し、advanceは 0(補正しない)。これで変換器はフォントもレイアウトエンジンも持たず、ブラウザではimageonlyのモジュールに同居する。折り返した行は、空白で折ったなら LINE、全角文字の間で折ったなら WRAP の MARK(spec §7.8)。題名は HEADING 1、項目は TABLE の CELL(A1 rowが見出し)、歌詞は段落(空行で分ける)と LINE、章は LIST。テキスト索引も作る。 - 再生: 入力のファイルを 1 バイトも変えずに
audioの Part に置き、play.audioとplay.type(audio/mpegなど、Dublin Core のformatと同じ値)で指す。§4.4 のseqが SMF をそのまま持つのと同じ形で、デコーダは持たない(鳴らすのはブラウザ)。タグとカバーをファイルから外さないのは、MP4 のチャンクのオフセット(stco)がファイルの先頭からの位置で、MP3 の Xing ヘッダーの目次もファイルの大きさを前提にするからで、切り詰めて壊すよりカバーの重複のほうが安い。Part は圧縮しない(shouldCompress)。MP3・AAC・Vorbis・Opus・FLAC は縮まず、PCM は 1 割ほど縮むが、そのために数百 MB を deflate に通すことはしない。保護された音声(drms)は鳴らせないので格納しない。 - 格納する大きさ: 文書は Part をメモリに持つので、格納する音声はファイルごと読み込む。
Options.MaxAudio(既定 256 MiB、-param maxaudio=<MiB>)より大きいファイルは音声を格納せず、警告してカードだけの文書にする(変換を断らない)。256 MiB は 320 kbps の MP3 で約 110 分、CD 品質の WAV で約 25 分にあたり、曲とポッドキャストはほぼ収まる。長いオーディオブックと長時間の WAV は収まらない。Options.NoPlay(-param play=false)は音声を格納しない。ファイル一覧・サムネイル・検索の索引のためだけの変換には音声が要らないので、ページを選んだ変換(サムネイルの近道のPages: converter.PageList(1)、-pages 1)も格納しない(-param play=trueで格納させられる)。この規則はconverter.Convertを通る登録済みの形式のもので、audio.Convertを直接呼ぶときはNoPlayで決める。ブラウザの変換モジュール(cmd/bdfwasm)は既定の上限を 64 MiB にする。入力のコピー、Part、書き出した文書、JavaScript に返すバイト列が同時にモジュールのメモリに載り、ファイルの 4〜5 倍を使うからで、256 MiB のままだと 1 GiB を超えて、スマートフォンでは変換そのものが失敗しうる。convert・openのparams(-paramと同じ名前と値。この機能のために足した)でmaxaudioとplayを変えられ、検索テキストのページ(site/text)はplay: "false"で変換する。 - 歌詞の時刻: 3 つの形から読む。ID3 の
SYLT(内容種別が歌詞のもの。タイムスタンプがミリ秒のとき)、歌詞のフィールド(USLT、©lyr、Vorbis コメントのLYRICS・UNSYNCEDLYRICS・SYNCEDLYRICS)に入った LRC のテキスト。LRC は実際のファイルでいちばん多い形で、タグ付けソフトは[00:12.34]で始まる行をそのまま歌詞のフィールドに書く。SYLTがあればそれを、なければ LRC として読めたフィールドを、どちらもなければ最初の歌詞のフィールドをそのまま表示する。- LRC: 行頭の
[分:秒.端数](端数は 1〜3 桁、区切りは.か:、なくてもよい)を時刻にする。1 行に複数あれば、その行を時刻ごとに 1 回ずつ並べる(サビの繰り返し)。行は時刻の順に並べ直す。拡張形式の語ごとの<分:秒.端数>は落とす(行の単位でだけ追う)。[ar:…]・[ti:…]・[al:…]・[by:…]・[offset:…]などファイルを説明するタグは表示せず、offset(ミリ秒。正なら歌詞を早める)は時刻に足し込む。時刻だけの行([01:23.45])は歌の切れ目で、空行と同じく次の行から連を改める。時刻のない行は前の行に続く行として同じ矩形に入れる。時刻つきの行が時刻のない行より少ないテキストは LRC とみなさない([Chorus: …]のような見出しや、たまたま[0:30]で始まる 1 行を持つ普通の歌詞を壊さないため)。 - SYLT: 項目は文字列と時刻の組で、行ごとのことも、語や音節ごとのこともある。改行を含む項目があれば、文字列をつないで改行で行に分け、行の時刻は最初の断片の時刻にする(空行は連の切れ目)。改行がどこにもなければ、前後に空白を持つ項目があるなら語を並べた 1 行、ないなら 1 項目が 1 行とみる。タイムスタンプが MPEG フレーム単位のもの(まれ)は時刻に直さず、時刻のない歌詞として読む。
- 以前の
SYLTの読み取りは内容種別のバイトを 1 つ後ろ(内容記述の先頭)で見ていて、記述が空のふつうのフレームを読めていなかった。直した。
- LRC: 行頭の
- cue: 時刻のある歌詞の行ごとに、その行が組まれた矩形(折り返した行は全部。本文の幅、行送り 16 pt × 行数)を system に、行の時刻(ミリ秒)を cue にする。同期した歌詞がなく章があれば、章の行を system に、章の開始時刻を cue にする(オーディオブックとポッドキャスト)。両方あれば歌詞を取る(位置は 1 つしか示せない)。cue は
play.audioがあるときだけ付ける。行は 2,000、章は 1,000 までなので、cue 索引は数十 KB に収まる。 - ビューア:
@bdfkit/coreのBdfDocument.audioが音声の Part と cue を読む。大きい Part なので文書のキャッシュには入れない(鳴らす側が持つ)。Worker はそれをBlobにしてページに渡す(BdfWorkerClient.audio)。Blobはブラウザが持つ(大きければディスクに置く)ので、ページとの受け渡しでコピーが増えない。typeはaudio/で始まるものだけをBlobの型にする。@bdfkit/renderのAudioPlayerが<audio>要素を包み、MusicPlayerと同じ操作(play・pause・stop・seek・position・duration・state・setSpeedPercent・cursorAt・timeAt・onUpdate・onEnd)を持つので、ビューアは楽譜と同じ再生ボタンで両方を扱える。cursorAtは行の矩形の全体(Cursor.w)を返し、最初の cue より前は null。状態は要素のイベントから取るので、要素自身のコントロールやキーボードのメディアキーで再生・停止されても合う。ブラウザが鳴らせない形式(Chrome の AIFF など)はonErrorで知らせ、カードはそのまま見える。デモビューアは位置のスライダーを加え、歌っている行を帯で示し、行のクリックでそこから鳴らす。速度はplaybackRate(音程は変わらない)。ミニビューア(examples/miniviewer)はブラウザのコントロール(<audio controls>)をページの下に出す。 - カバー: 画像のヘッダーだけ読んで大きさを得る(
image.DecodeConfig。PNG・JPEG・GIF・BMP・WebP)。それ以外(TIFF など)や壊れた画像は警告してプレースホルダーにする。ページの幅はカバーの幅(360〜720 pt に収める)。-images convertのときはimgconv.Optimizeで再エンコードする(他の文書に埋め込まれた画像と同じ扱い。入力そのものである画像をそのまま格納する §3.19 とは違う)。 - 信頼しない入力への備え: ファイルは 1 GiB、格納する音声は
MaxAudio(256 MiB)、ID3 タグは 64 MiB(大きさの主張は 256 MiB まで書ける)、FLAC のコメントは 8 MiB、画像ブロックは 16 MiB、MP4 のmoovは 64 MiB、XMP は 16 MiB、カバーは 64 MiB、歌詞は 256 KiB・2,000 行、SYLTの項目は 1 万、ほかの項目は 16 KiB まで読む。1 項目の値は 64 個、画像は 64 枚、章は 1,000 件まで。チャンクやボックス、フレームがファイルより大きいと言えば、ファイルの終わりで切る。 - テスト: ffmpeg と mutagen で作った 1 秒の小さな実ファイル(
test/audio/gen.sh、converter/audio/testdata)で全形式の変換結果(Dublin Core、ページ、検索テキスト、サムネイルがカバーの絵になること、音声の Part が入力と同じバイト列で圧縮されずに入ること)を確かめ、同期歌詞は 15 秒の曲(synced.mp3:test/audio/melody.pyが書く旋律にSYLTの歌詞。デモサイトのサンプルでもある)と LRC を持つ FLAC(lrc.flac)で cue の時刻と矩形を確かめる。LRC とSYLTの読み分け、NoPlay・MaxAudio・ページ選択での扱いは合成した入力で確かめる。AudioPlayerは<audio>要素の代わりのオブジェクトで Node のテストにし(packages/render/test/audio.test.mjs)、実際の再生と行の追従はデモビューアとミニビューアをブラウザで動かして確かめた。ID3 の版・フラグ・文字コード、MP4 の箱の順序と QuickTime の形、Ogg の複数ページにまたがるパケット、RF64、AIFF-C、上限の挙動は合成したバイト列で確かめる。フィクスチャは fuzz の種にもする。カードの文字はビューアのフォントで組まれる。 - 未対応・今後: 音声ファイルの隣の
.lrcファイル(いまはファイルの中のタグだけを読む)、語ごとの時刻で行の中を追うこと、MPEG フレーム単位のSYLTの時刻、分割形式の文書で Part の URL を<audio>に直接渡すストリーミング(いまは Part を読み切ってから鳴らす)、ブラウザで大きいファイルを鳴らすこと(ドロップされたFileをそのまま<audio>に渡せばコピーは要らないが、いまは文書に入った音声だけを鳴らす)、WMA/ASF と APE・WavPack のタグ、波形、QuickTime の章トラック。動画は別の変換器にする(メタデータ・章・字幕は同じ箱の読み手で読めるが、1 枚の絵を出すにはデコーダが要る)。
3.32 InDesign → BDF 変換器(converter/idml)の構造
converter/idml は InDesign が書き出す IDML(InDesign Markup Language。XML を zip に詰めたパッケージで、Adobe が仕様を公開している)を読む。ネイティブの .indd はバイナリで仕様がなく、版ごとに構造が変わるので読まない(IDML に書き出してもらう)。判別は zip の mimetype(application/vnd.adobe.indesign-idml-package)か designmap.xml の有無。パッケージの読み取りは Office と同じ ooxml.Package(部品名の大文字小文字を区別しない zip)で、XML は ooxml.Node の木にする。InDesign が XML に書けない文字(ページ番号マーカー U+0018 など)は処理命令 <?ACE 18?> で書くので、xmltree はこれを文字として読む。
- 部品:
designmap.xmlが他の部品をsrcで指す。Resources/Graphic.xml(スウォッチ:Color・Tint・Gradient・MixedInk、線種StrokeStyle・DashedStrokeStyle・DottedStrokeStyle・StripedStrokeStyle)、Resources/Styles.xml(段落スタイルと文字スタイル。グループは再帰)、Resources/Preferences.xml(綴じ方向PageBinding)、MasterSpreads/*.xml、Spreads/*.xml、Stories/*.xml、META-INF/metadata.xml(XMP → Dublin Core、converter/internal/xmp)。設計マップのLayerは前面から順に並ぶので、ページアイテムはレイヤーの逆順に安定ソートして背面から描く。スプレッドとストーリーの部品は合わせて 10,000 まで。 - プロパティ: 値は属性(
PointSize="12")かPropertiesの子要素(<AppliedFont type="string">、type="list"のListItem、<Leading type="enumeration">Auto</Leading>)のどちらかで書かれるので、propが両方を見る。スタイルのBasedOnは種類付き(ParagraphStyle/$ID/NormalParagraphStyle)とも種類なし($ID/[No paragraph style])とも書かれるので、両方で引く。 - 座標: 単位はポイント、y は下向き。アイテムの
ItemTransform(a b c d tx ty)は自分の座標(PathGeometryの点の座標)を親(スプレッド、またはグループ)の座標に写し、ページもItemTransformでスプレッドに置かれ、GeometricBounds(上 左 下 右)が自分の大きさである。アイテム → ページの行列は、ページの逆行列にグループとアイテムの行列を掛けたもの。アイテムはスプレッドのもので、外接矩形が重なるページすべてに描く(見開きをまたぐアイテムは両ページに出る)。 - ページとレイヤー: 文書のスプレッドの順にページを作り、
PageBindingがRightToLeftなら View のDirectionを rtl にする。Sectionがページ番号を決める(PageNumberStart、ContinueNumbering、PageNumberStyleのアラビア数字・ローマ数字・英字・前ゼロ)。マスターはAppliedMasterのマスタースプレッドから、ノドの同じ側(スプレッドのBindingLocationより前なら左)のページを選び、そのページの座標で文書のページに重ねる(MasterPageTransformは読まない)。マスターのマスターは先に描く。ページのアイテムがOverriddenMasterPageItemで上書きしたマスターアイテムは除く。マスターのアイテムはmaster、ページのアイテムはbodyのレイヤーにし、どちらも Canvas の Object なので、内容が同じページ(ページ番号のないマスター)では共有される。Visible="false"のアイテムとVisible="false"のレイヤーのアイテムは描かない。グループ(Group、Button、MultiStateObjectは最初の状態だけ)は再帰する(64 段まで)。 - 図形:
PathGeometryのGeometryPathType(PathOpen)ごとに、PathPointTypeのAnchor・LeftDirection・RightDirectionから直線か 3 次ベジェの部分パスにする。4 隅すべてがRoundedCornerの長方形はCornerRadiusの角丸にする(他の角のオプションは読まない)。塗りはFillColorのスウォッチとFillTint、線はStrokeWeight・StrokeColor・StrokeTint・StrokeType・EndCap・EndJoin・MiterLimit。StrokeAlignmentが内側なら、パスでクリップして 2 倍の太さで描き、外側なら 2 倍の太さで描いてから塗りを重ねる。不透明度(TransparencySetting・FillTransparencySetting・StrokeTransparencySettingのBlendingSetting/@Opacity)は色に混ぜる(GROUP の alpha は使わない。§3.11 の draw.io と同じ理由)。描画モードは通常だけ(他は警告)。矢印などの線端は描かない(警告)。 - 色: CMYK は
(1−C)(1−K)の単純な換算、RGB はそのまま、Lab は D50 の XYZ を経て sRGB にする(プロファイルは読まない)。Tintは基準色を白と混ぜる。GradientはGradientStopのStopColor・Locationから LINEAR_GRADIENT か RADIAL_GRADIENT にし、アイテムのGradientFillAngle(x 軸から反時計回り)・GradientFillStart・GradientFillLengthで置く(どれもなければ外接矩形を角度の方向に横切る)。線のグラデーションは最初の分岐点の色で代える。MixedInkは黒(警告)。 - 線種:
$ID/Solid、$ID/Dashed(12 と 4 pt)、$ID/DottedとCanned dotted(丸い線端の点を線の太さの 2 倍の間隔で)、Canned Dashed 3x2・4x4(太さの倍数)、DashedStrokeStyleのDashArray(ポイント)、DottedStrokeStyleのDotGap。ストライプ(ThinThin、ThickThinなど)と白いひし形・斜線・波線は実線で描く(警告)。 - 画像: フレーム(
Rectangleなどの子のImage)はItemTransformとProperties/GraphicBoundsで置き、フレームのパスでクリップする。Properties/Contentsはリンク画像では画像の XMP パケットなので読まず、Link/@StoredStateがEmbeddedのときだけ、そこにあるデータ(base64 か 16 進)を画像にする。それ以外はLink/@LinkResourceURIのファイル名でconverter.Options.ReadRef(Files・Dir。§3.29 の KiCad と同じ仕組み。絶対パスは読まないので、ファイル名だけで探す)。TIFF はconverter/internal/tiffで最初のページを読み、ほかはimgconv.Optimizeで格納する。EPS・PDF・PICT・WMF・ImportedPageは描かない(警告)。 - テキスト: ストーリーは
ParagraphStyleRange>CharacterStyleRange>Contentで、Brが段落の終わり、U+2028 が段落内の改行、タブは\t。HyperlinkTextSource・XMLElement・Changeなどの包みは中を読み、Table・Footnote・アンカーしたオブジェクトは警告して飛ばす(Noteは読まない)。段落の属性はParagraphStyleRange→AppliedParagraphStyle→ そのBasedOnの順、文字の属性はCharacterStyleRange→AppliedCharacterStyleの連鎖 → 段落の連鎖の順に探す。これを Visio(§3.8)と同じく DrawingML のテキスト本体(bodyPr、pPrの揃え・インデント・行送りlnSpc・段落前後・箇条書き(BulletCharの文字、NumberingFormatの番号)・TabListのタブ、rPrの大きさ・太字と斜体(FontStyleの語から)・下線・取り消し線・大文字・上付き下付きとBaselineShift・トラッキング(1/1000 em)・言語(AppliedLanguage→ BCP 47)・塗り・フォント)に写す。行送りはLeadingの値か、AutoならAutoLeading%×段落の最大の文字の大きさを固定の高さにする。ページ番号マーカーは、ストーリーの最初のフレームのページの番号にする(マスターのフレームはページごとに組むので、各ページの番号が入る)。 - 連結(スレッド):
TextFrameはParentStory・PreviousTextFrame・NextTextFrameで連なる。描く前に全ページのフレーム(マスターのフレームはページごとに別の鍵)を集め、前のないフレームからNextTextFrameをたどって連なりを作り(環になっているものは 2 巡目で拾う)、ストーリーごとにdrawingml.TextFlowで流す。TextFlowは DrawingML のテキスト本体の段落を一度だけ読み、Fillのたびに枠(TextFramePreferenceの段組みTextColumnCount・TextColumnGutter、インセットInsetSpacing、垂直方向の揃えVerticalJustification、縦組みStoryOrientation→eaVert)に入るだけの行を取り、残りを次の枠に回す。段落の途中で切れたら、残りの文字を先頭インデント・箇条書き・段落前の間隔なしの段落として続ける。両端揃えは行のアイテムの幅を伸ばすので、段落は複製してからレイアウトし、残りはもとの文字で組み直す。レイアウトは枠に入る高さ(段組みなら段数倍)を超えた段落で打ち切るので、長いストーリーを枠ごとに全部組むことはない。最後の枠に入りきらない(オーバーセット)テキストは描かず、警告する。枠自身の塗りと線は図形として描き、その上にテキストを描く。 - 未対応: .indd、表、脚注、アンカー付きオブジェクト、回り込み、文字の横幅・縦幅の拡大縮小、ルビ、縦中横、文字組みアキ量設定、段落の右インデント、角のオプション(角丸以外)、矢印、描画モード、効果(影・ぼかし)、配置した EPS・PDF、
MasterPageTransform、条件テキスト(すべて描く)。
テスト用の文書は test/idml/gen.py(標準ライブラリだけ)が書く(npm run test:idml:gen)。basic.idml は 1 ページのスプレッドと見開きのスプレッド、マスター(罫線とページ番号)、各種のスウォッチと線種、角丸・グループ・非表示のレイヤーとアイテム、埋め込みとリンク(隣の link.png)の画像、スタイルの連鎖・揃え・タブ・箇条書き・番号・文字属性を持つストーリーが 2 段組みの枠から次のページの枠へ流れるもの。vertical.idml は右綴じの縦組みのストーリーが 2 ページにわたるもの。Go のテストはページとレイヤー、ストーリーがページをまたいで流れること、ページ番号、リンク画像を Dir と Files から読むこと、縦組み、ページの選択と判別を確かめ、変換結果は testdata/idml/ に置く(フォントは PowerPoint と同じ M PLUS 1p のサブセット)。InDesign 自身が書き出した IDML は SimpleIDML(BSD 3 条項)の回帰テストのもの 7 件を converter/idml/testdata/simpleidml/ に置き(README に出典)、CS5.5・CC 2014・InDesign 2020 の書き出しで、レイヤーの並び(設計マップは前面から)、リンク画像の Contents が XMP であること、BasedOn の種類なしの表記、連結フレーム、回転したフレーム、インセットがフレームより大きいフレーム(何も表示しない)を確かめた。配置画像のファイルは入れていない(リンク切れの警告の確認に使う)。テキストの溢れ(オーバーセット)は代替フォントの幅に依るので、テストでは数えない。
3.33 適合テスト(conformance、他の読み手のために)
BDF v1 の読み手が Go 以外にも書かれる(C の libbdf)ので、「この入力をどう扱うべきか」を機械可読で配る。conformance パッケージが suite を組み、bdf conformance -testdata testdata <dir> が書き出す。
- 形式: ディレクトリに
suite.json({"format": "bdf-conformance/1", "bdf": 1, "opset": 1, "cases": [...]})とinputs/の入力。事例はkind(single・object・paths)とexpectを持つ。singleは開けるか(open)、施錠中か、View の{id, kind, pages}、Part の数、読むべき Part とその結果(reads)または全 Part が読めること(allParts)。objectはデコードできるか、全命令が読めるか(walk)、読めた命令数。pathsはデコードとパスの数。messageはこの実装のエラー文言で、人が読むためのもの。 - 期待値はこの実装を走らせて記録する。各事例は「何を示すために書いたか」の判定(受理か拒否か、どの Part が読めないか)も持ち、生成時に観測と食い違えば生成が失敗する。だから suite が参照実装から離れることはなく、
conformance_test.goはそれを CI で確かめる。 - 事例は、このパッケージ自身のテスト(
malformed_test.go、bdf_test.go)を写したもの: ヘッダ(マジック、版、フラグ、予約バイト、manifest の範囲と deflate、暗号化フラグの整合)、manifest の数値の範囲と JSON の受理・拒否、Part の範囲と inflate の上限、書き手が作る文書、Object(切り詰めの全接頭辞、未知の opcode、巨大な個数、不正な参照)、パス集。それに testdata の全文書を受理の事例として足す(351 事例)。 - 配布: CI の成果物
bdf-conformanceと、タグのリリースに添付するbdf-conformance-<tag>.tar.gz(publish.yml。リリースがまだなければ下書きとして作る)。
最初の suite が見つけたのは参照実装の癖で、ファイル末尾の長さ 0 の Part を ReadAt が io.EOF で読み損なっていた(直した)。
3.34 帳票テンプレートと穴埋め(form、form/tag、converter/xlsx・docx・pptx の Fill、converter/pdfform、converter/pdfme)
請求書・納品書・証明書のような帳票を、テンプレートとデータから作る。テンプレートは利用者が Excel・Word・PowerPoint で作ったファイルで、文字のところにタグ({{name}}、{{#items}}…{{/items}})を書く。出力は BDF だけで、Office のファイルは書かない。3 つの形式は帳票の形が違う。Excel は印刷のページで、行が増えればページが増える。Word は流れるページで、長くなった分は次のページへ送られる。PowerPoint は固定のページで、図形は動かない。タグとデータは共通にした。
どこで穴埋めするか: 検討した案は 3 つ。(1) ブックの XML を書き換えて穴埋め済みの .xlsx を作り、それを変換する。再レイアウトがただで手に入り、Excel で開いて確かめられるが、出力に Office のファイルが混ざり、共有文字列や mc:AlternateContent のような XML の層の罠をすべて踏む。(2) テンプレートを静的な Object と「箱」の表に事前に落とし、穴埋めは BDF の上で行う。静的な部分が文書をまたいで共有されるが、行が増える帳票では結局組版が要り、Word と PowerPoint では組版エンジンを丸ごと持ち込むことになる。(3) 変換器が XML を読み終えてレイアウトする直前に持っている内部の形(Excel なら行とセル)を書き換え、既存のレイアウトをそのまま走らせる。採ったのは (3)。行の高さの自動調整、ページ分割、印刷タイトルの繰り返し、条件付き書式は今あるコードのままで、同じ内容の Object は内容アドレスで共有される。
タグ(form/tag): Mustache 風の値・区間・逆の区間・画像と、表示形式(| fmt:"#,##0"。書式は Excel の表示形式コードで、Word と PowerPoint のテンプレートでも同じもの。xlsx.Formatter を form が渡す)。式は持たない。テンプレートは信頼できない入力で、名前を引いて書式を付けるだけなら実行するコードがない。合計・税・繰越とその端数処理はデータの側の仕事にした(端数の規則は帳票ごとに違い、テンプレートに持たせると際限がない)。日本語入力と Word が置き換える全角の波括弧・記号・曲がった引用符も読む。データを引く Scope は、項目から外側へ名前を探す。項目のない枠の行は「空のスコープ」で、データの名前は外側にあっても見えず(外の name が空行に出ないように)、変数(_blank など)だけが見える。
Excel での区間(template.go、template_expand.go、template_calc.go): 開始と終了のタグが同じセルにあれば文字の中の繰り返しか条件、別のセルにあれば行の区間になる。行の区間は 3 通り。
rows(同じ行のタグの既定): 行(別の行のタグなら間の行すべて)を項目ごとに複製し、下を押し下げる。slots(別の行のタグの既定): 開始の行から終了の行までが項目 1 つずつの枠。行は増えない。項目のない行はタグを消して罫線を残す。1 行目のタグと数式を、自分のタグを持たない行に使う。項目が枠より多ければエラー。プレ印刷用紙や複写伝票の流儀をそのまま電子化した「10 行の伝票」のための形。pages:rowsと同じ複製で、複製ごとに改ページを入れる。10 行の伝票を 3 枚にする帳票は、pagesの中にslotsを置き、データがページごとの行と繰越を持つ。
区間は木になる(行の範囲で入れ子。兄弟は同じ行を繰り返せない)。穴埋めはこの木をたどって新しい行の列を作り、同時に「テンプレートの行 → 穴埋め後の行」の対応を、区間の複製の木(secInst・itemInst)として残す。
行に追随するもの: 結合セル、条件付き書式の範囲、リンク、オートフィルター、印刷範囲と印刷タイトル、手動の改ページ、テーブルと図形のアンカーは、対応の木で写す。複製された行の中にあるものは複製ごとに 1 つ、外にあって複製された行を含む範囲は最初の複製から最後の複製まで(Excel で範囲の中に行を挿入したときと同じ)。条件付き書式の数式は範囲の先頭からの相対なので書き換えない。
数式の再計算: 変換器はふだん数式を計算せず保存された値を描くが、穴埋めすると値が変わる。テンプレートのときだけセルの数式を読み(共有数式は持ち主の式を行と列の差だけずらして使う)、穴埋めの後で計算し直す。評価は条件付き書式の評価器で、参照を読むところだけを差し替える(fenv.remap)。数式の参照はテンプレートの番地のままで、「その数式がいる複製から見た行」に写して読む: 同じ複製の中の行は同じ複製の行、数式のいない区間の行は最初の複製(範囲の終わりは最後の複製)。こうすると、明細行の =C4*D4 は自分の行を、ページの中の =SUM(D4:D13) は自分のページを、外の =SUM(E4:E4) は全部の明細を読む。項目が 0 で行が消えた範囲は空(合計は 0)、単独の参照は #REF!。計算の順は、数式が読むセルの数式を先に計算する深さ優先(再帰ではなくスタックで。数式の鎖はシートの長さになりうる)。評価器が知らない数式のセルは空にして警告する。
値の型: タグ 1 つだけのセルは値をそのまま受け取る(数値は数値、真偽値は真偽値)。日付は、YAML とブックのデータでは time.Time、JSON と CSV では文字列で来るので、文字列は「セルかタグの表示形式が日付のとき」だけ日付として読む("2026-10-13" という文字列を勝手に日付にしない)。表示形式のないセルに入った日付には、Excel がセルに日付を打ったときと同じ既定の書式を付ける。fmt 付きのタグは、セルに新しい表示形式のスタイルを足す(数値のまま残るので、数式から読める)。
コンパイル済みテンプレート(.bdft、spec §4.5): テンプレートを検査して配る形。BDF の文書そのもので、ページはサンプルデータで穴埋めしたもの、Part にテンプレートの本体(src)と、タグが引く名前の一覧(tpl、JSON)を持つ。既存の読み手は 2 つの Part を参照しないので、ビューア・サムネイル・検索はテンプレートをふつうの文書として扱える。幾何を事前に落とした独自の形にしなかったのは、変換器の内部の形に結び付いた形式を版ごとに保つより、元のファイルから読み直す方が安いため(帳票の大きさのブックなら読むのは数ミリ秒)。.bdft を穴埋めした結果は、元のブックを穴埋めした結果とバイト単位で同じになる。
信頼できないテンプレートへの備え: 穴埋め後のセルは合わせて 4,194,304 個まで、行はシートの行数まで(超えたらエラー)。行の区間の入れ子は 32 段まで。1 セルの文字は Excel と同じ 32,767 文字で切る。複製で増える結合セル・条件付き書式の範囲・リンクはそれぞれ 1,048,576 個まで。数式の順序づけで見るセルは 16,777,216 個まで(超えたらセルの順に計算する)。データが名前で指す画像はテンプレートに渡したファイルの中だけから読み(ネットワークは読まない)、32 MiB まで。
確かめ方: タグの字句解析と、4 つのセルとデータを与える穴埋めには fuzz ターゲットがある(FuzzParse、形式ごとの FuzzFill)。Word と PowerPoint は、テストの中で小さな文書を組み立てて穴埋め後の文字を突き合わせ、3 つの形態のテンプレート(test/docx/gen.py・test/pptx/gen.py の forms)を Excel と同じデータで穴埋めし、ページ数と検索テキストを確かめる。区間・値の型・追随・数式は、数行のシートを XML で書いて穴埋め後のセルを突き合わせる表駆動のテストで、3 つの形態(1 ページの帳票、行が増える表、10 行の伝票)は openpyxl で作ったブック(test/xlsx/gen.py の forms)を穴埋めし、ページ数と検索テキストを確かめる。
Word と PowerPoint での穴埋め(converter/internal/wordproc/template.go、converter/pptx/template.go): この 2 つの変換器が「レイアウトする直前に持っている形」は、部品の XML を読んだ要素の木(ooxml.Node)そのものなので、穴埋めは木を複製しながらタグを置き換え、できた木を既存の読み手に渡す。ファイルは書かない。
- 段落が単位: Word も PowerPoint も、スペルチェックや言語の切り替わりでタグを複数の run に分けて保存する(
{{custo+mer}})。段落の中で連続する run の文字をひと続きとして読み、タグを探す。値はタグが始まる run の書式で出し、タグでない文字は元の run のまま残す。Word ではブックマーク(最後のカーソル位置に Word が入れる_GoBackなど)と校正の印がタグの途中に入るので、これらは文字の並びを切らないものとして扱う。値の中の改行は行の区切り(a:br、w:br)にする。 - 区間が繰り返すものは、開始と終了のタグを両方含むいちばん小さい入れ物で決める。同じ段落なら文字、同じ入れ物の別々の段落ならその間の段落(Word では表も含む)、表の同じ行なら行、別々の行なら枠(
slots)、PowerPoint で同じスライドの別々の図形ならスライド、別々のスライドならその間のスライド。Word の実装は、段落までの親の列(step)を持ち、2 つのタグのいちばん深い共通の親を求める。タグだけの段落は出力しない(セルとテキストボックスには、Word の規則どおり段落を 1 つ残す)。 - 繰り返し方を書いた区間は持ち上げる:
| rowsはタグが同じ段落にあっても表の行(表の外なら段落)を、| pagesは PowerPoint ではスライドを、Word では段落と表を改ページつきで繰り返す。1 列の表や 1 段落の箇条書きを増やせるようにするための規則で、Excel にも同じ規則を入れた(同じセルの| rowsは行を繰り返す)。そのためにタグの構文で、逆の区間にも繰り返し方を書けるようにした。 - PowerPoint の文字の収まり: 図形は動かないので、値を入れた枠の収まりを決め直す必要がある。
normAutofitの枠は、ファイルのfontScale(テンプレートの文字に対する値)を捨てて、PowerPoint と同じ刻みで収まる倍率を探す。spAutoFitの図形は高さを文字に合わせる。穴埋めしたa:bodyPrに印(bdf-refit)を付けて DrawingML のレンダラに伝える。図形どうしは押し合わない(PowerPoint もそうする)。 - PowerPoint の表の続き: 行が増えた表がスライドの下端(または下にある図形の上端)を越えたら、スライドを複製して行を配り直す。繰り返す行より上は各スライドに、下は最後のスライドに置く。行の高さは描くときと同じコードで測る(
TableRowHeights)。スライド番号は穴埋めの後でしか決まらないので、{{_slide}}・{{_slides}}はフィールド(a:fld)にして、描くときに値を入れる。 - Word の改ページ:
| pagesの 2 つ目以降の複製は、最初の段落にw:pageBreakBeforeを付ける(表で始まるなら、高さ 1 pt の段落を前に置く)。ページ番号は Word のフィールドがそのまま働く。 - 画像: 図の代替テキストに書いた
{{%photo}}を、データの画像に差し替える。画像は DrawingML のレンダラに直接渡し(InjectImage)、部品としては足さない。 - 制限: できあがるスライドは 10,000 枚、要素は 8,388,608 個、1 段落は 65,536 文字、区間の入れ子は 32 段まで。Word と PowerPoint には数式にあたるものがないので、計算はしない。Word の脚注は穴埋めしない。
QR コードとバーコード(image/barcode): 帳票は機械に読ませる記号を載せる(伝票、ラベル、請求書の支払い用のコード)。画像のタグに qr か barcode:種類 を付けると、値を符号化した記号を画像の場所に描く({{%url | qr:H}}、{{%jan | barcode:jan}}、余白は quiet:N)。
- 記号はベクタで描く: 記号はモジュールの格子(
Symbol)で、暗いモジュールを矩形のパスにしてFILL_PATH1 つで塗る(横に続くモジュールと、縦に同じ並びが続く行は 1 つの矩形にまとめる)。画素の画像にすると、拡大とブラウザの補間でバーの太さが変わり、PDF にしたときにも解像度を決めなければならない。矩形ならビューア、ラスタライザ、PDF のどれでも同じ形になる。 - 対応する記号: QR コード(モデル 2、バージョン 1〜40、誤り訂正 L・M・Q・H、数字・英数字・バイトのモード、マスクは 8 種の罰点で選ぶ)、JAN / EAN-13、EAN-8、UPC-A、Code 39、Code 128(コードセットは自動)、GS1-128(括弧で書いた AI、可変長の後ろに FNC1)、ITF と ITF-14、NW-7。バイトモードの文字は UTF-8 のまま入れる(ECI は付けない。いまの読み取り機は UTF-8 と判定する)。
- 置き方: Excel はタグのセル(結合セルならその範囲)に、Word と PowerPoint は代替テキストにタグを書いた画像の枠に描く。DrawingML のレンダラには、画像の代わりに描く関数を渡す(
InjectDrawing)。QR コードは正方形のまま枠に収め、バーコードは枠いっぱいに伸ばす(バーの高さは自由で、幅は読み取り機に合わせて帳票の作者が決める)。 - 値の検査: 桁数、チェックデジット(付いていなければ足し、付いていれば確かめる)、記号が持たない文字を見る。入らない値は記号を描かずに警告にし、厳密なときはエラーにする。値の意味(その JAN が実在の商品か)は見ない。
- しないこと: バーコードの下の目視文字、カスタマバーコード(郵便)、DataMatrix、PDF417、QR コードの漢字モードと連結。ITF-14 のベアラバーも描かない。pdfme のテンプレートでは、埋め手(
converter/pdfme)がバーの下の文字と ITF-14 の枠を足す。 - 確かめ方: 自前の読み手(テストの中だけにある QR コードの復号)で全バージョン・全レベルを往復させる。静穏域・サイズ・入力上限と、不正な入力やオプションの拒否も確かめる。
FuzzEncodeがある。
データの JSON Schema(form.Schema): テンプレートが求めるデータの形を JSON Schema(2020-12)で書き出す。入力フォームを自動で作る仕組み(デモサイトのフォームのページ、利用者のアプリの JSON Schema フォーム)と、データの検証のためである。タグは型を持たないので、スキーマはタグから推し量る。中に名前か {{.}} がある区間と、繰り返し方を書いた区間は配列、それ以外の区間は真偽値、数値の表示形式が付いた値は数値、日付の表示形式なら format: date の文字列、ほかの値は文字列か数値にする。そのために、{{.}} もフィールドの一覧に載せるようにした(名前 "."。区間の項目が値であることを示す)。プロパティはテンプレートの順に並べ、タグの情報は x-bdf に入れる。
区間の中の名前は項目とその外側の両方から探されるので、どちらのプロパティかはタグだけでは決まらない(伝票の見出しの {{customer}} は、ページの区間の中にあるがデータではその外にある)。外側でも同じ名前を使っていれば外側のもの、そうでなければ項目のものとする。サンプルデータがあれば、名前はサンプルにある場所に置き、値の型と、区間が配列かオブジェクトか真偽値かもサンプルで決める。コンパイル済みテンプレートはサンプルを持つので、コンパイルのときにスキーマを作って tpl に入れておく(spec §4.5)。フォームを作る側はテンプレートを読み直さなくてよい。
場所の決まったフィールドを埋めるテンプレート(converter/pdfform、converter/pdfme、converter/internal/formbox): タグを書いた Office のファイルのほかに、フィールドが場所を持っている形式もテンプレートにする。入力欄のある PDF(AcroForm)と、pdfme のテンプレート(デザイナーが保存する JSON)である。どちらもタグの代わりにフィールドの名前がデータの名前になり、form.Fill・form.Fields・form.Schema・.bdft はタグのテンプレートと同じに扱う(形式は中身で見分ける。%PDF- と、schemas か basePdf を持つ JSON のオブジェクト)。値を描く部分(フォントの選択と計測、行の折り返し、文字・画像・記号・印の描画)は formbox に置いて共有し、値を枠のどこに置くかは形式ごとの規則なので、それぞれの埋め手が決める。
- PDF フォーム: PDF の変換器(§3.7)が入力欄を読む(
pdf.FormFields)。欄の完全名(親の名前をドットでつないだもの)、種類(テキスト・チェックボックス・ラジオ・選択・ボタン・署名)、値と既定値、複数行・マス目(comb)・最大文字数、選択肢、既定の外観(DA)のフォント・大きさ・色、揃え(Q)、ウィジェットのページ・大きさ・回転を含む行列。埋めるときは、変換のオプション(pdf.Options.Form)で欄ごとに見せ方を変える。ボタンは値の状態の外観(AP /Nの状態)に切り替え、テキストと選択は外観の中の/Tx BMC … EMCだけを消して枠と背景を残し(外観がなければMKの枠と背景を描く)、その上にpdfformが値を描く。値の置き方は PDF のリーダー(pdf.js)が外観を作る規則に合わせた。1 行の欄は中央、複数行は上から、大きさ 0 は収まる大きさ、comb はMaxLen等分のマス。フォームのフォントは欧文だけのことが多いので、名前(Helvなど)は書体の手がかりにだけ使い、文字を持つマシンのフォントで描く。できた帳票に入力欄は残らない(フラットな PDF と同じ)。 - pdfme のテンプレート: pdfme 6.2 の生成器の規則をそのまま写す。場所と大きさはミリ(1 mm = 2.8346 pt)、文字の大きさはポイント。テキストは、枠から罫線と余白を引いた中に、ベースラインを「フォントのアセント+行間の調整」で決めて置く(上・中央・下、左・中央・右・両端揃え、文字間、下線と取り消し線、回転と不透明度)。大きさの自動調整(
dynamicFontSize)は 0.25 pt 刻みで伸ばしてから縮める規則も同じ。表は列幅を百分率で決め、セルの高さを行数から出し、縞・見出し・列ごとの揃えを写す。白紙の土台では、表・リスト・伸びるテキストを「単位の高さの列」(表なら見出しと各行)にして、ページの内容の高さに詰めていく pdfme の動的レイアウトを写した(見出しだけがページの下に残らないようにする規則、repeatHeadで見出しを繰り返すときの高さの数え方、下にあるフィールドを押し下げる量も同じ)。PDF が土台のときはconverter/pdfでページを変換し、その上のレイヤーにフィールドを描く。 - 式: pdfme は読み取り専用のテキストの
{…}を JavaScript の式として評価する。ここでは式の評価器を自前で持つ(expr.go。リテラル、演算子、三項、メンバーと添字、呼び出し、配列・オブジェクト・アロー関数、文字列・数値・配列のメソッド、Math・JSONなど)。任意のコードは動かさず、長さ・深さ・手数に上限があり、constructorや__proto__は引けない。式が読む名前のうちフィールドでないものは、データのフィールドとしてform.Fieldsに出す(「合計 {total}」のtotal)。pdfme が時計から読むdateとdateTimeも同じ扱いにして、同じデータから同じ帳票ができるようにした(Go からはTemplateData.Nowで渡せる)。 - データは JSON の値: pdfme の入力は文字列(表は JSON の文字列)だが、ここでは値で受ける。表は行の配列(列名のオブジェクトかセルの配列)、チェックは真偽値、変数入りテキストは変数のオブジェクト。デザイナーがフィールドに残した内容(
content)は、テンプレートのサンプルとして使う(pdfme.Defaults。form.Compileはデータがなければこれで埋め、form.Describeが返す)。 - pdfme と違うところ: テキストの中の Markdown 風の書式、
japanpost・upce・gs1datamatrix・pdf417のコード(image/barcodeが作らない)。バーコードの下の文字は 1 行で置く(pdfme は bwip-js の描いた SVG を枠に引き伸ばす)。SVG は枠に全体を収める(pdf-lib は横長の枠で高さに合わせて切り落とす)。チェックボックスの角は丸い(デザイナーの表示と同じ。pdf-lib はrxを描かない)。半透明の罫線は塗りの上に重ねない(PDF の塗りと線を同時に描く演算子の規則に合わせた)。 - 確かめ方: Node の pdfme 6.2.4 を同じフォントで動かし、同じテンプレートとデータの出力を Ghostscript で描いて重ねた(テストの 2 つのテンプレートと、機能を並べた 1 枚)。文字の位置、表の行の高さ、ページの切れ目、押し下げられたフィールドの位置が一致し、残る差は QR コードとバーコードの符号化の選び方(マスク、コードセット)だけになる。Go のテストは、その値(行の高さ、ページごとの行、押し下げの量)を数で確かめる。
FuzzFillとFuzzExprは任意のテンプレートと式を通す。テンプレートは信頼できない入力なので、ページ・フィールド・行・セル・文字数に上限がある。
まだないもの: 列方向の繰り返し、フォントをテンプレートに固定すること(組むマシンのフォントに依存する)、Word の図形が文字の量で押し合う可変レイアウト。
3.35 PDF への書き出し(vector/pdfbdf、bdf pdf)
帳票は最後に紙か PDF になる。BDF の文書を PDF に書き出す書き手を持つ。入力の変換器とは逆向きで、BDF の命令列(spec §7)を同じ順に PDF の内容ストリームへ写す。
対応: SAVE / RESTORE は q / Q、変換は cm、パスは PDF のパス演算子(楕円弧・arcTo・角丸は 3 次ベジェにする。Canvas のパスの規則はラスタライザと同じコードの写し)、クリップは W n、線と破線はそのまま。色のアルファと ALPHA は ExtGState の ca・CA、BLEND の 16 のブレンドモードは BM。線形・放射のグラデーションは軸・放射シェーディングのパターン(Canvas の 2 円の放射は PDF のタイプ 3 そのもの)、扇形は中心のまわりの三角形の網(タイプ 4)、画像のパターンはタイリングパターン。グループは透明グループのフォーム、ソフトマスクはグループを描くときの SMask。停止点のアルファが違うグラデーションは、アルファを輝度のソフトマスクにする。リンクはリンク注釈、文書情報は Info と XMP。
文字は文字のまま: 文書のフォント(WOFF2 は戻す)を、使っているグリフだけ残して CID フォント(Identity-H)として埋め込み、グリフから文字への対応(ToUnicode)を付けるので、PDF で検索とコピーができる。グリフ番号を保つ刈り込み(PruneGlyphs・PruneCFF)を使うので、コードはグリフ番号のまま(CID キーの CFF は CID)。名前で参照するフォントは書き出すマシンのフォントを探して埋め込む。埋め込みを許さないフォント(OS/2 fsType)と、PDF が持てないフォントは、グリフのアウトラインを描いて ActualText を付ける。文字の配置はラスタライザと同じ(シェーピングなし、advance に合わせて横に伸縮、太字と斜体の合成)。共通の部分は internal/textrun に出した。
すべてのフォームをページの座標系で定義する: ビューアは、フォームの中のパターンとソフトマスクの座標系の解釈が揃っていない。Ghostscript は仕様どおりフォームの座標系を基準にするが、Quartz(macOS のプレビュー)はフォームの中のパターン行列をページの座標系への行列として読み、別の座標系で設定したソフトマスクをページの矩形で切る。そこで、グループ・ソフトマスク・共有レイヤーのフォームはすべてページの既定の座標系で描き、フォームの中身の先頭に変換を持たせる。こうするとどちらの解釈でも同じ行列になる。小さな PDF を両方のビューアで描いて確かめた。
ビュー: ページのあるビュー(fixed・flow)のページを書く。ページのない文書は、scroll の帯を積んだ 1 ページ、sheet のタイルが届く範囲の 1 ページにする(14,400 pt を超えたら UserUnit)。複数のページが共有するレイヤー(スライドのマスター)は 1 度だけ書く。SkipRoles でレイヤーを役割ごとに省ける(印刷済みの用紙に重ねて刷る帳票)。
影は影だけを画像にする: PDF には影もぼかしもない。影を落とす図形(パス、文字、画像、グループ)を 1 つだけ、同じ状態(変換、線、フォント)でラスタライザ(raster/imagebdf)に描かせ、そのアルファをラスタライザと同じぼかし(箱形 3 回、internal/gauss)にかけて、影の色のソフトマスクつき画像として図形の下に置く。図形そのものはベクタのまま上に描く。影はページに平行な格子で作り(回転した図形でもぼかしは等方)、解像度は 300 dpi、1 つの影は 4,194,304 画素、文書全体で 2.56 億画素まで(超えたら解像度を下げ、それでも収まらなければ省いて警告)。グループの影は、グループを閉じるときに中身を描いて作る。
タグ付き PDF(Options.Tagged、-tagged): 文書の MARK(§7.8)を、PDF の構造木に写す。MARK をたどる状態機械は、検索用テキストを作るもの(packages/core/src/text.ts の Extraction)と同じ規則にした。葉の MARK(PARAGRAPH、HEADING、BOX、LIST の外の LIST_ITEM、TABLE の外の CELL)は次の文字が属する要素を予約し、構造の MARK(LIST、TABLE、FIGURE、END)はその場で要素を開閉する。
- 内容に印を付ける: 描画命令は、属する構造要素ごとに
/P <</MCID n>> BDC … EMCで囲む。文字は葉の要素(P、H1〜H6、LIの中のLBody、TD・TH)、代替テキストつきの図形や画像はFigure、それ以外の描画は/Artifact BMC。q・Qをまたぐ印は PDF が許さないので、状態の保存と復元のたびに閉じて開き直す。 - 構造木:
StructTreeRootの下にDocument、その下に要素を置く。各ページのStructParentsとParentTree(数の木)で、MCID から要素を引ける。グループやソフトマスクのフォーム XObject の中の内容は、フォームのStructParentsとMCRの/Stmで指す。リンクの注釈はLink要素のOBJRにし、注釈にはStructParentとContentsを付ける。 - 表: CELL の payload(
B2:C2 col)から行と列を決め、Table→TR→TH(Scope)・TD(RowSpan・ColSpan)にする。行によってセルの数が違う表は、検査(PDF/UA の表の規則)を通るように、空のTDを足して長方形にそろえる。 - 装飾: ヘッダー・フッター・マスター・背景のレイヤー(役割で分かる)、シートの枠線、影の画像はアーティファクトにする。タグ付きのときは、共有するレイヤーをフォームにまとめるのは装飾の役割のものだけにする(本文のレイヤーはページごとに印を付けるため)。
- 言語: LANG の MARK と
meta.dc.languageを、要素のLangとカタログのLangにする。
PDF/A(Options.PDFA、-pdfa): 第 2 部と第 3 部の、レベル b・u・a を書く(2b・2u・2a・3b・3u・3a)。レベル a はタグ付きを兼ねる。
- 足すもの: sRGB の出力インテント(ICC プロファイルは行列と階調曲線から自前で作る。同じバイト列になるように日付などは固定)、XMP の
pdfaid:partとpdfaid:conformance、文書情報と XMP の一致。 - 変えるもの: 画像の
/Interpolateを付けない。どのフォントにもない文字は、.notdefのグリフを描かずに省いて警告する(PDF/A は.notdefの描画を許さない)。レベル a では、私用領域の文字(アイコンフォント)に警告を出す(規格はActualTextを求める)。 - 書かないもの: 第 1 部(PDF/A-1)は透明を持てないので書かない。PDF/UA は名乗らない(構造は同じものを書くが、文書そのもののタイトル・言語・見出しの順序が要る)。
- 確かめ方: veraPDF で、testdata の全文書(124 個)を書き出して検査する。2b と 2u は全部が通り、2a は私用領域の文字を持つ 2 個(アイコンとフォントの見本)を除いて通る(警告のとおり)。この検査で、グリフを刈り込んだフォントの幅の誤り(
PruneGlyphsが、最後の全幅のメトリックを共有するグリフの送り幅を 0 にしていた)が見つかり、直した。Ghostscript では、タグ付きとそうでない PDF が同じ絵になることを確かめた。
書けないもの: FILTER、ブレンドモードでない Canvas の合成(destination-out など。通常の重ね描きにする)、グループの中の CLEAR_RECT、AVIF の画像。警告を出す。暗号化はまだ書かない。
信頼できない文書への備え: 再利用される Object は描かれる回数だけ書き出すので、書き直す命令は 4,194,304 個まで。開いているグループとソフトマスクは 64 段、保存する状態は 1,024 個まで。書き出しの中の panic はエラーにする。
確かめ方: テストは、書き出した PDF を自前の PDF 変換器で読み直し、元の文書と同じ絵になることをラスタライザで比べる(文字は読み直した文書の検索用テキストでも確かめる)。testdata の全文書は Ghostscript で描いて突き合わせ、ソフトマスクとパターンは Quartz でも確かめた。同じ文書からは同じバイト列ができる(amd64 と arm64 でも同じ)。FuzzWrite は任意の命令列を書き出す。
4. テキストの扱い
一番忠実度を左右する部分。3 段階を用意する。
| 方法 | 忠実度 | サイズ | 検索/選択 | 用途 |
|---|---|---|---|---|
FILL_TEXT + 埋め込み WOFF2 + advance 補正 |
高 | 小 | 可 | 標準 |
FILL_TEXT + システムフォント + advance 補正 |
中 | 最小 | 可 | 埋め込み不可のフォント、和文のフォールバック |
アウトライン化(FILL_PATH_RUN + Path collection) |
完全 | 大 | 不可(MARK で文字列を別途持てば可) |
ロゴ、特殊フォント、Type3 |
advance 補正は pdf.js が長年使っている手法で、フォント代替時の行崩れを実用上ほぼ消せる。文字単位の位置がずれても行の右端は揃う。
Canvas にはグリフ ID で描く API がないので、「サブセットフォントを埋め込んでも、文字列→グリフの対応はブラウザのシェーピングに任せる」ことになる。合字やコンテキスト依存の字形が原文と違うことはありうる。それが許容できない箇所だけアウトライン化する。
4.1 検索と選択
検索と選択は別の問題として扱い、検索はライブラリ(Worker)が提供する。
- 検索はデータの問題。
FILL_TEXTの文字列とMARKの境界(§7.8)から読み順の run 列が得られる。View ごとのテキスト索引 Part(§7.9)があれば Object を一切デコードせずに全文検索でき、同じ Part をサーバー側の検索エンジンにも流せる。索引がなければ Object を走査して同じ run 列を作る(Go と TypeScript の抽出器は同じ順序・通し番号を返すことをテストで保証している)。 - 正規化は検索時に行う。NFKC は run の文字列全体に掛け、そのあと大文字小文字、カタカナ→ひらがな、数式のマイナス記号(U+2212)→ハイフンマイナスを 1 文字ずつ畳む。当初は NFKC も 1 文字ずつ掛けていたが、それでは
e+ 結合アクセントがéに、半角のガががに一致しない(1 文字ずつでは合成が起きない)。対応表は「ひとまとまりで正規化される断片」ごとに持つ: 断片は普通 1 文字で、前の文字と一緒でないと正規化の結果が変わる文字(結合文字、ハングルの字母)だけが前の断片に加わる。断片を順に正規化してつなげたものは、文字列全体の正規化と同じになる。断片は 32 文字までで打ち切る: 1 文字に結合文字を何万個も続けたテキストは、文字を足すたびに断片を正規化し直すので、長さの 2 乗の時間がかかる。打ち切った先の結合文字は、それだけで正規化する。正規化後の各コード単位から元の (run, 断片の始まりと終わり) を引けるので、合成や全角半角で長さが変わっても、ヒットを元の文字範囲に戻せる。合成は run の中だけで、run をまたいでは行わない。 - ヒットの矩形は Worker で計算する。該当 Object をデコードして run の位置・フォント・
advanceを取り、接頭辞幅をmeasureTextで測って部分一致の矩形を出す。フォントは Worker に読み込まれているので、埋め込みフォントでも正しく測れる。行をまたぐヒットは複数の矩形になる。 - ブラウザの検索ショートカットはビューアが受ける。ブラウザ自身の検索(Cmd+F、Ctrl+F)が探せるのはページにある DOM のテキスト、つまり表示範囲の近くに置いたテキスト層だけで、まだ描いていないページには届かない(ビットマップだけを置くビューアでは何も見つからない)。そこでビューアは、フォーカスが自分の中にある間このショートカットを受け、Worker の検索で View 全体を探す。
@bdfkit/renderのfindKeyがキーを判定し(macOS は Cmd、ほかは Ctrl。macOS の Ctrl+F はテキスト欄のカーソル移動なので取らない。Cmd/Ctrl+G と F3 は次の一致、Shift 付きは前の一致)、自前の検索欄を持たないビューアはFindBarを出す。ページの一部として埋め込まれたビューアがページの他の部分の検索を奪わないように、既定はフォーカスがある間だけにし、ページ全体がビューアならfind: "page"でどこにフォーカスがあっても受ける。検索欄の中でもう一度押されたショートカットはブラウザに渡すので、ブラウザ自身の検索も残る。 - ヒットの矩形は要るページから求める。検索はテキスト索引だけで済むが、矩形にはそのページの Object とフォントが要る。入力のたびに探す検索欄で全ヒットの矩形を求めると、Range で読んでいる文書のほとんどを取りに行くことになる。
FindHitsはヒットを 1,000 件まで持ち(それより多いことは件数に+で示す)、表示範囲の近くのページ(シートはタイル)と移動先のヒットのページだけをlocateする。検索は読んでいるページから始め、最後まで行ったら先頭へ戻る。 - 選択・コピーは透明 DOM(pdf.js 方式)。
@bdfkit/renderのbuildTextLayer(runs, scale)が run ごとに透明なspanを絶対配置した層を作り、ページ型では可視ページだけに置く。span の幅は Worker が埋め込みフォントで測ったadvanceに合わせてscaleXで伸縮するので、メインスレッドにフォントが無くても選択範囲が描画と一致する。回転・斜体の run はmatrixをそのまま CSS transform に渡す。ALT_TEXTの run も既定で層に入れる。合字や私用領域の文字で描いた run は本来の文字列を同じ位置・advanceで置き、パスや子 Object で描いたものは位置と基線の向きしか分からないのでアンカーから基線に沿って置く(これを落とすとその行がスクリーンリーダーからも選択からも消える)。ヒットの矩形も同じ規則で合字の run を含める。層にはforced-color-adjust: noneを付け、Windows のハイコントラスト(forced colors)で透明な文字が不透明に塗られて描画に重なるのを防ぐ。選択をドラッグしてテキストのない所(余白、行や語の間、ページの外)へ出ると、絶対配置の span しかない層ではブラウザがポインタを遠い位置(多くはページの先頭)に解決し、選択が飛ぶ。そこで pdf.js と同じく、層の末尾に選択できない要素を置き、ドラッグの間はこれでスクロール領域を span の下から覆い、選択の動く端の run の隣へ DOM の上で移す。テキストのない所ではポインタがこの要素に乗るので、選択はその run の隣で止まる(Firefox はこの要素の上では選択を動かさないので、要素は層の末尾に置いたままにする)。スクロール領域の外ではブラウザ自身のドラッグのまま(はみ出した方向へスクロールする)。span の z-index は 1 で、固定ペインのセルのように他の run より上に置く span は 2 にする。連続モードはcontinuousContent(view, viewport)が帯座標に移した内容を返し、同じ層を帯ごとに置く。シートはsheetContent(view, viewport)で表示範囲の周りのタイルだけを層にし、スクロールに合わせて作り直す(10 万セルの span は重すぎるので全体は DOM にしない)。 - コピーはブラウザの直列化に任せない。無関係な span を選択したときのブラウザのテキスト化は区切りが落ちる(単語がくっつく)ので、
installCopyHandler(container)が copy イベントを取り、選択範囲に交差する span を文書順に集めてselectionText()で組み立てる。run のsep(MARK 由来、無ければ位置からの推定)を使い、LINE は空白、PARAGRAPH/CELL/BOX は改行、ページをまたいだら空行にする。span の途中で始まる・終わる選択は Range のオフセットで切る。 - 表はセルで選ぶ。選択が表のあるセルから同じ表の別のセルへ届くと(ドラッグでもキーでも)、ワープロや表計算ソフトと同じく、2 つのセルを角とする矩形のセルの選択になる。層は run の外接矩形から各セルのテキストの範囲を知っているので、選んだ列にあるすべてのセルのテキストの左右端と、選んだ行にあるすべてのセルの上下端から矩形を作り(列の端がどの行でも揃う)、右端と下端は次の列・行のテキストの手前まで延ばして 1 つの枠で示す(セルの罫線は層には分からない。テキストはセルの左上から始まることが多いので、そこまでがセル)。表の中の run はブラウザの選択の色を消す(DOM の選択は読み順に一直線なので、矩形の外のセルも含む)。結合セルを切る矩形はそれを含むまで広げる。1 つのセルの中の選択と、表の外へ出た選択はテキストの選択のまま。コピーはセルを
text/plainのタブ区切り(TSV。タブ・改行・引用符を含むセルは Excel と同じく引用符で囲み、引用符を重ねる)とtext/htmlの<table>(結合セルはrowspan/colspan、見出しのセルはth、セル内の改行は Excel が行を分けない<br style="mso-data-placement:same-cell">)の両方で置く。表計算ソフトはどちらからでもそのままセルとして貼り付ける。セルのテキストは run をsepで連結したもの(セルの中の段落は改行)で、抽出したTextContentからtableCells()が作り、cellClipboard()が矩形を 2 つの形式にする(ビューアのシートも同じ関数を使う)。 - セルのコピーの上限: コピーする矩形の位置(行 × 列)は 2^22 個か、中のセルの数の 64 倍の多い方までで、超える矩形は
cellClipboardがエラーにし、テキスト層は run のテキストのコピーに切り替える。A1 と A20000000 の 2 つのセルだけのシートで全体を選ぶと、空の行を 2000 万行書き出して 360 MB の HTML を作っていた。 - シートはテキストを選ばない。シートでは選択の単位がセルなので、ビューアはテキスト層を選択できなくし(
selectable: false。リンクと読み上げはそのまま)、表計算ソフトと同じ操作でセルを選ぶ: セルを押してドラッグ(スクロール領域の端の外では、その向きへ離れた距離に応じてスクロールする)、Shift を押しながらで伸ばす、列・行の見出しで列・行全体、左上の角ですべて、矢印キー(Shift で伸ばす。非表示の行・列は飛ばす)、Ctrl/Cmd+A、Escape。選択は canvas の上に描き(アクティブセルを除いて塗り、枠で囲み、見出しを色分けする)、View ごとに覚えておくので、ズームやタブの切り替えでも残る。コピーは Worker にその矩形のsheetContentを頼んで同じcellClipboard()で作る(非表示の行・列は除き、列・行全体の選択はテキストのある最後の行・列まで)。テキスト層が読み込んである範囲なら同期的に、そうでなければ選択を決めた時点で先に頼んでおく。copy イベントは同期的にしか書けないので、まだ届いていなければnavigator.clipboard.write()に後から解決するClipboardItemを渡す(Safari はイベントの後の書き込みをこの形でしか受け付けない)。Safari は選択がないと copy イベントを出さず、ブラウザの「コピー」も無効になるので、セルを選んでいる間はページの選択を画面外の要素(中身はセルの範囲の名前)に置いておく。 - ビューアの責務はヒット一覧の表示とハイライトの描画だけで、フォーマットの内部事情(run の分断、MARK、正規化)を知らなくてよい。
4.2 読み上げ(アクセシビリティ)
Canvas はアクセシビリティツリーに出ないので、支援技術が読むのはテキスト層だけになる。そこで層を単なる span の並びではなく、構造を持つ DOM にする。
- 構造は MARK から作る(spec §7.8)。
extractContent()が run と同じ走査で構造ノード(段落・見出し・リスト・表とセル・図)とリンクを集め、buildTextLayer(content, scale)がそれぞれをrole=paragraph/heading(aria-level)/list・listitem/table・row・cell・columnheader・rowheader/imgの要素にする。構造の要素は大きさを持たず、中の span の配置は変わらない。例外は図とリンクで、図は代替テキストを名前に持つrole=imgをその範囲に、リンクは<a>をリンク領域に絶対配置し、中の span をそこからの相対位置に置く(読み上げカーソルの枠が描画と合う)。 - DOM の順は run の順のまま(選択とコピーが DOM 順で区切りを復元するため)。表の行もセルの開始行が変わるところで区切るだけで並べ替えない。ノードは命令列の順に作られ、run はいつも最新のノードに属するので、run を順に置けば構造の要素も読み順に並ぶ。
- 構造の状態は走査順に一直線(
USEや SAVE/RESTORE をまたぐ)。PDF 変換の共有プレフィックス(§5)で命令列が子 Object に分かれても同じ結果になるためで、Go の抽出器は区切り(sep)だけを扱い、構造は持たない。区切りは MARK の種類だけで決まるので、Go の索引と TS の抽出が一致する(テストで全レイヤーを突き合わせる)。 - リンクは
http:/https:/mailto:と#page=N、#view=IDだけを<a>にする(文書に埋め込まれたjavascript:などを実行させない)。リンク領域に中心が入る連続した run を包み、run の無いリンク(画像のリンク)は名前付きの<a>にする。#page=Nはビューアがページ移動とフォーカス移動に、#view=IDは View の切り替え(シート見出しのタブを選ぶのと同じ)に置き換える。 - 言語は
meta.dc.languageの最初の値を層のlangに、MARK LANGの run を span のlangにする。ビューアの UI の言語と文書の言語は別なので、html langではなく層に付ける。 - 先読み: テキスト層はビットマップより広い範囲(前後 3 画面)で作る。スクリーンリーダーのカーソルが進むとページがスクロールされ、その先の層が作られるので読み進められる。全ページを一度に作ると分割形式や Range 取得で全 Object を読んでしまうので避ける。
- ビューアはページに
role=groupと「Page n of N」の名前を付け(ページ数だけのランドマークを作らない)、Canvas にはaria-hiddenを付ける。
5. 共有オブジェクトの効き方
内容アドレスなので、エンコーダが「同じものを同じバイト列で出す」だけで共有される。効く条件は決定的な出力:
- f32 の丸め方を固定(Go 側で
float32()に落としてから書く)。 - 文字列表・パス表の順序は出現順に固定。
- Object の bbox はコンテンツから計算し、ページ位置に依存させない(ローカル座標)。
- 「ほぼ同じだが少し違う」ものは共有されない。マスター+差分という構造は変換元の情報がある場合(PPTX 直接変換)にのみ作れる。
さらに読み手側では USE 対象を (hash, scale) でビットマップキャッシュできるので、共有はサイズだけでなくスクロール時の描画コストも下げる。
5.1 デコードした画像の保持(ビューア)
レンダラ(@bdfkit/render の ResourceCache)は、Path2D・FontFace・ImageBitmap を文書ごとにキャッシュする。このうち ImageBitmap は桁違いに大きい。A4 のスキャンを 192dpi にしたページ 1 枚は、RGBA にデコードすると 14MB になる。スキャンの TIFF や PDF を読み進めると 100 ページで 1.4GB になり、タブが落ちる。そこで、デコードした画像だけは予算の中で持つ。
- 予算: 幅 × 高さ × 4 バイトで数え、既定は 256MiB(
DEFAULT_IMAGE_BUDGET)。超えた分は、最も前に準備された画像からclose()する。Map の挿入順を LRU の順に使い、prepareで触れた画像を末尾へ移す。予算はResourceOptions.imageBudgetで変え、ワーカーにはBdfWorkerClient.openのoptions.imageBudgetで渡す。 - 読み直し: 閉じた画像は、次にそれを使う描画の
prepareがもう一度デコードする。BdfDocument.ensureは読み込み済みの印を持たず、呼ぶたびに依存パーツをonResourceに渡し直すので、読み直しの仕組みは要らない。符号化されたバイト列(画像の Part)はBdfDocumentが持ち続ける。デコード後の数十分の一の大きさなので、Range 取得をやり直さずに済む方を取った。 - 描画中の画像は閉じない: ワーカーの要求は非同期に重なる。描画 A が画像を準備して別の画像のデコードを待つ間に、描画 B が終わることがある。B の終わりで予算まで削ると、A が準備済みでまだ描いていない画像を閉じてしまう。そこで描画ごとに
hold()を取り、prepareが触れた画像をその hold に入れ、描き終えてrelease()するまで閉じない。削るのは、デコードで予算を超えたときと hold を外したとき。押さえられた画像だけで予算を超えるときは、超えたまま持つ。 - 同じ画像の同時デコード: 1 回のデコードを共有する(以前は重なると先の ImageBitmap を閉じずに捨てていた)。
- テキストだけの要求は画像を読まない: テキスト層・連続モードのテキスト・シートのテキストは
prepareTextで Object・フォント・パスだけを準備する。前後の画面のテキスト層を先に作っても、描かない画像をデコードして予算を使うことはない。 - 文書を閉じる・開き直す: ワーカーはキャッシュの
dispose()で画像を閉じる。その文書の描画がまだ途中なら、押さえている画像はその描画が終わってから閉じる。 - 縮小デコードはしない:
createImageBitmapのresizeWidthで小さくデコードする手もあるが、キャッシュは倍率をまたいで共有しており、ズームのたびにデコードし直すことになるので見送った。画像の大きさは変換側の解像度の上限(§3.2、§3.15)で抑える。
文書が決める大きさへの備え(ビューア): 画像はデコードする前にヘッダー(PNG、JPEG、GIF、WebP、BMP)から大きさを読み、maxImagePixels(既定 2^28 画素)を超えるものはデコードせず、何も描かない(ResourceCache.image() は undefined を返す)。大きな写真はそのまま格納してブラウザに描かせる方針(§3.19)なので、カメラの 1〜2 億画素の写真が入るよう、Go の 100 × 2^20 画素より大きくしてある。1 回の描画が押さえる画像は holdLimit(既定 4 GiB)までで、超える描画は失敗にする(読み込みの順で結果が変わらないように、画像を選んで落とすことはしない)。グループとソフトマスクの一時キャンバスは、描く先のキャンバスと影・フィルタの届く範囲に切り詰め、合成したら大きさを 0 にしてすぐに手放す。ページのキャンバスは 2^28 画素、1 辺 32,767 画素まで。USE の入れ子と繰り返し、Part の展開、manifest の数値、鍵スロットの反復回数は Go の読み手と同じ上限で(spec §3.2、§3.5、§4、§7.6)、超えたら BdfFormatError にする。分割形式の Part 名は URL に入るので、32 文字の hex であることを確かめてから取得する。
6. Excel シートの Tile 化
- Tile サイズは 2048 unit(≈ 28 インチ)を既定にする。標準行高 20pt なら 100 行、標準列幅 64pt なら 32 列がおおむね 1 Tile。
- 空 Tile は表に載せないので、疎なシートは小さくなる。
- 描く範囲に入る Tile は、範囲の Tile 座標を数え上げて探すが、範囲の座標の数がシートの Tile の数より多いときは、シートの Tile の方を調べる(
tilesIn)。範囲は画面とタイルの大きさから決まるので、シート全体を選んだときなどは、Tile のない座標がほとんどになる。 - 座標を Tile 原点相対にする理由: Excel の最大行数 1,048,576 × 20pt ≈ 2,000 万 unit で f32 の整数精度(約 1,677 万)を超える。
- 行列ヘッダー・グリッド線・固定ペインはビューアが
cols/rows/freezeから描く。内容にグリッド線を焼き込まないのは、表示切替とズーム時の線幅制御のため。グリッド線はタイルより先に描くので、セルの塗りがグリッド線を隠す(Excel と同じ)。デモビューアはシートを 512 CSS px 角のチャンク(シートの原点からの格子で、辺は整数のデバイスピクセル)に分けてワーカーに 1 度ずつ描かせ、ビットマップを覚えておく。スクロールでは描いてあるチャンクを固定ペインごとに切り抜いて canvas に並べ、見えてきたチャンクと 1 つ外側のチャンクだけを頼む(届くまではグリッド線だけを描く)。以前は表示範囲全体をスクロールのたびに描き直して送っており、2 倍の解像度では 1 枚 25 MB のビットマップの転送が WebKit で 1 回 20 ms を超え、描画も文字数に比例して重かった。行列ヘッダーはメインスレッドで描く。テキスト層は 1 つの表のままにして(読み上げでは表が 1 つ、固定した行・列が見出しになる)、固定ペインのセルの span だけを CSS のtranslateでスクロール量だけずらして画面に留める(span ごとに書く。層のカスタムプロパティで一度にずらすと、層の数千の span のスタイルがスクロールのたびに計算し直される)。 - 1 つの Tile には数千の run があり、ビューアが描く領域はその一部なので、レンダラ(
CanvasRenderer.drawのvisible)は見える矩形から十分に離れた FILL_TEXT を描かずに飛ばす(アンカーからadvanceとフォントサイズの 4 倍より遠いもの。変換を通して矩形を追い、影やフィルタがあるとき、状態が分からないときは飛ばさない)。advance補正のためのmeasureTextの結果も、読み込み済みのフォントと文字列ごとに覚えておく(スクロールのたびに全 run を測り直していた。Chrome では描画より計測の方が重い)。連続モードの帯も同じ規則で描く。 - 1 Tile を 1 Object にして「クリップして描くだけ」にしたのは、レンダラの Tile キャッシュを単純にするため。またがるオブジェクトの重複は
USE参照の重複(16 バイト + 数バイト)だけなので許容。 - セルの塗り・罫線・はみ出したテキストのように小さなものは、共有 Object にせず触れる Tile それぞれに描く(Part を増やすより命令の重複の方が安い)。そのままでは検索でヒットが二重になるので、テキストの抽出では run をアンカーのある Tile のものとする(spec §4.1)。ワーカーのテキスト層は以前からこの規則で重複を除いていたが、テキスト索引(Go の
BuildTextIndexと TS の索引なしの経路)も同じ規則にした。
7. Word の「縦一枚」表示
再レイアウトはしないと決めた。理由は BDF が表示リストであること、そして再レイアウトを許すとフォーマットが「文書モデル」になって PDF との差別化(軽いデコーダ)が消えること。
代わりに 2 つの手段を用意し、Word の変換器(§3.9)は両方を作る。
flowView の連続モード:bodyレイヤーを本文矩形で切り出して積む。ページ境界で段落が割れるのは残るが、ヘッダー・フッター・余白は消える。多くのプレビュー用途はこれで足りる。- 変換側で「用紙高さ無限」の設定でもう一度レイアウトし、別 View(
scroll、spec §4.1)として同梱する。画像・フォント・繰り返し部品は共有されるので、増えるのは本文の命令列だけ。
2 は当初「ページ 1 枚の fixed View」とするつもりだったが、帯に切った scroll にした。1 枚のページでは、長い文書で Canvas の大きさの上限(一辺 32,767 px 程度)を超えてページ単位の描画ができず、見えていない部分まで 1 つの Object として読み込むことになるため。帯は flow の連続モードと同じ描き方(隙間 0、帯全体が本文)で積めるので、レンダラとテキスト層・検索はそのまま使える。帯の境界は行の間に置き、境界をまたぐ図や塗りだけを両方の帯に描く(シートのタイルと同じ考え方)。
8. ロードマップ
- 仕様固め:
spec.mdの opset 1 を確定。Go のエンコーダ + TS のデコーダを最小実装し、手書きの Object で Canvas に描けるところまで。 - フィクスチャとブラウザの機能テスト: Go でフィクスチャを生成し、ブラウザで描画・検索・選択・文書構造を検証する。
- PDF → BDF: 最初の実用変換(実装済み、§3.1)。
- ビューア: Worker + OffscreenCanvas、ページ/連続/シートの 3 モード、テキストレイヤー、検索。
- XLSX → BDF: 直接変換、Tile 化、固定ペイン(実装済み、§3.6)。CSV・TSV も同じ描画で(実装済み、§3.10)。
- PPTX 直接変換: マスター共有の本領(実装済み、§3.4)。
- Visio 直接変換: .vsdx。背景ページの共有とテーマの解決(実装済み、§3.8)。
- DOCX 直接変換: 変換側のレイアウトエンジン、紙面と scroll の 2 つの View(実装済み、§3.9)。
- draw.io 直接変換: ページごとの View とシートのような切り替え(実装済み、§3.11)。
- CAD 図面: DXF(実装済み、§3.12)、JWW(Jw_cad、実装済み、§3.13)、SXF(電子納品の P21 と SFC、実装済み、§3.14)、CGM(バイナリとクリアテキスト、実装済み、§3.20)、HP-GL/2 のプロットファイル(HP RTL の画像も、実装済み、§3.22)。いずれも共通の
converter/internal/cadの上に作った。 - TIFF: 多ページのスキャンと FAX、画像の入力に共通の解像度の上限(実装済み、§3.15)。
- HTML / Markdown: リーダー表示。DOCX のレイアウトエンジンを共有し、固定幅の scroll View を作る(実装済み、§3.16)。
- Illustrator・Photoshop: アートボードをページに(実装済み、§3.17・§3.18)。
- 画像: ブラウザが表示できる画像はそのまま格納し、メタデータだけを読む。SVG はページが描く(実装済み、§3.19)。
- プリント基板: Gerber(RS-274X、X2)と Excellon。ZIP にまとめたファイルの組を、基板の表と裏の見た目と層ごとの View に(実装済み、§3.21)。
- 数式: Office Math・MathML・LaTeX を OpenType MATH のフォントで組む共通のエンジン(Word・PowerPoint・Excel・HTML・Markdown・draw.io・EPUB は実装済み、§3.23)。
- EPUB: リフロー型の本は同じリーダー表示で本のページに組み(縦書きを含む)、固定レイアウトの本は画像のページにする(実装済み、§3.24)。
- サーバー側のプレビュー: 純 Go のラスタライザで描くサムネイルとページの画像、検索エンジン向けのテキスト(実装済み、§3.25)。
- Parquet: 列指向のデータファイルを表のシートに。読み込みは仕様から自前で書く(実装済み、§3.26)。
- 楽譜と演奏: MML・MIDI・MusicXML を五線譜に組み、View に SMF と cue を持たせてビューアで演奏する(実装済み、§3.27)。
- フォントファイル: 文字・グリフ・OpenType フィーチャーのプレビュー。GSUB・GPOS は自前で読む(実装済み、§3.28)。
- KiCad: KiCad 6 以降の回路図と基板。階層のシートをページに、基板を表・裏と層ごとの View に、KiCad の線の字体(CC0 の newstroke)で描く。参照するファイルはサーバーでは列挙して渡し、ウェブでは ZIP で(実装済み、§3.29)。
- 区間の配信: ログインした読者に文書を 10 ページずつ、要求ごとの使い捨ての鍵で封印して渡す。記録された通信は後で鍵が漏れても開けない(実装済み、§3.30)。
- 化学構造式(予定): MOL・SDF(SDF は表のシート)、ChemDraw の CDXML・CDX を ACS 1996 の描き方で。
- 音声ファイル: MP3・M4A・AAC・FLAC・Ogg・WAV・AIFF のカバーアートとタグを 1 ページのカードに。サムネイルはカバー、タグは Dublin Core。ファイルをそのまま持ってビューアで再生し、時刻つきの歌詞(
SYLT、LRC)は歌っている行を示す(実装済み、§3.31)。 - InDesign: IDML のページ・マスター・図形・画像と、連結したフレームをページをまたいで流れるストーリー。テキストは DrawingML のテキストエンジンに
TextFlowを足して組む(実装済み、§3.32)。 - 適合テスト: 他の言語の読み手(C の libbdf)のために、入力と期待する扱いを機械可読で配る。期待値は参照実装を走らせて記録する(実装済み、§3.33)。
- 帳票: Excel の印刷のページ(印刷範囲・ページ設定・印刷タイトル・ヘッダーとフッター)と、タグを書いたブックをデータで穴埋めするテンプレート。固定ページ・行が増える表・決まった行数のページ複製の 3 形態を、Excel・Word・PowerPoint のテンプレートで(実装済み、§3.6・§3.34)。BDF から PDF への書き出し(影、タグ付き PDF、PDF/A も含めて実装済み、§3.35)。QR コードとバーコード(実装済み、§3.34)。入力欄のある PDF と pdfme のテンプレートも、テンプレートとして埋められる(実装済み、§3.34)。フォントの対応規則は予定。
9. リポジトリ構成(案)
bdf/
├── docs/ spec.md, design.md
├── *.go Go パッケージ bdf(module github.com/shibukawa/bdf): Object builder、Part エンコード、コンテナ I/O、デコーダ
├── cmd/bdf/ CLI: generate / fill / template / pdf / thumbnail / text / render / ls / manifest / disasm / extract / split / join / encrypt / decrypt / segment / demo
├── form/ 帳票のテンプレートをデータで穴埋めする(§3.34)。form/tag はタグの読み手とデータのスコープ
├── converter/pdfform/ 入力欄のある PDF(AcroForm)をテンプレートとして埋める(§3.34)
├── converter/pdfme/ pdfme のテンプレートを埋める(§3.34。式の評価器と動的レイアウトを持つ)
├── segment/ 区間を要求ごとに封印して返す HTTP ハンドラ(§3.30。wasm に net/http を持ち込まないよう bdf とは別のパッケージ)
├── cmd/bdfwasm/ ブラウザ内変換用の wasm モジュール(§2)
├── image/imgconv/ 画像の格納方針と WebP/AVIF 変換(internal/ は wasm2go で生成した純 Go コーデック)
├── image/svg/ SVG の読み手: 要素の木、スタイルシートの適用、色・長さ・変換の値(§3.25。描くのは raster/imagebdf)
├── image/barcode/ QR コードとバーコードを矩形のパスとして作る(§3.34。帳票のテンプレートの画像タグが使う)
├── font/woff2/ TrueType/OpenType ↔ WOFF2(glyf 変換と Brotli)
├── raster/imagebdf/ ページと単体の Object を画像に描く純 Go のラスタライザと SVG レンダラ(§3.25)
├── vector/pdfbdf/ 文書を PDF に書き出す(§3.35。タグ付き PDF と PDF/A も)
├── raster/ebitenginebdf/ パスだけの Object を Ebitengine で描く(別モジュール。§3.23)。raster/internal/shapes が Object を図形の並びにする
├── image/formula/ LaTeX・MathML の数式をパスだけの Object にする公開パッケージ(§3.23。STIX Two Math を同梱)
├── thumbnail/ 文書のサムネイル(文書の種類によるレイアウト、PNG・JPEG・WebP)
├── internal/ fontdb(フォントの探索・解決・計測・サブセット)、sfnt(TrueType/OpenType の読み書きとグリフの輪郭)、
│ cff(CFF の読み取りとサブセット化)、otlayout(GSUB・GPOS・GDEF の読み取り)。変換器と imagebdf が共有する。
│ textrun(FILL_TEXT の並べ方: 表示順、ベースライン、合成の太字と斜体。imagebdf と pdfbdf が共有する)
│ gauss(影のぼかし。箱形 3 回のガウス近似。imagebdf と pdfbdf が共有する)
│ mathlayout(数式の木・読み手・組版。§3.23)、xmltree(XML の読み手。tinygodriver の xmlro で読む。Office の XML の木と、ほかの形式の XML の開き方)
├── converter/ 入力形式の登録(static plugin)、共通のオプション、形式の判別、ページ指定
│ ├── pdf/ PDF → BDF 変換器(testdata/ にテスト用 PDF)
│ ├── ai/ Illustrator(.ai)→ BDF 変換器(PDF 部分を pdf で描く。testdata/ にテスト用 .ai)
│ ├── psd/ Photoshop(.psd、.psb)→ BDF 変換器(testdata/ にテスト用文書)
│ ├── pptx/ PowerPoint → BDF 変換器(testdata/ にテスト用デッキとフォント)
│ ├── xlsx/ Excel → BDF 変換器(testdata/ にテスト用ブック。フォントは pptx のものを使う)
│ ├── csv/ CSV・TSV → BDF 変換器(描画は xlsx。testdata/ にテスト用ファイル)
│ ├── parquet/ Apache Parquet → BDF 変換器(描画は xlsx。testdata/ にテスト用ファイル)
│ ├── docx/ Word → BDF 変換器(testdata/ にテスト用文書とフォント)
│ ├── html/ HTML(.html、.xhtml、.mhtml)→ BDF 変換器、リーダー表示(testdata/ にテスト用のページ)
│ ├── markdown/ Markdown → BDF 変換器、HTML を経由(testdata/ にテスト用文書と画像)
│ ├── epub/ EPUB → BDF 変換器、リフロー型はリーダー表示、固定レイアウトは画像のページ(testdata/ にテスト用の本)
│ ├── emf/ Windows メタファイル(.emf、.wmf)→ BDF 変換器
│ ├── visio/ Visio(.vsdx)→ BDF 変換器(testdata/ にテスト用図面)
│ ├── idml/ InDesign(.idml)→ BDF 変換器(テキストは pptx と同じ DrawingML のエンジン。testdata/ にテスト用文書)
│ ├── drawio/ draw.io → BDF 変換器(testdata/ にテスト用の図、stencils/ に同梱のステンシル)
│ ├── dxf/ AutoCAD DXF → BDF 変換器(testdata/ にテスト用図面)
│ ├── jww/ Jw_cad(.jww)→ BDF 変換器(testdata/ にテスト用図面)
│ ├── sxf/ SXF(.p21、.p2z、.sfc)→ BDF 変換器(testdata/ にテスト用図面)
│ ├── cgm/ CGM(.cgm、.cgz)→ BDF 変換器(testdata/ にテスト用メタファイル)
│ ├── gerber/ Gerber・Excellon(とその ZIP)→ BDF 変換器(testdata/ にテスト用の基板とファイル)
│ ├── kicad/ KiCad の回路図・基板・プロジェクト(とその ZIP)→ BDF 変換器(CC0 の newstroke の字形を埋め込む。testdata/ にテスト用のプロジェクト)
│ ├── tiff/ TIFF(.tif、.tiff)→ BDF 変換器(testdata/ にテスト用のスキャン・FAX・向きのファイル)
│ ├── image/ 画像(PNG・JPEG・GIF・WebP・AVIF・BMP・ICO・SVG)→ BDF 変換器(そのまま格納してメタデータを読む。testdata/ にテスト用画像)
│ ├── mml/ MML(汎用・マビノギ・PPMCK)→ BDF 変換器、楽譜にする(testdata/ にテスト用の曲)
│ ├── midi/ Standard MIDI File → BDF 変換器、楽譜にする(testdata/ にテスト用の曲)
│ ├── musicxml/ MusicXML(.musicxml、.mxl)→ BDF 変換器(testdata/ にテスト用の楽譜と gen.py)
│ ├── font/ フォントファイル → BDF 変換器(testdata/ にテスト用フォント。test/font/gen.py が作る)
│ ├── all/ すべての形式を登録する
│ └── internal/ Office 系の変換器で共有する ooxml(OPC パッケージと XML の要素木・トークンの読み手)、ziputil(zip の展開。klauspost の inflate)と
│ ooxml/drawingml(DrawingML の図形・テキスト・表・グラフ)、fontset(レイアウト用の
│ フォント選択・計測・サブセット埋め込み。draw.io も使う)、canvas(組み立て中の Object。draw.io も使う)、
│ metafile(EMF/WMF の再生)、
│ 暗号化された Office 文書を開く cfb(複合ファイル)と offcrypto(Agile / Standard 暗号化の復号)、
│ linebreak(行分割の規則)、CAD の変換器で共有する cad(図面をページに描く)、tiff(TIFF の読み取りと CCITT のデコーダ)、wordproc(Word・HTML・Markdown・EPUB の組版エンジン)、xmp(XMP の Dublin Core)、
│ equation(数式の読み手と組版)、music(楽譜の記譜と組版、演奏の SMF と cue。smufl/ に Bravura の字形)、
│ webdoc(HTML と XHTML の読み込み、svg 要素の SVG 文書化、KaTeX・MathJax の数式の書き方の正規化、data: URL。HTML・Markdown・EPUB が使う)
├── fixture/ フィクスチャ生成(埋め込みフォント、計測、サンプル文書)
├── packages/
│ ├── core/ @bdfkit/core デコーダ・コンテナ読み込み・テキスト抽出(依存なし)
│ └── render/ @bdfkit/render Canvas バックエンド、ページ/連続/シート描画(scroll View は連続描画)、Worker とクライアント(SVG の画像はクライアントが描く)
├── examples/
│ ├── viewer/ デモビューア(Worker 描画、テキストレイヤー)
│ ├── common/ ビューアと site/ が共有するもの(変換 Worker のクライアント、ビルドの補助)
│ ├── miniviewer/ 埋め込み用の小さなビューア(examples/ のサンプルが使う)
│ ├── light-server/、preview-server/、search/ 構成のサンプル(変換をブラウザで・サーバーで、検索エンジンと)
│ └── secure-reader/ ログインした読者に PDF を区間ごとに封印して読ませるサンプル(§3.30)
├── site/ デモサイト(トップページ、ビューア、サムネイル・検索テキストのページ、ドキュメント)。GitHub Pages で公開
├── testdata/ Go が生成した demo.bdf / demo-split / demo-encrypted.bdf、区間の文書(segments/、テスト専用の鍵で封印)、PDF・Illustrator・Photoshop・PowerPoint・Excel・Visio・Word・DXF・JWW・SXF・CGM・Gerber・TIFF・Markdown・HTML・画像・EPUB・楽譜の変換結果
└── test/ ブラウザの機能テストとフィクスチャ生成スクリプト
将来の変換器(DOCX など)も converter/ のサブパッケージとして追加し、bdf generate から形式を判別して呼ぶ。