From e872a194c9dc3d0094065955df40788a6202595f Mon Sep 17 00:00:00 2001 From: DIO0550 <47467473+DIO0550@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:02:40 +0900 Subject: [PATCH 1/4] feat(core): resolve pattern and shading resources --- docs/guides/README.md | 1 + docs/guides/patterns-shadings.html | 270 ++++++++++++++++ docs/guides/resources.html | 1 + docs/specs/04_resources_graphics_state.md | 25 +- .../content-stream/color-operation/index.ts | 30 ++ .../__tests__/color-space.pattern.test.ts | 4 +- .../__tests__/setup-color-space.ts | 10 +- .../content-stream/operator-registry/index.ts | 7 +- .../operators/color/color-operators/index.ts | 10 +- .../operators/color/selection/index.ts | 53 +++- .../shading/sh/__tests__/sh.basic.test.ts | 76 +++++ .../operators/shading/sh/index.ts | 51 +++ .../__tests__/paint-resources.error.test.ts | 112 +++++++ .../paint-resources.integration.test.ts | 85 +++++ .../paint-resources/__tests__/setup-paint.ts | 56 ++++ .../content-stream/paint-resources/index.ts | 105 +++++++ .../__tests__/parameters.basic.test.ts | 46 +++ .../document/resources/parameters/index.ts | 116 +++++++ .../pattern/__tests__/pattern.basic.test.ts | 88 ++++++ .../pattern/__tests__/pattern.error.test.ts | 52 +++ .../pattern/__tests__/pattern.type.test-d.ts | 20 ++ .../src/document/resources/pattern/index.ts | 297 ++++++++++++++++++ .../shading/__tests__/shading.basic.test.ts | 76 +++++ .../shading/__tests__/shading.error.test.ts | 31 ++ .../shading/__tests__/shading.type.test-d.ts | 11 + .../src/document/resources/shading/index.ts | 197 ++++++++++++ .../src/document/resources/xobject/index.ts | 37 +-- packages/core/src/index.ts | 15 + packages/core/src/pdf/errors/warning/index.ts | 6 + 29 files changed, 1840 insertions(+), 48 deletions(-) create mode 100644 docs/guides/patterns-shadings.html create mode 100644 packages/core/src/content-stream/operators/shading/sh/__tests__/sh.basic.test.ts create mode 100644 packages/core/src/content-stream/operators/shading/sh/index.ts create mode 100644 packages/core/src/content-stream/paint-resources/__tests__/paint-resources.error.test.ts create mode 100644 packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts create mode 100644 packages/core/src/content-stream/paint-resources/__tests__/setup-paint.ts create mode 100644 packages/core/src/content-stream/paint-resources/index.ts create mode 100644 packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts create mode 100644 packages/core/src/document/resources/parameters/index.ts create mode 100644 packages/core/src/document/resources/pattern/__tests__/pattern.basic.test.ts create mode 100644 packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts create mode 100644 packages/core/src/document/resources/pattern/__tests__/pattern.type.test-d.ts create mode 100644 packages/core/src/document/resources/pattern/index.ts create mode 100644 packages/core/src/document/resources/shading/__tests__/shading.basic.test.ts create mode 100644 packages/core/src/document/resources/shading/__tests__/shading.error.test.ts create mode 100644 packages/core/src/document/resources/shading/__tests__/shading.type.test-d.ts create mode 100644 packages/core/src/document/resources/shading/index.ts diff --git a/docs/guides/README.md b/docs/guides/README.md index cb5701dd..b7f46666 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -18,6 +18,7 @@ PDF機能の実装に合わせて、仕様の読み方と実装の振る舞い | [字句トークンと描画命令の境界](token-layers.html) | 字句解析と演算子の再分類、LexicalTokenとContentStreamToken、巻き戻しと画像辞書の型境界、Operator.value、正常例・未知の命令・壊れた画像 | [Issue #308](https://github.com/DIO0550/pdfmod/issues/308) / `packages/core/src/pdf/types/token/index.ts` | | [暗号化辞書](encryption-dictionary.html) | Standard の6組と方式ごとの型・生成処理、crypt filter と Identity、鍵長の単位正規化、符号なし権限、未対応辞書の保持、間接参照と解析の境界 | [Issue #651](https://github.com/DIO0550/pdfmod/issues/651) / `packages/core/src/pdf/encryption/index.ts` / `rust/crates/core/src/encrypt.rs` | | [拡張グラフィックス状態](ext-gstate.html) | gs の名前解決、各パラメータ、Font の辞書指定、透明度、q/Q、警告と回復 | [Issue #667](https://github.com/DIO0550/pdfmod/issues/667) / `packages/core/src/content-stream/ext-gstate/index.ts` | +| [パターンとシェーディング](patterns-shadings.html) | PatternType・ShadingType、着色と基底色、scn / SCN / sh、間接参照、Functionの未評価保持、警告と回復 | [Issue #677](https://github.com/DIO0550/pdfmod/issues/677) / `packages/core/src/content-stream/paint-resources/` / `packages/core/src/document/resources/pattern/` / `packages/core/src/document/resources/shading/` | ## 読み方 diff --git a/docs/guides/patterns-shadings.html b/docs/guides/patterns-shadings.html new file mode 100644 index 00000000..0b34e0cb --- /dev/null +++ b/docs/guides/patterns-shadings.html @@ -0,0 +1,270 @@ + + + + + + +パターンとシェーディング — PDF仕様解説 + + + + +
+
文字サイズ
+
+
色を繰り返す・滑らかに変える

パターンと
シェーディング

PDFの色は、RGBの数値だけではありません。小さな絵を敷き詰める定義も、場所ごとに色が変わる定義も、リソース名を通して選択できます。

コードは名前=緑、数値=黄、参照・構文=青、命令=紫、コメント=灰で色分けしています。

+

1. パターンとシェーディングの違い

+

パターン(Pattern)

図形を塗るときの「色」として選択する仕組みです。/Pattern 色空間を選び、scn または SCN でパターン名を渡します。

種別1はタイル、種別2はシェーディングを使うパターンです。

シェーディング(Shading)

場所によって色がどう変わるかを記述する定義です。軸方向・放射状・メッシュなど7種類があり、パターンの内部からも参照されます。

/S1 sh は現在の /Shading 辞書から定義を選択する命令です。

+
定義を読む段階と、描画する段階今回の実装は辞書の取得・種別の判別・主要属性の保持までです。以下で説明する敷き詰めやグラデーションの画素描画、関数評価は実装していません。
+
+

2. タイリングパターンと基底色

+

タイリング(tiling)は、一つのセルを一定間隔で繰り返す仕組みです。セルの命令列はPDFストリームにあり、/BBox がセルの境界、/XStep と /YStep が繰り返し間隔を表します。

+

次は構造を示す抜粋です。セルは8×8、繰り返し間隔は10です。ストリーム長とページ全体の構造は省略しています。

+
/Resources << /Pattern << /P1 10 0 R >> >>
+10 0 obj
+<< /Type /Pattern /PatternType 1
+   /PaintType 1 /TilingType 1
+   /BBox [0 0 8 8] /XStep 10 /YStep 10
+   /Resources << >> >>
+stream
+.2 .5 .8 rg
+0 0 8 8 re f
+endstream
+endobj
+
+ + + + + + +
属性意味今回の保持方法
PatternType1=タイリング、2=シェーディングパターン種別番号を判別子にした型
PaintType1=セル内で色を指定、2=呼び出し側から色を渡す1または2に限定
TilingType1=間隔一定、2=形状の歪みを避ける、3=高速化を許す間隔一定1〜3に限定
BBox / XStep / YStep境界と間隔。間隔は0以外で、負の値も指定可能4個の有限座標と有限な間隔
Resourcesセル自身が使うローカルリソース辞書を取得して保持。内部の再帰実行は後段
Matrixパターン座標から既定のユーザー座標への変換6要素。省略時は単位行列
+

着色パターン

PaintType 1 はセルが色を持ちます。基底色空間を伴わない /Pattern を選択し、数値成分を渡さず名前だけを指定します。次の /P1 は種別1・着色として定義済みとします。

/Pattern cs      % 塗りの色空間を選択
+/P1 scn          % 数値成分は不要、最後にパターン名
+

非着色パターン

PaintType 2 はセルが形状を持ち、色は呼び出し側が渡します。例えば [/Pattern /DeviceRGB] はRGBの3成分と名前を受け取ります。/P2 は非着色として定義済みとします。

% /Resources内の色空間定義
+/ColorSpace << /Pat [/Pattern /DeviceRGB] >>
+% コンテンツストリーム
+/Pat cs
+.1 .2 .3 /P2 scn
+

小文字の cs / scn は塗り、大文字の CS / SCN は輪郭に作用します。sc / SC はPatternには使用できません。基底色の有無と着色方式が食い違う組み合わせもエラーになります。

+
+

3. シェーディングの7種類

+

シェーディングは色変化の計算方法を /ShadingType で選びます。型1〜3は関数で色を定義し、型4〜7はストリーム内のメッシュを使います。

+
+ +
番号種類考え方
1関数ベース2次元の位置から色を求める
2軸方向2点を結ぶ軸に沿って色が変わる
3放射状二つの円を基に色が変わる
4フリーフォーム自由に連結した三角形メッシュ
5ラティス格子状の三角形メッシュ
6クーンズパッチ曲線の境界でパッチを定義
7テンソル積パッチ内部制御点も使うパッチ
+

次は軸方向の定義を二つの経路から使う抜粋です。20 0 R と 21 0 R は間接参照で、オブジェクト番号・世代番号・参照記号の順です。/Function のオブジェクト本体は省略しています。

/Resources <<
+  /Pattern << /P3 << /PatternType 2 /Shading 20 0 R >> >>
+  /Shading << /S1 20 0 R >>
+>>
+20 0 obj
+<< /ShadingType 2 /ColorSpace /DeviceRGB
+   /Background [.1 .2 .3] /BBox [0 0 200 100]
+   /Coords [0 0 200 0] /Function 21 0 R >>
+endobj
+% パターン経由の選択
+/Pattern cs /P3 scn
+% シェーディングの直接指定
+/S1 sh
+

/ColorSpace は色の表現、任意の /Background は背景の成分、任意の /BBox はシェーディングの境界です。この段階では色空間の名前または配列を取得して保持し、背景の成分数や関数固有の値域の検証は後段に残します。

+
Functionは参照のまま/Function 21 0 R を見つけてもオブジェクト21を読みません。直接の関数辞書・関数配列も元の値として保持します。関数評価とメッシュの展開をここへ混ぜないためです。
+
+

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は色・色空間・パス・クリップを変更しません。

+
+

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

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

警告による回復は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で判別します。成功・エラーと値の有無はそれぞれ別に確認してください。

+

対応した範囲

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

後段の処理

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

+
+
+
+ + + diff --git a/docs/guides/resources.html b/docs/guides/resources.html index bb842309..8c8bb05d 100644 --- a/docs/guides/resources.html +++ b/docs/guides/resources.html @@ -446,6 +446,7 @@

保存した名前をAPIへ渡す

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

間接参照とPattern

+

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

カテゴリ辞書・各定義・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です。参照先の構文解析エラーは警告へ置き換えず返します。

diff --git a/docs/specs/04_resources_graphics_state.md b/docs/specs/04_resources_graphics_state.md index cb635618..282aa067 100644 --- a/docs/specs/04_resources_graphics_state.md +++ b/docs/specs/04_resources_graphics_state.md @@ -230,7 +230,7 @@ Q % グラフィックスステート復元 - 色空間配列の先頭にある未知の族は `COLOR_SPACE_UNSUPPORTED`、成分数を確定するための値の不正・欠落や循環は `COLOR_SPACE_INVALID` を報告する。不在の名前は、`cs` / `CS` で直接指定した場合も、定義内の別名や基底色空間から参照した場合も `COLOR_SPACE_NOT_FOUND` とする。いずれも選択した命令の位置で報告する。再帰は32段を上限とし、上限でも不明な定義として回復する。 - 不明な色空間は以前の成分数を引き継がず、成分数不明として保持する。その後の色命令は数値型を検証して保持するが、個数の一致は検証しない。Indexed は基底が不明でも添字は1成分である。 - 既知の成分数の過不足は `OPERATOR_OPERAND_COUNT_MISMATCH`、型の不一致は `OPERATOR_OPERAND_TYPE_MISMATCH`。Pattern に `sc` / `SC` を使う場合は `OPERATOR_ILLEGAL_STATE` を返す。 -- 色空間と色値は `q` / `Q` の保存・復元対象。ここで扱うのは族・成分数・色値の保持までであり、ICCプロファイル変換、CIEパラメータの数値検証、パレット展開、特色の関数評価、パターンの取得・描画は後段の処理とする。 +- 色空間と色値は `q` / `Q` の保存・復元対象。ここで扱うのは族・成分数・色値の保持までであり、ICCプロファイル変換、CIEパラメータの数値検証、パレット展開、特色の関数評価、パターンの描画は後段の処理とする。パターンの取得は次節の描画リソースの準備が担当する。 ### 3.5 /Pattern(パターン辞書) @@ -241,6 +241,14 @@ Q % グラフィックスステート復元 | タイリングパターン | 1 | 繰り返し描画される小さな図形の定義 | | シェーディングパターン | 2 | グラデーション等のスムーズな色変化 | +#### 主要属性と名前オペランド + +- 種別1はストリームを持ち、`/PaintType`(1=着色、2=非着色)、`/TilingType`(1〜3)、`/BBox`(4数値)、`/XStep`・`/YStep`(有限・0以外)、`/Resources`(辞書)を読む。`/Matrix` は6数値で、省略時は単位行列を使う。未復号のセルストリームを保持し、内部の命令は実行しない。 +- 種別2は `/Shading` を解決して次節の型へ変換し、`/Matrix` と任意の `/ExtGState` 辞書を保持する。拡張状態の適用は行わない。 +- `scn` / `SCN` の名前オペランドは、現在の色空間が Pattern の場合だけ許可する。名前から現在の `/Pattern` サブ辞書を引く。Pattern以外では数値オペランドとして型・個数を検証し、名前を受理しない。 +- 着色タイリングとシェーディングパターンは基底色空間を伴わない Pattern、非着色タイリングは基底を伴う Pattern と組み合わせる。異なる組み合わせは `OPERATOR_ILLEGAL_STATE`。数値成分数は基底の成分数に従い、名前は数えない。 +- 名前と数値成分は従来どおり色状態へ保存する。取得した型付き定義は `PaintResources.patterns` で参照できる。実際のパターン描画は行わない。 + ### 3.6 /Shading(シェーディング辞書) グラデーション描画を定義する。 @@ -255,6 +263,21 @@ Q % グラフィックスステート復元 | クーンズパッチメッシュ | 6 | クーンズパッチによるメッシュ | | テンソル積パッチメッシュ | 7 | テンソル積パッチによるメッシュ | +#### 共通属性と sh + +- `/ColorSpace` を名前または配列として取得し、任意の `/Background` を数値配列、`/BBox` を4数値として読む。色空間配列の内部の解釈、背景の成分数やシェーディング固有の必須キー・値域の全面検証は後段が担当する。 +- 種別4〜7はメッシュの未復号ストリームを必須とする。種別番号は1〜7に閉じ、未知の番号は元のオブジェクトを持つ Unknown とする。 +- `/Function` は元の `PdfValue` のまま保持する。間接参照を解決せず、関数辞書・配列の内部も評価しない。 +- `sh` は名前オペランドを正確に1個受け取り、現在の `/Shading` サブ辞書を引く。既定のレジストリにも登録する。色・色空間・パス・クリップを変更せず、この段階では描画しない。 + +#### pdfmod の描画リソースの準備・回復方針 + +- 同期実行前に `PaintResources.fromResources(resources, resolveRef)` で Pattern / Shading のカテゴリ・各定義・主要属性を間接参照も含めて解決する。数値配列は各要素も解決する。別のページ・フォームの同名定義へフォールバックしない。 +- 解決結果は `OperatorRegistry.createDefault(extGStates, colorSpaces, paints)`、または色命令に限れば `registerColorOperators(registry, colorSpaces, paints)` へ渡す。 +- カテゴリの参照解決エラーは準備時に返す。各定義・主要属性の参照エラーは名前別に保持し、実際にその名前を使う `scn` / `SCN` / `sh` で元の原因を返す。参照先が存在せずリゾルバがPDFのnullを返した場合は、解決エラーとせず不正な定義として扱う。 +- 不在の名前は `PATTERN_NOT_FOUND` / `SHADING_NOT_FOUND`。未知・欠落・不正な型の種別番号は `PATTERN_UNSUPPORTED` / `SHADING_UNSUPPORTED`、既知種別の主要属性が不正なら `PATTERN_INVALID` / `SHADING_INVALID`。警告は命令の位置で報告し後続命令を処理する。不正なMatrixを単位行列へ置き換える回復はしない。 +- 名前不在・Unknownのパターンでも指定名と数値成分は色状態へ保存する。警告による回復はpdfmod独自の方針であり、未知の定義が描画可能であることを意味しない。 + ### 3.7 /ProcSet(手続きセット配列) ``` diff --git a/packages/core/src/content-stream/color-operation/index.ts b/packages/core/src/content-stream/color-operation/index.ts index da61e8ad..ab23c515 100644 --- a/packages/core/src/content-stream/color-operation/index.ts +++ b/packages/core/src/content-stream/color-operation/index.ts @@ -1,3 +1,4 @@ +import type { PdfPattern } from "../../document/resources/pattern/index"; import type { PdfOperatorIllegalStateError } from "../../pdf/errors/index"; import { none, type Option, some } from "../../utils/option/index"; import type { ValueOf } from "../../utils/types/utility-type"; @@ -17,6 +18,35 @@ export type ColorOperation = { }; export const ColorOperation = { + /** + * 非着色パターンと基底色空間の組み合わせを検証する。 + * @param operation - 現在の色空間と色指定命令 + * @param pattern - 名前から取得したパターン + * @returns 着色方式と基底の有無が一致しなければ不正状態のエラー + */ + patternError( + operation: ColorOperation, + pattern: PdfPattern, + ): Option { + const space = operation.colorSpace; + const hasPatternBase = + typeof space !== "string" && + space.family === "Pattern" && + space.base.some; + if (pattern.patternType === "Unknown") { + return none; + } + + const requiresBase = pattern.patternType === 1 && pattern.paintType === 2; + return hasPatternBase !== requiresBase + ? some({ + code: "OPERATOR_ILLEGAL_STATE", + operatorName: operation.operatorName, + message: + "Pattern PaintType does not match the selected base color space", + }) + : none; + }, /** * 色空間に使用できない色指定命令を検出する。 * @param operation - 選択中の色空間と実行する命令 diff --git a/packages/core/src/content-stream/color-space-resources/__tests__/color-space.pattern.test.ts b/packages/core/src/content-stream/color-space-resources/__tests__/color-space.pattern.test.ts index abcb0b84..0788c664 100644 --- a/packages/core/src/content-stream/color-space-resources/__tests__/color-space.pattern.test.ts +++ b/packages/core/src/content-stream/color-space-resources/__tests__/color-space.pattern.test.ts @@ -1,5 +1,6 @@ import { assert, expect, test } from "vitest"; import { GraphicsStateStack } from "../../graphics-state/index"; +import { tilingPattern } from "../../paint-resources/__tests__/setup-paint"; import { executeColorSpace } from "./setup-color-space"; test.each([ @@ -44,7 +45,8 @@ test.each([ target, }) => { const result = await executeColorSpace({ - resources: `<< /ColorSpace << /Pat ${definition} >> >>`, + resources: `<< /ColorSpace << /Pat ${definition} >> /Pattern << /P1 1 0 R >> >>`, + objects: [tilingPattern(components.length > 0 ? "/PaintType 2" : "")], content: `/Pat ${selection} ${operands} ${operatorName}`, }); assert(result.ok); diff --git a/packages/core/src/content-stream/color-space-resources/__tests__/setup-color-space.ts b/packages/core/src/content-stream/color-space-resources/__tests__/setup-color-space.ts index d2341010..49b4cbe8 100644 --- a/packages/core/src/content-stream/color-space-resources/__tests__/setup-color-space.ts +++ b/packages/core/src/content-stream/color-space-resources/__tests__/setup-color-space.ts @@ -5,6 +5,7 @@ import type { Result } from "../../../utils/result/index"; import type { ContentStreamInterpreterResult } from "../../interpreter/index"; import { ContentStreamInterpreter } from "../../interpreter/index"; import { OperatorRegistry } from "../../operator-registry/index"; +import { PaintResources } from "../../paint-resources/index"; import { ColorSpaceResources } from "../index"; /** @@ -23,7 +24,14 @@ export async function executeColorSpace(source: { resolveRef, ); assert(prepared.ok); - const registry = OperatorRegistry.createDefault(undefined, prepared.value); + const paints = await PaintResources.fromResources(resources, resolveRef); + assert(paints.ok); + + const registry = OperatorRegistry.createDefault( + undefined, + prepared.value, + paints.value, + ); assert(registry.ok); return ContentStreamInterpreter.execute({ diff --git a/packages/core/src/content-stream/operator-registry/index.ts b/packages/core/src/content-stream/operator-registry/index.ts index 819f5a3f..2edebd9d 100644 --- a/packages/core/src/content-stream/operator-registry/index.ts +++ b/packages/core/src/content-stream/operator-registry/index.ts @@ -50,6 +50,7 @@ import { reHandler } from "../operators/path/re/index"; import { strokeHandler } from "../operators/path/stroke/index"; import { vHandler } from "../operators/path/v/index"; import { yHandler } from "../operators/path/y/index"; +import { ShOperator } from "../operators/shading/sh/index"; import { apostropheHandler } from "../operators/text/apostrophe/index"; import { btHandler } from "../operators/text/bt/index"; import { etHandler } from "../operators/text/et/index"; @@ -68,6 +69,7 @@ import { tsHandler } from "../operators/text/ts/index"; 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"; /** * Content stream operator handler が受け取り、更新後に返す実行コンテキスト。 @@ -229,15 +231,18 @@ export const OperatorRegistry = { * * @param extGStates - 実行対象スコープの解決済み拡張状態 * @param colorSpaces - 実行対象スコープの解決済み色空間 + * @param paints - 実行対象スコープの解決済みパターン・シェーディング * @returns 全ビルトインオペレータ登録済みの OperatorRegistry、または重複登録時の PdfError */ createDefault( extGStates: ExtGStateResources = ExtGStateResources.empty(), colorSpaces: ColorSpaceResources = ColorSpaceResources.empty(), + paints: PaintResources = PaintResources.empty(), ): Result { const operators = [ ...BUILTIN_OPERATORS, - ...ColorSpaceOperators.create(colorSpaces), + ...ColorSpaceOperators.create(colorSpaces, paints), + ["sh", ShOperator.create(paints)] as const, ]; return operators.reduce>( (acc, [name, handler]) => diff --git a/packages/core/src/content-stream/operators/color/color-operators/index.ts b/packages/core/src/content-stream/operators/color/color-operators/index.ts index 7c55e8ce..57d6c395 100644 --- a/packages/core/src/content-stream/operators/color/color-operators/index.ts +++ b/packages/core/src/content-stream/operators/color/color-operators/index.ts @@ -4,6 +4,7 @@ import { flatMap, ok } from "../../../../utils/result/index"; import { ColorSpaceResources } from "../../../color-space-resources/index"; import type { OperatorHandler } from "../../../operator-registry/index"; import { OperatorRegistry } from "../../../operator-registry/index"; +import { PaintResources } from "../../../paint-resources/index"; import { kHandler } from "../cmyk/fill"; import { KHandler } from "../cmyk/stroke"; import { gHandler } from "../gray/fill"; @@ -36,15 +37,18 @@ const COLOR_OPERATORS: ReadonlyArray = [ * * @param registry - 登録元 registry * @param colorSpaces - 実行対象スコープの解決済み色空間 + * @param paints - 実行対象スコープのパターンリソース * @returns 全 operator が登録された新しい registry、または重複登録時の PdfError */ export const registerColorOperators = ( registry: OperatorRegistry, colorSpaces: ColorSpaceResources = ColorSpaceResources.empty(), + paints: PaintResources = PaintResources.empty(), ): Result => - [...COLOR_OPERATORS, ...ColorSpaceOperators.create(colorSpaces)].reduce< - Result - >( + [ + ...COLOR_OPERATORS, + ...ColorSpaceOperators.create(colorSpaces, paints), + ].reduce>( (acc, [name, handler]) => flatMap(acc, (r) => OperatorRegistry.register(r, name, handler)), ok(registry), 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 9a697f7c..9a8d91db 100644 --- a/packages/core/src/content-stream/operators/color/selection/index.ts +++ b/packages/core/src/content-stream/operators/color/selection/index.ts @@ -17,6 +17,7 @@ import type { OperatorHandler, OperatorHandlerContext, } from "../../../operator-registry/index"; +import { PaintResources } from "../../../paint-resources/index"; import { OperandExtractor } from "../../operand-extractor/index"; type ColorTarget = "fill" | "stroke"; @@ -26,18 +27,20 @@ export const ColorSpaceOperators = { /** * 現在のスコープのリソースを使用するハンドラを作る。 * @param resources - 解決済みの色空間リソース + * @param paints - 解決済みのパターンリソース * @returns 登録する命令名とハンドラの組 */ create( resources: ColorSpaceResources = ColorSpaceResources.empty(), + paints: PaintResources = PaintResources.empty(), ): ReadonlyArray { return [ ["cs", ColorSpaceOperators.select("fill", resources)], ["CS", ColorSpaceOperators.select("stroke", resources)], - ["sc", ColorSpaceOperators.setColor("sc", "fill")], - ["SC", ColorSpaceOperators.setColor("SC", "stroke")], - ["scn", ColorSpaceOperators.setColor("scn", "fill")], - ["SCN", ColorSpaceOperators.setColor("SCN", "stroke")], + ["sc", ColorSpaceOperators.setColor("sc", "fill", paints)], + ["SC", ColorSpaceOperators.setColor("SC", "stroke", paints)], + ["scn", ColorSpaceOperators.setColor("scn", "fill", paints)], + ["SCN", ColorSpaceOperators.setColor("SCN", "stroke", paints)], ]; }, @@ -87,11 +90,13 @@ export const ColorSpaceOperators = { * 現在の色空間の成分数を検証して色値を更新する。 * @param operatorName - 色指定命令 * @param target - 塗りまたは輪郭 + * @param resources - 解決済みのパターンリソース * @returns 個数・型の不一致を Err にするハンドラ */ setColor( operatorName: ColorOperation["operatorName"], target: ColorTarget, + resources: PaintResources, ): OperatorHandler { return (context) => { const state = GraphicsStateStack.current(context.graphicsStateStack); @@ -132,12 +137,40 @@ export const ColorSpaceOperators = { return values; } - return ok( - ColorSpaceOperators.update(context, target, { - color: Color.fromComponents(space, values.value, patternName.value), - space, - }), - ); + const pattern = patternName.value.some + ? (resources.patterns.get(patternName.value.value) ?? ok(none)) + : ok(none); + if (!pattern.ok) { + return pattern; + } + + const patternError = pattern.value.some + ? ColorOperation.patternError( + { colorSpace: space, operatorName }, + pattern.value.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), + space, + }); + return ok({ + ...next, + warnings: missingPattern + ? [ + { + code: "PATTERN_NOT_FOUND", + message: "Pattern resource not found", + }, + ] + : warnings, + }); }; }, diff --git a/packages/core/src/content-stream/operators/shading/sh/__tests__/sh.basic.test.ts b/packages/core/src/content-stream/operators/shading/sh/__tests__/sh.basic.test.ts new file mode 100644 index 00000000..9b904446 --- /dev/null +++ b/packages/core/src/content-stream/operators/shading/sh/__tests__/sh.basic.test.ts @@ -0,0 +1,76 @@ +import { assert, expect, test } from "vitest"; +import { GraphicsStateStack } from "../../../../graphics-state/index"; +import { ContentStreamInterpreter } from "../../../../interpreter/index"; +import { OperandStack } from "../../../../operand-stack/index"; +import { setupPaint } from "../../../../paint-resources/__tests__/setup-paint"; + +test("shはShadingの名前を解決し色と色空間を変更しない", async () => { + const { execute } = await setupPaint({ + resources: + "<< /Shading << /S1 << /ShadingType 2 /ColorSpace /DeviceRGB /Function 99 0 R >> >> >>", + }); + const result = execute(".25 g /S1 sh"); + assert(result.ok); + + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack) + .fillColor, + ).toEqual({ kind: "gray", g: 0.25 }); + expect(result.value.warnings).toEqual([]); + expect(OperandStack.depth(result.value.context.operandStack)).toBe(0); +}); + +test("既定レジストリにもshが登録され不在の名前は位置付き警告になる", () => { + const result = ContentStreamInterpreter.execute({ + data: new TextEncoder().encode("/Missing sh"), + }); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([ + { code: "SHADING_NOT_FOUND", offset: 9 }, + ]); +}); + +test.each([ + 0, 8, 99, +])("未知のShadingType %s はshの位置で警告し処理を継続する", async (type) => { + const { execute } = await setupPaint({ + resources: `<< /Shading << /S1 << /ShadingType ${type} /ColorSpace /DeviceRGB >> >> >>`, + }); + const result = execute("/S1 sh .75 g"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([ + { code: "SHADING_UNSUPPORTED", offset: 4 }, + ]); + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack) + .fillColor, + ).toEqual({ kind: "gray", g: 0.75 }); +}); + +test.each([ + "sh", + "/S1 /S2 sh", +])("shの名前の過不足を拒否する: %s", async (content) => { + const { execute } = await setupPaint({ resources: "<< >>" }); + const result = execute(content); + assert(!result.ok); + + expect(result.error).toMatchObject({ + code: "OPERATOR_OPERAND_COUNT_MISMATCH", + operatorName: "sh", + required: 1, + }); +}); + +test("shは数値オペランドを拒否する", async () => { + const { execute } = await setupPaint({ resources: "<< >>" }); + const result = execute("1 sh"); + assert(!result.ok); + + expect(result.error).toMatchObject({ + code: "OPERATOR_OPERAND_TYPE_MISMATCH", + operatorName: "sh", + }); +}); diff --git a/packages/core/src/content-stream/operators/shading/sh/index.ts b/packages/core/src/content-stream/operators/shading/sh/index.ts new file mode 100644 index 00000000..a7491f82 --- /dev/null +++ b/packages/core/src/content-stream/operators/shading/sh/index.ts @@ -0,0 +1,51 @@ +import { none } from "../../../../utils/option/index"; +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 { OperandExtractor } from "../../operand-extractor/index"; + +/** sh は名前から定義を取得する。描画と関数評価は後段が担う。 */ +export const ShOperator = { + /** + * 現在のスコープのシェーディングを使用するハンドラを作る。 + * @param resources - 解決済みの描画リソース + * @returns 個数・型の不一致と参照エラーは Err、不在・未知の定義は警告 + */ + create(resources: PaintResources = PaintResources.empty()): OperatorHandler { + return (context) => { + const actual = OperandStack.depth(context.operandStack); + if (actual !== 1) { + return err({ + code: "OPERATOR_OPERAND_COUNT_MISMATCH", + operatorName: "sh", + required: 1, + actual, + message: `Operator 'sh' requires 1 operand, got ${actual}`, + }); + } + + const name = OperandExtractor.popName(context.operandStack, "sh"); + if (!name.ok) { + return name; + } + + const shading = resources.shadings.get(name.value) ?? ok(none); + if (!shading.ok) { + return shading; + } + + return ok({ + ...context, + warnings: shading.value.some + ? shading.value.value.warnings + : [ + { + code: "SHADING_NOT_FOUND", + message: `Shading resource not found: /${name.value}`, + }, + ], + }); + }; + }, +} as const; diff --git a/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.error.test.ts b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.error.test.ts new file mode 100644 index 00000000..bc4ac279 --- /dev/null +++ b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.error.test.ts @@ -0,0 +1,112 @@ +import { assert, expect, test } from "vitest"; +import { setupResources } from "../../../document/resources/__tests__/setup-resources"; +import { PaintResources } from "../index"; +import { setupPaint, tilingPattern } from "./setup-paint"; + +test.each([ + "scn", + "SCN", +])("Pattern以外の色空間では%sの名前オペランドを拒否する", async (operator) => { + const { execute } = await setupPaint({ resources: "<< >>" }); + const selection = operator === "scn" ? "cs" : "CS"; + const result = execute(`/DeviceRGB ${selection} .1 .2 /P1 ${operator}`); + assert(!result.ok); + + expect(result.error).toMatchObject({ + code: "OPERATOR_OPERAND_TYPE_MISMATCH", + operatorName: operator, + }); +}); + +test.each([ + "Pattern", + "Shading", +])("/%s カテゴリの参照エラーは準備時に伝播する", async (category) => { + const { resources, resolveRef } = setupResources({ + resources: `<< /${category} 1 0 R >>`, + objects: ["<< /Broken"], + }); + const result = await PaintResources.fromResources(resources, resolveRef); + assert(!result.ok); + + expect(result.error).toMatchObject({ + code: "OBJECT_PARSE_UNEXPECTED_TOKEN", + }); +}); + +test.each([ + { category: "Pattern", content: "/Pattern cs /Bad scn" }, + { category: "Shading", content: "/Bad sh" }, +])("/$category の個別参照エラーは使用する命令へ伝播する", async ({ + category, + content, +}) => { + const { execute } = await setupPaint({ + resources: `<< /${category} << /Bad 1 0 R >> >>`, + objects: ["<< /Broken"], + }); + const unused = execute(".5 g"); + assert(unused.ok); + + const used = execute(content); + assert(!used.ok); + + expect(used.error).toMatchObject({ + code: "OBJECT_PARSE_UNEXPECTED_TOKEN", + }); +}); + +test.each([ + { definition: "/Pattern", operands: "/P1", paint: 2 }, + { definition: "[/Pattern /DeviceRGB]", operands: ".1 .2 .3 /P1", paint: 1 }, +])("着色方式とPattern基底色空間の不一致を拒否する: $definition", async ({ + definition, + operands, + paint, +}) => { + const { execute } = await setupPaint({ + resources: `<< /ColorSpace << /Pat ${definition} >> /Pattern << /P1 1 0 R >> >>`, + objects: [tilingPattern(`/PaintType ${paint}`)], + }); + const result = execute(`/Pat cs ${operands} scn`); + assert(!result.ok); + + expect(result.error).toMatchObject({ + code: "OPERATOR_ILLEGAL_STATE", + operatorName: "scn", + }); +}); + +test.each([ + { + resources: "<< /Pattern << /Bad 1 0 R >> >>", + objects: [tilingPattern("/Matrix [1 0 0 1 2 0 R 0]"), "<< /Broken"], + content: "/Pattern cs /Bad scn", + }, + { + resources: + "<< /Shading << /Bad << /ShadingType 2 /ColorSpace /DeviceRGB /BBox [0 0 1 0 R 10] >> >> >>", + objects: ["<< /Broken"], + content: "/Bad sh", + }, +])("主要属性の配列要素の参照エラーを使用位置へ伝播する: $content", async ({ + resources, + objects, + content, +}) => { + const { execute } = await setupPaint({ resources, objects }); + const result = execute(content); + assert(!result.ok); + + expect(result.error).toMatchObject({ code: "OBJECT_PARSE_UNEXPECTED_TOKEN" }); +}); + +test("参照先が存在せずnullへ解決された場合は不正なパターンとして警告する", async () => { + const { execute } = await setupPaint({ + resources: "<< /Pattern << /P1 99 0 R >> >>", + }); + const result = execute("/Pattern cs /P1 scn"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([{ code: "PATTERN_INVALID" }]); +}); diff --git a/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts new file mode 100644 index 00000000..4c3a006f --- /dev/null +++ b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts @@ -0,0 +1,85 @@ +import { assert, expect, test } from "vitest"; +import { GraphicsStateStack } from "../../graphics-state/index"; +import { setupPaint, tilingPattern } from "./setup-paint"; + +test.each([ + { selection: "cs", operator: "scn", target: "fillColor" }, + { selection: "CS", operator: "SCN", target: "strokeColor" }, +] as const)("$operator の名前から着色タイリングを取得できる", async ({ + selection, + operator, + target, +}) => { + const { execute } = await setupPaint({ + resources: "<< /Pattern << /P1 1 0 R >> >>", + objects: [tilingPattern()], + }); + const result = execute(`/Pattern ${selection} /P1 ${operator}`); + assert(result.ok); + + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack)[target], + ).toMatchObject({ pattern: { some: true, value: "P1" }, components: [] }); + expect(result.value.warnings).toEqual([]); +}); + +test("非着色タイリングは基底色の数値成分とパターン名を受け取る", async () => { + const { execute } = await setupPaint({ + resources: + "<< /ColorSpace << /Pat [/Pattern /DeviceRGB] >> /Pattern << /P1 1 0 R >> >>", + objects: [tilingPattern("/PaintType 2")], + }); + const result = execute("/Pat cs .1 .2 .3 /P1 scn"); + assert(result.ok); + + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack) + .fillColor, + ).toMatchObject({ + components: [0.1, 0.2, 0.3], + pattern: { some: true, value: "P1" }, + }); + expect(result.value.warnings).toEqual([]); +}); + +test("シェーディングパターンをscnから取得しFunctionを評価しない", async () => { + const { execute } = await setupPaint({ + resources: + "<< /Pattern << /P2 << /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceRGB /Function 99 0 R >> >> >> >>", + }); + const result = execute("/Pattern cs /P2 scn"); + assert(result.ok); + + expect(result.value.warnings).toEqual([]); +}); + +test("パターンの選択はq/Qによって保存・復元される", async () => { + const { execute } = await setupPaint({ + resources: "<< /Pattern << /P1 1 0 R /P2 2 0 R >> >>", + objects: [tilingPattern(), tilingPattern("/XStep 16")], + }); + const result = execute("/Pattern cs /P1 scn q /P2 scn Q"); + assert(result.ok); + + expect( + GraphicsStateStack.current(result.value.context.graphicsStateStack) + .fillColor, + ).toMatchObject({ pattern: { some: true, value: "P1" } }); + expect(result.value.warnings).toEqual([]); +}); + +test("別のページの同名パターンへフォールバックしない", async () => { + const defined = await setupPaint({ + resources: "<< /Pattern << /P1 1 0 R >> >>", + objects: [tilingPattern()], + }); + const empty = await setupPaint({ resources: "<< >>" }); + const first = defined.execute("/Pattern cs /P1 scn"); + const second = empty.execute("/Pattern cs /P1 scn"); + assert(first.ok && second.ok); + + expect(first.value.warnings).toEqual([]); + expect(second.value.warnings).toMatchObject([ + { code: "PATTERN_NOT_FOUND", offset: 16 }, + ]); +}); diff --git a/packages/core/src/content-stream/paint-resources/__tests__/setup-paint.ts b/packages/core/src/content-stream/paint-resources/__tests__/setup-paint.ts new file mode 100644 index 00000000..90e91d85 --- /dev/null +++ b/packages/core/src/content-stream/paint-resources/__tests__/setup-paint.ts @@ -0,0 +1,56 @@ +import { assert } from "vitest"; +import { setupResources } from "../../../document/resources/__tests__/setup-resources"; +import type { PdfError } from "../../../pdf/errors/index"; +import type { Result } from "../../../utils/result/index"; +import { ColorSpaceResources } from "../../color-space-resources/index"; +import type { ContentStreamInterpreterResult } from "../../interpreter/index"; +import { ContentStreamInterpreter } from "../../interpreter/index"; +import { OperatorRegistry } from "../../operator-registry/index"; +import { PaintResources } from "../index"; + +/** + * 実バイト列から現在の描画スコープとインタプリタを準備する。 + * @param source - リソース辞書と間接オブジェクト本体 + * @returns 解決済みリソースと、同じスコープで命令を実行する関数 + */ +export async function setupPaint(source: { + readonly resources: string; + readonly objects?: readonly string[]; +}): Promise<{ + readonly paints: PaintResources; + readonly execute: ( + content: string, + ) => Result; +}> { + const { resources, resolveRef } = setupResources(source); + const spaces = await ColorSpaceResources.fromResources(resources, resolveRef); + assert(spaces.ok); + + const paints = await PaintResources.fromResources(resources, resolveRef); + assert(paints.ok); + + const registry = OperatorRegistry.createDefault( + undefined, + spaces.value, + paints.value, + ); + assert(registry.ok); + + return { + paints: paints.value, + execute: (content: string) => + ContentStreamInterpreter.execute({ + data: new TextEncoder().encode(content), + registry: registry.value, + }), + }; +} + +/** + * 最小のタイリングセルをPDFストリームとして組み立てる。 + * @param entries - 既定の属性へ上書きする辞書エントリ + * @returns 間接オブジェクト本体へ置くストリーム + */ +export function tilingPattern(entries = ""): string { + return `<< /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 8 8] /XStep 8 /YStep 8 /Resources <<>> ${entries} /Length 0 >>\nstream\n\nendstream`; +} diff --git a/packages/core/src/content-stream/paint-resources/index.ts b/packages/core/src/content-stream/paint-resources/index.ts new file mode 100644 index 00000000..2058098f --- /dev/null +++ b/packages/core/src/content-stream/paint-resources/index.ts @@ -0,0 +1,105 @@ +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 { map, ok, type Result } from "../../utils/result/index"; + +/** 同期の色指定・描画命令から参照する、現在のスコープのパターンとシェーディング。 */ +export type PaintResources = { + readonly patterns: ReadonlyMap, PdfError>>; + readonly shadings: ReadonlyMap, PdfError>>; +}; + +export const PaintResources = { + /** @returns 描画リソースのないスコープ */ + empty(): PaintResources { + return { patterns: new Map(), shadings: new Map() }; + }, + + /** + * 同期実行前に /Pattern と /Shading の定義を解決する。 + * @param resources - 現在のページ・フォームのリソーススコープ + * @param resolveRef - 同じ文書の参照解決関数 + * @returns カテゴリの参照エラーは Err、個別定義のエラーは使用時まで保持する + */ + async fromResources( + resources: PdfResources, + resolveRef: ResolveRef, + ): Promise> { + const patterns = await PaintResources.readCategory( + resources, + { name: "Pattern", read: PdfPattern.fromObject }, + resolveRef, + ); + if (!patterns.ok) { + return patterns; + } + + const shadings = await PaintResources.readCategory( + resources, + { name: "Shading", read: PdfShading.fromObject }, + resolveRef, + ); + if (!shadings.ok) { + return shadings; + } + + return ok({ patterns: patterns.value, shadings: shadings.value }); + }, + + /** + * カテゴリの名前と解決結果を対応付ける。 + * @typeParam T - カテゴリ固有のパターンまたはシェーディング + * @param resources - 現在のリソーススコープ + * @param category - カテゴリ名と定義の読み取り関数 + * @param resolveRef - 同じ文書の参照解決関数 + * @returns 名前ごとの結果。不正な参照番号は None、参照エラーは保持する + */ + async readCategory( + resources: PdfResources, + category: { + readonly name: "Pattern" | "Shading"; + readonly read: ( + object: PdfObject, + resolveRef: ResolveRef, + ) => Promise>; + }, + resolveRef: ResolveRef, + ): Promise< + Result, PdfError>>, PdfError> + > { + const dictionary = await PdfResources.getCategory( + resources, + category.name, + 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/document/resources/parameters/__tests__/parameters.basic.test.ts b/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts new file mode 100644 index 00000000..f4e11350 --- /dev/null +++ b/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts @@ -0,0 +1,46 @@ +import { assert, expect, test } from "vitest"; +import { setupResources } from "../../__tests__/setup-resources"; +import { ResourceParameters } from "../index"; + +test.each([ + 4, 6, +] as const)("リソースの数値配列を%s個の座標へ絞り込む", async (size) => { + const values = Array.from({ length: size }, (_, i) => i + 1); + const { resources, resolveRef } = setupResources({ + resources: `<< /Value [${values.join(" ")}] >>`, + }); + const fields = await ResourceParameters.resolve( + resources.dictionary, + ["Value"], + resolveRef, + ); + assert(fields.ok); + + expect( + await ResourceParameters.coordinates( + fields.value.get("Value"), + size, + resolveRef, + ), + ).toEqual({ ok: true, value: { some: true, value: values } }); +}); + +test("数値でない配列要素は座標として受理しない", async () => { + const { resources, resolveRef } = setupResources({ + resources: "<< /Value [0 0 /Bad 4] >>", + }); + const fields = await ResourceParameters.resolve( + resources.dictionary, + ["Value"], + resolveRef, + ); + assert(fields.ok); + + expect( + await ResourceParameters.coordinates( + fields.value.get("Value"), + 4, + resolveRef, + ), + ).toEqual({ ok: true, value: { some: false } }); +}); diff --git a/packages/core/src/document/resources/parameters/index.ts b/packages/core/src/document/resources/parameters/index.ts new file mode 100644 index 00000000..67617e40 --- /dev/null +++ b/packages/core/src/document/resources/parameters/index.ts @@ -0,0 +1,116 @@ +import type { PdfError } from "../../../pdf/errors/index"; +import { + type PdfDictionary, + PdfObject, + ResolveRef, +} from "../../../pdf/types/index"; +import { none, type Option, some } from "../../../utils/option/index"; +import { ok, type Result } from "../../../utils/result/index"; + +export const CoordinateCounts = { Rectangle: 4, Matrix: 6 } as const; + +type Coordinates = { + readonly [CoordinateCounts.Rectangle]: readonly [ + number, + number, + number, + number, + ]; + readonly [CoordinateCounts.Matrix]: readonly [ + number, + number, + number, + number, + number, + number, + ]; +}; + +/** リソース辞書の直下の属性と、数値配列の要素を解決する。 */ +export const ResourceParameters = { + /** + * 指定した属性だけを解決し、辞書・ストリームの内部へは再帰しない。 + * @param dictionary - 属性を持つ辞書 + * @param keys - 解決するキー + * @param resolveRef - 同じ文書の参照解決関数 + * @returns 存在する属性の対応表。参照解決の失敗は元の Err + */ + async resolve( + dictionary: PdfDictionary, + keys: readonly string[], + resolveRef: ResolveRef, + ): Promise, PdfError>> { + const fields = new Map(); + for (const key of keys) { + const result = await ResolveRef.object( + dictionary.entries.get(key), + resolveRef, + ); + if (!result.ok) { + return result; + } + + if (result.value.some) { + fields.set(key, result.value.value); + } + } + + return ok(fields); + }, + + /** + * 配列の各要素を有限な数値へ変換する。 + * @param value - 解決済みの配列候補 + * @param resolveRef - 配列要素の参照解決関数 + * @returns 配列以外・数値以外は None、参照エラーは Err + */ + async numbers( + value: PdfObject | undefined, + resolveRef: ResolveRef, + ): Promise, PdfError>> { + if (value?.type !== "array") { + return ok(none); + } + + const numbers: number[] = []; + for (const element of value.elements) { + const result = await ResolveRef.object(element, resolveRef); + if (!result.ok) { + return result; + } + + const number = result.value.some + ? PdfObject.asFiniteNumber(result.value.value) + : none; + if (!number.some) { + return ok(none); + } + + numbers.push(number.value); + } + + return ok(some(numbers)); + }, + + /** + * 矩形の4座標または行列の6要素へ絞り込む。 + * @typeParam Size - 必要な要素数 + * @param value - 解決済みの配列候補 + * @param size - 必要な要素数 + * @param resolveRef - 配列要素の参照解決関数 + * @returns 個数・型が不正なら None、参照エラーは Err + */ + async coordinates( + value: PdfObject | undefined, + size: Size, + resolveRef: ResolveRef, + ): Promise, PdfError>> { + const result = await ResourceParameters.numbers(value, resolveRef); + if (!result.ok) { + return result; + } + + const valid = result.value.some && result.value.value.length === size; + return ok(valid ? some(result.value.value as Coordinates[Size]) : none); + }, +} as const; diff --git a/packages/core/src/document/resources/pattern/__tests__/pattern.basic.test.ts b/packages/core/src/document/resources/pattern/__tests__/pattern.basic.test.ts new file mode 100644 index 00000000..7a3eead2 --- /dev/null +++ b/packages/core/src/document/resources/pattern/__tests__/pattern.basic.test.ts @@ -0,0 +1,88 @@ +import { assert, expect, test } from "vitest"; +import { + setupPaint, + tilingPattern, +} from "../../../../content-stream/paint-resources/__tests__/setup-paint"; + +test("タイリングパターンはセルの種別・境界・間隔・リソースと未復号ストリームを保持する", async () => { + const { paints } = await setupPaint({ + resources: "<< /Pattern << /P1 1 0 R >> >>", + objects: [tilingPattern()], + }); + const result = paints.patterns.get("P1"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + patternType: 1, + paintType: 1, + tilingType: 1, + bbox: [0, 0, 8, 8], + xStep: 8, + yStep: 8, + matrix: [1, 0, 0, 1, 0, 0], + resources: { type: "dictionary" }, + stream: { type: "stream", data: new Uint8Array() }, + }); +}); + +test("タイリングの属性と座標要素が間接参照でも解決できる", async () => { + const { paints } = await setupPaint({ + resources: "<< /Pattern 1 0 R >>", + objects: [ + "<< /Tile 2 0 R >>", + tilingPattern( + "/PatternType 3 0 R /PaintType 4 0 R /TilingType 5 0 R /BBox 6 0 R /XStep 7 0 R /YStep 8 0 R /Resources 9 0 R /Matrix 10 0 R", + ), + "1", + "2", + "3", + "[0 0 11 0 R 12 0 R]", + "-12", + "10", + "<< /Font <<>> >>", + "[1 0 0 1 13 0 R 14 0 R]", + "12", + "10", + "3", + "4", + ], + }); + const result = paints.patterns.get("Tile"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + patternType: 1, + paintType: 2, + tilingType: 3, + bbox: [0, 0, 12, 10], + xStep: -12, + yStep: 10, + matrix: [1, 0, 0, 1, 3, 4], + }); +}); + +test("シェーディングパターンは間接参照のShadingとExtGStateを保持する", async () => { + const { paints } = await setupPaint({ + resources: "<< /Pattern << /P2 1 0 R >> >>", + objects: [ + "<< /PatternType 2 /Shading 2 0 R /ExtGState 3 0 R /Matrix [2 0 0 2 5 6] >>", + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Function 99 0 R >>", + "<< /ca .4 >>", + ], + }); + const result = paints.patterns.get("P2"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + patternType: 2, + shading: { + shadingType: 2, + function: { + some: true, + value: { type: "indirect-ref", objectNumber: 99, generationNumber: 0 }, + }, + }, + extGState: { some: true, value: { type: "dictionary" } }, + matrix: [2, 0, 0, 2, 5, 6], + }); +}); diff --git a/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts b/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts new file mode 100644 index 00000000..d44fd5ef --- /dev/null +++ b/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts @@ -0,0 +1,52 @@ +import { assert, expect, test } from "vitest"; +import { + setupPaint, + tilingPattern, +} from "../../../../content-stream/paint-resources/__tests__/setup-paint"; + +test.each([ + "/PaintType 3", + "/TilingType 0", + "/XStep 0", + "/YStep 0", + "/BBox [0 0 8]", + "/Matrix [1 0]", + "/Resources /Bad", +])("タイリングの不正な属性 %s は警告になる", async (entries) => { + const { execute } = await setupPaint({ + resources: "<< /Pattern << /P1 1 0 R >> >>", + objects: [tilingPattern(entries)], + }); + const result = execute("/Pattern cs /P1 scn"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([ + { code: "PATTERN_INVALID", offset: 16 }, + ]); +}); + +test.each([ + "<< /PatternType 42 >>", + "<< >>", +])("未知・欠落のPatternTypeは未対応の定義として保持する: %s", async (definition) => { + const { paints } = await setupPaint({ + resources: `<< /Pattern << /P1 ${definition} >> >>`, + }); + const result = paints.patterns.get("P1"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + patternType: "Unknown", + warnings: [{ code: "PATTERN_UNSUPPORTED" }], + }); +}); + +test("シェーディングパターンのShading欠落は不正な定義として警告する", async () => { + const { execute } = await setupPaint({ + resources: "<< /Pattern << /P2 << /PatternType 2 >> >> >>", + }); + const result = execute("/Pattern cs /P2 scn"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([{ code: "PATTERN_INVALID" }]); +}); diff --git a/packages/core/src/document/resources/pattern/__tests__/pattern.type.test-d.ts b/packages/core/src/document/resources/pattern/__tests__/pattern.type.test-d.ts new file mode 100644 index 00000000..cad6654d --- /dev/null +++ b/packages/core/src/document/resources/pattern/__tests__/pattern.type.test-d.ts @@ -0,0 +1,20 @@ +import { expectTypeOf, test } from "vitest"; +import type { + PdfPattern, + PdfPatternPaintType, + PdfPatternTilingType, +} from "../index"; + +test("タイリングとシェーディングは種別番号で異なる属性へ絞り込める", () => { + expectTypeOf< + Extract["paintType"] + >().toEqualTypeOf<1 | 2>(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf>().not.toHaveProperty( + "paintType", + ); + expectTypeOf["bbox"]>().toEqualTypeOf< + readonly [number, number, number, number] + >(); +}); diff --git a/packages/core/src/document/resources/pattern/index.ts b/packages/core/src/document/resources/pattern/index.ts new file mode 100644 index 00000000..8eef1e21 --- /dev/null +++ b/packages/core/src/document/resources/pattern/index.ts @@ -0,0 +1,297 @@ +import type { PdfError } from "../../../pdf/errors/index"; +import type { PdfWarning } from "../../../pdf/errors/warning/index"; +import { + type PdfDictionary, + PdfObject, + type PdfStream, + ResolveRef, +} from "../../../pdf/types/index"; +import { none, type Option, some } from "../../../utils/option/index"; +import { ok, type Result } from "../../../utils/result/index"; +import type { ValueOf } from "../../../utils/types/utility-type"; +import { CoordinateCounts, ResourceParameters } from "../parameters/index"; +import { PdfShading } from "../shading/index"; + +/** ISO 32000 §8.7 のパターン種別。 */ +export const PdfPatternTypes = { Tiling: 1, Shading: 2 } as const; +export type PdfPatternType = ValueOf; + +/** セル内で色を指定するか、呼び出し側から色を渡すか。 */ +export const PdfPatternPaintTypes = { Colored: 1, Uncolored: 2 } as const; +export type PdfPatternPaintType = ValueOf; + +/** セルの間隔と形状を画素へ合わせる方式。 */ +export const PdfPatternTilingTypes = { + ConstantSpacing: 1, + NoDistortion: 2, + Faster: 3, +} as const; +export type PdfPatternTilingType = ValueOf; + +type PatternMatrix = readonly [number, number, number, number, number, number]; + +/** タイリングはセル、シェーディングは色変化の定義を保持する。 */ +export type PdfPattern = + | { + readonly patternType: typeof PdfPatternTypes.Tiling; + readonly stream: PdfStream; + readonly paintType: PdfPatternPaintType; + readonly tilingType: PdfPatternTilingType; + readonly bbox: readonly [number, number, number, number]; + readonly xStep: number; + readonly yStep: number; + readonly resources: PdfDictionary; + readonly matrix: PatternMatrix; + readonly warnings: readonly PdfWarning[]; + } + | { + readonly patternType: typeof PdfPatternTypes.Shading; + readonly dictionary: PdfDictionary; + readonly shading: PdfShading; + readonly extGState: Option; + readonly matrix: PatternMatrix; + readonly warnings: readonly PdfWarning[]; + } + | { + readonly patternType: "Unknown"; + readonly object: PdfObject; + readonly warnings: readonly PdfWarning[]; + }; + +export const PdfPattern = { + /** + * パターンの種別と主要属性を間接参照も含めて読む。 + * @param object - /Pattern のローカル名から取得したオブジェクト + * @param resolveRef - 同じ文書の参照解決関数 + * @returns 不正・未知の定義は警告付き Unknown、参照エラーは Err + */ + async fromObject( + object: PdfObject, + resolveRef: ResolveRef, + ): Promise> { + const dictionary = object.type === "stream" ? object.dictionary : object; + if (dictionary.type !== "dictionary") { + return ok(PdfPattern.unresolved(object, "PATTERN_INVALID")); + } + + const type = await ResolveRef.object( + dictionary.entries.get("PatternType"), + resolveRef, + ); + if (!type.ok) { + return type; + } + + const patternType = + type.value.some && type.value.value.type === "integer" + ? type.value.value.value + : 0; + switch (patternType) { + case PdfPatternTypes.Tiling: + return PdfPattern.readTiling(object, resolveRef); + case PdfPatternTypes.Shading: + return PdfPattern.readShading(dictionary, resolveRef); + default: + return ok(PdfPattern.unresolved(object, "PATTERN_UNSUPPORTED")); + } + }, + + /** + * セルの属性を読み、必須値の揃ったタイリングパターンを作る。 + * @param object - パターンストリーム候補 + * @param resolveRef - 同じ文書の参照解決関数 + * @returns 型・個数・値域が不正なら Unknown、参照エラーは Err + */ + async readTiling( + object: PdfObject, + resolveRef: ResolveRef, + ): Promise> { + if (object.type !== "stream") { + return ok(PdfPattern.unresolved(object, "PATTERN_INVALID")); + } + + const fields = await ResourceParameters.resolve( + object.dictionary, + [ + "PaintType", + "TilingType", + "BBox", + "XStep", + "YStep", + "Resources", + "Matrix", + ], + resolveRef, + ); + if (!fields.ok) { + return fields; + } + + const bbox = await ResourceParameters.coordinates( + fields.value.get("BBox"), + CoordinateCounts.Rectangle, + resolveRef, + ); + if (!bbox.ok) { + return bbox; + } + + const matrix = await PdfPattern.readMatrix( + fields.value.get("Matrix"), + resolveRef, + ); + if (!matrix.ok) { + return matrix; + } + + const paint = fields.value.get("PaintType"); + const tiling = fields.value.get("TilingType"); + const paintType = + paint?.type === "integer" ? PdfPattern.paintType(paint.value) : none; + const tilingType = + tiling?.type === "integer" ? PdfPattern.tilingType(tiling.value) : none; + const x = fields.value.get("XStep"); + const y = fields.value.get("YStep"); + const xStep = x ? PdfObject.asFiniteNumber(x) : none; + const yStep = y ? PdfObject.asFiniteNumber(y) : none; + const resources = fields.value.get("Resources"); + const valid = + paintType.some && + tilingType.some && + bbox.value.some && + matrix.value.some && + xStep.some && + yStep.some && + xStep.value !== 0 && + yStep.value !== 0 && + resources?.type === "dictionary"; + if (!valid) { + return ok(PdfPattern.unresolved(object, "PATTERN_INVALID")); + } + + return ok({ + patternType: PdfPatternTypes.Tiling, + stream: object, + paintType: paintType.value, + tilingType: tilingType.value, + bbox: bbox.value.value, + xStep: xStep.value, + yStep: yStep.value, + resources, + matrix: matrix.value.value, + warnings: [], + }); + }, + + /** + * シェーディングと任意の拡張状態を取得する。 + * @param dictionary - 種別2のパターン辞書 + * @param resolveRef - 同じ文書の参照解決関数 + * @returns Shading が欠落・属性が不正なら Unknown、参照エラーは Err + */ + async readShading( + dictionary: PdfDictionary, + resolveRef: ResolveRef, + ): Promise> { + const fields = await ResourceParameters.resolve( + dictionary, + ["Shading", "ExtGState", "Matrix"], + resolveRef, + ); + if (!fields.ok) { + return fields; + } + + const matrix = await PdfPattern.readMatrix( + fields.value.get("Matrix"), + resolveRef, + ); + if (!matrix.ok) { + return matrix; + } + + const object = fields.value.get("Shading"); + const extended = fields.value.get("ExtGState"); + const invalidExtended = + extended !== undefined && extended.type !== "dictionary"; + const invalid = !object || !matrix.value.some || invalidExtended; + if (invalid) { + return ok(PdfPattern.unresolved(dictionary, "PATTERN_INVALID")); + } + + const shading = await PdfShading.fromObject(object, resolveRef); + if (!shading.ok) { + return shading; + } + + return ok({ + patternType: PdfPatternTypes.Shading, + dictionary, + shading: shading.value, + extGState: extended?.type === "dictionary" ? some(extended) : none, + matrix: matrix.value.value, + warnings: shading.value.warnings, + }); + }, + + /** + * Matrix の省略を単位行列へ変換する。 + * @param value - 解決済みの Matrix。省略時は undefined + * @param resolveRef - 配列要素の参照解決関数 + * @returns 省略時は単位行列、不正な配列は None、参照エラーは Err + */ + async readMatrix( + value: PdfObject | undefined, + resolveRef: ResolveRef, + ): Promise, PdfError>> { + return value === undefined + ? ok(some([1, 0, 0, 1, 0, 0])) + : ResourceParameters.coordinates( + value, + CoordinateCounts.Matrix, + resolveRef, + ); + }, + + /** + * @param value - PaintType の整数 + * @returns 1・2以外は None + */ + paintType(value: number): Option { + const valid = + value === PdfPatternPaintTypes.Colored || + value === PdfPatternPaintTypes.Uncolored; + return valid ? some(value) : none; + }, + + /** + * @param value - TilingType の整数 + * @returns 1・2・3以外は None + */ + tilingType(value: number): Option { + const valid = + value === PdfPatternTilingTypes.ConstantSpacing || + value === PdfPatternTilingTypes.NoDistortion || + value === PdfPatternTilingTypes.Faster; + return valid ? some(value) : none; + }, + + /** + * 未解釈のオブジェクトを警告とともに保持する。 + * @param object - 不正または未対応の定義 + * @param code - 不正と未対応の区別 + * @returns 元のオブジェクトを保持した Unknown + */ + unresolved( + object: PdfObject, + code: "PATTERN_INVALID" | "PATTERN_UNSUPPORTED", + ): PdfPattern { + return { + patternType: "Unknown", + object, + warnings: [ + { code, message: "Invalid or unsupported pattern definition" }, + ], + }; + }, +} as const; diff --git a/packages/core/src/document/resources/shading/__tests__/shading.basic.test.ts b/packages/core/src/document/resources/shading/__tests__/shading.basic.test.ts new file mode 100644 index 00000000..05037db3 --- /dev/null +++ b/packages/core/src/document/resources/shading/__tests__/shading.basic.test.ts @@ -0,0 +1,76 @@ +import { assert, expect, test } from "vitest"; +import { setupPaint } from "../../../../content-stream/paint-resources/__tests__/setup-paint"; + +test.each([ + 1, 2, 3, 4, 5, 6, 7, +])("ShadingType %s を判別しメッシュの未復号ストリームを保持する", async (shadingType) => { + const dictionary = `<< /ShadingType ${shadingType} /ColorSpace /DeviceRGB /Function 99 0 R /Length 0 >>`; + const object = + shadingType < 4 ? dictionary : `${dictionary}\nstream\n\nendstream`; + const { paints } = await setupPaint({ + resources: "<< /Shading << /S1 1 0 R >> >>", + objects: [object], + }); + const result = paints.shadings.get("S1"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + shadingType, + colorSpace: { type: "name", value: "DeviceRGB" }, + function: { some: true, value: { type: "indirect-ref", objectNumber: 99 } }, + }); + if (shadingType >= 4) { + expect(result.value.value).toMatchObject({ + stream: { data: new Uint8Array() }, + }); + } +}); + +test("シェーディングの色空間・背景色・境界と配列の各要素を間接参照から取得する", async () => { + const { paints } = await setupPaint({ + resources: "<< /Shading 1 0 R >>", + objects: [ + "<< /S1 2 0 R >>", + "<< /ShadingType 3 0 R /ColorSpace 4 0 R /Background 5 0 R /BBox 6 0 R /Function [99 0 R] >>", + "3", + "/DeviceRGB", + "[.1 7 0 R .3]", + "[0 0 8 0 R 200]", + ".2", + "100", + ], + }); + const result = paints.shadings.get("S1"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + shadingType: 3, + colorSpace: { type: "name", value: "DeviceRGB" }, + background: { some: true, value: [0.1, 0.2, 0.3] }, + bbox: { some: true, value: [0, 0, 100, 200] }, + function: { + some: true, + value: { + type: "array", + elements: [{ type: "indirect-ref", objectNumber: 99 }], + }, + }, + }); +}); + +test("背景色・境界・Functionの省略を不在として保持する", async () => { + const { paints } = await setupPaint({ + resources: + "<< /Shading << /S1 << /ShadingType 2 /ColorSpace [/CalRGB <<>>] >> >> >>", + }); + const result = paints.shadings.get("S1"); + assert(result?.ok && result.value.some); + + expect(result.value.value).toMatchObject({ + shadingType: 2, + colorSpace: { type: "array" }, + background: { some: false }, + bbox: { some: false }, + function: { some: false }, + }); +}); diff --git a/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts b/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts new file mode 100644 index 00000000..ee0b114f --- /dev/null +++ b/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts @@ -0,0 +1,31 @@ +import { assert, expect, test } from "vitest"; +import { setupPaint } from "../../../../content-stream/paint-resources/__tests__/setup-paint"; + +test.each([ + "/ColorSpace 42", + "/Background [.1 /Bad .3]", + "/BBox [0 0 10]", + "/BBox [0 0 10 /Bad]", +])("シェーディングの不正な共通属性は警告になる: %s", async (entries) => { + const { execute } = await setupPaint({ + resources: `<< /Shading << /S1 << /ShadingType 2 /ColorSpace /DeviceRGB ${entries} >> >> >>`, + }); + const result = execute("/S1 sh"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([ + { code: "SHADING_INVALID", offset: 4 }, + ]); +}); + +test.each([ + 4, 5, 6, 7, +])("メッシュ種別%sがストリームでない場合は警告する", async (shadingType) => { + const { execute } = await setupPaint({ + resources: `<< /Shading << /S1 << /ShadingType ${shadingType} /ColorSpace /DeviceRGB >> >> >>`, + }); + const result = execute("/S1 sh"); + assert(result.ok); + + expect(result.value.warnings).toMatchObject([{ code: "SHADING_INVALID" }]); +}); diff --git a/packages/core/src/document/resources/shading/__tests__/shading.type.test-d.ts b/packages/core/src/document/resources/shading/__tests__/shading.type.test-d.ts new file mode 100644 index 00000000..34a03791 --- /dev/null +++ b/packages/core/src/document/resources/shading/__tests__/shading.type.test-d.ts @@ -0,0 +1,11 @@ +import { expectTypeOf, test } from "vitest"; +import type { PdfStream } from "../../../../pdf/types/index"; +import type { PdfShading, PdfShadingType } from "../index"; + +test("ShadingTypeは1〜7に閉じメッシュの型にはストリームが必須になる", () => { + expectTypeOf().toEqualTypeOf<1 | 2 | 3 | 4 | 5 | 6 | 7>(); + expectTypeOf().not.toMatchTypeOf(); + expectTypeOf< + Extract["stream"] + >().toEqualTypeOf(); +}); diff --git a/packages/core/src/document/resources/shading/index.ts b/packages/core/src/document/resources/shading/index.ts new file mode 100644 index 00000000..bc480b81 --- /dev/null +++ b/packages/core/src/document/resources/shading/index.ts @@ -0,0 +1,197 @@ +import type { PdfError } from "../../../pdf/errors/index"; +import type { PdfWarning } from "../../../pdf/errors/warning/index"; +import { + type PdfArray, + type PdfDictionary, + type PdfName, + PdfObject, + type PdfStream, + type PdfValue, + ResolveRef, +} from "../../../pdf/types/index"; +import { + fromNullable, + none, + type Option, + some, +} from "../../../utils/option/index"; +import { ok, type Result } from "../../../utils/result/index"; +import type { ValueOf } from "../../../utils/types/utility-type"; +import { CoordinateCounts, ResourceParameters } from "../parameters/index"; + +/** ISO 32000 §8.7.4 のシェーディング種別。 */ +export const PdfShadingTypes = { + Function: 1, + Axial: 2, + Radial: 3, + FreeForm: 4, + Lattice: 5, + Coons: 6, + Tensor: 7, +} as const; + +export type PdfShadingType = ValueOf; + +type ShadingParameters = { + readonly dictionary: PdfDictionary; + readonly colorSpace: PdfName | PdfArray; + readonly background: Option; + readonly bbox: Option; + readonly function: Option; + readonly warnings: readonly PdfWarning[]; +}; + +/** 種別と共通属性。メッシュは未復号ストリームを必ず保持する。 */ +export type PdfShading = + | (ShadingParameters & { + readonly shadingType: + | typeof PdfShadingTypes.Function + | typeof PdfShadingTypes.Axial + | typeof PdfShadingTypes.Radial; + }) + | (ShadingParameters & { + readonly shadingType: + | typeof PdfShadingTypes.FreeForm + | typeof PdfShadingTypes.Lattice + | typeof PdfShadingTypes.Coons + | typeof PdfShadingTypes.Tensor; + readonly stream: PdfStream; + }) + | { + readonly shadingType: "Unknown"; + readonly object: PdfObject; + readonly warnings: readonly PdfWarning[]; + }; + +export const PdfShading = { + /** + * シェーディングの種別と属性を読む。Function は評価・参照解決しない。 + * @param object - 名前またはパターンから取得したオブジェクト + * @param resolveRef - 同じ文書の参照解決関数 + * @returns 不正・未知の定義は警告付き Unknown、参照エラーは Err + */ + async fromObject( + object: PdfObject, + resolveRef: ResolveRef, + ): Promise> { + const dictionary = object.type === "stream" ? object.dictionary : object; + if (dictionary.type !== "dictionary") { + return ok(PdfShading.unresolved(object, "SHADING_INVALID")); + } + + const type = await ResolveRef.object( + dictionary.entries.get("ShadingType"), + resolveRef, + ); + if (!type.ok) { + return type; + } + + const code = + type.value.some && type.value.value.type === "integer" + ? type.value.value.value + : 0; + const shadingType = PdfShading.typeFromNumber(code); + if (!shadingType.some) { + return ok(PdfShading.unresolved(object, "SHADING_UNSUPPORTED")); + } + + const fields = await ResourceParameters.resolve( + dictionary, + ["ColorSpace", "Background", "BBox"], + resolveRef, + ); + if (!fields.ok) { + return fields; + } + + const colorSpace = PdfObject.asNameOrArray(fields.value.get("ColorSpace")); + const background = await ResourceParameters.numbers( + fields.value.get("Background"), + resolveRef, + ); + if (!background.ok) { + return background; + } + + const bbox = await ResourceParameters.coordinates( + fields.value.get("BBox"), + CoordinateCounts.Rectangle, + resolveRef, + ); + if (!bbox.ok) { + return bbox; + } + + const invalidBackground = + fields.value.has("Background") && !background.value.some; + const invalidBBox = fields.value.has("BBox") && !bbox.value.some; + const invalid = !colorSpace.some || invalidBackground || invalidBBox; + if (invalid) { + return ok(PdfShading.unresolved(object, "SHADING_INVALID")); + } + + const parameters: ShadingParameters = { + dictionary, + colorSpace: colorSpace.value, + background: background.value, + bbox: bbox.value, + function: fromNullable(dictionary.entries.get("Function")), + warnings: [], + }; + switch (shadingType.value) { + case PdfShadingTypes.Function: + case PdfShadingTypes.Axial: + case PdfShadingTypes.Radial: + return ok({ ...parameters, shadingType: shadingType.value }); + case PdfShadingTypes.FreeForm: + case PdfShadingTypes.Lattice: + case PdfShadingTypes.Coons: + case PdfShadingTypes.Tensor: + return ok( + object.type === "stream" + ? { ...parameters, shadingType: shadingType.value, stream: object } + : PdfShading.unresolved(object, "SHADING_INVALID"), + ); + } + }, + + /** + * 仕様の種別番号へ絞り込む。 + * @param value - /ShadingType の整数 + * @returns 1〜7なら種別、それ以外は None + */ + typeFromNumber(value: number): Option { + switch (value) { + case PdfShadingTypes.Function: + case PdfShadingTypes.Axial: + case PdfShadingTypes.Radial: + case PdfShadingTypes.FreeForm: + case PdfShadingTypes.Lattice: + case PdfShadingTypes.Coons: + case PdfShadingTypes.Tensor: + return some(value); + default: + return none; + } + }, + + /** + * 未解釈のオブジェクトを警告とともに保持する。 + * @param object - 不正または未対応の定義 + * @param code - 不正と未対応の区別 + * @returns 元のオブジェクトを保持した Unknown + */ + unresolved( + object: PdfObject, + code: "SHADING_INVALID" | "SHADING_UNSUPPORTED", + ): PdfShading { + return { + shadingType: "Unknown", + object, + warnings: [ + { code, message: "Invalid or unsupported shading definition" }, + ], + }; + }, +} as const; diff --git a/packages/core/src/document/resources/xobject/index.ts b/packages/core/src/document/resources/xobject/index.ts index ba800468..6366fcc9 100644 --- a/packages/core/src/document/resources/xobject/index.ts +++ b/packages/core/src/document/resources/xobject/index.ts @@ -18,6 +18,7 @@ import { } from "../../../utils/option/index"; import { ok, type Result } from "../../../utils/result/index"; import { PdfRectangle } from "../../page-tree/resolved-page/index"; +import { ResourceParameters } from "../parameters/index"; const ComponentDepth = { One: 1, @@ -96,36 +97,6 @@ const FormKeys = ["BBox", "Matrix", "Resources"] as const; const RECTANGLE_SIZE = 4; const MATRIX_SIZE = 6; -/** - * 種別が使用する辞書エントリのみを1段解決する。子のリソース辞書は走査しない。 - * @param dictionary - 対象ストリームの辞書 - * @param keys - この種別で使用するPDFのキー - * @param resolveRef - 同じ文書の間接参照解決関数 - * @returns 存在する属性の対応表。参照先の解析に失敗した場合はそのエラー - */ -async function resolveFields( - dictionary: PdfDictionary, - keys: readonly string[], - resolveRef: ResolveRef, -): Promise, PdfError>> { - const fields = new Map(); - for (const key of keys) { - const result = await ResolveRef.object( - dictionary.entries.get(key), - resolveRef, - ); - - if (!result.ok) { - return result; - } - - if (result.value.some) { - fields.set(key, result.value.value); - } - } - return ok(fields); -} - const PdfImageXObject = { /** @param stream - 画像の元ストリーム * @param fields - 解決済み画像属性 @@ -285,7 +256,11 @@ export const PdfXObject = { } const keys = name === PdfXObjectSubtype.Image ? ImageKeys : FormKeys; - const fields = await resolveFields(stream.dictionary, keys, resolveRef); + const fields = await ResourceParameters.resolve( + stream.dictionary, + keys, + resolveRef, + ); if (!fields.ok) { return fields; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 515a3645..2b4845c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,21 @@ export { } from "./content-stream/graphics-state/blend-mode/index"; 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 { + PdfPattern, + type PdfPatternPaintType, + PdfPatternPaintTypes, + type PdfPatternTilingType, + PdfPatternTilingTypes, + type PdfPatternType, + PdfPatternTypes, +} from "./document/resources/pattern/index"; +export { + PdfShading, + type PdfShadingType, + PdfShadingTypes, +} from "./document/resources/shading/index"; /** * `@pdfmod/core` — PDF処理エンジン。 * ISO 32000-1:2008 (PDF 1.7) およびISO 32000-2:2020 (PDF 2.0) 準拠のPDF字句解析・構造解析を提供する。 diff --git a/packages/core/src/pdf/errors/warning/index.ts b/packages/core/src/pdf/errors/warning/index.ts index 3598e82f..dad79c9c 100644 --- a/packages/core/src/pdf/errors/warning/index.ts +++ b/packages/core/src/pdf/errors/warning/index.ts @@ -35,6 +35,12 @@ export type PdfWarningCode = | "COLOR_SPACE_NOT_FOUND" | "COLOR_SPACE_INVALID" | "COLOR_SPACE_UNSUPPORTED" + | "PATTERN_NOT_FOUND" + | "PATTERN_INVALID" + | "PATTERN_UNSUPPORTED" + | "SHADING_NOT_FOUND" + | "SHADING_INVALID" + | "SHADING_UNSUPPORTED" | "UNKNOWN_OPERATOR" | "CATALOG_VERSION_INVALID" | "UNBALANCED_RESTORE" From 941568650cc4dcdea1df8ced2b87d298872a89cd Mon Sep 17 00:00:00 2001 From: DIO0550 <47467473+DIO0550@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:03:00 +0900 Subject: [PATCH 2/4] fix(core): distinguish invalid resource references from omitted attributes --- .../__tests__/parameters.basic.test.ts | 14 ++++++++++++++ .../src/document/resources/parameters/index.ts | 16 ++++++++++------ .../pattern/__tests__/pattern.error.test.ts | 1 + .../shading/__tests__/shading.error.test.ts | 2 ++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts b/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts index f4e11350..90520e7f 100644 --- a/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts +++ b/packages/core/src/document/resources/parameters/__tests__/parameters.basic.test.ts @@ -44,3 +44,17 @@ test("数値でない配列要素は座標として受理しない", async () => ), ).toEqual({ ok: true, value: { some: false } }); }); + +test("不正な属性参照をPDFのnullとして保持し、属性の省略と区別する", async () => { + const { resources, resolveRef } = setupResources({ + resources: "<< /Matrix 0 0 R >>", + }); + const result = await ResourceParameters.resolve( + resources.dictionary, + ["Matrix", "BBox"], + resolveRef, + ); + assert(result.ok); + + expect([...result.value]).toEqual([["Matrix", { type: "null" }]]); +}); diff --git a/packages/core/src/document/resources/parameters/index.ts b/packages/core/src/document/resources/parameters/index.ts index 67617e40..225f9398 100644 --- a/packages/core/src/document/resources/parameters/index.ts +++ b/packages/core/src/document/resources/parameters/index.ts @@ -42,17 +42,21 @@ export const ResourceParameters = { ): Promise, PdfError>> { const fields = new Map(); for (const key of keys) { - const result = await ResolveRef.object( - dictionary.entries.get(key), - resolveRef, - ); + const value = dictionary.entries.get(key); + const result = await ResolveRef.object(value, resolveRef); if (!result.ok) { return result; } - if (result.value.some) { - fields.set(key, result.value.value); + if (!result.value.some) { + if (value !== undefined) { + fields.set(key, { type: "null" }); + } + + continue; } + + fields.set(key, result.value.value); } return ok(fields); diff --git a/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts b/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts index d44fd5ef..599cd315 100644 --- a/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts +++ b/packages/core/src/document/resources/pattern/__tests__/pattern.error.test.ts @@ -11,6 +11,7 @@ test.each([ "/YStep 0", "/BBox [0 0 8]", "/Matrix [1 0]", + "/Matrix 0 0 R", "/Resources /Bad", ])("タイリングの不正な属性 %s は警告になる", async (entries) => { const { execute } = await setupPaint({ diff --git a/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts b/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts index ee0b114f..2dd0c160 100644 --- a/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts +++ b/packages/core/src/document/resources/shading/__tests__/shading.error.test.ts @@ -6,6 +6,8 @@ test.each([ "/Background [.1 /Bad .3]", "/BBox [0 0 10]", "/BBox [0 0 10 /Bad]", + "/BBox 0 0 R", + "/Background 0 0 R", ])("シェーディングの不正な共通属性は警告になる: %s", async (entries) => { const { execute } = await setupPaint({ resources: `<< /Shading << /S1 << /ShadingType 2 /ColorSpace /DeviceRGB ${entries} >> >> >>`, From 6199a8cc3535548878dc43c84807f9046997cb89 Mon Sep 17 00:00:00 2001 From: DIO0550 <47467473+DIO0550@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:07:17 +0900 Subject: [PATCH 3/4] style(docs): remove trailing whitespace from pattern guide --- docs/guides/patterns-shadings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/patterns-shadings.html b/docs/guides/patterns-shadings.html index 0b34e0cb..b10559f6 100644 --- a/docs/guides/patterns-shadings.html +++ b/docs/guides/patterns-shadings.html @@ -152,7 +152,7 @@ [data-example-panel][hidden] { display: block !important; } a { color: inherit; } } - + .reading { font-size: 19px; } .reading pre, .reading table { font-size: .92em; } .reading th { font-size: .95em; } From 7c552357e86bf5f8d57573cb47f1a33516b05c55 Mon Sep 17 00:00:00 2001 From: DIO0550 <47467473+DIO0550@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:58:29 +0900 Subject: [PATCH 4/4] fix(core): include the pattern name in missing resource warnings --- .../src/content-stream/operators/color/selection/index.ts | 2 +- .../__tests__/paint-resources.integration.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 9a8d91db..7f3275c7 100644 --- a/packages/core/src/content-stream/operators/color/selection/index.ts +++ b/packages/core/src/content-stream/operators/color/selection/index.ts @@ -166,7 +166,7 @@ export const ColorSpaceOperators = { ? [ { code: "PATTERN_NOT_FOUND", - message: "Pattern resource not found", + message: `Pattern resource not found: /${patternName.value.value}`, }, ] : warnings, diff --git a/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts index 4c3a006f..61ec7541 100644 --- a/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts +++ b/packages/core/src/content-stream/paint-resources/__tests__/paint-resources.integration.test.ts @@ -80,6 +80,10 @@ test("別のページの同名パターンへフォールバックしない", as expect(first.value.warnings).toEqual([]); expect(second.value.warnings).toMatchObject([ - { code: "PATTERN_NOT_FOUND", offset: 16 }, + { + code: "PATTERN_NOT_FOUND", + message: "Pattern resource not found: /P1", + offset: 16, + }, ]); });