diff --git a/docs/guides/README.md b/docs/guides/README.md index b7f46666f..2b8bde6d4 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -11,7 +11,7 @@ PDF機能の実装に合わせて、仕様の読み方と実装の振る舞い | [ドキュメントカタログ](document-catalog.html) | Root・Catalog・Pagesの関係、必須キー、バージョンの優先関係、表示設定、未解釈エントリの保持、位置付きエラー | [Issue #635](https://github.com/DIO0550/pdfmod/issues/635) / [PR #736](https://github.com/DIO0550/pdfmod/pull/736) の作成時点(`6c654ce`) | | [ドキュメント情報辞書](document-info.html) | Info参照、6つのテキスト項目・2つの日付・Trapped、項目ごとの成功/不在/失敗、未知キー保持、間接参照とNull | [Issue #661](https://github.com/DIO0550/pdfmod/issues/661) / `rust/crates/core/src/document_info.rs` / `rust/crates/core/src/resolver/document_info.rs` | | [ページツリーと属性の継承](page-tree.html) | Kids の文書順走査、Count・Type の検証、循環と共有の区別、型付きページ位置、Parent チェーンによる4属性の継承、無効値の回復、間接座標参照、回転の正規化と既定値 | [Issue #658](https://github.com/DIO0550/pdfmod/issues/658) / [Issue #659](https://github.com/DIO0550/pdfmod/issues/659) / [Issue #660](https://github.com/DIO0550/pdfmod/issues/660) / `rust/crates/core/src/page_tree.rs` / `rust/crates/core/src/resolver/page_attributes.rs` | -| [リソース辞書と属性継承](resources.html) | /Resourcesの継承・間接参照・ProcSet、Tfからのフォント辞書、Doからの画像・フォーム、cs / CSの色空間選択とsc / scnの成分数検証 | [Issue #668](https://github.com/DIO0550/pdfmod/issues/668) / [Issue #666](https://github.com/DIO0550/pdfmod/issues/666) / [Issue #665](https://github.com/DIO0550/pdfmod/issues/665) / [Issue #648](https://github.com/DIO0550/pdfmod/issues/648) / `packages/core/src/document/resources/` / `packages/core/src/content-stream/color-space-resources/` | +| [リソース辞書と属性継承](resources.html) | /Resourcesの継承・間接参照・ProcSet、Tf・Do・gsへの実行スコープの供給、参照失敗からの回復、cs / CSの色空間選択とsc / scnの成分数検証 | [Issue #678](https://github.com/DIO0550/pdfmod/issues/678) / [Issue #668](https://github.com/DIO0550/pdfmod/issues/668) / [Issue #666](https://github.com/DIO0550/pdfmod/issues/666) / [Issue #665](https://github.com/DIO0550/pdfmod/issues/665) / [Issue #648](https://github.com/DIO0550/pdfmod/issues/648) / `packages/core/src/document/resources/` / `packages/core/src/content-stream/resources/` | | [ページ矩形と境界ボックス](page-boxes.html) | 対角の正規化、5種のボックスの既定値、MediaBoxとの交差、不正入力と未解決参照の扱い | [Issue #636](https://github.com/DIO0550/pdfmod/issues/636) 実装版 | | [テキスト文字列](text-strings.html) | PDFDocEncoding・UTF-16BE・UTF-8、BOM判定、未割当バイト、サロゲート、不正入力の拒否/置換、バイト列との型境界 | [Issue #637](https://github.com/DIO0550/pdfmod/issues/637) / `rust/crates/core/src/text_string.rs` | | [日付文字列](date-format.html) | ISO 32000-1 / ISO 32000-2 構文、D: 接頭辞、階層的省略、タイムゾーン形式(' 区切り・Z)、うるう年・カレンダー厳格検証 | [Issue #638](https://github.com/DIO0550/pdfmod/issues/638) / `rust/crates/core/src/date.rs` | diff --git a/docs/guides/ext-gstate.html b/docs/guides/ext-gstate.html index 8c72cf09d..7ab183d95 100644 --- a/docs/guides/ext-gstate.html +++ b/docs/guides/ext-gstate.html @@ -188,10 +188,17 @@

03 パラメータと型

キーPDFの値現在の状態での役割
/LW数値線幅。w と同じ値を更新
/LC整数 0 / 1 / 2線端。順に平端・丸端・突出した四角端
/LJ整数 0 / 1 / 2線接合。順にマイター・丸・面取り
/ML数値マイター限界。M と同じ値を更新
/D[[2 3] 1]描画長2・空白長3を繰り返し、開始位置を1ずらす
/CA /ca0〜1輪郭/塗りの不透明度
/BM名前 または 名前の配列背景と描画色を混ぜる方式
/SMask辞書 または /None位置ごとの透明度を与えるマスク、または解除
/Font[font_ref size]フォント辞書とサイズ

ブレンドモードは、たとえば /Normal(通常)、/Multiply(乗算)、/Screen(スクリーン)です。配列 [/Future /Multiply /Screen] は優先順位を表し、最初に対応できるモードを使います。この実装では Multiply が選ばれます。対応できる名前のない配列は Normal になります。

状態の保持と、実際の描画は別この実装は透明度・ブレンドモード・ソフトマスクを状態として保持します。ピクセルの合成、透明グループの描画、ソフトマスク辞書の内部検証は実装範囲外です。線幅・マイター・破線の値域検証も既存の専用オペレータと同様に今後の範囲です。
-

04 フォント指定の違い

命令で指定する場合

/F1 12 Tf

F1 はリソースの /Font 内を引くための名前です。テキスト状態は名前指定を保持します。

拡張状態で指定する場合

/Font [6 0 R 12]

配列の先頭はフォント辞書への参照です。/F1 ではありません。参照を解決して、辞書とサイズ12を保持します。

-

TypeScriptでは、名前指定と辞書指定を判別可能なユニオンとして表します。type を見れば、次に読める値が name か dictionary かが決まります。

type TextFont =
-  | { readonly type: "name"; readonly name: string }
-  | { readonly type: "dictionary"; readonly dictionary: PdfDictionary };

どちらの指定も、同じ TextState.withFont を通ります。gs の後に Tf が来れば名前指定へ置き換わり、以前の辞書と新しい名前が併存することはありません。文字間隔や行送りなどは保持します。既存の fontName は font から求める読み取り用アクセサで、辞書指定では None を返します。

+

04 フォント指定の違い

+
+

命令で指定する場合

/F1 12 Tf

F1 はリソースの /Font 内を引くための名前です。テキスト状態に名前・サイズと取得できた辞書を保持します。

+

拡張状態で指定する場合

/Font [6 0 R 12]

配列の先頭はフォント辞書への間接参照です。オブジェクト6を解決し、辞書とサイズ12を保持します。

+
+

TypeScriptの TextFont は、type によって2種類の指定を区別します。

+
type TextFont =
+  | { readonly type: "name"; readonly name: string; readonly dictionary?: PdfDictionary }
+  | { readonly type: "dictionary"; readonly dictionary: PdfDictionary };
+

どちらの指定も TextState.withFont を通り、現在のフォント指定を置き換えます。文字間隔や行送りは引き継ぎます。fontName は名前指定のときにその名前を返し、辞書指定では None を返します。

+

05 保存と復元

q は現在のグラフィックス状態を保存し、Q は直前に保存した状態を復元します。拡張状態も同じ状態に入るため、専用の「拡張状態スタック」は必要ありません。

5 w
 q
   /GS1 gs   % LW=3, ca=0.4
@@ -199,23 +206,25 @@
     9 w
   Q
 Q
実行したところ現在の線幅塗りの不透明度
最初の 5 w51
/GS1 gs30.4
9 w90.4
内側の Q30.4
外側の Q51
-

06 警告と回復

以下はpdfmodの回復方針です。仕様上不正な値を推奨するものではありません。

入力結果
<< /LW 3 /Unknown true /ca .4 >>未知キーを警告し、線幅3・不透明度0.4を適用
<< /CA 2 /LW 3 >>範囲外の不透明度だけを警告して無視。以前の不透明度を保持し、線幅3を適用
/Missing gs名前の不在を警告。状態を保持して次の命令へ進む
選択した辞書の参照先が構文エラー原因のエラーをそのまま返す。未解決警告とは区別
未使用の名前の参照先が構文エラー名前ごとにエラーを保持。別の正常な名前の実行は可能

警告には gs 命令のバイト位置が付きます。同じ辞書を再度適用した場合は、その命令位置で再び警告します。後続の専用オペレータで警告が重複することはありません。

-

07 実装の使い方

間接参照の解決は非同期、コンテンツストリームの実行は同期です。最初に現在のスコープの拡張状態を解決し、既定のレジストリを組み立てます。

const prepared = await ExtGStateResources.fromResources(
+

06 警告と回復

以下は実行スコープを渡したときのpdfmodの回復方針です。

+
+ + + + + +
入力結果
<< /LW 3 /Unknown true /ca .4 >>未知キーを警告し、線幅3・不透明度0.4を適用
<< /CA 2 /LW 3 >>範囲外の不透明度を警告。以前の不透明度を保持し、線幅3を適用
/Missing gsEXT_GSTATE_NOT_FOUND。状態を保持して続行
選択した辞書や内部の参照先が構文エラーRESOURCES_RESOLVE_FAILED。原因を報告し、状態を保持して続行
未使用の名前の参照先が構文エラー取得結果に原因を保持。実行中の警告は追加しない
+

警告には gs 命令のバイト位置が付きます。同じ辞書を再度使うと、新しい命令位置で警告します。

+
+

07 実装の使い方

間接参照を非同期で解決し、コンテンツストリームは準備したスコープを使って同期で実行します。

const scope = await ContentStreamResources.fromResources(
   resources, resolveRef,
 );
-if (!prepared.ok) {
-  return prepared;
-}
-
-const registry = OperatorRegistry.createDefault(prepared.value);
-if (!registry.ok) {
-  return registry;
-}
-
 return ContentStreamInterpreter.execute({
   data: contentBytes,
-  registry: registry.value,
-});

Result は成功の値と失敗の理由を分ける型です。ok を確認した後でだけ、成功値 value を使えます。準備時はカテゴリ全体を解決しますが、各名前の参照エラーはその名前を使うまで実行エラーになりません。ページやフォームのスコープが変わるときは、そのスコープ用に準備し直してください。

registerGraphicsStateOperators(registry, prepared.value) で、グラフィックス関係だけを登録する方法もあります。/OP、/RI など今回扱わない辞書キーは未対応警告になります。/Type は描画パラメータではないため読み飛ばします。

+ resources: scope, +});
+

各カテゴリ・名前の取得結果はスコープに保持されます。ページやフォームの辞書が変わるときは、その辞書を使って準備し直します。/OP や /RI など未対応の辞書キーは個別に警告します。/Type は辞書の種別として読み飛ばします。

+
diff --git a/docs/guides/patterns-shadings.html b/docs/guides/patterns-shadings.html index b10559f63..f24a138f5 100644 --- a/docs/guides/patterns-shadings.html +++ b/docs/guides/patterns-shadings.html @@ -238,29 +238,23 @@

非着色パターン

PaintType 2 はセルが形状を

4. 名前から定義に到達する

同じ /P1 という名前でも、ページやフォームが違えば別の定義を指せます。現在のスコープだけを引き、別ページの定義へフォールバックしません。

/Pattern cs → /P1 scnパターン色を選択
現在のResourcesから参照 ↓
/Pattern → /P1種別1のセル、または種別2のShading
/S1 shシェーディングを指定
現在のResourcesから参照 ↓
/Shading → /S1ShadingType 1〜7と共通属性
-
  1. ページのリソースを準備する。

    継承とResources自体の解決は既存のページ処理が担当します。

  2. 非同期で定義を取得する。

    PaintResourcesがカテゴリ・各定義・主要属性の間接参照を解決します。座標と背景色では配列の各要素も解決します。

  3. 同期の命令に渡す。

    scn / SCNが名前を引いて着色方式を検証し、shがShadingの名前を引きます。命令のたびに非同期I/Oは行いません。

  4. 状態と警告を返す。

    パターン名と数値成分は色状態に保存されq/Qの対象です。shは色・色空間・パス・クリップを変更しません。

+
  1. ページのリソースを準備する。

    継承とResources自体の解決はページ処理が担当します。

  2. 非同期で定義を取得する。

    カテゴリ・各定義・主要属性の間接参照を解決し、実行スコープを作ります。座標と背景色では配列の各要素も解決します。

  3. 同期の命令に渡す。

    scn / SCNが名前を引いて着色方式を検証し、shがShadingの名前を引きます。

  4. 状態と警告を返す。

    パターン名と数値成分は色状態に保存されq/Qの対象です。shはシェーディングを取得し、描画状態を保持します。

5. 不正入力・未知の種別・参照エラー

- +
入力今回の結果
Pattern以外で名前の色オペランド数値として受理せず型・個数エラー
基底RGBで数値2個+パターン名必要な数値3個に足りず個数エラー
存在しないパターン / シェーディングPATTERN_NOT_FOUND / SHADING_NOT_FOUNDの警告
未知・欠落の種別番号元オブジェクトをUnknownとして保持し、使用位置でUNSUPPORTEDの警告
セル間隔0・座標の個数不足・メッシュがストリーム以外INVALIDの警告。既定値で正しい定義に見せない
カテゴリの参照解決エラー準備時に元のエラーを返す
個別定義や主要属性の参照解決エラーその名前を使用する命令までエラーを保持
Pattern以外で名前の色オペランド型・個数エラー
基底RGBで数値2個+パターン名必要な数値3個に足りず個数エラー
存在しないパターン / シェーディングPATTERN_NOT_FOUND / SHADING_NOT_FOUNDの警告
未知・欠落の種別番号元オブジェクトをUnknownとして保持し、使用位置でUNSUPPORTEDの警告
セル間隔0・座標の個数不足・メッシュがストリーム以外INVALIDの警告
カテゴリ・個別定義・主要属性の参照解決エラー実行スコープに原因を保持。名前を使用した命令位置に RESOURCES_RESOLVE_FAILED を報告して続行

警告による回復はpdfmodの方針です。未知の定義を描画できると判断した意味ではありません。関数評価前のため、すべてのシェーディング固有の必須キーまで検証する段階でもありません。

名前が見つからないパターンでも指定名と数値成分は色状態へ保持し、後続命令を処理します。shの定義が不正でも、警告のあとに来る命令を処理できます。

6. 実装の入口と対応範囲

-

呼び出し側は同じResourcesから色空間と描画定義を準備し、結果を確認してからレジストリへ渡します。次は成功経路に絞ったTypeScriptの抜粋です。

-
const spaces = await ColorSpaceResources.fromResources(resources, resolveRef);
-if (!spaces.ok) {
-  return spaces;
-}
-
-const paints = await PaintResources.fromResources(resources, resolveRef);
-if (!paints.ok) {
-  return paints;
-}
-
-const registry = OperatorRegistry.createDefault(undefined, spaces.value, paints.value);
-

paints.value.patterns と shadings の名前別結果から、型付き定義を参照できます。パターンの結果は1・2・Unknown、シェーディングの結果は1〜7・Unknownで判別します。成功・エラーと値の有無はそれぞれ別に確認してください。

+

現在のリソース辞書から色空間と描画定義を準備し、実行時に渡します。

+
const scope = await ContentStreamResources.fromResources(resources, resolveRef);
+const result = ContentStreamInterpreter.execute({
+  data: contentBytes,
+  resources: scope,
+});
+

scope.getPattern("P1") と scope.getShading("S1") から型付き定義を参照できます。ok で解決の成否、some で定義の有無を確認します。パターンは1・2・Unknown、シェーディングは1〜7・Unknownで判別します。

対応した範囲

間接参照の解決、種別と主要属性の保持、オペランド検証、警告、shの登録です。メッシュのバイト列は未復号で保持します。

後段の処理

Functionの評価、実際の塗り、セルの再帰実行、メッシュ復号、色変換、シェーディング固有のすべての属性の検証です。Rust側には対応する命令実行層がまだないため、今回の変更はTypeScript側です。

diff --git a/docs/guides/resources.html b/docs/guides/resources.html index 8c8bb05db..da76e843f 100644 --- a/docs/guides/resources.html +++ b/docs/guides/resources.html @@ -77,6 +77,8 @@ .key { color: #89e0ce; } .value { color: #ffd688; } .operator { color: #d6b5ff; } + .syntax { color: #9ecaff; } + .string { color: #ffc2d2; } .comment { color: #a3bdc5; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .grid > * { min-width: 0; } @@ -148,6 +150,8 @@ .key { color: var(--teal); } .value { color: var(--amber); } .operator { color: #663399; } + .syntax { color: #215589; } + .string { color: #96314f; } .comment { color: var(--muted); } .table-wrap { overflow: visible; } [data-example-panel][hidden] { display: block !important; } @@ -341,83 +345,62 @@

未定義・ProcSet・壊れた参照

06

Tfからフォント辞書へ

/F1 はフォント製品の名前ではなく、現在のリソース辞書で使うローカル名です。別のページの /F1 が同じフォントとは限りません。次の抜粋では 12 Tf の直前の名前を手掛かりに、辞書へ2段階で到達します。

% PDF断片。完全なPDFではありません。
-/Resources << /Font 6 0 R >>
+/Resources << /Font 6 0 R >>
 
-6 0 obj
-<< /F1 7 0 R >>
-endobj
+6 0 obj
+<< /F1 7 0 R >>
+endobj
 
-7 0 obj
-<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
-endobj
+7 0 obj
+<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
+endobj
 
 % コンテンツストリームの本文
-BT /F1 12 Tf (Hello) Tj ET
+BT /F1 12 Tf (Hello) Tj ET
    -
  1. Tf が名前 F1 とサイズ 12 をテキスト状態に保存します。名前の先頭の / は構文上の記号なので、APIへ渡す文字列には含めません。
  2. +
  3. Tf の入力は名前 F1 とサイズ 12 です。APIへ渡す名前は、構文上の接頭辞 / を外した文字列です。
  4. ページの /Resources にある /Font の値を読みます。この例では 6 0 R を解決し、名前の対応表を取得します。
  5. -
  6. 対応表の /F1 を読み、7 0 R を解決します。実体が辞書なら取得成功です。直接埋め込まれた辞書でも同じ結果になります。
  7. +
  8. 対応表の /F1 を読み、7 0 R を解決します。実行時には Tf が名前・サイズと、取得できた辞書をテキスト状態に保存します。直接埋め込まれた辞書でも同じ結果になります。

不在と失敗を分ける

Result は処理の成功・失敗、Option は成功時に値があるかを表します。この二つを重ねることで、「名前がない」と「参照先を解析できない」を区別できます。次の扱いはpdfmodの回復方針であり、壊れたフォントをPDF仕様上有効とみなす意味ではありません。

- + - - - - + + + +
入力取得結果テキスト状態
入力名前を引いた結果Tf 実行時
名前に対応する辞書があるOk(Some(辞書))名前・サイズを保持
名前または /Font がないOk(None):未解決名前・サイズ・文字間隔等を保持
/Font または名前の参照先が数値・Null・ストリームOk(None):辞書として扱わない状態を変更しない
参照解決関数が構文エラー等を返すErr(原因):呼び出し側へ伝播状態を変更しない
名前に対応する辞書があるOk(Some(辞書))名前・サイズ・辞書を保持
名前または /Font がないOk(None)FONT_NOT_FOUND。名前・サイズを保持
/Font または名前の参照先が数値・Null・ストリームOk(None)FONT_NOT_FOUND。名前・サイズを保持
参照解決関数が構文エラー等を返すErr(原因)RESOURCES_RESOLVE_FAILED。名前・サイズを保持して続行
-

保存した名前をAPIへ渡す

-

次はTypeScriptの関数本体の抜粋です。textState は Tf 実行後の状態、resources と resolveRef は同じ文書・ページのものを渡します。

-
if (!textState.fontName.some) {
-  return ok(none);
-}
-
-const result = await PdfResources.getFont(
-  resources,
-  textState.fontName.value,
-  resolveRef,
-);
-
-if (!result.ok) {
-  return result; // 参照先の解析失敗を呼び出し側へ返す
-}
-
-if (!result.value.some) {
-  return ok(none); // 未解決。フォントを勝手に置き換えない
-}
-
-const dictionary = result.value.value;
-return ok(some(dictionary));
-
辞書の取得とフォントの解釈は別の段階です。このAPIは辞書の内容を保持して返します。/Subtype の妥当性、文字コードの変換、グリフ幅、埋め込みフォントの読み込みはここでは扱いません。また、解決結果をテキスト状態へ重複保存しないため、フォント切替後に古い辞書が残ることもありません。
+

どの結果でも文字間隔や行送りは保持します。次の Tf は新しい名前・サイズと、その名前で取得した辞書でフォント指定全体を置き換えます。

+
取得した辞書はテキスト状態から読めるtextState.font が Some で、type が "name" のとき、name は命令が指定した名前、dictionary は取得できた場合に入る辞書です。辞書の /Subtype、文字コード、グリフ幅、埋め込みフォントの処理は後段が担います。
07

Doから画像・フォームへ

Do は、名前で指定した外部オブジェクト(XObject)を描画する命令です。名前は作成者が自由に付けられます。たとえば /Logo が写真か、文字や線で組み立てた図かは、参照先の /Subtype で決まります。

% PDFの抜粋。ページのリソース辞書
-/Resources << /XObject << /Logo 8 0 R >> >>
+/Resources << /XObject << /Logo 8 0 R >> >>
 % ページの描画命令
-/Logo Do
+/Logo Do
 
 % 8番のオブジェクト。3バイトでRGBの1画素を表す
-8 0 obj
-<< /Type /XObject /Subtype /Image
-   /Width 1 /Height 1 /ColorSpace /DeviceRGB
-   /BitsPerComponent 8 /Length 3 >>
-stream
+8 0 obj
+<< /Type /XObject /Subtype /Image
+   /Width 1 /Height 1 /ColorSpace /DeviceRGB
+   /BitsPerComponent 8 /Length 3 >>
+stream
 RGB
-endstream
-endobj
+endstream +endobj

8 0 R はオブジェクト番号8、世代番号0への参照です。この例で RGB は3個の文字コードからなる実データです。順に赤・緑・青の強度(82、71、66)として読めます。/Filter が付いた画像では、画素を得る前に指定のフィルタを適用します。取得APIではフィルタと元のバイト列の両方を保持します。

ストリームの種別辞書から読み取れること
Image幅・高さ、色空間、成分のビット数、フィルタ。/ImageMask true は描画色を置く場所を指定するステンシルです。/SMask は濃淡を持つマスク、/Mask はマスク画像または透明にする色の範囲を指定します。
Form本文は繰り返し実行できる描画命令です。/BBox [0 0 40 20] はフォーム座標で描画を切り取る境界、/Matrix [1 0 0 1 10 5] は原点を (10, 5) へ移す変換です。行列を省略すると座標はそのままです。内部で使う名前はフォーム自身の /Resources に定義できます。
PS・未知非推奨のPostScriptは独立した種別です。未知の名前、欠落、型の違う値でも元ストリームを取得できます。
-
pdfmodで不完全なPDFを読むとき名前の参照先がストリームでなければ未解決です。Formの境界に数値が4個揃わない場合は境界だけが未設定になり、ほかの属性は読めます。参照先の構文を解析できないときは、そのエラーを返します。
+
pdfmodで不完全なPDFを読むとき名前の参照先がストリームでなければ未解決です。Formの境界に数値が4個揃わない場合は境界だけが未設定になり、ほかの属性は読めます。Do は取得できなかった名前や参照の解析エラーを警告して先へ進みます。画像の復号とFormの入れ子実行は後段が担います。
@@ -446,16 +429,35 @@

保存した名前をAPIへ渡す

Pattern0、または基底の成分数繰り返し模様。scn / SCN の最後にパターン名を指定。

間接参照とPattern

-

Pattern / Shadingの種別と主要属性の取得、scn / SCN / shによる名前解決については、パターンとシェーディングの解説を参照してください。実行前に PaintResources.fromResources(resources, resolveRef) を呼び、createDefault の第3引数へ渡します。

+

Pattern / Shadingの種別と主要属性、scn / SCN / shの名前解決は パターンとシェーディングの解説で扱います。

カテゴリ辞書・各定義・ICCBasedのプロファイルや /N・DeviceNの名前配列・IndexedやPatternの基底は間接参照でも解決します。着色パターンは /Pattern cs /P1 scn、非着色パターンは基底色の成分も必要です。たとえば [/Pattern /DeviceRGB] を選んだ後は 0.1 0.2 0.3 /P1 scn と書きます。名前は数値成分数に含めません。

不一致・未知の定義を読むとき

-

RGBを選んだ後の 0.1 0.2 sc は成分が1個不足するためエラーです。余分な成分もエラーになります。色空間配列の先頭に未知の族がある場合は、選択位置に COLOR_SPACE_UNSUPPORTED の警告を出し、成分数不明として保持します。その後の数値は型を確認して保存しますが、個数は検証しません。名前が見つからない場合は、直接の指定でも定義内の別名・基底からの参照でも COLOR_SPACE_NOT_FOUND を報告し、以前の成分数で検証を続けません。基底が不明なIndexedでも、添字の成分数は1です。参照先の構文解析エラーは警告へ置き換えず返します。

-
実行前の準備と実装の範囲ColorSpaceResources.fromResources(resources, resolveRef) で現在のスコープを準備し、OperatorRegistry.createDefault(extGStates, colorSpaces) の第2引数へ渡します。各定義は事前に解決し、成功・失敗を名前ごとに保持します。カテゴリのエラーは準備時に返し、個々の定義のエラーはその名前の選択時まで返しません。族と成分数を読み色値を保持するところまでを扱い、ICC変換・パレット展開・特色の関数評価・パターン描画は後段の処理です。
+

RGBを選んだ後の 0.1 0.2 sc は成分が1個不足するためエラーです。余分な成分もエラーになります。未知の族は COLOR_SPACE_UNSUPPORTED、名前の不在は COLOR_SPACE_NOT_FOUND、参照先の解析エラーは RESOURCES_RESOLVE_FAILED として、選択した命令位置に警告します。成分数不明の色空間では、後続の数値の型を検証して保存します。基底が不明なIndexedでも、添字の成分数は1です。

+
色値を保持するまで色空間の族・成分数を読み、指定した色値を保持します。ICC変換・パレット展開・特色の関数評価・パターン描画は後段の処理です。
09

実装と対応範囲

-

TypeScript API(@pdfmod/core)におけるリソースの利用例です。以下の抜粋では、page は読み込み済みのページ、resolveRef はその文書の間接参照解決関数とします。Result の ok で成功を、Option の some で値の存在を確認します。

+

ページのスコープをインタプリタへ渡す

+

PDFでは現在のページやフォームのリソース辞書が名前の意味を決めます。pdfmodは間接参照を事前に解決し、同期で実行される各命令へ同じスコープを渡します。たとえば q と Q は描画状態を保存・復元し、スコープはその間も共有されます。

+
import { ContentStreamInterpreter, ContentStreamResources } from "@pdfmod/core";
+
+const resources = await ContentStreamResources.fromResources(
+  page.resources,
+  (ref) => document.resolver.get(ref),
+);
+const result = ContentStreamInterpreter.execute({ data: contentBytes, resources });
+
+if (result.ok) {
+  for (const warning of result.value.warnings) {
+    console.log(warning.offset, warning.message);
+  }
+}
+

document は読み込み済みの PdfDocument、page は同じ文書のページ、contentBytes は復号済みの命令列です。カスタムハンドラは context.resources?.getXObject("Logo") など、カテゴリごとの取得メソッドを使えます。結果の ok で参照解決の成否、some で値の有無を確認します。

+

明示したスコープは initialContext のスコープより優先されます。省略時は初期コンテキストのスコープを引き継ぎます。新しいページに切り替えるときは、そのページの辞書を準備して渡します。

+
参照の失敗は命令を使用した位置で報告する準備時に壊れた参照を見つけても、そのカテゴリ・名前の取得結果として保持します。たとえば壊れた /Logo を Do が使うと命令のバイト位置に警告が付き、その後の 7 w も実行されます。名前が未使用なら警告は追加されません。命令のオペランドが型違いの場合は引き続き実行エラーです。
+

辞書だけを取得する

+

単独の名前を引く場合は PdfResources の遅延解決APIを使えます。次の resolveRef は同じ文書の参照解決関数です。

import { PdfResources } from "@pdfmod/core";
 
 // ページからリソース辞書を取得
@@ -492,16 +494,16 @@ 

不一致・未知の定義を読むとき

今回対応する範囲

-

ページツリーからの全置換属性継承、空辞書フォールバック、標準・カスタムカテゴリの保持、2段階オンデマンド解決、ストリーム透過返却、フォント辞書への型を絞った到達、非推奨 /ProcSet の除外。色空間の事前解決と、cs / CS・sc / SC・scn / SCNの成分数検証。

+

ページから継承した辞書の取得、カテゴリと名前の解決、命令へのスコープの供給。Tf はフォント辞書を状態に保存し、Do は画像・フォーム等のストリームを取得し、gs は拡張状態を適用します。

後続処理・未対応の範囲

-

フォント辞書内部のエンコーディング・CMAP 解析、画像ストリームのデコードとピクセル展開、フォントを用いた文字描画。Tf の名前・サイズの保存は既存のインタプリタが行います。

+

フォント辞書内部のエンコーディング・CMAP 解析、画像ストリームのデコード、フォーム内の命令の入れ子実行、画面への描画。

覚えておくこと - ① /Resources はキー単位でマージされず、直近の祖先辞書で完全に上書きされます。② 間接参照はオンデマンド解決されるため、不要な解決オーバーヘッドや破損参照の巻き添えを防ぎます。③ PdfResources.lookup は辞書だけでなく画像などのストリームも透過的に返却します。 + ページがリソース辞書を継承する場合は、直近の祖先辞書が単位です。単独の名前を引くAPIは必要な参照だけを解決し、命令列を実行するAPIはスコープを事前に準備してから同期で名前を引きます。
diff --git a/packages/core/src/content-stream/ext-gstate/index.ts b/packages/core/src/content-stream/ext-gstate/index.ts index c29b0844e..dc2e5bf9e 100644 --- a/packages/core/src/content-stream/ext-gstate/index.ts +++ b/packages/core/src/content-stream/ext-gstate/index.ts @@ -93,30 +93,19 @@ export const ExtGStateResources = { resources: PdfResources, resolveRef: ResolveRef, ): Promise> { - const category = await PdfResources.getCategory( + const states = await PdfResources.readCategory( resources, - "ExtGState", + { + name: "ExtGState", + read: (object, resolve) => + ExtGStateResources.parse( + ok(object.type === "dictionary" ? some(object) : none), + resolve, + ), + }, resolveRef, ); - if (!category.ok) { - return category; - } - - if (!category.value.some) { - return ok(ExtGStateResources.empty()); - } - - const states = new Map, PdfError>>(); - for (const name of category.value.value.entries.keys()) { - const result = await PdfResources.getExtGState( - resources, - name, - resolveRef, - ); - states.set(name, await ExtGStateResources.parse(result, resolveRef)); - } - - return ok({ states }); + return states.ok ? ok({ states: states.value }) : states; }, /** diff --git a/packages/core/src/content-stream/graphics-state/text-font/index.ts b/packages/core/src/content-stream/graphics-state/text-font/index.ts index f6057afc2..775c29ca1 100644 --- a/packages/core/src/content-stream/graphics-state/text-font/index.ts +++ b/packages/core/src/content-stream/graphics-state/text-font/index.ts @@ -1,9 +1,13 @@ import type { PdfDictionary } from "../../../pdf/types/index"; import { none, type Option, some } from "../../../utils/option/index"; -/** Tf のローカル名、または gs /Font の解決済み辞書。 */ +/** Tf のローカル名と取得できた辞書、または gs /Font の辞書指定。 */ export type TextFont = - | { readonly type: "name"; readonly name: string } + | { + readonly type: "name"; + readonly name: string; + readonly dictionary?: PdfDictionary; + } | { readonly type: "dictionary"; readonly dictionary: PdfDictionary }; export const TextFont = { diff --git a/packages/core/src/content-stream/interpreter/index.ts b/packages/core/src/content-stream/interpreter/index.ts index 4832d41f9..695b2d4a6 100644 --- a/packages/core/src/content-stream/interpreter/index.ts +++ b/packages/core/src/content-stream/interpreter/index.ts @@ -18,6 +18,7 @@ import { type OperatorHandlerContext, OperatorRegistry, } from "../operator-registry/index"; +import type { ContentStreamResources } from "../resources/index"; import { ContentStreamTokenizer } from "../tokenizer/index"; import { readArrayOperand, readDictOperand } from "./composite-operand/index"; @@ -31,6 +32,8 @@ export type ContentStreamInterpreterExecuteOptions = { readonly registry?: OperatorRegistry; /** 呼び出し側が指定する任意の初期 context(省略時は新規生成される) */ readonly initialContext?: OperatorHandlerContext; + /** 実行対象のリソース。指定時は initialContext のスコープより優先される。 */ + readonly resources?: ContentStreamResources; }; /** @@ -63,7 +66,7 @@ export const ContentStreamInterpreter = { /** * Content stream の token 列をRPNとしてEOFまで逐次実行する。 * - * @param options - 入力データ、任意のoperator registry(省略時はデフォルト)、任意の初期context + * @param options - 入力データ、任意の registry、初期 context、解決済みリソース * @returns 最終context、またはtokenize / 変換 / handlerのエラー */ execute( @@ -78,8 +81,12 @@ export const ContentStreamInterpreter = { } const registry = registryResult.value; + const resources = options.resources ?? options.initialContext?.resources; const tokenizer = new ContentStreamTokenizer(options.data); - let context = createInitialContext(options.initialContext); + let context = withResources( + createInitialContext(options.initialContext), + resources, + ); const warnings: PdfWarning[] = []; while (true) { @@ -109,11 +116,19 @@ export const ContentStreamInterpreter = { warnings.push(warning); } - context = step.value.context; + context = withResources(step.value.context, resources); } }, } as const; +/** ハンドラが従来のフィールドだけを返しても、実行開始時のスコープを保つ。 */ +function withResources( + context: OperatorHandlerContext, + resources: ContentStreamResources | undefined, +): OperatorHandlerContext { + return context.resources === resources ? context : { ...context, resources }; +} + /** * 1 token を分類して以下のいずれかを実行する: * EOF(完了)/ operator dispatch / inline image dispatch / array reader dispatch / diff --git a/packages/core/src/content-stream/operator-registry/index.ts b/packages/core/src/content-stream/operator-registry/index.ts index 2edebd9d5..834e8cbce 100644 --- a/packages/core/src/content-stream/operator-registry/index.ts +++ b/packages/core/src/content-stream/operator-registry/index.ts @@ -70,6 +70,7 @@ import { twHandler } from "../operators/text/tw/index"; import { tzHandler } from "../operators/text/tz/index"; import { doHandler } from "../operators/xobject/do/index"; import { PaintResources } from "../paint-resources/index"; +import type { ContentStreamResources } from "../resources/index"; /** * Content stream operator handler が受け取り、更新後に返す実行コンテキスト。 @@ -81,6 +82,8 @@ export type OperatorHandlerContext = { readonly graphicsStateStack: GraphicsStateStack; /** BMC/BDC/EMC 由来の marked content stack (ISO 32000-2:2020 §14.6) */ readonly markedContentStack: MarkedContentStack; + /** 現在のページ・フォームのリソース。実行中の命令は同じスコープを参照する。 */ + readonly resources?: ContentStreamResources; }; /** diff --git a/packages/core/src/content-stream/operators/color/selection/index.ts b/packages/core/src/content-stream/operators/color/selection/index.ts index 7f3275c7b..f4da73ff9 100644 --- a/packages/core/src/content-stream/operators/color/selection/index.ts +++ b/packages/core/src/content-stream/operators/color/selection/index.ts @@ -18,6 +18,7 @@ import type { OperatorHandlerContext, } from "../../../operator-registry/index"; import { PaintResources } from "../../../paint-resources/index"; +import { ContentStreamResources } from "../../../resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; type ColorTarget = "fill" | "stroke"; @@ -47,8 +48,8 @@ export const ColorSpaceOperators = { /** * 色空間を選択し、その色空間の初期色へ切り替える。 * @param target - 塗りまたは輪郭 - * @param resources - 現在のリソーススコープ - * @returns 名前不在は警告、参照解決の失敗は Err にするハンドラ + * @param resources - コンテキストにスコープがない場合の従来の色空間 + * @returns スコープから取得できなければ、成分数不明の色空間を使うハンドラ */ select(target: ColorTarget, resources: ColorSpaceResources): OperatorHandler { return (context) => { @@ -64,25 +65,35 @@ export const ColorSpaceOperators = { } const builtin = ColorSpace.fromName(name.value); + const scoped = builtin.some + ? undefined + : context.resources?.getColorSpace(name.value); + const missing = (): ColorSpaceDefinition => + ColorSpaceDefinition.unresolved(name.value, "COLOR_SPACE_NOT_FOUND"); + const prepared = + scoped && + map(scoped, (value) => (value.some ? value.value : missing())); const resolved = builtin.some ? ok({ colorSpace: builtin.value, warnings: [] }) - : (resources.spaces.get(name.value) ?? - ok( - ColorSpaceDefinition.unresolved( - name.value, - "COLOR_SPACE_NOT_FOUND", - ), - )); - if (!resolved.ok) { + : (prepared ?? resources.spaces.get(name.value) ?? ok(missing())); + if (!resolved.ok && scoped === undefined) { return resolved; } - const definition = resolved.value; + const definition = resolved.ok ? resolved.value : missing(); const next = ColorSpaceOperators.update(context, target, { color: Color.initial(definition.colorSpace), space: definition.colorSpace, }); - return ok({ ...next, warnings: definition.warnings }); + const warnings = resolved.ok + ? definition.warnings + : [ + ContentStreamResources.warning( + { category: "ColorSpace", name: name.value }, + resolved.error, + ), + ]; + return ok({ ...next, warnings }); }; }, @@ -90,7 +101,7 @@ export const ColorSpaceOperators = { * 現在の色空間の成分数を検証して色値を更新する。 * @param operatorName - 色指定命令 * @param target - 塗りまたは輪郭 - * @param resources - 解決済みのパターンリソース + * @param resources - コンテキストにスコープがない場合の従来のパターンリソース * @returns 個数・型の不一致を Err にするハンドラ */ setColor( @@ -137,39 +148,56 @@ export const ColorSpaceOperators = { return values; } - const pattern = patternName.value.some - ? (resources.patterns.get(patternName.value.value) ?? ok(none)) + const name = patternName.value; + const scoped = name.some + ? context.resources?.getPattern(name.value) + : undefined; + const pattern = name.some + ? (scoped ?? resources.patterns.get(name.value) ?? ok(none)) : ok(none); - if (!pattern.ok) { + if (!pattern.ok && scoped === undefined) { return pattern; } - const patternError = pattern.value.some + const definition = pattern.ok ? pattern.value : none; + const patternError = definition.some ? ColorOperation.patternError( { colorSpace: space, operatorName }, - pattern.value.value, + definition.value, ) : none; if (patternError.some) { return err(patternError.value); } - const warnings = pattern.value.some ? pattern.value.value.warnings : []; - const missingPattern = patternName.value.some && !pattern.value.some; const next = ColorSpaceOperators.update(context, target, { - color: Color.fromComponents(space, values.value, patternName.value), + color: Color.fromComponents(space, values.value, name), space, }); + if (!pattern.ok && name.some) { + return ok({ + ...next, + warnings: [ + ContentStreamResources.warning( + { category: "Pattern", name: name.value }, + pattern.error, + ), + ], + }); + } + + const warnings = definition.some ? definition.value.warnings : []; return ok({ ...next, - warnings: missingPattern - ? [ - { - code: "PATTERN_NOT_FOUND", - message: `Pattern resource not found: /${patternName.value.value}`, - }, - ] - : warnings, + warnings: + name.some && !definition.some + ? [ + { + code: "PATTERN_NOT_FOUND", + message: `Pattern resource not found: /${name.value}`, + }, + ] + : warnings, }); }; }, diff --git a/packages/core/src/content-stream/operators/graphics-state/gs/index.ts b/packages/core/src/content-stream/operators/graphics-state/gs/index.ts index 12d405ad5..6ffbd5620 100644 --- a/packages/core/src/content-stream/operators/graphics-state/gs/index.ts +++ b/packages/core/src/content-stream/operators/graphics-state/gs/index.ts @@ -3,14 +3,15 @@ import { ok } from "../../../../utils/result/index"; import { ExtGState, ExtGStateResources } from "../../../ext-gstate/index"; import { GraphicsStateStack } from "../../../graphics-state/index"; import type { OperatorHandler } from "../../../operator-registry/index"; +import { ContentStreamResources } from "../../../resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; -/** gs ハンドラを、解決済みのリソーススコープに束縛する。 */ +/** gs ハンドラを作り、実行コンテキストから拡張状態を引く。 */ export const GsOperator = { /** * 名前オペランドから拡張状態を適用するハンドラを生成する。 - * @param resources - 実行対象スコープの解決済み拡張状態 - * @returns 不在は警告して状態を保持し、参照解決エラーは Err にするハンドラ + * @param resources - コンテキストにスコープがない場合の従来の拡張状態 + * @returns スコープから取得できなければ警告して状態を保持するハンドラ */ create( resources: ExtGStateResources = ExtGStateResources.empty(), @@ -21,11 +22,24 @@ export const GsOperator = { return name; } - const resolved = resources.states.get(name.value) ?? ok(none); - if (!resolved.ok) { + const scoped = context.resources?.getExtGState(name.value); + const resolved = scoped ?? resources.states.get(name.value) ?? ok(none); + if (!resolved.ok && scoped === undefined) { return resolved; } + if (!resolved.ok) { + return ok({ + ...context, + warnings: [ + ContentStreamResources.warning( + { category: "ExtGState", name: name.value }, + resolved.error, + ), + ], + }); + } + const extended = resolved.value; if (!extended.some) { return ok({ diff --git a/packages/core/src/content-stream/operators/shading/sh/index.ts b/packages/core/src/content-stream/operators/shading/sh/index.ts index a7491f82f..ce81665b5 100644 --- a/packages/core/src/content-stream/operators/shading/sh/index.ts +++ b/packages/core/src/content-stream/operators/shading/sh/index.ts @@ -3,14 +3,15 @@ import { err, ok } from "../../../../utils/result/index"; import { OperandStack } from "../../../operand-stack/index"; import type { OperatorHandler } from "../../../operator-registry/index"; import { PaintResources } from "../../../paint-resources/index"; +import { ContentStreamResources } from "../../../resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; /** sh は名前から定義を取得する。描画と関数評価は後段が担う。 */ export const ShOperator = { /** * 現在のスコープのシェーディングを使用するハンドラを作る。 - * @param resources - 解決済みの描画リソース - * @returns 個数・型の不一致と参照エラーは Err、不在・未知の定義は警告 + * @param resources - コンテキストにスコープがない場合の従来の描画リソース + * @returns 個数・型の不一致は Err、スコープから取得できない場合は警告 */ create(resources: PaintResources = PaintResources.empty()): OperatorHandler { return (context) => { @@ -30,11 +31,24 @@ export const ShOperator = { return name; } - const shading = resources.shadings.get(name.value) ?? ok(none); - if (!shading.ok) { + const scoped = context.resources?.getShading(name.value); + const shading = scoped ?? resources.shadings.get(name.value) ?? ok(none); + if (!shading.ok && scoped === undefined) { return shading; } + if (!shading.ok) { + return ok({ + ...context, + warnings: [ + ContentStreamResources.warning( + { category: "Shading", name: name.value }, + shading.error, + ), + ], + }); + } + return ok({ ...context, warnings: shading.value.some diff --git a/packages/core/src/content-stream/operators/text/tf/index.ts b/packages/core/src/content-stream/operators/text/tf/index.ts index 7e4b93ee6..ad151ebc7 100644 --- a/packages/core/src/content-stream/operators/text/tf/index.ts +++ b/packages/core/src/content-stream/operators/text/tf/index.ts @@ -1,3 +1,5 @@ +import type { PdfWarning } from "../../../../pdf/errors/index"; +import { none } from "../../../../utils/option/index"; import { err, ok } from "../../../../utils/result/index"; import { GraphicsState, @@ -8,6 +10,7 @@ import type { OperatorHandler, OperatorHandlerContext, } from "../../../operator-registry/index"; +import { ContentStreamResources } from "../../../resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; const OPERATOR_NAME = "Tf"; @@ -17,7 +20,7 @@ const OPERAND_COUNT = 2; * PDF §9.3.1 `Tf` operator (font and font size) のハンドラ。 * * operand (PDF 順): `font` (name) `size` (number)。スタック頂上は size のため - * size → font の順に 2 回 pop し、`textState.fontName` / `textState.fontSize` を更新する。 + * size → font の順に pop し、名前とサイズ、取得できたフォント辞書を保持する。 * * - operand 不足: `OPERATOR_OPERAND_MISSING`(actual = pop 成功数 / required = 2) * - 型不一致: `OPERATOR_OPERAND_TYPE_MISMATCH`(size は "number"、font は "name") @@ -50,9 +53,15 @@ export const tfHandler: OperatorHandler = (context: OperatorHandlerContext) => { } const fontName = fontResult.value; + const resolved = context.resources?.getFont(fontName); + const dictionary = resolved?.ok ? resolved.value : none; const current = GraphicsStateStack.current(context.graphicsStateStack); const nextTextState = TextState.withFont(current.textState, { - font: { type: "name", name: fontName }, + font: { + type: "name", + name: fontName, + ...(dictionary.some ? { dictionary: dictionary.value } : {}), + }, size: sizeValue, }); const next = GraphicsState.update(current, { textState: nextTextState }); @@ -61,9 +70,19 @@ export const tfHandler: OperatorHandler = (context: OperatorHandlerContext) => { next, ); - return ok({ - operandStack: context.operandStack, - graphicsStateStack, - markedContentStack: context.markedContentStack, - }); + const updated = { ...context, graphicsStateStack }; + if (resolved === undefined || dictionary.some) { + return ok(updated); + } + + const warning: PdfWarning = resolved.ok + ? { + code: "FONT_NOT_FOUND", + message: `Font resource not found: /${fontName}`, + } + : ContentStreamResources.warning( + { category: "Font", name: fontName }, + resolved.error, + ); + return ok({ ...updated, warnings: [warning] }); }; diff --git a/packages/core/src/content-stream/operators/xobject/do/index.ts b/packages/core/src/content-stream/operators/xobject/do/index.ts index fbe91356b..04a901a30 100644 --- a/packages/core/src/content-stream/operators/xobject/do/index.ts +++ b/packages/core/src/content-stream/operators/xobject/do/index.ts @@ -1,8 +1,10 @@ +import type { PdfWarning } from "../../../../pdf/errors/index"; import { err, ok } from "../../../../utils/result/index"; import type { OperatorHandler, OperatorHandlerContext, } from "../../../operator-registry/index"; +import { ContentStreamResources } from "../../../resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; const OPERATOR_NAME = "Do"; @@ -10,23 +12,8 @@ const OPERATOR_NAME = "Do"; /** * PDF §8.8 `Do` operator (invoke named XObject) のハンドラ。 * - * operand stack から name 1 個を pop し、name 型であれば受理して - * operand を消費する骨格実装。XObject 解決と実体描画 - * (画像 decode / form XObject 再帰実行) は本フェーズでは行わず、 - * 後続フェーズで本 handler に追加する。 - * - * 検査順序(厳守): - * (1) operand pop(none なら `OPERATOR_OPERAND_MISSING`) - * (2) 型検査(PdfName.is が false なら `OPERATOR_OPERAND_TYPE_MISMATCH`) - * - * - 本フェーズの仕様により `textObject.active` は検査しない(BT/ET 内外で同じ動作)。 - * - name の妥当性(`/Resources` の `/XObject` への存在性)と value の値域(空文字 等) - * は本フェーズでは検証しない。 - * - graphics state は更新しないため、`graphicsStateStack` は入力と同一参照で返す。 - * - エラー時に部分消費した operand stack は復元しない(既存ハンドラ規約)。 - * - * @param context - 実行コンテキスト (operand stack / graphics state stack) - * @returns 成功なら更新後コンテキスト、失敗なら PdfError + * 名前を消費し、リソーススコープがあれば画像・フォーム等のストリームを引く。 + * 描画やフォームの入れ子実行は後段が担う。 */ export const doHandler: OperatorHandler = (context: OperatorHandlerContext) => { const nameResult = OperandExtractor.popName( @@ -37,9 +24,20 @@ export const doHandler: OperatorHandler = (context: OperatorHandlerContext) => { return err(nameResult.error); } - return ok({ - operandStack: context.operandStack, - graphicsStateStack: context.graphicsStateStack, - markedContentStack: context.markedContentStack, - }); + const name = nameResult.value; + const resolved = context.resources?.getXObject(name); + if (resolved === undefined || (resolved.ok && resolved.value.some)) { + return ok(context); + } + + const warning: PdfWarning = resolved.ok + ? { + code: "XOBJECT_NOT_FOUND", + message: `XObject resource not found: /${name}`, + } + : ContentStreamResources.warning( + { category: "XObject", name }, + resolved.error, + ); + return ok({ ...context, warnings: [warning] }); }; diff --git a/packages/core/src/content-stream/paint-resources/index.ts b/packages/core/src/content-stream/paint-resources/index.ts index 2058098f0..5b9c24935 100644 --- a/packages/core/src/content-stream/paint-resources/index.ts +++ b/packages/core/src/content-stream/paint-resources/index.ts @@ -2,8 +2,8 @@ import { PdfResources } from "../../document/resources/index"; import { PdfPattern } from "../../document/resources/pattern/index"; import { PdfShading } from "../../document/resources/shading/index"; import type { PdfError } from "../../pdf/errors/index"; -import { type PdfObject, ResolveRef } from "../../pdf/types/index"; -import { none, type Option, some } from "../../utils/option/index"; +import type { PdfObject, ResolveRef } from "../../pdf/types/index"; +import { type Option, some } from "../../utils/option/index"; import { map, ok, type Result } from "../../utils/result/index"; /** 同期の色指定・描画命令から参照する、現在のスコープのパターンとシェーディング。 */ @@ -70,36 +70,14 @@ export const PaintResources = { ): Promise< Result, PdfError>>, PdfError> > { - const dictionary = await PdfResources.getCategory( + return PdfResources.readCategory( resources, - category.name, + { + name: category.name, + read: async (object, resolve) => + map(await category.read(object, resolve), some), + }, resolveRef, ); - if (!dictionary.ok) { - return dictionary; - } - - const values = new Map, PdfError>>(); - if (!dictionary.value.some) { - return ok(values); - } - - for (const [name, value] of dictionary.value.value.entries) { - const resolved = await ResolveRef.object(value, resolveRef); - if (!resolved.ok) { - values.set(name, resolved); - continue; - } - - if (!resolved.value.some) { - values.set(name, ok(none)); - continue; - } - - const parsed = await category.read(resolved.value.value, resolveRef); - values.set(name, map(parsed, some)); - } - - return ok(values); }, } as const; diff --git a/packages/core/src/content-stream/resources/__tests__/content-stream-resources.integration.test.ts b/packages/core/src/content-stream/resources/__tests__/content-stream-resources.integration.test.ts new file mode 100644 index 000000000..47394463c --- /dev/null +++ b/packages/core/src/content-stream/resources/__tests__/content-stream-resources.integration.test.ts @@ -0,0 +1,280 @@ +import { assert, expect, test } from "vitest"; +import { setupResources } from "../../../document/resources/__tests__/setup-resources"; +import { + ContentStreamInterpreter, + type ContentStreamInterpreterResult, + ContentStreamResources, + ExtGStateResources, + GraphicsStateStack, + OperandStack, + OperatorRegistry, + Result, +} from "../../../index"; + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value); + +async function prepare( + source: Parameters[0], +): Promise { + const { resources, resolveRef } = setupResources(source); + return ContentStreamResources.fromResources(resources, resolveRef); +} + +test("Tf・Do・gs が同じスコープの間接参照を引き、Q は描画状態だけを復元する", async () => { + const resources = await prepare({ + resources: + "<< /Font 1 0 R /XObject << /Im0 3 0 R >> /ExtGState << /GS1 4 0 R >> >>", + objects: [ + "<< /F1 2 0 R >>", + "<< /Type /Font /BaseFont /Courier >>", + "<< /Subtype /Image /Width 1 /Height 1 /Length 1 >>\nstream\nA\nendstream", + "<< /LW 5 /ca .4 >>", + ], + }); + const defaults = OperatorRegistry.createDefault(); + assert(defaults.ok); + const registry = OperatorRegistry.register( + defaults.value, + "inspect", + (context) => { + const image = context.resources?.getXObject("Im0"); + assert(image?.ok && image.value.some); + assert(image.value.value.subtype === "Image"); + expect(image.value.value.stream.data).toEqual(encode("A")); + expect(image.value.value.width).toEqual({ some: true, value: 1 }); + expect( + GraphicsStateStack.current(context.graphicsStateStack), + ).toMatchObject({ + lineWidth: 5, + fillAlpha: 0.4, + }); + + return Result.ok({ + operandStack: context.operandStack, + graphicsStateStack: context.graphicsStateStack, + markedContentStack: context.markedContentStack, + }); + }, + ); + assert(registry.ok); + + const result = ContentStreamInterpreter.execute({ + data: encode("q /GS1 gs /Im0 Do inspect Q BT /F1 12 Tf ET"), + registry: registry.value, + resources, + }); + + assert(result.ok); + const state = GraphicsStateStack.current( + result.value.context.graphicsStateStack, + ); + expect(state.lineWidth).toBe(1); + expect(state.fillAlpha).toBe(1); + expect(state.textState.font).toMatchObject({ + some: true, + value: { + type: "name", + name: "F1", + dictionary: { + entries: new Map([ + ["Type", { type: "name", value: "Font" }], + ["BaseFont", { type: "name", value: "Courier" }], + ]), + }, + }, + }); + expect(state.textState.fontName).toEqual({ some: true, value: "F1" }); + expect(state.textState.fontSize).toBe(12); + expect(result.value.context.resources).toBe(resources); + expect(result.value.warnings).toEqual([]); +}); + +test("色空間・パターン・シェーディングも実行スコープから引ける", async () => { + const resources = await prepare({ + resources: + "<< /ColorSpace << /LocalRGB /DeviceRGB >> /Pattern << /P1 << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB >> >> >> /Shading << /S1 << /ShadingType 2 /ColorSpace /DeviceRGB >> >> /Properties << /Tag << /MCID 1 >> >> >>", + }); + + const result = ContentStreamInterpreter.execute({ + data: encode("/LocalRGB CS .1 .2 .3 SC /Pattern cs /P1 scn /S1 sh"), + resources, + }); + + assert(result.ok); + expect(result.value.warnings).toEqual([]); + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack), + ).toMatchObject({ + strokeColorSpace: "DeviceRGB", + fillColorSpace: { family: "Pattern" }, + }); + expect(result.value.context.resources?.getProperties("Tag")).toMatchObject({ + ok: true, + value: { some: true, value: { type: "dictionary" } }, + }); +}); + +test("壊れたカテゴリや名前は使用した命令位置にだけ警告し、後続の命令を実行する", async () => { + const resources = await prepare({ + resources: + "<< /Font << /Bad 1 0 R /Unused 1 0 R /Good << /Type /Font >> >> /XObject 1 0 R /ExtGState << /Bad 1 0 R /Good << /LW 6 >> >> /ColorSpace 1 0 R /Pattern 1 0 R /Shading << /Bad 1 0 R /Good << /ShadingType 2 /ColorSpace /DeviceGray >> >> >>", + objects: ["<< /Broken"], + }); + const operations = [ + "/Bad 12 Tf", + "/Bad Do", + "/Bad gs", + "/Bad cs", + "/Bad scn", + "/Bad sh", + ]; + const categories = [ + "Font", + "XObject", + "ExtGState", + "ColorSpace", + "Pattern", + "Shading", + ]; + const content = + "/Bad 12 Tf /Bad Do /Bad gs /Bad cs .2 .4 sc /Pattern cs /Bad scn /Bad sh /Good 14 Tf /Good gs /Good sh"; + + const result = ContentStreamInterpreter.execute({ + data: encode(content), + resources, + }); + + assert(result.ok); + expect(result.value.warnings).toEqual( + operations.map((operation, index) => ({ + code: "RESOURCES_RESOLVE_FAILED", + offset: content.indexOf(operation) + operation.lastIndexOf(" ") + 1, + message: expect.stringContaining( + `/${categories[index]} /Bad: OBJECT_PARSE_UNEXPECTED_TOKEN`, + ), + })), + ); + const state = GraphicsStateStack.current( + result.value.context.graphicsStateStack, + ); + expect(state.lineWidth).toBe(6); + expect(state.textState.fontName).toEqual({ some: true, value: "Good" }); + expect(state.textState.fontSize).toBe(14); + expect(OperandStack.depth(result.value.context.operandStack)).toBe(0); +}); + +test("不在・型違いのリソースを警告し、Tf の名前とサイズを保持する", async () => { + const resources = await prepare({ + resources: "<< /Font << /Known << /Type /Font >> /F1 42 >> >>", + }); + + const result = ContentStreamInterpreter.execute({ + data: encode("3 Tc /Known 10 Tf /F1 12 Tf /Im0 Do /GS1 gs 7 w"), + resources, + }); + + assert(result.ok); + expect(result.value.warnings.map(({ code }) => code)).toEqual([ + "FONT_NOT_FOUND", + "XOBJECT_NOT_FOUND", + "EXT_GSTATE_NOT_FOUND", + ]); + const state = GraphicsStateStack.current( + result.value.context.graphicsStateStack, + ); + expect(state.lineWidth).toBe(7); + expect(state.textState.font).toEqual({ + some: true, + value: { type: "name", name: "F1" }, + }); + expect(state.textState).toMatchObject({ + fontSize: 12, + charSpace: 3, + }); +}); + +test("スコープを省略した従来の Tf・Do・gs を実行できる", () => { + const result = ContentStreamInterpreter.execute({ + data: encode("/F1 12 Tf /Im0 Do /GS1 gs 7 w"), + }); + + assert(result.ok); + expect(result.value.warnings.map(({ code }) => code)).toEqual([ + "EXT_GSTATE_NOT_FOUND", + ]); + const state = GraphicsStateStack.current( + result.value.context.graphicsStateStack, + ); + expect(state.lineWidth).toBe(7); + expect(state.textState.font).toEqual({ + some: true, + value: { type: "name", name: "F1" }, + }); +}); + +test("実行ごとのスコープは初期 context と従来の registry 設定から独立する", async () => { + const { resources: source, resolveRef } = setupResources({ + resources: "<< /ExtGState << /GS << /LW 9 >> >> >>", + }); + const states = await ExtGStateResources.fromResources(source, resolveRef); + assert(states.ok); + const registry = OperatorRegistry.createDefault(states.value); + assert(registry.ok); + const firstScope = await prepare({ + resources: "<< /ExtGState << /GS << /LW 3 >> >> >>", + }); + const secondScope = await prepare({ + resources: "<< /ExtGState << /GS << /LW 7 >> >> >>", + }); + const emptyScope = await prepare({ resources: "<< >>" }); + const options = { data: encode("/GS gs"), registry: registry.value }; + const first = ContentStreamInterpreter.execute({ + ...options, + resources: firstScope, + }); + assert(first.ok); + const initialContext = first.value.context; + + const second = ContentStreamInterpreter.execute({ + ...options, + initialContext, + resources: secondScope, + }); + const empty = ContentStreamInterpreter.execute({ + ...options, + initialContext, + resources: emptyScope, + }); + const inherited = ContentStreamInterpreter.execute({ + ...options, + initialContext, + }); + const legacy = ContentStreamInterpreter.execute(options); + + assert(second.ok && empty.ok && inherited.ok && legacy.ok); + const width = (result: ContentStreamInterpreterResult): number => + GraphicsStateStack.current(result.context.graphicsStateStack).lineWidth; + expect(width(first.value)).toBe(3); + expect(width(second.value)).toBe(7); + expect(width(empty.value)).toBe(3); + expect(empty.value.warnings).toMatchObject([ + { code: "EXT_GSTATE_NOT_FOUND" }, + ]); + expect(width(inherited.value)).toBe(3); + expect(width(legacy.value)).toBe(9); + expect(initialContext.resources).toBe(firstScope); +}); + +test("リソースを指定しても Do の不正なオペランドは実行エラーになる", async () => { + const resources = await prepare({ resources: "<< >>" }); + + const result = ContentStreamInterpreter.execute({ + data: encode("5 Do"), + resources, + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "OPERATOR_OPERAND_TYPE_MISMATCH", operatorName: "Do" }, + }); +}); diff --git a/packages/core/src/content-stream/resources/index.ts b/packages/core/src/content-stream/resources/index.ts new file mode 100644 index 000000000..b2a7a724e --- /dev/null +++ b/packages/core/src/content-stream/resources/index.ts @@ -0,0 +1,119 @@ +import { + type PdfResourceCategory, + PdfResources, +} from "../../document/resources/index"; +import { PdfPattern } from "../../document/resources/pattern/index"; +import { PdfShading } from "../../document/resources/shading/index"; +import { PdfXObject } from "../../document/resources/xobject/index"; +import type { PdfError, PdfWarning } from "../../pdf/errors/index"; +import type { + PdfDictionary, + PdfObject, + ResolveRef, +} from "../../pdf/types/index"; +import { none, type Option, some } from "../../utils/option/index"; +import { flatMap, map, ok, type Result } from "../../utils/result/index"; +import { + type ColorSpaceDefinition, + ColorSpaceResources, +} from "../color-space-resources/index"; +import { type ExtGState, ExtGStateResources } from "../ext-gstate/index"; +import { PaintResources } from "../paint-resources/index"; + +type ResourceLookup = (name: string) => Result, PdfError>; + +/** ページまたはフォームの名前を同期実行中に引く、読み取り専用のリソーススコープ。 */ +export type ContentStreamResources = { + readonly getFont: ResourceLookup; + readonly getXObject: ResourceLookup; + readonly getExtGState: ResourceLookup; + readonly getColorSpace: ResourceLookup; + readonly getPattern: ResourceLookup; + readonly getShading: ResourceLookup; + readonly getProperties: ResourceLookup; +}; + +function readDictionary( + object: PdfObject, +): Result, PdfError> { + return ok(object.type === "dictionary" ? some(object) : none); +} + +function lookup( + category: Result, PdfError>>, PdfError>, +): ResourceLookup { + return (name) => + flatMap(category, (entries) => entries.get(name) ?? ok(none)); +} + +export const ContentStreamResources = { + /** + * 同期実行の前に PDF のリソースを準備する。各 getter はスラッシュを除いた名前を受け取る。 + * 名前の不在は None。カテゴリまたは参照先のエラーは該当する getter から返す。 + */ + async fromResources( + resources: PdfResources, + resolveRef: ResolveRef, + ): Promise { + const fonts = await PdfResources.readCategory( + resources, + { name: "Font", read: readDictionary }, + resolveRef, + ); + const xObjects = await PdfResources.readCategory( + resources, + { name: "XObject", read: PdfXObject.fromObject }, + resolveRef, + ); + const states = await ExtGStateResources.fromResources( + resources, + resolveRef, + ); + const colorSpaces = await ColorSpaceResources.fromResources( + resources, + resolveRef, + ); + const patterns = await PaintResources.readCategory( + resources, + { name: "Pattern", read: PdfPattern.fromObject }, + resolveRef, + ); + const shadings = await PaintResources.readCategory( + resources, + { name: "Shading", read: PdfShading.fromObject }, + resolveRef, + ); + const properties = await PdfResources.readCategory( + resources, + { name: "Properties", read: readDictionary }, + resolveRef, + ); + + return { + getFont: lookup(fonts), + getXObject: lookup(xObjects), + getExtGState: lookup(map(states, ({ states }) => states)), + getColorSpace: (name) => + flatMap(colorSpaces, ({ spaces }) => { + const space = spaces.get(name); + return space === undefined + ? ok>(none) + : map(space, some); + }), + getPattern: lookup(patterns), + getShading: lookup(shadings), + getProperties: lookup(properties), + }; + }, + + /** リソースの参照エラーを、命令の位置に付ける警告へ変換する。 */ + warning( + resource: { readonly category: PdfResourceCategory; readonly name: string }, + error: PdfError, + ): PdfWarning { + return { + code: "RESOURCES_RESOLVE_FAILED", + message: `Could not resolve /${resource.category} /${resource.name}: ${error.code}: ${error.message}`, + }; + }, +} as const; diff --git a/packages/core/src/document/resources/index.ts b/packages/core/src/document/resources/index.ts index e8400a816..17eee4401 100644 --- a/packages/core/src/document/resources/index.ts +++ b/packages/core/src/document/resources/index.ts @@ -2,7 +2,7 @@ import type { PdfError } from "../../pdf/errors/index"; import type { PdfDictionary, PdfObject, PdfValue } from "../../pdf/types/index"; import { ResolveRef } from "../../pdf/types/index"; import { none, type Option, some } from "../../utils/option/index"; -import { map, ok, type Result } from "../../utils/result/index"; +import { ok, type Result } from "../../utils/result/index"; import { PdfXObject } from "./xobject/index"; /** @@ -146,11 +146,11 @@ export const PdfResources = { } const target = result.value; - if (!target.some || target.value.type !== "stream") { + if (!target.some) { return ok(none); } - return map(await PdfXObject.fromStream(target.value, resolveRef), some); + return PdfXObject.fromObject(target.value, resolveRef); }, /** @@ -203,6 +203,50 @@ export const PdfResources = { return ok(some(resolvedObj)); }, + /** + * カテゴリ辞書を一度引き、名前ごとの読み取り結果を保持する。 + * カテゴリ自体の参照エラーと、個別の参照・読み取りエラーを区別する。 + */ + async readCategory( + resources: PdfResources, + category: { + readonly name: PdfResourceCategory; + readonly read: ( + object: PdfObject, + resolveRef: ResolveRef, + ) => Result, PdfError> | Promise, PdfError>>; + }, + resolveRef: ResolveRef, + ): Promise< + Result, PdfError>>, PdfError> + > { + const dictionary = await PdfResources.getCategory( + resources, + category.name, + resolveRef, + ); + if (!dictionary.ok) { + return dictionary; + } + + const entries = new Map, PdfError>>(); + if (!dictionary.value.some) { + return ok(entries); + } + + for (const [name, value] of dictionary.value.value.entries) { + const object = await ResolveRef.object(value, resolveRef); + if (!object.ok || !object.value.some) { + entries.set(name, object.ok ? ok(none) : object); + continue; + } + + entries.set(name, await category.read(object.value.value, resolveRef)); + } + + return ok(entries); + }, + /** * 指定されたカテゴリとローカルリソース名から、間接参照を解決して実体オブジェクトを取得する。 * diff --git a/packages/core/src/document/resources/xobject/index.ts b/packages/core/src/document/resources/xobject/index.ts index 6366fcc99..b50f7ba82 100644 --- a/packages/core/src/document/resources/xobject/index.ts +++ b/packages/core/src/document/resources/xobject/index.ts @@ -16,7 +16,7 @@ import { type Option, some, } from "../../../utils/option/index"; -import { ok, type Result } from "../../../utils/result/index"; +import { map, ok, type Result } from "../../../utils/result/index"; import { PdfRectangle } from "../../page-tree/resolved-page/index"; import { ResourceParameters } from "../parameters/index"; @@ -216,6 +216,18 @@ const PdfFormXObject = { } as const; export const PdfXObject = { + /** ストリームを XObject として読み、それ以外のオブジェクトは None を返す。 */ + async fromObject( + object: PdfObject, + resolveRef: ResolveRef, + ): Promise, PdfError>> { + if (object.type !== "stream") { + return ok(none); + } + + return map(await PdfXObject.fromStream(object, resolveRef), some); + }, + /** * /Subtype から種別と属性を読み、元のストリームを保持する。 * @param stream - 名前解決で得たストリーム diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b4845c3a..a8e2628fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,7 @@ -export { ColorSpaceResources } from "./content-stream/color-space-resources/index"; +export { + type ColorSpaceDefinition, + ColorSpaceResources, +} from "./content-stream/color-space-resources/index"; export { ExtGState, type ExtGStateParameter, @@ -12,6 +15,7 @@ export { export { SoftMask } from "./content-stream/graphics-state/soft-mask/index"; export { TextFont } from "./content-stream/graphics-state/text-font/index"; export { PaintResources } from "./content-stream/paint-resources/index"; +export { ContentStreamResources } from "./content-stream/resources/index"; export { PdfPattern, type PdfPatternPaintType, diff --git a/packages/core/src/pdf/errors/warning/index.ts b/packages/core/src/pdf/errors/warning/index.ts index dad79c9cc..ce8f7bb1d 100644 --- a/packages/core/src/pdf/errors/warning/index.ts +++ b/packages/core/src/pdf/errors/warning/index.ts @@ -29,6 +29,8 @@ export type PdfWarningCode = | "INFO_NOT_DICTIONARY" | "STRING_DECODE_FAILED" | "TRAPPED_INVALID" + | "FONT_NOT_FOUND" + | "XOBJECT_NOT_FOUND" | "EXT_GSTATE_NOT_FOUND" | "EXT_GSTATE_INVALID_ENTRY" | "EXT_GSTATE_UNSUPPORTED_ENTRY"