commit 855c7328df3d3c52fb821f81d786ba9c70fb36fd Author: Yuya KAMATAKI Date: Mon Sep 21 13:41:40 2026 +0900 initial commit diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..8291051 --- /dev/null +++ b/.clang-format @@ -0,0 +1,6 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 110 +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +BreakBeforeBraces: Attach diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9af5de --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Out-of-source builds and downloaded dependencies +/build/ +/build-*/ +/cmake-build-*/ +/out/ +/.deps/ + +# CMake / CTest files from accidental in-source builds +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +CTestTestfile.cmake +DartConfiguration.tcl +Testing/ +/Makefile +/build.ninja +/.ninja_deps +/.ninja_log +/compile_commands.json +/install_manifest*.txt +/CMakeUserPresets.json + +# Generated validation reports (keep tests/fixtures under version control) +/tests/results/ + +# Python caches and local virtual environments +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ + +# Local Qt Creator / IDE settings and editor temporary files +*.user +*.user.* +/.vs/ +/.idea/ +*.swp +*.swo +*~ + +# OS metadata +.DS_Store +Thumbs.db +Desktop.ini diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8daeb2b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,195 @@ +cmake_minimum_required(VERSION 3.24) +project(DocView VERSION 0.1.0 LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +include(CTest) +if(MSVC) + add_compile_options(/utf-8 /permissive- /Zc:__cplusplus) +endif() +if(WIN32 AND (NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8)) + message(FATAL_ERROR "DocView on Windows requires a 64-bit MSVC-compatible toolchain and matching Qt WebEngine.") +endif() + +find_package(Qt6 6.11.2 EXACT REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2 WebEngineQuick Xml LinguistTools) +if(WIN32) + find_package(libzip 1.11 CONFIG REQUIRED) + find_package(tomlplusplus 3.4 CONFIG REQUIRED) + set(DOCVIEW_ZIP_TARGET libzip::zip) + set(DOCVIEW_TOML_TARGET tomlplusplus::tomlplusplus) +else() + find_package(PkgConfig REQUIRED) + pkg_check_modules(LIBZIP REQUIRED IMPORTED_TARGET libzip>=1.11) + pkg_check_modules(TOML REQUIRED IMPORTED_TARGET tomlplusplus>=3.4) + pkg_check_modules(SECCOMP REQUIRED IMPORTED_TARGET libseccomp>=2.5) + pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig>=2.13) + set(DOCVIEW_ZIP_TARGET PkgConfig::LIBZIP) + set(DOCVIEW_TOML_TARGET PkgConfig::TOML) +endif() +include(cmake/Pdfium.cmake) +find_package(qpdf 12.4.1 EXACT CONFIG REQUIRED) + +add_library(docview_common + src/common/ipc.cpp src/common/contracts.cpp src/common/sandbox.cpp + src/common/worker_process.cpp src/common/session_storage.cpp src/common/single_instance.cpp + src/common/windows_sandbox.cpp src/common/windows_pipe.cpp src/common/windows_runtime_staging.cpp + src/common/worker_runtime.cpp + src/common/font_contract.cpp src/common/worker_font_client.cpp + src/web/resource_policy.cpp src/web/renderer_watchdog.cpp) +target_include_directories(docview_common PUBLIC src) +target_link_libraries(docview_common PUBLIC Qt6::Core Qt6::Network) +if(WIN32) + target_compile_definitions(docview_common PUBLIC _WIN32_WINNT=0x0A00 NOMINMAX WIN32_LEAN_AND_MEAN) + target_link_libraries(docview_common PRIVATE userenv advapi32 kernel32 shell32 ole32 psapi) +else() + target_link_libraries(docview_common PRIVATE PkgConfig::SECCOMP) +endif() + +add_library(docview_core src/core/config.cpp src/core/input.cpp src/core/state.cpp src/core/types.cpp + src/common/memory_pressure.cpp) +target_include_directories(docview_core PUBLIC src) +target_link_libraries(docview_core PUBLIC Qt6::Core ${DOCVIEW_TOML_TARGET}) +if(WIN32) + target_compile_definitions(docview_core PRIVATE _WIN32_WINNT=0x0A00 NOMINMAX WIN32_LEAN_AND_MEAN) + target_link_libraries(docview_core PRIVATE shell32 ole32) +endif() +add_library(docview_pdf src/pdf/pdf_document.cpp src/pdf/link_border_reader.cpp src/pdf/pdf_metadata_context.cpp src/pdf/structure_reader.cpp) +target_sources(docview_pdf PRIVATE src/pdf/system_font_adapter.cpp) +target_include_directories(docview_pdf PUBLIC src) +target_link_libraries(docview_pdf PUBLIC Qt6::Core PDFium::PDFium docview_common) +target_link_libraries(docview_pdf PRIVATE qpdf::libqpdf) +add_library(docview_font_broker src/broker/font_broker.cpp src/broker/font_backend.cpp) +target_include_directories(docview_font_broker PUBLIC src) +target_link_libraries(docview_font_broker PUBLIC docview_common Qt6::Core) +if(WIN32) + target_sources(docview_font_broker PRIVATE src/broker/font_backend_win.cpp) + target_link_libraries(docview_font_broker PRIVATE gdi32) +else() + target_sources(docview_font_broker PRIVATE src/broker/font_backend_linux.cpp) + target_link_libraries(docview_font_broker PRIVATE PkgConfig::FONTCONFIG) +endif() +add_executable(docview-pdf-worker src/pdf/main.cpp) +target_link_libraries(docview-pdf-worker PRIVATE docview_pdf docview_common) +add_library(docview_archive src/archive/archive.cpp) +target_include_directories(docview_archive PUBLIC src) +target_link_libraries(docview_archive PUBLIC Qt6::Core Qt6::Xml ${DOCVIEW_ZIP_TARGET}) +add_executable(docview-archive-worker src/archive/main.cpp) +target_link_libraries(docview-archive-worker PRIVATE docview_archive docview_common) + +set(DOCVIEW_SHELL_SOURCES src/app/controller.cpp src/app/pdf_canvas.cpp src/web/web_profile.cpp resources.qrc) +qt_add_executable(docview src/app/main.cpp src/app/benchmark.cpp src/app/translations.cpp ${DOCVIEW_SHELL_SOURCES}) +target_link_libraries(docview PRIVATE docview_core docview_common docview_font_broker Qt6::Gui Qt6::Quick Qt6::Qml Qt6::QuickControls2 Qt6::WebEngineQuick) +add_dependencies(docview docview-pdf-worker docview-archive-worker) +qt_add_translations(TARGETS docview + SOURCE_TARGETS docview docview_core docview_common docview_pdf docview_archive + docview_font_broker docview-pdf-worker docview-archive-worker + SOURCES qml/Main.qml qml/WebPane.qml + TS_FILES resources/i18n/docview_ja.ts + LUPDATE_OPTIONS -locations relative -no-obsolete + LUPDATE_TARGET docview_lupdate + LRELEASE_TARGET docview_lrelease + RESOURCE_PREFIX "/i18n") +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # Register candidate revalidation before any install rule copies a binary. + include(cmake/PdfiumSupplementalNotices.cmake) +endif() +if(UNIX) + set_target_properties(docview-pdf-worker PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") + install(FILES "${DOCVIEW_PDFIUM_LIBRARY}" DESTINATION lib) +endif() +install(TARGETS docview docview-pdf-worker docview-archive-worker RUNTIME DESTINATION bin) +install(FILES resources/config.example.toml DESTINATION share/doc/docview) +install(DIRECTORY resources/licenses/qpdf DESTINATION share/doc/docview/licenses) +install(FILES "${DOCVIEW_PDFIUM_ROOT}/LICENSE" DESTINATION share/doc/docview/pdfium) +install(DIRECTORY "${DOCVIEW_PDFIUM_ROOT}/licenses/" DESTINATION share/doc/docview/pdfium/licenses) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + install(FILES docs/LINUX-DEVELOPMENT-PACKAGE.md DESTINATION share/doc/docview) + install(FILES resources/licenses/README.md DESTINATION share/doc/docview RENAME THIRD-PARTY-SCOPE.md) + include(cmake/LinuxDevelopmentPackage.cmake) +endif() +if(WIN32) + include(cmake/WindowsDeployment.cmake) + foreach(worker docview-pdf-worker docview-archive-worker) + docview_configure_windows_worker(${worker}) + docview_install_windows_worker(${worker}) + endforeach() + docview_install_windows_gui(docview) +endif() + +if(BUILD_TESTING) + find_package(Qt6 REQUIRED COMPONENTS Test QuickTest) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + qt_add_executable(test_translations tests/test_translations.cpp src/app/translations.cpp) + target_link_libraries(test_translations PRIVATE docview_core Qt6::Test Qt6::Quick Qt6::Qml Qt6::QuickControls2) + qt_add_resources(test_translations test_translations_catalog + PREFIX "/i18n" BASE "${CMAKE_CURRENT_BINARY_DIR}" + FILES "${CMAKE_CURRENT_BINARY_DIR}/docview_ja.qm") + add_dependencies(test_translations docview_lrelease) + add_test(NAME translations COMMAND test_translations) + set_tests_properties(translations PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;LANG=C.UTF-8") + add_test(NAME runtime_manifest COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_runtime_manifest.py" -v) + add_test(NAME benchmark_runner COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_benchmark_runner.py" -v) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME linux_package COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_linux_package.py" -v) + add_test(NAME dependency_provenance COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_dependency_provenance_qt.py" -v) + add_test(NAME ubuntu_runtime_inventory COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ubuntu_validation_runtime.py" -v) + add_test(NAME pdfium_candidate_staging COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_stage_pdfium_candidate.py" -v) + add_test(NAME pdfium_candidate_integration COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pdfium_candidate_integration.py" -v) + endif() + foreach(name core archive pdf security instance renderer_watchdog worker_process runtime_staging font_contract font_broker system_font_adapter memory_pressure) + add_executable(test_${name} tests/test_${name}.cpp) + target_link_libraries(test_${name} PRIVATE docview_common Qt6::Test) + if(TARGET docview_${name}) + target_link_libraries(test_${name} PRIVATE docview_${name}) + endif() + add_test(NAME ${name} COMMAND test_${name}) + endforeach() + target_link_libraries(test_memory_pressure PRIVATE docview_core) + target_link_libraries(test_pdf PRIVATE docview_font_broker qpdf::libqpdf) + target_link_libraries(test_system_font_adapter PRIVATE docview_pdf) + add_executable(pdf-worker-evidence tests/render_pdf_worker.cpp) + target_link_libraries(pdf-worker-evidence PRIVATE docview_common docview_font_broker Qt6::Gui) + add_dependencies(pdf-worker-evidence docview-pdf-worker) + if(WIN32) + foreach(name windows_pipe windows_resources windows_worker) + add_executable(test_${name} tests/test_${name}.cpp) + target_link_libraries(test_${name} PRIVATE docview_common Qt6::Test) + add_test(NAME ${name} COMMAND test_${name}) + endforeach() + add_executable(docview-windows-worker-probe tests/windows_worker_probe.cpp) + target_link_libraries(docview-windows-worker-probe PRIVATE docview_common ws2_32 userenv advapi32 ole32) + docview_configure_windows_worker(docview-windows-worker-probe) + add_dependencies(test_windows_worker docview-windows-worker-probe) + set_tests_properties(windows_worker PROPERTIES TIMEOUT 120) + endif() + add_dependencies(test_pdf docview-pdf-worker) + add_dependencies(test_archive docview-archive-worker) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + # Exercises the actual 10/30/120-second timers without shortening the + # production budgets. Run explicitly, or opt in to the long CTest case. + add_executable(test_worker_deadlines tests/test_worker_deadlines.cpp) + target_link_libraries(test_worker_deadlines PRIVATE docview_common Qt6::Test) + option(DOCVIEW_ENABLE_LONG_TESTS "Register real-time worker watchdog tests" OFF) + if(DOCVIEW_ENABLE_LONG_TESTS) + add_test(NAME worker_deadlines COMMAND test_worker_deadlines) + set_tests_properties(worker_deadlines PROPERTIES TIMEOUT 150 LABELS "long") + endif() + endif() + add_executable(test_pdf_canvas tests/test_pdf_canvas.cpp src/app/pdf_canvas.cpp) + target_link_libraries(test_pdf_canvas PRIVATE docview_pdf docview_common docview_font_broker Qt6::Test Qt6::Quick Qt6::Gui) + add_dependencies(test_pdf_canvas docview-pdf-worker) + add_test(NAME pdf_canvas COMMAND test_pdf_canvas) + set_tests_properties(pdf_canvas PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_test(NAME pdf_canvas_hidpi COMMAND test_pdf_canvas annotationFlagsHitTargetsMatchRenderedPixels sceneGraphPresentsPdfPixelsAndReplacesOldPage) + set_tests_properties(pdf_canvas_hidpi PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_SCALE_FACTOR=2") + qt_add_executable(test_app tests/test_app.cpp ${DOCVIEW_SHELL_SOURCES}) + target_link_libraries(test_app PRIVATE docview_core docview_common docview_font_broker Qt6::Gui Qt6::Quick Qt6::Qml Qt6::QuickControls2 Qt6::WebEngineQuick Qt6::Test ${DOCVIEW_ZIP_TARGET}) + target_compile_definitions(test_app PRIVATE DOCVIEW_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") + add_dependencies(test_app docview-pdf-worker docview-archive-worker) + add_test(NAME app COMMAND test_app) + qt_add_executable(test_web_qml tests/test_web_qml.cpp src/web/web_profile.cpp resources.qrc) + target_link_libraries(test_web_qml PRIVATE docview_common Qt6::Gui Qt6::Quick Qt6::Qml Qt6::WebEngineQuick Qt6::QuickTest Qt6::Test) + add_test(NAME web COMMAND test_web_qml) + set_tests_properties(app web PROPERTIES TIMEOUT 90) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0641d6 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# DocView + +PDF、ローカルHTML、HTML ZIP、DRMなしのEPUB 2/3を読む、Qt Quick製の文書ビューアーです。日本語UIとVim風のキーボード操作を備え、PDF原本の編集・保存は行いません。 + +**動作を確認したのはLinux開発版です。Windows向けの起動・保護・配布コードは実装しましたが、Windowsでのコンパイルと実行、対象OSでの配布確認、基準機での性能受入は未完了です。** 実施範囲と残作業は [検証記録](docs/VALIDATION.md)、設計基準は [設計書](docs/design/00-guide.md) を参照してください。 + +[PDFiumの第2修正候補](tests/results/pdfium-intent-context/README.md)を専用ビルドとUbuntu検証用debへ組み込みました。ICC変換指定、パターンの状態継承、タイルの線色を修正し、Arch・Ubuntuで各972点の描画試験とDocView全24群、上流1,979試験が成功しました。PDF性能4条件と約1GB・5,000ページの負荷試験も成功しています。 + +比較用に残した元の依存版には[ICC変換指定による描画差](tests/results/cmyk-lut/README.md)があります。修正版の試験では以前のパターン期待値も訂正しています。生成資料の成功だけで色の互換性全体を保証するものではなく、人による描画承認を含む最終受入は未完了です。 + +## ビルドと起動 + +この作業環境の修正版は `./build-context/docview` で起動します。[修正版PDFiumの配置・再ビルド手順](docs/PDFIUM-CANDIDATE.md)を参照してください。以下の `.deps/pdfium` を使う手順は比較用に保持した元の依存版です。 + +確認済み環境はArch Linux x64(GCC 16.2.1)と専用Ubuntu 24.04仮想環境(GCC 13.3.0)、Qt 6.11.2です。C++20、CMake 3.24以上、Ninja、pkg-config(Archではpkgconf)、Qt Core/Gui/Network/Qml/Quick/QuickControls2/WebEngineQuick/Xml/LinguistTools、libzip 1.11以上、toml++ 3.4以上、qpdf 12.4.1、libseccomp 2.5以上、Fontconfig 2.13以上が必要です。Qt標準部品の日本語表示にはQtの日本語翻訳資源も必要です(Archのqt6-translations)。試験にはQt Test/QuickTestとXvfbを使います。PDFiumは固定版の非V8ビルドを取得します。 + +```sh +python3 cmake/fetch_pdfium.py +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build -j 6 +./build/docview "/path/to/book.pdf" +``` + +引数なしでも起動でき、`Ctrl+O`、ドラッグ&ドロップ、`:open "パス"`で文書を開けます。`--page 42`でPDFの実ページを指定できます。独自設定を使う場合は`--config /path/to/config.toml`を指定します。通常起動時は同一ユーザーの既存ウィンドウへ文書を引き渡します。 + +Linuxワーカーの隔離にはLandlock ABI 3以上、seccomp、利用可能なQt WebEngineのサンドボックスが必要です。隔離できない環境では文書処理を開始しません。`QTWEBENGINE_DISABLE_SANDBOX`、`--no-sandbox`などによる回避はサポートしません。 + +Ubuntuの公式Qt SDKを用いた[ビルド・検証手順](tests/ubuntu_vm/README.md)、[ローカル検証用debの手順](docs/UBUNTU-PACKAGE.md)、[URL比較修正後の試験記録](tests/results/portal/README.md)、[Qt告知補足版の検証](tests/results/qt-notice-supplement/README.md)も保存しています。 + +Windowsでのビルド手順、必要な依存物、未検証項目は [Windowsの準備手順](docs/WINDOWS-BUILD.md) にまとめています。 + +日本語を初期表示にし、翻訳可能な文言を[翻訳カタログ](resources/i18n/docview_ja.ts)へ分離しています。 +文言を変更したときは[更新手順](docs/TRANSLATIONS.md)に従ってカタログを更新します。 + +## 主な操作 + +| キー | 動作 | +|---|---| +| `j` / `k` / `h` / `l` | 下・上・左・右へスクロール | +| `Ctrl+D` / `Ctrl+U` | 半画面進む・戻る | +| `Ctrl+F` / `Ctrl+B` | 一画面進む・戻る | +| `gg` / `G` | 文書の先頭・末尾 | +| `42G` / `:page 42` | PDFの42ページ目へ移動 | +| `J` / `K` | 次・前のPDFページ | +| `]]` / `[[` | 次・前の見出し | +| `]c` / `[c` | 次・前のEPUB章 | +| `t` / `p` / `zs` | 目次・ページ一覧・ステータスの個別切替 | +| `Ctrl+W` → `w` | 本文・目次・ページ一覧の操作先を切替 | +| `Enter` / `Esc` | 項目・リンク・PDF注釈の確定 / 取消・本文へ戻る | +| `+` / `-` / `=` | 拡大・縮小・幅に合わせる | +| `:rotate 90` | PDFを90度回転 | +| `Tab` / `Shift+Tab` | 次・前のリンクを選択(PDFでは注釈も対象) | +| `/` / `n` / `N` | 本文検索・次・前の結果 | +| `Alt+Left` / `Alt+Right` | 移動履歴を戻る・進む | +| `?` / `:help` | 現在の設定に対応する操作一覧 | +| `:info` / `:reload` / `:q` | 文書情報・設定再読込・終了 | + +目次・ページ一覧では`j`/`k`で選択し、`Enter`で移動します。目次の`>`は現在読んでいる節、枠はキー操作で選択中の項目を示します。`h`/`l`は階層の折りたたみ・展開です。入力欄やダイアログでは閲覧キーを停止します。操作一覧・コマンド入力・ダイアログを閉じると元の操作先へ戻ります。元のパネルが非表示になった場合は本文へ戻り、文書の切替などで操作先が指定された場合はその指定を優先します。PDFのステータスにはページラベルと実ページ番号を表示します。 + +目次の準備中に`t`を押すと読み込み中の表示を開き、準備できた項目を表示します。PDFの構造見出しがない場合、文書内の移動先を持つ目次項目を見出し移動に使います。外部リンクや禁止されたアクションは代替候補に含めません。Webの選択リンクは`Esc`で解除できます。 + +HTML・EPUBで欠落した資源や遮断された参照がある場合は、`:info`で理由と検出件数を確認できます。同じ資源の再試行も件数に含まれます。URLや本文を警告履歴に保存しません。資源提供側の読込み失敗は、不在・アクセス拒否・破損・上限・保存や読取りの失敗などを理由別に区別します。 + +すべての形式でステータスに倍率を表示します。HTML・HTML ZIP・流込EPUBの倍率は25–500%です。固定レイアウトEPUBでは、ページ全体または幅に合わせた大きさを100%とし、倍率に「ページ合わせ」「幅合わせ」を添えます。`privacy.remember_position`が有効なら、読書位置と倍率を保存して同じ原本を開き直したときに復元します。固定EPUBは合わせ方も保存するため、ウィンドウの大きさが変わってもその基準で表示します。 + +ファイル選択や`:open`で読込みに失敗した場合は、対象名、原因の分類、利用者ができる対処を表示します。切替前の文書は表示を続け、`:info`で最後に開こうとした文書の失敗を確認できます。この情報は表示中の文書の警告と分けて扱い、ディスクには保存せず、次に文書を開く試行で更新します。 + +固定レイアウトEPUBは著者指定に応じた見開きに対応し、RTL、中央の単独ページ、項目別指定を反映します。複数入口のHTML ZIPで選んだページは、履歴または位置保存が有効なら、同じ原本を開き直したときに再利用します。`:history clear`でこの選択も消去できます。 + +PDF検索は既存の文字情報、HTML/ZIP検索は表示中のHTML、EPUB検索は表示中の章を対象にします。EPUBの`gg`/`G`は通常の読書順序に含まれる先頭章の先頭/最終章の末尾へ移動します。 + +## 設定と保存先 + +[設定例](resources/config.example.toml)を配置して編集し、`:reload`で再読込します。検証に失敗した設定は適用されず、現在の有効な設定が維持されます。既定のLinux保存先は次のとおりです。 + +| 用途 | パス | +|---|---| +| 設定 | `$XDG_CONFIG_HOME/docview/config.toml`(未指定時`~/.config/docview/config.toml`) | +| 読書状態 | `$XDG_STATE_HOME/docview/state.json`(未指定時`~/.local/state/docview/state.json`) | +| 一時資源 | `$XDG_CACHE_HOME/docview/sessions`(未指定時`~/.cache/docview/sessions`) | + +`privacy.remember_position`と`privacy.recent_documents`で位置・履歴の保存を制御します。`:history clear`は件数を確認して履歴と保存位置を消去します。本文引用の保存は既定で無効です。パスワードは保存しません。 + +開いた原本の識別情報を保存・復元時に再確認します。閲覧中に原本が変更・置換された場合、以前の文書の位置を新しい原本へ保存しません。保存済み位置との同一性を確認できない原本を明示的に開いた場合は、復元できないことを通知して先頭から表示します。 + +履歴を有効にしている場合、文書を開く前の画面に最近使った文書を表示します。矢印または`j`/`k`で選び、`Enter`で開けます。削除・変更・置換された原本は自動では開かず、ファイル選択から開き直すよう通知します。 + +単独HTMLは原則として同じフォルダー配下の資源を参照します。親フォルダーにある資源が必要な場合は`:root`で許可する文書フォルダーを明示します。文書のJavaScript、外部資源の自動取得、フォーム送信は無効です。利用者が選んだ外部リンクは宛先を確認して外部ブラウザーで開きます。 + +## 試験 + +試験資料はリポジトリー内の生成スクリプトから作成します。基本PDFの再生成にはqpdf、追加PDFの再生成にはReportLab、pypdf、Pillowと指定フォントが必要です。生成済み資料とハッシュも含めています。 + +```sh +python3 tests/fixtures/pdf/generate.py +python3 tests/generate_web_fixtures.py +QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software \ + QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + xvfb-run -a ctest --test-dir build --output-on-failure --output-junit tests.xml +``` + +通常のデスクトップセッションではXvfbを省略できます。試験でもアプリ内部の隔離は有効です。外側の実行環境がローカルソケットや名前空間を禁止する場合、その環境ではGUI・ワーカー試験を実施できません。 + +Linuxの実時間watchdog試験は`./build/test_worker_deadlines`で別途実行できます。PDFの30秒通知・120秒停止、展開の10秒通知・30秒停止、待機中の取消を検証します。[現行結果と再現手順](tests/results/completion-final/worker-deadlines/README.md)を参照してください。 + +```sh +python3 tests/compare_pdf_renderers.py +python3 tests/compare_pdf_extended.py +python3 tests/compare_pdf_fonts.py +python3 tests/compare_font_collection.py +python3 tests/compare_pdf_icc.py +QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software \ + QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + xvfb-run -a python3 tests/benchmark.py --operations 125 --cold-process-runs 30 +``` + +描画比較には独立したPopplerを使います。実TTCと個別TTFを比べる追加試験にはfontToolsとOFLフォントを使い、一時フォントは試験後に削除します。性能測定は新プロセス・空のアプリキャッシュで30回、同一プロセスの初回と再openを30回、同条件の29開閉、対応するページ・見出し・章系列を各125操作、継続scrollを240入力で記録します。PDFの125操作はページ移動101回と倍率変更24回です。OSファイルキャッシュは消去しません。Qtのフレーム通知は物理ディスプレイへの提示時刻ではなく、この開発機の結果で基準機・両OSの合格とはしません。 + +PDFの比較は本番の隔離ワーカーとフォント供給処理を経由します。非埋込日本語の横書き・縦書き資料は`tests/fixtures/pdf/generate_fonts.py`で、タグ付きPDFの境界資料は`tests/fixtures/pdf/generate_headings.py`で再生成できます。使用した代替フォントと制約は[PDFの検証記録](tests/PDF-VALIDATION.md)にまとめています。 + +500ページのPDF、500章のEPUB、400見出しのHTMLを使う測定も実行できます。これらは小さな画像を再利用した生成資料であり、1 GiBのスキャン資料ではありません。 + +```sh +python3 tests/generate_performance_corpus.py +QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software \ + QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ +xvfb-run -a python3 tests/benchmark.py --corpus standard --operations 125 --cold-process-runs 30 --output tests/results/performance-standard +``` + +長押し、連続する遠距離ジャンプ、リサイズ、文書切替、取消の計測は別に実行します。出力先は新規ディレクトリーを指定してください。通常のCTestではこの計測用slotだけを意図的にskipします。 + +```sh +QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software \ + QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + xvfb-run -a -s '-screen 0 1100x760x24' python3 tests/measure_interaction.py \ + --binary build/test_app --output tests/results/performance-interaction/new-run +``` + +約1GB・5,000ページの画像PDFも生成し、100操作と末尾への移動を検証しました。[負荷試験記録](tests/STRESS-PDF-VALIDATION.md)に数値と再現手順があります。大容量資料は試験後に自動削除します。 + +## ローカルインストール + +```sh +cmake --install build --prefix "$PWD/build/install" +./build/install/bin/docview +``` + +本体、ワーカー、PDFiumとそのライセンスを配置します。Qtとその他のライブラリはシステムの動的ライブラリを利用します。単体で配布できるパッケージではありません。依存関係は [DEPENDENCIES.md](docs/DEPENDENCIES.md)、追加形式の接続方法は [adapter-contract.md](docs/adapter-contract.md) を参照してください。 + +Arch Linux向けの開発用tar.gzと、依存物の版・ハッシュ・告知をまとめる手順は [ローカルパッケージ](docs/LINUX-DEVELOPMENT-PACKAGE.md) にあります。生成物は同じ開発環境のシステムライブラリーを必要とします。 + +Ubuntu 24.04 amd64向けには、修正版PDFiumとQt SDK等を同梱した[validation4 deb](docs/UBUNTU-PACKAGE.md)を作成しました。ビルド環境とSDKのない既存試験VMで、それぞれX11・Wayland合計12条件の起動、install/remove/purgeを確認しています。新規OSからの試験は旧validation1の履歴です。公開リリースの受入は未完了です。 diff --git a/cmake/InstallWindowsWorker.cmake b/cmake/InstallWindowsWorker.cmake new file mode 100644 index 0000000..3f5570b --- /dev/null +++ b/cmake/InstallWindowsWorker.cmake @@ -0,0 +1,95 @@ +# Verify the complete inventory before installation and again at the destination. +include("${CMAKE_CURRENT_LIST_DIR}/WriteWindowsRuntimeManifest.cmake") + +function(docview_verify_windows_worker executable output_files) + docview_runtime_file_info("${executable}" EXE worker_name worker_size) + get_filename_component(worker_directory "${executable}" DIRECTORY) + set(manifest "${executable}.runtime.json") + if(NOT EXISTS "${manifest}" OR IS_SYMLINK "${manifest}" OR IS_DIRECTORY "${manifest}") + message(FATAL_ERROR "Build the worker runtime manifest before installing: ${manifest}") + endif() + file(SIZE "${manifest}" manifest_size) + if(manifest_size LESS 1 OR manifest_size GREATER 131072) + message(FATAL_ERROR "Worker runtime manifest exceeds its size limit or is empty") + endif() + file(READ "${manifest}" inventory) + string(JSON root_type TYPE "${inventory}") + string(JSON fields LENGTH "${inventory}") + string(JSON version_type TYPE "${inventory}" schemaVersion) + string(JSON executable_type TYPE "${inventory}" executable) + string(JSON files_type TYPE "${inventory}" files) + string(JSON version GET "${inventory}" schemaVersion) + string(JSON declared_executable GET "${inventory}" executable) + string(JSON count LENGTH "${inventory}" files) + if(NOT root_type STREQUAL "OBJECT" OR NOT fields EQUAL 3 OR + NOT version_type STREQUAL "NUMBER" OR NOT version STREQUAL "1" OR + NOT executable_type STREQUAL "STRING" OR NOT declared_executable STREQUAL worker_name OR + NOT files_type STREQUAL "ARRAY" OR count LESS 1 OR count GREATER 128) + message(FATAL_ERROR "Invalid worker runtime inventory schema") + endif() + math(EXPR last "${count} - 1") + set(files) + set(names) + set(total 0) + set(found_executable FALSE) + foreach(index RANGE 0 ${last}) + string(JSON entry_type TYPE "${inventory}" files ${index}) + string(JSON fields LENGTH "${inventory}" files ${index}) + string(JSON name_type TYPE "${inventory}" files ${index} path) + string(JSON hash_type TYPE "${inventory}" files ${index} sha256) + string(JSON size_type TYPE "${inventory}" files ${index} size) + string(JSON name GET "${inventory}" files ${index} path) + string(JSON expected_hash GET "${inventory}" files ${index} sha256) + string(JSON expected_size GET "${inventory}" files ${index} size) + string(LENGTH "${expected_hash}" hash_length) + if(NOT entry_type STREQUAL "OBJECT" OR NOT fields EQUAL 3 OR + NOT name_type STREQUAL "STRING" OR NOT hash_type STREQUAL "STRING" OR + NOT size_type STREQUAL "NUMBER" OR NOT expected_size MATCHES "^[1-9][0-9]*$" OR + NOT hash_length EQUAL 64 OR NOT expected_hash MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "Invalid worker runtime file record") + endif() + if(name STREQUAL worker_name) + set(kind EXE) + set(found_executable TRUE) + else() + set(kind DLL) + endif() + get_filename_component(basename "${name}" NAME) + if(NOT name STREQUAL basename) + message(FATAL_ERROR "Runtime manifest contains a non-basename path") + endif() + set(path "${worker_directory}/${name}") + docview_runtime_file_info("${path}" "${kind}" checked_name size) + string(TOLOWER "${name}" folded) + if(folded IN_LIST names) + message(FATAL_ERROR "Runtime manifest has a duplicate name") + endif() + list(APPEND names "${folded}") + math(EXPR total "${total} + ${size}") + if(NOT size EQUAL expected_size OR total GREATER 536870912) + message(FATAL_ERROR "Runtime file size no longer matches its build inventory: ${name}") + endif() + file(SHA256 "${path}" actual_hash) + if(NOT actual_hash STREQUAL expected_hash) + message(FATAL_ERROR "Runtime file no longer matches its build inventory: ${name}") + endif() + list(APPEND files "${path}") + endforeach() + if(NOT found_executable) + message(FATAL_ERROR "Runtime manifest is missing its worker executable") + endif() + set(${output_files} "${files}" PARENT_SCOPE) +endfunction() + +docview_verify_windows_worker("${WORKER_EXECUTABLE}" verified_files) +if(NOT WORKER_VERIFY_ONLY) + if(NOT IS_ABSOLUTE "${WORKER_INSTALL_DIRECTORY}") + message(FATAL_ERROR "An absolute worker installation directory is required") + endif() + # file(INSTALL) applies DESTDIR itself. Its input must retain the unstaged + # install prefix; native tools and the verification below need the real path. + file(INSTALL DESTINATION "${WORKER_INSTALL_DIRECTORY}" TYPE FILE + FILES ${verified_files} "${WORKER_EXECUTABLE}.runtime.json") + get_filename_component(worker_name "${WORKER_EXECUTABLE}" NAME) + docview_verify_windows_worker("$ENV{DESTDIR}${WORKER_INSTALL_DIRECTORY}/${worker_name}" installed_files) +endif() diff --git a/cmake/LinuxDevelopmentPackage.cmake b/cmake/LinuxDevelopmentPackage.cmake new file mode 100644 index 0000000..a19b364 --- /dev/null +++ b/cmake/LinuxDevelopmentPackage.cmake @@ -0,0 +1,16 @@ +# Local Arch development packaging is explicit; normal builds do not package. +include_guard(GLOBAL) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + return() +endif() +find_package(Python3 3.11 COMPONENTS Interpreter QUIET) +if(Python3_Interpreter_FOUND) + set(DOCVIEW_LINUX_PACKAGE_OUTPUT "${CMAKE_BINARY_DIR}/linux-development-package" CACHE PATH + "New output directory for the local Arch development package") + add_custom_target(linux-development-package + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/tools/package_linux_development.py" + --build-dir "${CMAKE_BINARY_DIR}" --output "${DOCVIEW_LINUX_PACKAGE_OUTPUT}" + DEPENDS docview docview-pdf-worker docview-archive-worker + USES_TERMINAL VERBATIM + COMMENT "Packaging the local Arch development build with dependency notices") +endif() diff --git a/cmake/Pdfium.cmake b/cmake/Pdfium.cmake new file mode 100644 index 0000000..e83d140 --- /dev/null +++ b/cmake/Pdfium.cmake @@ -0,0 +1,10 @@ +# The non-V8 build never executes PDF JavaScript or dynamic XFA. +set(DOCVIEW_PDFIUM_ROOT "${CMAKE_SOURCE_DIR}/.deps/pdfium" CACHE PATH "Pinned PDFium installation") +find_path(DOCVIEW_PDFIUM_INCLUDE_DIR fpdfview.h HINTS "${DOCVIEW_PDFIUM_ROOT}/include" REQUIRED) +find_library(DOCVIEW_PDFIUM_LIBRARY NAMES pdfium pdfium.dll HINTS "${DOCVIEW_PDFIUM_ROOT}/lib" REQUIRED) +add_library(PDFium::PDFium SHARED IMPORTED GLOBAL) +set_target_properties(PDFium::PDFium PROPERTIES IMPORTED_LOCATION "${DOCVIEW_PDFIUM_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${DOCVIEW_PDFIUM_INCLUDE_DIR}") +if(WIN32) + find_file(DOCVIEW_PDFIUM_RUNTIME pdfium.dll HINTS "${DOCVIEW_PDFIUM_ROOT}/bin" NO_DEFAULT_PATH REQUIRED) + set_target_properties(PDFium::PDFium PROPERTIES IMPORTED_IMPLIB "${DOCVIEW_PDFIUM_LIBRARY}" IMPORTED_LOCATION "${DOCVIEW_PDFIUM_RUNTIME}") +endif() diff --git a/cmake/PdfiumSupplementalNotices.cmake b/cmake/PdfiumSupplementalNotices.cmake new file mode 100644 index 0000000..f128b49 --- /dev/null +++ b/cmake/PdfiumSupplementalNotices.cmake @@ -0,0 +1,83 @@ +# Extra notices tied to the exact inspected Linux PDFium library and source pin. +# These are install data; no application code or PDF rendering option changes. +set(_notice_root "${CMAKE_CURRENT_SOURCE_DIR}/resources/licenses/pdfium-supplemental") +file(READ "${_notice_root}/sources.json" _notice_manifest) +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/cmake/pdfium.lock.json" _pdfium_lock) +foreach(_pair "pdfiumVersion;version" "pdfiumUpstreamCommit;upstreamCommit") + list(GET _pair 0 _notice_key) + list(GET _pair 1 _lock_key) + string(JSON _notice_value GET "${_notice_manifest}" "${_notice_key}") + string(JSON _lock_value GET "${_pdfium_lock}" "${_lock_key}") + if(NOT _notice_value STREQUAL _lock_value) + message(FATAL_ERROR "PDFium supplemental notices need review for this source pin") + endif() +endforeach() +string(JSON _notice_binary GET "${_notice_manifest}" pdfiumLibrarySha256) +file(SHA256 "${DOCVIEW_PDFIUM_LIBRARY}" _actual_binary) +if(NOT _notice_binary STREQUAL _actual_binary OR EXISTS "${DOCVIEW_PDFIUM_ROOT}/BUILDINFO.json") + # Only the separately reviewed v2 tuple may differ from the original + # provider. There is deliberately no user-supplied trust/hash override. + find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) + set(_candidate_verifier "${CMAKE_CURRENT_SOURCE_DIR}/tools/verify_pdfium_candidate.py") + set(_candidate_notice "${CMAKE_CURRENT_BINARY_DIR}/pdfium-candidate-notices/sources.json") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${_candidate_verifier}" + --prefix "${DOCVIEW_PDFIUM_ROOT}" --library "${DOCVIEW_PDFIUM_LIBRARY}" + --include-dir "${DOCVIEW_PDFIUM_INCLUDE_DIR}" --notice-output "${_candidate_notice}" + RESULT_VARIABLE _candidate_result OUTPUT_VARIABLE _candidate_record ERROR_VARIABLE _candidate_error) + if(NOT _candidate_result EQUAL 0) + message(FATAL_ERROR "PDFium supplemental notices need review for this binary: ${_candidate_error}") + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${DOCVIEW_PDFIUM_ROOT}/BUILDINFO.json" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/pdfium-candidate-v2.lock.json" + "${_candidate_verifier}") + # Recheck at install as well: configure success is not permission to install + # a prefix whose library, headers, or correspondence subsequently changed. + install(CODE " + execute_process(COMMAND \"${Python3_EXECUTABLE}\" \"${_candidate_verifier}\" + --prefix \"${DOCVIEW_PDFIUM_ROOT}\" --library \"${DOCVIEW_PDFIUM_LIBRARY}\" + --include-dir \"${DOCVIEW_PDFIUM_INCLUDE_DIR}\" --notice-output \"${_candidate_notice}\" + RESULT_VARIABLE candidate_result OUTPUT_QUIET ERROR_VARIABLE candidate_error) + if(NOT candidate_result EQUAL 0) + message(FATAL_ERROR \"PDFium candidate changed before install: \${candidate_error}\") + endif()") + string(JSON _candidate_files LENGTH "${_candidate_record}" installFiles) + math(EXPR _candidate_last "${_candidate_files} - 1") + foreach(_index RANGE 0 ${_candidate_last}) + string(JSON _source GET "${_candidate_record}" installFiles ${_index} source) + string(JSON _destination GET "${_candidate_record}" installFiles ${_index} destination) + get_filename_component(_directory "${_destination}" DIRECTORY) + install(FILES "${DOCVIEW_PDFIUM_ROOT}/${_source}" DESTINATION "${_directory}") + endforeach() + install(FILES "${_candidate_notice}" DESTINATION share/doc/docview/pdfium/supplemental) + install(FILES "${DOCVIEW_PDFIUM_ROOT}/licenses/libcxx-LICENSE.txt" + "${DOCVIEW_PDFIUM_ROOT}/licenses/libcxxabi-LICENSE.txt" + DESTINATION share/doc/docview/pdfium/supplemental) + return() +endif() +string(JSON _notice_count LENGTH "${_notice_manifest}" files) +string(JSON _notice_schema GET "${_notice_manifest}" schemaVersion) +if(NOT _notice_schema EQUAL 1) + message(FATAL_ERROR "Unsupported PDFium supplemental notice schema") +endif() +if(NOT _notice_count EQUAL 2) + message(FATAL_ERROR "Expected the two audited PDFium supplemental notices") +endif() +set(_notice_names) +foreach(_index RANGE 0 1) + string(JSON _name GET "${_notice_manifest}" files ${_index} name) + string(JSON _hash GET "${_notice_manifest}" files ${_index} sha256) + if(NOT _name MATCHES "^(libcxx|libcxxabi)-LICENSE[.]txt$") + message(FATAL_ERROR "Unexpected PDFium supplemental notice filename") + endif() + if(_name IN_LIST _notice_names) + message(FATAL_ERROR "Duplicate PDFium supplemental notice filename") + endif() + list(APPEND _notice_names "${_name}") + file(SHA256 "${_notice_root}/${_name}" _actual) + if(NOT _actual STREQUAL _hash) + message(FATAL_ERROR "PDFium supplemental notice digest mismatch") + endif() + install(FILES "${_notice_root}/${_name}" DESTINATION share/doc/docview/pdfium/supplemental) +endforeach() +install(FILES "${_notice_root}/sources.json" DESTINATION share/doc/docview/pdfium/supplemental) diff --git a/cmake/StageWindowsWorker.cmake b/cmake/StageWindowsWorker.cmake new file mode 100644 index 0000000..068af44 --- /dev/null +++ b/cmake/StageWindowsWorker.cmake @@ -0,0 +1,255 @@ +# Native script/include inputs: WORKER_EXECUTABLE, RUNTIME_LIBRARIES, +# SEARCH_DIRECTORIES and EXTRA_RUNTIME_LIBRARIES (explicit MSVC redistributables). +# No input is resolved through PATH. CMake's PE scanner searches the importing +# binary's directory, OS directories and the explicit DIRECTORIES list. +cmake_minimum_required(VERSION 3.24) +include("${CMAKE_CURRENT_LIST_DIR}/WriteWindowsRuntimeManifest.cmake") + +function(docview_runtime_actual_path input output) + if(NOT IS_ABSOLUTE "${input}" OR NOT EXISTS "${input}" OR IS_SYMLINK "${input}") + message(FATAL_ERROR "Invalid worker runtime source: ${input}") + endif() + set(actual "${input}") + if(WIN32) + # CMake before 3.27 lowercases DLL result names. Recover on-disk casing + # before comparing an imported target against the scanner's result. + get_filename_component(parent "${input}" DIRECTORY) + get_filename_component(name "${input}" NAME) + string(TOLOWER "${name}" wanted) + file(GLOB siblings LIST_DIRECTORIES FALSE "${parent}/*") + set(matches) + foreach(sibling IN LISTS siblings) + get_filename_component(candidate "${sibling}" NAME) + string(TOLOWER "${candidate}" folded) + if(folded STREQUAL wanted) + list(APPEND matches "${sibling}") + endif() + endforeach() + list(LENGTH matches count) + if(NOT count EQUAL 1) + message(FATAL_ERROR "Ambiguous case-insensitive runtime source: ${input}") + endif() + list(GET matches 0 actual) + endif() + set(${output} "${actual}" PARENT_SCOPE) +endfunction() + +# Pure package-copy step is independently testable with synthetic MZ fixtures. +# It does not claim that these files passed the native PE dependency scanner. +function(docview_stage_runtime_files executable sources) + docview_runtime_file_info("${executable}" EXE executable_name executable_size) + get_filename_component(directory "${executable}" DIRECTORY) + file(LOCK "${directory}/.docview-worker-runtime.lock" GUARD FUNCTION TIMEOUT 60 RESULT_VARIABLE locked) + if(NOT locked STREQUAL "0") + message(FATAL_ERROR "Cannot lock the worker runtime package directory: ${locked}") + endif() + set(names) + set(paths) + set(digests) + set(sizes) + set(total "${executable_size}") + foreach(input IN LISTS sources) + docview_runtime_actual_path("${input}" source) + docview_runtime_file_info("${source}" DLL name size) + string(TOLOWER "${name}" key) + list(FIND names "${key}" existing) + file(SHA256 "${source}" digest) + if(NOT existing EQUAL -1) + list(GET paths ${existing} previous) + get_filename_component(previous_name "${previous}" NAME) + list(GET digests ${existing} previous_digest) + if(NOT name STREQUAL previous_name OR NOT digest STREQUAL previous_digest) + message(FATAL_ERROR "Conflicting worker runtime DLL sources: ${previous};${source}") + endif() + continue() + endif() + list(APPEND names "${key}") + list(APPEND paths "${source}") + list(APPEND digests "${digest}") + list(APPEND sizes "${size}") + list(LENGTH names count) + math(EXPR total "${total} + ${size}") + if(count GREATER 127 OR total GREATER 536870912) + message(FATAL_ERROR "Worker runtime exceeds 128 files or 512 MiB") + endif() + endforeach() + # Inspect existing names case-insensitively, including on case-sensitive + # build hosts. Never overwrite a different DLL shared by another worker. + file(GLOB existing_entries LIST_DIRECTORIES TRUE "${directory}/*") + set(runtime_files "${executable}") + list(LENGTH paths count) + if(count GREATER 0) + math(EXPR last "${count} - 1") + foreach(index RANGE 0 ${last}) + list(GET paths ${index} source) + list(GET names ${index} key) + list(GET digests ${index} digest) + list(GET sizes ${index} size) + get_filename_component(name "${source}" NAME) + set(destination "${directory}/${name}") + foreach(entry IN LISTS existing_entries) + get_filename_component(existing_name "${entry}" NAME) + string(TOLOWER "${existing_name}" existing_key) + if(existing_key STREQUAL key AND NOT existing_name STREQUAL name) + message(FATAL_ERROR "Case-insensitive runtime destination collision: ${name}") + endif() + endforeach() + if(EXISTS "${destination}" OR IS_SYMLINK "${destination}") + docview_runtime_file_info("${destination}" DLL old_name old_size) + file(SHA256 "${destination}" old_digest) + if(NOT old_size EQUAL size OR NOT old_digest STREQUAL digest) + message(FATAL_ERROR "Refusing to replace a different runtime DLL: ${destination}") + endif() + else() + string(RANDOM LENGTH 24 ALPHABET 0123456789abcdef nonce) + set(temporary "${destination}.${nonce}.tmp") + if(EXISTS "${temporary}" OR IS_SYMLINK "${temporary}") + message(FATAL_ERROR "Runtime copy temporary path already exists") + endif() + file(COPY_FILE "${source}" "${temporary}" RESULT copied) + if(NOT copied STREQUAL "0") + file(REMOVE "${temporary}") + message(FATAL_ERROR "Cannot copy runtime DLL: ${copied}") + endif() + file(SHA256 "${temporary}" copied_digest) + file(SIZE "${temporary}" copied_size) + if(NOT copied_digest STREQUAL digest OR NOT copied_size EQUAL size) + file(REMOVE "${temporary}") + message(FATAL_ERROR "Runtime DLL changed while copying: ${source}") + endif() + file(RENAME "${temporary}" "${destination}" NO_REPLACE RESULT renamed) + if(NOT renamed STREQUAL "0") + file(REMOVE "${temporary}") + message(FATAL_ERROR "Cannot publish runtime DLL: ${renamed}") + endif() + # Older CMake on POSIX can publish NO_REPLACE with a hard link + # while leaving the source name behind. Remove only our own + # temporary name after successful publication, preserving the + # destination and NO_REPLACE's collision protection. + file(REMOVE "${temporary}") + if(EXISTS "${temporary}" OR IS_SYMLINK "${temporary}") + message(FATAL_ERROR "Cannot remove the runtime copy temporary path") + endif() + endif() + list(APPEND runtime_files "${destination}") + endforeach() + endif() + docview_write_windows_runtime_manifest("${executable}" "${runtime_files}") +endfunction() + +function(docview_regex_case_path path output) + string(REPLACE "\\" "/" normalized "${path}") + string(LENGTH "${normalized}" length) + math(EXPR last "${length} - 1") + set(regex) + set(metacharacters "." "[" "]" "(" ")" "+" "*" "?" "^" "$" "|" "{" "}") + foreach(index RANGE 0 ${last}) + string(SUBSTRING "${normalized}" ${index} 1 character) + if(character MATCHES "[A-Za-z]") + string(TOUPPER "${character}" upper) + string(TOLOWER "${character}" lower) + string(APPEND regex "[${upper}${lower}]") + elseif(character IN_LIST metacharacters) + string(APPEND regex "\\${character}") + else() + string(APPEND regex "${character}") + endif() + endforeach() + set(${output} "${regex}" PARENT_SCOPE) +endfunction() + +function(docview_is_windows_os_runtime path system_root output) + string(REPLACE "\\" "/" normalized "${path}") + string(REPLACE "\\" "/" root "${system_root}") + string(REGEX REPLACE "/+$" "" root "${root}") + string(TOLOWER "${normalized}" normalized) + string(TOLOWER "${root}/" root) + string(FIND "${normalized}" "${root}" prefix) + set(result FALSE) + if(prefix EQUAL 0) + string(LENGTH "${root}" length) + string(SUBSTRING "${normalized}" ${length} -1 relative) + if(relative MATCHES "^(system32|syswow64)/" OR relative MATCHES "^[^/]+[.]dll$") + set(result TRUE) + endif() + endif() + set(${output} "${result}" PARENT_SCOPE) +endfunction() + +function(docview_stage_windows_worker) + if(NOT CMAKE_HOST_WIN32) + message(FATAL_ERROR "Native Windows PE runtime dependency scanning requires a Windows host") + endif() + docview_runtime_file_info("${WORKER_EXECUTABLE}" EXE executable_name executable_size) + file(TO_CMAKE_PATH "$ENV{SystemRoot}" system_root) + if(NOT IS_ABSOLUTE "${system_root}" OR NOT IS_DIRECTORY "${system_root}/System32") + message(FATAL_ERROR "Cannot locate the Windows OS runtime directories") + endif() + docview_regex_case_path("${system_root}" os_regex) + set(os_exclusions "^${os_regex}/[Ss][Yy][Ss][Tt][Ee][Mm]32/" + "^${os_regex}/[Ss][Yy][Ss][Ww][Oo][Ww]64/" + "^${os_regex}/[^/]+[.][Dd][Ll][Ll]$") + set(explicit_libraries) + set(directories ${SEARCH_DIRECTORIES}) + foreach(input IN LISTS RUNTIME_LIBRARIES EXTRA_RUNTIME_LIBRARIES) + docview_runtime_actual_path("${input}" source) + docview_runtime_file_info("${source}" DLL name size) + string(TOLOWER "${name}" folded) + if(folded MATCHES "^(api|ext)-ms-win-.*[.]dll$") + message(FATAL_ERROR "API-set DLLs belong to Windows and must not be copied: ${name}") + endif() + docview_is_windows_os_runtime("${source}" "${system_root}" os_file) + if(os_file) + message(FATAL_ERROR "Do not package DLLs from Windows OS directories: ${source}") + endif() + list(APPEND explicit_libraries "${source}") + get_filename_component(parent "${source}" DIRECTORY) + list(APPEND directories "${parent}") + endforeach() + list(REMOVE_DUPLICATES explicit_libraries) + list(REMOVE_DUPLICATES directories) + foreach(directory IN LISTS directories) + if(NOT IS_ABSOLUTE "${directory}" OR NOT IS_DIRECTORY "${directory}") + message(FATAL_ERROR "DLL search directories must be explicit existing absolute paths: ${directory}") + endif() + endforeach() + set(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM "windows+pe") + if(DEFINED CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND AND + (NOT IS_ABSOLUTE "${CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND}" OR + NOT EXISTS "${CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND}")) + message(FATAL_ERROR "The PE inspection tool must have an existing absolute path") + endif() + file(GET_RUNTIME_DEPENDENCIES + EXECUTABLES "${WORKER_EXECUTABLE}" + LIBRARIES ${explicit_libraries} + DIRECTORIES ${directories} + RESOLVED_DEPENDENCIES_VAR resolved + UNRESOLVED_DEPENDENCIES_VAR unresolved + CONFLICTING_DEPENDENCIES_PREFIX conflicting + PRE_EXCLUDE_REGEXES "^api-ms-win-.*[.]dll$" "^ext-ms-win-.*[.]dll$" + POST_EXCLUDE_REGEXES ${os_exclusions}) + if(unresolved) + message(FATAL_ERROR "Unresolved worker runtime DLL dependencies: ${unresolved}") + endif() + if(conflicting_FILENAMES) + message(FATAL_ERROR "Conflicting worker runtime DLL dependencies: ${conflicting_FILENAMES}") + endif() + set(package_dependencies) + foreach(dependency IN LISTS resolved) + docview_runtime_actual_path("${dependency}" dependency) + docview_is_windows_os_runtime("${dependency}" "${system_root}" os_file) + get_filename_component(name "${dependency}" NAME) + string(TOLOWER "${name}" folded) + if(NOT os_file AND NOT folded MATCHES "^(api|ext)-ms-win-.*[.]dll$") + list(APPEND package_dependencies "${dependency}") + endif() + endforeach() + # Explicit redistributables are included even if a matching import resolved + # to a Windows OS DLL that the scanner correctly excluded. + set(package_sources ${explicit_libraries} ${package_dependencies}) + docview_stage_runtime_files("${WORKER_EXECUTABLE}" "${package_sources}") +endfunction() + +if(DEFINED WORKER_EXECUTABLE) + docview_stage_windows_worker() +endif() diff --git a/cmake/WindowsDeployment.cmake b/cmake/WindowsDeployment.cmake new file mode 100644 index 0000000..ddb4b78 --- /dev/null +++ b/cmake/WindowsDeployment.cmake @@ -0,0 +1,61 @@ +# Windows SDK tools and matching Qt binaries are required on the build host. +include_guard(GLOBAL) +if(NOT WIN32) + return() +endif() +set(DOCVIEW_EXTRA_WORKER_RUNTIME_DLLS "" CACHE STRING + "Explicit additional redistributable DLLs approved for worker packaging") +find_program(DOCVIEW_DUMPBIN_EXECUTABLE NAMES dumpbin REQUIRED) +find_program(DOCVIEW_WINDEPLOYQT_EXECUTABLE NAMES windeployqt + HINTS "${Qt6_DIR}/../../../bin" REQUIRED) + +function(docview_configure_windows_worker target) + set(script "${CMAKE_CURRENT_BINARY_DIR}/stage-${target}-$.cmake") + file(GENERATE OUTPUT "${script}" CONTENT " +set(WORKER_EXECUTABLE [==[$]==]) +set(RUNTIME_LIBRARIES [==[$]==]) +set(SEARCH_DIRECTORIES [==[$;$;$;${DOCVIEW_PDFIUM_ROOT}/bin]==]) +set(EXTRA_RUNTIME_LIBRARIES [==[${DOCVIEW_EXTRA_WORKER_RUNTIME_DLLS}]==]) +set(CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL dumpbin) +set(CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND [==[${DOCVIEW_DUMPBIN_EXECUTABLE}]==]) +include([==[${CMAKE_CURRENT_FUNCTION_LIST_DIR}/StageWindowsWorker.cmake]==]) +") + add_custom_command(TARGET ${target} POST_BUILD + COMMAND "${CMAKE_COMMAND}" -P "${script}" VERBATIM + COMMENT "Collecting and hashing the restricted runtime for ${target}") +endfunction() + +function(docview_install_windows_worker target) + set(script "${CMAKE_CURRENT_BINARY_DIR}/install-${target}-$.cmake") + file(GENERATE OUTPUT "${script}" CONTENT " +set(WORKER_EXECUTABLE [==[$]==]) +set(WORKER_INSTALL_DIRECTORY \"\${CMAKE_INSTALL_PREFIX}/bin\") +set(WORKER_VERIFY_ONLY FALSE) +include([==[${CMAKE_CURRENT_FUNCTION_LIST_DIR}/InstallWindowsWorker.cmake]==]) +") + install(SCRIPT "${script}") +endfunction() + +function(docview_install_windows_gui target) + set(script "${CMAKE_CURRENT_BINARY_DIR}/deploy-${target}-$.cmake") + file(GENERATE OUTPUT "${script}" CONTENT " +set(destination \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/bin\") +execute_process(COMMAND [==[${DOCVIEW_WINDEPLOYQT_EXECUTABLE}]==] + --$,debug,release> --no-compiler-runtime --nopatchqt + --dir \"\${destination}\" --plugindir \"\${destination}/plugins\" + --qml-deploy-dir \"\${destination}/qml\" --translationdir \"\${destination}/translations\" + --qmldir [==[${CMAKE_SOURCE_DIR}/qml]==] \"\${destination}/$\" + RESULT_VARIABLE deployed) +if(NOT deployed EQUAL 0) + message(FATAL_ERROR \"windeployqt failed: \${deployed}\") +endif() +file(WRITE \"\${destination}/qt.conf\" \"[Paths]\\nPrefix=.\\nPlugins=plugins\\nQmlImports=qml\\nLibraryExecutables=.\\nData=.\\nTranslations=translations\\n\") +# windeployqt must not patch or replace any hashed worker dependency. +set(WORKER_VERIFY_ONLY TRUE) +foreach(name docview-pdf-worker.exe docview-archive-worker.exe) + set(WORKER_EXECUTABLE \"\${destination}/\${name}\") + include([==[${CMAKE_CURRENT_FUNCTION_LIST_DIR}/InstallWindowsWorker.cmake]==]) +endforeach() +") + install(SCRIPT "${script}") +endfunction() diff --git a/cmake/WriteWindowsRuntimeManifest.cmake b/cmake/WriteWindowsRuntimeManifest.cmake new file mode 100644 index 0000000..e348b86 --- /dev/null +++ b/cmake/WriteWindowsRuntimeManifest.cmake @@ -0,0 +1,123 @@ +# Script API: -DWORKER_EXECUTABLE= -DRUNTIME_FILES= +# Include API: docview_write_windows_runtime_manifest(executable "${files}"). +# This writer validates the package inventory, not the PE import table. The +# native dependency scanner is StageWindowsWorker.cmake. +cmake_minimum_required(VERSION 3.24) +include_guard(GLOBAL) + +function(docview_runtime_file_info path kind out_name out_size) + if(NOT IS_ABSOLUTE "${path}" OR NOT EXISTS "${path}" OR IS_DIRECTORY "${path}" OR IS_SYMLINK "${path}") + message(FATAL_ERROR "Worker runtime requires an absolute regular file: ${path}") + endif() + get_filename_component(name "${path}" NAME) + string(LENGTH "${name}" length) + string(TOLOWER "${name}" folded) + if(length GREATER 128 OR NOT name MATCHES "^[A-Za-z0-9][A-Za-z0-9._-]*$" OR + name MATCHES "[.]$" OR folded MATCHES "^(con|prn|aux|nul|com[1-9]|lpt[1-9])([.]|$)") + message(FATAL_ERROR "Invalid Windows runtime filename: ${name}") + endif() + if((kind STREQUAL "EXE" AND NOT folded MATCHES "[.]exe$") OR + (kind STREQUAL "DLL" AND NOT folded MATCHES "[.]dll$")) + message(FATAL_ERROR "Only the worker executable and DLLs may enter its runtime: ${name}") + endif() + file(SIZE "${path}" size) + if(size LESS 2 OR size GREATER 268435456) + message(FATAL_ERROR "Worker runtime file exceeds the 256 MiB limit or is empty: ${name}") + endif() + file(READ "${path}" signature LIMIT 2 HEX) + if(NOT signature STREQUAL "4d5a") + message(FATAL_ERROR "Worker runtime file has no MZ signature: ${name}") + endif() + set(${out_name} "${name}" PARENT_SCOPE) + set(${out_size} "${size}" PARENT_SCOPE) +endfunction() + +function(docview_write_windows_runtime_manifest executable files) + docview_runtime_file_info("${executable}" EXE executable_name executable_size) + file(REAL_PATH "${executable}" executable_real) + get_filename_component(directory "${executable_real}" DIRECTORY) + if(WIN32) + string(TOLOWER "${directory}" directory) + endif() + list(LENGTH files count) + if(count LESS 1 OR count GREATER 128) + message(FATAL_ERROR "Worker runtime inventory must contain 1..128 files") + endif() + set(seen) + set(total 0) + set(has_executable FALSE) + # Check every limit before hashing potentially large files or changing an + # existing manifest. File order does not affect the serialized inventory. + foreach(path IN LISTS files) + get_filename_component(name "${path}" NAME) + if(name STREQUAL executable_name) + set(kind EXE) + else() + set(kind DLL) + endif() + docview_runtime_file_info("${path}" "${kind}" name size) + file(REAL_PATH "${path}" real) + get_filename_component(parent "${real}" DIRECTORY) + if(WIN32) + string(TOLOWER "${parent}" parent) + endif() + if(NOT parent STREQUAL directory) + message(FATAL_ERROR "Manifest entries must already be beside the worker: ${name}") + endif() + string(TOLOWER "${name}" key) + if(key IN_LIST seen) + message(FATAL_ERROR "Case-insensitive duplicate worker runtime filename: ${name}") + endif() + list(APPEND seen "${key}") + if(name STREQUAL executable_name) + if(NOT real STREQUAL executable_real) + message(FATAL_ERROR "Manifest executable does not match its worker") + endif() + set(has_executable TRUE) + endif() + math(EXPR total "${total} + ${size}") + if(total GREATER 536870912) + message(FATAL_ERROR "Worker runtime exceeds the 512 MiB aggregate limit") + endif() + endforeach() + if(NOT has_executable) + message(FATAL_ERROR "Worker runtime manifest must include its executable") + endif() + list(SORT files CASE INSENSITIVE) + set(entries) + foreach(path IN LISTS files) + get_filename_component(name "${path}" NAME) + file(SIZE "${path}" size) + file(SHA256 "${path}" digest) + string(TOLOWER "${digest}" digest) + list(APPEND entries " {\"path\":\"${name}\",\"sha256\":\"${digest}\",\"size\":${size}}") + endforeach() + list(JOIN entries ",\n" entries_json) + set(json "{\n \"schemaVersion\":1,\n \"executable\":\"${executable_name}\",\n \"files\":[\n${entries_json}\n ]\n}\n") + string(LENGTH "${json}" json_size) + if(json_size GREATER 131072) + message(FATAL_ERROR "Worker runtime manifest exceeds its 128 KiB limit") + endif() + set(output "${executable}.runtime.json") + if(IS_SYMLINK "${output}" OR IS_DIRECTORY "${output}") + message(FATAL_ERROR "Refusing a non-regular worker runtime manifest destination") + endif() + string(RANDOM LENGTH 24 ALPHABET 0123456789abcdef nonce) + set(temporary "${output}.${nonce}.tmp") + if(EXISTS "${temporary}" OR IS_SYMLINK "${temporary}") + message(FATAL_ERROR "Worker runtime manifest temporary path already exists") + endif() + file(WRITE "${temporary}" "${json}") + file(RENAME "${temporary}" "${output}" RESULT renamed) + if(NOT renamed STREQUAL "0") + file(REMOVE "${temporary}") + message(FATAL_ERROR "Cannot atomically publish worker runtime manifest: ${renamed}") + endif() +endfunction() + +if(CMAKE_SCRIPT_MODE_FILE STREQUAL CMAKE_CURRENT_LIST_FILE) + if(NOT DEFINED WORKER_EXECUTABLE OR NOT DEFINED RUNTIME_FILES) + message(FATAL_ERROR "WORKER_EXECUTABLE and RUNTIME_FILES are required") + endif() + docview_write_windows_runtime_manifest("${WORKER_EXECUTABLE}" "${RUNTIME_FILES}") +endif() diff --git a/cmake/fetch_pdfium.py b/cmake/fetch_pdfium.py new file mode 100644 index 0000000..631bcfa --- /dev/null +++ b/cmake/fetch_pdfium.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Fetch the hash-pinned non-V8 PDFium dependency for Linux or Windows x64.""" +from pathlib import Path +import argparse +import hashlib +import json +import platform +import tarfile +import tempfile +import urllib.request + +root = Path(__file__).resolve().parent.parent +lock = json.loads((root / 'cmake/pdfium.lock.json').read_text()) +host = {'Linux': 'linux-x64', 'Windows': 'windows-x64'}.get(platform.system()) +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--platform', choices=['linux-x64', 'windows-x64'], default=host) +parser.add_argument('--destination', type=Path) +args = parser.parse_args() +if args.platform is None or (args.platform == host and platform.machine() not in ('x86_64', 'AMD64')): + raise SystemExit('A supported x64 platform is required; specify --platform for a cross-platform download.') +prefix = 'windowsX64' if args.platform == 'windows-x64' else 'linuxX64' +destination = args.destination or root / '.deps' / ('pdfium' if args.platform == host else 'pdfium-' + args.platform.split('-')[0]) +with tempfile.TemporaryDirectory(prefix='docview-pdfium-') as temporary: + archive = Path(temporary) / 'pdfium.tgz' + urllib.request.urlretrieve(lock[prefix + 'Archive'], archive) + if hashlib.sha256(archive.read_bytes()).hexdigest() != lock[prefix + 'Sha256']: + raise SystemExit('PDFium archive hash mismatch; dependency not installed.') + destination.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive) as bundle: + bundle.extractall(destination, filter='data') +print(f'Installed pinned PDFium {lock["version"]} in {destination}') diff --git a/cmake/patches/pdfium-rendering-intent-context.patch b/cmake/patches/pdfium-rendering-intent-context.patch new file mode 100644 index 0000000..87acb33 --- /dev/null +++ b/cmake/patches/pdfium-rendering-intent-context.patch @@ -0,0 +1,2900 @@ +diff --git a/core/fpdfapi/font/BUILD.gn b/core/fpdfapi/font/BUILD.gn +--- a/core/fpdfapi/font/BUILD.gn ++++ b/core/fpdfapi/font/BUILD.gn +@@ -62,10 +62,12 @@ + "cpdf_simplefont_unittest.cpp", + "cpdf_tounicodemap_unittest.cpp", + "cpdf_truetypefont_unittest.cpp", ++ "cpdf_type3font_unittest.cpp", + ] + deps = [ + ":font", + "../../fxge", ++ "../page", + "../page:unit_test_support", + "../parser", + "../parser:unit_test_support", +diff --git a/core/fpdfapi/font/cpdf_font.h b/core/fpdfapi/font/cpdf_font.h +--- a/core/fpdfapi/font/cpdf_font.h ++++ b/core/fpdfapi/font/cpdf_font.h +@@ -20,6 +20,7 @@ + #include "core/fxcrt/fx_coordinates.h" + #include "core/fxcrt/fx_string.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/unowned_ptr.h" + #include "core/fxge/cfx_font.h" +@@ -43,7 +44,8 @@ + public: + virtual ~FormIface() = default; + +- virtual void ParseContentForType3Char(CPDF_Type3Char* pChar) = 0; ++ virtual void ParseContentForType3Char(CPDF_Type3Char* pChar, ++ RenderingIntent intent) = 0; + virtual bool HasPageObjects() const = 0; + virtual CFX_FloatRect CalcBoundingBox() const = 0; + virtual std::optional, CFX_Matrix>> +diff --git a/core/fpdfapi/font/cpdf_type3font.cpp b/core/fpdfapi/font/cpdf_type3font.cpp +--- a/core/fpdfapi/font/cpdf_type3font.cpp ++++ b/core/fpdfapi/font/cpdf_type3font.cpp +@@ -96,12 +96,17 @@ + CheckFontMetrics(); + } + +-CPDF_Type3Char* CPDF_Type3Font::LoadChar(uint32_t charcode) { +- if (char_loading_depth_ >= kMaxType3FormLevel) { ++CPDF_Type3Char* CPDF_Type3Font::LoadChar(uint32_t charcode, ++ RenderingIntent intent) { ++ if (charcode >= char_width_l_.size() || ++ static_cast(intent) > ++ static_cast(RenderingIntent::kAbsoluteColorimetric) || ++ char_loading_depth_ >= kMaxType3FormLevel) { + return nullptr; + } + +- auto it = cache_map_.find(charcode); ++ const GlyphKey key{charcode, intent}; ++ auto it = cache_map_.find(key); + if (it != cache_map_.end()) { + return it->second.get(); + } +@@ -132,9 +137,9 @@ + { + AutoRestorer restorer(&char_loading_depth_); + char_loading_depth_++; +- pForm->ParseContentForType3Char(pNewChar.get()); ++ pForm->ParseContentForType3Char(pNewChar.get(), intent); + } +- it = cache_map_.find(charcode); ++ it = cache_map_.find(key); + if (it != cache_map_.end()) { + return it->second.get(); + } +@@ -145,7 +150,7 @@ + } + + CPDF_Type3Char* pCachedChar = pNewChar.get(); +- cache_map_[charcode] = std::move(pNewChar); ++ cache_map_[key] = std::move(pNewChar); + return pCachedChar; + } + +diff --git a/core/fpdfapi/font/cpdf_type3font.h b/core/fpdfapi/font/cpdf_type3font.h +--- a/core/fpdfapi/font/cpdf_type3font.h ++++ b/core/fpdfapi/font/cpdf_type3font.h +@@ -12,6 +12,7 @@ + #include + #include + #include ++#include + + #include "core/fpdfapi/font/cpdf_simplefont.h" + #include "core/fxcrt/fx_coordinates.h" +@@ -36,7 +37,9 @@ + void SetPageResources(CPDF_Dictionary* pResources) { + page_resources_.Reset(pResources); + } +- CPDF_Type3Char* LoadChar(uint32_t charcode); ++ CPDF_Type3Char* LoadChar( ++ uint32_t charcode, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric); + void CheckType3FontMetrics(); + + CFX_Matrix& GetFontMatrix() { return font_matrix_; } +@@ -56,7 +59,10 @@ + RetainPtr char_procs_; + RetainPtr page_resources_; + RetainPtr font_resources_; +- std::map> cache_map_; ++ // Type 3 uses one-byte character codes. At most four parsed variants per ++ // character preserve inherited RI while keeping glyph-local ri/q/Q intact. ++ using GlyphKey = std::pair; ++ std::map> cache_map_; + std::array char_width_l_ = {}; + }; + +diff --git a/core/fpdfapi/font/cpdf_type3font_unittest.cpp b/core/fpdfapi/font/cpdf_type3font_unittest.cpp +--- /dev/null ++++ b/core/fpdfapi/font/cpdf_type3font_unittest.cpp +@@ -0,0 +1,147 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#include "core/fpdfapi/font/cpdf_type3font.h" ++ ++#include ++#include ++#include ++#include ++ ++#include "core/fpdfapi/font/cpdf_type3char.h" ++#include "core/fpdfapi/page/cpdf_form.h" ++#include "core/fpdfapi/page/cpdf_pageobject.h" ++#include "core/fpdfapi/page/test_with_page_module.h" ++#include "core/fpdfapi/parser/cpdf_dictionary.h" ++#include "core/fpdfapi/parser/cpdf_name.h" ++#include "core/fpdfapi/parser/cpdf_reference.h" ++#include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_test_document.h" ++#include "core/fpdfapi/render/cpdf_type3cache.h" ++#include "core/fxcrt/data_vector.h" ++#include "testing/gtest/include/gtest/gtest.h" ++ ++namespace { ++ ++using CPDFType3FontTest = TestWithPageModule; ++ ++constexpr RenderingIntent kIntents[] = { ++ RenderingIntent::kPerceptual, ++ RenderingIntent::kRelativeColorimetric, ++ RenderingIntent::kSaturation, ++ RenderingIntent::kAbsoluteColorimetric, ++}; ++ ++class CountingFormFactory final : public CPDF_Font::FormFactoryIface { ++ public: ++ std::unique_ptr CreateForm( ++ CPDF_Document* document, ++ RetainPtr resources, ++ RetainPtr stream) override { ++ ++count; ++ return std::make_unique(document, std::move(resources), ++ std::move(stream)); ++ } ++ ++ int count = 0; ++}; ++ ++RetainPtr MakeType3Font(CPDF_TestDocument* document, ++ CountingFormFactory* factory, ++ std::string_view content) { ++ auto stream = document->NewIndirect( ++ DataVector(content.begin(), content.end()), ++ pdfium::MakeRetain()); ++ auto dict = pdfium::MakeRetain(); ++ dict->SetNewFor("Type", "Font"); ++ dict->SetNewFor("Subtype", "Type3"); ++ dict->SetNewFor("Encoding", "WinAnsiEncoding"); ++ dict->SetMatrixFor("FontMatrix", CFX_Matrix(0.001f, 0, 0, 0.001f, 0, 0)); ++ dict->SetRectFor("FontBBox", CFX_FloatRect(0, 0, 600, 600)); ++ auto char_procs = dict->SetNewFor("CharProcs"); ++ char_procs->SetNewFor("A", document, stream->GetObjNum()); ++ return CPDF_Font::Create(document, std::move(dict), factory); ++} ++ ++} // namespace ++ ++TEST_F(CPDFType3FontTest, IntentVariantsPreserveExplicitIntentAndSavedState) { ++ CPDF_TestDocument document; ++ CountingFormFactory factory; ++ auto font = MakeType3Font( ++ &document, &factory, ++ "600 0 d0 0 0 1 rg 0 0 20 20 re f " ++ "q /AbsoluteColorimetric ri 30 0 20 20 re f Q 60 0 20 20 re f"); ++ ASSERT_TRUE(font); ++ CPDF_Type3Font* type3 = font->AsType3Font(); ++ ASSERT_TRUE(type3); ++ std::array variants{}; ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ variants[i] = type3->LoadChar(65, kIntents[i]); ++ ASSERT_TRUE(variants[i]); ++ const auto* form = static_cast(variants[i]->form()); ++ ASSERT_TRUE(form); ++ ASSERT_EQ(form->GetPageObjectCount(), 3u); ++ EXPECT_EQ(form->GetPageObjectByIndex(0)->general_state().GetRenderIntent(), ++ kIntents[i]); ++ EXPECT_EQ(form->GetPageObjectByIndex(1)->general_state().GetRenderIntent(), ++ RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_EQ(form->GetPageObjectByIndex(2)->general_state().GetRenderIntent(), ++ kIntents[i]); ++ for (size_t j = 0; j < i; ++j) { ++ EXPECT_NE(variants[j], variants[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ for (int repeat = 0; repeat < 32; ++repeat) { ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ EXPECT_EQ(type3->LoadChar(65, kIntents[i]), variants[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ // Metrics use the default variant and do not create a fifth entry. ++ EXPECT_EQ(type3->GetCharWidth(65), variants[1]->width()); ++ EXPECT_EQ(type3->GetCharBBox(65), variants[1]->bbox()); ++ EXPECT_EQ(factory.count, 4); ++ EXPECT_FALSE(type3->LoadChar(256, RenderingIntent::kPerceptual)); ++ EXPECT_FALSE(type3->LoadChar(65, static_cast(4))); ++ EXPECT_EQ(factory.count, 4); ++ // Earlier variants remain alive and retain their own inherited state. ++ const auto* first_form = static_cast(variants[0]->form()); ++ EXPECT_EQ(first_form->GetPageObjectByIndex(2)->general_state().GetRenderIntent(), ++ RenderingIntent::kPerceptual); ++} ++ ++TEST_F(CPDFType3FontTest, BitmapCacheUsesTheSameIntentVariantAsFontCache) { ++ CPDF_TestDocument document; ++ CountingFormFactory factory; ++ auto font = MakeType3Font( ++ &document, &factory, ++ "600 0 0 0 10 10 d1 10 0 0 10 0 0 cm " ++ "BI /W 1 /H 1 /IM true /BPC 1 ID \xff EI"); ++ ASSERT_TRUE(font); ++ CPDF_Type3Font* type3 = font->AsType3Font(); ++ ASSERT_TRUE(type3); ++ auto cache = pdfium::MakeRetain(type3); ++ const CFX_Matrix matrix(10, 0, 0, 10, 0, 0); ++ std::array bitmaps{}; ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ auto* glyph = type3->LoadChar(65, kIntents[i]); ++ ASSERT_TRUE(glyph); ++ ASSERT_TRUE(glyph->LoadBitmapFromSoleImageOfForm()); ++ bitmaps[i] = cache->LoadGlyphBitmap(65, matrix, kIntents[i]); ++ ASSERT_TRUE(bitmaps[i]); ++ EXPECT_EQ(cache->LoadGlyphBitmap(65, matrix, kIntents[i]), bitmaps[i]); ++ for (size_t j = 0; j < i; ++j) { ++ EXPECT_NE(bitmaps[j], bitmaps[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ EXPECT_EQ(cache->LoadGlyphBitmap(65, matrix, kIntents[i]), bitmaps[i]); ++ } ++ EXPECT_EQ(factory.count, 4); ++ EXPECT_FALSE(cache->LoadGlyphBitmap(256, matrix, kIntents[0])); ++ EXPECT_FALSE(cache->LoadGlyphBitmap(65, matrix, static_cast(4))); ++} +diff --git a/core/fpdfapi/page/BUILD.gn b/core/fpdfapi/page/BUILD.gn +--- a/core/fpdfapi/page/BUILD.gn ++++ b/core/fpdfapi/page/BUILD.gn +@@ -149,7 +149,9 @@ + "cpdf_page_unittest.cpp", + "cpdf_pageimagecache_unittest.cpp", + "cpdf_pageobjectholder_unittest.cpp", ++ "cpdf_patterncontext_unittest.cpp", + "cpdf_psengine_unittest.cpp", ++ "cpdf_renderingintent_unittest.cpp", + "cpdf_streamcontentparser_unittest.cpp", + "cpdf_streamparser_unittest.cpp", + ] +diff --git a/core/fpdfapi/page/cpdf_allstates.cpp b/core/fpdfapi/page/cpdf_allstates.cpp +--- a/core/fpdfapi/page/cpdf_allstates.cpp ++++ b/core/fpdfapi/page/cpdf_allstates.cpp +@@ -27,6 +27,11 @@ + + CPDF_AllStates::~CPDF_AllStates() = default; + ++void CPDF_AllStates::SetRenderIntent(const ByteString& name) { ++ mutable_general_state().SetRenderIntent(name); ++ mutable_color_state().SetRenderIntent(general_state().GetRenderIntent()); ++} ++ + void CPDF_AllStates::SetDefaultStates() { + graphic_states_.SetDefaultStates(); + } +@@ -76,7 +81,9 @@ + break; + } + case FXBSTR_ID('R', 'I', 0, 0): +- mutable_general_state().SetRenderIntent(pObject->GetString()); ++ if (pObject->IsName()) { ++ SetRenderIntent(pObject->GetString()); ++ } + break; + case FXBSTR_ID('F', 'o', 'n', 't'): { + const CPDF_Array* font = pObject->AsArray(); +diff --git a/core/fpdfapi/page/cpdf_allstates.h b/core/fpdfapi/page/cpdf_allstates.h +--- a/core/fpdfapi/page/cpdf_allstates.h ++++ b/core/fpdfapi/page/cpdf_allstates.h +@@ -22,6 +22,7 @@ + ~CPDF_AllStates(); + + void SetDefaultStates(); ++ void SetRenderIntent(const ByteString& name); + + void ProcessExtGS(const CPDF_Dictionary* pGS, + CPDF_StreamContentParser* pParser); +diff --git a/core/fpdfapi/page/cpdf_color.cpp b/core/fpdfapi/page/cpdf_color.cpp +--- a/core/fpdfapi/page/cpdf_color.cpp ++++ b/core/fpdfapi/page/cpdf_color.cpp +@@ -100,8 +100,9 @@ + CPDF_ColorSpace::GetStockCS(CPDF_ColorSpace::Family::kDeviceGray); + } + +-std::optional CPDF_Color::GetColorRef() const { +- std::optional> maybe_rgb = GetRGB(); ++std::optional CPDF_Color::GetColorRef( ++ RenderingIntent intent) const { ++ std::optional> maybe_rgb = GetRGB(intent); + if (!maybe_rgb.has_value()) { + return std::nullopt; + } +@@ -113,17 +114,18 @@ + FXSYS_roundf(r * 255.0f)); + } + +-std::optional> CPDF_Color::GetRGB() const { ++std::optional> CPDF_Color::GetRGB( ++ RenderingIntent intent) const { + if (IsPatternInternal()) { + if (std::holds_alternative>(color_data_)) { + const auto& pattern_value = + std::get>(color_data_); +- return cs_->AsPatternCS()->GetPatternRGB(*pattern_value); ++ return cs_->AsPatternCS()->GetPatternRGB(*pattern_value, intent); + } + } else { + if (std::holds_alternative>(color_data_)) { + const auto& buffer = std::get>(color_data_); +- return cs_->GetRGB(buffer); ++ return cs_->GetRGB(buffer, intent); + } + } + return std::nullopt; +diff --git a/core/fpdfapi/page/cpdf_color.h b/core/fpdfapi/page/cpdf_color.h +--- a/core/fpdfapi/page/cpdf_color.h ++++ b/core/fpdfapi/page/cpdf_color.h +@@ -14,6 +14,7 @@ + #include + #include + ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxge/dib/fx_dib.h" +@@ -44,8 +45,10 @@ + // Wrapper around GetRGB() that returns the RGB value as FX_COLORREF. The + // GetRGB() return value is clamped to fit into FX_COLORREF, where the color + // components are 8-bit fields within an unsigned integer. +- std::optional GetColorRef() const; +- std::optional> GetRGB() const; ++ std::optional GetColorRef( ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; ++ std::optional> GetRGB( ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + + // Should only be called if IsPattern() returns true. + RetainPtr GetPattern() const; +diff --git a/core/fpdfapi/page/cpdf_colorspace.cpp b/core/fpdfapi/page/cpdf_colorspace.cpp +--- a/core/fpdfapi/page/cpdf_colorspace.cpp ++++ b/core/fpdfapi/page/cpdf_colorspace.cpp +@@ -126,7 +126,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -135,7 +136,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + + private: + static constexpr float kDefaultGamma = 1.0f; +@@ -154,13 +156,15 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void TranslateImageLine(pdfium::span dest_span, + pdfium::span src_span, + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -184,7 +188,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -194,7 +199,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -216,14 +222,16 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + RetainPtr GetIccProfile() const override; + void TranslateImageLine(pdfium::span dest_span, + pdfium::span src_span, + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + bool IsNormal() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, +@@ -253,7 +261,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -276,7 +285,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -638,7 +648,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + // Only applies to CMYK colorspaces. None of the colorspaces that use this + // generic base implementation are CMYK. + CHECK(!bTransMask); +@@ -652,7 +663,7 @@ + for (uint32_t j = 0; j < components_; j++) { + src[j] = static_cast(*src_buf++) / divisor; + } +- auto rgb = GetRGBOrZerosOnError(src); ++ auto rgb = GetRGBOrZerosOnError(src, intent); + *dest_buf++ = static_cast(rgb.blue * 255); + *dest_buf++ = static_cast(rgb.green * 255); + *dest_buf++ = static_cast(rgb.red * 255); +@@ -717,7 +728,8 @@ + } + + std::optional> CPDF_CalGray::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + const float gray = pBuf[0]; + return FX_RGB_STRUCT{gray, gray, gray}; + } +@@ -727,7 +739,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + auto gray_span = src_span.first(static_cast(pixels)); +@@ -778,7 +791,8 @@ + } + + std::optional> CPDF_CalRGB::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + float a = pBuf[0]; + float b = pBuf[1]; + float c = pBuf[2]; +@@ -810,7 +824,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + fxcodec::ReverseRGB(dest_span, src_span, pixels); + } +@@ -865,7 +880,8 @@ + } + + std::optional> CPDF_LabCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + float Lstar = pBuf[0]; + float astar = pBuf[1]; + float bstar = pBuf[2]; +@@ -901,7 +917,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + auto bgr_span = fxcrt::reinterpret_span>(dest_span); +@@ -917,7 +934,7 @@ + // Better code than the equivalent GetRGBOrZerosOnError() since that + // is implemented in a base class and can't devirtualize the GetRGB() + // call despite this class being marked final. +- auto rgb = GetRGB(lab).value_or(FX_RGB_STRUCT{}); ++ auto rgb = GetRGB(lab, intent).value_or(FX_RGB_STRUCT{}); + bgr_ref.blue = static_cast(rgb.blue * 255); + bgr_ref.green = static_cast(rgb.green * 255); + bgr_ref.red = static_cast(rgb.red * 255); +@@ -969,17 +986,20 @@ + } + + std::optional> CPDF_ICCBasedCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (profile_->IsSRGB()) { + return FX_RGB_STRUCT{pBuf[0], pBuf[1], pBuf[2]}; + } + if (profile_->IsSupported()) { + float rgb[3]; +- profile_->Translate(pBuf.first(ComponentCount()), rgb); +- return FX_RGB_STRUCT{rgb[0], rgb[1], rgb[2]}; ++ if (profile_->Translate(pBuf.first(ComponentCount()), rgb, intent)) { ++ return FX_RGB_STRUCT{rgb[0], rgb[1], rgb[2]}; ++ } ++ return std::nullopt; + } + if (base_cs_) { +- return base_cs_->GetRGB(pBuf); ++ return base_cs_->GetRGB(pBuf, intent); + } + return FX_RGB_STRUCT{}; + } +@@ -993,7 +1013,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + if (profile_->IsSRGB()) { +@@ -1003,12 +1024,14 @@ + if (!profile_->IsSupported()) { + if (base_cs_) { + base_cs_->TranslateImageLine(dest_span, src_span, pixels, image_width, +- image_height, false); ++ image_height, false, intent); + } + return; + } + +- profile_->TranslateScanline(dest_span, src_span, pixels); ++ if (!profile_->TranslateScanline(dest_span, src_span, pixels, intent)) { ++ std::ranges::fill(dest_span.first(static_cast(pixels) * 3), 0); ++ } + } + + bool CPDF_ICCBasedCS::IsNormal() const { +@@ -1131,7 +1154,8 @@ + } + + std::optional> CPDF_SeparationCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (is_none_type_) { + return std::nullopt; + } +@@ -1140,7 +1164,7 @@ + return std::nullopt; + } + std::vector results(base_cs_->ComponentCount(), pBuf[0]); +- return base_cs_->GetRGB(results); ++ return base_cs_->GetRGB(results, intent); + } + + // Using at least 16 elements due to the call alt_cs_->GetRGB() below. +@@ -1150,7 +1174,7 @@ + return std::nullopt; + } + if (base_cs_) { +- return base_cs_->GetRGB(results); ++ return base_cs_->GetRGB(results, intent); + } + return std::nullopt; + } +@@ -1199,7 +1223,8 @@ + } + + std::optional> CPDF_DeviceNCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (!func_) { + return std::nullopt; + } +@@ -1211,5 +1236,5 @@ + if (nresults == 0) { + return std::nullopt; + } +- return base_cs_->GetRGB(results); +-} ++ return base_cs_->GetRGB(results, intent); ++} +diff --git a/core/fpdfapi/page/cpdf_colorspace.h b/core/fpdfapi/page/cpdf_colorspace.h +--- a/core/fpdfapi/page/cpdf_colorspace.h ++++ b/core/fpdfapi/page/cpdf_colorspace.h +@@ -21,6 +21,7 @@ + #include "core/fpdfapi/parser/cpdf_object.h" + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -103,13 +104,16 @@ + // Wrapper around GetRGB() that returns black (0, 0, 0) when an actual value + // can not be determined. + FX_RGB_STRUCT GetRGBOrZerosOnError( +- pdfium::span pBuf) const { +- return GetRGB(pBuf).value_or(FX_RGB_STRUCT{}); ++ pdfium::span pBuf, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const { ++ return GetRGB(pBuf, intent).value_or(FX_RGB_STRUCT{}); + } + + // Use CPDF_Pattern::GetPatternColorRef() instead of GetRGB() for patterns. + virtual std::optional> GetRGB( +- pdfium::span pBuf) const = 0; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const = 0; + + virtual RetainPtr GetIccProfile() const; + +@@ -118,12 +122,14 @@ + float* min, + float* max) const; + +- virtual void TranslateImageLine(pdfium::span dest_span, +- pdfium::span src_span, +- int pixels, +- int image_width, +- int image_height, +- bool bTransMask) const; ++ virtual void TranslateImageLine( ++ pdfium::span dest_span, ++ pdfium::span src_span, ++ int pixels, ++ int image_width, ++ int image_height, ++ bool bTransMask, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + virtual void EnableStdConversion(bool bEnabled); + virtual bool IsNormal() const; + +diff --git a/core/fpdfapi/page/cpdf_colorstate.cpp b/core/fpdfapi/page/cpdf_colorstate.cpp +--- a/core/fpdfapi/page/cpdf_colorstate.cpp ++++ b/core/fpdfapi/page/cpdf_colorstate.cpp +@@ -26,6 +26,24 @@ + + void CPDF_ColorState::SetDefault() { + ref_.GetPrivateCopy()->SetDefault(); ++} ++ ++void CPDF_ColorState::SetRenderIntent(RenderingIntent intent) { ++ if (ref_ && ref_.GetObject()->render_intent_ == intent) { ++ return; ++ } ++ ColorData* data = ref_.GetPrivateCopy(); ++ data->render_intent_ = intent; ++ if (!data->fill_color_.IsNull()) { ++ if (auto color = data->fill_color_.GetColorRef(intent)) { ++ data->fill_color_ref_ = *color; ++ } ++ } ++ if (!data->stroke_color_.IsNull()) { ++ if (auto color = data->stroke_color_.GetColorRef(intent)) { ++ data->stroke_color_ref_ = *color; ++ } ++ } + } + + FX_COLORREF CPDF_ColorState::GetFillColorRef() const { +@@ -127,14 +145,16 @@ + if (!color.IsPattern()) { + color.SetValueForNonPattern(std::move(values)); + } +- return color.GetColorRef().value_or(0xFFFFFFFF); ++ return color.GetColorRef(ref_.GetObject()->render_intent_) ++ .value_or(0xFFFFFFFF); + } + + FX_COLORREF CPDF_ColorState::SetPattern(RetainPtr pattern, + pdfium::span values, + CPDF_Color& color) { + color.SetValueForPattern(pattern, values); +- std::optional colorref = color.GetColorRef(); ++ std::optional colorref = ++ color.GetColorRef(ref_.GetObject()->render_intent_); + if (colorref.has_value()) { + return colorref.value(); + } +@@ -146,7 +166,8 @@ + CPDF_ColorState::ColorData::ColorData() = default; + + CPDF_ColorState::ColorData::ColorData(const ColorData& src) +- : fill_color_ref_(src.fill_color_ref_), ++ : render_intent_(src.render_intent_), ++ fill_color_ref_(src.fill_color_ref_), + stroke_color_ref_(src.stroke_color_ref_), + fill_color_(src.fill_color_), + stroke_color_(src.stroke_color_) {} +@@ -154,6 +175,7 @@ + CPDF_ColorState::ColorData::~ColorData() = default; + + void CPDF_ColorState::ColorData::SetDefault() { ++ render_intent_ = RenderingIntent::kRelativeColorimetric; + fill_color_ref_ = 0; + stroke_color_ref_ = 0; + fill_color_.SetColorSpace( +diff --git a/core/fpdfapi/page/cpdf_colorstate.h b/core/fpdfapi/page/cpdf_colorstate.h +--- a/core/fpdfapi/page/cpdf_colorstate.h ++++ b/core/fpdfapi/page/cpdf_colorstate.h +@@ -27,6 +27,7 @@ + + void Emplace(); + void SetDefault(); ++ void SetRenderIntent(RenderingIntent intent); + + FX_COLORREF GetFillColorRef() const; + void SetFillColorRef(FX_COLORREF colorref); +@@ -62,6 +63,7 @@ + + void SetDefault(); + ++ RenderingIntent render_intent_ = RenderingIntent::kRelativeColorimetric; + FX_COLORREF fill_color_ref_ = 0; + FX_COLORREF stroke_color_ref_ = 0; + CPDF_Color fill_color_; +diff --git a/core/fpdfapi/page/cpdf_devicecs.cpp b/core/fpdfapi/page/cpdf_devicecs.cpp +--- a/core/fpdfapi/page/cpdf_devicecs.cpp ++++ b/core/fpdfapi/page/cpdf_devicecs.cpp +@@ -44,7 +44,8 @@ + } + + std::optional> CPDF_DeviceCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + switch (GetFamily()) { + case Family::kDeviceGray: { + const float pix = NormalizeChannel(pBuf.front()); +@@ -83,7 +84,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + auto rgb_out = fxcrt::reinterpret_span>(dest_span); + switch (GetFamily()) { + case Family::kDeviceGray: +diff --git a/core/fpdfapi/page/cpdf_devicecs.h b/core/fpdfapi/page/cpdf_devicecs.h +--- a/core/fpdfapi/page/cpdf_devicecs.h ++++ b/core/fpdfapi/page/cpdf_devicecs.h +@@ -19,13 +19,18 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; +- void TranslateImageLine(pdfium::span dest_span, +- pdfium::span src_span, +- int pixels, +- int image_width, +- int image_height, +- bool bTransMask) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; ++ void TranslateImageLine( ++ pdfium::span dest_span, ++ pdfium::span src_span, ++ int pixels, ++ int image_width, ++ int image_height, ++ bool bTransMask, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +diff --git a/core/fpdfapi/page/cpdf_dib.cpp b/core/fpdfapi/page/cpdf_dib.cpp +--- a/core/fpdfapi/page/cpdf_dib.cpp ++++ b/core/fpdfapi/page/cpdf_dib.cpp +@@ -138,7 +138,36 @@ + + CPDF_DIB::JpxSMaskInlineData::~JpxSMaskInlineData() = default; + ++// static ++RenderingIntent CPDF_DIB::ResolveRenderingIntent( ++ const CPDF_Dictionary* dict, RenderingIntent inherited_intent) { ++ if (!dict) { ++ return inherited_intent; ++ } ++ RetainPtr value = dict->GetDirectObjectFor("Intent"); ++ if (!value || !value->IsName()) { ++ return inherited_intent; ++ } ++ const ByteString name = value->GetString(); ++ if (name == "Perceptual") { ++ return RenderingIntent::kPerceptual; ++ } ++ if (name == "RelativeColorimetric") { ++ return RenderingIntent::kRelativeColorimetric; ++ } ++ if (name == "Saturation") { ++ return RenderingIntent::kSaturation; ++ } ++ if (name == "AbsoluteColorimetric") { ++ return RenderingIntent::kAbsoluteColorimetric; ++ } ++ // An unrecognized intent name falls back to the PDF default (not the ++ // inherited state); an absent or non-name entry above keeps inheritance. ++ return RenderingIntent::kRelativeColorimetric; ++} ++ + bool CPDF_DIB::Load() { ++ rendering_intent_ = RenderingIntent::kRelativeColorimetric; + if (!LoadInternal(nullptr, nullptr)) { + return false; + } +@@ -204,7 +233,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { ++ rendering_intent_ = intent; + std_cs_ = bStdCS; + has_mask_ = bHasMask; + group_family_ = GroupFamily; +@@ -813,6 +844,7 @@ + } + + dict_ = stream_->GetDict(); ++ rendering_intent_ = ResolveRenderingIntent(dict_.Get(), rendering_intent_); + SetWidth(dict_->GetIntegerFor("Width")); + SetHeight(dict_->GetIntegerFor("Height")); + if (!IsValidDimension(GetWidth()) || !IsValidDimension(GetHeight())) { +@@ -873,7 +905,7 @@ + std::vector colors = + ReadArrayElementsToVector(pMatte.Get(), components_); + +- auto rgb = color_space_->GetRGBOrZerosOnError(colors); ++ auto rgb = color_space_->GetRGBOrZerosOnError(colors, rendering_intent_); + matte_color_ = + ArgbEncode(0, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -915,7 +947,8 @@ + mask_ = pdfium::MakeRetain(document_, std::move(mask_stream)); + LoadState ret = + mask_->StartLoadDIBBase(false, nullptr, nullptr, true, +- CPDF_ColorSpace::Family::kUnknown, false, {0, 0}); ++ CPDF_ColorSpace::Family::kUnknown, false, {0, 0}, ++ rendering_intent_); + if (ret == LoadState::kContinue) { + if (status_ == LoadState::kFail) { + status_ = LoadState::kContinue; +@@ -957,7 +990,7 @@ + float color_values[3]; + std::ranges::fill(color_values, comp_data_[0].decode_min_); + +- auto rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ auto rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + FX_ARGB argb0 = + ArgbEncode(255, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -972,7 +1005,7 @@ + color_values[0] += comp_data_[0].decode_step_; + color_values[1] += comp_data_[0].decode_step_; + color_values[2] += comp_data_[0].decode_step_; +- auto result = color_space_->GetRGBOrZerosOnError(color_values); ++ auto result = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + argb1 = ArgbEncode(255, FXSYS_roundf(result.red * 255), + FXSYS_roundf(result.green * 255), + FXSYS_roundf(result.blue * 255)); +@@ -1006,9 +1039,9 @@ + color_space_->ComponentCount() > 1) { + const size_t nComponents = color_space_->ComponentCount(); + std::vector temp_buf(nComponents, color_values[0]); +- rgb = color_space_->GetRGBOrZerosOnError(temp_buf); ++ rgb = color_space_->GetRGBOrZerosOnError(temp_buf, rendering_intent_); + } else { +- rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + } + SetPaletteArgb(i, ArgbEncode(255, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), +@@ -1078,7 +1111,7 @@ + rgb.green = (1.0f - color_values[1]) * k; + rgb.blue = (1.0f - color_values[2]) * k; + } else if (family_ != CPDF_ColorSpace::Family::kPattern) { +- rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + } + const float R = std::clamp(rgb.red, 0.0f, 1.0f); + const float G = std::clamp(rgb.green, 0.0f, 1.0f); +@@ -1105,7 +1138,8 @@ + + if (ComponentCountMatchesColorSpace()) { + color_space_->TranslateImageLine(dest_scan, src_scan, GetWidth(), +- GetWidth(), GetHeight(), TransMask()); ++ GetWidth(), GetHeight(), TransMask(), ++ rendering_intent_); + } + return true; + } +diff --git a/core/fpdfapi/page/cpdf_dib.h b/core/fpdfapi/page/cpdf_dib.h +--- a/core/fpdfapi/page/cpdf_dib.h ++++ b/core/fpdfapi/page/cpdf_dib.h +@@ -14,6 +14,7 @@ + + #include "core/fpdfapi/page/cpdf_colorspace.h" + #include "core/fxcrt/data_vector.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -56,6 +57,11 @@ + uint32_t GetMatteColor() const { return matte_color_; } + bool IsJBigImage() const; + ++ // Missing or non-name image Intent preserves graphics-state inheritance. ++ // Standard names select that intent; unknown names use the PDF default. ++ static RenderingIntent ResolveRenderingIntent( ++ const CPDF_Dictionary* dict, RenderingIntent inherited_intent); ++ + bool Load(); + LoadState StartLoadDIBBase(bool bHasMask, + const CPDF_Dictionary* pFormResources, +@@ -63,7 +69,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + LoadState ContinueLoadDIBBase(PauseIndicatorIface* pPause); + RetainPtr DetachMask(); + +@@ -129,6 +137,7 @@ + uint32_t components_ = 0; + CPDF_ColorSpace::Family family_ = CPDF_ColorSpace::Family::kUnknown; + CPDF_ColorSpace::Family group_family_ = CPDF_ColorSpace::Family::kUnknown; ++ RenderingIntent rendering_intent_ = RenderingIntent::kRelativeColorimetric; + uint32_t matte_color_ = 0; + LoadState status_ = LoadState::kFail; + bool load_mask_ = false; +diff --git a/core/fpdfapi/page/cpdf_dib_unittest.cpp b/core/fpdfapi/page/cpdf_dib_unittest.cpp +--- a/core/fpdfapi/page/cpdf_dib_unittest.cpp ++++ b/core/fpdfapi/page/cpdf_dib_unittest.cpp +@@ -13,6 +13,7 @@ + #include "core/fpdfapi/parser/cpdf_name.h" + #include "core/fpdfapi/parser/cpdf_number.h" + #include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_string.h" + #include "core/fpdfapi/parser/cpdf_test_document.h" + #include "core/fxcrt/data_vector.h" + #include "core/fxcrt/retain_ptr.h" +@@ -90,3 +91,39 @@ + EXPECT_THAT(dib->GetScanline(0), + testing::ElementsAre(0x55, 0x33, 0x11, 0xbb, 0x99, 0x77)); + } ++ ++TEST_F(CPDFDIBTest, ImageIntentDistinguishesUnknownNameFromMissingOrInvalidType) { ++ auto dict = pdfium::MakeRetain(); ++ constexpr RenderingIntent inherited = RenderingIntent::kSaturation; ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(nullptr, inherited), inherited); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++ struct IntentCase { ++ const char* name; ++ RenderingIntent intent; ++ }; ++ const IntentCase cases[] = { ++ {"Perceptual", RenderingIntent::kPerceptual}, ++ {"RelativeColorimetric", RenderingIntent::kRelativeColorimetric}, ++ {"Saturation", RenderingIntent::kSaturation}, ++ {"AbsoluteColorimetric", RenderingIntent::kAbsoluteColorimetric}, ++ }; ++ for (const auto& test : cases) { ++ dict->SetNewFor("Intent", test.name); ++ for (const auto& source : cases) { ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), source.intent), ++ test.intent); ++ } ++ } ++ // Unknown names fall back to RelativeColorimetric, including old ++ // four-character prefix matches. PDF names are case sensitive. ++ for (const char* name : {"Unknown", "Abso", "AbsoluteColorimetricSuffix", ++ "perceptual", ""}) { ++ dict->SetNewFor("Intent", name); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), ++ RenderingIntent::kRelativeColorimetric); ++ } ++ dict->SetNewFor("Intent", 3); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++ dict->SetNewFor("Intent", "Perceptual"); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++} +diff --git a/core/fpdfapi/page/cpdf_docpagedata.cpp b/core/fpdfapi/page/cpdf_docpagedata.cpp +--- a/core/fpdfapi/page/cpdf_docpagedata.cpp ++++ b/core/fpdfapi/page/cpdf_docpagedata.cpp +@@ -382,27 +382,36 @@ + + RetainPtr CPDF_DocPageData::GetPattern( + RetainPtr pPatternObj, +- const CFX_Matrix& matrix) { ++ const CFX_Matrix& matrix, ++ RenderingIntent initial_render_intent) { + CHECK(pPatternObj->IsDictionary() || pPatternObj->IsStream()); + + auto it = pattern_map_.find(pPatternObj); +- if (it != pattern_map_.end() && it->second) { +- return pdfium::WrapRetain(it->second.Get()); ++ if (it != pattern_map_.end() && it->second && ++ it->second->MatchesContext(matrix, initial_render_intent)) { ++ auto* shading = it->second->AsShadingPattern(); ++ if (!shading || !shading->IsShadingObject()) { ++ return pdfium::WrapRetain(it->second.Get()); ++ } + } + + RetainPtr pattern; + switch (pPatternObj->GetDict()->GetIntegerFor("PatternType")) { + case CPDF_Pattern::kTiling: + pattern = pdfium::MakeRetain(GetDocument(), +- pPatternObj, matrix); ++ pPatternObj, matrix, ++ initial_render_intent); + break; + case CPDF_Pattern::kShading: + pattern = pdfium::MakeRetain( +- GetDocument(), pPatternObj, false, matrix); ++ GetDocument(), pPatternObj, false, matrix, initial_render_intent); + break; + default: + return nullptr; + } ++ // Retain only the most recently requested context per source object. Existing ++ // page objects own their immutable pattern through PatternValue, so replacing ++ // this cache entry cannot change an already parsed page/Form's interpretation. + pattern_map_[pPatternObj].Reset(pattern.Get()); + return pattern; + } +@@ -413,8 +422,13 @@ + CHECK(pPatternObj->IsDictionary() || pPatternObj->IsStream()); + + auto it = pattern_map_.find(pPatternObj); +- if (it != pattern_map_.end() && it->second) { +- return pdfium::WrapRetain(it->second->AsShadingPattern()); ++ if (it != pattern_map_.end() && it->second && ++ it->second->MatchesContext(matrix, ++ RenderingIntent::kRelativeColorimetric)) { ++ auto* shading = it->second->AsShadingPattern(); ++ if (shading && shading->IsShadingObject()) { ++ return pdfium::WrapRetain(shading); ++ } + } + + auto pPattern = pdfium::MakeRetain( +diff --git a/core/fpdfapi/page/cpdf_docpagedata.h b/core/fpdfapi/page/cpdf_docpagedata.h +--- a/core/fpdfapi/page/cpdf_docpagedata.h ++++ b/core/fpdfapi/page/cpdf_docpagedata.h +@@ -78,7 +78,8 @@ + std::set* pVisited); + + RetainPtr GetPattern(RetainPtr pPatternObj, +- const CFX_Matrix& matrix); ++ const CFX_Matrix& matrix, ++ RenderingIntent initial_render_intent); + RetainPtr GetShading(RetainPtr pPatternObj, + const CFX_Matrix& matrix); + +diff --git a/core/fpdfapi/page/cpdf_form.cpp b/core/fpdfapi/page/cpdf_form.cpp +--- a/core/fpdfapi/page/cpdf_form.cpp ++++ b/core/fpdfapi/page/cpdf_form.cpp +@@ -9,6 +9,7 @@ + #include + #include + ++#include "core/fpdfapi/page/cpdf_allstates.h" + #include "core/fpdfapi/page/cpdf_contentparser.h" + #include "core/fpdfapi/page/cpdf_imageobject.h" + #include "core/fpdfapi/page/cpdf_pageobject.h" +@@ -70,8 +71,19 @@ + ParseContentInternal(pGraphicStates, pParentMatrix, nullptr, recursion_state); + } + +-void CPDF_Form::ParseContentForType3Char(CPDF_Type3Char* pType3Char) { +- ParseContentInternal(nullptr, nullptr, pType3Char, nullptr); ++void CPDF_Form::ParseContentForType3Char(CPDF_Type3Char* pType3Char, ++ RenderingIntent intent) { ++ // Retain the existing empty glyph color/graphics state, inheriting only RI. ++ // Parsing with that initial RI lets ordinary ri and q/Q operators override ++ // and restore it, without mutating an already-cached glyph object graph. ++ CPDF_AllStates states; ++ states.mutable_general_state().Emplace(); ++ states.mutable_graph_state().Emplace(); ++ states.mutable_text_state().Emplace(); ++ states.mutable_color_state().Emplace(); ++ states.mutable_general_state().SetRenderIntent(intent); ++ states.mutable_color_state().SetRenderIntent(intent); ++ ParseContentInternal(&states, nullptr, pType3Char, nullptr); + } + + void CPDF_Form::ParseContentInternal(const CPDF_AllStates* pGraphicStates, +diff --git a/core/fpdfapi/page/cpdf_form.h b/core/fpdfapi/page/cpdf_form.h +--- a/core/fpdfapi/page/cpdf_form.h ++++ b/core/fpdfapi/page/cpdf_form.h +@@ -46,7 +46,8 @@ + ~CPDF_Form() override; + + // CPDF_Font::FormIface: +- void ParseContentForType3Char(CPDF_Type3Char* pType3Char) override; ++ void ParseContentForType3Char(CPDF_Type3Char* pType3Char, ++ RenderingIntent intent) override; + bool HasPageObjects() const override; + CFX_FloatRect CalcBoundingBox() const override; + std::optional, CFX_Matrix>> +diff --git a/core/fpdfapi/page/cpdf_generalstate.cpp b/core/fpdfapi/page/cpdf_generalstate.cpp +--- a/core/fpdfapi/page/cpdf_generalstate.cpp ++++ b/core/fpdfapi/page/cpdf_generalstate.cpp +@@ -13,23 +13,6 @@ + #include "core/fpdfapi/parser/cpdf_object.h" + + namespace { +- +-int RI_StringToId(const ByteString& ri) { +- uint32_t id = ri.GetID(); +- if (id == FXBSTR_ID('A', 'b', 's', 'o')) { +- return 1; +- } +- +- if (id == FXBSTR_ID('S', 'a', 't', 'u')) { +- return 2; +- } +- +- if (id == FXBSTR_ID('P', 'e', 'r', 'c')) { +- return 3; +- } +- +- return 0; +-} + + BlendMode GetBlendTypeInternal(const ByteString& mode) { + switch (mode.GetID()) { +@@ -80,8 +63,32 @@ + + CPDF_GeneralState::~CPDF_GeneralState() = default; + ++RenderingIntent CPDF_GeneralState::GetRenderIntent() const { ++ return ref_ ? ref_.GetObject()->render_intent_ ++ : RenderingIntent::kRelativeColorimetric; ++} ++ + void CPDF_GeneralState::SetRenderIntent(const ByteString& ri) { +- ref_.GetPrivateCopy()->render_intent_ = RI_StringToId(ri); ++ RenderingIntent intent; ++ if (ri == "Perceptual") { ++ intent = RenderingIntent::kPerceptual; ++ } else if (ri == "RelativeColorimetric") { ++ intent = RenderingIntent::kRelativeColorimetric; ++ } else if (ri == "Saturation") { ++ intent = RenderingIntent::kSaturation; ++ } else if (ri == "AbsoluteColorimetric") { ++ intent = RenderingIntent::kAbsoluteColorimetric; ++ } else { ++ // PDF requires unknown intent names to use RelativeColorimetric. ++ intent = RenderingIntent::kRelativeColorimetric; ++ } ++ SetRenderIntent(intent); ++} ++ ++void CPDF_GeneralState::SetRenderIntent(RenderingIntent intent) { ++ if (intent != GetRenderIntent()) { ++ ref_.GetPrivateCopy()->render_intent_ = intent; ++ } + } + + ByteString CPDF_GeneralState::GetBlendMode() const { +diff --git a/core/fpdfapi/page/cpdf_generalstate.h b/core/fpdfapi/page/cpdf_generalstate.h +--- a/core/fpdfapi/page/cpdf_generalstate.h ++++ b/core/fpdfapi/page/cpdf_generalstate.h +@@ -12,6 +12,7 @@ + #include "constants/transparency.h" + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/fx_coordinates.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/shared_copy_on_write.h" + #include "core/fxcrt/span.h" +@@ -30,7 +31,9 @@ + void Emplace() { ref_.Emplace(); } + bool HasRef() const { return !!ref_; } + ++ RenderingIntent GetRenderIntent() const; + void SetRenderIntent(const ByteString& ri); ++ void SetRenderIntent(RenderingIntent intent); + + ByteString GetBlendMode() const; + BlendMode GetBlendType() const; +@@ -98,7 +101,7 @@ + float fill_alpha_ = 1.0f; + RetainPtr tr_; + RetainPtr transfer_func_; +- int render_intent_ = 0; ++ RenderingIntent render_intent_ = RenderingIntent::kRelativeColorimetric; + bool stroke_adjust_ = false; + bool alpha_source_ = false; + bool text_knockout_ = false; +diff --git a/core/fpdfapi/page/cpdf_iccprofile.cpp b/core/fpdfapi/page/cpdf_iccprofile.cpp +--- a/core/fpdfapi/page/cpdf_iccprofile.cpp ++++ b/core/fpdfapi/page/cpdf_iccprofile.cpp +@@ -42,24 +42,51 @@ + } + + src_components_ = components; +- transform_ = std::move(transform); ++ const size_t index = ++ static_cast(RenderingIntent::kRelativeColorimetric); ++ transforms_[index] = std::move(transform); ++ transform_attempted_[index] = true; + } + + CPDF_IccProfile::~CPDF_IccProfile() = default; + + bool CPDF_IccProfile::IsNormal() const { +- return transform_->IsNormal(); ++ return transforms_[static_cast( ++ RenderingIntent::kRelativeColorimetric)] ++ ->IsNormal(); + } + +-void CPDF_IccProfile::Translate(pdfium::span src_values, +- pdfium::span dest_values) { +- transform_->Translate(src_values, dest_values); ++fxcodec::IccTransform* CPDF_IccProfile::GetTransform(RenderingIntent intent) { ++ const size_t index = static_cast(intent); ++ if (!transform_attempted_[index]) { ++ transform_attempted_[index] = true; ++ transforms_[index] = fxcodec::IccTransform::CreateTransformSRGB( ++ stream_acc_->GetSpan(), intent); ++ } ++ return transforms_[index].get(); + } + +-void CPDF_IccProfile::TranslateScanline(pdfium::span pDest, +- pdfium::span pSrc, +- int pixels) { +- transform_->TranslateScanline(pDest, pSrc, pixels); ++bool CPDF_IccProfile::Translate(pdfium::span src_values, ++ pdfium::span dest_values, ++ RenderingIntent intent) { ++ auto* transform = GetTransform(intent); ++ if (!transform) { ++ return false; ++ } ++ transform->Translate(src_values, dest_values); ++ return true; ++} ++ ++bool CPDF_IccProfile::TranslateScanline(pdfium::span dest, ++ pdfium::span src, ++ int pixels, ++ RenderingIntent intent) { ++ auto* transform = GetTransform(intent); ++ if (!transform) { ++ return false; ++ } ++ transform->TranslateScanline(dest, src, pixels); ++ return true; + } + + RetainPtr CPDF_IccProfile::GetStreamAcc() const { +diff --git a/core/fpdfapi/page/cpdf_iccprofile.h b/core/fpdfapi/page/cpdf_iccprofile.h +--- a/core/fpdfapi/page/cpdf_iccprofile.h ++++ b/core/fpdfapi/page/cpdf_iccprofile.h +@@ -9,8 +9,10 @@ + + #include + ++#include + #include + ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + +@@ -26,15 +28,20 @@ + + bool IsValid() const { return IsSRGB() || IsSupported(); } + bool IsSRGB() const { return is_srgb_; } +- bool IsSupported() const { return !!transform_; } ++ bool IsSupported() const { ++ return !!transforms_[static_cast( ++ RenderingIntent::kRelativeColorimetric)]; ++ } + uint32_t GetComponents() const { return src_components_; } + + bool IsNormal() const; +- void Translate(pdfium::span src_values, +- pdfium::span dest_values); +- void TranslateScanline(pdfium::span pDest, ++ bool Translate(pdfium::span src_values, ++ pdfium::span dest_values, ++ RenderingIntent intent); ++ bool TranslateScanline(pdfium::span pDest, + pdfium::span pSrc, +- int pixels); ++ int pixels, ++ RenderingIntent intent); + + RetainPtr GetStreamAcc() const; + +@@ -43,11 +50,14 @@ + uint32_t expected_components); + ~CPDF_IccProfile() override; + +- // Keeps stream alive for the lifetime of this object, so `transform_` can ++ fxcodec::IccTransform* GetTransform(RenderingIntent intent); ++ ++ // Keeps stream alive for the lifetime of this object, so `transforms_` can + // safely access the stream data. + RetainPtr const stream_acc_; + // Uses data from `stream_acc_`. +- std::unique_ptr transform_; ++ std::array, 4> transforms_; ++ std::array transform_attempted_ = {}; + const bool is_srgb_; + uint32_t src_components_ = 0; + }; +diff --git a/core/fpdfapi/page/cpdf_image.cpp b/core/fpdfapi/page/cpdf_image.cpp +--- a/core/fpdfapi/page/cpdf_image.cpp ++++ b/core/fpdfapi/page/cpdf_image.cpp +@@ -363,11 +363,12 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { + RetainPtr source = CreateNewDIB(); + CPDF_DIB::LoadState ret = + source->StartLoadDIBBase(true, pFormResource, pPageResource, bStdCS, +- GroupFamily, bLoadMask, max_size_required); ++ GroupFamily, bLoadMask, max_size_required, intent); + if (ret == CPDF_DIB::LoadState::kFail) { + dibbase_.Reset(); + return false; +diff --git a/core/fpdfapi/page/cpdf_image.h b/core/fpdfapi/page/cpdf_image.h +--- a/core/fpdfapi/page/cpdf_image.h ++++ b/core/fpdfapi/page/cpdf_image.h +@@ -10,6 +10,7 @@ + #include + + #include "core/fpdfapi/page/cpdf_colorspace.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -66,7 +67,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + + // Returns whether to Continue() or not. + bool Continue(PauseIndicatorIface* pPause); +diff --git a/core/fpdfapi/page/cpdf_imageloader.cpp b/core/fpdfapi/page/cpdf_imageloader.cpp +--- a/core/fpdfapi/page/cpdf_imageloader.cpp ++++ b/core/fpdfapi/page/cpdf_imageloader.cpp +@@ -30,15 +30,16 @@ + const CFX_Size& max_size_required) { + cache_ = pPageImageCache; + image_object_ = pImage; ++ const RenderingIntent intent = pImage->general_state().GetRenderIntent(); + bool should_continue; + if (cache_) { + should_continue = cache_->StartGetCachedBitmap( + image_object_->GetImage(), pFormResource, pPageResource, bStdCS, +- eFamily, bLoadMask, max_size_required); ++ eFamily, bLoadMask, max_size_required, intent); + } else { + should_continue = image_object_->GetImage()->StartLoadDIBBase( + pFormResource, pPageResource, bStdCS, eFamily, bLoadMask, +- max_size_required); ++ max_size_required, intent); + } + if (!should_continue) { + Finish(); +diff --git a/core/fpdfapi/page/cpdf_indexedcs.cpp b/core/fpdfapi/page/cpdf_indexedcs.cpp +--- a/core/fpdfapi/page/cpdf_indexedcs.cpp ++++ b/core/fpdfapi/page/cpdf_indexedcs.cpp +@@ -89,7 +89,8 @@ + } + + std::optional> CPDF_IndexedCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + int32_t index = static_cast(pBuf[0]); + if (index < 0 || index > max_index_) { + return std::nullopt; +@@ -112,5 +113,5 @@ + comp.min + + comp.max * lookup_table_[index * component_min_max_.size() + i] / 255; + } +- return base_cs_->GetRGB(comps); ++ return base_cs_->GetRGB(comps, intent); + } +diff --git a/core/fpdfapi/page/cpdf_indexedcs.h b/core/fpdfapi/page/cpdf_indexedcs.h +--- a/core/fpdfapi/page/cpdf_indexedcs.h ++++ b/core/fpdfapi/page/cpdf_indexedcs.h +@@ -29,7 +29,9 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + const CPDF_IndexedCS* AsIndexedCS() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, +diff --git a/core/fpdfapi/page/cpdf_meshstream.cpp b/core/fpdfapi/page/cpdf_meshstream.cpp +--- a/core/fpdfapi/page/cpdf_meshstream.cpp ++++ b/core/fpdfapi/page/cpdf_meshstream.cpp +@@ -102,11 +102,13 @@ + ShadingType type, + const std::vector>& funcs, + RetainPtr pShadingStream, +- RetainPtr pCS) ++ RetainPtr pCS, ++ RenderingIntent rendering_intent) + : type_(type), + funcs_(funcs), + shading_stream_(std::move(pShadingStream)), + cs_(std::move(pCS)), ++ rendering_intent_(rendering_intent), + stream_(pdfium::MakeRetain(shading_stream_)) {} + + CPDF_MeshStream::~CPDF_MeshStream() = default; +@@ -215,7 +217,7 @@ + component_max_; + } + if (funcs_.empty()) { +- return cs_->GetRGBOrZerosOnError(color_value); ++ return cs_->GetRGBOrZerosOnError(color_value, rendering_intent_); + } + return {color_value[0], 0.0f, 0.0f}; + } +diff --git a/core/fpdfapi/page/cpdf_meshstream.h b/core/fpdfapi/page/cpdf_meshstream.h +--- a/core/fpdfapi/page/cpdf_meshstream.h ++++ b/core/fpdfapi/page/cpdf_meshstream.h +@@ -14,6 +14,7 @@ + #include + + #include "core/fpdfapi/page/cpdf_shadingpattern.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxge/dib/fx_dib.h" + +@@ -40,7 +41,9 @@ + CPDF_MeshStream(ShadingType type, + const std::vector>& funcs, + RetainPtr pShadingStream, +- RetainPtr pCS); ++ RetainPtr pCS, ++ RenderingIntent rendering_intent = ++ RenderingIntent::kRelativeColorimetric); + ~CPDF_MeshStream(); + + bool Load(); +@@ -79,6 +82,7 @@ + const std::vector>& funcs_; + RetainPtr const shading_stream_; + RetainPtr const cs_; ++ const RenderingIntent rendering_intent_; + uint32_t coord_bits_ = 0; + uint32_t component_bits_ = 0; + uint32_t flag_bits_ = 0; +diff --git a/core/fpdfapi/page/cpdf_pageimagecache.cpp b/core/fpdfapi/page/cpdf_pageimagecache.cpp +--- a/core/fpdfapi/page/cpdf_pageimagecache.cpp ++++ b/core/fpdfapi/page/cpdf_pageimagecache.cpp +@@ -174,19 +174,32 @@ + bool bStdCS, + CPDF_ColorSpace::Family eFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { + // A cross-document image may have come from the embedder. + if (page_->GetDocument() != pImage->GetDocument()) { + return false; + } + + RetainPtr pStream = pImage->GetStream(); +- const auto it = image_cache_.find(pStream); ++ const RenderingIntent effective_intent = ++ CPDF_DIB::ResolveRenderingIntent(pImage->GetDict().Get(), intent); ++ auto it = image_cache_.find(pStream); ++ if (it != image_cache_.end() && ++ it->second->GetRenderingIntent() != effective_intent) { ++ // Keep one decoded variant per stream. ClearImageCacheEntry also accounts ++ // for its bytes and clears any borrowed current-entry pointer. Bitmap ++ // consumers retain their own references, so replacing this entry cannot ++ // alter pixels already being rendered. ++ ClearImageCacheEntry(pStream.Get()); ++ it = image_cache_.end(); ++ } + cur_find_cache_ = it != image_cache_.end(); + if (cur_find_cache_) { + cur_image_cache_entry_ = it->second.get(); + } else { +- cur_image_cache_entry_ = std::make_unique(std::move(pImage)); ++ cur_image_cache_entry_ = ++ std::make_unique(std::move(pImage), effective_intent); + } + CPDF_DIB::LoadState ret = cur_image_cache_entry_->StartGetCachedBitmap( + this, pFormResources, pPageResources, bStdCS, eFamily, bLoadMask, +@@ -247,8 +260,9 @@ + return cur_image_cache_entry_->DetachMask(); + } + +-CPDF_PageImageCache::Entry::Entry(RetainPtr pImage) +- : image_(std::move(pImage)) {} ++CPDF_PageImageCache::Entry::Entry(RetainPtr pImage, ++ RenderingIntent intent) ++ : image_(std::move(pImage)), rendering_intent_(intent) {} + + CPDF_PageImageCache::Entry::~Entry() = default; + +@@ -282,7 +296,7 @@ + cur_bitmap_ = image_->CreateNewDIB(); + CPDF_DIB::LoadState ret = cur_bitmap_.AsRaw()->StartLoadDIBBase( + true, pFormResources, pPageResources, bStdCS, eFamily, bLoadMask, +- max_size_required); ++ max_size_required, rendering_intent_); + cached_set_max_size_required_ = + (max_size_required.width != 0 && max_size_required.height != 0); + if (ret == CPDF_DIB::LoadState::kContinue) { +diff --git a/core/fpdfapi/page/cpdf_pageimagecache.h b/core/fpdfapi/page/cpdf_pageimagecache.h +--- a/core/fpdfapi/page/cpdf_pageimagecache.h ++++ b/core/fpdfapi/page/cpdf_pageimagecache.h +@@ -40,7 +40,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family eFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + + bool Continue(PauseIndicatorIface* pPause); + +@@ -51,7 +53,7 @@ + private: + class Entry { + public: +- explicit Entry(RetainPtr pImage); ++ Entry(RetainPtr pImage, RenderingIntent intent); + ~Entry(); + + void Reset(); +@@ -60,6 +62,7 @@ + uint32_t GetTimeCount() const { return time_count_; } + void SetTimeCount(uint32_t count) { time_count_ = count; } + CPDF_Image* GetImage() const { return image_.Get(); } ++ RenderingIntent GetRenderingIntent() const { return rendering_intent_; } + + CPDF_DIB::LoadState StartGetCachedBitmap( + CPDF_PageImageCache* pPageImageCache, +@@ -86,6 +89,7 @@ + uint32_t matte_color_ = 0; + uint32_t cache_size_ = 0; + RetainPtr const image_; ++ const RenderingIntent rendering_intent_; + RetainPtr cur_bitmap_; + RetainPtr cur_mask_; + RetainPtr cached_bitmap_; +diff --git a/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp b/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp +--- a/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp ++++ b/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp +@@ -14,6 +14,12 @@ + #include "core/fpdfapi/page/cpdf_page.h" + #include "core/fpdfapi/page/cpdf_pagemodule.h" + #include "core/fpdfapi/page/test_with_page_module.h" ++#include "core/fpdfapi/parser/cpdf_dictionary.h" ++#include "core/fpdfapi/parser/cpdf_name.h" ++#include "core/fpdfapi/parser/cpdf_number.h" ++#include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_test_document.h" ++#include "core/fxcrt/data_vector.h" + #include "core/fpdfapi/parser/cpdf_parser.h" + #include "core/fpdfapi/render/cpdf_docrenderdata.h" + #include "core/fxcrt/cfx_fileaccess_stream.h" +@@ -252,3 +258,74 @@ + } + + } // namespace pdfium ++ ++namespace pdfium { ++ ++TEST_F(CPDFPageImageCacheTest, ImageIntentReplacesSingleStreamCacheVariant) { ++ CPDF_TestDocument document; ++ auto page = pdfium::MakeRetain( ++ &document, pdfium::MakeRetain()); ++ page->AddPageImageCache(); ++ CPDF_PageImageCache* cache = page->GetPageImageCache(); ++ auto dict = pdfium::MakeRetain(); ++ dict->SetNewFor("Type", "XObject"); ++ dict->SetNewFor("Subtype", "Image"); ++ dict->SetNewFor("ColorSpace", "DeviceRGB"); ++ dict->SetNewFor("Width", 1); ++ dict->SetNewFor("Height", 1); ++ dict->SetNewFor("BitsPerComponent", 8); ++ auto stream = pdfium::MakeRetain( ++ DataVector{0x11, 0x22, 0x33}, dict); ++ auto image = pdfium::MakeRetain(&document, stream); ++ const auto load = [&](RenderingIntent intent) { ++ bool more = cache->StartGetCachedBitmap( ++ image, nullptr, nullptr, false, CPDF_ColorSpace::Family::kUnknown, ++ false, {0, 0}, intent); ++ while (more) { ++ more = cache->Continue(nullptr); ++ } ++ return cache->DetachCurBitmap(); ++ }; ++ ++ RetainPtr relative = load(RenderingIntent::kRelativeColorimetric); ++ ASSERT_TRUE(relative); ++ EXPECT_EQ(load(RenderingIntent::kRelativeColorimetric).Get(), relative.Get()); ++ RetainPtr perceptual = load(RenderingIntent::kPerceptual); ++ ASSERT_TRUE(perceptual); ++ EXPECT_NE(perceptual.Get(), relative.Get()); ++ RetainPtr relative_again = ++ load(RenderingIntent::kRelativeColorimetric); ++ ASSERT_TRUE(relative_again); ++ EXPECT_NE(relative_again.Get(), relative.Get()); ++ EXPECT_NE(relative_again.Get(), perceptual.Get()); ++ // Entry replacement must not invalidate a bitmap held by an earlier loader. ++ EXPECT_EQ(relative->GetScanline(0)[0], 0x33); ++ EXPECT_EQ(perceptual->GetScanline(0)[2], 0x11); ++ ++ for (int i = 0; i < 128; ++i) { ++ const RenderingIntent intent = ++ i % 2 ? RenderingIntent::kPerceptual : RenderingIntent::kSaturation; ++ RetainPtr bitmap = load(intent); ++ ASSERT_TRUE(bitmap); ++ // The budget should contain exactly the active single-stream variant, ++ // without charging every old intent again or underflowing on eviction. ++ cache->CacheOptimization( ++ static_cast(bitmap->GetEstimatedImageMemoryBurden())); ++ EXPECT_EQ(load(intent).Get(), bitmap.Get()); ++ } ++ ++ // Explicit /Intent takes priority. Different graphics-state intents now ++ // resolve to the same variant and therefore reuse its decoded bitmap. ++ dict->SetNewFor("Intent", "AbsoluteColorimetric"); ++ RetainPtr explicit_intent = load(RenderingIntent::kPerceptual); ++ ASSERT_TRUE(explicit_intent); ++ EXPECT_EQ(load(RenderingIntent::kSaturation).Get(), explicit_intent.Get()); ++ EXPECT_EQ(load(RenderingIntent::kRelativeColorimetric).Get(), ++ explicit_intent.Get()); ++ cache->CacheOptimization(0); ++ EXPECT_NE(load(RenderingIntent::kPerceptual).Get(), explicit_intent.Get()); ++ EXPECT_EQ(explicit_intent->GetScanline(0)[1], 0x22); ++ page->ClearView(); ++} ++ ++} // namespace pdfium +diff --git a/core/fpdfapi/page/cpdf_pattern.cpp b/core/fpdfapi/page/cpdf_pattern.cpp +--- a/core/fpdfapi/page/cpdf_pattern.cpp ++++ b/core/fpdfapi/page/cpdf_pattern.cpp +@@ -13,10 +13,12 @@ + + CPDF_Pattern::CPDF_Pattern(CPDF_Document* doc, + RetainPtr pObj, +- const CFX_Matrix& parentMatrix) ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent) + : document_(doc), + pattern_obj_(std::move(pObj)), +- parent_matrix_(parentMatrix) { ++ parent_matrix_(parentMatrix), ++ initial_render_intent_(initial_render_intent) { + DCHECK(document_); + DCHECK(pattern_obj_); + } +diff --git a/core/fpdfapi/page/cpdf_pattern.h b/core/fpdfapi/page/cpdf_pattern.h +--- a/core/fpdfapi/page/cpdf_pattern.h ++++ b/core/fpdfapi/page/cpdf_pattern.h +@@ -10,6 +10,7 @@ + #include "core/fpdfapi/parser/cpdf_object.h" + #include "core/fxcrt/fx_coordinates.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/unowned_ptr.h" + +@@ -26,11 +27,16 @@ + virtual CPDF_ShadingPattern* AsShadingPattern(); + + const CFX_Matrix& pattern_to_form() const { return pattern_to_form_; } ++ RenderingIntent initial_render_intent() const { return initial_render_intent_; } ++ bool MatchesContext(const CFX_Matrix& matrix, RenderingIntent intent) const { ++ return parent_matrix_ == matrix && initial_render_intent_ == intent; ++ } + + protected: + CPDF_Pattern(CPDF_Document* doc, + RetainPtr pObj, +- const CFX_Matrix& parentMatrix); ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent); + ~CPDF_Pattern() override; + + // All the getters that return pointers return non-NULL pointers. +@@ -45,6 +51,7 @@ + RetainPtr const pattern_obj_; + CFX_Matrix pattern_to_form_; + const CFX_Matrix parent_matrix_; ++ const RenderingIntent initial_render_intent_; + }; + + #endif // CORE_FPDFAPI_PAGE_CPDF_PATTERN_H_ +diff --git a/core/fpdfapi/page/cpdf_patterncontext_unittest.cpp b/core/fpdfapi/page/cpdf_patterncontext_unittest.cpp +--- /dev/null ++++ b/core/fpdfapi/page/cpdf_patterncontext_unittest.cpp +@@ -0,0 +1,240 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#include "core/fpdfapi/page/cpdf_pattern.h" ++ ++#include ++#include ++#include ++#include ++ ++#include "core/fpdfapi/page/cpdf_allstates.h" ++#include "core/fpdfapi/page/cpdf_docpagedata.h" ++#include "core/fpdfapi/page/cpdf_form.h" ++#include "core/fpdfapi/page/cpdf_shadingobject.h" ++#include "core/fpdfapi/page/cpdf_shadingpattern.h" ++#include "core/fpdfapi/page/cpdf_tilingpattern.h" ++#include "core/fpdfapi/page/test_with_page_module.h" ++#include "core/fpdfapi/parser/cpdf_array.h" ++#include "core/fpdfapi/parser/cpdf_dictionary.h" ++#include "core/fpdfapi/parser/cpdf_name.h" ++#include "core/fpdfapi/parser/cpdf_number.h" ++#include "core/fpdfapi/parser/cpdf_reference.h" ++#include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_test_document.h" ++#include "core/fxcrt/data_vector.h" ++#include "core/fxcrt/observed_ptr.h" ++#include "testing/gtest/include/gtest/gtest.h" ++ ++namespace { ++ ++using CPDFPatternContextTest = TestWithPageModule; ++ ++RetainPtr MakeStream(CPDF_TestDocument* document, ++ RetainPtr dict, ++ std::string_view content) { ++ return document->NewIndirect( ++ DataVector(content.begin(), content.end()), std::move(dict)); ++} ++ ++RetainPtr MakeTiling(CPDF_TestDocument* document) { ++ auto dict = pdfium::MakeRetain(); ++ dict->SetNewFor("PatternType", 1); ++ dict->SetNewFor("PaintType", 1); ++ dict->SetNewFor("TilingType", 1); ++ dict->SetNewFor("XStep", 100); ++ dict->SetNewFor("YStep", 100); ++ dict->SetRectFor("BBox", CFX_FloatRect(0, 0, 100, 100)); ++ return MakeStream(document, std::move(dict), ++ "0 0 1 rg 0 0 20 20 re f " ++ "q /AbsoluteColorimetric ri 30 0 20 20 re f " ++ "Q 60 0 20 20 re f"); ++} ++ ++RetainPtr MakeShading(CPDF_TestDocument* document) { ++ auto shading = document->NewIndirect(); ++ shading->SetNewFor("ShadingType", 2); ++ shading->SetNewFor("ColorSpace", "DeviceRGB"); ++ auto coords = shading->SetNewFor("Coords"); ++ for (int value : {0, 0, 100, 0}) { ++ coords->AppendNew(value); ++ } ++ auto function = shading->SetNewFor("Function"); ++ function->SetNewFor("FunctionType", 2); ++ function->SetNewFor("N", 1); ++ auto domain = function->SetNewFor("Domain"); ++ domain->AppendNew(0); ++ domain->AppendNew(1); ++ auto c0 = function->SetNewFor("C0"); ++ auto c1 = function->SetNewFor("C1"); ++ for (int i = 0; i < 3; ++i) { ++ c0->AppendNew(0); ++ c1->AppendNew(1); ++ } ++ return shading; ++} ++ ++RetainPtr MakeParent(CPDF_TestDocument* document, ++ const CPDF_Object* pattern, ++ const CPDF_Object* shading, ++ std::string_view content) { ++ auto dict = pdfium::MakeRetain(); ++ dict->SetRectFor("BBox", CFX_FloatRect(0, 0, 100, 100)); ++ auto resources = dict->SetNewFor("Resources"); ++ auto patterns = resources->SetNewFor("Pattern"); ++ patterns->SetNewFor("P", document, pattern->GetObjNum()); ++ if (shading) { ++ resources->SetNewFor("Shading")->SetNewFor( ++ "S", document, shading->GetObjNum()); ++ } ++ return MakeStream(document, std::move(dict), content); ++} ++ ++} // namespace ++ ++TEST_F(CPDFPatternContextTest, ++ ReplacingContextRetainsOldUsersWithoutCacheGrowth) { ++ CPDF_TestDocument document; ++ auto source = MakeTiling(&document); ++ auto* data = CPDF_DocPageData::FromDocument(&document); ++ const CFX_Matrix matrix; ++ auto first = data->GetPattern(source, matrix, RenderingIntent::kPerceptual); ++ ASSERT_TRUE(first); ++ EXPECT_EQ(data->GetPattern(source, matrix, RenderingIntent::kPerceptual), ++ first); ++ auto second = data->GetPattern(source, matrix, RenderingIntent::kSaturation); ++ ASSERT_TRUE(second); ++ EXPECT_NE(second, first); ++ auto third = data->GetPattern(source, matrix, RenderingIntent::kPerceptual); ++ EXPECT_NE(third, second); ++ EXPECT_EQ(first->initial_render_intent(), RenderingIntent::kPerceptual); ++ EXPECT_EQ(second->initial_render_intent(), RenderingIntent::kSaturation); ++ EXPECT_EQ(third->initial_render_intent(), RenderingIntent::kPerceptual); ++ ++ const CFX_Matrix translated(1, 0, 0, 1, 17, 29); ++ auto moved = ++ data->GetPattern(source, translated, RenderingIntent::kPerceptual); ++ EXPECT_NE(moved, third); ++ EXPECT_EQ(moved->pattern_to_form(), translated); ++ EXPECT_EQ(third->pattern_to_form(), matrix); ++ ++ // Once a user releases an old context, replacement releases the cache's last ++ // reference. Repeating with many distinct matrices does not retain variants. ++ for (int i = 0; i < 128; ++i) { ++ ObservedPtr previous( ++ data->GetPattern(source, CFX_Matrix(1, 0, 0, 1, i, 0), ++ RenderingIntent::kAbsoluteColorimetric) ++ .Get()); ++ ASSERT_TRUE(previous); ++ data->GetPattern(source, matrix, RenderingIntent::kRelativeColorimetric); ++ EXPECT_FALSE(previous); ++ } ++ EXPECT_EQ(first->initial_render_intent(), RenderingIntent::kPerceptual); ++ EXPECT_EQ(moved->pattern_to_form(), translated); ++} ++ ++TEST_F(CPDFPatternContextTest, ParserKeepsInitialIntentAcrossRiAndSavedState) { ++ CPDF_TestDocument document; ++ auto source = MakeTiling(&document); ++ auto parent = ++ MakeParent(&document, source.Get(), nullptr, ++ "/AbsoluteColorimetric ri /Pattern cs /P scn 0 0 20 20 re f " ++ "q /Saturation ri /P scn 30 0 20 20 re f " ++ "Q /P scn 60 0 20 20 re f"); ++ std::vector> retained_forms; ++ for (RenderingIntent initial : ++ {RenderingIntent::kPerceptual, RenderingIntent::kSaturation, ++ RenderingIntent::kPerceptual}) { ++ CPDF_AllStates state; ++ state.SetDefaultStates(); ++ state.mutable_general_state().SetRenderIntent(initial); ++ state.mutable_color_state().SetRenderIntent(initial); ++ auto form = std::make_unique(&document, nullptr, parent); ++ form->ParseContent(&state, nullptr, nullptr); ++ ASSERT_EQ(form->GetPageObjectCount(), 3u); ++ for (size_t i = 0; i < 3; ++i) { ++ auto* paint = form->GetPageObjectByIndex(i); ++ const RenderingIntent paint_intent = ++ i == 1 ? RenderingIntent::kSaturation ++ : RenderingIntent::kAbsoluteColorimetric; ++ EXPECT_EQ(paint->general_state().GetRenderIntent(), paint_intent); ++ auto pattern = paint->color_state().GetFillColor()->GetPattern(); ++ ASSERT_TRUE(pattern); ++ EXPECT_EQ(pattern->initial_render_intent(), initial); ++ auto cell = pattern->AsTilingPattern()->Load(paint); ++ ASSERT_TRUE(cell); ++ ASSERT_EQ(cell->GetPageObjectCount(), 3u); ++ EXPECT_EQ( ++ cell->GetPageObjectByIndex(0)->general_state().GetRenderIntent(), ++ initial); ++ EXPECT_EQ( ++ cell->GetPageObjectByIndex(1)->general_state().GetRenderIntent(), ++ RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_EQ( ++ cell->GetPageObjectByIndex(2)->general_state().GetRenderIntent(), ++ initial); ++ } ++ retained_forms.push_back(std::move(form)); ++ } ++ // Replacing the shared-source cache must not recolor the first parsed Form. ++ auto* original = retained_forms[0]->GetPageObjectByIndex(0); ++ auto original_pattern = original->color_state().GetFillColor()->GetPattern(); ++ EXPECT_EQ(original_pattern->initial_render_intent(), ++ RenderingIntent::kPerceptual); ++ EXPECT_EQ(original->general_state().GetRenderIntent(), ++ RenderingIntent::kAbsoluteColorimetric); ++ CPDF_Form default_form(&document, nullptr, parent); ++ default_form.ParseContent(); ++ EXPECT_EQ(default_form.GetPageObjectByIndex(0) ++ ->color_state() ++ .GetFillColor() ++ ->GetPattern() ++ ->initial_render_intent(), ++ RenderingIntent::kRelativeColorimetric); ++} ++ ++TEST_F(CPDFPatternContextTest, ShadingPatternAndShUseDifferentInheritedStates) { ++ CPDF_TestDocument document; ++ auto shading = MakeShading(&document); ++ auto pattern = document.NewIndirect(); ++ pattern->SetNewFor("PatternType", 2); ++ pattern->SetNewFor("Shading", &document, ++ shading->GetObjNum()); ++ auto parent = MakeParent( ++ &document, pattern.Get(), shading.Get(), ++ "/AbsoluteColorimetric ri /Pattern cs /P scn 0 0 20 20 re f /S sh"); ++ CPDF_AllStates state; ++ state.SetDefaultStates(); ++ state.SetRenderIntent("Perceptual"); ++ CPDF_Form form(&document, nullptr, parent); ++ form.ParseContent(&state, nullptr, nullptr); ++ ASSERT_EQ(form.GetPageObjectCount(), 2u); ++ auto* painted = form.GetPageObjectByIndex(0); ++ auto selected = painted->color_state().GetFillColor()->GetPattern(); ++ ASSERT_TRUE(selected->AsShadingPattern()); ++ EXPECT_EQ(selected->AsShadingPattern()->GetRenderIntent( ++ painted->general_state().GetRenderIntent()), ++ RenderingIntent::kPerceptual); ++ auto* direct = form.GetPageObjectByIndex(1)->AsShading(); ++ ASSERT_TRUE(direct); ++ EXPECT_EQ(direct->general_state().GetRenderIntent(), ++ RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_EQ(direct->pattern()->GetRenderIntent( ++ direct->general_state().GetRenderIntent()), ++ RenderingIntent::kAbsoluteColorimetric); ++ ++ auto gs = pattern->SetNewFor("ExtGState"); ++ gs->SetNewFor("RI", "Saturation"); ++ EXPECT_EQ(selected->AsShadingPattern()->GetRenderIntent( ++ RenderingIntent::kAbsoluteColorimetric), ++ RenderingIntent::kSaturation); ++ gs->SetNewFor("RI", 42); ++ EXPECT_EQ(selected->AsShadingPattern()->GetRenderIntent( ++ RenderingIntent::kAbsoluteColorimetric), ++ RenderingIntent::kPerceptual); ++ gs->SetNewFor("RI", "UnrecognizedIntent"); ++ EXPECT_EQ(selected->AsShadingPattern()->GetRenderIntent( ++ RenderingIntent::kAbsoluteColorimetric), ++ RenderingIntent::kRelativeColorimetric); ++} +diff --git a/core/fpdfapi/page/cpdf_patterncs.cpp b/core/fpdfapi/page/cpdf_patterncs.cpp +--- a/core/fpdfapi/page/cpdf_patterncs.cpp ++++ b/core/fpdfapi/page/cpdf_patterncs.cpp +@@ -48,7 +48,8 @@ + } + + std::optional> CPDF_PatternCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + NOTREACHED(); + } + +@@ -57,10 +58,11 @@ + } + + std::optional> CPDF_PatternCS::GetPatternRGB( +- const PatternValue& value) const { ++ const PatternValue& value, ++ RenderingIntent intent) const { + if (!base_cs_) { + return std::nullopt; + } + +- return base_cs_->GetRGB(value.GetComps()); ++ return base_cs_->GetRGB(value.GetComps(), intent); + } +diff --git a/core/fpdfapi/page/cpdf_patterncs.h b/core/fpdfapi/page/cpdf_patterncs.h +--- a/core/fpdfapi/page/cpdf_patterncs.h ++++ b/core/fpdfapi/page/cpdf_patterncs.h +@@ -27,14 +27,17 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + const CPDF_PatternCS* AsPatternCS() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; + + std::optional> GetPatternRGB( +- const PatternValue& value) const; ++ const PatternValue& value, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + + private: + CPDF_PatternCS(); +diff --git a/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp b/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp +--- /dev/null ++++ b/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp +@@ -0,0 +1,92 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#include "core/fpdfapi/page/cpdf_allstates.h" ++#include "core/fpdfapi/page/cpdf_colorspace.h" ++#include "core/fpdfapi/page/test_with_page_module.h" ++#include "testing/gtest/include/gtest/gtest.h" ++ ++namespace { ++ ++// A deterministic color space makes the color-cache and copy-on-write behavior ++// observable without relying on ICC tables or their precision. ++class IntentColorSpace final : public CPDF_ColorSpace { ++ public: ++ IntentColorSpace() : CPDF_ColorSpace(Family::kDeviceRGB) {} ++ std::optional> GetRGB( ++ pdfium::span values, ++ RenderingIntent intent) const override { ++ return FX_RGB_STRUCT{static_cast(intent) / 3.0f, 0, 0}; ++ } ++ ++ private: ++ uint32_t v_Load(CPDF_Document*, ++ const CPDF_Array*, ++ std::set*) override { ++ return 3; ++ } ++}; ++ ++} // namespace ++ ++using CPDFRenderingIntentTest = TestWithPageModule; ++ ++TEST_F(CPDFRenderingIntentTest, DefaultAndExactNames) { ++ CPDF_GeneralState state; ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++ state.SetRenderIntent("Perceptual"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kPerceptual); ++ state.SetRenderIntent("Saturation"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kSaturation); ++ state.SetRenderIntent("AbsoluteColorimetric"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kAbsoluteColorimetric); ++ for (const char* name : {"AbsoluteColorimetricSuffix", "Rela", ""}) { ++ state.SetRenderIntent("AbsoluteColorimetric"); ++ state.SetRenderIntent(name); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++ } ++ state.SetRenderIntent("RelativeColorimetric"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++} ++ ++TEST_F(CPDFRenderingIntentTest, ColorCacheAndSavedStateAreIndependent) { ++ CPDF_AllStates state; ++ state.SetDefaultStates(); ++ auto colorspace = pdfium::MakeRetain(); ++ state.mutable_color_state().SetFillColor(colorspace, {0, 0, 0}); ++ state.mutable_color_state().SetStrokeColor(colorspace, {0, 0, 0}); ++ const auto initial_fill = state.color_state().GetFillColorRef(); ++ const auto initial_stroke = state.color_state().GetStrokeColorRef(); ++ CPDF_AllStates saved(state); ++ state.SetRenderIntent("Perceptual"); ++ EXPECT_NE(state.color_state().GetFillColorRef(), initial_fill); ++ EXPECT_NE(state.color_state().GetStrokeColorRef(), initial_stroke); ++ EXPECT_EQ(saved.color_state().GetFillColorRef(), initial_fill); ++ EXPECT_EQ(saved.color_state().GetStrokeColorRef(), initial_stroke); ++ EXPECT_EQ(saved.general_state().GetRenderIntent(), ++ RenderingIntent::kRelativeColorimetric); ++ state = saved; ++ EXPECT_EQ(state.color_state().GetFillColorRef(), initial_fill); ++ state.SetRenderIntent("Saturation"); ++ const auto saturation_fill = state.color_state().GetFillColorRef(); ++ state.mutable_color_state().SetFillColor(colorspace, {1, 0, 0}); ++ EXPECT_EQ(state.color_state().GetFillColorRef(), saturation_fill); ++ state.SetRenderIntent("PerceptualInvalid"); ++ EXPECT_EQ(state.color_state().GetFillColorRef(), initial_fill); ++} ++ ++TEST_F(CPDFRenderingIntentTest, EmptyColorsSurviveIntentChanges) { ++ CPDF_ColorState state; ++ state.SetRenderIntent(RenderingIntent::kPerceptual); ++ EXPECT_FALSE(state.HasFillColor()); ++ EXPECT_FALSE(state.HasStrokeColor()); ++ state.Emplace(); ++ state.SetRenderIntent(RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_FALSE(state.HasFillColor()); ++ EXPECT_FALSE(state.HasStrokeColor()); ++ auto colorspace = pdfium::MakeRetain(); ++ state.SetFillColor(colorspace, {0, 0, 0}); ++ EXPECT_TRUE(state.HasFillColor()); ++ EXPECT_EQ(state.GetFillColorRef(), FXSYS_BGR(0, 0, 255)); ++} +diff --git a/core/fpdfapi/page/cpdf_shadingpattern.cpp b/core/fpdfapi/page/cpdf_shadingpattern.cpp +--- a/core/fpdfapi/page/cpdf_shadingpattern.cpp ++++ b/core/fpdfapi/page/cpdf_shadingpattern.cpp +@@ -11,6 +11,7 @@ + + #include "core/fpdfapi/page/cpdf_docpagedata.h" + #include "core/fpdfapi/page/cpdf_function.h" ++#include "core/fpdfapi/page/cpdf_generalstate.h" + #include "core/fpdfapi/parser/cpdf_array.h" + #include "core/fpdfapi/parser/cpdf_dictionary.h" + #include "core/fpdfapi/parser/cpdf_document.h" +@@ -33,8 +34,10 @@ + CPDF_ShadingPattern::CPDF_ShadingPattern(CPDF_Document* doc, + RetainPtr pPatternObj, + bool bShading, +- const CFX_Matrix& parentMatrix) +- : CPDF_Pattern(doc, std::move(pPatternObj), parentMatrix), ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent) ++ : CPDF_Pattern(doc, std::move(pPatternObj), parentMatrix, ++ initial_render_intent), + shading_(bShading) { + DCHECK(document()); + if (!bShading) { +@@ -97,6 +100,26 @@ + : pattern_obj()->GetDict()->GetDirectObjectFor("Shading"); + } + ++RenderingIntent CPDF_ShadingPattern::GetRenderIntent( ++ RenderingIntent inherited_intent) const { ++ if (shading_) { ++ return inherited_intent; ++ } ++ ++ RetainPtr dict = pattern_obj()->GetDict(); ++ RetainPtr state_dict = ++ dict ? dict->GetDictFor("ExtGState") : nullptr; ++ RetainPtr intent = ++ state_dict ? state_dict->GetDirectObjectFor("RI") : nullptr; ++ if (!intent || !intent->IsName()) { ++ return initial_render_intent(); ++ } ++ ++ CPDF_GeneralState state; ++ state.SetRenderIntent(intent->GetString()); ++ return state.GetRenderIntent(); ++} ++ + bool CPDF_ShadingPattern::Validate() const { + if (shading_type_ == kInvalidShading) { + return false; +diff --git a/core/fpdfapi/page/cpdf_shadingpattern.h b/core/fpdfapi/page/cpdf_shadingpattern.h +--- a/core/fpdfapi/page/cpdf_shadingpattern.h ++++ b/core/fpdfapi/page/cpdf_shadingpattern.h +@@ -55,6 +55,7 @@ + ShadingType GetShadingType() const { return shading_type_; } + bool IsShadingObject() const { return shading_; } + RetainPtr GetShadingObject() const; ++ RenderingIntent GetRenderIntent(RenderingIntent inherited_intent) const; + RetainPtr GetCS() const { return cs_; } + const std::vector>& GetFuncs() const { + return functions_; +@@ -64,7 +65,9 @@ + CPDF_ShadingPattern(CPDF_Document* doc, + RetainPtr pPatternObj, + bool bShading, +- const CFX_Matrix& parentMatrix); ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent = ++ RenderingIntent::kRelativeColorimetric); + CPDF_ShadingPattern(const CPDF_ShadingPattern&) = delete; + CPDF_ShadingPattern& operator=(const CPDF_ShadingPattern&) = delete; + +diff --git a/core/fpdfapi/page/cpdf_streamcontentparser.cpp b/core/fpdfapi/page/cpdf_streamcontentparser.cpp +--- a/core/fpdfapi/page/cpdf_streamcontentparser.cpp ++++ b/core/fpdfapi/page/cpdf_streamcontentparser.cpp +@@ -406,6 +406,8 @@ + object_holder_(pObjHolder), + recursion_state_(recursion_state), + bbox_(rcBBox), ++ initial_render_intent_(pStates ? pStates->general_state().GetRenderIntent() ++ : RenderingIntent::kRelativeColorimetric), + cur_states_(std::make_unique()) { + if (pmtContentToUser) { + mt_content_to_user_ = *pmtContentToUser; +@@ -1094,7 +1096,12 @@ + GetNumbers(3)); + } + +-void CPDF_StreamContentParser::Handle_SetRenderIntent() {} ++void CPDF_StreamContentParser::Handle_SetRenderIntent() { ++ RetainPtr name = GetObject(0); ++ if (name && name->IsName()) { ++ cur_states_->SetRenderIntent(name->GetString()); ++ } ++} + + void CPDF_StreamContentParser::Handle_CloseStrokePath() { + Handle_ClosePath(); +@@ -1299,7 +1306,7 @@ + return nullptr; + } + return CPDF_DocPageData::FromDocument(document_)->GetPattern( +- std::move(pPattern), cur_states_->parent_matrix()); ++ std::move(pPattern), cur_states_->parent_matrix(), initial_render_intent_); + } + + RetainPtr CPDF_StreamContentParser::FindShading( +diff --git a/core/fpdfapi/page/cpdf_streamcontentparser.h b/core/fpdfapi/page/cpdf_streamcontentparser.h +--- a/core/fpdfapi/page/cpdf_streamcontentparser.h ++++ b/core/fpdfapi/page/cpdf_streamcontentparser.h +@@ -20,6 +20,7 @@ + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/fx_coordinates.h" + #include "core/fxcrt/fx_number.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -216,6 +217,8 @@ + UnownedPtr const recursion_state_; + CFX_Matrix mt_content_to_user_; + const CFX_FloatRect bbox_; ++ // Remains unchanged by ri, gs, q/Q and cm within this content stream. ++ const RenderingIntent initial_render_intent_; + uint32_t param_start_pos_ = 0; + uint32_t param_count_ = 0; + std::unique_ptr syntax_; +diff --git a/core/fpdfapi/page/cpdf_tilingpattern.cpp b/core/fpdfapi/page/cpdf_tilingpattern.cpp +--- a/core/fpdfapi/page/cpdf_tilingpattern.cpp ++++ b/core/fpdfapi/page/cpdf_tilingpattern.cpp +@@ -20,8 +20,10 @@ + + CPDF_TilingPattern::CPDF_TilingPattern(CPDF_Document* doc, + RetainPtr pPatternObj, +- const CFX_Matrix& parentMatrix) +- : CPDF_Pattern(doc, std::move(pPatternObj), parentMatrix) { ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent) ++ : CPDF_Pattern(doc, std::move(pPatternObj), parentMatrix, ++ initial_render_intent) { + DCHECK(document()); + colored_ = pattern_obj()->GetDict()->GetIntegerFor("PaintType") == 1; + SetPatternToFormMatrix(); +@@ -53,6 +55,11 @@ + all_states.mutable_graph_state().Emplace(); + all_states.mutable_text_state().Emplace(); + all_states.mutable_general_state() = pPageObj->general_state(); ++ // Pattern cells inherit the initial state of their defining content stream, ++ // not the state of the object later painted with the pattern (PDF 1.7, 4.6.2). ++ // Explicit ri/gs and q/Q inside the cell are then handled by the parser. ++ all_states.mutable_general_state().SetRenderIntent(initial_render_intent()); ++ all_states.mutable_color_state().SetRenderIntent(initial_render_intent()); + form->ParseContent(&all_states, &matrix, nullptr); + bbox_ = dict->GetRectFor("BBox"); + return form; +diff --git a/core/fpdfapi/page/cpdf_tilingpattern.h b/core/fpdfapi/page/cpdf_tilingpattern.h +--- a/core/fpdfapi/page/cpdf_tilingpattern.h ++++ b/core/fpdfapi/page/cpdf_tilingpattern.h +@@ -36,7 +36,9 @@ + private: + CPDF_TilingPattern(CPDF_Document* doc, + RetainPtr pPatternObj, +- const CFX_Matrix& parentMatrix); ++ const CFX_Matrix& parentMatrix, ++ RenderingIntent initial_render_intent = ++ RenderingIntent::kRelativeColorimetric); + CPDF_TilingPattern(const CPDF_TilingPattern&) = delete; + CPDF_TilingPattern& operator=(const CPDF_TilingPattern&) = delete; + +diff --git a/core/fpdfapi/render/cpdf_rendershading.cpp b/core/fpdfapi/render/cpdf_rendershading.cpp +--- a/core/fpdfapi/render/cpdf_rendershading.cpp ++++ b/core/fpdfapi/render/cpdf_rendershading.cpp +@@ -67,6 +67,7 @@ + const std::vector>& funcs, + const RetainPtr& pCS, + int alpha, ++ RenderingIntent rendering_intent, + std::array* output) { + const uint32_t results_count = GetValidatedOutputsCount(funcs, pCS); + if (results_count == 0) { +@@ -91,7 +92,7 @@ + result_span = result_span.subspan(nresults.value()); + } + } +- auto rgb = pCS->GetRGBOrZerosOnError(result_array); ++ auto rgb = pCS->GetRGBOrZerosOnError(result_array, rendering_intent); + shading_steps[i] = + ArgbEncode(alpha, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -111,7 +112,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + RetainPtr pCoords = dict->GetArrayFor("Coords"); +@@ -141,7 +143,7 @@ + float axis_len_square = (x_span * x_span) + (y_span * y_span); + + std::array shading_steps; +- if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, &shading_steps)) { ++ if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + +@@ -179,7 +181,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + RetainPtr pCoords = dict->GetArrayFor("Coords"); +@@ -205,7 +208,7 @@ + const bool bEndExtend = pArray && pArray->GetBooleanAt(1, false); + + std::array shading_steps; +- if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, &shading_steps)) { ++ if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + +@@ -279,7 +282,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + const uint32_t total_results = GetValidatedOutputsCount(funcs, pCS); +@@ -327,7 +331,7 @@ + result_span = result_span.subspan(nresults.value()); + } + } +- auto rgb = pCS->GetRGBOrZerosOnError(result_array); ++ auto rgb = pCS->GetRGBOrZerosOnError(result_array, rendering_intent); + dib_buf[column] = ArgbEncode(alpha, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +@@ -461,11 +465,12 @@ + RetainPtr pShadingStream, + const std::vector>& funcs, + RetainPtr pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + CPDF_MeshStream stream(kFreeFormGouraudTriangleMeshShading, funcs, +- std::move(pShadingStream), pCS); ++ std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -473,7 +478,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -522,7 +527,8 @@ + RetainPtr pShadingStream, + const std::vector>& funcs, + RetainPtr pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + int row_verts = pShadingStream->GetDict()->GetIntegerFor("VerticesPerRow"); +@@ -531,7 +537,7 @@ + } + + CPDF_MeshStream stream(kLatticeFormGouraudTriangleMeshShading, funcs, +- std::move(pShadingStream), pCS); ++ std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -539,7 +545,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -853,7 +859,8 @@ + const std::vector>& funcs, + RetainPtr pCS, + bool bNoPathSmooth, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + DCHECK(type == kCoonsPatchMeshShading || + type == kTensorProductPatchMeshShading); +@@ -864,7 +871,7 @@ + return; + } + +- CPDF_MeshStream stream(type, funcs, std::move(pShadingStream), pCS); ++ CPDF_MeshStream stream(type, funcs, std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -872,7 +879,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -1012,7 +1019,8 @@ + const CFX_Matrix& mtMatrix, + const FX_RECT& clip_rect, + int alpha, +- const CPDF_RenderOptions& options) { ++ const CPDF_RenderOptions& options, ++ RenderingIntent rendering_intent) { + RetainPtr pColorSpace = pPattern->GetCS(); + if (!pColorSpace) { + return; +@@ -1027,7 +1035,7 @@ + std::vector comps = ReadArrayElementsToVector( + pBackColor.Get(), pColorSpace->ComponentCount()); + +- auto rgb = pColorSpace->GetRGBOrZerosOnError(comps); ++ auto rgb = pColorSpace->GetRGBOrZerosOnError(comps, rendering_intent); + background = ArgbEncode(255, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +@@ -1039,7 +1047,10 @@ + mtMatrix.TransformRect(dict->GetRectFor("BBox")).GetOuterRect()); + } + #if defined(PDF_USE_SKIA) +- if (pDevice->RenderCapShading() && ++ // The native shading API has no rendering-intent argument. Use the ++ // color-managed bitmap path when its default intent is not appropriate. ++ if (rendering_intent == RenderingIntent::kRelativeColorimetric && ++ pDevice->RenderCapShading() && + pDevice->DrawShading(*pPattern, mtMatrix, clip_rect_bbox, alpha)) { + return; + } +@@ -1061,15 +1072,15 @@ + return; + case kFunctionBasedShading: + DrawFuncShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kAxialShading: + DrawAxialShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kRadialShading: + DrawRadialShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kFreeFormGouraudTriangleMeshShading: { + // The shading object can be a stream or a dictionary. We do not handle +@@ -1078,7 +1089,7 @@ + ToStream(pPattern->GetShadingObject()); + if (pStream) { + DrawFreeGouraudShading(pBitmap, final_matrix, std::move(pStream), funcs, +- pColorSpace, alpha); ++ pColorSpace, alpha, rendering_intent); + } + break; + } +@@ -1089,7 +1100,7 @@ + ToStream(pPattern->GetShadingObject()); + if (pStream) { + DrawLatticeGouraudShading(pBitmap, final_matrix, std::move(pStream), +- funcs, pColorSpace, alpha); ++ funcs, pColorSpace, alpha, rendering_intent); + } + break; + } +@@ -1102,7 +1113,8 @@ + if (pStream) { + DrawCoonPatchMeshes(pPattern->GetShadingType(), pBitmap, final_matrix, + std::move(pStream), funcs, pColorSpace, +- options.GetOptions().bNoPathSmooth, alpha); ++ options.GetOptions().bNoPathSmooth, alpha, ++ rendering_intent); + } + break; + } +diff --git a/core/fpdfapi/render/cpdf_rendershading.h b/core/fpdfapi/render/cpdf_rendershading.h +--- a/core/fpdfapi/render/cpdf_rendershading.h ++++ b/core/fpdfapi/render/cpdf_rendershading.h +@@ -6,6 +6,8 @@ + + #ifndef CORE_FPDFAPI_RENDER_CPDF_RENDERSHADING_H_ + #define CORE_FPDFAPI_RENDER_CPDF_RENDERSHADING_H_ ++ ++#include "core/fxcrt/rendering_intent.h" + + class CFX_Matrix; + class CFX_RenderDevice; +@@ -24,7 +26,8 @@ + const CFX_Matrix& mtMatrix, + const FX_RECT& clip_rect, + int alpha, +- const CPDF_RenderOptions& options); ++ const CPDF_RenderOptions& options, ++ RenderingIntent rendering_intent); + + CPDF_RenderShading() = delete; + CPDF_RenderShading(const CPDF_RenderShading&) = delete; +diff --git a/core/fpdfapi/render/cpdf_renderstatus.cpp b/core/fpdfapi/render/cpdf_renderstatus.cpp +--- a/core/fpdfapi/render/cpdf_renderstatus.cpp ++++ b/core/fpdfapi/render/cpdf_renderstatus.cpp +@@ -20,6 +20,7 @@ + #include "core/fpdfapi/font/cpdf_font.h" + #include "core/fpdfapi/font/cpdf_type3char.h" + #include "core/fpdfapi/font/cpdf_type3font.h" ++#include "core/fpdfapi/page/cpdf_allstates.h" + #include "core/fpdfapi/page/cpdf_docpagedata.h" + #include "core/fpdfapi/page/cpdf_form.h" + #include "core/fpdfapi/page/cpdf_formobject.h" +@@ -962,7 +963,8 @@ + continue; + } + +- CPDF_Type3Char* pType3Char = pType3Font->LoadChar(charcode); ++ const RenderingIntent intent = textobj->general_state().GetRenderIntent(); ++ CPDF_Type3Char* pType3Char = pType3Font->LoadChar(charcode, intent); + if (!pType3Char) { + continue; + } +@@ -1064,7 +1066,7 @@ + CPDF_DocRenderData::FromDocument(doc)->GetCachedType3(pType3Font); + + const CFX_GlyphBitmap* pBitmap = +- pCache->LoadGlyphBitmap(charcode, matrix); ++ pCache->LoadGlyphBitmap(charcode, matrix, intent); + if (!pBitmap) { + continue; + } +@@ -1164,6 +1166,7 @@ + CPDF_PathObject path; + path.mutable_graph_state() = textobj->graph_state(); + path.mutable_color_state() = textobj->color_state(); ++ path.mutable_general_state() = textobj->general_state(); + + CFX_Matrix matrix = charpos.GetEffectiveMatrix(CFX_Matrix( + font_size, 0, 0, font_size, charpos.origin_.x, charpos.origin_.y)); +@@ -1200,7 +1203,8 @@ + FXSYS_roundf(255 * (stroke ? pPageObj->general_state().GetStrokeAlpha() + : pPageObj->general_state().GetFillAlpha())); + CPDF_RenderShading::Draw(device_, context_, cur_obj_, pattern, matrix, rect, +- alpha, options_); ++ alpha, options_, pattern->GetRenderIntent( ++ pPageObj->general_state().GetRenderIntent())); + } + + void CPDF_RenderStatus::ProcessShading(const CPDF_ShadingObject* pShadingObj, +@@ -1214,7 +1218,7 @@ + CPDF_RenderShading::Draw( + device_, context_, cur_obj_, pShadingObj->pattern(), matrix, rect, + FXSYS_roundf(255 * pShadingObj->general_state().GetFillAlpha()), +- options_); ++ options_, pShadingObj->general_state().GetRenderIntent()); + } + + void CPDF_RenderStatus::DrawTilingPattern(CPDF_TilingPattern* pattern, +@@ -1448,7 +1452,20 @@ + + CPDF_Form form(context_->GetDocument(), context_->GetMutablePageResources(), + pGroup); +- form.ParseContent(); ++ // Keep the parser's ordinary empty-stream initial state, inheriting only the ++ // color-conversion intent. In particular, do not inherit the active mask, ++ // alpha, clipping path, or current colors into the mask's content stream. ++ CPDF_AllStates mask_states; ++ mask_states.mutable_general_state().Emplace(); ++ mask_states.mutable_graph_state().Emplace(); ++ mask_states.mutable_text_state().Emplace(); ++ mask_states.mutable_color_state().Emplace(); ++ const RenderingIntent rendering_intent = ++ cur_obj_ ? cur_obj_->general_state().GetRenderIntent() ++ : initial_states_.general_state().GetRenderIntent(); ++ mask_states.mutable_general_state().SetRenderIntent(rendering_intent); ++ mask_states.mutable_color_state().SetRenderIntent(rendering_intent); ++ form.ParseContent(&mask_states, nullptr, nullptr); + + bool bLuminosity = + smask_dict->GetByteStringFor(pdfium::transparency::kSoftMaskSubType) != +@@ -1574,7 +1591,10 @@ + std::vector floats = ReadArrayElementsToVector(pBC.Get(), count); + floats.resize(comps); + +- auto rgb = pCS->GetRGBOrZerosOnError(floats); ++ const RenderingIntent rendering_intent = ++ cur_obj_ ? cur_obj_->general_state().GetRenderIntent() ++ : initial_states_.general_state().GetRenderIntent(); ++ auto rgb = pCS->GetRGBOrZerosOnError(floats, rendering_intent); + return ArgbEncode(255, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +diff --git a/core/fpdfapi/render/cpdf_renderstatus.h b/core/fpdfapi/render/cpdf_renderstatus.h +--- a/core/fpdfapi/render/cpdf_renderstatus.h ++++ b/core/fpdfapi/render/cpdf_renderstatus.h +@@ -106,6 +106,7 @@ + RetainPtr pObject) const; + + FX_ARGB GetFillArgb(CPDF_PageObject* pObj) const; ++ FX_ARGB GetStrokeArgb(CPDF_PageObject* pObj) const; + FX_ARGB GetFillArgbForType3(CPDF_PageObject* pObj) const; + + void DrawTilingPattern(CPDF_TilingPattern* pattern, +@@ -185,7 +186,6 @@ + FX_ARGB GetBackgroundColor(const CPDF_Dictionary* pSMaskDict, + const CPDF_Dictionary* group_dict, + CPDF_ColorSpace::Family* pCSFamily); +- FX_ARGB GetStrokeArgb(CPDF_PageObject* pObj) const; + FX_RECT GetObjectClippedRect(const CPDF_PageObject* pObj, + const CFX_Matrix& mtObj2Device) const; + // Returns the format that is compatible with `device_`. +diff --git a/core/fpdfapi/render/cpdf_rendertiling.cpp b/core/fpdfapi/render/cpdf_rendertiling.cpp +--- a/core/fpdfapi/render/cpdf_rendertiling.cpp ++++ b/core/fpdfapi/render/cpdf_rendertiling.cpp +@@ -234,7 +234,8 @@ + pPatternBitmap->ConvertColorScale(/*is_white_on_black=*/false); + } + +- FX_ARGB fill_argb = pRenderStatus->GetFillArgb(pPageObj); ++ FX_ARGB fill_argb = bStroke ? pRenderStatus->GetStrokeArgb(pPageObj) ++ : pRenderStatus->GetFillArgb(pPageObj); + int clip_width = clip_box.right - clip_box.left; + int clip_height = clip_box.bottom - clip_box.top; + auto pScreen = pdfium::MakeRetain(); +diff --git a/core/fpdfapi/render/cpdf_type3cache.cpp b/core/fpdfapi/render/cpdf_type3cache.cpp +--- a/core/fpdfapi/render/cpdf_type3cache.cpp ++++ b/core/fpdfapi/render/cpdf_type3cache.cpp +@@ -82,12 +82,19 @@ + + const CFX_GlyphBitmap* CPDF_Type3Cache::LoadGlyphBitmap( + uint32_t charcode, +- const CFX_Matrix& mtMatrix) { ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent) { ++ if (charcode >= 256 || static_cast(intent) > ++ static_cast( ++ RenderingIntent::kAbsoluteColorimetric)) { ++ return nullptr; ++ } + SizeKey keygen = { + FXSYS_roundf(mtMatrix.a * 10000), + FXSYS_roundf(mtMatrix.b * 10000), + FXSYS_roundf(mtMatrix.c * 10000), + FXSYS_roundf(mtMatrix.d * 10000), ++ intent, + }; + CPDF_Type3GlyphMap* pSizeCache; + auto it = size_map_.find(keygen); +@@ -104,7 +111,7 @@ + } + + std::unique_ptr pNewBitmap = +- RenderGlyph(pSizeCache, charcode, mtMatrix); ++ RenderGlyph(pSizeCache, charcode, mtMatrix, intent); + CFX_GlyphBitmap* pGlyphBitmap = pNewBitmap.get(); + pSizeCache->SetBitmap(charcode, std::move(pNewBitmap)); + return pGlyphBitmap; +@@ -113,8 +120,9 @@ + std::unique_ptr CPDF_Type3Cache::RenderGlyph( + CPDF_Type3GlyphMap* pSize, + uint32_t charcode, +- const CFX_Matrix& mtMatrix) { +- CPDF_Type3Char* pChar = font_->LoadChar(charcode); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent) { ++ CPDF_Type3Char* pChar = font_->LoadChar(charcode, intent); + if (!pChar) { + return nullptr; + } +diff --git a/core/fpdfapi/render/cpdf_type3cache.h b/core/fpdfapi/render/cpdf_type3cache.h +--- a/core/fpdfapi/render/cpdf_type3cache.h ++++ b/core/fpdfapi/render/cpdf_type3cache.h +@@ -15,6 +15,7 @@ + + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + + class CFX_GlyphBitmap; +@@ -27,17 +28,20 @@ + CONSTRUCT_VIA_MAKE_RETAIN; + + const CFX_GlyphBitmap* LoadGlyphBitmap(uint32_t charcode, +- const CFX_Matrix& mtMatrix); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent); + + private: +- using SizeKey = std::tuple; ++ // Preserve the existing size key, with at most four RI variants per size. ++ using SizeKey = std::tuple; + + explicit CPDF_Type3Cache(CPDF_Type3Font* font); + ~CPDF_Type3Cache() override; + + std::unique_ptr RenderGlyph(CPDF_Type3GlyphMap* pSize, + uint32_t charcode, +- const CFX_Matrix& mtMatrix); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent); + + RetainPtr const font_; + std::map> size_map_; +diff --git a/core/fxcodec/icc/icc_transform.cpp b/core/fxcodec/icc/icc_transform.cpp +--- a/core/fxcodec/icc/icc_transform.cpp ++++ b/core/fxcodec/icc/icc_transform.cpp +@@ -54,7 +54,8 @@ + + // static + std::unique_ptr IccTransform::CreateTransformSRGB( +- pdfium::span span) { ++ pdfium::span span, ++ RenderingIntent intent) { + ScopedCmsProfile srcProfile(cmsOpenProfileFromMem( + span.data(), pdfium::checked_cast(span.size()))); + if (!srcProfile) { +@@ -96,9 +97,9 @@ + cmsHTRANSFORM hTransform = nullptr; + switch (dstCS) { + case cmsSigRgbData: +- hTransform = +- cmsCreateTransform(srcProfile.get(), srcFormat, dstProfile.get(), +- TYPE_BGR_8, INTENT_PERCEPTUAL, /*dwFlags=*/0); ++ hTransform = cmsCreateTransform( ++ srcProfile.get(), srcFormat, dstProfile.get(), TYPE_BGR_8, ++ static_cast(intent), /*dwFlags=*/0); + break; + case cmsSigGrayData: + case cmsSigCmykData: +diff --git a/core/fxcodec/icc/icc_transform.h b/core/fxcodec/icc/icc_transform.h +--- a/core/fxcodec/icc/icc_transform.h ++++ b/core/fxcodec/icc/icc_transform.h +@@ -12,6 +12,7 @@ + #include + + #include "core/fxcodec/fx_codec_def.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/span.h" + + #if defined(USE_SYSTEM_LCMS2) +@@ -25,7 +26,8 @@ + class IccTransform { + public: + static std::unique_ptr CreateTransformSRGB( +- pdfium::span span); ++ pdfium::span span, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric); + + ~IccTransform(); + +diff --git a/core/fxcrt/BUILD.gn b/core/fxcrt/BUILD.gn +--- a/core/fxcrt/BUILD.gn ++++ b/core/fxcrt/BUILD.gn +@@ -113,6 +113,7 @@ + "ptr_util.h", + "raw_span.h", + "retain_ptr.h", ++ "rendering_intent.h", + "scoped_set_insertion.h", + "shared_copy_on_write.h", + "span.h", +diff --git a/core/fxcrt/rendering_intent.h b/core/fxcrt/rendering_intent.h +--- /dev/null ++++ b/core/fxcrt/rendering_intent.h +@@ -0,0 +1,16 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#ifndef CORE_FXCRT_RENDERING_INTENT_H_ ++#define CORE_FXCRT_RENDERING_INTENT_H_ ++ ++// Values match the ICC rendering intents, as used by LittleCMS. ++enum class RenderingIntent { ++ kPerceptual = 0, ++ kRelativeColorimetric = 1, ++ kSaturation = 2, ++ kAbsoluteColorimetric = 3, ++}; ++ ++#endif // CORE_FXCRT_RENDERING_INTENT_H_ diff --git a/cmake/patches/pdfium-rendering-intent.patch b/cmake/patches/pdfium-rendering-intent.patch new file mode 100644 index 0000000..77a4773 --- /dev/null +++ b/cmake/patches/pdfium-rendering-intent.patch @@ -0,0 +1,2401 @@ +diff --git a/core/fpdfapi/font/BUILD.gn b/core/fpdfapi/font/BUILD.gn +--- a/core/fpdfapi/font/BUILD.gn ++++ b/core/fpdfapi/font/BUILD.gn +@@ -62,10 +62,12 @@ + "cpdf_simplefont_unittest.cpp", + "cpdf_tounicodemap_unittest.cpp", + "cpdf_truetypefont_unittest.cpp", ++ "cpdf_type3font_unittest.cpp", + ] + deps = [ + ":font", + "../../fxge", ++ "../page", + "../page:unit_test_support", + "../parser", + "../parser:unit_test_support", +diff --git a/core/fpdfapi/font/cpdf_font.h b/core/fpdfapi/font/cpdf_font.h +--- a/core/fpdfapi/font/cpdf_font.h ++++ b/core/fpdfapi/font/cpdf_font.h +@@ -20,6 +20,7 @@ + #include "core/fxcrt/fx_coordinates.h" + #include "core/fxcrt/fx_string.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/unowned_ptr.h" + #include "core/fxge/cfx_font.h" +@@ -43,7 +44,8 @@ + public: + virtual ~FormIface() = default; + +- virtual void ParseContentForType3Char(CPDF_Type3Char* pChar) = 0; ++ virtual void ParseContentForType3Char(CPDF_Type3Char* pChar, ++ RenderingIntent intent) = 0; + virtual bool HasPageObjects() const = 0; + virtual CFX_FloatRect CalcBoundingBox() const = 0; + virtual std::optional, CFX_Matrix>> +diff --git a/core/fpdfapi/font/cpdf_type3font.cpp b/core/fpdfapi/font/cpdf_type3font.cpp +--- a/core/fpdfapi/font/cpdf_type3font.cpp ++++ b/core/fpdfapi/font/cpdf_type3font.cpp +@@ -96,12 +96,17 @@ + CheckFontMetrics(); + } + +-CPDF_Type3Char* CPDF_Type3Font::LoadChar(uint32_t charcode) { +- if (char_loading_depth_ >= kMaxType3FormLevel) { ++CPDF_Type3Char* CPDF_Type3Font::LoadChar(uint32_t charcode, ++ RenderingIntent intent) { ++ if (charcode >= char_width_l_.size() || ++ static_cast(intent) > ++ static_cast(RenderingIntent::kAbsoluteColorimetric) || ++ char_loading_depth_ >= kMaxType3FormLevel) { + return nullptr; + } + +- auto it = cache_map_.find(charcode); ++ const GlyphKey key{charcode, intent}; ++ auto it = cache_map_.find(key); + if (it != cache_map_.end()) { + return it->second.get(); + } +@@ -132,9 +137,9 @@ + { + AutoRestorer restorer(&char_loading_depth_); + char_loading_depth_++; +- pForm->ParseContentForType3Char(pNewChar.get()); ++ pForm->ParseContentForType3Char(pNewChar.get(), intent); + } +- it = cache_map_.find(charcode); ++ it = cache_map_.find(key); + if (it != cache_map_.end()) { + return it->second.get(); + } +@@ -145,7 +150,7 @@ + } + + CPDF_Type3Char* pCachedChar = pNewChar.get(); +- cache_map_[charcode] = std::move(pNewChar); ++ cache_map_[key] = std::move(pNewChar); + return pCachedChar; + } + +diff --git a/core/fpdfapi/font/cpdf_type3font.h b/core/fpdfapi/font/cpdf_type3font.h +--- a/core/fpdfapi/font/cpdf_type3font.h ++++ b/core/fpdfapi/font/cpdf_type3font.h +@@ -12,6 +12,7 @@ + #include + #include + #include ++#include + + #include "core/fpdfapi/font/cpdf_simplefont.h" + #include "core/fxcrt/fx_coordinates.h" +@@ -36,7 +37,9 @@ + void SetPageResources(CPDF_Dictionary* pResources) { + page_resources_.Reset(pResources); + } +- CPDF_Type3Char* LoadChar(uint32_t charcode); ++ CPDF_Type3Char* LoadChar( ++ uint32_t charcode, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric); + void CheckType3FontMetrics(); + + CFX_Matrix& GetFontMatrix() { return font_matrix_; } +@@ -56,7 +59,10 @@ + RetainPtr char_procs_; + RetainPtr page_resources_; + RetainPtr font_resources_; +- std::map> cache_map_; ++ // Type 3 uses one-byte character codes. At most four parsed variants per ++ // character preserve inherited RI while keeping glyph-local ri/q/Q intact. ++ using GlyphKey = std::pair; ++ std::map> cache_map_; + std::array char_width_l_ = {}; + }; + +diff --git a/core/fpdfapi/font/cpdf_type3font_unittest.cpp b/core/fpdfapi/font/cpdf_type3font_unittest.cpp +--- /dev/null ++++ b/core/fpdfapi/font/cpdf_type3font_unittest.cpp +@@ -0,0 +1,147 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#include "core/fpdfapi/font/cpdf_type3font.h" ++ ++#include ++#include ++#include ++#include ++ ++#include "core/fpdfapi/font/cpdf_type3char.h" ++#include "core/fpdfapi/page/cpdf_form.h" ++#include "core/fpdfapi/page/cpdf_pageobject.h" ++#include "core/fpdfapi/page/test_with_page_module.h" ++#include "core/fpdfapi/parser/cpdf_dictionary.h" ++#include "core/fpdfapi/parser/cpdf_name.h" ++#include "core/fpdfapi/parser/cpdf_reference.h" ++#include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_test_document.h" ++#include "core/fpdfapi/render/cpdf_type3cache.h" ++#include "core/fxcrt/data_vector.h" ++#include "testing/gtest/include/gtest/gtest.h" ++ ++namespace { ++ ++using CPDFType3FontTest = TestWithPageModule; ++ ++constexpr RenderingIntent kIntents[] = { ++ RenderingIntent::kPerceptual, ++ RenderingIntent::kRelativeColorimetric, ++ RenderingIntent::kSaturation, ++ RenderingIntent::kAbsoluteColorimetric, ++}; ++ ++class CountingFormFactory final : public CPDF_Font::FormFactoryIface { ++ public: ++ std::unique_ptr CreateForm( ++ CPDF_Document* document, ++ RetainPtr resources, ++ RetainPtr stream) override { ++ ++count; ++ return std::make_unique(document, std::move(resources), ++ std::move(stream)); ++ } ++ ++ int count = 0; ++}; ++ ++RetainPtr MakeType3Font(CPDF_TestDocument* document, ++ CountingFormFactory* factory, ++ std::string_view content) { ++ auto stream = document->NewIndirect( ++ DataVector(content.begin(), content.end()), ++ pdfium::MakeRetain()); ++ auto dict = pdfium::MakeRetain(); ++ dict->SetNewFor("Type", "Font"); ++ dict->SetNewFor("Subtype", "Type3"); ++ dict->SetNewFor("Encoding", "WinAnsiEncoding"); ++ dict->SetMatrixFor("FontMatrix", CFX_Matrix(0.001f, 0, 0, 0.001f, 0, 0)); ++ dict->SetRectFor("FontBBox", CFX_FloatRect(0, 0, 600, 600)); ++ auto char_procs = dict->SetNewFor("CharProcs"); ++ char_procs->SetNewFor("A", document, stream->GetObjNum()); ++ return CPDF_Font::Create(document, std::move(dict), factory); ++} ++ ++} // namespace ++ ++TEST_F(CPDFType3FontTest, IntentVariantsPreserveExplicitIntentAndSavedState) { ++ CPDF_TestDocument document; ++ CountingFormFactory factory; ++ auto font = MakeType3Font( ++ &document, &factory, ++ "600 0 d0 0 0 1 rg 0 0 20 20 re f " ++ "q /AbsoluteColorimetric ri 30 0 20 20 re f Q 60 0 20 20 re f"); ++ ASSERT_TRUE(font); ++ CPDF_Type3Font* type3 = font->AsType3Font(); ++ ASSERT_TRUE(type3); ++ std::array variants{}; ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ variants[i] = type3->LoadChar(65, kIntents[i]); ++ ASSERT_TRUE(variants[i]); ++ const auto* form = static_cast(variants[i]->form()); ++ ASSERT_TRUE(form); ++ ASSERT_EQ(form->GetPageObjectCount(), 3u); ++ EXPECT_EQ(form->GetPageObjectByIndex(0)->general_state().GetRenderIntent(), ++ kIntents[i]); ++ EXPECT_EQ(form->GetPageObjectByIndex(1)->general_state().GetRenderIntent(), ++ RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_EQ(form->GetPageObjectByIndex(2)->general_state().GetRenderIntent(), ++ kIntents[i]); ++ for (size_t j = 0; j < i; ++j) { ++ EXPECT_NE(variants[j], variants[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ for (int repeat = 0; repeat < 32; ++repeat) { ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ EXPECT_EQ(type3->LoadChar(65, kIntents[i]), variants[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ // Metrics use the default variant and do not create a fifth entry. ++ EXPECT_EQ(type3->GetCharWidth(65), variants[1]->width()); ++ EXPECT_EQ(type3->GetCharBBox(65), variants[1]->bbox()); ++ EXPECT_EQ(factory.count, 4); ++ EXPECT_FALSE(type3->LoadChar(256, RenderingIntent::kPerceptual)); ++ EXPECT_FALSE(type3->LoadChar(65, static_cast(4))); ++ EXPECT_EQ(factory.count, 4); ++ // Earlier variants remain alive and retain their own inherited state. ++ const auto* first_form = static_cast(variants[0]->form()); ++ EXPECT_EQ(first_form->GetPageObjectByIndex(2)->general_state().GetRenderIntent(), ++ RenderingIntent::kPerceptual); ++} ++ ++TEST_F(CPDFType3FontTest, BitmapCacheUsesTheSameIntentVariantAsFontCache) { ++ CPDF_TestDocument document; ++ CountingFormFactory factory; ++ auto font = MakeType3Font( ++ &document, &factory, ++ "600 0 0 0 10 10 d1 10 0 0 10 0 0 cm " ++ "BI /W 1 /H 1 /IM true /BPC 1 ID \xff EI"); ++ ASSERT_TRUE(font); ++ CPDF_Type3Font* type3 = font->AsType3Font(); ++ ASSERT_TRUE(type3); ++ auto cache = pdfium::MakeRetain(type3); ++ const CFX_Matrix matrix(10, 0, 0, 10, 0, 0); ++ std::array bitmaps{}; ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ auto* glyph = type3->LoadChar(65, kIntents[i]); ++ ASSERT_TRUE(glyph); ++ ASSERT_TRUE(glyph->LoadBitmapFromSoleImageOfForm()); ++ bitmaps[i] = cache->LoadGlyphBitmap(65, matrix, kIntents[i]); ++ ASSERT_TRUE(bitmaps[i]); ++ EXPECT_EQ(cache->LoadGlyphBitmap(65, matrix, kIntents[i]), bitmaps[i]); ++ for (size_t j = 0; j < i; ++j) { ++ EXPECT_NE(bitmaps[j], bitmaps[i]); ++ } ++ } ++ EXPECT_EQ(factory.count, 4); ++ for (size_t i = 0; i < std::size(kIntents); ++i) { ++ EXPECT_EQ(cache->LoadGlyphBitmap(65, matrix, kIntents[i]), bitmaps[i]); ++ } ++ EXPECT_EQ(factory.count, 4); ++ EXPECT_FALSE(cache->LoadGlyphBitmap(256, matrix, kIntents[0])); ++ EXPECT_FALSE(cache->LoadGlyphBitmap(65, matrix, static_cast(4))); ++} +diff --git a/core/fpdfapi/page/BUILD.gn b/core/fpdfapi/page/BUILD.gn +--- a/core/fpdfapi/page/BUILD.gn ++++ b/core/fpdfapi/page/BUILD.gn +@@ -150,6 +150,7 @@ + "cpdf_pageimagecache_unittest.cpp", + "cpdf_pageobjectholder_unittest.cpp", + "cpdf_psengine_unittest.cpp", ++ "cpdf_renderingintent_unittest.cpp", + "cpdf_streamcontentparser_unittest.cpp", + "cpdf_streamparser_unittest.cpp", + ] +diff --git a/core/fpdfapi/page/cpdf_allstates.cpp b/core/fpdfapi/page/cpdf_allstates.cpp +--- a/core/fpdfapi/page/cpdf_allstates.cpp ++++ b/core/fpdfapi/page/cpdf_allstates.cpp +@@ -27,6 +27,11 @@ + + CPDF_AllStates::~CPDF_AllStates() = default; + ++void CPDF_AllStates::SetRenderIntent(const ByteString& name) { ++ mutable_general_state().SetRenderIntent(name); ++ mutable_color_state().SetRenderIntent(general_state().GetRenderIntent()); ++} ++ + void CPDF_AllStates::SetDefaultStates() { + graphic_states_.SetDefaultStates(); + } +@@ -76,7 +81,9 @@ + break; + } + case FXBSTR_ID('R', 'I', 0, 0): +- mutable_general_state().SetRenderIntent(pObject->GetString()); ++ if (pObject->IsName()) { ++ SetRenderIntent(pObject->GetString()); ++ } + break; + case FXBSTR_ID('F', 'o', 'n', 't'): { + const CPDF_Array* font = pObject->AsArray(); +diff --git a/core/fpdfapi/page/cpdf_allstates.h b/core/fpdfapi/page/cpdf_allstates.h +--- a/core/fpdfapi/page/cpdf_allstates.h ++++ b/core/fpdfapi/page/cpdf_allstates.h +@@ -22,6 +22,7 @@ + ~CPDF_AllStates(); + + void SetDefaultStates(); ++ void SetRenderIntent(const ByteString& name); + + void ProcessExtGS(const CPDF_Dictionary* pGS, + CPDF_StreamContentParser* pParser); +diff --git a/core/fpdfapi/page/cpdf_color.cpp b/core/fpdfapi/page/cpdf_color.cpp +--- a/core/fpdfapi/page/cpdf_color.cpp ++++ b/core/fpdfapi/page/cpdf_color.cpp +@@ -100,8 +100,9 @@ + CPDF_ColorSpace::GetStockCS(CPDF_ColorSpace::Family::kDeviceGray); + } + +-std::optional CPDF_Color::GetColorRef() const { +- std::optional> maybe_rgb = GetRGB(); ++std::optional CPDF_Color::GetColorRef( ++ RenderingIntent intent) const { ++ std::optional> maybe_rgb = GetRGB(intent); + if (!maybe_rgb.has_value()) { + return std::nullopt; + } +@@ -113,17 +114,18 @@ + FXSYS_roundf(r * 255.0f)); + } + +-std::optional> CPDF_Color::GetRGB() const { ++std::optional> CPDF_Color::GetRGB( ++ RenderingIntent intent) const { + if (IsPatternInternal()) { + if (std::holds_alternative>(color_data_)) { + const auto& pattern_value = + std::get>(color_data_); +- return cs_->AsPatternCS()->GetPatternRGB(*pattern_value); ++ return cs_->AsPatternCS()->GetPatternRGB(*pattern_value, intent); + } + } else { + if (std::holds_alternative>(color_data_)) { + const auto& buffer = std::get>(color_data_); +- return cs_->GetRGB(buffer); ++ return cs_->GetRGB(buffer, intent); + } + } + return std::nullopt; +diff --git a/core/fpdfapi/page/cpdf_color.h b/core/fpdfapi/page/cpdf_color.h +--- a/core/fpdfapi/page/cpdf_color.h ++++ b/core/fpdfapi/page/cpdf_color.h +@@ -14,6 +14,7 @@ + #include + #include + ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxge/dib/fx_dib.h" +@@ -44,8 +45,10 @@ + // Wrapper around GetRGB() that returns the RGB value as FX_COLORREF. The + // GetRGB() return value is clamped to fit into FX_COLORREF, where the color + // components are 8-bit fields within an unsigned integer. +- std::optional GetColorRef() const; +- std::optional> GetRGB() const; ++ std::optional GetColorRef( ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; ++ std::optional> GetRGB( ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + + // Should only be called if IsPattern() returns true. + RetainPtr GetPattern() const; +diff --git a/core/fpdfapi/page/cpdf_colorspace.cpp b/core/fpdfapi/page/cpdf_colorspace.cpp +--- a/core/fpdfapi/page/cpdf_colorspace.cpp ++++ b/core/fpdfapi/page/cpdf_colorspace.cpp +@@ -126,7 +126,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -135,7 +136,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + + private: + static constexpr float kDefaultGamma = 1.0f; +@@ -154,13 +156,15 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void TranslateImageLine(pdfium::span dest_span, + pdfium::span src_span, + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -184,7 +188,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -194,7 +199,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +@@ -216,14 +222,16 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + RetainPtr GetIccProfile() const override; + void TranslateImageLine(pdfium::span dest_span, + pdfium::span src_span, + int pixels, + int image_width, + int image_height, +- bool bTransMask) const override; ++ bool bTransMask, ++ RenderingIntent intent) const override; + bool IsNormal() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, +@@ -253,7 +261,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -276,7 +285,8 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent) const override; + void GetDefaultValue(int iComponent, + float* value, + float* min, +@@ -638,7 +648,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + // Only applies to CMYK colorspaces. None of the colorspaces that use this + // generic base implementation are CMYK. + CHECK(!bTransMask); +@@ -652,7 +663,7 @@ + for (uint32_t j = 0; j < components_; j++) { + src[j] = static_cast(*src_buf++) / divisor; + } +- auto rgb = GetRGBOrZerosOnError(src); ++ auto rgb = GetRGBOrZerosOnError(src, intent); + *dest_buf++ = static_cast(rgb.blue * 255); + *dest_buf++ = static_cast(rgb.green * 255); + *dest_buf++ = static_cast(rgb.red * 255); +@@ -717,7 +728,8 @@ + } + + std::optional> CPDF_CalGray::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + const float gray = pBuf[0]; + return FX_RGB_STRUCT{gray, gray, gray}; + } +@@ -727,7 +739,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + auto gray_span = src_span.first(static_cast(pixels)); +@@ -778,7 +791,8 @@ + } + + std::optional> CPDF_CalRGB::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + float a = pBuf[0]; + float b = pBuf[1]; + float c = pBuf[2]; +@@ -810,7 +824,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + fxcodec::ReverseRGB(dest_span, src_span, pixels); + } +@@ -865,7 +880,8 @@ + } + + std::optional> CPDF_LabCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + float Lstar = pBuf[0]; + float astar = pBuf[1]; + float bstar = pBuf[2]; +@@ -901,7 +917,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + auto bgr_span = fxcrt::reinterpret_span>(dest_span); +@@ -917,7 +934,7 @@ + // Better code than the equivalent GetRGBOrZerosOnError() since that + // is implemented in a base class and can't devirtualize the GetRGB() + // call despite this class being marked final. +- auto rgb = GetRGB(lab).value_or(FX_RGB_STRUCT{}); ++ auto rgb = GetRGB(lab, intent).value_or(FX_RGB_STRUCT{}); + bgr_ref.blue = static_cast(rgb.blue * 255); + bgr_ref.green = static_cast(rgb.green * 255); + bgr_ref.red = static_cast(rgb.red * 255); +@@ -969,17 +986,20 @@ + } + + std::optional> CPDF_ICCBasedCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (profile_->IsSRGB()) { + return FX_RGB_STRUCT{pBuf[0], pBuf[1], pBuf[2]}; + } + if (profile_->IsSupported()) { + float rgb[3]; +- profile_->Translate(pBuf.first(ComponentCount()), rgb); +- return FX_RGB_STRUCT{rgb[0], rgb[1], rgb[2]}; ++ if (profile_->Translate(pBuf.first(ComponentCount()), rgb, intent)) { ++ return FX_RGB_STRUCT{rgb[0], rgb[1], rgb[2]}; ++ } ++ return std::nullopt; + } + if (base_cs_) { +- return base_cs_->GetRGB(pBuf); ++ return base_cs_->GetRGB(pBuf, intent); + } + return FX_RGB_STRUCT{}; + } +@@ -993,7 +1013,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + CHECK(!bTransMask); // Only applies to CMYK colorspaces. + + if (profile_->IsSRGB()) { +@@ -1003,12 +1024,14 @@ + if (!profile_->IsSupported()) { + if (base_cs_) { + base_cs_->TranslateImageLine(dest_span, src_span, pixels, image_width, +- image_height, false); ++ image_height, false, intent); + } + return; + } + +- profile_->TranslateScanline(dest_span, src_span, pixels); ++ if (!profile_->TranslateScanline(dest_span, src_span, pixels, intent)) { ++ std::ranges::fill(dest_span.first(static_cast(pixels) * 3), 0); ++ } + } + + bool CPDF_ICCBasedCS::IsNormal() const { +@@ -1131,7 +1154,8 @@ + } + + std::optional> CPDF_SeparationCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (is_none_type_) { + return std::nullopt; + } +@@ -1140,7 +1164,7 @@ + return std::nullopt; + } + std::vector results(base_cs_->ComponentCount(), pBuf[0]); +- return base_cs_->GetRGB(results); ++ return base_cs_->GetRGB(results, intent); + } + + // Using at least 16 elements due to the call alt_cs_->GetRGB() below. +@@ -1150,7 +1174,7 @@ + return std::nullopt; + } + if (base_cs_) { +- return base_cs_->GetRGB(results); ++ return base_cs_->GetRGB(results, intent); + } + return std::nullopt; + } +@@ -1199,7 +1223,8 @@ + } + + std::optional> CPDF_DeviceNCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + if (!func_) { + return std::nullopt; + } +@@ -1211,5 +1236,5 @@ + if (nresults == 0) { + return std::nullopt; + } +- return base_cs_->GetRGB(results); +-} ++ return base_cs_->GetRGB(results, intent); ++} +diff --git a/core/fpdfapi/page/cpdf_colorspace.h b/core/fpdfapi/page/cpdf_colorspace.h +--- a/core/fpdfapi/page/cpdf_colorspace.h ++++ b/core/fpdfapi/page/cpdf_colorspace.h +@@ -21,6 +21,7 @@ + #include "core/fpdfapi/parser/cpdf_object.h" + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -103,13 +104,16 @@ + // Wrapper around GetRGB() that returns black (0, 0, 0) when an actual value + // can not be determined. + FX_RGB_STRUCT GetRGBOrZerosOnError( +- pdfium::span pBuf) const { +- return GetRGB(pBuf).value_or(FX_RGB_STRUCT{}); ++ pdfium::span pBuf, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const { ++ return GetRGB(pBuf, intent).value_or(FX_RGB_STRUCT{}); + } + + // Use CPDF_Pattern::GetPatternColorRef() instead of GetRGB() for patterns. + virtual std::optional> GetRGB( +- pdfium::span pBuf) const = 0; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const = 0; + + virtual RetainPtr GetIccProfile() const; + +@@ -118,12 +122,14 @@ + float* min, + float* max) const; + +- virtual void TranslateImageLine(pdfium::span dest_span, +- pdfium::span src_span, +- int pixels, +- int image_width, +- int image_height, +- bool bTransMask) const; ++ virtual void TranslateImageLine( ++ pdfium::span dest_span, ++ pdfium::span src_span, ++ int pixels, ++ int image_width, ++ int image_height, ++ bool bTransMask, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + virtual void EnableStdConversion(bool bEnabled); + virtual bool IsNormal() const; + +diff --git a/core/fpdfapi/page/cpdf_colorstate.cpp b/core/fpdfapi/page/cpdf_colorstate.cpp +--- a/core/fpdfapi/page/cpdf_colorstate.cpp ++++ b/core/fpdfapi/page/cpdf_colorstate.cpp +@@ -26,6 +26,24 @@ + + void CPDF_ColorState::SetDefault() { + ref_.GetPrivateCopy()->SetDefault(); ++} ++ ++void CPDF_ColorState::SetRenderIntent(RenderingIntent intent) { ++ if (ref_ && ref_.GetObject()->render_intent_ == intent) { ++ return; ++ } ++ ColorData* data = ref_.GetPrivateCopy(); ++ data->render_intent_ = intent; ++ if (!data->fill_color_.IsNull()) { ++ if (auto color = data->fill_color_.GetColorRef(intent)) { ++ data->fill_color_ref_ = *color; ++ } ++ } ++ if (!data->stroke_color_.IsNull()) { ++ if (auto color = data->stroke_color_.GetColorRef(intent)) { ++ data->stroke_color_ref_ = *color; ++ } ++ } + } + + FX_COLORREF CPDF_ColorState::GetFillColorRef() const { +@@ -127,14 +145,16 @@ + if (!color.IsPattern()) { + color.SetValueForNonPattern(std::move(values)); + } +- return color.GetColorRef().value_or(0xFFFFFFFF); ++ return color.GetColorRef(ref_.GetObject()->render_intent_) ++ .value_or(0xFFFFFFFF); + } + + FX_COLORREF CPDF_ColorState::SetPattern(RetainPtr pattern, + pdfium::span values, + CPDF_Color& color) { + color.SetValueForPattern(pattern, values); +- std::optional colorref = color.GetColorRef(); ++ std::optional colorref = ++ color.GetColorRef(ref_.GetObject()->render_intent_); + if (colorref.has_value()) { + return colorref.value(); + } +@@ -146,7 +166,8 @@ + CPDF_ColorState::ColorData::ColorData() = default; + + CPDF_ColorState::ColorData::ColorData(const ColorData& src) +- : fill_color_ref_(src.fill_color_ref_), ++ : render_intent_(src.render_intent_), ++ fill_color_ref_(src.fill_color_ref_), + stroke_color_ref_(src.stroke_color_ref_), + fill_color_(src.fill_color_), + stroke_color_(src.stroke_color_) {} +@@ -154,6 +175,7 @@ + CPDF_ColorState::ColorData::~ColorData() = default; + + void CPDF_ColorState::ColorData::SetDefault() { ++ render_intent_ = RenderingIntent::kRelativeColorimetric; + fill_color_ref_ = 0; + stroke_color_ref_ = 0; + fill_color_.SetColorSpace( +diff --git a/core/fpdfapi/page/cpdf_colorstate.h b/core/fpdfapi/page/cpdf_colorstate.h +--- a/core/fpdfapi/page/cpdf_colorstate.h ++++ b/core/fpdfapi/page/cpdf_colorstate.h +@@ -27,6 +27,7 @@ + + void Emplace(); + void SetDefault(); ++ void SetRenderIntent(RenderingIntent intent); + + FX_COLORREF GetFillColorRef() const; + void SetFillColorRef(FX_COLORREF colorref); +@@ -62,6 +63,7 @@ + + void SetDefault(); + ++ RenderingIntent render_intent_ = RenderingIntent::kRelativeColorimetric; + FX_COLORREF fill_color_ref_ = 0; + FX_COLORREF stroke_color_ref_ = 0; + CPDF_Color fill_color_; +diff --git a/core/fpdfapi/page/cpdf_devicecs.cpp b/core/fpdfapi/page/cpdf_devicecs.cpp +--- a/core/fpdfapi/page/cpdf_devicecs.cpp ++++ b/core/fpdfapi/page/cpdf_devicecs.cpp +@@ -44,7 +44,8 @@ + } + + std::optional> CPDF_DeviceCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + switch (GetFamily()) { + case Family::kDeviceGray: { + const float pix = NormalizeChannel(pBuf.front()); +@@ -83,7 +84,8 @@ + int pixels, + int image_width, + int image_height, +- bool bTransMask) const { ++ bool bTransMask, ++ RenderingIntent intent) const { + auto rgb_out = fxcrt::reinterpret_span>(dest_span); + switch (GetFamily()) { + case Family::kDeviceGray: +diff --git a/core/fpdfapi/page/cpdf_devicecs.h b/core/fpdfapi/page/cpdf_devicecs.h +--- a/core/fpdfapi/page/cpdf_devicecs.h ++++ b/core/fpdfapi/page/cpdf_devicecs.h +@@ -19,13 +19,18 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; +- void TranslateImageLine(pdfium::span dest_span, +- pdfium::span src_span, +- int pixels, +- int image_width, +- int image_height, +- bool bTransMask) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; ++ void TranslateImageLine( ++ pdfium::span dest_span, ++ pdfium::span src_span, ++ int pixels, ++ int image_width, ++ int image_height, ++ bool bTransMask, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; +diff --git a/core/fpdfapi/page/cpdf_dib.cpp b/core/fpdfapi/page/cpdf_dib.cpp +--- a/core/fpdfapi/page/cpdf_dib.cpp ++++ b/core/fpdfapi/page/cpdf_dib.cpp +@@ -138,7 +138,36 @@ + + CPDF_DIB::JpxSMaskInlineData::~JpxSMaskInlineData() = default; + ++// static ++RenderingIntent CPDF_DIB::ResolveRenderingIntent( ++ const CPDF_Dictionary* dict, RenderingIntent inherited_intent) { ++ if (!dict) { ++ return inherited_intent; ++ } ++ RetainPtr value = dict->GetDirectObjectFor("Intent"); ++ if (!value || !value->IsName()) { ++ return inherited_intent; ++ } ++ const ByteString name = value->GetString(); ++ if (name == "Perceptual") { ++ return RenderingIntent::kPerceptual; ++ } ++ if (name == "RelativeColorimetric") { ++ return RenderingIntent::kRelativeColorimetric; ++ } ++ if (name == "Saturation") { ++ return RenderingIntent::kSaturation; ++ } ++ if (name == "AbsoluteColorimetric") { ++ return RenderingIntent::kAbsoluteColorimetric; ++ } ++ // An unrecognized intent name falls back to the PDF default (not the ++ // inherited state); an absent or non-name entry above keeps inheritance. ++ return RenderingIntent::kRelativeColorimetric; ++} ++ + bool CPDF_DIB::Load() { ++ rendering_intent_ = RenderingIntent::kRelativeColorimetric; + if (!LoadInternal(nullptr, nullptr)) { + return false; + } +@@ -204,7 +233,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { ++ rendering_intent_ = intent; + std_cs_ = bStdCS; + has_mask_ = bHasMask; + group_family_ = GroupFamily; +@@ -813,6 +844,7 @@ + } + + dict_ = stream_->GetDict(); ++ rendering_intent_ = ResolveRenderingIntent(dict_.Get(), rendering_intent_); + SetWidth(dict_->GetIntegerFor("Width")); + SetHeight(dict_->GetIntegerFor("Height")); + if (!IsValidDimension(GetWidth()) || !IsValidDimension(GetHeight())) { +@@ -873,7 +905,7 @@ + std::vector colors = + ReadArrayElementsToVector(pMatte.Get(), components_); + +- auto rgb = color_space_->GetRGBOrZerosOnError(colors); ++ auto rgb = color_space_->GetRGBOrZerosOnError(colors, rendering_intent_); + matte_color_ = + ArgbEncode(0, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -915,7 +947,8 @@ + mask_ = pdfium::MakeRetain(document_, std::move(mask_stream)); + LoadState ret = + mask_->StartLoadDIBBase(false, nullptr, nullptr, true, +- CPDF_ColorSpace::Family::kUnknown, false, {0, 0}); ++ CPDF_ColorSpace::Family::kUnknown, false, {0, 0}, ++ rendering_intent_); + if (ret == LoadState::kContinue) { + if (status_ == LoadState::kFail) { + status_ = LoadState::kContinue; +@@ -957,7 +990,7 @@ + float color_values[3]; + std::ranges::fill(color_values, comp_data_[0].decode_min_); + +- auto rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ auto rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + FX_ARGB argb0 = + ArgbEncode(255, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -972,7 +1005,7 @@ + color_values[0] += comp_data_[0].decode_step_; + color_values[1] += comp_data_[0].decode_step_; + color_values[2] += comp_data_[0].decode_step_; +- auto result = color_space_->GetRGBOrZerosOnError(color_values); ++ auto result = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + argb1 = ArgbEncode(255, FXSYS_roundf(result.red * 255), + FXSYS_roundf(result.green * 255), + FXSYS_roundf(result.blue * 255)); +@@ -1006,9 +1039,9 @@ + color_space_->ComponentCount() > 1) { + const size_t nComponents = color_space_->ComponentCount(); + std::vector temp_buf(nComponents, color_values[0]); +- rgb = color_space_->GetRGBOrZerosOnError(temp_buf); ++ rgb = color_space_->GetRGBOrZerosOnError(temp_buf, rendering_intent_); + } else { +- rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + } + SetPaletteArgb(i, ArgbEncode(255, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), +@@ -1078,7 +1111,7 @@ + rgb.green = (1.0f - color_values[1]) * k; + rgb.blue = (1.0f - color_values[2]) * k; + } else if (family_ != CPDF_ColorSpace::Family::kPattern) { +- rgb = color_space_->GetRGBOrZerosOnError(color_values); ++ rgb = color_space_->GetRGBOrZerosOnError(color_values, rendering_intent_); + } + const float R = std::clamp(rgb.red, 0.0f, 1.0f); + const float G = std::clamp(rgb.green, 0.0f, 1.0f); +@@ -1105,7 +1138,8 @@ + + if (ComponentCountMatchesColorSpace()) { + color_space_->TranslateImageLine(dest_scan, src_scan, GetWidth(), +- GetWidth(), GetHeight(), TransMask()); ++ GetWidth(), GetHeight(), TransMask(), ++ rendering_intent_); + } + return true; + } +diff --git a/core/fpdfapi/page/cpdf_dib.h b/core/fpdfapi/page/cpdf_dib.h +--- a/core/fpdfapi/page/cpdf_dib.h ++++ b/core/fpdfapi/page/cpdf_dib.h +@@ -14,6 +14,7 @@ + + #include "core/fpdfapi/page/cpdf_colorspace.h" + #include "core/fxcrt/data_vector.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -56,6 +57,11 @@ + uint32_t GetMatteColor() const { return matte_color_; } + bool IsJBigImage() const; + ++ // Missing or non-name image Intent preserves graphics-state inheritance. ++ // Standard names select that intent; unknown names use the PDF default. ++ static RenderingIntent ResolveRenderingIntent( ++ const CPDF_Dictionary* dict, RenderingIntent inherited_intent); ++ + bool Load(); + LoadState StartLoadDIBBase(bool bHasMask, + const CPDF_Dictionary* pFormResources, +@@ -63,7 +69,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + LoadState ContinueLoadDIBBase(PauseIndicatorIface* pPause); + RetainPtr DetachMask(); + +@@ -129,6 +137,7 @@ + uint32_t components_ = 0; + CPDF_ColorSpace::Family family_ = CPDF_ColorSpace::Family::kUnknown; + CPDF_ColorSpace::Family group_family_ = CPDF_ColorSpace::Family::kUnknown; ++ RenderingIntent rendering_intent_ = RenderingIntent::kRelativeColorimetric; + uint32_t matte_color_ = 0; + LoadState status_ = LoadState::kFail; + bool load_mask_ = false; +diff --git a/core/fpdfapi/page/cpdf_dib_unittest.cpp b/core/fpdfapi/page/cpdf_dib_unittest.cpp +--- a/core/fpdfapi/page/cpdf_dib_unittest.cpp ++++ b/core/fpdfapi/page/cpdf_dib_unittest.cpp +@@ -13,6 +13,7 @@ + #include "core/fpdfapi/parser/cpdf_name.h" + #include "core/fpdfapi/parser/cpdf_number.h" + #include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_string.h" + #include "core/fpdfapi/parser/cpdf_test_document.h" + #include "core/fxcrt/data_vector.h" + #include "core/fxcrt/retain_ptr.h" +@@ -90,3 +91,39 @@ + EXPECT_THAT(dib->GetScanline(0), + testing::ElementsAre(0x55, 0x33, 0x11, 0xbb, 0x99, 0x77)); + } ++ ++TEST_F(CPDFDIBTest, ImageIntentDistinguishesUnknownNameFromMissingOrInvalidType) { ++ auto dict = pdfium::MakeRetain(); ++ constexpr RenderingIntent inherited = RenderingIntent::kSaturation; ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(nullptr, inherited), inherited); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++ struct IntentCase { ++ const char* name; ++ RenderingIntent intent; ++ }; ++ const IntentCase cases[] = { ++ {"Perceptual", RenderingIntent::kPerceptual}, ++ {"RelativeColorimetric", RenderingIntent::kRelativeColorimetric}, ++ {"Saturation", RenderingIntent::kSaturation}, ++ {"AbsoluteColorimetric", RenderingIntent::kAbsoluteColorimetric}, ++ }; ++ for (const auto& test : cases) { ++ dict->SetNewFor("Intent", test.name); ++ for (const auto& source : cases) { ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), source.intent), ++ test.intent); ++ } ++ } ++ // Unknown names fall back to RelativeColorimetric, including old ++ // four-character prefix matches. PDF names are case sensitive. ++ for (const char* name : {"Unknown", "Abso", "AbsoluteColorimetricSuffix", ++ "perceptual", ""}) { ++ dict->SetNewFor("Intent", name); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), ++ RenderingIntent::kRelativeColorimetric); ++ } ++ dict->SetNewFor("Intent", 3); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++ dict->SetNewFor("Intent", "Perceptual"); ++ EXPECT_EQ(CPDF_DIB::ResolveRenderingIntent(dict.Get(), inherited), inherited); ++} +diff --git a/core/fpdfapi/page/cpdf_form.cpp b/core/fpdfapi/page/cpdf_form.cpp +--- a/core/fpdfapi/page/cpdf_form.cpp ++++ b/core/fpdfapi/page/cpdf_form.cpp +@@ -9,6 +9,7 @@ + #include + #include + ++#include "core/fpdfapi/page/cpdf_allstates.h" + #include "core/fpdfapi/page/cpdf_contentparser.h" + #include "core/fpdfapi/page/cpdf_imageobject.h" + #include "core/fpdfapi/page/cpdf_pageobject.h" +@@ -70,8 +71,19 @@ + ParseContentInternal(pGraphicStates, pParentMatrix, nullptr, recursion_state); + } + +-void CPDF_Form::ParseContentForType3Char(CPDF_Type3Char* pType3Char) { +- ParseContentInternal(nullptr, nullptr, pType3Char, nullptr); ++void CPDF_Form::ParseContentForType3Char(CPDF_Type3Char* pType3Char, ++ RenderingIntent intent) { ++ // Retain the existing empty glyph color/graphics state, inheriting only RI. ++ // Parsing with that initial RI lets ordinary ri and q/Q operators override ++ // and restore it, without mutating an already-cached glyph object graph. ++ CPDF_AllStates states; ++ states.mutable_general_state().Emplace(); ++ states.mutable_graph_state().Emplace(); ++ states.mutable_text_state().Emplace(); ++ states.mutable_color_state().Emplace(); ++ states.mutable_general_state().SetRenderIntent(intent); ++ states.mutable_color_state().SetRenderIntent(intent); ++ ParseContentInternal(&states, nullptr, pType3Char, nullptr); + } + + void CPDF_Form::ParseContentInternal(const CPDF_AllStates* pGraphicStates, +diff --git a/core/fpdfapi/page/cpdf_form.h b/core/fpdfapi/page/cpdf_form.h +--- a/core/fpdfapi/page/cpdf_form.h ++++ b/core/fpdfapi/page/cpdf_form.h +@@ -46,7 +46,8 @@ + ~CPDF_Form() override; + + // CPDF_Font::FormIface: +- void ParseContentForType3Char(CPDF_Type3Char* pType3Char) override; ++ void ParseContentForType3Char(CPDF_Type3Char* pType3Char, ++ RenderingIntent intent) override; + bool HasPageObjects() const override; + CFX_FloatRect CalcBoundingBox() const override; + std::optional, CFX_Matrix>> +diff --git a/core/fpdfapi/page/cpdf_generalstate.cpp b/core/fpdfapi/page/cpdf_generalstate.cpp +--- a/core/fpdfapi/page/cpdf_generalstate.cpp ++++ b/core/fpdfapi/page/cpdf_generalstate.cpp +@@ -13,23 +13,6 @@ + #include "core/fpdfapi/parser/cpdf_object.h" + + namespace { +- +-int RI_StringToId(const ByteString& ri) { +- uint32_t id = ri.GetID(); +- if (id == FXBSTR_ID('A', 'b', 's', 'o')) { +- return 1; +- } +- +- if (id == FXBSTR_ID('S', 'a', 't', 'u')) { +- return 2; +- } +- +- if (id == FXBSTR_ID('P', 'e', 'r', 'c')) { +- return 3; +- } +- +- return 0; +-} + + BlendMode GetBlendTypeInternal(const ByteString& mode) { + switch (mode.GetID()) { +@@ -80,8 +63,32 @@ + + CPDF_GeneralState::~CPDF_GeneralState() = default; + ++RenderingIntent CPDF_GeneralState::GetRenderIntent() const { ++ return ref_ ? ref_.GetObject()->render_intent_ ++ : RenderingIntent::kRelativeColorimetric; ++} ++ + void CPDF_GeneralState::SetRenderIntent(const ByteString& ri) { +- ref_.GetPrivateCopy()->render_intent_ = RI_StringToId(ri); ++ RenderingIntent intent; ++ if (ri == "Perceptual") { ++ intent = RenderingIntent::kPerceptual; ++ } else if (ri == "RelativeColorimetric") { ++ intent = RenderingIntent::kRelativeColorimetric; ++ } else if (ri == "Saturation") { ++ intent = RenderingIntent::kSaturation; ++ } else if (ri == "AbsoluteColorimetric") { ++ intent = RenderingIntent::kAbsoluteColorimetric; ++ } else { ++ // PDF requires unknown intent names to use RelativeColorimetric. ++ intent = RenderingIntent::kRelativeColorimetric; ++ } ++ SetRenderIntent(intent); ++} ++ ++void CPDF_GeneralState::SetRenderIntent(RenderingIntent intent) { ++ if (intent != GetRenderIntent()) { ++ ref_.GetPrivateCopy()->render_intent_ = intent; ++ } + } + + ByteString CPDF_GeneralState::GetBlendMode() const { +diff --git a/core/fpdfapi/page/cpdf_generalstate.h b/core/fpdfapi/page/cpdf_generalstate.h +--- a/core/fpdfapi/page/cpdf_generalstate.h ++++ b/core/fpdfapi/page/cpdf_generalstate.h +@@ -12,6 +12,7 @@ + #include "constants/transparency.h" + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/fx_coordinates.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/shared_copy_on_write.h" + #include "core/fxcrt/span.h" +@@ -30,7 +31,9 @@ + void Emplace() { ref_.Emplace(); } + bool HasRef() const { return !!ref_; } + ++ RenderingIntent GetRenderIntent() const; + void SetRenderIntent(const ByteString& ri); ++ void SetRenderIntent(RenderingIntent intent); + + ByteString GetBlendMode() const; + BlendMode GetBlendType() const; +@@ -98,7 +101,7 @@ + float fill_alpha_ = 1.0f; + RetainPtr tr_; + RetainPtr transfer_func_; +- int render_intent_ = 0; ++ RenderingIntent render_intent_ = RenderingIntent::kRelativeColorimetric; + bool stroke_adjust_ = false; + bool alpha_source_ = false; + bool text_knockout_ = false; +diff --git a/core/fpdfapi/page/cpdf_iccprofile.cpp b/core/fpdfapi/page/cpdf_iccprofile.cpp +--- a/core/fpdfapi/page/cpdf_iccprofile.cpp ++++ b/core/fpdfapi/page/cpdf_iccprofile.cpp +@@ -42,24 +42,51 @@ + } + + src_components_ = components; +- transform_ = std::move(transform); ++ const size_t index = ++ static_cast(RenderingIntent::kRelativeColorimetric); ++ transforms_[index] = std::move(transform); ++ transform_attempted_[index] = true; + } + + CPDF_IccProfile::~CPDF_IccProfile() = default; + + bool CPDF_IccProfile::IsNormal() const { +- return transform_->IsNormal(); ++ return transforms_[static_cast( ++ RenderingIntent::kRelativeColorimetric)] ++ ->IsNormal(); + } + +-void CPDF_IccProfile::Translate(pdfium::span src_values, +- pdfium::span dest_values) { +- transform_->Translate(src_values, dest_values); ++fxcodec::IccTransform* CPDF_IccProfile::GetTransform(RenderingIntent intent) { ++ const size_t index = static_cast(intent); ++ if (!transform_attempted_[index]) { ++ transform_attempted_[index] = true; ++ transforms_[index] = fxcodec::IccTransform::CreateTransformSRGB( ++ stream_acc_->GetSpan(), intent); ++ } ++ return transforms_[index].get(); + } + +-void CPDF_IccProfile::TranslateScanline(pdfium::span pDest, +- pdfium::span pSrc, +- int pixels) { +- transform_->TranslateScanline(pDest, pSrc, pixels); ++bool CPDF_IccProfile::Translate(pdfium::span src_values, ++ pdfium::span dest_values, ++ RenderingIntent intent) { ++ auto* transform = GetTransform(intent); ++ if (!transform) { ++ return false; ++ } ++ transform->Translate(src_values, dest_values); ++ return true; ++} ++ ++bool CPDF_IccProfile::TranslateScanline(pdfium::span dest, ++ pdfium::span src, ++ int pixels, ++ RenderingIntent intent) { ++ auto* transform = GetTransform(intent); ++ if (!transform) { ++ return false; ++ } ++ transform->TranslateScanline(dest, src, pixels); ++ return true; + } + + RetainPtr CPDF_IccProfile::GetStreamAcc() const { +diff --git a/core/fpdfapi/page/cpdf_iccprofile.h b/core/fpdfapi/page/cpdf_iccprofile.h +--- a/core/fpdfapi/page/cpdf_iccprofile.h ++++ b/core/fpdfapi/page/cpdf_iccprofile.h +@@ -9,8 +9,10 @@ + + #include + ++#include + #include + ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + +@@ -26,15 +28,20 @@ + + bool IsValid() const { return IsSRGB() || IsSupported(); } + bool IsSRGB() const { return is_srgb_; } +- bool IsSupported() const { return !!transform_; } ++ bool IsSupported() const { ++ return !!transforms_[static_cast( ++ RenderingIntent::kRelativeColorimetric)]; ++ } + uint32_t GetComponents() const { return src_components_; } + + bool IsNormal() const; +- void Translate(pdfium::span src_values, +- pdfium::span dest_values); +- void TranslateScanline(pdfium::span pDest, ++ bool Translate(pdfium::span src_values, ++ pdfium::span dest_values, ++ RenderingIntent intent); ++ bool TranslateScanline(pdfium::span pDest, + pdfium::span pSrc, +- int pixels); ++ int pixels, ++ RenderingIntent intent); + + RetainPtr GetStreamAcc() const; + +@@ -43,11 +50,14 @@ + uint32_t expected_components); + ~CPDF_IccProfile() override; + +- // Keeps stream alive for the lifetime of this object, so `transform_` can ++ fxcodec::IccTransform* GetTransform(RenderingIntent intent); ++ ++ // Keeps stream alive for the lifetime of this object, so `transforms_` can + // safely access the stream data. + RetainPtr const stream_acc_; + // Uses data from `stream_acc_`. +- std::unique_ptr transform_; ++ std::array, 4> transforms_; ++ std::array transform_attempted_ = {}; + const bool is_srgb_; + uint32_t src_components_ = 0; + }; +diff --git a/core/fpdfapi/page/cpdf_image.cpp b/core/fpdfapi/page/cpdf_image.cpp +--- a/core/fpdfapi/page/cpdf_image.cpp ++++ b/core/fpdfapi/page/cpdf_image.cpp +@@ -363,11 +363,12 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { + RetainPtr source = CreateNewDIB(); + CPDF_DIB::LoadState ret = + source->StartLoadDIBBase(true, pFormResource, pPageResource, bStdCS, +- GroupFamily, bLoadMask, max_size_required); ++ GroupFamily, bLoadMask, max_size_required, intent); + if (ret == CPDF_DIB::LoadState::kFail) { + dibbase_.Reset(); + return false; +diff --git a/core/fpdfapi/page/cpdf_image.h b/core/fpdfapi/page/cpdf_image.h +--- a/core/fpdfapi/page/cpdf_image.h ++++ b/core/fpdfapi/page/cpdf_image.h +@@ -10,6 +10,7 @@ + #include + + #include "core/fpdfapi/page/cpdf_colorspace.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxcrt/span.h" + #include "core/fxcrt/unowned_ptr.h" +@@ -66,7 +67,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family GroupFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + + // Returns whether to Continue() or not. + bool Continue(PauseIndicatorIface* pPause); +diff --git a/core/fpdfapi/page/cpdf_imageloader.cpp b/core/fpdfapi/page/cpdf_imageloader.cpp +--- a/core/fpdfapi/page/cpdf_imageloader.cpp ++++ b/core/fpdfapi/page/cpdf_imageloader.cpp +@@ -30,15 +30,16 @@ + const CFX_Size& max_size_required) { + cache_ = pPageImageCache; + image_object_ = pImage; ++ const RenderingIntent intent = pImage->general_state().GetRenderIntent(); + bool should_continue; + if (cache_) { + should_continue = cache_->StartGetCachedBitmap( + image_object_->GetImage(), pFormResource, pPageResource, bStdCS, +- eFamily, bLoadMask, max_size_required); ++ eFamily, bLoadMask, max_size_required, intent); + } else { + should_continue = image_object_->GetImage()->StartLoadDIBBase( + pFormResource, pPageResource, bStdCS, eFamily, bLoadMask, +- max_size_required); ++ max_size_required, intent); + } + if (!should_continue) { + Finish(); +diff --git a/core/fpdfapi/page/cpdf_indexedcs.cpp b/core/fpdfapi/page/cpdf_indexedcs.cpp +--- a/core/fpdfapi/page/cpdf_indexedcs.cpp ++++ b/core/fpdfapi/page/cpdf_indexedcs.cpp +@@ -89,7 +89,8 @@ + } + + std::optional> CPDF_IndexedCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + int32_t index = static_cast(pBuf[0]); + if (index < 0 || index > max_index_) { + return std::nullopt; +@@ -112,5 +113,5 @@ + comp.min + + comp.max * lookup_table_[index * component_min_max_.size() + i] / 255; + } +- return base_cs_->GetRGB(comps); ++ return base_cs_->GetRGB(comps, intent); + } +diff --git a/core/fpdfapi/page/cpdf_indexedcs.h b/core/fpdfapi/page/cpdf_indexedcs.h +--- a/core/fpdfapi/page/cpdf_indexedcs.h ++++ b/core/fpdfapi/page/cpdf_indexedcs.h +@@ -29,7 +29,9 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + const CPDF_IndexedCS* AsIndexedCS() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, +diff --git a/core/fpdfapi/page/cpdf_meshstream.cpp b/core/fpdfapi/page/cpdf_meshstream.cpp +--- a/core/fpdfapi/page/cpdf_meshstream.cpp ++++ b/core/fpdfapi/page/cpdf_meshstream.cpp +@@ -102,11 +102,13 @@ + ShadingType type, + const std::vector>& funcs, + RetainPtr pShadingStream, +- RetainPtr pCS) ++ RetainPtr pCS, ++ RenderingIntent rendering_intent) + : type_(type), + funcs_(funcs), + shading_stream_(std::move(pShadingStream)), + cs_(std::move(pCS)), ++ rendering_intent_(rendering_intent), + stream_(pdfium::MakeRetain(shading_stream_)) {} + + CPDF_MeshStream::~CPDF_MeshStream() = default; +@@ -215,7 +217,7 @@ + component_max_; + } + if (funcs_.empty()) { +- return cs_->GetRGBOrZerosOnError(color_value); ++ return cs_->GetRGBOrZerosOnError(color_value, rendering_intent_); + } + return {color_value[0], 0.0f, 0.0f}; + } +diff --git a/core/fpdfapi/page/cpdf_meshstream.h b/core/fpdfapi/page/cpdf_meshstream.h +--- a/core/fpdfapi/page/cpdf_meshstream.h ++++ b/core/fpdfapi/page/cpdf_meshstream.h +@@ -14,6 +14,7 @@ + #include + + #include "core/fpdfapi/page/cpdf_shadingpattern.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + #include "core/fxge/dib/fx_dib.h" + +@@ -40,7 +41,9 @@ + CPDF_MeshStream(ShadingType type, + const std::vector>& funcs, + RetainPtr pShadingStream, +- RetainPtr pCS); ++ RetainPtr pCS, ++ RenderingIntent rendering_intent = ++ RenderingIntent::kRelativeColorimetric); + ~CPDF_MeshStream(); + + bool Load(); +@@ -79,6 +82,7 @@ + const std::vector>& funcs_; + RetainPtr const shading_stream_; + RetainPtr const cs_; ++ const RenderingIntent rendering_intent_; + uint32_t coord_bits_ = 0; + uint32_t component_bits_ = 0; + uint32_t flag_bits_ = 0; +diff --git a/core/fpdfapi/page/cpdf_pageimagecache.cpp b/core/fpdfapi/page/cpdf_pageimagecache.cpp +--- a/core/fpdfapi/page/cpdf_pageimagecache.cpp ++++ b/core/fpdfapi/page/cpdf_pageimagecache.cpp +@@ -174,19 +174,32 @@ + bool bStdCS, + CPDF_ColorSpace::Family eFamily, + bool bLoadMask, +- const CFX_Size& max_size_required) { ++ const CFX_Size& max_size_required, ++ RenderingIntent intent) { + // A cross-document image may have come from the embedder. + if (page_->GetDocument() != pImage->GetDocument()) { + return false; + } + + RetainPtr pStream = pImage->GetStream(); +- const auto it = image_cache_.find(pStream); ++ const RenderingIntent effective_intent = ++ CPDF_DIB::ResolveRenderingIntent(pImage->GetDict().Get(), intent); ++ auto it = image_cache_.find(pStream); ++ if (it != image_cache_.end() && ++ it->second->GetRenderingIntent() != effective_intent) { ++ // Keep one decoded variant per stream. ClearImageCacheEntry also accounts ++ // for its bytes and clears any borrowed current-entry pointer. Bitmap ++ // consumers retain their own references, so replacing this entry cannot ++ // alter pixels already being rendered. ++ ClearImageCacheEntry(pStream.Get()); ++ it = image_cache_.end(); ++ } + cur_find_cache_ = it != image_cache_.end(); + if (cur_find_cache_) { + cur_image_cache_entry_ = it->second.get(); + } else { +- cur_image_cache_entry_ = std::make_unique(std::move(pImage)); ++ cur_image_cache_entry_ = ++ std::make_unique(std::move(pImage), effective_intent); + } + CPDF_DIB::LoadState ret = cur_image_cache_entry_->StartGetCachedBitmap( + this, pFormResources, pPageResources, bStdCS, eFamily, bLoadMask, +@@ -247,8 +260,9 @@ + return cur_image_cache_entry_->DetachMask(); + } + +-CPDF_PageImageCache::Entry::Entry(RetainPtr pImage) +- : image_(std::move(pImage)) {} ++CPDF_PageImageCache::Entry::Entry(RetainPtr pImage, ++ RenderingIntent intent) ++ : image_(std::move(pImage)), rendering_intent_(intent) {} + + CPDF_PageImageCache::Entry::~Entry() = default; + +@@ -282,7 +296,7 @@ + cur_bitmap_ = image_->CreateNewDIB(); + CPDF_DIB::LoadState ret = cur_bitmap_.AsRaw()->StartLoadDIBBase( + true, pFormResources, pPageResources, bStdCS, eFamily, bLoadMask, +- max_size_required); ++ max_size_required, rendering_intent_); + cached_set_max_size_required_ = + (max_size_required.width != 0 && max_size_required.height != 0); + if (ret == CPDF_DIB::LoadState::kContinue) { +diff --git a/core/fpdfapi/page/cpdf_pageimagecache.h b/core/fpdfapi/page/cpdf_pageimagecache.h +--- a/core/fpdfapi/page/cpdf_pageimagecache.h ++++ b/core/fpdfapi/page/cpdf_pageimagecache.h +@@ -40,7 +40,9 @@ + bool bStdCS, + CPDF_ColorSpace::Family eFamily, + bool bLoadMask, +- const CFX_Size& max_size_required); ++ const CFX_Size& max_size_required, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric); + + bool Continue(PauseIndicatorIface* pPause); + +@@ -51,7 +53,7 @@ + private: + class Entry { + public: +- explicit Entry(RetainPtr pImage); ++ Entry(RetainPtr pImage, RenderingIntent intent); + ~Entry(); + + void Reset(); +@@ -60,6 +62,7 @@ + uint32_t GetTimeCount() const { return time_count_; } + void SetTimeCount(uint32_t count) { time_count_ = count; } + CPDF_Image* GetImage() const { return image_.Get(); } ++ RenderingIntent GetRenderingIntent() const { return rendering_intent_; } + + CPDF_DIB::LoadState StartGetCachedBitmap( + CPDF_PageImageCache* pPageImageCache, +@@ -86,6 +89,7 @@ + uint32_t matte_color_ = 0; + uint32_t cache_size_ = 0; + RetainPtr const image_; ++ const RenderingIntent rendering_intent_; + RetainPtr cur_bitmap_; + RetainPtr cur_mask_; + RetainPtr cached_bitmap_; +diff --git a/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp b/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp +--- a/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp ++++ b/core/fpdfapi/page/cpdf_pageimagecache_unittest.cpp +@@ -14,6 +14,12 @@ + #include "core/fpdfapi/page/cpdf_page.h" + #include "core/fpdfapi/page/cpdf_pagemodule.h" + #include "core/fpdfapi/page/test_with_page_module.h" ++#include "core/fpdfapi/parser/cpdf_dictionary.h" ++#include "core/fpdfapi/parser/cpdf_name.h" ++#include "core/fpdfapi/parser/cpdf_number.h" ++#include "core/fpdfapi/parser/cpdf_stream.h" ++#include "core/fpdfapi/parser/cpdf_test_document.h" ++#include "core/fxcrt/data_vector.h" + #include "core/fpdfapi/parser/cpdf_parser.h" + #include "core/fpdfapi/render/cpdf_docrenderdata.h" + #include "core/fxcrt/cfx_fileaccess_stream.h" +@@ -252,3 +258,74 @@ + } + + } // namespace pdfium ++ ++namespace pdfium { ++ ++TEST_F(CPDFPageImageCacheTest, ImageIntentReplacesSingleStreamCacheVariant) { ++ CPDF_TestDocument document; ++ auto page = pdfium::MakeRetain( ++ &document, pdfium::MakeRetain()); ++ page->AddPageImageCache(); ++ CPDF_PageImageCache* cache = page->GetPageImageCache(); ++ auto dict = pdfium::MakeRetain(); ++ dict->SetNewFor("Type", "XObject"); ++ dict->SetNewFor("Subtype", "Image"); ++ dict->SetNewFor("ColorSpace", "DeviceRGB"); ++ dict->SetNewFor("Width", 1); ++ dict->SetNewFor("Height", 1); ++ dict->SetNewFor("BitsPerComponent", 8); ++ auto stream = pdfium::MakeRetain( ++ DataVector{0x11, 0x22, 0x33}, dict); ++ auto image = pdfium::MakeRetain(&document, stream); ++ const auto load = [&](RenderingIntent intent) { ++ bool more = cache->StartGetCachedBitmap( ++ image, nullptr, nullptr, false, CPDF_ColorSpace::Family::kUnknown, ++ false, {0, 0}, intent); ++ while (more) { ++ more = cache->Continue(nullptr); ++ } ++ return cache->DetachCurBitmap(); ++ }; ++ ++ RetainPtr relative = load(RenderingIntent::kRelativeColorimetric); ++ ASSERT_TRUE(relative); ++ EXPECT_EQ(load(RenderingIntent::kRelativeColorimetric).Get(), relative.Get()); ++ RetainPtr perceptual = load(RenderingIntent::kPerceptual); ++ ASSERT_TRUE(perceptual); ++ EXPECT_NE(perceptual.Get(), relative.Get()); ++ RetainPtr relative_again = ++ load(RenderingIntent::kRelativeColorimetric); ++ ASSERT_TRUE(relative_again); ++ EXPECT_NE(relative_again.Get(), relative.Get()); ++ EXPECT_NE(relative_again.Get(), perceptual.Get()); ++ // Entry replacement must not invalidate a bitmap held by an earlier loader. ++ EXPECT_EQ(relative->GetScanline(0)[0], 0x33); ++ EXPECT_EQ(perceptual->GetScanline(0)[2], 0x11); ++ ++ for (int i = 0; i < 128; ++i) { ++ const RenderingIntent intent = ++ i % 2 ? RenderingIntent::kPerceptual : RenderingIntent::kSaturation; ++ RetainPtr bitmap = load(intent); ++ ASSERT_TRUE(bitmap); ++ // The budget should contain exactly the active single-stream variant, ++ // without charging every old intent again or underflowing on eviction. ++ cache->CacheOptimization( ++ static_cast(bitmap->GetEstimatedImageMemoryBurden())); ++ EXPECT_EQ(load(intent).Get(), bitmap.Get()); ++ } ++ ++ // Explicit /Intent takes priority. Different graphics-state intents now ++ // resolve to the same variant and therefore reuse its decoded bitmap. ++ dict->SetNewFor("Intent", "AbsoluteColorimetric"); ++ RetainPtr explicit_intent = load(RenderingIntent::kPerceptual); ++ ASSERT_TRUE(explicit_intent); ++ EXPECT_EQ(load(RenderingIntent::kSaturation).Get(), explicit_intent.Get()); ++ EXPECT_EQ(load(RenderingIntent::kRelativeColorimetric).Get(), ++ explicit_intent.Get()); ++ cache->CacheOptimization(0); ++ EXPECT_NE(load(RenderingIntent::kPerceptual).Get(), explicit_intent.Get()); ++ EXPECT_EQ(explicit_intent->GetScanline(0)[1], 0x22); ++ page->ClearView(); ++} ++ ++} // namespace pdfium +diff --git a/core/fpdfapi/page/cpdf_patterncs.cpp b/core/fpdfapi/page/cpdf_patterncs.cpp +--- a/core/fpdfapi/page/cpdf_patterncs.cpp ++++ b/core/fpdfapi/page/cpdf_patterncs.cpp +@@ -48,7 +48,8 @@ + } + + std::optional> CPDF_PatternCS::GetRGB( +- pdfium::span pBuf) const { ++ pdfium::span pBuf, ++ RenderingIntent intent) const { + NOTREACHED(); + } + +@@ -57,10 +58,11 @@ + } + + std::optional> CPDF_PatternCS::GetPatternRGB( +- const PatternValue& value) const { ++ const PatternValue& value, ++ RenderingIntent intent) const { + if (!base_cs_) { + return std::nullopt; + } + +- return base_cs_->GetRGB(value.GetComps()); ++ return base_cs_->GetRGB(value.GetComps(), intent); + } +diff --git a/core/fpdfapi/page/cpdf_patterncs.h b/core/fpdfapi/page/cpdf_patterncs.h +--- a/core/fpdfapi/page/cpdf_patterncs.h ++++ b/core/fpdfapi/page/cpdf_patterncs.h +@@ -27,14 +27,17 @@ + + // CPDF_ColorSpace: + std::optional> GetRGB( +- pdfium::span pBuf) const override; ++ pdfium::span pBuf, ++ RenderingIntent intent = ++ RenderingIntent::kRelativeColorimetric) const override; + const CPDF_PatternCS* AsPatternCS() const override; + uint32_t v_Load(CPDF_Document* doc, + const CPDF_Array* pArray, + std::set* pVisited) override; + + std::optional> GetPatternRGB( +- const PatternValue& value) const; ++ const PatternValue& value, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric) const; + + private: + CPDF_PatternCS(); +diff --git a/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp b/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp +--- /dev/null ++++ b/core/fpdfapi/page/cpdf_renderingintent_unittest.cpp +@@ -0,0 +1,92 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#include "core/fpdfapi/page/cpdf_allstates.h" ++#include "core/fpdfapi/page/cpdf_colorspace.h" ++#include "core/fpdfapi/page/test_with_page_module.h" ++#include "testing/gtest/include/gtest/gtest.h" ++ ++namespace { ++ ++// A deterministic color space makes the color-cache and copy-on-write behavior ++// observable without relying on ICC tables or their precision. ++class IntentColorSpace final : public CPDF_ColorSpace { ++ public: ++ IntentColorSpace() : CPDF_ColorSpace(Family::kDeviceRGB) {} ++ std::optional> GetRGB( ++ pdfium::span values, ++ RenderingIntent intent) const override { ++ return FX_RGB_STRUCT{static_cast(intent) / 3.0f, 0, 0}; ++ } ++ ++ private: ++ uint32_t v_Load(CPDF_Document*, ++ const CPDF_Array*, ++ std::set*) override { ++ return 3; ++ } ++}; ++ ++} // namespace ++ ++using CPDFRenderingIntentTest = TestWithPageModule; ++ ++TEST_F(CPDFRenderingIntentTest, DefaultAndExactNames) { ++ CPDF_GeneralState state; ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++ state.SetRenderIntent("Perceptual"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kPerceptual); ++ state.SetRenderIntent("Saturation"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kSaturation); ++ state.SetRenderIntent("AbsoluteColorimetric"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kAbsoluteColorimetric); ++ for (const char* name : {"AbsoluteColorimetricSuffix", "Rela", ""}) { ++ state.SetRenderIntent("AbsoluteColorimetric"); ++ state.SetRenderIntent(name); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++ } ++ state.SetRenderIntent("RelativeColorimetric"); ++ EXPECT_EQ(state.GetRenderIntent(), RenderingIntent::kRelativeColorimetric); ++} ++ ++TEST_F(CPDFRenderingIntentTest, ColorCacheAndSavedStateAreIndependent) { ++ CPDF_AllStates state; ++ state.SetDefaultStates(); ++ auto colorspace = pdfium::MakeRetain(); ++ state.mutable_color_state().SetFillColor(colorspace, {0, 0, 0}); ++ state.mutable_color_state().SetStrokeColor(colorspace, {0, 0, 0}); ++ const auto initial_fill = state.color_state().GetFillColorRef(); ++ const auto initial_stroke = state.color_state().GetStrokeColorRef(); ++ CPDF_AllStates saved(state); ++ state.SetRenderIntent("Perceptual"); ++ EXPECT_NE(state.color_state().GetFillColorRef(), initial_fill); ++ EXPECT_NE(state.color_state().GetStrokeColorRef(), initial_stroke); ++ EXPECT_EQ(saved.color_state().GetFillColorRef(), initial_fill); ++ EXPECT_EQ(saved.color_state().GetStrokeColorRef(), initial_stroke); ++ EXPECT_EQ(saved.general_state().GetRenderIntent(), ++ RenderingIntent::kRelativeColorimetric); ++ state = saved; ++ EXPECT_EQ(state.color_state().GetFillColorRef(), initial_fill); ++ state.SetRenderIntent("Saturation"); ++ const auto saturation_fill = state.color_state().GetFillColorRef(); ++ state.mutable_color_state().SetFillColor(colorspace, {1, 0, 0}); ++ EXPECT_EQ(state.color_state().GetFillColorRef(), saturation_fill); ++ state.SetRenderIntent("PerceptualInvalid"); ++ EXPECT_EQ(state.color_state().GetFillColorRef(), initial_fill); ++} ++ ++TEST_F(CPDFRenderingIntentTest, EmptyColorsSurviveIntentChanges) { ++ CPDF_ColorState state; ++ state.SetRenderIntent(RenderingIntent::kPerceptual); ++ EXPECT_FALSE(state.HasFillColor()); ++ EXPECT_FALSE(state.HasStrokeColor()); ++ state.Emplace(); ++ state.SetRenderIntent(RenderingIntent::kAbsoluteColorimetric); ++ EXPECT_FALSE(state.HasFillColor()); ++ EXPECT_FALSE(state.HasStrokeColor()); ++ auto colorspace = pdfium::MakeRetain(); ++ state.SetFillColor(colorspace, {0, 0, 0}); ++ EXPECT_TRUE(state.HasFillColor()); ++ EXPECT_EQ(state.GetFillColorRef(), FXSYS_BGR(0, 0, 255)); ++} +diff --git a/core/fpdfapi/page/cpdf_shadingpattern.cpp b/core/fpdfapi/page/cpdf_shadingpattern.cpp +--- a/core/fpdfapi/page/cpdf_shadingpattern.cpp ++++ b/core/fpdfapi/page/cpdf_shadingpattern.cpp +@@ -11,6 +11,7 @@ + + #include "core/fpdfapi/page/cpdf_docpagedata.h" + #include "core/fpdfapi/page/cpdf_function.h" ++#include "core/fpdfapi/page/cpdf_generalstate.h" + #include "core/fpdfapi/parser/cpdf_array.h" + #include "core/fpdfapi/parser/cpdf_dictionary.h" + #include "core/fpdfapi/parser/cpdf_document.h" +@@ -97,6 +98,26 @@ + : pattern_obj()->GetDict()->GetDirectObjectFor("Shading"); + } + ++RenderingIntent CPDF_ShadingPattern::GetRenderIntent( ++ RenderingIntent inherited_intent) const { ++ if (shading_) { ++ return inherited_intent; ++ } ++ ++ RetainPtr dict = pattern_obj()->GetDict(); ++ RetainPtr state_dict = ++ dict ? dict->GetDictFor("ExtGState") : nullptr; ++ RetainPtr intent = ++ state_dict ? state_dict->GetDirectObjectFor("RI") : nullptr; ++ if (!intent || !intent->IsName()) { ++ return inherited_intent; ++ } ++ ++ CPDF_GeneralState state; ++ state.SetRenderIntent(intent->GetString()); ++ return state.GetRenderIntent(); ++} ++ + bool CPDF_ShadingPattern::Validate() const { + if (shading_type_ == kInvalidShading) { + return false; +diff --git a/core/fpdfapi/page/cpdf_shadingpattern.h b/core/fpdfapi/page/cpdf_shadingpattern.h +--- a/core/fpdfapi/page/cpdf_shadingpattern.h ++++ b/core/fpdfapi/page/cpdf_shadingpattern.h +@@ -55,6 +55,7 @@ + ShadingType GetShadingType() const { return shading_type_; } + bool IsShadingObject() const { return shading_; } + RetainPtr GetShadingObject() const; ++ RenderingIntent GetRenderIntent(RenderingIntent inherited_intent) const; + RetainPtr GetCS() const { return cs_; } + const std::vector>& GetFuncs() const { + return functions_; +diff --git a/core/fpdfapi/page/cpdf_streamcontentparser.cpp b/core/fpdfapi/page/cpdf_streamcontentparser.cpp +--- a/core/fpdfapi/page/cpdf_streamcontentparser.cpp ++++ b/core/fpdfapi/page/cpdf_streamcontentparser.cpp +@@ -1094,7 +1094,12 @@ + GetNumbers(3)); + } + +-void CPDF_StreamContentParser::Handle_SetRenderIntent() {} ++void CPDF_StreamContentParser::Handle_SetRenderIntent() { ++ RetainPtr name = GetObject(0); ++ if (name && name->IsName()) { ++ cur_states_->SetRenderIntent(name->GetString()); ++ } ++} + + void CPDF_StreamContentParser::Handle_CloseStrokePath() { + Handle_ClosePath(); +diff --git a/core/fpdfapi/page/cpdf_tilingpattern.cpp b/core/fpdfapi/page/cpdf_tilingpattern.cpp +--- a/core/fpdfapi/page/cpdf_tilingpattern.cpp ++++ b/core/fpdfapi/page/cpdf_tilingpattern.cpp +@@ -53,6 +53,8 @@ + all_states.mutable_graph_state().Emplace(); + all_states.mutable_text_state().Emplace(); + all_states.mutable_general_state() = pPageObj->general_state(); ++ all_states.mutable_color_state().SetRenderIntent( ++ all_states.general_state().GetRenderIntent()); + form->ParseContent(&all_states, &matrix, nullptr); + bbox_ = dict->GetRectFor("BBox"); + return form; +diff --git a/core/fpdfapi/render/cpdf_rendershading.cpp b/core/fpdfapi/render/cpdf_rendershading.cpp +--- a/core/fpdfapi/render/cpdf_rendershading.cpp ++++ b/core/fpdfapi/render/cpdf_rendershading.cpp +@@ -67,6 +67,7 @@ + const std::vector>& funcs, + const RetainPtr& pCS, + int alpha, ++ RenderingIntent rendering_intent, + std::array* output) { + const uint32_t results_count = GetValidatedOutputsCount(funcs, pCS); + if (results_count == 0) { +@@ -91,7 +92,7 @@ + result_span = result_span.subspan(nresults.value()); + } + } +- auto rgb = pCS->GetRGBOrZerosOnError(result_array); ++ auto rgb = pCS->GetRGBOrZerosOnError(result_array, rendering_intent); + shading_steps[i] = + ArgbEncode(alpha, FXSYS_roundf(rgb.red * 255), + FXSYS_roundf(rgb.green * 255), FXSYS_roundf(rgb.blue * 255)); +@@ -111,7 +112,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + RetainPtr pCoords = dict->GetArrayFor("Coords"); +@@ -141,7 +143,7 @@ + float axis_len_square = (x_span * x_span) + (y_span * y_span); + + std::array shading_steps; +- if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, &shading_steps)) { ++ if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + +@@ -179,7 +181,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + RetainPtr pCoords = dict->GetArrayFor("Coords"); +@@ -205,7 +208,7 @@ + const bool bEndExtend = pArray && pArray->GetBooleanAt(1, false); + + std::array shading_steps; +- if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, &shading_steps)) { ++ if (!GetShadingSteps(t_min, t_max, funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + +@@ -279,7 +282,8 @@ + const CPDF_Dictionary* dict, + const std::vector>& funcs, + const RetainPtr& pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + const uint32_t total_results = GetValidatedOutputsCount(funcs, pCS); +@@ -327,7 +331,7 @@ + result_span = result_span.subspan(nresults.value()); + } + } +- auto rgb = pCS->GetRGBOrZerosOnError(result_array); ++ auto rgb = pCS->GetRGBOrZerosOnError(result_array, rendering_intent); + dib_buf[column] = ArgbEncode(alpha, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +@@ -461,11 +465,12 @@ + RetainPtr pShadingStream, + const std::vector>& funcs, + RetainPtr pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + CPDF_MeshStream stream(kFreeFormGouraudTriangleMeshShading, funcs, +- std::move(pShadingStream), pCS); ++ std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -473,7 +478,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -522,7 +527,8 @@ + RetainPtr pShadingStream, + const std::vector>& funcs, + RetainPtr pCS, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + + int row_verts = pShadingStream->GetDict()->GetIntegerFor("VerticesPerRow"); +@@ -531,7 +537,7 @@ + } + + CPDF_MeshStream stream(kLatticeFormGouraudTriangleMeshShading, funcs, +- std::move(pShadingStream), pCS); ++ std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -539,7 +545,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -853,7 +859,8 @@ + const std::vector>& funcs, + RetainPtr pCS, + bool bNoPathSmooth, +- int alpha) { ++ int alpha, ++ RenderingIntent rendering_intent) { + DCHECK_EQ(pBitmap->GetFormat(), FXDIB_Format::kBgra); + DCHECK(type == kCoonsPatchMeshShading || + type == kTensorProductPatchMeshShading); +@@ -864,7 +871,7 @@ + return; + } + +- CPDF_MeshStream stream(type, funcs, std::move(pShadingStream), pCS); ++ CPDF_MeshStream stream(type, funcs, std::move(pShadingStream), pCS, rendering_intent); + if (!stream.Load()) { + return; + } +@@ -872,7 +879,7 @@ + std::array shading_steps; + if (!funcs.empty()) { + if (!GetShadingSteps(stream.component_min(0), stream.component_max(0), +- funcs, pCS, alpha, &shading_steps)) { ++ funcs, pCS, alpha, rendering_intent, &shading_steps)) { + return; + } + } +@@ -1012,7 +1019,8 @@ + const CFX_Matrix& mtMatrix, + const FX_RECT& clip_rect, + int alpha, +- const CPDF_RenderOptions& options) { ++ const CPDF_RenderOptions& options, ++ RenderingIntent rendering_intent) { + RetainPtr pColorSpace = pPattern->GetCS(); + if (!pColorSpace) { + return; +@@ -1027,7 +1035,7 @@ + std::vector comps = ReadArrayElementsToVector( + pBackColor.Get(), pColorSpace->ComponentCount()); + +- auto rgb = pColorSpace->GetRGBOrZerosOnError(comps); ++ auto rgb = pColorSpace->GetRGBOrZerosOnError(comps, rendering_intent); + background = ArgbEncode(255, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +@@ -1039,7 +1047,10 @@ + mtMatrix.TransformRect(dict->GetRectFor("BBox")).GetOuterRect()); + } + #if defined(PDF_USE_SKIA) +- if (pDevice->RenderCapShading() && ++ // The native shading API has no rendering-intent argument. Use the ++ // color-managed bitmap path when its default intent is not appropriate. ++ if (rendering_intent == RenderingIntent::kRelativeColorimetric && ++ pDevice->RenderCapShading() && + pDevice->DrawShading(*pPattern, mtMatrix, clip_rect_bbox, alpha)) { + return; + } +@@ -1061,15 +1072,15 @@ + return; + case kFunctionBasedShading: + DrawFuncShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kAxialShading: + DrawAxialShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kRadialShading: + DrawRadialShading(pBitmap, final_matrix, dict.Get(), funcs, pColorSpace, +- alpha); ++ alpha, rendering_intent); + break; + case kFreeFormGouraudTriangleMeshShading: { + // The shading object can be a stream or a dictionary. We do not handle +@@ -1078,7 +1089,7 @@ + ToStream(pPattern->GetShadingObject()); + if (pStream) { + DrawFreeGouraudShading(pBitmap, final_matrix, std::move(pStream), funcs, +- pColorSpace, alpha); ++ pColorSpace, alpha, rendering_intent); + } + break; + } +@@ -1089,7 +1100,7 @@ + ToStream(pPattern->GetShadingObject()); + if (pStream) { + DrawLatticeGouraudShading(pBitmap, final_matrix, std::move(pStream), +- funcs, pColorSpace, alpha); ++ funcs, pColorSpace, alpha, rendering_intent); + } + break; + } +@@ -1102,7 +1113,8 @@ + if (pStream) { + DrawCoonPatchMeshes(pPattern->GetShadingType(), pBitmap, final_matrix, + std::move(pStream), funcs, pColorSpace, +- options.GetOptions().bNoPathSmooth, alpha); ++ options.GetOptions().bNoPathSmooth, alpha, ++ rendering_intent); + } + break; + } +diff --git a/core/fpdfapi/render/cpdf_rendershading.h b/core/fpdfapi/render/cpdf_rendershading.h +--- a/core/fpdfapi/render/cpdf_rendershading.h ++++ b/core/fpdfapi/render/cpdf_rendershading.h +@@ -6,6 +6,8 @@ + + #ifndef CORE_FPDFAPI_RENDER_CPDF_RENDERSHADING_H_ + #define CORE_FPDFAPI_RENDER_CPDF_RENDERSHADING_H_ ++ ++#include "core/fxcrt/rendering_intent.h" + + class CFX_Matrix; + class CFX_RenderDevice; +@@ -24,7 +26,8 @@ + const CFX_Matrix& mtMatrix, + const FX_RECT& clip_rect, + int alpha, +- const CPDF_RenderOptions& options); ++ const CPDF_RenderOptions& options, ++ RenderingIntent rendering_intent); + + CPDF_RenderShading() = delete; + CPDF_RenderShading(const CPDF_RenderShading&) = delete; +diff --git a/core/fpdfapi/render/cpdf_renderstatus.cpp b/core/fpdfapi/render/cpdf_renderstatus.cpp +--- a/core/fpdfapi/render/cpdf_renderstatus.cpp ++++ b/core/fpdfapi/render/cpdf_renderstatus.cpp +@@ -20,6 +20,7 @@ + #include "core/fpdfapi/font/cpdf_font.h" + #include "core/fpdfapi/font/cpdf_type3char.h" + #include "core/fpdfapi/font/cpdf_type3font.h" ++#include "core/fpdfapi/page/cpdf_allstates.h" + #include "core/fpdfapi/page/cpdf_docpagedata.h" + #include "core/fpdfapi/page/cpdf_form.h" + #include "core/fpdfapi/page/cpdf_formobject.h" +@@ -962,7 +963,8 @@ + continue; + } + +- CPDF_Type3Char* pType3Char = pType3Font->LoadChar(charcode); ++ const RenderingIntent intent = textobj->general_state().GetRenderIntent(); ++ CPDF_Type3Char* pType3Char = pType3Font->LoadChar(charcode, intent); + if (!pType3Char) { + continue; + } +@@ -1064,7 +1066,7 @@ + CPDF_DocRenderData::FromDocument(doc)->GetCachedType3(pType3Font); + + const CFX_GlyphBitmap* pBitmap = +- pCache->LoadGlyphBitmap(charcode, matrix); ++ pCache->LoadGlyphBitmap(charcode, matrix, intent); + if (!pBitmap) { + continue; + } +@@ -1164,6 +1166,7 @@ + CPDF_PathObject path; + path.mutable_graph_state() = textobj->graph_state(); + path.mutable_color_state() = textobj->color_state(); ++ path.mutable_general_state() = textobj->general_state(); + + CFX_Matrix matrix = charpos.GetEffectiveMatrix(CFX_Matrix( + font_size, 0, 0, font_size, charpos.origin_.x, charpos.origin_.y)); +@@ -1200,7 +1203,8 @@ + FXSYS_roundf(255 * (stroke ? pPageObj->general_state().GetStrokeAlpha() + : pPageObj->general_state().GetFillAlpha())); + CPDF_RenderShading::Draw(device_, context_, cur_obj_, pattern, matrix, rect, +- alpha, options_); ++ alpha, options_, pattern->GetRenderIntent( ++ pPageObj->general_state().GetRenderIntent())); + } + + void CPDF_RenderStatus::ProcessShading(const CPDF_ShadingObject* pShadingObj, +@@ -1214,7 +1218,7 @@ + CPDF_RenderShading::Draw( + device_, context_, cur_obj_, pShadingObj->pattern(), matrix, rect, + FXSYS_roundf(255 * pShadingObj->general_state().GetFillAlpha()), +- options_); ++ options_, pShadingObj->general_state().GetRenderIntent()); + } + + void CPDF_RenderStatus::DrawTilingPattern(CPDF_TilingPattern* pattern, +@@ -1448,7 +1452,20 @@ + + CPDF_Form form(context_->GetDocument(), context_->GetMutablePageResources(), + pGroup); +- form.ParseContent(); ++ // Keep the parser's ordinary empty-stream initial state, inheriting only the ++ // color-conversion intent. In particular, do not inherit the active mask, ++ // alpha, clipping path, or current colors into the mask's content stream. ++ CPDF_AllStates mask_states; ++ mask_states.mutable_general_state().Emplace(); ++ mask_states.mutable_graph_state().Emplace(); ++ mask_states.mutable_text_state().Emplace(); ++ mask_states.mutable_color_state().Emplace(); ++ const RenderingIntent rendering_intent = ++ cur_obj_ ? cur_obj_->general_state().GetRenderIntent() ++ : initial_states_.general_state().GetRenderIntent(); ++ mask_states.mutable_general_state().SetRenderIntent(rendering_intent); ++ mask_states.mutable_color_state().SetRenderIntent(rendering_intent); ++ form.ParseContent(&mask_states, nullptr, nullptr); + + bool bLuminosity = + smask_dict->GetByteStringFor(pdfium::transparency::kSoftMaskSubType) != +@@ -1574,7 +1591,10 @@ + std::vector floats = ReadArrayElementsToVector(pBC.Get(), count); + floats.resize(comps); + +- auto rgb = pCS->GetRGBOrZerosOnError(floats); ++ const RenderingIntent rendering_intent = ++ cur_obj_ ? cur_obj_->general_state().GetRenderIntent() ++ : initial_states_.general_state().GetRenderIntent(); ++ auto rgb = pCS->GetRGBOrZerosOnError(floats, rendering_intent); + return ArgbEncode(255, static_cast(rgb.red * 255), + static_cast(rgb.green * 255), + static_cast(rgb.blue * 255)); +diff --git a/core/fpdfapi/render/cpdf_type3cache.cpp b/core/fpdfapi/render/cpdf_type3cache.cpp +--- a/core/fpdfapi/render/cpdf_type3cache.cpp ++++ b/core/fpdfapi/render/cpdf_type3cache.cpp +@@ -82,12 +82,19 @@ + + const CFX_GlyphBitmap* CPDF_Type3Cache::LoadGlyphBitmap( + uint32_t charcode, +- const CFX_Matrix& mtMatrix) { ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent) { ++ if (charcode >= 256 || static_cast(intent) > ++ static_cast( ++ RenderingIntent::kAbsoluteColorimetric)) { ++ return nullptr; ++ } + SizeKey keygen = { + FXSYS_roundf(mtMatrix.a * 10000), + FXSYS_roundf(mtMatrix.b * 10000), + FXSYS_roundf(mtMatrix.c * 10000), + FXSYS_roundf(mtMatrix.d * 10000), ++ intent, + }; + CPDF_Type3GlyphMap* pSizeCache; + auto it = size_map_.find(keygen); +@@ -104,7 +111,7 @@ + } + + std::unique_ptr pNewBitmap = +- RenderGlyph(pSizeCache, charcode, mtMatrix); ++ RenderGlyph(pSizeCache, charcode, mtMatrix, intent); + CFX_GlyphBitmap* pGlyphBitmap = pNewBitmap.get(); + pSizeCache->SetBitmap(charcode, std::move(pNewBitmap)); + return pGlyphBitmap; +@@ -113,8 +120,9 @@ + std::unique_ptr CPDF_Type3Cache::RenderGlyph( + CPDF_Type3GlyphMap* pSize, + uint32_t charcode, +- const CFX_Matrix& mtMatrix) { +- CPDF_Type3Char* pChar = font_->LoadChar(charcode); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent) { ++ CPDF_Type3Char* pChar = font_->LoadChar(charcode, intent); + if (!pChar) { + return nullptr; + } +diff --git a/core/fpdfapi/render/cpdf_type3cache.h b/core/fpdfapi/render/cpdf_type3cache.h +--- a/core/fpdfapi/render/cpdf_type3cache.h ++++ b/core/fpdfapi/render/cpdf_type3cache.h +@@ -15,6 +15,7 @@ + + #include "core/fxcrt/bytestring.h" + #include "core/fxcrt/observed_ptr.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/retain_ptr.h" + + class CFX_GlyphBitmap; +@@ -27,17 +28,20 @@ + CONSTRUCT_VIA_MAKE_RETAIN; + + const CFX_GlyphBitmap* LoadGlyphBitmap(uint32_t charcode, +- const CFX_Matrix& mtMatrix); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent); + + private: +- using SizeKey = std::tuple; ++ // Preserve the existing size key, with at most four RI variants per size. ++ using SizeKey = std::tuple; + + explicit CPDF_Type3Cache(CPDF_Type3Font* font); + ~CPDF_Type3Cache() override; + + std::unique_ptr RenderGlyph(CPDF_Type3GlyphMap* pSize, + uint32_t charcode, +- const CFX_Matrix& mtMatrix); ++ const CFX_Matrix& mtMatrix, ++ RenderingIntent intent); + + RetainPtr const font_; + std::map> size_map_; +diff --git a/core/fxcodec/icc/icc_transform.cpp b/core/fxcodec/icc/icc_transform.cpp +--- a/core/fxcodec/icc/icc_transform.cpp ++++ b/core/fxcodec/icc/icc_transform.cpp +@@ -54,7 +54,8 @@ + + // static + std::unique_ptr IccTransform::CreateTransformSRGB( +- pdfium::span span) { ++ pdfium::span span, ++ RenderingIntent intent) { + ScopedCmsProfile srcProfile(cmsOpenProfileFromMem( + span.data(), pdfium::checked_cast(span.size()))); + if (!srcProfile) { +@@ -96,9 +97,9 @@ + cmsHTRANSFORM hTransform = nullptr; + switch (dstCS) { + case cmsSigRgbData: +- hTransform = +- cmsCreateTransform(srcProfile.get(), srcFormat, dstProfile.get(), +- TYPE_BGR_8, INTENT_PERCEPTUAL, /*dwFlags=*/0); ++ hTransform = cmsCreateTransform( ++ srcProfile.get(), srcFormat, dstProfile.get(), TYPE_BGR_8, ++ static_cast(intent), /*dwFlags=*/0); + break; + case cmsSigGrayData: + case cmsSigCmykData: +diff --git a/core/fxcodec/icc/icc_transform.h b/core/fxcodec/icc/icc_transform.h +--- a/core/fxcodec/icc/icc_transform.h ++++ b/core/fxcodec/icc/icc_transform.h +@@ -12,6 +12,7 @@ + #include + + #include "core/fxcodec/fx_codec_def.h" ++#include "core/fxcrt/rendering_intent.h" + #include "core/fxcrt/span.h" + + #if defined(USE_SYSTEM_LCMS2) +@@ -25,7 +26,8 @@ + class IccTransform { + public: + static std::unique_ptr CreateTransformSRGB( +- pdfium::span span); ++ pdfium::span span, ++ RenderingIntent intent = RenderingIntent::kRelativeColorimetric); + + ~IccTransform(); + +diff --git a/core/fxcrt/BUILD.gn b/core/fxcrt/BUILD.gn +--- a/core/fxcrt/BUILD.gn ++++ b/core/fxcrt/BUILD.gn +@@ -113,6 +113,7 @@ + "ptr_util.h", + "raw_span.h", + "retain_ptr.h", ++ "rendering_intent.h", + "scoped_set_insertion.h", + "shared_copy_on_write.h", + "span.h", +diff --git a/core/fxcrt/rendering_intent.h b/core/fxcrt/rendering_intent.h +--- /dev/null ++++ b/core/fxcrt/rendering_intent.h +@@ -0,0 +1,16 @@ ++// Copyright 2026 The PDFium Authors ++// Use of this source code is governed by a BSD-style license that can be ++// found in the LICENSE file. ++ ++#ifndef CORE_FXCRT_RENDERING_INTENT_H_ ++#define CORE_FXCRT_RENDERING_INTENT_H_ ++ ++// Values match the ICC rendering intents, as used by LittleCMS. ++enum class RenderingIntent { ++ kPerceptual = 0, ++ kRelativeColorimetric = 1, ++ kSaturation = 2, ++ kAbsoluteColorimetric = 3, ++}; ++ ++#endif // CORE_FXCRT_RENDERING_INTENT_H_ diff --git a/cmake/pdfium-candidate-v2.lock.json b/cmake/pdfium-candidate-v2.lock.json new file mode 100644 index 0000000..3b863f6 --- /dev/null +++ b/cmake/pdfium-candidate-v2.lock.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "candidateId": "reviewed-v2", + "sourceRevision": "a5a7089234f121990b336b3841008009dca143bf", + "librarySha256": "cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403", + "buildInfoSha256": "6fbb6f24a7ca241150f8abf8d9ad031c76d9cfffc809026a61068c357d47ae18", + "anchorRecordPath": "provenance/master6.json", + "anchorRecordSha256": "80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b", + "patchSha256": "470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616", + "gnArgsSha256": "5a2e04e1c0bb3eb05dc415dfa005a917804be8f63ade2365016c148e8efd8197", + "supplementalSourceSha256": "97d75f0b50e5ad8114229ec4e5cece147272563dfed4fd434087b70d871a114a" +} diff --git a/cmake/pdfium.lock.json b/cmake/pdfium.lock.json new file mode 100644 index 0000000..5de356b --- /dev/null +++ b/cmake/pdfium.lock.json @@ -0,0 +1,28 @@ +{ + "version": "155.0.8057.0", + "branch": "chromium/8057", + "upstreamCommit": "a5a7089234f121990b336b3841008009dca143bf", + "upstream": "https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf", + "binaryProvider": "https://github.com/bblanchon/pdfium-binaries", + "linuxX64Archive": "https://github.com/bblanchon/pdfium-binaries/releases/download/chromium%2F8057/pdfium-linux-x64.tgz", + "linuxX64Sha256": "7788fa57a2996ebd21afc4090eb0a42b9f95ef3a72e7ef4c0756f1ea703c0415", + "windowsX64Archive": "https://github.com/bblanchon/pdfium-binaries/releases/download/chromium%2F8057/pdfium-win-x64.tgz", + "windowsX64Sha256": "e307d519e42f2e69b1b531f0c2a32dffcdf3891ec0eba60328ba51a57cec01ed", + "windowsBuildOptions": { + "pdf_enable_v8": false, + "pdf_enable_xfa": false, + "target_cpu": "x64", + "target_os": "win" + }, + "buildOptions": { + "pdf_enable_v8": false, + "pdf_enable_xfa": false, + "pdf_is_standalone": true, + "is_debug": false, + "pdf_use_partition_alloc": false, + "target_cpu": "x64", + "target_os": "linux" + }, + "licenses": [".deps/pdfium/LICENSE", ".deps/pdfium/licenses/"], + "apiNotes": "Structure tree, MCID and page-object access use experimental public APIs, compiled against these exact headers. No private Qt or PDFium API is used." +} diff --git a/docs/ACCEPTANCE-AUDIT.md b/docs/ACCEPTANCE-AUDIT.md new file mode 100644 index 0000000..ad50b20 --- /dev/null +++ b/docs/ACCEPTANCE-AUDIT.md @@ -0,0 +1,89 @@ +# 原設計との受入照合 + +2026-09-20。基準は変更していない [設計書11件](design/00-guide.md)。個別試験の成功と製品全体の完成を区別する。実行結果とビルドの対応は [検証記録](VALIDATION.md) に置く。 + +## 要件の追跡 + +2026-09-20追記:[第2PDFium候補](../tests/results/pdfium-intent-context/README.md)を専用ビルドとUbuntu validation4へ統合した。旧試験の `shading-pattern-inherit` 8点の期待値は誤りだったため、旧648点の成功をその規則の根拠にしない。訂正版600点・元48点・追加324点はArch・Ubuntuで成功し、新ビルド各24群、上流1,979試験も成功した。SoftMask/tiling専用試験、Ubuntu配布境界13試験、2回のdeb生成一致、SDKなし既存VMの12条件起動・削除、PDF性能4条件と約1GBの負荷試験まで実施済み。以下の第1候補・元依存版の未完了記述は当時の履歴である。T01/G-RENDER全体の承認と対象実機・Windows・公開配布の最終受入は未完了のまま。 + +2026-09-20追記:[ICC変換指定の修正候補](../tests/results/pdfium-intent-build/README.md)では、固定ソースへのパッチを再適用確認し、隔離ビルドの648点・上流1,976試験・DocView全22群が成功した。製品依存とUbuntuパッケージは未変更で、T01/G-RENDERの完成判定は進めない。比較先のshading pattern RI指定の差、候補のUbuntu・配布統合、SoftMask/tiling専用の画素試験、人による承認は残る。 + +2026-09-20追記:[ICC CMYK追加比較](../tests/results/cmyk-lut/README.md)で、文書の相対的測色指定に対して固定PDFiumが知覚的変換を使う差を確認した。Arch・Ubuntu各48点中30点が許容差を超えた。原因は照合済みだが、T01/G-RENDERのこの色一致条件は未達である。変換指定への対応または明示的な互換性判断、検出可能な制約の通知を未完了事項に追加する。 + +2026-09-20追記:[Qt告知補足版](../tests/results/qt-notice-supplement/README.md)はWebM/WebPのPatent本文を固定ソースと照合してUbuntu debへ追加した。梱包8試験×2環境、deb再現性と全2,016ファイル検証、SDKなしVMでの起動12条件・install/remove/purgeが成功。本体と同梱ランタイムは同一である。全Chromium告知・対応ソースの完成、Windows/物理環境/人による描画承認の受入を済ませたとはしない。 + +2026-09-20追記:[実ファイル選択の追加試験](../tests/results/portal/README.md)で特殊なファイル名のHTMLが読込中になる問題を発見・修正した。新しい本体ではArch・Ubuntuの全22群が成功。以下の過去の性能・GPU・IME等の証拠は固定した旧版の範囲に限り、現行本体への再実行は別に扱う。 + +修正後の[性能10条件と追加負荷](../tests/results/portal/performance-suite/README.md)も再実行した。新規プロセス300回・同一プロセス内300回・移動2,500操作、約1GB/5,000ページPDFが成功し、測定した参考予算の超過は0だった。基準機・対象OS・実フレーム提示の受入条件は変更しない。 + +| 原要件 | 現在の証拠 | 最終判定に残るもの | +|---|---|---| +| R01 PDF描画、R14 原本を編集しない | 本番の隔離PDFWorker、独立レンダラー比較、原本ハッシュ、注釈・フォーム・日本語・幾何試験 | G-RENDERの承認画像、対象OS、代表制作ソフトの互換性判断 | +| R02 HTML、R03 HTML ZIP、R04 EPUB | 本番WebEngine/ArchiveWorker、相対資源・spine・RTL・縦書き・固定見開き・入口選択試験 | 両OSのG-WEB・実ディスプレイ。遅延レイアウトと実読取量の回帰はLinuxで確認 | +| R05 キー操作、R08 最小UI、R15 GUIのTUI風操作 | InputRouter、設定、QMLの実キーイベント試験、パネル表示/フォーカス試験 | X11 US/JP XKBとX11/Wayland IBus/Mozcは追加検証済み。物理入力・Windows IME・対象ディスプレイ | +| R06 見出し、R07 目次 | PDF構造/内部宛先outline代替・Form所有元、DOM/ARIA、EPUB nav/NCX、抽出とGUI移動、索引loadingの待機表示・失敗した見出し章境界の破棄 | 特殊構造の既知制約、対象OSのG-HEADINGS | +| R09 拡張性 | [接続契約の採用記録](adapter-contract.md)、能力スキーマ、操作と解析プロセスの分離 | 形式追加の実装時は同契約と取消・位置のレビューを適用 | +| R10 ファイル設定 | TOMLの有限スキーマ、一括適用、未知キー/型/衝突/不正時の保持、再読込試験、日本語の行・原因案内 | 対象OSの実入力系 | +| R11 書籍の応答性 | 冷起動/再open各30回・各125操作、500ページ/章、約1GB/5,000ページ、cache上限・旧応答破棄・メモリ圧迫対処 | 基準機・GPU/60Hzと長時間負荷 | +| R12 Linux/Windows、R13 OS配置 | ArchとUbuntu 24.04専用VMのX11/Wayland、ArchのAMD OpenGL、XDG・専用保存先、Windows Known Folders/LPAC/pipe等の実装 | Windows nativeビルドと試験、対象OSのGPU/IME、製品配布の実機検証 | + +補助機能T-P01〜04、安全試験T-S01〜05、通常受入T01〜21の個別範囲は [検証記録の対応表](VALIDATION.md#受入idとの対応) を参照する。外部から追加環境は提供されていないが、このPC内にUbuntu 24.04専用KVM環境を作り、全22群・Wayland GUI・配置後起動を[検証した](../tests/results/ui-final/ubuntu/README.md)。生成文書を使うことは利用者が指定済みであり、実書籍がないことを理由にローカル試験を止めない。 + +## 今回の照合で見つけたローカル項目 + +| 項目 | 原文 | 対応・必要な証拠 | +|---|---|---| +| 現在位置と目次選択を別表示 | [04 §2.2](design/04-ui-navigation.md#22-閲覧画面) | 現在節の記号と選択枠を分け、選択のみで本文が動かないGUI試験。折りたたみ・文書切替・古い応答も確認する | +| 現在PDFページとズーム中心 | [04 §5.1](design/04-ui-navigation.md#51-pdf) | 中央線のページ/余白の距離/同距離の前ページと、保存用先頭位置を分離。ズーム中心のPDF座標保持を検証する | +| ページ精度・回転見出しの反復移動 | [04 §5.4](design/04-ui-navigation.md#54-見出し移動の共通定義) | `navigationY`で表示上の前後を判定。ページ先頭を前操作で再選択しないこと、回転時の実GUI移動を試験 | +| ICC入力 | [G-RENDER](design/09-decisions-roadmap.md#4-実装前の技術ゲート) | 自作RGB ICCの54点と独立比較。元依存版のCMYK CLUTで48点中30点の差を再現し、第2候補で48点・訂正済み状態600点・SoftMask/tiling等324点が各OSで成功。校正済み印刷色や人による描画承認は未完了 | +| Web rendererの応答監視 | [07 §3.2](design/07-security-errors.md#32-上限の初期提案) | RSSだけでなく有限deadlineの応答監視、実renderer停止、取消/世代/対象identity/2viewを検証する | +| メモリ圧迫の段階的縮退 | [02 資源管理](design/02-architecture.md#キャッシュと資源管理)、[06 設定](design/06-config-storage.md#設定項目) | OSの空き監視、先読み停止→不可視cache回収→解像度抑制通知→処理停止と回復。[契約](memory-pressure.md)と注入試験 | +| 依存告知・配布アーカイブ | [09 配布](design/09-decisions-roadmap.md) | 同梱とシステム依存、実版・由来・license・対応sourceを区別したmanifest。再現可能な開発版パッケージ・サイズ・解凍起動の証跡 | +| XHTMLの章内見出しと目次 | [03 EPUB](design/03-formats.md)、[04 見出し移動](design/04-ui-navigation.md#54-見出し移動の共通定義) | 実XHTMLでnative見出しの小文字タグ名と名前空間付きepub:typeを扱う。著者目次と章内見出しを区別し、測定時も実索引の種類・件数を記録する | +| 原本の識別と位置保存 | [06 読書状態と履歴](design/06-config-storage.md#読書状態と履歴) | 全形式のopen時identityを保存時に照合し、閲覧中に置換された新原本へ旧位置を転用しない。通常openでの復元不能通知も実ファイルで検査 | +| 目次の準備中表示 | [02 開く処理](design/02-architecture.md) | DOM索引が空でも能力loadingなら要求を保持し、読み込み中のパネルを表示。索引確定後の表示までGUIで検査 | +| PDF見出し代替の内部宛先 | [04 §5.1](design/04-ui-navigation.md#51-pdf) | 外部・禁止アクションを代替見出し候補から除外。外部だけの場合の能力と、混在時の次/前移動をGUIで検査 | +| Webリンクと章境界の取消 | [04 入力・見出し移動](design/04-ui-navigation.md) | EscでDOMリンク選択とfocusを解除。成立した章移動にだけ最初/最後の見出し指示を付け、失敗後の通常章移動へ残さない | +| PDF情報の省略通知 | [03 PDF](design/03-formats.md)、[07 エラー](design/07-security-errors.md) | 注釈本文長・ページ内件数・CBOR情報予算超過を通知し、取得済みprefixを保持。本文省略でも注釈を一覧から消さない | +| 一時入力とダイアログの焦点復帰 | [04 入力状態](design/04-ui-navigation.md) | 目次・ページ一覧からのコマンド取消、help/file dialog終了後は有効な元領域へ戻す。明示的な本文移動と非表示領域へのfallbackを検査 | +| Web倍率の表示と位置保存 | [04 ステータス](design/04-ui-navigation.md)、[06 状態](design/06-config-storage.md) | HTML/ZIP/EPUBの倍率を表示・保存・再復元。fixedはfitの基準と論理倍率を分け、25〜500%の境界を検査 | +| 開く操作の失敗案内 | [04 失敗表示](design/04-ui-navigation.md) | 失敗した対象名・有限の分類・対処を表示し、表示中の旧文書と区別して文書情報へ試行内容を保持する | +| 日本語初期表示と翻訳資源 | [01 対象環境](design/01-requirements.md) | tr/qsTrのTS抽出・QM埋込・QTranslatorとQt標準部品の日本語資源を接続。英語localeの実Qt Quick Dialogを検査。OSネイティブdialogはOS言語に従う | + +この表のうちXHTML修正までの履歴はLinux統合19試験群(2026-09-19、162.00秒)と、その時点のPDFWorkerに対応する6組の比較で確認した。後から加えた原本同一性・目次待機・PDF候補・Web取消等は[追加回帰](../tests/results/completion-regressions/controller-state.md)で確認した。翻訳試験を含む[前回20試験群](../tests/results/completion-final/ctest/tests.xml)はすべて成功した(165.61秒)。[描画6系列・実時間watchdog・再現可能な開発パッケージと6条件起動](../tests/results/completion-final/README.md)も再検証した。T20の[10条件と負荷の再測定](../tests/results/completion-final/performance-summary.md)も完了し、[検証記録](VALIDATION.md)の現行版と履歴の区別に従う。旧結果を現行バイナリーへ読み替えない。 + +今回のUI追加修正は[最新の22群のX11統合](../tests/results/ui-final/README.md)で検証した。Waylandの6条件起動と全GUIも成功した。見開きresizeの初回2失敗は試験側の確定待ちを修正し、製品の描画処理は変更していない。修正前の本体の[性能10条件と操作負荷](../tests/results/ui-final/performance-summary.md)、[約1GB・5,000ページ](../tests/results/ui-final/stress/README.md)も成功した。以下の20群・従来性能測定は修正前に固定したビルドの結果であり、最新本体へ読み替えない。 + +## 追加測定と個別の検証結果 + +1. **T20の測定範囲** — [08 §3.3](design/08-performance-tests.md#33-再現可能な測定手順) は開始時・最大・文書close後の本体/プロセス群RSS、同一文書10開閉、見出し/章の固定100操作、連続scrollフレーム間隔、描画待ち数を要求する。測定経路を拡充し、アプリキャッシュなし30回、同一プロセス30回、29同条件開閉と最後のclose、各125操作系列、240scroll、描画待ち/旧要求破棄を記録する。別の固定操作列で長押し・遠距離ジャンプ・resize・異文書切替・取消を確認する。実scanoutとの結合と基準機判定は未実施。 +2. **遅延フォント・画像による再配置** — [03 EPUB](design/03-formats.md) の「レイアウト確定後に同じ論理位置へ再配置」について、load成功時/設定時/resizeの一度の復元後にフォントが届く場合を実DOMで再現し、位置保持と取消を確認する。遅延資源の実読込み、横/縦の文字位置保持、新しい移動を優先する回帰を実装した。連続scroll、native anchoring、poll先行、クリック後の画像到着も追加し、Web全34ケースが成功した。 +3. **圧縮率の実入力量** — [07 §3.2](design/07-security-errors.md#32-上限の初期提案) の200倍判定は入力消費量/出力量を指定している。ZIP索引のcompressed sizeだけの判定を補い、source callbackの実read量を各展開に計数する修正を実装した。5圧縮方式のpadding資料、正規6方式、本番sandbox workerを含むArchive全76ケースが成功した。実出力256MiB/文書2GiBの別上限があることを、この比率の検証の代用にしない。 + +4. **メモリ圧迫** — 検出/遷移27ケース、Canvas21ケースとDPR2、GUIでの段階縮退・停止・回復・原本保持を検証。実RAMを枯渇させず、同じ本番経路へ有限sampleを注入した。 +5. **初期画面サイズ** — 配置後の実測で200%時の画面超過を検出し、利用可能な画面へ収める修正を実施。配置後/別展開先の6条件で寸法と表示を確認した。 +6. **XHTMLの見出し** — 小型EPUBの性能試験を契機に、HTMLとXHTMLのタグ名表現の違いでnative見出しが欠落する問題を実DOMで再現した。見出しと目次を修正し、計測側の著者目次による代用も除いた。[修正前の失敗記録](../tests/results/performance-before-xhtml-fix/README.md)は保持し、旧EPUBの見出し系列は最終版の証拠に使わない。 +7. **章末の重複移動先** — 章末で同じスクロール位置へ収まる2見出しを交互に再選択する問題を横書き・縦書きで再現した。実移動がない候補をまとめ、EPUBの次章境界へ進めるよう修正した。元の500章資料を保持して章を跨ぐ往復を検証し、性能用には全13見出しの移動先が異なる別版を生成した。[回帰記録](../tests/results/xhtml-headings/README.md) +8. **PDFiumの追加告知** — 固定providerの収集処理と実libraryを調べ、libc++/libc++abiの2告知が従来の15件に含まれないことを確認した。固定ソースの全文と出典をinstallへ追加し、版・library・本文の不一致を拒否する。[新パッケージ](../tests/results/linux-development-package/README.md)は553ファイルを照合し、独立生成2回のbyte一致・6条件の別展開先起動を確認。packaging 12件とprovenance 23件の限定試験も成功した。この告知追加時点では全3実行ファイルとPDFiumのバイト列は従来版と同一だった。後続の製品修正後のバイナリー一致を示す記録ではない。[現行パッケージ](../tests/results/completion-final/linux-development-package/README.md)は別のhashで2回生成一致・6条件の起動を再確認し、[由来台帳](../tests/results/completion-final/dependency-provenance/README.md)も更新した。 + +9. **高解像度画像・高密度ベクターの取消** — [追加GUI試験](../tests/results/heavy-pdf/README.md)では600dpi相当のRGB画像と60,000個の半透明パスを生成し、独立復号と実ウィンドウ画素を照合した。実open/render RPCが未完の状態で取消・文書切替を行い、原本不変、旧session回収、通常解像度とGUI応答の維持をArch/Ubuntuで確認した。単一生成資料での機能検証であり、長時間・基準機の性能受入ではない。 + +10. **実IMEと日英配列** — [Ubuntu X11のIBus/Mozc試験](../tests/results/ime/README.md)で、検索・コマンド入力の変換確定、変換候補/未確定入力のEsc取消、j/k・数字の入力保護、通常操作への復帰を検証した。US pc105/JP jp106のXKBとXTestキー番号で記号操作を確認。[WaylandのQt+IBus D-Bus経路](../tests/results/ime/WAYLAND.md)も実変換と入力保護が成功。物理キーボード・Windowsは未実施。 + +11. **固定PDFiumのソースと配布ヘッダー** — [Git snapshot収集](../tests/results/source-archives/PDFIUM.md)で、本体と27依存・追加gitlinkの計29件を保管。Git objectと全アーカイブ内容を検査し、先行原文31件・公開ヘッダー24件・告知15件が一致した。Linux3件/Windows4件のproviderパッチは専用コピーに適用成功。ビルドの再現、Windows実行、全ビルド入力の回収とは区別する。 + +12. **Ubuntu SDK内のChromium告知** — [SBOM本文抽出](../tests/results/source-archives/QT-SDK-NOTICES.md)で、129entry・105本文をQt source原文と照合した。元SDK archiveのファイルhashをSBOM67件・Ubuntu実行時83件と一致確認。元SBOMの参照ID不整合1件と本文なし2部品を記録し、生成器のskipも保持した。SDKの全告知やArchの実GN構成の完全性を示すものではない。 + +13. **Ubuntu用debの配置と回収** — [ローカル検証パッケージ](../tests/results/ubuntu-package/README.md)は2回生成でbyte一致し、全2,009ファイルを照合した。元SDK・build/installを隠した通常ユーザーでX11/Waylandの12条件がReadyとなり、install/reinstall/remove/purge、AppArmorの登録・解除、設定と文書の保持probeが成功した。さらに[新しいUbuntu OS](../tests/results/ubuntu-package/clean-vm/README.md)でも宣言依存の解決、12条件起動、remove/purgeが成功した。公開配布条件と実機desktopの受入は残る。 + +## 外部条件を要する受入 + +UbuntuのOSファイル選択は、[実XDG/GTK portal](../tests/results/portal/README.md)のX11/Wayland計10ケースで追加検証した。生成資料の日本語・空白・予約文字を含む名前を用い、取消・4形式の選択・焦点復帰を確認。仮想入力とheadless描画の証拠であり、以下の物理環境の項目は残る。 + +- Windows 11 native、物理入力・Windows IME・対象OSのGPU・物理表示、公開配布条件と実機desktopの検証。Ubuntu 24.04は専用VMのXvfb/Swayで全22群、100%・200%を含む起動、配置後起動を確認済み。Archでは[AMD OpenGLのGUIと6条件起動](../tests/results/ui-final/wayland/GPU-VALIDATION.md)も成功したが、対象OSのGPU・物理表示の受入とは区別する。 +- 本番期限の[現行実時間watchdog](../tests/results/completion-final/worker-deadlines/README.md)は成功しているが、基準機の性能・メモリ・実フレーム提示は未判定。仮想X11の開発機数値は参考値に限定する。 +- 実際の配布範囲に対する対応source・告知・ライセンス条件の最終照合。[台帳](DEPENDENCY-PROVENANCE.md)と[ソース対応記録](../tests/results/source-correspondence/README.md)で、告知追加版の553ファイル、PDFiumの15+2告知、toml++組込コード、Qtの動的リンク、146件の同版キャッシュmetadata、主要4 recipeと1,632実ファイルを照合した。[現行アーカイブの由来台帳](../tests/results/completion-final/dependency-provenance/README.md)も別に生成し、2回一致を確認した。[Qt一式とtoml++のsource取得・照合](../tests/results/source-archives/README.md)は後続で完了した。独立PDFium等を含む完全な対応source・全linked入力・全Chromium告知は未完了。収集状況と実際の配布条件から生じる必要性の判断を区別する。DocView自身の公開ライセンスは未選定であり、ローカルアーカイブ生成は公開配布を意味しない。 +- G-RENDERで指定された正解画像の人による承認。[現行21組の比較画面](../tests/results/completion-final/render-review/index.html)に確認項目・既知の差分・原本/画像ハッシュをまとめた。独立Popplerとの19組と同一PDFiumのTTC/TTF比較2組を区別する。生成時の期待値やエージェントの画像確認だけで承認済みとは記録しない。 + +保存外観のない非Text注釈のNoZoomとannotation所有OBJR/StmOwnは検出時に制約を示す。PDFの情報上限による省略も黙って欠如として扱わず通知する。原設計は検証済み互換範囲と明示制約の管理を許すため、これらを任意PDFへの無制限な対応義務とは読み替えない。一方、対象OSや承認ゲートの不足を「既知制約」で合格に変更しない。 diff --git a/docs/ARCH-RECIPE-CORRESPONDENCE.md b/docs/ARCH-RECIPE-CORRESPONDENCE.md new file mode 100644 index 0000000..1d7e1c9 --- /dev/null +++ b/docs/ARCH-RECIPE-CORRESPONDENCE.md @@ -0,0 +1,86 @@ +# Arch recipe とローカル依存版の対応 + +最終開発版の[依存由来台帳](DEPENDENCY-PROVENANCE.md)に記録した 4 件について、 +キャッシュ `.BUILDINFO` の `pkgbuild_sha256sum` と**完全一致する**公式 Arch packaging +Git の固定 commit を確認した。latest recipe で代用していない。 +全 SHA-256、URL、Git blob ID、取得ファイル一覧は +[correspondence.json](../tests/results/source-correspondence/arch/correspondence.json)に保存した。 + +| パッケージ版 | 一致した公式 Arch commit | recipe SHA-256 の先頭 | +|---|---|---| +| qt6-base 6.11.2-3 | [dc5728701868](https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-base/-/commit/dc5728701868792b88f1a3e7f9b56aa3362c5474) | `217bd5ea50a3c721` | +| qt6-declarative 6.11.2-2 | [9eac2c716283](https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-declarative/-/commit/9eac2c716283905721fdcbc5bb18494f0a486325) | `f955fe1f3f93ca32` | +| qt6-webengine 6.11.2-1 | [f701f0aa989dc](https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-webengine/-/commit/f701f0aa989dcbc653805d696118f48d6a70dd0d) | `e4395367f03fd6ca` | +| tomlplusplus 3.4.0-2 | [69b0e478ef49](https://gitlab.archlinux.org/archlinux/packaging/packages/tomlplusplus/-/commit/69b0e478ef49b2a5636346d8c9b64c1effc39cfa) | `08383384ca64c9cd` | + +同 commit の packaging ファイル 30 件、25,299 bytes を取得した。PKGBUILD、展開済み +参照一覧 `.SRCINFO`、local patch、packaging 自体の LICENSE/REUSE 等を含む。 +全件の SHA-256 と Git blob ID を照合した。recipe のコードは実行していない。 +元の本体・ワーカー・PDFium lock・最終 tar は変更していない。 + +## 元ソースと適用差分 + +- **Qt base**: `qtbase` の `v6.11.2` を参照。Qt 公式 tag の現在の解決先は + `ef55f427f2c8b410d34f8a7681020a3000cf6866`。Arch の + `qt6-base-cflags.patch` と `qt6-base-nostrip.patch` は取得し、recipe 内 SHA-256 と一致。 + さらに upstream commit `e80e3f0cebae9c3a45a1b7ce81d6454c699d89c6` を + `cherry-pick -n` する。対応する公式 patch 本文も取得した。 +- **Qt declarative**: `qtdeclarative` の `v6.11.2` を参照し、現在の解決先は + `4e3399c26ec57246c08de019cfcbda8d23604cfa`。 + `2efb7c60ef45952cc8e04b9c9a07965d14c30446` の cherry-pick patch 本文を取得した。 +- **Qt WebEngine**: `qtwebengine` の `v6.11.2` と別取得する `qtwebengine-chromium` を参照。 + Qt tag の現在の解決先は `a33fa2a897e5ee58e385b3f88dc247d99fca56db`、その + `src/3rdparty` gitlink は `5170777d28bee1ce92cc693a0dbf2ad01492e5cf`。 + この exact recipe の `_chromium` は空なので、prepare は `git submodule update` の + gitlink を使い、別 commit の checkout は行わない。packaging にある + `bump-chromium-commit` は保守用 helper で、この recipe からは実行されない。 + Chromium source 行の checksum は `SKIP`。これを検証済み source hash と扱っていない。 +- **toml++**: GitHub の公式 `v3.4.0.tar.gz` を `tomlplusplus-3.4.0.tar.gz` として参照。 + recipe の SHA-512 / BLAKE2b を記録した。当時は元 archive 未取得だった。後続の[取得・検査](../tests/results/source-archives/README.md)で両checksumと現在の51ヘッダー・LICENSEの一致を確認した。tag の Git commit 解決はこの追加作業には含まない。 + +上の Qt tag 解決は今回の公式 metadata の観測であり、当時の builder が受け取った +source bytes を再現した証明とは異なる。Git source に宣言された SHA-256 も台帳へ保持したが、 +全 checkout を取得してその checksum を計算したとは扱わない。 + +## Chromium の版表記 + +固定 gitlink の `chromium/chrome/VERSION` は `140.0.7339.225`。 +Qt の `src/core/configure/BUILD.root.gn.in` は `build/util/version.py` へ +`//chrome/VERSION` を渡し、その結果を `CHROMIUM_VERSION` macro にする。 +`QtGnGen.cmake` が指定する GN root は submodule の `chromium` ディレクトリーである。 +`web_engine_context.cpp` の `qWebEngineChromiumVersion()` はこの macro を文字列化する。 +したがって、この source から得られる版は[実ライブラリで別途観測した +`140.0.7339.225`](../tests/results/source-correspondence/qt-credits/resource-extraction.json) と整合する。 +[取得した固定 source とハッシュ](../tests/results/source-correspondence/arch/upstream-metadata/version-sources.json) + +一方、Qt repository 上位の説明ファイル `CHROMIUM_VERSION` は +base `140.0.7339.264` / security patches `151.0.7922.71` を記載する。 +この説明ファイルは上記 API の生成元ではない。 +`qWebEngineChromiumSecurityPatchVersion()` の source は `151.0.7922.71` の固定文字列を返す。 +base API、security patch API、説明 metadata の値を混ぜて単一の runtime 版としない。 +個々の security patch の完全性を、この版表記だけから保証するものでもない。 + +## 再検証と未完了範囲 + +[collect.py](../tests/results/source-correspondence/arch/collect.py) は既定で保存済み証拠を +オフライン検証する。`--fetch --output NEW_DIRECTORY` は固定 commit の小ファイルと metadata +だけを再取得する。1 ファイル 512 KiB、保存対象合計 4 MiB、packaging 64 ファイルを上限とし、 +対象は公式 Arch/Qt の HTTPS に限定する。source archive、clone、build は実施しない。 +HTML metadata には生成時刻が入り得るため、再取得時は semantic commit を照合し、 +recipe・patch・固定 source 本文は byte hash を照合する。 + +```sh +python3 tests/results/source-correspondence/arch/collect.py +python3 tests/results/source-correspondence/arch/test_collect.py +python3 tests/results/source-correspondence/arch/collect.py --fetch \ + --output tests/results/source-correspondence/arch/new-fetch +``` + +正式な G-DISTRIBUTION は引き続き未達。この作業で、4 recipe の版・commit・付属 patch・source +参照の対応は進んだが、全対応ソース、build toolchain、生成物の再ビルド再現、署名の真正性、 +全第三者告知や適用条件の判定、対象 OS のクリーン配布検証まで完了したものではない。 +DocView の公開ライセンスや製品版の同梱方針も選択していない。 + +## 公式リリースソース取得の追加結果 + +[Qt 6.11.2一式の公式archive](../tests/results/source-archives/README.md)を公開SHA-256と照合し、全390,976通常ファイルを検査した。上記WebEngineの固定source 8件が一致し、base/declarativeの4差分は専用コピーへ適用できた。CFLAGS patchは元recipeのGNU patch既定許容範囲でfuzz 1を必要とし、upstream 2差分はfuzz 0で確認した。適用後のmkspec 2件は現在のArchファイルと一致した。実ビルド、全コンパイル入力・全告知の照合、G-DISTRIBUTIONの完了は示さない。 diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md new file mode 100644 index 0000000..5b96085 --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,35 @@ +# 依存関係とビルド記録 + +2026-09-19のLinux検証で使用した構成。依存ライブラリは動的リンク、DocView内部のライブラリは静的リンクで本体・ワーカーに組み込む。 + +| 要素 | 検証版 | 固定・取得方法 | +|---|---|---| +| Qt Base | 6.11.2-3(Arch) | CMakeで6.11.2 EXACT | +| Qt Declarative | 6.11.2-2(Arch) | 同上 | +| Qt WebEngine | 6.11.2-1(Arch) | 同上。文書JavaScript・通信をアプリ側で制限 | +| PDFium | 155.0.8057.0 / chromium/8057 | [lock](../cmake/pdfium.lock.json)の取得URL・SHA-256を照合 | +| libzip | 1.11.4-1 | システムライブラリ、CMake下限1.11 | +| toml++ | 3.4.0-2 | システムライブラリ、CMake下限3.4 | +| libseccomp | 2.6.0-1 | Linuxのみ、CMake下限2.5 | +| Fontconfig | 2.18.3 | LinuxのMain側FontBrokerのみ、CMake下限2.13。OS font索引・選択に利用 | +| GCC | 16.2.1+r23+gd564253eb6c8-1 | C++20、Releaseビルド | +| CMake / Ninja | 4.4.3 / 1.13.2 | ビルド・CTest | +| Poppler | 26.08.0-1 | 試験のみ。PDFiumと独立した比較器 | +| libqpdf / qpdf | 12.4.1-1 | CMakeで12.4.1 EXACT。PDFWorker内でリンク注釈辞書・構造見出しの共有メタデータ読取り。CLIは試験資料の暗号化生成にも利用 | +| Xvfb | 21.1.24-1 | GUI自動試験の仮想X11画面 | + +PDFiumの上流コミットは`a5a7089234f121990b336b3841008009dca143bf`。配布元はbblanchon/pdfium-binariesのLinux x64成果物で、V8・XFAは無効。アーカイブSHA-256は`7788fa57a2996ebd21afc4090eb0a42b9f95ef3a72e7ef4c0756f1ea703c0415`。公開APIだけを利用するが、構造タグのAPIと`FPDF_SYSFONTINFO` version 2にはExperimental指定があるため、更新時にタグ・MCID・座標に加え、TTCのface選択とshort-buffer callbackの回帰試験を行う。 + +FontBrokerはPDFiumをMainにリンクせず、LinuxではFontconfig、WindowsではOSのGDI(`gdi32`)を専用threadで利用する。選択済みfontは上限付きのバイト列としてPDFWorkerへ転送し、Workerにはfontディレクトリーの読取り権限を与えない。static TTF/OpenType/collectionを初期対象とし、候補順と実際の選択情報を[font broker契約](font-broker-contract.md)に記録する。WindowsのGDI実装はソースと補助コンパイルまでで、native実行は未検証である。 + +Linuxの実行にはシステムのQt WebEngineプロセス、資源、ロケール、Qtプラグインも必要。Linuxでの`cmake --install`はPDFium本体とLICENSE/licensesを同梱し、Qtその他はシステムから利用する開発用配置である。クリーンOS向けの自己完結した配布物ではない。 + +libqpdfはPDFWorkerにだけリンクし、MainにはPDFパーサーを追加しない。同じ継承済みの読み取り専用ファイルから、PDFium公開APIでは取得できないリンク枠のdash配列・Border Style辞書を読む。ファイル名による再openや書込みは行わない。CMakeパッケージとtargetは[公式のライブラリー利用方法](https://qpdf.readthedocs.io/en/stable/library.html)に従う。使用したLinuxパッケージのApache-2.0ライセンス本文を[resources/licenses/qpdf](../resources/licenses/qpdf/README.txt)に複写し、installへ含める。各配布物の推移依存と告知の最終確認は残る。 + +Windows x64の同一版PDFiumアーカイブも取得し、SHA-256 `e307d519e42f2e69b1b531f0c2a32dffcdf3891ec0eba60328ba51a57cec01ed`、非V8・非XFAのビルド引数、DLLとimport library、Linux版と公開ヘッダー22件の一致を確認した。Windows上でのロード・実行は未検証。WindowsではCMakeパッケージのlibzip/toml++とqpdf 12.4.1 EXACT、libqpdfの推移依存DLL、MSVC x64、対応する公式Visual C++ Redistributableを必要とし、Qt資源は`windeployqt`を呼ぶ配置コードを用意した。[Windowsの準備手順](WINDOWS-BUILD.md) + +PDFiumのBSD系ライセンスと同梱第三者ライセンス、Qtの利用するモジュールごとのライセンス、libzip、toml++、libseccomp、Fontconfigのライセンスを配布方式に応じて収集・表示する工程が残る。DocView自身の公開ライセンスはこの作業では指定していない。 + +試験文書の本文・図形は本リポジトリーの生成スクリプトで作成した。追加日本語PDFはBIZ UDPGothic Regularをsubset埋込し、[OFL本文](../tests/fixtures/pdf/extended/FONT-OFL.txt)と元フォントSHA-256を[manifest](../tests/fixtures/pdf/extended/manifest.json)に保存している。フォント自体をアプリのランタイム依存として追加してはいない。 + +セキュリティ修正版の導入時は、Qt、PDFium、libqpdf、libzipを独立に更新せず、版・ハッシュを更新して全CTest、PDF独立比較、WebEngine隔離試験を再実施する。記載版の将来のサポート継続や無脆弱性を保証するものではない。 diff --git a/docs/DEPENDENCY-PROVENANCE.md b/docs/DEPENDENCY-PROVENANCE.md new file mode 100644 index 0000000..524d6e9 --- /dev/null +++ b/docs/DEPENDENCY-PROVENANCE.md @@ -0,0 +1,119 @@ +# 開発版パッケージの依存由来台帳 + +URL比較修正前のArch開発版について、[ui-final台帳](../tests/results/ui-final/dependency-provenance/README.md)は、焦点・Web倍率・失敗案内の修正後のArch開発版を対象にする。561ファイル、圧縮6,378,914 bytes、展開内容17,504,986 bytes。tarは2回生成してSHA-256 `83e69c6c6354f99a8666c94cbdd669a7d4e1a86ee87d956f566132a0ea6f1153`が一致し、台帳も2回生成して`be6ed689820ce414ff23831fee2266e08bbdb3e2bf617b2324c4a924b21cad35`が一致した。 + +146件の同版cache metadata、system告知530件、PDFium告知17件を照合した。追加したQtの7本文は13資源・2 DataPack・固定版/hash/元全文との対応を検証した部分集合であり、`completeChromiumNotices=false`、runtimeはsystem依存である。この保存台帳のsource欄は当時の未収集状態を保持する。後続の[ソース取得](../tests/results/source-archives/README.md)ではQt 6.11.2一式とtoml++を収集・検査し、現在の収集状況は部分完了となった。[抽出の証拠](../tests/results/source-correspondence/qt-embedded-notices/README.md)と[境界試験](../tests/results/ui-final/dependency-provenance/tests.log)を保存した。Qt資源調査の旧「credits文字列の検索結果」と、後から抽出した完全な告知コメント7件は区別する。 + +[告知補足版](../tests/results/qt-notice-supplement/README.md)では、固定ソースのWebM/WebPメタデータが指定するPatentファイル2件をUbuntu用debへ追加した。本文は同一だが元の2パスを保持する。IDのみのmini_chromium/libdrmは、上流メタデータが非同梱と記すことを保存し、実SDKのリンク構成未確認という制約を分けて記録した。全告知の完全性判定は引き続き未完了。 + +## Ubuntu専用環境の配置確認 + +[Ubuntuの別台帳](../tests/results/ui-final/ubuntu/installed-runtime-final/runtime.json)は公式Qt SDK 6.11.2、専用prefixのqpdf/libzip、Ubuntu 24.04の85 packageを対象にし、1,740ファイルの依存解決を確認した。告知104件、SDK SBOM43件、PDFium出典metadata1件を保存した。Qtの選択位置の告知本文とqpdf/libzipのinstall先告知directoryの欠落3件を明示し、後者2件は選択sourceから各告知を別途採取した。完全なChromium告知・対応sourceとはしない。Archの固定告知pinは流用していない。この台帳の採取後に、[Ubuntu用deb](UBUNTU-PACKAGE.md)を生成して配置・起動・削除を検証した。[範囲と実起動の記録](../tests/results/ui-final/ubuntu/README.md)を併読する。 + +後続の[SDK告知抽出](../tests/results/source-archives/QT-SDK-NOTICES.md)で、保存済みSBOMに含まれていたChromium告知129entry・105本文を採取し、Qt source原文・元SDK archive・Ubuntu実行時inventoryと照合した。元SBOMの外部package参照不整合1件と本文のない2部品、生成器の除外を明示しており、完全性は未判定。旧台帳の採取時点の本文件数は変更していない。 + +## 日本語UI修正時の前回台帳 + +Arch Linux 開発版 tar に何が含まれ、どの資料まで照合できたかを記録する。 +前回の[台帳](../tests/results/completion-final/dependency-provenance/first/provenance.json)は、 +日本語UI・Web操作等の修正後に生成した `completion-final` パッケージを対象にする。 +前回tarのSHA-256は`e384c8c21da11343f845a5ca7310ee6ca944e51c9585de4b2b5c7c73ca352e0e`。 +553ファイル、圧縮後6,367,967 bytes、展開内容17,460,237 bytesを全照合した。 +2回の台帳生成はbyte一致し、台帳SHA-256は +`9d43b6ae79bb8e7d4ced790ef25f399b4c616ad0d02cf286f59ae93d29d3220b`。 +146件のcache metadata、PDFium告知17件とsystem告知523件を照合し、限定23試験が成功した。 +Qtの日本語QMはsystem依存の `qt6-translations 6.11.2-1` に対応する。 +DocView自身の日本語catalogは本体に含むが、QtのQMをtarへコピーしたとは扱わない。 +限定試験・再生成の結果は[前回結果 README](../tests/results/completion-final/dependency-provenance/README.md)、 +従来版は[履歴 README](../tests/results/dependency-provenance/README.md)、 +固定recipe・告知原文と実ファイルの照合は[別の記録](../tests/results/source-correspondence/README.md)にある。 + +## 追加告知を収録した従来版 + +[従来の台帳](../tests/results/dependency-provenance/notices-final/provenance.json)は、 +固定ソースとの照合で見つかったPDFiumの2告知を補った時点のパッケージを対象とする。 +そのtarのSHA-256は`df9c0cc5396d7d952804a4611c85e5e2130fad821311e76f880a17e78be49c06`。 +553ファイル、圧縮後6,342,588 bytes、展開内容17,418,141 bytesを照合した。 +追加はlibc++/libc++abi告知と出典metadataの3ファイル。変更は説明文・告知一覧・manifestの5ファイルで、 +当時の本体・2ワーカー・PDFiumの全バイナリーは、下記550ファイル版と同一だった。 +今回の現行バイナリーとの同一性を主張する記録ではない。 +追加告知はソースrevision・版・実ライブラリー・本文hashを結び付けて検証する。 +2回の独立パッケージ生成はbyte一致し、別展開先から6条件のGUI起動が成功した。 + +## 元のローカル採取で確認した範囲 + +対象は `docview-0.1.0-arch-x86_64-development.tar.gz`、SHA-256 は +`fe1cbeb7af6bf53c82dbee3a571b369454115b5c0f1561674cdb09e26c089c4d`。 +圧縮後 6,338,459 bytes、展開ファイル合計 17,369,299 bytes、550 ファイルを +tar 内の payload manifest と照合した。manifest 自身は自己ハッシュから除外されるが、 +そのハッシュも台帳へ保存する。元の dependency manifest のハッシュも記録する。 + +| 対象 | 実際の含有・参照 | 追加で採取した証拠 | +|---|---|---| +| DocView と 2 ワーカー | tar 内の実行ファイル 3 本 | 全ファイル SHA-256 と `readelf` の直接 `DT_NEEDED` | +| 独立 PDFium 155.0.8057.0 | `lib/libpdfium.so` を同梱 | ローカルのライブラリ・15 告知文が tar と一致。`VERSION`、全 10 項目の `args.gn`、固定 lock のハッシュと値を照合 | +| Qt 6.11.2 | Qt/WebEngine の runtime 本体はシステムから動的に参照 | tar 内の 3 実行ファイルから Qt の直接リンクを確認。元の依存 inventory の Qt 共有ライブラリ 42 行を保持 | +| toml++ 3.4.0-2 | 共有ライブラリはシステム側。ヘッダー由来コードは DocView 本体に含む | 本体に定義された `toml::` シンボル 27 件、`toml++/toml.h` の include、同梱 MIT 告知文のハッシュ | +| システム依存候補 146 パッケージ | 元 inventory が記録した共有ライブラリ・Qt 資源など。runtime 本体をこの tar へコピーしていない | 全 146 件で同版・同 architecture・同 package base の cache `.PKGINFO` / `.BUILDINFO` を照合。各 raw metadata と `PKGBUILD` の SHA-256 | + +システム共有ライブラリの非同梱は、ヘッダー・テンプレート由来コードが実行ファイルに +含まれないという意味ではない。toml++ は両方を実物で確認した。Qt を含むその他全依存の +ヘッダー由来コードの列挙までは実施していない。また、146 件の一覧は元 inventory の +依存候補集合であり、各資料を開くたびに全件を実際にロードしたとの主張ではない。 + +PDFium の先頭 `LICENSE` は binary provider の MIT 文である。 +PDFium upstream の BSD 条件は `licenses/pdfium.txt` にあり、残りのライセンス文も別々に +対応させた。上位の `LICENSE` だけを PDFium 全体の条件として扱っていない。 +lock 記載の provider archive SHA-256 は既存の固定値として記録し、今回その取得 archive を +再ダウンロード・再検証したとは扱わない。 + +## キャッシュ証拠の意味 + +Qt base/declarative/webengine と toml++ の 4 archive は全体の SHA-256 も記録した。 +他の 142 archive は展開を進めず、最大 8 MiB の先頭領域にある metadata のみ採取する。 +raw metadata はハッシュだけを保存し、本文からは package 名・版・architecture・license label・ +build date・build tool・`PKGBUILD` hash を選択する。氏名、メール、build directory、 +builder の全 installed-package 一覧は保存しない。利用者のホーム内データも探索しない。 + +このcollector単独では、recipe本文やcacheのruntime payloadを検査しない。 +後続の[照合](../tests/results/source-correspondence/README.md)では主要4パッケージの +実行時ファイル1,581件と現在のtoml++ヘッダー51件をcache payloadと照合し、全て一致した。 +4つの`PKGBUILD`本文もBUILDINFOのhashに一致する公式commitへ対応させ、patchを取得した。 +残り142パッケージのpayload比較、cacheの署名認証、過去のコンパイル時点のヘッダー証明、 +システム全体のソース回収を行ったという意味ではない。 + +## 再実行 + +Python 3.14 以上の zstd 対応 `tarfile`、GNU `nm` / `readelf`、既存の最終 tar、 +`.deps/pdfium`、ローカル Arch package cache を使う。既存結果は上書きしないため、 +`--output` は新しいディレクトリーにする。 + +```sh +python3 tools/record_dependency_provenance.py \ + --archive tests/results/ui-final/linux-development-package/package/docview-0.1.0-arch-x86_64-development.tar.gz \ + --output tests/results/ui-final/dependency-provenance/new-run +python3 tests/test_dependency_provenance_qt.py -v +``` + +別配置の入力は `--archive`、`--pdfium-root`、`--package-cache` で指定できる。 +同じ tar・ローカル PDFium・cache metadata・調査ツール・source include から同じ JSON を生成する。 +時刻や作業ディレクトリーを出力に含めない。cache がない項目は明示的に未採取と記録し、 +版不一致や壊れた metadata は成功へ変換しない。本体やワーカー、`ldd` は実行しない。 + +## 原設計に対して残る作業 + +[設計 09 §4 G-DISTRIBUTION と §5](design/09-decisions-roadmap.md) は、 +実配布物と対応ソース・告知・依存一覧の対応、および対象 OS での配布確認を求める。 +この台帳だけで合格とはしない。`sourceCollectionStatus: not-collected` は資料収集状況であり、 +全 146 システム依存のソースを一律に再配布する法的義務が確定したという判定でもない。 + +1. **対応ソースと告知の整理**: 主要4つのArch recipe/patch、PDFium providerの固定recipe・ + 依存pin・既存15告知の原文対応は確認した。見つかったlibc++/libc++abiの告知不足は現行tarへ + 反映した。Qt一式とtoml++のsource取得・固定hash照合、Archの4差分の適用確認は[追加記録](../tests/results/source-archives/README.md)で完了した。[PDFiumのGitソース収集](../tests/results/source-archives/PDFIUM.md)では本体と27依存・追加gitlinkを保管し、公開ヘッダー24件と告知15件の一致、Linux/Windows用パッチの適用を確認した。全linked component・compiler runtime/sysroot・完全な対応ソース、 + Qt WebEngineの実GN構成に対応する全Chromium告知は未完了。 + [追加の資源調査](../tests/results/source-correspondence/qt-embedded-notices/README.md)で7件の完全な告知本文を収集したが、全Chromium告知は取得できておらず、 + 当該buildの生成物または同じ構成による生成が必要になる。これらを対象OS試験の不在だけで説明しない。 +2. **公開方針の決定を伴う整理**: DocView 自身の公開ライセンス、採用する Qt の条件、 + 製品で実際に同梱する範囲・形式を決め、その具体的な配布に適用する告知・ソース提供等を + 確認する。この台帳は条件の選択や法的適合の断定を行わない。 +3. **追加環境が必要な確認**: Windows 11 x64 の署名済み配布・削除、対象OSの物理GPU・表示・入力の受入は残る。Ubuntu 24.04については[新しいOSから作成した専用VM](../tests/results/ubuntu-package/clean-vm/README.md)で依存解決・sandbox・X11/Waylandの起動・削除を検証し、[URL修正版](../tests/results/portal/README.md)では更新と実ファイル選択も確認した。これらをWindowsや物理環境の代わりとはしない。 diff --git a/docs/IMPLEMENTATION-DECISIONS.md b/docs/IMPLEMENTATION-DECISIONS.md new file mode 100644 index 0000000..b0135e0 --- /dev/null +++ b/docs/IMPLEMENTATION-DECISIONS.md @@ -0,0 +1,32 @@ +# 実装時の判断記録 + +設計書は[docs/design](design/00-guide.md)へそのまま複写し、[SHA-256](design/sha256.json)を保存した。本書は実装時の追加判断であり、原設計の受入条件を書き換えない。 + +| 判断 | 実装と理由 | 検証・影響 | +|---|---|---| +| フォントを選択したバイト列だけで供給 | MainのFontconfig/GDIでOS索引から選択し、64 KiBずつ既存IPCでWorkerへ渡す。SFNT/TTC解析とPDFiumはWorker内 | Linuxのフォントディレクトリー許可を撤去。日本語CID横/縦と転送・取消・上限を試験。Windows実行は未確認。[契約](font-broker-contract.md) | +| Linuxから実装 | Arch Linux x64に加え、専用KVMのUbuntu 24.04でもQt 6.11.2と固定PDFiumをビルド・検証 | UbuntuはXvfb/Swayのsoftware描画、ArchはAMD OpenGLも確認。Windows native、対象OSの物理表示・IME、製品配布の受入は未完了 | +| PDFタイルのオフスクリーン余白16px | 設計案の2pxでは、タイル境界を横切る文字のアンチエイリアスに差が出た。16px描画して要求矩形だけを返す | タイルと全ページの切り出しが試験資料でbyte一致。転送タイルは最大514px、通常512pxのまま | +| アーカイブワーカーを読み取り専用にする | ZIP解析・CRC確認・フォント難読化解除はワーカー、最大64KiBずつの展開結果の保存はメイン側 | パーサー侵害で一時領域を無制限に書き込む経路を除去。メイン側で資源256MiB・文書2GiBを確認し、完了後だけ公開 | +| 索引を分割する | PDF目次最大20,000件、初回64件/128KiB、継続最大256件/512KiB。ページ情報は最大256件/256KiB。アーカイブ目次等も件数と実バイト数で分割 | IPC制御フレーム1MiBを保ち、大きなラベルで一括応答が溢れる問題を防止。メイン側でも累積32MiBの索引上限 | +| 先読みの予算と優先度 | 可視タイルと通常操作が完了した後、設定された隣接ページの可視域相当の帯を1件ずつ読む。前ページは末尾、後ページは先頭を対象にする | 0〜3ページ、隣接1ページ最大8タイル。余白がなければ先読みせず、先読みで既存キャッシュを追い出さない | +| メモリ圧迫の段階的対応 | OSの利用可能物理メモリを1秒ごとに読み、先読み停止→不可視タイル回収→描画解像度抑制と通知→文書処理停止の順で進む。LinuxはMemAvailable、WindowsはGlobalMemoryStatusEx | 閾値・ヒステリシス・位置保持・回復と停止後の再openは [契約](memory-pressure.md)。設定のキャッシュ上限・隔離の資源上限は維持する。Windows側の実行は未確認 | +| 実際に提示した位置を保存 | PDFは可視タイル完成後のframeSwapped、Webは提示時の論理位置を状態へ渡す | 未表示のジャンプ先を終了時に保存しない。2秒debounce/10秒最大間隔はStateStoreへ集約 | +| 保存先の原本をセッション開始時と照合 | 全形式のopen時に取得した正規パス・サイズ・更新時刻・物理ファイルIDを保存時にも照合する | 閲覧中の変更・置換で古い位置を新原本へ転用しない。通常openでも履歴との同一性不一致を通知。[実ファイル置換の回帰](../tests/results/completion-regressions/controller-state.md) | +| 目次の準備中を欠如と区別 | 能力がloadingの間は空の索引でも表示要求を保持し、パネルに読み込み中と示す | 本文のReadyを待たせず、DOM索引到着後に同じパネルへ項目を表示する | +| 見出しの代替と章境界を限定 | PDFのoutline代替は内部宛先だけを採用。EPUBの最初/最後の見出しへの指示は成立した章移動にだけ結び付ける | 外部だけの目次を見出しありと判定しない。失敗した章境界操作の指示が後の通常移動へ残らない | +| 日本語を初期表示し翻訳資源を分離 | tr/qsTr/固定contextからTSを生成し、QMを本体へ埋め込む。Qt標準部品の日本語資源も読み込む | 英語localeでの設定・コマンドの案内とQt Quick Dialogを検査。OSネイティブdialogはOS言語に従う。[更新・配置条件](TRANSLATIONS.md) | +| Webの内部URLを履歴から除外 | Mainで位置の型・上限・originを検証し、resourcePath/CFI/進捗とEPUB package/spineで復元する。旧state/backupのlocation.urlも書込み可能時に除去 | 旧href形式の復元を維持。読み取り専用・forceReadOnlyではファイルを変えず、本文引用中のURLは削除しない | +| ZIP入口は読書位置と別に保存 | 選んだ相対HTMLを同じ原本identityと現在の候補で再確認し、表示成功後だけ記憶する | recent-only/position-only、別GUIプロセス再起動、取消・消去・原本置換を試験。[記録](../tests/results/zip-entry-choice/README.md) | +| 固定EPUBの見開きは最大2ビュー | 同じ文書profileのprimary/companionへ左右を配置し、author viewportと共通倍率を維持。renderer監視は2枠に限定 | LTR/RTL、両向き、単独中央、混在、取消を確認。章loadの同期通知は現在章のみ更新し、読書位置保存は提示後のまま | +| 資源拒否を分類・件数だけ集約 | broker、interceptor、ブラウザーCSPの検出を文書内でまとめ、`:info`へ表示する | URL・本文を記録しない。合成イベント・古いセッションを拒否。[契約](resource-warning-contract.md) | +| PDFリンク枠の辞書をWorker内で補完 | PDFium公開APIで不足するBorder/BSをlibqpdfで読み、保存外観がないリンクにだけ数値から一時外観を生成する | 原本は書かず、既存外観を維持。通常枠の欠落を解消。NoZoom/NoRotateは描画中だけRect/回転を補正し、原本座標と保存APを保持する。[比較](../tests/results/link-borders/README.md) | +| PDF注釈へキーボードで到達 | 既存のリンク順の後に本文付き注釈を加え、Tab/Shift+Tabで選びEnterで読み取り専用の本文を表示する | CropBox外の注釈も選択可能。通常モードのEscで選択解除し、Web文書へ切り替えた後はPDFの選択と画像を回収する | +| 生成資料を使用 | 利用者から既存コーパスなしとの回答。本文・図形・座標・色の期待値を持つ資料を生成 | PDFiumとは独立したPopplerで比較。実書籍全般・全描画機能の互換性を保証する試験には置き換えない | +| OSの保護が利用できないときは停止 | LinuxはLandlock ABI3以上・seccomp・資源上限。WindowsはLPAC・Job・明示ハンドル継承・子側の実token検証を本番経路へ接続 | Windows固有部分は未ビルド・未実行。保護なしで起動するフォールバックを設けない | +| Windowsワーカーの依存物を個別コピー | ビルド時に有限のDLL一覧とハッシュを生成し、起動時に検証して専用領域へコピーする | インストール先や原本のACLを変更しない。実機でのPE解析・初期表示速度・配布確認は残る | +| Windowsのprofileを読み取り専用にする | AppContainer作成時にできる固有filesystem/registryの書込権限も制限し、子側で検証する | 任意Tempと自身のprofileへの書込拒否を試すnative試験を追加。Windowsでの保護成立は未確認 | + +PDF見出しはWorker内の共有qpdf contextでページ/Formの所有元、ParentTree、RoleMapと構造順を読み、文字・座標は公開PDFium APIから取得する。FormだけのStructParentsにも対応し、入れ子Form・名前付きproperty・複数ページの子要素順を試験した。複数呼出や一意に対応できないFormは理由付きページ精度へ下げ、別のstreamの同じMCIDから文字や座標を借用しない。跨ページ見出しはページ別の断片として扱い、フォントサイズから見出しを推測しない。[補完アダプター](pdf-structure-adapter-plan.md)。 + +本文の検索・保存位置・最近使った文書のローカル記録を実装した。これら補助機能の合格範囲は検証記録のT-P系列で区別する。OCR、PDF入力・編集・保存、外部通信、文書スクリプト実行を追加しない。 diff --git a/docs/LINUX-DEVELOPMENT-PACKAGE.md b/docs/LINUX-DEVELOPMENT-PACKAGE.md new file mode 100644 index 0000000..06163c8 --- /dev/null +++ b/docs/LINUX-DEVELOPMENT-PACKAGE.md @@ -0,0 +1,122 @@ +# Linux開発版のローカルパッケージ + +この手順は、現在の **Arch Linux x86_64・Qt 6.11.2** 環境向けの開発版を +tar.gzにまとめる。公開・アップロード・署名は行わない。Ubuntu 24.04、Windows、 +任意Linuxでの互換性やクリーンインストールの合格を意味しない。 + +## 形式と対象範囲 + +| 候補 | この段階での判断 | +|---|---| +| tar.gz + システム依存 | ローカル開発版に採用。展開先を移して検証でき、元に戻す操作も明確。配布先のパッケージ管理やABIを変更しない | +| Ubuntu向けdeb | 初期対象の製品版候補。Ubuntu上で依存解決・インストール/削除の検証が必要なため未採用 | +| AppImage等のランタイム同梱方式 | Qt/WebEngine/GPU・sandbox・ライセンス集合を含む別検証が必要。今回の開発版には採用しない | + +製品版の初期対象ごとの形式決定は未完了。Windowsの既存配置コードをこの判断で変更しない。 + +同梱するランタイムは本体、PDF/アーカイブワーカー、固定版の独立PDFium共有ライブラリ。 +Qt、WebEngine helper/資源/翻訳/QML/plugin、libqpdf、libzip、libseccomp、 +Fontconfig等はシステムから読み込む。Qt共有ライブラリとpluginの配置は別の依存条件である。 +[QtのLinux配布資料](https://doc.qt.io/qt-6/linux-deployment.html) + +toml++はシステムパッケージのヘッダーから実行ファイルへ組み込むため、 +実行時だけのシステム依存とは区別する。MIT本文は同梱済みである。 +実際の含有範囲とローカルビルド由来は[依存物の証跡台帳](DEPENDENCY-PROVENANCE.md)を参照する。 + +`third-party/dependency-manifest.json`に実際のArchパッケージ版、共有ライブラリや +Qt資源のハッシュ、実行ファイルが要求するGLIBC/GLIBCXX/CXXABIのsymbol versionを記録する。 +Archのrolling-release ABIに依存し、単に同じCPUやQtのmajor版であれば動くという主張はしない。 +X11/Wayland、IME、GPU driver、フォント、Landlock ABI 3以上とseccompが別途必要。 +隔離できない環境ではワーカーを起動せず、sandboxを無効にして実行しない。 + +## 生成 + +Python 3.11以上、CMake、Archのpacmanデータベース、`ldd`、`readelf`、 +`/usr/lib/qt6/bin/qtpaths`とビルド済み実行ファイルを必要とする。 +自分でビルドした信頼できる実行ファイルだけを入力にする。 +初回は[READMEのビルド手順](../README.md#ビルドと起動)に従い、依存物と固定版PDFiumを準備する。 + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build +python3 tools/package_linux_development.py --build-dir build --output build/package-local +``` + +Pythonが見つかったLinuxビルドには`linux-development-package` targetも登録する。 +その既定出力先は`build/linux-development-package`で、必要ならCMakeの +`DOCVIEW_LINUX_PACKAGE_OUTPUT`で変更する。`BUILD_TESTING`時は軽量の`linux_package`試験も登録する。 + +生成スクリプトは一時領域に`cmake --install`し、実依存を調査して告知を収集する。 +既に存在する出力アーカイブは上書きしない。別の出力ディレクトリーを指定して再生成する。 +既存の新規install treeを使う場合は`--build-dir`の代わりに`--prefix`を指定できる。 +利用者の設定・履歴・文書や、system library/fontの実体はパッケージに含めない。 +LinuxのinstallにはPDFium providerの15告知に加えて、固定ソースから補った +libc++/libc++abiの2告知と出典metadataを含める。版・commit・ライブラリー・告知の +ハッシュをconfigure時とpackage生成時に照合する。PDFiumを更新した場合は再調査が必要。 +追加の根拠は[PDFiumのソース対応記録](PDFIUM-SOURCE-CORRESPONDENCE.md)を参照する。 +Linux packagerはさらに、QtWebEngineの実DataPack内13資源から得た7種類の完全な告知本文と +出典metadataを収録する。固定Qt版・Arch package版・DataPackのpath/hash・本文のsize/hashを +現在のsystem inventoryへ照合し、不明な版・variantや欠落・改変は新たなレビューまで拒否する。 +本文とmetadataだけをコピーし、QtのruntimeとDataPackはsystem依存のままとする。 +これは全Chromium告知の完成ではない。根拠と比較範囲は +[資源内告知の抽出記録](../tests/results/source-correspondence/qt-embedded-notices/README.md)にある。 + +- `docview-0.1.0-arch-x86_64-development.tar.gz`: ローカル開発版。 +- 同名`.json`: アーカイブSHA-256、圧縮後サイズ、展開後ファイルサイズ合計、 + ファイル数、本体/workerハッシュ、含めていないsystem runtime候補の合計サイズ。 +- アーカイブ内`share/doc/docview/package-manifest.json`: 全payloadの相対パス・mode・ + サイズ・SHA-256。自己ハッシュは含めない。 +- 同`third-party/`: 依存版/出典/対応ソースの収集状況・NOTICE・収集したlicense本文。 + +順序、uid/gid、所有者名、mode、mtimeとgzip headerを固定する。 +`SOURCE_DATE_EPOCH`は未指定時0。必要なら`--epoch`で指定できる。 +同じinstall内容・ホスト依存/告知・スクリプト・Python/zlib・epochから同じバイト列を得る。 +コンパイラーや実行ファイル自体の再現可能ビルドを証明する手順ではない。 +生成時に展開して全hashを確認し、その展開物から再圧縮したhashが一致することも要求する。 + +## 展開、起動、削除 + +```sh +mkdir -p /tmp/docview-local-check +tar -xzf build/package-local/docview-0.1.0-arch-x86_64-development.tar.gz -C /tmp/docview-local-check +/tmp/docview-local-check/docview-0.1.0-arch-x86_64-development/bin/docview /absolute/path/to/book.pdf +``` + +PDFiumの検索先はworkerからの相対RPATHを使う。`LD_LIBRARY_PATH`の追加は不要。 +実行すると通常は利用者のXDG設定・履歴を使う。試験は次の専用スクリプトで行い、 +一時XDG領域へ隔離する。 +試験結果を区別するため、`--output`には毎回新しいディレクトリーを指定する。 + +```sh +python3 tests/test_linux_package.py -v +xvfb-run -a -s '-screen 0 1100x760x24' \ + env QT_QPA_PLATFORM=xcb QT_QUICK_BACKEND=software QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \ + python3 tests/smoke_linux_package.py \ + --archive build/package-local/docview-0.1.0-arch-x86_64-development.tar.gz \ + --output tests/results/linux-development-package/new-run +``` + +後者は全payloadを検証して空の一時ディレクトリーへ展開し、6条件の本番GUI smokeを実行する。 +元のinstall pathと異なる位置からPDF/HTML/ZIP/EPUBを開く。Chromiumとworkerのsandboxは有効。 +成功時も失敗時も一時展開先を回収する。同一開発機での検証なのでclean-OS試験ではない。 +同じ出力先を再利用した場合も、開始時に前回の`results.json`と`package-smoke.json`を除去する。 +途中失敗では成功集計を残さず、古い画像だけでは当該実行の成功を示さない。 + +この配置はpackage managerやsystem directoryに登録しない。不要になった展開ディレクトリーを +削除すれば実行ファイルの配置を取り除ける。通常起動で保存した設定・履歴は別に残る。 +原本文書を削除対象に含めない。 + +## 告知と未完了事項 + +DocView自身の公開ライセンスは未指定。第三者のlicense本文やpackage labelsを収録しても、 +DocViewのライセンスを選択したことにはならない。主要4つのArch recipeとpatch、PDFium +providerと固定依存ソースの対応は[別の調査記録](../tests/results/source-correspondence/README.md)に +保存した。各依存の対応ソース一式・完全なbuild materialはパッケージに含めず、Qt WebEngineの +build固有の全Chromium告知も未取得。今回の7本文は有限の部分告知集として記録し、 +`completeChromiumNotices = false`を維持する。manifestの`source.status = not-collected`は +パッケージ側の完全なソース収集が未完了であることを表す。ソースURLだけを履行済みの証拠にしない。 + +今回計測する圧縮サイズはWebEngine込みの自己完結した配布サイズではない。 +system runtime候補の合計は必要ディスク量の参考であり、共有・既存install・任意追加providerを +含むため、追加インストール容量や製品配布サイズと同一視しない。 +G-DISTRIBUTION/T-S05の正式合格には、対象OSの実配布・依存/告知/対応ソース整理が引き続き必要。 diff --git a/docs/PDFIUM-CANDIDATE.md b/docs/PDFIUM-CANDIDATE.md new file mode 100644 index 0000000..8fc414a --- /dev/null +++ b/docs/PDFIUM-CANDIDATE.md @@ -0,0 +1,54 @@ +# 修正版PDFiumの配置とビルド + +現在の候補は `v2` です。ICC変換指定、パターンの初期状態、未着色タイルの線色を修正しています。[検証記録](../tests/results/pdfium-intent-context/README.md)に、元ソース・パッチ・バイナリーと試験結果の対応を保存しています。 + +## この作業環境での起動 + +配置済みの `.deps/pdfium-context` を選択した `build-context` を使用します。 + +```sh +./build-context/docview "/path/to/book.pdf" +``` + +新しいprefixを作る場合は、まだ存在しないパスを指定します。既存prefixを上書きすることはできません。 + +```sh +python3 tools/stage_pdfium_candidate.py --candidate v2 --verify-only +python3 tools/stage_pdfium_candidate.py --candidate v2 --output "$PWD/pdfium-context-prefix" +cmake -S . -B build-context-new -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DDOCVIEW_PDFIUM_ROOT="$PWD/pdfium-context-prefix" +cmake --build build-context-new -j 6 +``` + +この配置ツールは、検証済みソース・ツール・結果が保存された本作業環境を入力にします。ネットワーク取得やPDFiumの再ビルドは行いません。CMakeには新しいビルドディレクトリーを使い、別版のライブラリーが残るキャッシュを避けます。 + +## 固定値と照合 + +| 項目 | 値 | +|---|---| +| upstream revision | `a5a7089234f121990b336b3841008009dca143bf` | +| 候補library SHA-256 | `cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403` | +| 固定ビルド記録 | `tests/results/pdfium-intent-context/record.json` | +| 記録SHA-256 | `80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b` | +| 配置検証の固定値 | `cmake/pdfium-candidate-v2.lock.json` | + +配置時は固定記録、パッチ、変更ソース60件、ツールアーカイブ、実際のGN引数、公開ヘッダー24件、告知17件を照合します。ヘッダーはproviderパッチを逆適用して元Git inventoryとの一致も確認します。告知は元ソースとの対応記録に結び付けます。 + +`include/`、`lib/`、告知、`VERSION`、`args.gn`、`provenance/`、`BUILDINFO.json`を配置します。入力のリンクや特殊ファイルを拒否し、検証したバイト列から一時prefixを作成して、既存出力を置換しないrenameで公開します。 + +CMakeは候補libraryとヘッダーの選択先を確認し、BUILDINFO・原記録・パッチ・告知を固定lockに照合します。配置先へコピーする前に再検証します。配置後とUbuntu deb検証でも同じ対応を確認します。配置済みの告知・由来記録は `share/doc/docview/pdfium/` に含まれます。 + +## 回帰試験 + +LinuxのCTestには配置ツールと配布対応の試験を含めています。個別にも実行できます。 + +```sh +python3 -m unittest discover -s tests -p test_stage_pdfium_candidate.py -v +python3 -m unittest discover -s tests -p test_pdfium_candidate_integration.py -v +``` + +合成入力の改変、ヘッダーと原本の同時改変、既存出力・リンク・特殊ファイル、書込み途中の失敗、公開時の競合、配置前の改変、自己整合だけを装ったdebを検査します。実debの境界試験には `dpkg-deb` が必要です。 + +第1候補は `--candidate v1` で履歴再現用に保持しています。パターン状態の問題があり、現在のCMake配置先としては受理しません。CLIの既定値は以前の再現手順を保つv1なので、現在の候補には `--candidate v2` を指定してください。 + +この成果物はLinux x64の検証用です。Windows、物理表示・基準機、人による正解画像承認、公開配布の最終判断は別の未完了条件です。 diff --git a/docs/PDFIUM-SOURCE-CORRESPONDENCE.md b/docs/PDFIUM-SOURCE-CORRESPONDENCE.md new file mode 100644 index 0000000..1111d12 --- /dev/null +++ b/docs/PDFIUM-SOURCE-CORRESPONDENCE.md @@ -0,0 +1,119 @@ +# PDFium配布物と固定ソースの対応 + +2026-09-19、DocViewが固定するPDFium **155.0.8057.0**について、配布元・ビルド手順・依存pin・同梱告知の対応を調査した。これは技術的な出所確認であり、法的適合性の認定、全対応ソースの収集完了、再現ビルドの成功を意味しない。製品ソース、PDFium lock、配布アーカイブは変更していない。 + +**後続の収集状況:** [固定Gitソースの追加記録](../tests/results/source-archives/PDFIUM.md)で、本体と27依存、FreeType内の追加参照dlgを取得した。Git objectとアーカイブ全payloadを検査し、先行取得原文31件、パッチ後の全公開ヘッダー24件、同梱告知15件の一致を確認した。Linux3件・Windows4件のパッチを専用コピーへ適用済み。以下の「未取得・未適用」は先行調査時点の範囲を記録したもので、現在の取得状況は追加記録を参照する。コンパイラー/sysroot等の全入力と再現ビルドは引き続き未完了。 + +機械可読の結果は[report.json](../tests/results/source-correspondence/pdfium/report.json)、取得URL・時刻・元応答と保存内容のSHA-256は各`*.retrieval.json`、全保存物の一覧は[manifest.json](../tests/results/source-correspondence/pdfium/manifest.json)にある。保存物は小規模なメタデータ・レシピ・patch・告知原文であり、巨大ソースarchive、依存checkout、ビルドは取得・実行していない。 + +## 配布物からレシピへの対応 + +| 対象 | 確認した固定値 | +|---|---| +| PDFium upstream commit | `a5a7089234f121990b336b3841008009dca143bf` | +| provider tag | `bblanchon/pdfium-binaries`の`chromium/8057` | +| provider recipe commit | `f2e9a1c45bb17b85b540abf1af30146ef65416ac` | +| GitHub release ID | `388577209`、公開APIの`immutable=true` | +| build workflow run | `34853011437`、attempt 1、`workflow_dispatch`、`success` | +| Linux x64 archive SHA-256 | `7788fa57a2996ebd21afc4090eb0a42b9f95ef3a72e7ef4c0756f1ea703c0415` | +| Windows x64 archive SHA-256 | `e307d519e42f2e69b1b531f0c2a32dffcdf3891ec0eba60328ba51a57cec01ed` | + +上のarchive digestは、既存[lock](../cmake/pdfium.lock.json)、[GitHub release API](https://api.github.com/repos/bblanchon/pdfium-binaries/releases/tags/chromium/8057)、releaseに同梱されたattestationのsubjectで一致した。今回archive本体は再取得していない。 + +[タグAPI](https://api.github.com/repos/bblanchon/pdfium-binaries/git/ref/tags/chromium/8057)、releaseの`target_commitish`、[workflow run](https://github.com/bblanchon/pdfium-binaries/actions/runs/34853011437)、attestation payloadの`resolvedDependencies`は、同じprovider commitを示す。Linux x64 / Build(job `104005424482`)とWindows x64 / Build(job `104005424089`)の成功も公開job metadataで確認した。 + +[attestation原文](../tests/results/source-correspondence/pdfium/provider/attestation.json)のSHA-256は`3928dc9e52ebda1b200d9cb2dcbd971aee57b08190a49ea3e6cd7c31ee445719`で、release assetのdigestと一致する。DSSE payloadは[読取り用JSON](../tests/results/source-correspondence/pdfium/provider/attestation-payload.json)へbase64展開した。**Sigstore署名・証明書・透明性ログの信頼検証は未実施**であり、payloadを読めたことを署名検証済みとは扱わない。payloadが固定するソースはprovider repositoryであり、PDFium本体のcheckout SHAや全依存は列挙していない。 + +PDFiumの[固定commit](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf)と調査時点の`refs/heads/chromium/8057`は一致した。ただしproviderは`gclient sync -r origin/chromium/8057`で取得するため、現在のbranch参照とversionだけで当時の全checkout状態を暗号学的に証明したことにはならない。 + +## レシピと適用patch + +固定commitの[レシピ一式](https://github.com/bblanchon/pdfium-binaries/tree/f2e9a1c45bb17b85b540abf1af30146ef65416ac)から、6 workflow、`build.sh`、11段階の`steps`、全`patches`、README、LICENSEの計44ファイルを保存した。全ファイルのGit blob SHA-1を固定commitのtreeと照合し、個別SHA-256も記録している。別OS用・static版用を含むpatch集合全体を保持し、対象に適用する集合とは区別した。 + +| 対象 | `steps/03-patch.sh`の分岐が選ぶpatch | +|---|---| +| Linux x64、shared、V8無効 | `shared_library.patch`、`public_headers.patch`、`clang_rt.patch` | +| Windows x64、shared、V8無効 | 上記3件+`win/build.patch`。別途`win/resources.rc`をversion/yearで展開 | + +`shared_library.patch`はPDFium targetを共有library化し、Windows resourcesを追加する。`public_headers.patch`はexport条件と公開C++ headerの相対includeを調整する。`clang_rt.patch`はChromium build側の未対応platform分岐を調整し、`win/build.patch`はresource compilerの呼出しを変更する。これらは公開元のpatchをそのまま保存したもので、今回新規に適用してビルドしたものではない。 + +[checkout手順](../tests/results/source-correspondence/pdfium/provider/recipe/steps/02-checkout.sh)はV8無効時に`checkout_configuration=minimal`を使う。[configure手順](../tests/results/source-correspondence/pdfium/provider/recipe/steps/05-configure.sh)と既存Linux配布物の[args.gn](../tests/results/source-correspondence/pdfium/packaged/args.gn)は、Release、standalone、shared、x64、V8/XFA/partition allocator無効と整合する。配布物の`args.gn`には指定した値のみがあり、全GN既定値の展開結果ではない。 + +## 依存ソースのpin + +[固定PDFium DEPS](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/DEPS)はSHA-256 `55e7f4ecba645a748d99b91df6ff8ed9133faa08a07d39a6c0324a717926b44e`。Pythonとして実行せず、定数・文字列連結・`Var`参照だけを読取り、56依存のURL/revision、条件、CIPD version、GCS object hash/generationを[dependency-pins.json](../tests/results/source-correspondence/pdfium/dependency-pins.json)へ保存した。 + +明示された`recursedeps`は3件で、いずれもその固定revisionのDEPSを取得した。そこからさらに`recursedeps`は宣言されていない。 + +| recursive dependency | 固定revision | 宣言された追加依存 | +|---|---|---:| +| Chromium build | `2cd94c0abeada712aeea6906d99021913c9fc28d` | 9 sysroot | +| Chromium buildtools | `6f6a5dbf04b734214f3b1f386567d101ec9d607e` | 4 formatter package | +| instrumented_libraries | `d15c278eed5d38d9acf2d8054cf37baba93cef8e` | 1条件付きbinary集合 | + +これはgclientが宣言する取得グラフであり、全てが今回のlibraryへリンクされるという意味ではない。条件は評価せず原文を保持する。固定`build_overrides/pdfium.gni`ではBrotli、Skia、Fontations、Rust BMP/JPEG/PNGが無効。V8無効のminimal checkout、partition allocator無効というrecipeの設定も別に記録した。CIPDのversion文字列からinstance IDへの解決、toolchain/sysroot archiveの取得、実際の最終`build.ninja`との照合は未実施。 + +## 同梱15告知とソース原文 + +対象は既存Linux配布物のトップレベル`LICENSE`と`licenses/`の14ファイル。providerの[収集手順](../tests/results/source-correspondence/pdfium/provider/recipe/steps/08-licenses.sh)とstage手順の指定を、データ変換として再現した。**15件全てが同梱ファイルとbyte単位で一致**した。各原文URL・固定revision・原文hash・変換後hash・同梱hashは[license-correspondence.json](../tests/results/source-correspondence/pdfium/license-correspondence.json)にある。 + +| 同梱ファイル | ソース所在/固定revision | 対応方法 | +|---|---|---| +| `LICENSE` | provider `f2e9a1c45bb17b85b540abf1af30146ef65416ac` | LICENSEへ第三者告知の案内を追記 | +| `pdfium.txt` | PDFium `a5a7089234f121990b336b3841008009dca143bf` | LICENSEの先頭`//`を除去 | +| `agg23.txt` | 同PDFium内`third_party/agg23` | `agg_array.h`冒頭の告知を抽出 | +| `lcms.txt` | 同PDFium内`third_party/lcms` | `include/lcms2.h`冒頭の告知を抽出 | +| `libopenjpeg.txt` | 同PDFium内`third_party/libopenjpeg` | `openjpeg.c`冒頭の告知を抽出 | +| `freetype.txt` | PDFium内FTL.TXT。実ソースpin `4800589f76dc62fbe523a74a59e0d4850a5dd7df` | 原文と一致 | +| `abseil.txt` | `435e7d977fb36fb47854a4c552c0706dad0bd7cf` | LICENSEと一致 | +| `fast_float.txt` | `34164f547b7df3f5d794ff67e9f885c36819ebfc` | LICENSE-MITと一致 | +| `icu.txt` | `8cc91d9b6ab9991802fd208ee03a69714fd0251c` | LICENSEと一致 | +| `libjpeg_turbo.md` | `640f254ad0fa03f6b1f29f89b7dd9366f2f6e533` | LICENSE.mdと一致 | +| `libjpeg_turbo.ijg` | 同libjpeg-turbo revision | README.ijgと一致 | +| `libpng.txt` | `6d5341764ef4e38cbc07d35514a8aa73de50de8a` | LICENSEと一致 | +| `llvm-libc.txt` | `320824188c37e5c28738b9652a0ca8087c934bc9` | LICENSE.TXTと一致 | +| `simdutf.txt` | `f7356eed293f8208c40b3c1b344a50bd70971983` | LICENSEと一致 | +| `zlib.txt` | `285e94b8fa95ad3b7d16b80798ec8dce6febb8c8` | zlib.h冒頭の告知を抽出 | + +AGG、Little CMS、OpenJPEGはPDFium内へ取り込まれた変更済みソースである。保存したREADMEにはAGG 2.3 hard fork、Little CMS 2.19の原revision `b76633e60c8387a77268fb3359277ca25b5fd75c`、OpenJPEG 2.5.4の原revision `6c4a29b00211eb0430fa0e5e890f1ce5c80f409f`とローカル変更が記載される。対応ソースとしては元のreleaseだけでなく、上表の固定PDFium treeが必要になる。 + +fast_floatのREADMEはversion 7.0.0/`cb1d42aaa1e14b09e1452cfdef373d051b8c02a4`を記載する一方、DEPSのcheckout pinは`34164f5…`である。この差を推測で統一せず、checkoutの特定にはDEPSの完全なrevisionを採用した。 + +## 追加告知の技術的根拠 + +15件の一致は告知一式の網羅性を証明しない。providerの収集処理は`build.ninja`の`third_party`名を英小文字・数字・`_`・`-`の範囲で抽出するため、`libc++`と`libc++abi`は`libc`へ切り詰められ、そのcaseは収集対象から除かれる。 + +固定Chromiumの`config/c++/c++.gni`は`use_custom_libcxx=true`を既定にし、配布Linux `args.gn`にはその無効化がない。加えて、実`libpdfium.so`(SHA-256 `08f6ea53a64f6fbb9f5ba8d9c02e0051987c6a9175f3fb5ba25f219174731aaa`)から次の小規模な証拠を保存した。 + +- [DT_NEEDED](../tests/results/source-correspondence/pdfium/local-readelf-dynamic.txt)は`libpthread`、`libm`、`libgcc_s`、`libc`、dynamic loaderで、外部`libstdc++`/`libc++`依存はない。この観測だけで標準libraryの実装までは決定しない。 +- [公開symbol](../tests/results/source-correspondence/pdfium/local-readelf-cxx-symbols.txt)には、定義済みの`std::__Cr::__libcpp_verbose_abort`がある。 +- [読取り文字列](../tests/results/source-correspondence/pdfium/local-cxx-string-indicators.txt)には`third_party/libc++abi/src/src/private_typeinfo.cpp`、`fallback_malloc.cpp`と`libc++abi:`がある。 + +これらを合わせると、libc++/libc++abiの告知を追加する技術的根拠がある。取得済みの固定原文は次の通り。`llvm-libc`とは別のcomponentである。 + +| 追加候補 | DEPS固定revision | 原文SHA-256 | +|---|---|---| +| [libc++ LICENSE.TXT](../tests/results/source-correspondence/pdfium/dependencies/third_party/libc++/src/LICENSE.TXT) | `97b436da4c33663581d394f4ee0a5977fc38c2f4` | `539dd7aed86e8a4f12cbdd0e6c50c189c7d74847e4fecc64ce2c6ee3a01da38b` | +| [libc++abi LICENSE.TXT](../tests/results/source-correspondence/pdfium/dependencies/third_party/libc++abi/src/LICENSE.TXT) | `fc1897a2c12aa27e703c3ed48b62eba8abf4ce19` | `e2b35be49f7284a45b7baca8fc7b3ab7440e7902392b2528a457816b5bb2a15c` | + +調査後、固定原文を`resources/licenses/pdfium-supplemental/`へ追加し、Linuxのinstallと +開発パッケージへ反映した。[配布検証記録](../tests/results/linux-development-package/source-notices-record.json)は +2件の告知・出典metadataの追加、全553ファイルの整合、従来版とのバイナリー一致を記録する。 +configureとpackagerはPDFium版・commit・library hash・告知hashを照合する。 +新しい[provenance](../tests/results/dependency-provenance/notices-final/provenance.json)は、 +repository原本・package内metadata・実告知の対応も検査する。 + +## 未証明の範囲と再確認 + +現段階で得たのは固定レシピ、patch集合、宣言された依存pin、既存15告知の原文対応である。全ソースの保管、全linked componentの一覧、compiler runtimeとsysrootを含む対応ソース、再現ビルド、Windows archiveの告知照合は未完了。providerの初期`depot_tools`取得は無指定のcloneであり、GitHub Actionsも`@v6`等の可変tagを使う。root DEPS内の`depot_tools_revision`を、その前に使用したbootstrap cloneのrevisionと同一視しない。 + +レシピの解釈と公開API metadataだけから、全入力のbit単位再現性や配布条件の充足を宣言しない。G-DISTRIBUTIONの完了判定には、これらの不足を対象に追加確認が必要である。 + +オフラインで再確認する場合はrepository rootから以下を実行できる。取得済みmetadataだけを読み、元PDFiumのビルドスクリプトは実行しない。 + +```sh +python3 tests/results/source-correspondence/pdfium/check_correspondence.py +python3 tests/results/source-correspondence/pdfium/summarize.py +``` + +再取得URLは`provider-plan.json`、`upstream-plan.json`、`supplement-plan.json`と各retrieval sidecarに保持する。1件の`README.pdfium` HTTP 404は[失敗記録](../tests/results/source-correspondence/pdfium/retrieval-failures.json)に残し、固定treeで確認した`README.md`を取得した。全source cloneや巨大archive取得へ置き換えていない。 diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md new file mode 100644 index 0000000..f8a88f1 --- /dev/null +++ b/docs/TRANSLATIONS.md @@ -0,0 +1,44 @@ +# 日本語表示と翻訳用資源 + +設計01の初期UI言語は日本語。文書の言語やOSの数値・日付localeを変更せず、 +本体の翻訳カタログとQt標準部品の日本語カタログを起動時に読み込む。 +言語切替UIや外部から任意の翻訳ファイルを読み込む機能は提供しない。 + +本体・ワーカーの利用者向け文字列は`tr`、`qsTr`、または固定contextを持つ +`QCoreApplication::translate`で抽出する。コマンドID、設定キー、IPCキー、 +エラーコード、文書の本文・見出し・パスは翻訳しない。 +構文解析器が返す英語の詳細は、日本語の原因・行番号案内と区別して表示する。 + +## 更新とビルド + +Qt 6.11.2のLinguistToolsを必要とする。 + +```sh +cmake --build build --target docview_lupdate +# resources/i18n/docview_ja.ts の新規・変更文言と訳文を確認する +cmake --build build --target docview_lrelease +cmake --build build +``` + +`docview_lupdate`は本番C++ targetとQMLから文言・相対ソース位置を収集する。 +日本語の原文に同じ日本語の訳文を登録したTSを基準にし、追加時も訳文を確認する。 +生成QMは本体へ埋め込み、通常ビルドではTSを書き換えない。新しい言語への翻訳には +TSを別言語として作成し、読み込む言語を選ぶ設計とワーカーの運用を別途追加する。 +現状の多言語動作を保証するものではない。 + +Qt標準部品には、Qtの`TranslationsPath`にある`qtbase_ja.qm`と +`qtdeclarative_ja.qm`を使用する。配置時に統合される`qt_ja.qm`も読込み対象にする。 +Linuxではシステム依存として明示し、開発版packagerが +存在とhashを検査する。Windowsの配置コードは`windeployqt`のtranslations出力を使う。 +資源が欠けても文書処理を終了させず、Qtの原文へ戻るが、その配置を日本語表示の +合格とはしない。OS自身が提供するネイティブダイアログはOS側の言語設定に従う。 + +`translations`試験は英語localeのプロセスで、埋込QM、設定の行・原因、コマンド入力エラー、 +実Qt Quick Dialogの「開く」「キャンセル」を検査する。統合実行の4ケースは成功し、最新の533件のTS文言を完了状態で収録した。 + +[CTestログ](../tests/results/ui-final/ctest-font-final/LastTest.log)と +[対象ビルドのhash](../tests/results/ui-final/build-record.json)に記録した。 + +実装はQt公式の[翻訳ビルド手順](https://doc.qt.io/qt-6/qtlinguist-cmake-qt-add-translations.html)と +[QTranslator](https://doc.qt.io/qt-6/qtranslator.html)の公開APIに基づく。 +配置時の統合カタログは[Qtの翻訳配置資料](https://doc.qt.io/qt-6/qt-deploy-translations.html)を参照する。 diff --git a/docs/UBUNTU-PACKAGE.md b/docs/UBUNTU-PACKAGE.md new file mode 100644 index 0000000..25f3c88 --- /dev/null +++ b/docs/UBUNTU-PACKAGE.md @@ -0,0 +1,87 @@ +# Ubuntu 24.04用ローカル検証パッケージ + +Ubuntu 24.04 amd64向けのDebianパッケージを作成した。Qt SDK、PDFium、qpdf、libzipを`/opt/docview`へ配置し、`/usr/bin/docview`が必要な実行時パスを設定する。呼び出す側で環境変数を設定する必要はない。現在の検証版は、修正版PDFiumを含む[validation4](../tests/results/pdfium-intent-context/ubuntu-package/README.md)。一般公開用のリリースではない。 + +[debファイル](../tests/results/pdfium-intent-context/ubuntu-package/guest/package/package/docview_0.1.0~validation4_amd64.deb)は123,417,928バイト、SHA-256は `bd4f00912078d406cb466cd6910ed01c0c9fdd32b76cb2f47b74cfb9719f1bd9`。 + +## インストールと起動 + +Ubuntu 24.04 amd64のデスクトップ環境で実行する。 + +```sh +sudo apt install ./docview_0.1.0~validation4_amd64.deb +docview +docview '/path/to/日本語の文書.pdf' +``` + +PDF、HTML、HTML ZIP、EPUBを開ける。アプリケーションメニューにはDocViewを登録する。基本操作と設定は[README](../README.md)を参照する。 + +共有システムライブラリーはDebian依存情報に記載した版以上を要求する。日本語代替フォントとして`fonts-noto-cjk`、ラテン文字用に`fonts-dejavu-core`を要求する。同梱ライブラリーだけでOSの依存をすべて代替する構成ではない。 + +`/etc/apparmor.d/docview`は同梱`QtWebEngineProcess`の正確な実行パスに対するプロファイルで、Ubuntuのuser namespace制限下でChromiumの隔離を起動するために使う。インストール時に読み込み、削除時に解除する。カーネルのuser namespace制限を無効にせず、アプリのsandbox無効化オプションも設定しない。 + +## 削除 + +```sh +sudo apt remove docview +sudo apt purge docview +``` + +`remove`は実行ファイル・同梱ランタイム・メニュー登録を削除し、システム設定ファイルを保持する。`purge`は保持されたAppArmor設定も削除する。どちらも利用者の文書・設定・読書履歴を削除しない。 + +## 生成と照合 + +収集器と梱包処理の限定試験は次のコマンドで実行できる。通常の本体CTestとは別の結果として保存する。 + +```sh +python3 tests/test_ubuntu_validation_runtime.py -v +python3 tests/test_ubuntu_package.py -v +``` + +生成はUbuntu 24.04 amd64で行う。ビルドとSDKの準備は[専用VM手順](../tests/ubuntu_vm/README.md)、候補prefixの選択は[修正版PDFiumの手順](PDFIUM-CANDIDATE.md)を参照する。以下は候補を選択して配置した `$HOME/docview-context-install` を入力にする。入力するSDK告知とQt LICENSESは、[元SDK・ソースとの照合済み資料](../tests/results/source-archives/QT-SDK-NOTICES.md)を使う。 + +```sh +python3 tools/package_ubuntu.py \ + --prefix "$HOME/docview-context-install" \ + --version '0.1.0~validation4' \ + --qtpaths /opt/docview-qt/6.11.2/gcc_64/bin/qtpaths \ + --dependency-prefix /opt/docview-deps \ + --source-root "$HOME/dependency-sources/qpdf-12.4.1" \ + --source-root "$HOME/dependency-sources/libzip-1.11.4" \ + --input-manifest "$HOME/incoming/sdk-downloads.json" \ + --sdk-notices tests/results/source-archives/qt-sdk-notices-final \ + --sdk-supplement tests/results/qt-notice-supplement/collection \ + --qt-licenses tests/results/ubuntu-package/inputs/qt-licenses \ + --output "$HOME/validation/new-ubuntu-package" +python3 tools/verify_ubuntu_package.py \ + --archive "$HOME/validation/new-ubuntu-package/docview_0.1.0~validation4_amd64.deb" \ + --output "$HOME/validation/new-ubuntu-package/verification.json" +``` + +生成先は毎回新しいディレクトリーを指定する。生成器は固定入力のハッシュを確認し、ランタイムの通常ファイルだけをコピーする。シンボリックリンクは参照先のバイト列を所定のロード名へ配置する。全ファイルのサイズ・モード・SHA-256をmanifestへ記録し、独立した検証器が`.deb`のdata/controlアーカイブを読み戻す。`SOURCE_DATE_EPOCH=0`と所有者・権限を固定し、同じ入力から2回生成した結果が一致した。 + +Qt SDKの抽出告知129entry、対応する105本文、QtのLICENSES 64ファイルと実行時依存の部分台帳を含む。validation3はさらに、生成器が除外していたWebM/WebPのPatentファイル2件(同一本文)と出典メタデータ4件を補う。補足台帳・元台帳・実行時ファイル・全補足原文のハッシュを確認し、一致しない入力では生成を拒否する。元SBOMの不整合や除外範囲、全対応ソースと公開ライセンスの未確定事項は解消済みとはしない。告知文書内のArch用開発パッケージ説明は従来の別方式を説明するものであり、このUbuntuパッケージの起動手順は本書に従う。 + +## validation4の検証 + +候補を明示的にリンクしたUbuntu新ビルドは全24試験群、実debを含む候補境界13試験、色probe972点が成功した。生成2回のdebが一致し、archive内の2,100ファイルを検証した。候補library、ヘッダー、BUILDINFO、固定ビルド記録、パッチ、補足告知の対応も検証する。参照CMMは色probe専用で、配布物には追加していない。 + +ビルド環境ではSDK・依存・旧新のbuild/install/prefixを隠し、配置後2,095ファイルと131 ELFを確認した。X11・Wayland各6条件の起動とinstall/remove/purgeが成功した。[SDKのない既存試験VM](../tests/results/pdfium-intent-context/ubuntu-package/clean-vm/README.md)でも同じ12条件、配置の照合、remove/purgeと4つの利用者データprobe保持が成功した。このVMには過去の試験ツールが残っており、新規OSでの試験とは扱わない。2台とも試験後に正常終了した。 + +## validation3の検証履歴 + +validation3は補足告知を追加した版で、本体・両ワーカー・同梱ランタイムはvalidation2と同じバイト列である。[今回の記録](../tests/results/qt-notice-supplement/README.md)で、Arch/Ubuntu各8試験、独立したdeb生成2回の一致、全2,016ファイルのarchive検証を確認した。 + +SDKのない既存試験VMでも、インストール後2,011ファイルと131 ELFの依存、X11/Waylandの起動12条件、remove/purge・ユーザーデータ保持が成功した。このVMには前回の試験ツールが残っている。新しいOSでの検証と、validation2で行った実portal試験の範囲は以下の履歴に分ける。 + +## validation2の検証履歴 + +validation2は日本語・空白等を含むHTML名のURL比較を修正した版である。[修正後の全22群と実portal](../tests/results/portal/README.md)はArch・UbuntuおよびUbuntu X11/Waylandで確認した。debは約123 MB、2回生成が同一で、SDKのない試験VMの旧版からの更新・配置ファイル/依存の照合も成功した。 + +X11/Waylandで通常起動12条件、実OSファイル選択の取消と4形式の表示10ケース、remove/purgeと4つのユーザーデータprobe保持を確認した。全Chromium告知・対応ソース・公開ライセンス、Windowsと物理環境の受入は未完了である。 + +## 旧版validation1での配置検証 + +専用Ubuntu VMでインストール・再インストール・削除・完全削除を確認した。元のQt SDK、依存ライブラリー、build/install先を私有マウント名前空間内で隠し、通常ユーザーの空の実行時環境からX11とWaylandの各6条件を起動した。131個のELFの依存解決、配置された2,004ファイルの所有者・権限・バイト列も確認した。 + +さらに、元のUbuntu cloud imageから作った[新しいVM](../tests/results/ubuntu-package/clean-vm/README.md)でも、SDKやコンパイラーを別途導入せずにインストール・依存解決・同じ12条件の起動・remove/purgeが成功した。共有ライブラリーの解決はGUI試験ツール追加前に確認した。この節の結果は旧版validation1の記録であり、修正版の実OSファイル選択は別記録で検査する。実GPU・物理表示、Windows、署名・公開配布条件の受入は残る。パッケージの再現性は梱包工程の一致であり、依存ライブラリーのソースからの再現ビルドを意味しない。 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..7fbd282 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,297 @@ +# 実装・検証記録 + +2026-09-20追加:[PDFium第2候補の統合検証](../tests/results/pdfium-intent-context/README.md)。ICC変換指定・パターン状態継承・タイル線色を修正し、Arch・Ubuntu各972描画点、新ビルド各24試験群、上流1,979試験が成功した。旧パターン期待値の誤りと比較先の差は別に記録する。 + +対象日: 2026-09-20。対象はこのリポジトリーのLinux開発版であり、設計書一式の完成版受入は未完了である。Windows向けワーカー保護・通信・資源アクセス・フォント供給・renderer監視・配布コードはソース上で統合したが、Windows版は未ビルド・未実行。Windows 11での実行、製品としての配布、基準機の性能判定が残っている。外部の追加試験環境は提供されていないため、このPC内にUbuntu 24.04の専用KVM環境を作成し、X11・Waylandで追加検証した。 + +## 現在の修正版と検証範囲 + +起動先は `build-context/docview`。専用prefix `.deps/pdfium-context` を明示的に選択し、元の依存版・旧ビルド・旧パッケージは保持した。候補の由来、ライブラリ、ヘッダー、告知の固定値をCMake・配置・deb検証で照合する。配置の再検査は実行物のコピー前に行い、不正入力で既存配置が変わらない回帰を含む。[ビルド手順](PDFIUM-CANDIDATE.md) + +[Ubuntu validation4](../tests/results/pdfium-intent-context/ubuntu-package/README.md)は全24群と配布境界13試験が成功し、2回生成した123,417,928バイトのdebが完全一致した。ビルド環境のSDK等を隠した試験と、SDKのない既存VMで、それぞれX11・Wayland各6条件の起動、install/remove/purge、利用者データ保持が成功した。後者は過去の試験ツールが残る再利用環境である。参考色の計算用CMMは配布物に追加していない。 + +[PDF性能4条件](../tests/results/pdfium-intent-context/performance/README.md)は、新規プロセス120回・同一プロセス内120回・750操作・960スクロール入力が成功した。約1GB・5,000ページPDFの3回open・100操作・末尾確認も成功した。測定した暫定予算の超過は0。Xvfb・software描画の参考値で、物理表示や基準機の受入ではない。今回の変更はPDFiumと配布対応であり、HTML・EPUBの性能を今回再測定したとはしない。 + +[21組の比較画面](../tests/results/pdfium-intent-context/render-review/index.html)を新ワーカーで再生成した。83枚の復号RGBAは前回と完全一致し、既知の比較差を保持している。人によるG-RENDER承認は未実施。[現在の統合JSON](validation-record.json)は固定ビルドアンカーと最終成果物・結果を結び付け、以下の旧結果を履歴として参照する。 + +残る最終受入はWindows 11でのビルド・実行・配布、対象OSの物理GPU・表示・入力と基準機・長時間負荷、人による描画承認、公開配布に必要な対応ソース・告知・ライセンスの最終確認である。 + +## 第1候補の履歴:2026-09-20のPDFium色変換修正 + +[固定ソースへの修正候補](../tests/results/pdfium-intent-build/README.md)を隔離先でビルドし、元のCMYK試験48点と4種類のintent・継承・画像キャッシュ・Type3等の600点が一致した。許容差4を維持し、最大差はそれぞれ0と2。上流の単体1,104件・結合872件、既存DocViewの全22試験群も候補を使って成功した。パッチを元ソースへfuzzなしで再適用し、変更後の全51ファイルが一致することを確認した。 + +比較先Popplerにはshading patternのRI指定で18点の不一致が残るため、比較全体の受入は未合格のまま保存した。候補は既存依存ライブラリ・Ubuntu validation3パッケージへ未導入で、以下の製品の失敗記録を上書きしない。Ubuntuでの候補試験・梱包、SoftMask/tiling専用の画素試験、Windows、人による描画受入は残る。[統合JSON](validation-record.json)は製品と候補の識別・結果を分けて保持する。 + +## 元の依存版の履歴:2026-09-20のICC CMYK追加比較 + +[4次元ICC CLUTの比較](../tests/results/cmyk-lut/README.md)で、文書の相対的測色指定に対してPDFiumが知覚的変換を使う差を確認した。Arch・Ubuntu各48測定点のうち30点が許容差を超え、最大channel差は74。独立Popplerは全点が数式の期待値に一致し、PDFiumの画素は固定ソースの知覚的変換と照合できた。原因が分かったことを描画受入の合格とはしない。 + +資料の構造・プロファイル16頂点・埋込バイト列・原本不変・3倍率の寸法を検証し、[追加比較画面](../tests/results/cmyk-lut/index.html)を用意した。製品ソースとバイナリーは不変で、全22群や配布試験の以前の結果と、今回の色一致の失敗は別の範囲である。ICCの変換指定への対応または明示的な互換性判断と、検出可能な制約の通知は未完了。[現在の統合JSON](validation-record.json)にも失敗を記録した。 + +## 2026-09-20のQt告知補足 + +[Ubuntu validation3](../tests/results/qt-notice-supplement/README.md)に、SDKのSBOM生成器が省いたWebM/WebPのPatentファイル2件(同一本文)と元メタデータを追加した。Arch/Ubuntu各8件の梱包試験、2回のdeb生成一致、全2,016ファイルのarchive検証、SDKのない既存VMでのinstall・起動12条件・remove/purgeとデータ保持が成功した。元SBOMの不整合・全告知と対応ソースの未確定範囲は残る。 + +本体・ワーカー・同梱ランタイムと全コンパイル対象ソースはvalidation2から不変である。以下の全22群・性能・実portalは元の実行結果を保持し、今回の再実行とは数えない。[告知補足時点の統合JSON](../tests/results/qt-notice-supplement/record.json)と[その直前のJSON](../tests/results/qt-notice-supplement/prior-validation-record.json)も変更せず保存する。 + +## 2026-09-20のURL比較修正 + +実OSファイル選択の追加試験から、日本語・空白・予約文字を含むHTMLの読込みが停止する不具合を発見し、URLの表現をそろえて比較するよう修正した。[追加記録](../tests/results/portal/README.md)に再現・変更・新しいビルドとの対応を保存する。修正後の全22試験群はArchで171.56秒、Ubuntuで184.71秒、いずれも失敗0だった。 + +この変更で本体バイナリーは変わった。以下の`ui-final`等による性能・GPU・IME・重負荷・旧パッケージの記録は、各記録で固定した修正前の版の履歴である。「現行」と記した従来節の語も当時の版を指す。新しい本体へ実行結果を読み替えない。旧統合JSONも[履歴として保存](../tests/results/portal/prior-validation-record.json)した。 + +新しい本体の[性能10条件](../tests/results/portal/performance-suite/README.md)を再測定し、新規プロセス300回・同一プロセス内300回・移動2,500操作、操作負荷と約1GB/5,000ページPDFが成功した。open p95は条件間最大266.85ms、応答p95は最大23.74ms、群RSS最大830.83MiBで、測定した暫定予算の超過は0。開発機の仮想表示・software描画の値である。Ubuntu修正版debは、実portal選択10ケース、通常起動12条件、旧版からの更新・remove/purgeも成功した。[現在の統合JSON](validation-record.json)と[履歴・制約](../tests/results/portal/README.md)を併記する。 + +## URL比較修正前のUI実装と検証 + +焦点復帰、Web倍率の表示・保存、開く操作の失敗対象と対処案内を修正した。詳細は[最新の統合記録](../tests/results/ui-final/README.md)と[変更前後の回帰](../tests/results/final-ui-regressions/README.md)へ保存する。ArchのX11全22試験群が166.00秒で成功し、WaylandではApp110・Web36と6条件起動が成功した。同行した旧Canvas21件は内部でoffscreenを強制していたため、Wayland描画の証拠から除外する。初回の見開き2件の失敗は、非同期resizeの確定前に次の要求を送った試験側の問題と診断し、サーバーとQt双方の確定寸法を待つ手順へ修正した。製品の描画処理は同一である。 + +[AMD GPUのOpenGL経路](../tests/results/ui-final/wayland/GPU-VALIDATION.md)でもApp110・Web36・Canvas22と本体6条件起動が成功した。Canvasは実platformの検査とQQuickWindowの取得画素によるページ表示・切替・画像消去の試験を追加し、Wayland/softwareとoffscreen/200%でも成功した。追加試験後も製品バイナリーは同一で、既存の全22群と後続の限定試験を別記録にしている。実モニターのscanout・物理入力・基準機の性能受入は未実施である。実IMEの追加範囲は以下に分けて記録する。 + +以下の`completion-final`の20試験群・性能10条件・大容量PDFは、今回のUI修正前に固定した前回の結果である。PDF/Archive worker、core試験、実時間watchdogとcommon libraryの5バイナリーは今回も同一であり、[変更されていない部品の対応記録](../tests/results/ui-final/unchanged-component-evidence.json)に既存比較との一致を残した。全アプリ測定を新本体へ読み替えない。新しい561ファイルの開発パッケージは[2回生成一致・6条件起動](../tests/results/ui-final/linux-development-package/README.md)、部分告知台帳は[2回一致](../tests/results/ui-final/dependency-provenance/README.md)を確認した。 + +[URL比較修正前の性能10条件](../tests/results/ui-final/performance-summary.md)は、新規プロセス300回・同一プロセス300回、対応する移動系列の計2500操作を完了した。Xvfb 1100×760・100%・software描画で、起動p95は最大271.03ms、初期応答p95は最大23.82ms、群RSS最大831.23MiBとなり、測定した暫定予算の超過はなかった。[別の操作負荷](../tests/results/ui-final/performance-interaction/record.json)と[約1GB・5,000ページの追加負荷](../tests/results/ui-final/stress/README.md)も同じ修正前の本体で成功した。後者の3回open p95は92.31ms、独立標本の群RSS最大は721.07MiBだった。実GPU・基準機の判定ではない。初回にホストの画面設定を引き継いだ[条件不一致の失敗](../tests/results/ui-final/performance-display-mismatch/README.md)は別保存し、採用していない。 + +[600dpi相当画像と60,000個の半透明ベクター](../tests/results/heavy-pdf/README.md)もArchとUbuntuで表示でき、実open処理中のEsc取消と実render処理中の文書切替が成功した。画像の独立復号hashと取得画素を検査し、原本不変、旧session回収、メモリ圧迫なしを確認した。既存の本番GUI部品・ライブラリ・ワーカーを使う独立試験で、通常の22群や本体の性能10条件とは別に記録する。単一試験のheartbeat最大間隔31/38msは視覚応答p95の代用ではない。 + +[実IBus/MozcとUS pc105・JP jp106配列の追加試験](../tests/results/ime/README.md)もUbuntuのX11で成功した。検索・コマンド欄の日本語変換、Enter/Escの保護、物理キー番号からの記号操作、入力後のj/k復帰を確認し、原本と本体は不変だった。2ケース・setup/cleanupを含む4件が12.329秒で成功。合成IMEイベントの既存試験とは別であり、後続の[Wayland試験](../tests/results/ime/WAYLAND.md)でもQt Wayland+IBusのD-Bus経路が1ケース・setup/cleanup込み3件、7.474秒で成功した。同一補助バイナリーのX11 US/JP回帰は4件・12.438秒で成功。物理入力・Windowsは未検証で、Waylandのkeysym入力を日英物理配列の証拠へ読み替えない。 + +## Ubuntu 24.04の追加検証 + +[専用KVM環境の記録](../tests/results/ui-final/ubuntu/README.md)で、X11全22試験群が177.63秒で成功した。WaylandでもApp110・Web36、6条件の本体起動が成功し、配置済み本体は別install prefixからX11の6条件でReadyとなった。Landlock ABI 4、seccomp、Chromium sandboxとUbuntuのuserns制限を維持した。公式Qt SDK 6.11.2とGCC 13.3.0によるUbuntu用ビルドであり、Archの実行ファイルを流用していない。 + +Canvas試験のplatform強制を除いた後、[通常/200%の2群](../tests/results/ui-final/ubuntu/canvas-ctest/tests.xml)と[実Waylandの22件](../tests/results/ui-final/ubuntu/canvas-wayland/report.json)も成功した。追加の実ウィンドウ画素検査を含み、本体・ワーカーのhashは全22群の実行時から同一である。 + +CMake 3.28の配置一時名の残留とPython 3.12のzstd非対応に対応した。provenanceの5件はUbuntuだけ明示skipし、30件が成功している。非埋込日本語PDFの試験は、RPCを待つ実イベントループへ変更した後、単独3回と全体実行で成功した。製品コードと15秒の期限は同じである。Archでも修正後の全22群が成功した。 + +配置の依存検査は1,740ファイル・85システムpackageで解決し、追加したSDK metadata上限の20試験も成功した。告知104件とmetadata44件は部分収集であり、Qt/Chromiumの告知と対応source全体は未完了。公式SDKを別配置した専用環境での検査であり、自己完結したUbuntu配布物や実GPU/IMEの受入を意味しない。 + +## Ubuntuパッケージの追加検証 + +[Ubuntu 24.04用deb](../tests/results/ubuntu-package/README.md)を約123 MBで作成し、2回生成のbyte一致、全2,009ファイルのarchive検証、配置後2,004ファイルと131 ELFの依存を確認した。元のSDK・依存・build/install先を私有マウント名前空間で隠し、呼出側のQt/LD環境変数なしでX11/Wayland各6条件がReadyとなった。install/reinstall/remove/purge、AppArmorの登録・解除、ユーザーデータ保持probeも成功した。本体・workerは既存のUbuntu配置済みバイナリーと一致し、全22群を今回再実行したものではない。さらに[新しいUbuntu OS](../tests/results/ubuntu-package/clean-vm/README.md)でも、SDKなしで宣言依存を導入し、131 ELF解決・12条件起動・remove/purgeが成功した。実GPU・native portalと公開配布条件は別の受入とする。[使用手順](UBUNTU-PACKAGE.md) + +## 対応ソース収集の追加 + +[Qt 6.11.2一式とtoml++ 3.4.0](../tests/results/source-archives/README.md)を固定した公開hashと照合して取得した。Qtの390,976通常ファイル・約4.74GBを検査し、告知等3,309件の所在を記録。固定WebEngine source 8件、現在のtoml++ヘッダー51件、Arch差分4件の適用とmkspec 2件の一致を確認した。本体・ワーカーは変更していない。実GN構成に対する全Chromium告知、独立PDFium等を含む全対応ソース、実配布条件の最終確認は残る。 + +[独立PDFiumの固定Gitソース](../tests/results/source-archives/PDFIUM.md)では、本体と27依存、追加gitlink 1件を保管し、通常46,430ファイル・リンク4件を検査した。先行Gitiles原文31件、パッチ後の公開ヘッダー24件、同梱告知15件が一致し、Linux3件/Windows4件のパッチ適用を確認した。検査の再実行結果もbyte一致した。Windowsのコンパイル・実行、compiler/sysroot等の全入力、再現ビルドはこの結果に含まない。 + +[Ubuntu用Qt SDKのChromium告知](../tests/results/source-archives/QT-SDK-NOTICES.md)は、実ビルドのSBOMから129entry・105本文を取り出し、Qt sourceの原文と全件一致した。SBOM記載67ファイルとUbuntu実行時83ファイルが元SDK archiveと一致。外部package参照IDの不整合1件、本文を持たない2部品と生成器の除外処理を記録し、全告知の網羅性とは区別する。 + +## 環境と資料 + +Arch Linux x64、Linux 7.2.2-zen1-1-zen、AMD Ryzen 9 9955HX(16コア/32スレッド)、約62 GiB RAM。Qt 6.11.2、GCC 16.2.1、Fontconfig 2.18.3、固定PDFium 155.0.8057.0をReleaseビルドした。全体CTestと性能測定はXvfb上のX11、Qt Quick software backend、WebEngine GPU無効で実行し、Chromium・Linuxワーカーの隔離は有効のままとした。Waylandは専用Sway headless/pixmanとAMD OpenGLの2経路を別々に記録する。実ディスプレイの60 Hzと実IMEの試験は含めない。 + +文書は生成した。PDFの本文、MCID、注釈、リンク、回転、切り抜き座標を生成元で指定し、追加日本語PDFはOFLフォントをsubset埋込した。さらにフォント非埋込のUniJIS-UCS2-H/V資料と、変換Form・回転・跨ページ構造を持つtagged PDF資料を生成した。HTML/ZIP/EPUBはローカルCSS・SVG・見出し・spineを持つ資料、破損資料、隔離攻撃用資料を生成した。 + +- [基本PDFの期待値と比較](../tests/PDF-VALIDATION.md) +- [21組のPDF比較レビュー画面](../tests/results/completion-final/render-review/index.html)・[資料と確認方法](../tests/results/completion-final/render-review/README.md)(人による承認は未実施) +- [追加PDFの原版・制約・ハッシュ](../tests/fixtures/pdf/extended/manifest.json) +- [非埋込日本語CID横/縦の前回比較](../tests/results/completion-final/pdf-fonts/comparison.json)・[手順と旧結果](../tests/results/pdf-fonts/README.md)・[フォント供給契約](font-broker-contract.md) +- [実TTCのface選択とTTF比較](../tests/results/completion-final/font-collection/comparison.json)・[手順と旧結果](../tests/results/font-collection/README.md) +- [保存外観のないPDFリンク枠の前回比較](../tests/results/completion-final/link-borders/comparison.json)・[補完の制約と旧結果](../tests/results/link-borders/README.md) +- [EPUB見開き](../tests/results/epub-spreads/README.md)・[ZIP入口の再利用](../tests/results/zip-entry-choice/README.md)・[資源警告](../tests/results/resource-warnings/README.md) +- [タグ付きPDFの境界資料](../tests/fixtures/pdf/headings/README.md)・[構造解析の検証](../tests/results/pdf-structure/README.md) +- [注釈の倍率・回転・DPI](../tests/results/pdf-annotation-flags/README.md)・[コメントのキー操作](../tests/results/pdf-comments/README.md) +- [資源エラーの詳細分類](../tests/results/resource-classification/README.md) +- [ICCプロファイルの3倍率・54点比較](../tests/results/completion-final/pdf-icc/comparison.json)・[手順と旧結果](../tests/results/pdf-icc/README.md) +- [ナビゲーションと遅延レイアウト](../tests/results/navigation-reflow/README.md) +- [Web応答監視](../tests/results/renderer-response/README.md)・[アーカイブ実読取量](../tests/results/archive-ratio/README.md) +- [メモリ圧迫への段階的対応](memory-pressure.md) +- [XHTML見出し・目次と章末の重複移動先](../tests/results/xhtml-headings/README.md) +- [前回Linux開発パッケージの証跡](../tests/results/completion-final/linux-development-package/README.md)・[作成手順](LINUX-DEVELOPMENT-PACKAGE.md) +- [同梱コード・システム依存・ローカルビルド由来の台帳](DEPENDENCY-PROVENANCE.md) +- [固定recipe・ソース参照・実行時ファイルの照合](../tests/results/source-correspondence/README.md) +- [原設計との受入照合](ACCEPTANCE-AUDIT.md) +- [原本同一性・目次待機・見出しとリンク取消の追加回帰](../tests/results/completion-regressions/controller-state.md) +- [日本語の初期表示・翻訳資源](TRANSLATIONS.md) +- [追加PDFの前回独立比較](../tests/results/completion-final/pdf-extended/comparison.json)・[手順と旧結果](../tests/results/pdf-extended/README.md) +- [HTML/EPUB資料のハッシュ](../tests/fixtures/web/manifest.json) +- [性能資料のハッシュ](../tests/fixtures/performance/manifest.json) +- [約1GB・5,000ページの画像PDF負荷試験](../tests/STRESS-PDF-VALIDATION.md) +- [ビルド依存](DEPENDENCIES.md)・[実装判断](IMPLEMENTATION-DECISIONS.md) + +前回ビルドの[6系列の比較](../tests/results/completion-final/comparison-run.json)はすべて完了し、原本とバイナリーは前後で不変だった。TTCの初回は選択したPythonにfontToolsがなく失敗し、失敗ログを残してsystem Pythonで再実行した。旧比較の対応83画像とは[RGBA画素が一致](../tests/results/completion-final/comparison-pixel-regression.json)した。これは人による正解画像の承認ではない。 + +追加PDFは5文書・9ページ。独立したsystem Poppler 26.08.0とproduction PDFiumを144 DPIで比較し、全9ページの寸法、CropBox/Rotateの16色位置、透明合成3点を確認した。日本語の抽出・検索も成功し、全ページの比較画像を確認した。RGB平均絶対差は0.5207〜2.5135/255だが、校正済みの互換性合格閾値には使わない。基本PDFの保存外観のない標準リンク枠は補完し、比較領域8,448画素がPopplerと一致した。コメントアイコンと文字のアンチエイリアスには比較器間の差が残る。 + +フォント選択はMainの専用処理で行い、選択済みバイト列だけを既存IPCでPDFWorkerへ渡す。SFNT/TTC解析はWorker内に残し、Linuxのfontディレクトリー許可を撤去した。非埋込日本語資料ではIPAexMincho/BIZ UDPGothicを選択し、横/縦2ページと検索を確認した。Popplerとは代替書体が異なり、その差を記録した。任意フォント名・範囲・応答・時間・キャッシュ上限も試験した。実際の2書体TTCでも通常/太字のface 0/1を選択し、個別TTF条件と2資料・892,800画素が一致した。試験用フォントは保存・配布せず回収した。 + +## 自動試験の範囲 + +今回の追加修正より前の統合結果は **19試験群すべて成功(失敗0、未実行0)**、所要162.00秒。記録は[旧CTest JUnit](../tests/results/completion-regressions/prior-19-tests.xml)、[旧CTestログ](../tests/results/completion-regressions/prior-19-LastTest.log)、[旧集計とバイナリーハッシュ](../tests/results/completion-regressions/prior-validation-record.json)へ保存した。通常CTestのapp群で計測専用slotを1件意図的にskipし、別runnerで実行する運用は継続する。Windows固有試験はLinuxの成功件数に含めない。 + +その後、原本の同一性・目次待機・PDF見出し候補・Web操作取消・PDF情報省略通知・翻訳資源を修正した。独立した`build-worker`では[追加回帰と全GUI](../tests/results/completion-regressions/controller-state.md)を実行し、core 111成功、GUI 96成功・0失敗・計測専用1skipを確認した。[Web全36ケース](../tests/results/completion-regressions/docview-web-navigation-regression.txt)も成功した。これらは最終の統合実行とは区別する。 + +前回ソースの統合実行は、`translations`を加えた **20試験群すべて成功(失敗0、未実行0)**、165.61秒。[前回CTest JUnit](../tests/results/completion-final/ctest/tests.xml)、[前回CTestログ](../tests/results/completion-final/ctest/LastTest.log)、[対象ビルドのhash](../tests/results/completion-final/build-record.json)を保存した。内訳はcore 111、PDF 75、Web 36、translations 4、app 96成功・計測専用1skipを含む。後から追加したpackaging境界2件は[限定14件の再実行](../tests/results/completion-final/ctest/linux-package-focused.txt)で成功し、全体CTest内の12件とは区別する。試験中も本番と同じLandlock・seccompの保護を使用した。 + +PDFiumのlibc++/libc++abi告知を追加した時点のpackaging 12件・provenance 23件と、[553ファイルの整合・生成一致・6条件起動](../tests/results/linux-development-package/README.md)は修正前バイナリーの配布履歴である。告知追加時には主3実行ファイルのhashが不変だったが、今回の製品変更後まで同一と主張しない。前回版は[553ファイルのアーカイブを独立に2回生成](../tests/results/completion-final/linux-development-package/README.md)し、hash一致と別展開先6条件のReadyを確認した。配布物・6系列の描画比較・実時間watchdogは[前回記録](../tests/results/completion-final/README.md)へ保存済みで、同じビルドの性能再測定も完了した。 + +| 試験群 | 主な確認 | +|---|---| +| core | 開いた原本identityと保存時の再照合、変更と履歴なしの区別、能力スキーマ、TOMLの一括適用、型・未知キー・衝突、論理キー正規化、数値G、期限・長押し・入力状態、XDG、位置・privacy・ロック・バックアップ | +| archive | ZIP名・型・重複・展開上限・CRC、source実read量による膨張率制限、5codecのpadding攻撃と正規6方式、XML制限、入口選択、EPUB 2/3・NCX/nav/spine・nonlinear・固定レイアウト・RTL・難読化フォント | +| pdf | コメント本文・ページ内件数・CBOR応答予算の省略通知とprefix保持、PDFium metadata・ラベル・タグ/RoleMap/MCID・リンク・注釈・フォーム外観・パスワード・検索・座標・タイル一致・破損・目次/ページ分割 | +| security | 資源の不在・安全拒否・権限・サイズ・読取/保存I/Oの分類、パス・シンボリックリンク・IPCフレーム・索引総量、展開途中の非公開、原本保護、一時領域回収、OSの読取/書込/通信/子プロセス/シグナル拒否、font grantなしで実fontファイル読取拒否 | +| instance | 同一ユーザーの二重起動、ロック、引渡し、ACK、異常要求、ソケット所有・リンク、読み取り専用時の回復 | +| memory_pressure | OSサンプルの有限解析、段階順序、閾値境界、回復ヒステリシス、無効値保持、1秒timerと注入 | +| renderer_watchdog | Chromium rendererの識別、親子関係とPID再利用防止、RSS上限、30秒の応答期限、保持pidfd/handleによる対象終了、取消・旧probe失効 | +| worker_process | 実IPCで成功・エラー、要求ID/世代/操作不一致、過大応答、異常終了、一度だけの失敗通知、キュー取消、展開ストリームの順序、font逆RPCの親・副ID・範囲・遅い応答・取消 | +| benchmark_runner | 起動失敗・timeoutで古い成功証跡を破棄、冷起動失敗と測定検査失敗を最終successへ反映 | +| linux_package | 相対パス・リンク・型・重複・未登録ファイル・サイズ/hashの境界、告知補完の版/hash、固定epochのtar/gzip再現性、失敗時の旧成功記録除去、Qt日本語資源の存在・hash検査(統合12件、追加後の限定実行14件) | +| runtime_manifest | 合成MZファイルによる有限の依存一覧、名前・型・件数・サイズ、SHA-256、衝突拒否、コピー・配置後の改変検知。native PE解析の証拠ではない | +| runtime_staging | 一覧の厳密なスキーマ、hash/size検証、未登録ファイルの除外、リンク拒否、原本不変、一時コピーの回収 | +| font_contract | font逆RPCの厳密な型・ID・上限、切断時の即時終了 | +| font_broker | OS索引の限定選択、16参照・256選択・512MiB累積転送、revoke、2thread上限、GUIで待たない破棄、文書間LRU回収と使用中参照保護 | +| system_font_adapter | SFNT/TTCの表・face境界、短いbuffer、hash、再利用、128MiB上限と使用中cacheの保持、エラー時の代替成功抑止 | +| pdf_canvas | 中央ページと保存用先頭位置の分離・余白同距離・ズーム中心保持、CropBox/回転の双方向変換、PDF座標の復元、古い移動の取消、先読みの優先度・件数・上限、メモリ圧迫での先読み停止・不可視cache回収・解像度抑制・回復、旧応答破棄、キャッシュ予算、先読み/旧倍率のfont致命エラー・旧文書応答の破棄 | +| pdf_canvas_hidpi | DPR 2で192条件の通常/抑制解像度の注釈の実描画画素とクリック・選択領域を照合 | +| translations | 英語localeで埋込QM・設定/コマンドの日本語案内・実Qt Quick Dialogの「開く」「キャンセル」。OSネイティブdialogの言語は対象外 | +| app | 全4形式の閲覧中原本置換と復元不能通知、DOM目次loadingパネル、内部宛先だけのPDF見出し代替、失敗したEPUB境界指示の破棄、WebリンクのEsc取消、本番QML/Controller、コメントのTab/Enter・CropBox外・極小ページ・解除・文書切替、検索完了/取消時の能力状態、実展開worker終了時の資源分類、初期パネル表示/480・1100px幅/表示切替、現在節と選択の分離・折りたたみ・IDなし見出しの履歴復元、PDFラベル/実ページ、明示closeと旧応答破棄、メモリ圧迫での処理停止・新open拒否・回復後の明示再open、能力境界、5,000ページの遠距離移動と取消、特殊キー、IMEイベント、パネル、全形式の文書切替、元資料ハッシュ、履歴消去・未提示位置、font失敗後の表示維持・新workerでの再open・警告の保存、文書切替の失敗/取消とRecoveringの整合 | +| web | リンク選択解除と作者outline復元、同一資源の見出し境界・新navigationによる旧指示失効、本番WebEngine、HTML/XHTMLのnative/ARIA見出し・名前空間付き目次・入れ子階層、章末重複移動先・内部scroll、MainWorldとApplicationWorldの分離、実通信拒否、meta refresh/フォーム拒否、CSS/SVG、縦書き/RTL、CFI・リフロー復元、遅延image/fontの横/縦・連続scroll・native anchoring・poll先行、生成見出しのbase解決、固定viewport、検索、実renderer SIGSTOPと取消・再open | + +5,000ページGUI試験は全ページ描画を待たず末尾へ移動し、異なる描画世代を連続要求して最後の緑色ページの画素を確認する。元の青いページの混入、置換後の旧画像、staging文書の取消、10ms周期のUI heartbeatも確認する。この試験の最大停止1.5秒という閾値は回帰検出用であり、T20のp95予算を緩和するものではない。 + +追加のGUI回帰では、[最近使った文書の初期画面](../tests/results/recent-documents/README.md)と[EPUBの文書端移動・Web検索取消](../tests/results/web-epub-boundaries/README.md)を確認した。Web位置はMainで型・上限・originを確認して論理位置だけを採用し、EPUBのpackage/spineを照合して復元する。履歴とバックアップに残る旧`location.url`は、書込み可能な状態だけで原子的に除去する。CFI・進捗・本文引用を保持し、lock取得失敗や`forceReadOnly()`ではファイルを変更しない。 + +## 受入IDとの対応 + +「Linux限定」は記載した生成資料・開発環境の個別試験が成功した範囲を指す。対象OS全体の最終合格を意味しない。「一部」は記載以外の確認が残る。 + +| ID | 判定範囲 | 実結果・残る確認 | +|---|---|---| +| T01 | 一部 | 文字・画像・ベクター・透明・回転・CropBox、埋込日本語を比較。非埋込UniJIS-UCS2-H/Vの横組・縦組も描画/検索/目視確認。保存appearanceなしの通常リンク枠を補完し、NoZoom/NoRotateは生成リンク枠・保存リンク/コメント外観・Textアイコンで検証。任意CID/CMap、校正済みICC/CMYK、特殊合成、両OS・全倍率の広いコーパスは未実施 | +| T02 | Linux限定 | ページ移動・倍率・回転・保存座標・旧tile破棄を自動確認 | +| T03 | Linux限定 | 5,000ページの直接末尾移動・応答性・取消・文書置換をGUI確認。約1GB(0.940 GiB)の実画像ストリームを持つ5,000ページ資料でも100操作と末尾移動を確認 | +| T04 | Linux限定 | ローカルHTMLのCSS/SVG/別HTML/アンカーを本番WebEngineで確認 | +| T05 | Linux限定 | ZIP相対資源・別HTML・入口不在/複数、ストリーム展開を確認。手動入口を原本identityへ結び付け、別GUIプロセスで再利用。変更・消去・取消・privacyを検証 | +| T06 | 一部 | EPUB 2/3・spine/目次順相違・nonlinear・RTL・固定viewportを確認。gg/Gの通常読書対象の先頭/末尾と章内端を横書きLTR/RTL・縦書きで確認。見開き・package/item上書き・左右/RTL・中央単独・混在・resize・zoom・取消を生成資料で確認。実XHTMLの章内見出し、章末の重複移動先と次章/前章への往復、失敗した境界移動が後の通常章移動へ残らないことも検証。両OSは未判定 | +| T07 | Linux限定 | 開く・読む・数値ページ・目次・パネル・文書置換のキーイベント試験 | +| T08 | Linux限定 | 内容・パネル・入力欄の分岐、文字入力中の閲覧停止 | +| T09 | 一部 | IME preedit/commitイベントと論理キーは確認。Ubuntu X11では実IBus/Mozc・US/JP XKBの変換と取消も成功。Waylandも実Qt+IBus/MozcのD-Bus経路が成功。物理入力・Windowsは未実施 | +| T10 | Linux限定 | 期限・Esc・操作先変更・長押し・数値列・設定再読込時の取消 | +| T11 | 一部 | 構造タグ/アウトライン、RoleMap、複数MCID、同一ページの異なる見出し、推測しないfallbackを確認。外部/禁止アクションのみと内部宛先混在の目次で能力・移動を検証。変換Form、回転CropBox、直接/子MCRの跨ページ順、vector衝突のfallbackを追加。FormだけのStructParentsは共有qpdfアダプターで取得し、実位置への移動もGUIで確認。annotation所有構造は明示的制約 | +| T12 | Linux限定 | 階層・現在位置印/選択の分離・折りたたみ・確定・不正宛先、更新後の選択保持、IDなし見出しの履歴と同HTML内復元、20,000件上限と分割目次、DOM索引のloadingと欠如の区別・表示要求の保持を確認 | +| T13 | Linux限定 | 3パネルの独立切替・隠した操作領域から本文への復帰 | +| T14 | 一部 | 暗/明テーマとPDF原色保持をGUI確認。表示スケールの証跡は下記。両OSは未実施 | +| T15 | Linux限定 | 有効設定を一括適用、キー一覧更新、文字設定変更と論理位置維持 | +| T16 | Linux限定 | 型・範囲・未知キー・重複・接頭辞衝突・別表記衝突を拒否して前設定を保持 | +| T17 | 一部 | XDG・非ASCII/空白パス・状態書込ロック・設定失敗を確認。Windows Known Folders実行・対象OS初回起動は未実施 | +| T18 | 一部 | 静的注釈・コメント・widget保存外観、欠損外観の通知、通常リンク枠の表示補完、入力不能・原本ハッシュ不変。NoZoom/NoRotateは生成リンク・保存AP・Textアイコンで確認。DPI、実画素とhit領域、Tab/Enterでのコメント閲覧を確認。APなしの他形式注釈はNoZoomの制約を通知。本文長・1,000件・512KiB情報予算での省略を明示し、取得済みprefixと注釈の存在を保持 | +| T19 | レビュー採用 | [接続契約のレビュー](adapter-contract.md)。解析をUIから分離し、能力・位置・ナビゲーション・取消しの既存経路と追加手順を追跡。全8能力・4状態・未対応理由を共通境界で検証。同名の抽象基底や動的登録は論理契約の必須条件としない | +| T20 | 一部 | 生成資料の新プロセス冷起動30回と同一プロセスopen30回、各系列125操作、29同条件開閉、連続scroll・操作集中時の開発機測定を実施。約1GB画像PDFでも3回open・100操作を追加。対象OS・基準機・GPU/60 Hzの受入は未実施 | +| T21 | Linux限定 | 破損・欠損資源・パスワード誤り・失敗時の旧文書維持・Esc・旧世代破棄。broker/interceptor/CSPの検出をURLなしで集計し、文書情報に分類と件数を保持。broker側も不在・安全拒否・アクセス拒否・CRC・上限・保存/読取失敗を有限分類で区別する | +| T-P01 | Linux限定 | PDF/HTMLの日本語/英語検索・n/N・文字情報なしを確認。04 §7の表示テキスト検索としてWebは現在の章/HTMLを対象にする。全spine一括検索は提供せず、入力欄とREADMEに範囲を表示 | +| T-P02 | Linux限定 | 入力モードの隔離、PDF/Webの検索revision取消、同文書内取消と文書切替後の旧callback破棄 | +| T-P03 | Linux限定 | PDF表示済み座標、Web CFI/DOM位置、package/spine解決、受信位置の型・範囲・origin検証、font/幅変更と遅延image/fontの横/縦同一文字維持、連続scroll・native anchoring・poll先行、全形式のopen時identityを保存時にも照合し閲覧中の置換先へ旧位置を転用しない。通常openの同一性不一致も通知。近似復元通知。内部URLを新規保存せず、旧履歴/backupも移行 | +| T-P04 | Linux限定 | 初期画面の一覧・キーで再表示・保存上限・無効化・履歴消去・終了時に消去位置が復活しないことを確認。削除・変更・置換された原本は以前の項目から開かず、明示的な再選択を案内 | +| T-S01 | Linux限定 | 絶対/親/リンク/重複名・実展開量・CRC・一時ファイル公開の境界を確認。実readを各streamで計数し、paddingされた5圧縮方式を途中で停止(codec内部の先読み量を含む) | +| T-S02 | Linux限定 | 実WebEngineで文書JS・外部通信・フォーム・meta/任意遷移・外部ファイル・ダウンロードを制限 | +| T-S03 | 一部 | OS隔離、資源上限、異常終了・不正返信・取消を確認。実時間でPDFの30秒通知/120秒停止、展開の10秒通知/30秒停止、途中応答で期限が延長されないことと取消を追加確認。Web rendererの実停止・応答期限・再open・取消も確認。Windows native攻撃試験は未実施 | +| T-S04 | Linux限定 | 現在/backup状態消去、原本不変、自分の未使用一時領域だけ回収 | +| T-S05 | 未完了 | Arch開発版tar.gzの依存/告知manifest、再現可能な生成、移動先からの6条件起動を確認。Ubuntu用debは同梱SDKで12条件起動・install/remove/purgeを確認。新しいUbuntu OSでも同じ12条件とremove/purgeが成功。Windows配布、対応sourceと全Chromium告知を含む公開パッケージは未完了 | + +## 性能・表示・配置の証跡 + +前回ビルドでは [10条件の再測定](../tests/results/completion-final/performance-summary.md)が完了した。新プロセスopen計300回、同一プロセスopen計300回、各適用系列125操作、各240scroll入力で測定検査は成功し、10条件の暫定予算の参考超過はなかった。原本・バイナリーのhashも照合した。500章EPUBを含む群RSS最大は830.75 MiB。OSファイルキャッシュは保持した。 + +[操作集中時の再測定](../tests/results/completion-final/performance-interaction/README.md)は3成功、長押し120入力・24ジャンプ・10resize・4切替を確認した。最大heartbeat超過145.82 msは独立して報告し、各入力の視覚応答や物理scanoutと同一視しない。[大容量PDF](../tests/results/completion-final/stress/README.md)も3回open・100操作・5,000ページ目を確認した。原本不変・一時資料回収済みで、最終品質p95は24.40 ms、独立監視の群RSS最大は720.46 MiBだった。これらは対象OS・基準機の受入を代替しない。 + +[前回の実時間watchdog試験](../tests/results/completion-final/worker-deadlines/README.md)では本番の待機時間を変更せず、隔離済みの試験用IPC peerが応答しない場合の停止を確認する。Mainのイベントループと別要求の取消も確認し、結果は通常CTestとは分けて記録する。PDFium/libzip自体を停止させた試験や、GUIフレーム性能の測定としては扱わない。前回実行は3成功・120.172秒、PDF通知/停止30,007/120,006ms、archive通知/停止10,008/30,008ms。実行ファイルとcommon libraryは統合ビルドのhashに一致する。[旧結果と再現手順](../tests/results/worker-deadlines/README.md)も保持する。 + +以下の数値とJSONは今回の原本同一性・翻訳等の修正前のビルドによる履歴である。前回版の再計測結果は[別の集計](../tests/results/completion-final/performance-summary.md)へ記録しており、履歴値を前回版の測定と扱わない。履歴の性能JSONは[小型資料](../tests/results/performance/)、[500ページ/500章資料](../tests/results/performance-standard/)、[非埋込日本語PDF](../tests/results/performance-fonts/)に保存する。記録したビルドの各条件を、新プロセス・空のXDG設定/状態/キャッシュで30回開く。OSファイルキャッシュとシステムフォントは保持するため、完全コールドとは呼ばない。計測の起点はアプリ起動後のファイルopenであり、プロセス起動時間は含めない。同一プロセスの計30回は、初回1回と明示的な再open29回に分けて集計する。 + +各資料で同条件のopen→最初の表示→closeを29回、close後1秒待ちで観測する。その後のナビゲーション/scrollを含む最後のcloseは別に記録する。対応するページ・見出し・章の系列は各125操作。PDFのページ系列は実ページ移動101回と倍率変更24回で、同じ位置へのno-opをcache hitへ算入しない。見出しがない原500ページPDFは対象不足を明示し、500件の著者outlineを持つ別資料で見出し系列を確認する。 + +Webの見出し系列は実DOMの`html-heading`索引だけを使い、EPUBの著者目次では代用しない。各系列には対象数・対象の種類・往復範囲を記録する。XHTMLのnative見出しが欠落していた修正前の測定は[別記録](../tests/results/performance-before-xhtml-fix/README.md)へ保存し、最終版の見出し性能の証拠から除外した。 + +500章EPUBの測定資料は、章末の複数見出しが同じスクロール位置に収まらないよう最後の節に1画面分の最小高さを指定し、13見出しに異なる到達点を持たせた。元資料は`standard-500-clamped.epub`として同じバイト列で保持する。元資料で見つかった同位置の見出しを交互に再選択する不具合は製品側で修正し、横書き・縦書きの実DOMとEPUB章境界の回帰試験を追加した。[資料の版と目的](../tests/fixtures/performance/README.md)を区別する。 + +入力はフォーカス中の本番ウィンドウへの合成QKeyEvent。`firstResponseFrameMs`は操作成立後の最初のQtフレーム通知までの時間で、PDFでは可視tileの完成前にも記録する。Webのスクロールは操作ID付きACK、見出しはACKと論理位置変化、章移動は検証済み`resourcePath`の変化を成立条件とする。`finalQualityFrameMs`は2回以上の通知を待ち、PDFでは対象の可視tileがそろった後の通知に限定する。以前のフレームの遅延callbackは除外する。 + +QtのframeSwappedは提示キューの通知であり、物理的なscanoutの時刻ではない。WebはChromium compositorのframe IDと結合していないため、両値とも対象画素の提示時刻を直接検証した値ではない。`success`は要求回数・移動・close・原本不変等の計測検査の成功であり、暫定性能予算の合格ではない。50 msとの参考比較は全操作系列をまとめた`firstResponseFrameMs`のp95を使い、原設計の視覚応答を直接測定した値とは区別する。 + +本体RSS、および本体+全子孫のRSS合計を100msごとに採取し、開始・最大・close後・反復差分を分ける。表の群ピークと1 GiBの参考比較は同一プロセスの開閉・操作系列を対象とし、新プロセス起動30回のRSSは各生JSONへ別に記録する。close後は1秒待ってEmptyと描画cacheゼロを確認するが、全補助プロセスの消滅を条件にはしない。RSS合計は共有ページの重複を含み、採取の間に生じたピークは捕捉できない。保持RSSが直ちに戻らないことだけでリークと断定しない。実QWindow/QScreenの寸法・DPR・報告refresh、pressure段階も保存し、メモリ圧迫中の測定を通常品質の成功値へ混ぜない。描画cacheの課金量はRSSとは別指標とする。 + +| 資料 | 形式 | 空のアプリcacheからopen p95 (ms) | 入力→初回Qt通知 p95 (ms) | 最終品質指標 p95 (ms) | 連続scrollフレーム p95 (ms) | 群ピーク RSS (MiB) | +|---|---|---:|---:|---:|---:|---:| +| 小型 | pdf | 63.34 | 7.95 | 13.12 | 19.14 | 174.45 | +| 小型 | html | 211.38 | 11.37 | 17.01 | 19.25 | 635.30 | +| 小型 | zip | 216.61 | 6.68 | 17.07 | 18.19 | 645.33 | +| 小型 | epub | 213.54 | 13.00 | 21.20 | 18.19 | 712.08 | +| 500ページ/章等 | pdf | 63.75 | 7.93 | 15.84 | 18.25 | 433.72 | +| 500ページ/章等 | pdf-headings | 66.44 | 8.04 | 16.67 | 20.33 | 451.60 | +| 500ページ/章等 | html | 259.46 | 6.50 | 12.82 | 17.06 | 701.00 | +| 500ページ/章等 | zip | 267.92 | 10.84 | 17.07 | 17.50 | 703.34 | +| 500ページ/章等 | epub | 270.86 | 22.75 | 31.07 | 19.91 | 826.52 | +| 非埋込日本語 | pdf | 87.28 | 7.80 | 12.93 | 18.56 | 211.64 | + +10条件すべて冷起動30回・原本不変・測定条件検査が成功した。OSファイルキャッシュは保持する。上記は開発機上のQt通知時刻による参考値であり、両OS・基準機の性能合格ではない。 + +| 資料 | PDF | cache hit 件数 / p95 (ms) | miss 件数 / p95 (ms) | 同位置移動の除外件数 | +|---|---|---:|---:|---:| +| 小型 | pdf | 101 / 13.02 | 0 / — | 0 | +| 500ページ/章等 | pdf | 81 / 12.14 | 20 / 13.26 | 0 | +| 500ページ/章等 | pdf-headings | 81 / 15.87 | 20 / 28.13 | 0 | +| 非埋込日本語 | pdf | 101 / 12.93 | 0 / — | 0 | + +メモリは本体RSSと、本体+全子孫の群RSSを分けて記録する。以下の3値は開始 / 最大 / 最終close後(MiB)。反復差分は、同条件29開閉の最初と最後のclose後の差(MiB)である。 + +| 資料 | 形式 | 本体 開始 / 最大 / close後 | 群 開始 / 最大 / close後 | 29開閉の群差分 | +|---|---|---:|---:|---:| +| 小型 | pdf | 103.38 / 141.95 / 125.04 | 103.38 / 174.45 / 125.04 | +7.75 | +| 小型 | html | 101.76 / 352.82 / 349.98 | 101.76 / 635.30 / 501.49 | +9.09 | +| 小型 | zip | 100.92 / 339.68 / 336.52 | 100.92 / 645.33 / 490.67 | +9.69 | +| 小型 | epub | 101.75 / 342.21 / 339.50 | 101.75 / 712.08 / 491.68 | +9.71 | +| 500ページ/章等 | pdf | 103.19 / 393.89 / 387.09 | 103.19 / 433.72 / 387.09 | +5.79 | +| 500ページ/章等 | pdf-headings | 101.93 / 409.69 / 346.12 | 101.93 / 451.60 / 346.12 | +2.02 | +| 500ページ/章等 | html | 102.21 / 373.73 / 353.61 | 102.21 / 701.00 / 513.59 | +8.68 | +| 500ページ/章等 | zip | 101.51 / 363.29 / 343.16 | 101.51 / 703.34 / 493.57 | +12.73 | +| 500ページ/章等 | epub | 101.55 / 359.94 / 343.18 | 101.55 / 826.52 / 504.09 | +11.85 | +| 非埋込日本語 | pdf | 103.57 / 154.39 / 154.39 | 103.57 / 211.64 / 154.39 | +18.98 | + +暫定予算から外れた参考値: なし。予算値は変更していない。継続scrollの物理60 Hz判定、完全OS-cold、長期リーク判定はこの計測では行わない。 + + +[操作集中時の専用測定](../tests/results/performance-interaction/README.md)は長押し120入力、遠距離ジャンプ24回、resize10回、staging取消、異なる文書への4切替を含む。QtTestの本番Controller/QML/workerを使い、最後の5,000ページ目は緑色の実画素も検査する。各入力の移動有無、描画待ち/実破棄数、10ms heartbeatを記録する。通常CTestの計測専用slotのskipは、この独立実行結果と分けて報告する。 + +大容量PDFは5,000ページ・1,009,828,104 bytes(0.940 GiB)。1,280個の異なる画像を全て実際に使い、ページ間で再利用する。修正前の記録では3回openのp95は97.70 ms、100操作の最終品質p95は24.12 ms、50 ms間隔の独立監視によるプロセス群ピークRSSは約720 MiBだった。30回openの受入を置き換える値ではない。原本ハッシュ不変と5,000ページ目の画面を確認し、大容量一時資料は回収した。[詳細と再現手順](../tests/STRESS-PDF-VALIDATION.md) + +## 前回の配置と記録の対応 + +前回の`cmake --install`と開発版tar.gzの別展開先で本体・ワーカー・PDFiumとライセンスを配置した。同じ開発機のシステムQtを使用し、クリーンOSへの独立配布の合格ではない。[前回配布記録](../tests/results/completion-final/linux-development-package/README.md)では2回のアーカイブ生成がbyte一致し、圧縮後6,367,967 bytes、553ファイル、SHA-256は`e384c8c21da11343f845a5ca7310ee6ca944e51c9585de4b2b5c7c73ca352e0e`。原本と別の一時展開先から、PDF・非埋込日本語PDF・HTML・ZIP・EPUB、100%/200%を含む[6条件すべてReady](../tests/results/completion-final/linux-development-package/smoke/package-smoke.json)を確認して一時展開先を回収した。100%は1100×760、200%は550×380論理pxの仮想画面内に収まり、worker/Chromiumのsandboxは有効だった。配置後PDFWorkerはRPATH変更でbuild直後とhashが異なるため、それぞれを記録する。[従来の配置結果](../tests/results/smoke/results.json)と[旧配布記録](../tests/results/linux-development-package/README.md)は履歴として保持する。 + +ソース、実行ファイル、試験、証跡のSHA-256と集計は[validation-record.json](validation-record.json)に記録する。今回の追加修正前の集計は[prior-validation-record.json](../tests/results/completion-regressions/prior-validation-record.json)に保存した。前回版の全試験・比較・配置・性能の結果は[completion-final](../tests/results/completion-final/README.md)にそろえ、旧結果を新しいバイナリーへ読み替えない。最終性能は10条件、PDF比較は6組を記録し、それぞれ対象バイナリーを照合する。前回の実時間watchdogは再実行済みで、専用試験実行ファイルとcommon libraryのhashを個別reportとbuild-recordで確認できる。最終集計の`workerDeadlinesBuildMatches`へも反映する。旧結果は従来の各フォルダーと旧集計へ保持する。記録の`matchesRecordedBuild`と`pdfComparisonBuildMatches`で対象を区別する。約1GBの負荷試験も最終ビルドで再実行し、主3実行ファイルの一致を`stressPdfBuildMatches`に記録する。旧ビルドの測定は`stressPdfPrior`として保持する。 + +## Windows向けに追加したコード + +LPAC・Job・明示したハンドルだけの継承、子側での実token検証、元文書のread-onlyハンドル入力、非同期pipe通信を本番ワーカー経路へ接続した。個別runtimeの一覧・ハッシュ照合、profileのfilesystem/registry書込拒否、handle相対の資源アクセス、rendererのプロセス識別・監視、volume+128bit file IDによる保存位置の照合を追加した。Windows PDFiumの固定アーカイブと公開ヘッダーも確認した。 + +Windows限定の`windows_pipe`、`windows_resources`、`windows_worker`試験をCMakeへ登録した。これらは未実行であり、Linuxでの合成MZ試験やWineヘッダーによる構文確認では代用しない。Windows GUI試験は一時保存先を明示し、実利用の履歴を消去しない構成とした。[準備手順](WINDOWS-BUILD.md)・[隔離の詳細とフォント代替の未確認点](windows-sandbox-port.md) + + +## リリースの残作業 + +### 追加で解消した実装上の不足 + +- 全形式でopen時の原本identityを保存時に照合し、閲覧中の変更・置換へ旧位置を転用しない。復元不能と履歴なしを区別して案内する。DOM目次のloadingは欠如と区別し、表示要求を保持する。[修正前6失敗と修正後のGUI/core](../tests/results/completion-regressions/controller-state.md)。 +- PDFの構造見出しがない場合の代替候補は内部宛先だけを採用する。EPUBの見出し章境界は成立したnavigationへ結び付け、失敗した操作の指示を次の章移動へ残さない。WebリンクのEsc取消はDOMのフォーカスと選択表示も解除する。 +- PDFのコメント本文長・ページ内1,000件・512KiB情報応答予算の省略を明示し、取得できたprefixを保持する。本文が長すぎる注釈は固定代替文で存在を示す。安全上限を緩めず、原本も変更しない。 +- 日本語sourceをQtの翻訳contextで抽出し、TS/QMと起動時のQTranslatorを接続した。英語localeでの日本語案内とQt Quick標準ボタンの4ケースを確認した。Qtの日本語資源の配置は必要条件であり、OSネイティブdialogや多言語切替の完成は主張しない。[翻訳・配置手順](TRANSLATIONS.md)。 + +- PDFのNoZoom/NoRotateは、保存APのバイト列を変更せず、描画中だけ矩形・回転を補正する。DPRを描画要求とキャッシュ識別へ渡し、Mainの選択枠・マウス判定も同じ表示座標へ合わせる。生成リンク枠、保存リンク/コメント外観、ネイティブTextアイコン、非表示指定、重なり、CropBox境界を生成資料で検証した。APなしの非Text注釈のNoZoomは制約を通知し、不正確なクリック補正を行わない。 +- 資源提供側の詳細原因を表示集計まで渡す。実在しない資源、安全検査拒否、権限、MIME、上限、CRC、容量不足、保存/読取I/O、Worker異常を区別し、取消は通知しない。[追加契約](resource-warning-contract.md)。 +- FormだけにStructParentsを持つ構造見出しは、[共有qpdfアダプター](pdf-structure-adapter-plan.md)で取得する。所有元と呼出を検証できる文字だけをexactにし、複数呼出・曖昧な対応は理由付きpage precisionへ下げる。循環・破損・quota超過は制約を返す。 +- PDF検索の能力は、走査完了の実結果でsupported/unavailableへ更新する。取消・失敗・旧応答では未確認状態を保持する。 +- PDFのコメント本文はTab/Shift+Tab、Enterで開ける。CropBox外の注釈も対象とし、通常モードのEscで選択を解除する。Web文書への切替後はPDFの選択・画像を回収する。 + +これらの資料に対する成功を、任意のPDF・全OSの互換性合格としては扱わない。 + +### 対象環境・配布・性能の残作業 + +1. 統合済みWindowsコードをMSVC・Windows Qtでビルドし、本番LPAC下のPDF/ZIP/EPUB、pipe・resource・worker試験を実行する。日本語を含む非埋込フォントの代替、profile回収、renderer監視、PE依存解析と配置を検証する。現状は利用可能と確認できたWindows版ではない。[詳細](windows-sandbox-port.md) +2. Ubuntu 24.04ではXvfb/Sway・software描画の22試験群、100%/200%を含む起動と配置済みアプリを確認済み。X11の実IBus/Mozc・US/JP XKBは補助試験で確認済み。WaylandのQt+IBus D-Bus経路も確認済み。ローカルdebのinstall/remove/purgeは既存VMと新しいUbuntu OSで確認済み。物理入力、GPU、native desktop portalと公開配布条件を確認する。Windows 11ではnativeビルドとこれら一式が未検証。 +3. 基準機と拡張コーパスでT20のp95、継続scrollのフレーム間隔、長時間稼働、様々なスキャン文書の取消と上限を測定する。600dpi相当画像と高密度ベクターの単一生成資料はArch/Ubuntuで検証済みだが、長い実スキャン群を網羅していない。 +4. PDF互換性の未検証項目と既知の外観差を評価し、依存ライセンス・告知・配布方式を確定する。 + +以上が残るため、G-RENDER/G-HEADINGS/G-SANDBOX/G-WEBは確認済みの範囲だけを記録し、G-DISTRIBUTIONと全要件の完成判定を合格にしない。 diff --git a/docs/WINDOWS-BUILD.md b/docs/WINDOWS-BUILD.md new file mode 100644 index 0000000..23b1cba --- /dev/null +++ b/docs/WINDOWS-BUILD.md @@ -0,0 +1,45 @@ +# Windowsビルドと未検証項目 + +Windows向けコードと試験を用意したが、Windows SDK・MSVC・Windows版Qtを利用できる環境は今回提供されていない。以下は実装したビルド経路であり、実行済みの手順や動作保証ではない。Windows 11でのコンパイル、実行、隔離攻撃試験、クリーン環境への配置が必要である。 + +環境を新規作成する場合、[MicrosoftのWindows 11 Enterprise評価版](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-11-enterprise)は利用登録後の90日評価を案内している(2026-09-19確認)。利用可能なISO・利用条件・必要なアカウントを確認してから専用VMへ導入する。旧Developer VMの英語URLは確認時点で[開発環境案内](https://learn.microsoft.com/en-us/windows/dev-environment/)へ転送され、既成VMの現行配布元としては確認できなかった。Windows VMはまだ作成していない。 + +## 必要なもの + +- Windows 11 x64、対応するMSVC x64コンパイラとWindows SDK。`dumpbin`を実行できるDeveloper PowerShellを使用する。 +- 同じツールチェーン向けのQt 6.11.2。Core、Gui、Network、Qml、Quick、QuickControls2、WebEngineQuick、Xml、LinguistTools、Test、QuickTestと`windeployqt`が必要。Qtの日本語翻訳資源(qtbase_ja.qm、qtdeclarative_ja.qm、または配置ツールが統合したqt_ja.qm)も配置する。 +- libzip 1.11以上、toml++ 3.4以上、qpdf 12.4.1のCMakeパッケージ。libzip/libqpdfとその依存DLLは同じアーキテクチャ・ランタイムで用意する。 +- CMake 3.24以上、Ninja、Python 3.12以上。 +- ビルドしたアプリに対応する公式Microsoft Visual C++ Redistributable。開発機のCRT DLLを自動で収集・再配布する構成にはしていない。 + +PDFiumはLinux版と同じ固定コミットのWindows x64成果物を取得する。V8とXFAは無効で、`bin/pdfium.dll`と`lib/pdfium.dll.lib`を使う。取得元とハッシュは [lock](../cmake/pdfium.lock.json) に固定した。 + +このWindows providerには、Linuxで検証した[第2候補の描画修正](PDFIUM-CANDIDATE.md)をまだ組み込んでいない。以下は元のproviderでビルド経路を確認する手順であり、修正版の完成手順ではない。Windows用PDFiumへの同修正の適用・ビルド・色とパターンの回帰試験も残っている。Linux用の共有ライブラリーと固定lockをWindowsへ流用しない。 + +## 構成・試験・配置 + +以下のQtと依存ライブラリの場所を実際の配置先へ置き換える。 + +```powershell +$env:CMAKE_PREFIX_PATH = "C:/Qt/6.11.2/msvc2022_64;C:/docview-dependencies" +python cmake/fetch_pdfium.py --platform windows-x64 --destination .deps/pdfium-windows +cmake -S . -B build-win -G Ninja -DCMAKE_BUILD_TYPE=Release -DDOCVIEW_PDFIUM_ROOT="$PWD/.deps/pdfium-windows" +cmake --build build-win --parallel 6 +ctest --test-dir build-win --output-on-failure --output-junit tests.xml +cmake --install build-win --prefix "$PWD/build-win/install" +& ./build-win/install/bin/docview.exe ./tests/fixtures/pdf/navigation.pdf +``` + +実機ではQtが使用するGPU・表示環境とChromium sandboxを有効にする。保護を無効にする引数や環境変数で試験を通さない。基礎試験資料は同梱し、追加生成手順は [README](../README.md) に記載した。 + +ワーカーのリンク後、`dumpbin`とCMakeのPE依存解析を使い、必要なDLLを実行ファイルの隣へ収集する。未解決依存、異なる内容の同名DLL、大小文字だけが異なる名前はビルド失敗とする。Windows API-setとOSのDLLは収集対象から除外する。特殊な再配布DLLを追加する場合のみ、`DOCVIEW_EXTRA_WORKER_RUNTIME_DLLS`に再配布可否を確認した絶対パスを指定する。 + +各`*.exe.runtime.json`は、ワーカー本体と有限個のDLLの名前・サイズ・SHA-256を記録する。起動時には全て照合して個別の一時領域へコピーし、そのコピーにだけAppContainerアクセス権を設定する。DLL更新時に古いDLLとの衝突が出る場合は、依存構成を確認して新しいビルドディレクトリーを用意する。既存の異なるDLLを無条件に上書きしない。 + +`cmake --install`はワーカーの一覧とハッシュを再検証し、GUI用Qtプラグイン・QML・WebEngine資源を`windeployqt`で配置する。`--nopatchqt`でQt DLLの変更を止め、明示した`qt.conf`で配置先を指定する。配置後もワーカー依存のハッシュを検証する。これはインストーラー作成、署名、アンインストール、ライセンス告知を完了する処理ではない。[QtのWindows配布手順](https://doc.qt.io/qt-6/windows-deployment.html) + +## Windows固有の試験 + +`windows_pipe`は非同期通信、上限、切断と取消、`windows_resources`はhardlink/reparse・名前変更・原子的公開、`windows_worker`は本番LPAC内の要求順序、不正応答、異常終了、原本・兄弟ファイル・設定・通信・子プロセス・余分な継承ハンドル・自身のprofile書込拒否を試す。追加試験用ワーカーは製品installの対象に含めない。GUI試験の履歴と資源は明示した一時保存先を使用する。 + +これらはWindowsではまだ実行していない。Linuxでのmanifest生成・コピー試験は合成MZファイルを使うため、PE依存解析やLPACの成立を証明しない。Windowsのフォント代替、profile回収、WebEngine renderer監視、Known Folders、IME、表示倍率、配布については [隔離の実装と残作業](windows-sandbox-port.md) と [受入対応表](VALIDATION.md) を参照する。 diff --git a/docs/adapter-contract.md b/docs/adapter-contract.md new file mode 100644 index 0000000..4f02065 --- /dev/null +++ b/docs/adapter-contract.md @@ -0,0 +1,106 @@ +# 文書アダプターの実装契約と拡張手順 + +対象は現在の C++20 / Qt 6 実装である。設計書 01 の R09、02 の処理境界、05 の論理契約との対応を記録する。設計書にある `FormatAdapter` は操作の意図を定義したもので、現在のコードに同名の基底クラスや動的プラグイン機構はない。 + +## 現在の境界 + +| 責務 | 実装 | 境界で受け渡すもの | +|---|---|---| +| 開く、取消し、文書切替、履歴、操作の振分け | `src/app/controller.*` | セッショントークン、generation、検証済みの QVariant データ | +| キー・入力欄から有限コマンドへの変換 | `src/core/input.*` | command ID、引数。シェル展開は行わない | +| PDF 解析・描画・構造情報 | `src/pdf/pdf_document.*`、`src/pdf/main.cpp` | CBOR。PDFium ハンドルはワーカーの外へ渡さない | +| PDF 表示・位置解決・タイルキャッシュ | `src/app/pdf_canvas.*` | PDF ポイント座標、画像タイル、renderRevision | +| ZIP/EPUB 解析 | `src/archive/archive.*`、`src/archive/main.cpp` | メタデータ、資源一覧、上限付きの展開ストリーム | +| HTML/EPUB 表示と資源提供 | `src/web/*`、`qml/WebPane.qml`、`resources/reader.js` | 文書別 `doc://` origin、アプリ所有の isolated-world コマンド | +| ワーカー輸送と応答検証 | `src/common/worker_process.*`、`ipc.*`、`contracts.*` | 有限 CBOR、要求 ID、世代、用途別の受信検証 | +| ローカル保存 | `src/core/state.*`、`config.*` | 検証した JSON 状態と TOML 設定 | + +PDFium と libzip は GUI 実行ファイルにリンクしない。PDF タイルと WebEngine の DOM を共通画像へ変換する層もない。共有するのは文書操作の意味、位置・世代・能力の扱いである。 + +## 論理操作と実際の呼出し + +| 設計書 05 の操作 | 現在の入口 | 結果・制約 | +|---|---|---| +| probe | Controller の先頭バイト判定、ArchiveReader の内部判定 | 複雑な ZIP/XML の解析はワーカー側。独立した FormatAdapter::probe はない | +| open | WorkerProcess `open`、ローカル HTML の profile 作成 | PDF は初期 metadata と初回描画の成功を確認して切替。HTML は staging view の load 成功を確認 | +| getOutline | PDF `outline(cursor,limit)`、archive `metadata`、Web `index` | PDF は安定 ID と parentId/depth、nextOutlineCursor。Web は読み込んだ資源の索引 | +| getHeadings | PDF `headings(page)`、Web `index` | PDF はページごとに抽出し、exact/page と source を保持。独立した節一覧の cursor はない | +| resolveLocation / navigate | PdfCanvas::goToLocation、Web `location.restore` / `navigate` | PDF のページ情報待ちを含む。後続の明示移動・倍率変更・取消しは未完了の復元を失効させる | +| renderPdfTiles | PDF `render` → PdfCanvas | page、物理scale、devicePixelRatio、rotation、clip、renderRevision。返却は BGRA8888 | +| setViewport | Canvas の geometry/DPR/fit 計算、WebPane の layout | PDF の描画 revision と Web view の documentSerial は別管理 | +| findText | PDF `search`、WebEngineView::findText | PDF は query/page/start/limit、矩形と nextCursor。OCR はない | +| close | WorkerProcess::stop、profile revoke、view dispose | GUI は終了期限を伴うプロセス終了を使う。PDF の close RPC は handle 解放後ワーカーを終了する | + +PDF の `pages` は outline を再送しない。初期応答に全ページ・全目次を入れず、ページは最大 256 件かつ 256 KiB、目次は最大 256 件かつ 512 KiB 以下に分割する。目次全体の走査上限は 20,000 件・深さ 64 で、省略を warning と truncated で通知する。GUI はページ範囲・連続性を再検証し、索引は許可したフィールドだけを残してセッション合計 32 MiB に制限する。 + +## 能力情報 + +`src/core/types.h` の `DocumentCapabilities` は Qt 非依存で、次の 8 個の `Capability` を持つ。PDF の metadata 生成はこの表現を実際に使用する。通信上の既存表現は `capabilities: {name: state}`、理由コードは `capabilityReasons: {name: reasonCode}` である。 + +| 項目 | PDF open 成功時 | +|---|---| +| pageNavigation | supported | +| chapterNavigation | unsupported / E_CHAPTER_NAVIGATION_UNSUPPORTED | +| outline | supported または unavailable | +| headings | loading。解析結果と文書内宛先を持つ outline fallback の確定は Controller の責務。外部・禁止アクションだけの目次を supported の根拠にしない | +| textSearch | loading。現在の検索応答で文字を確認した時点で supported、全ページの探索が完了しても文字がなければ unavailable。取消し・旧セッション/旧検索の応答・エラーでは未確定の能力を確定しない | +| textSelection | unsupported / E_TEXT_SELECTION_UNSUPPORTED | +| reflow | unsupported / E_REFLOW_UNSUPPORTED | +| password | unavailable。要求中は open の E_PASSWORD_REQUIRED / E_PASSWORD_INVALID とセッション状態で通知 | + +`supported` はアダプターが機能を提供できること、`unavailable` はその文書で対象がないこと、`loading` は未確定、`unsupported` は実装範囲外を表す。未知の能力名・状態を成功扱いしてはいけない。`CommandRegistry::requiredCapability()` がコマンドと能力の対応を返す。 + +`contracts.cpp::validateCapabilities()` は 8 項目が全て存在すること、状態が上記 4 種の文字列であること、`unsupported` に理由があることを検証する。理由は既知の能力に対応する 128 文字以下の `E_` で始まる英大文字・数字・アンダースコアのコードに限る。PDF の open 応答受信時と全形式の commit 前に検証し、不正な新文書では切替を中止して以前の文書を保持する。Web の能力と理由は Controller が既定の描画ポートから構築する。能力を必要とするコマンドは、検索入力・目次表示を含めて同じ検証と可否判定を通り、未知・欠落を実行許可に変換しない。`loading` は非同期探索を開始できる状態として扱い、`unsupported` の理由は一時通知に表示する。 + +HTMLのDOM索引がまだ届いていない場合、空の配列と`outline=loading`を目次の欠如へ変換しない。利用者の表示要求を保持し、読み込み中のパネルから確定した索引へ移行する。 + +PDF metadata の `isTagged` は公開 `FPDFCatalog_IsTagged` の結果であり、タグ付き文書の宣言を示す。構造木の完全性や見出しの存在を保証する値ではない。タグ宣言がある場合でも、個々の構造と位置は `headings` の結果で判定する。参考: [PDFium の catalog API 実装](https://pdfium.googlesource.com/pdfium/%2B/e8b8183f4d7a410b42bc4af6c31dcacb3b365685/fpdfsdk/fpdf_catalog.cpp)。 + +## 位置・応答の規約 + +- 内部ページ番号は 0 始まり。画面の物理番号は 1 始まり。ページラベルは文字列として保持し、数値計算に使わない。 +- PDF の保存位置は `kind=pdf`、pageIndex、pageLabel、xPt/yPt、viewRotation、fitMode、zoom。xPt/yPt は元 PDF 空間であり、Canvas が CropBox・元回転・利用者回転・DPR を適用する。読書位置は表示上端を基準にする。 +- Web の保存位置には token を含む URL を残さず、検証済みの資源相対パス、CFI/fragment、progression等を保存する。EPUBではpackagePath/spineIdrefも照合する。`zoom`は有限数の25–500で、HTML/HTML ZIP/流込EPUBではWebEngineのzoomFactor×100、固定EPUBではページ/幅合わせを基準にするspreadZoom×100を表す。固定EPUBの`fitWidth`はbool(falseはページ合わせ)、`panX`/`panY`は表示可能なパン範囲に対する有限数の0–1。画面サイズから計算する固定ページの最終ピクセル係数やDPRをこの倍率へ混ぜない。 +- WebPaneはアプリ側の表示倍率と合わせ方を`location`の結果に加える。Controllerの`logicalWebLocation()`が表示通知・保存位置の復元の両方で型と範囲を検証し、表示済み位置の`zoom`をStateStoreの位置と倍率へ保存する。復元ではレイアウトを確定して表示倍率を適用してからDOM位置を解決する。旧位置に`zoom`がなく別保存の倍率がある場合はそれを同じ境界で検証する。両方になければ既定表示を維持する。後続コマンドは未完了のWeb位置復元を取り消す。 +- PDF outline/link の wire target は平坦な page/x/y、HTML/EPUB は href 等を持つ。設計書の再帰的 TocNode と DocumentLocation variant に全面変換する実装ではない。 +- 各 IPC メッセージは protocolVersion=1、requestId、sessionId、generation、operation と、payload または result/error のどちらかを持つ。送信元の stdout を命令として扱わない。 +- 制御は 1 MiB、画像データは 8 MiB 以下。Linuxはローカルソケット、Windowsは継承した双方向pipeを使う。どちらも同じIpcChannelのバイトストリーム内で、長さヘッダーの上位 bit により画像と制御のフレームを識別する。 +- GUI は画像の幅・高さ・stride・形式・バイト数・ページ・clip・renderRevision を再検証する。Canvas の文書番号と revision も照合して、古い画像を採用しない。 +- `discardQueued(operation)` は未送信要求だけを除去する。実行中の PDFium 呼出しは中断されず、応答破棄またはワーカー終了で取消しを成立させる。 +- `frameReady` は画像の受信であり、画面提示完了ではない。保存・性能測定で表示済み位置が必要な場合は、window の frameSwapped と viewportReady を併用する。 +- 保存する位置は全形式でopen時の原本識別情報と結び付ける。保存時にパスを再照合して変更・置換があれば拒否し、復元時は保存済み位置なしと同一性不一致を区別して通知する。完全な内容ハッシュの常時計算は行わない。 + +## フォーカスと開く失敗の受渡し + +一時的な入力欄・操作一覧・ダイアログを開くときは、Main.qmlが元の操作領域を保持する。閉じるときは有効な目次/ページ一覧へ戻し、非表示・空になった領域は本文へ戻す。正常に完了したコマンドが`focus.content`等でフォーカスを指定した場合は、保留していた復帰先を破棄する。形式のcommitや新たなUI操作を追加するときも、後から閉じる入力欄でその指定を上書きしない。 + +`Controller::openPath()`の直接失敗とstagingセッションの失敗は、`recordOpenFailure()`がbasename・有限の原因分類・利用者対処へ変換する。認識しないコードは一般のopen失敗として扱い、任意のworkerエラー本文・内部例外を利用者説明へ流用しない。新しい失敗コードを追加する場合はこの対応も追加する。既存文書の描画やwarningsは維持し、最後の失敗はController内の`lastOpenFailure_`にだけ保持する。`:info`の別項目で表示し、次のopenPath試行で消去する。StateStoreや表示中セッションのmetadataへ保存しない。最近使った文書の欠落・置換検査など、openPath到達前の拒否は各入口の通知で扱う。 + +## 形式を追加する手順 + +1. 新形式の source、位置、能力、必要な読み取り権限、上限を定義する。既存の PDF・Web の描画ポートで扱えるかを判断する。 +2. パーサーと依存ライブラリを専用ターゲットに分離し、未信頼解析はワーカーに置く。ワーカーは許可された入力を開き、OS sandbox の成立後に解析する。保護失敗時の通常権限への再試行を作らない。 +3. IPC 操作を追加する場合は `ipc.cpp` の whitelist、ワーカーの入力スキーマ、GUI の結果スキーマを同時に更新する。ページング、総量、文字列長、数値範囲、取消し時の破棄条件を決める。 +4. Controller の型判定、staging/commit、機能振分け、位置保存を接続する。現在は中央の format 分岐の変更が必要であり、アダプター登録だけでは追加できない。 +5. 新コマンドが必要なら CommandRegistry の ID・引数・能力対応、InputRouter の既定割当、設定例、UI ハンドラーを追加する。解析済み文書からコマンド名や実行コードを注入しない。 +6. Web 資源を使う形式は文書別 origin と ResourceHandler を利用し、外部通信・文書スクリプト・ダウンロードの既定を維持する。展開先は broker 側の ResourceWriter が所有する。 +7. 形式単体、実ワーカー IPC、破損/巨大入力、取消し・文書切替、位置復元、実表示の回帰試験を追加する。依存の固定版と配布告知も更新する。 + +## R09 / 設計書 05 の評価 + +R09 の分離基盤は、ワーカー単位の解析、有限コマンド、能力表現、専用描画ポートとして成立している。一方で、形式追加の登録機構、Qt 非依存モデルを端から端まで通す adapter interface、全操作の統一取消しトークンは未実装である。`DocumentIdentity`、`DocumentLocation`、`ResponseStamp` 等は補助モデルと単体試験にとどまり、runtime の保存・通信は QVariant/QCbor と個別検証を使う。`DocumentCapabilities` の採用だけでこの差分を解消したとは扱わない。 + +UI のモデルは一部が format 分岐に依存する。利用者向け説明はQtの翻訳contextと日本語sourceへ分離したが、IPCは翻訳キーだけを送る統一エラー型ではなく、固定コードと説明文を使用する。Web の再配置には view ごとの serial と JavaScript callback の生存確認があり、設計書の単一 `layoutRevision` モデルとは異なる。これらを拡張時の変更点として扱う。不要な抽象基底を追加して runtime と別の契約を増やすより、次形式の追加時に既存 2 経路をその契約へ移すのが検証可能な手順となる。 + +証跡は `tests/test_pdf.cpp`、`test_pdf_canvas.cpp`、`test_core.cpp`、`test_archive.cpp`、`test_security.cpp`、`test_app.cpp`、`test_web_qml.cpp` と `tests/PDF-VALIDATION.md` にある。合格した個別試験と設計全体の受入完了は区別する。 + +## T19 設計レビュー記録(2026-09-19) + +**判定: 現在の静的組込み方式による接続手順を採用する。** T19 の受入条件である「UI 固有処理にパーサーを埋め込まず、能力・位置・ナビゲーション契約で接続できる」ことは、上記の責務表・操作表・追加手順から追跡できる。第五の形式の実装や、特定の名前を持つ C++ 抽象基底の存在を合否条件にはしない。 + +- 設計書 05 は `FormatAdapter` を言語非依存の論理契約と定義し、PDF タイルと Web 可視域には別の描画ポートを認めている。IPC、専用 Canvas、Web コマンドの組合せでこの操作境界を実現することは実装上の選択である。設計書 02 の初期拡張方式も配布元が組み込むモジュールに限定しているため、動的プラグイン機構は必須ではない。 +- PDFium と libzip は各ワーカー専用ターゲットへ分離され、QML へ形式パーサーを追加する必要はない。新形式は既存の二つの描画ポートを選ぶか、別の専用ポートを定義し、Controller で正規化済みメタデータ、位置復元、ナビゲーションを接続する。 +- 能力は厳密な境界検証と有限コマンドに接続する。位置は PDF 座標または Web の資源・CFI・fragment・progression、ナビゲーションは上限付き索引、非同期応答は要求 ID とセッション世代で区別する。これらの更新が形式追加時の必須作業である。`test_core` の能力スキーマ試験と `test_app` の不正能力での操作拒否試験が、未知の能力を許可扱いしないことを検証する。 +- 設計書 02 が目指す「登録だけで追加する」依存方向には差分が残る。現在は Controller の中央分岐と一部 UI モデルを変更する必要があり、Qt 非依存の位置型を実行時全経路で利用しているわけではない。この差分は拡張時の変更箇所として明示する。T19 の接続可能性の採用を、R09 配下の全ての理想的な構造が実装済みという意味にはしない。 + +以上から、同名基底の不在は単独の未合格理由としない。必須なのは既存文書の挙動、隔離、能力・位置・ナビゲーションの検証を保つ接続経路であり、形式追加時には上記 7 手順と同等の適合試験で再評価する。 diff --git a/docs/design/00-guide.md b/docs/design/00-guide.md new file mode 100644 index 0000000..3cae77e --- /dev/null +++ b/docs/design/00-guide.md @@ -0,0 +1,52 @@ +# DocView アプリケーション設計書 + +設計基準版 1.0 / 2026年9月19日 + +## 目的と設計の要点 + +DocView(仮称)は、PDFを中心にHTML・HTML ZIP・EPUBを読むための、Vim風操作を備えたデスクトップ文書リーダーである。本文を主役にした静かな画面と、キーボードだけで完結する移動を提供する。 + +表示基盤にはQt Quick、PDF描画には独立プロセス内のPDFium、HTML・EPUB表示にはQt WebEngineを採用する設計とする。PDFは紙面を保持し、HTML・EPUBはそれぞれの構造を使って移動する。表示形式の違いを無理に「ページ番号」だけへ統一しない。 + +本版は実装の入力となる設計基準である。動作・性能・互換性の実測結果は含まない。性能数値は受入目標、技術選定は検証ゲート付きの設計判断として示す。プログラム、ビルド環境、試作品は成果物に含まれない。 + +## 文書構成 + +| 文書 | 主な内容 | 主な読者 | +|---|---|---| +| [01 要件・スコープ](01-requirements.md) | 要件ID、優先順位、対応範囲、受入追跡 | 企画・開発・QA | +| [02 全体アーキテクチャ](02-architecture.md) | 構成、責務、処理フロー、拡張点 | 開発 | +| [03 形式別設計](03-formats.md) | PDF、HTML、ZIP、EPUBの取込みと表示 | 開発・QA | +| [04 画面・操作設計](04-ui-navigation.md) | 画面、モード、キー、フォーカス、見出し | 企画・開発・QA | +| [05 データ・インターフェース設計](05-data-contracts.md) | モデル、位置表現、コマンド、IPC、状態遷移 | 開発 | +| [06 設定・保存設計](06-config-storage.md) | TOML、キーバインド、OS別配置、永続化 | 開発・運用 | +| [07 セキュリティ・障害設計](07-security-errors.md) | 信頼境界、入力制限、障害復旧 | 開発・QA | +| [08 性能・試験設計](08-performance-tests.md) | 目標、測定条件、互換性、受入ケース | 開発・QA | +| [09 技術判断・開発計画](09-decisions-roadmap.md) | 選定理由、代替案、検証ゲート、残課題 | 技術責任者 | +| [10 参考資料](10-references.md) | 公式仕様・API・配布資料 | 開発 | + +初読は01→02→04→09の順、実装着手時は03・05・06・07・08を併読する。 + +## 設計の確度 + +| 区分 | 意味 | +|---|---| +| 確定要件 | 原要件、またはデスクトップGUIであるという補足合意 | +| 設計判断 | この版で採用する構成・振る舞い。変更は関連文書と試験を同時に更新する | +| 提案値 | 原要件に数値指定がないため設定した測定可能な初期値 | +| 検証ゲート | 実装開始後に実機・代表文書で確認し、未達なら選定を見直す条件 | + +PDFの再現性、構造タグからの見出し移動、隔離環境の動作、縦書きEPUB、Qt依存物を含めた配布を最初に検証する。これらが未確認の段階で、全PDFへの完全互換や全Linux環境への対応を宣言しない。 + +## 用語 + +- 文書: 開いたPDF、単独HTML、HTML ZIP、またはEPUB。HTMLの内部リンク先も同じ許可ルート内なら同一文書セッションに属する。 +- 目次: PDFアウトライン、EPUB nav/NCX、HTML見出しから構成したナビゲーションツリー。 +- 見出し: 文書構造に記録された節の起点。PDFアウトラインとPDF構造タグは出典を区別する。 +- ページ一覧: PDFの物理ページ、固定レイアウトEPUBのspine項目。流し込み文書では代わりに章一覧を使う。 +- 論理位置: 再描画やウィンドウサイズ変更後も可能な限り再発見できる読書位置。 +- 許可ルート: 文書セッションが参照できるファイル集合の境界。端末全体のファイル権限とは異なる。 + +## 文書管理 + +要件をR01〜R15、設計判断をADR、受入ケースをT、技術検証をGで識別する。要求の変更時は01の対応表から影響をたどる。補助的な機能は原要件と区別し、リリース範囲に入れる判断を記録する。外部資料は確認時点の情報であり、採用時にバージョンとライセンスを固定する。 diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 0000000..2cd6fd0 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,77 @@ +# 01 要件・スコープ + +## 製品の目的 + +PDFの紙面を正確に表示し、HTML・HTML ZIP・EPUBも同じ操作体系で読み進められる、Linux・Windows向けデスクトップGUIを提供する。TUI風とは、本文中心の簡潔な画面、必要時だけ現れる補助表示、Vim風のキー操作を指す。端末エミュレーター内での表示は対象としない。 + +## 要件一覧と設計への対応 + +「必須」は完成版の受入条件であり、段階的な開発順とは区別する。PDF表示を最初に成立させる。 + +| ID | 要件 | 優先度 | 主な設計文書 | 主な受入観点 | +|---|---|---|---|---| +| R01 | PDFを正確にレンダリングする | 最優先・必須 | 02・03・08 | 文字・画像・配置・透明・回転・注釈外観の再現 | +| R02 | ローカルHTMLを表示する | 必須 | 03・07 | HTML・CSS・画像・フォントの参照とリンク移動 | +| R03 | ZIP化HTMLを内部展開しindex.htmlと依存要素を読む | 必須 | 03・07 | 入口解決、安全な展開、相対パス・リンク | +| R04 | EPUBを表示する | 必須 | 03・08 | package/spine/nav、縦書き・RTL・固定レイアウト | +| R05 | キーボード主体・Vim風に操作する | 必須 | 04・05・06 | 開く・読む・移動・設定再読込・終了をキーで完結 | +| R06 | 見出し情報があれば見出しへジャンプする | 必須 | 03・04・05 | 元形式の構造情報から移動、情報の有無を正しく表示 | +| R07 | 目次があれば表示し、項目へジャンプする | 必須 | 03・04 | 階層・現在位置・移動先の整合 | +| R08 | 目次・ページ一覧等を選択表示し、余分なボタンやラベルを置かない | 必須 | 04・06 | 初期の最小表示、表示切替、フォーカス復帰 | +| R09 | 将来の拡張を容易にする | 必須 | 02・05・09 | 形式アダプター・コマンド・能力情報による分離 | +| R10 | キーバインドや表示設定をファイルで変更する | 必須 | 06 | スキーマ、上書き、再読込、エラー時の回復 | +| R11 | 書籍を読み込み滑らかに操作できる | 必須 | 02・08 | 初期表示、キー応答、ページ遷移、メモリの測定 | +| R12 | 少なくともLinux・Windowsで利用する | 必須 | 06・08・09 | 両OS実機で表示・入力・配布を検証 | +| R13 | 設定等をOSごとの慣習に沿って配置する | 必須 | 06 | XDG、Windows Known Folders、非ASCIIパス | +| R14 | PDFの編集・マークアップ・フォーム記入を含めない | 除外 | 03・04・07 | 保存操作やフォーム入力で原本を変更しない | +| R15 | デスクトップGUIで見た目とキー操作をTUI風にする | 確定補足 | 02・04 | GUIで正確な紙面と簡潔な操作画面を両立 | + +R01〜R14の根拠は提供要件「Vim/TUI風文書リーダー」(docview.md)、R15は製品形態の補足合意である。試験ケースへの詳細対応は08に置く。 + +## 初期版の範囲 + +| 項目 | 設計範囲 | +|---|---| +| 実行環境 | Windows 11 x64、Ubuntu 24.04 LTS x64を初期の試験対象とする提案。LinuxのX11・Wayland双方で確認 | +| 文書の開き方 | OSファイル選択、コマンドラインのローカルパス、ドラッグ&ドロップ。1ウィンドウ・1アクティブ文書 | +| PDF | 静的ページ、内部・外部リンク、アウトライン、構造見出し、ページラベル、拡大縮小、回転、暗号化文書のパスワード入力 | +| HTML | ローカル静的HTMLと、同じ許可ルート内の関連HTML・CSS・画像・フォント | +| HTML ZIP | 検証済み一時領域への内部展開、index.html選択、関連資源・複数ページ | +| EPUB | DRMなしのEPUB 2/3。流し込み、固定レイアウト、日本語縦書き、RTL。詳細は03 | +| ナビゲーション | スクロール、ページ/章移動、見出し、目次、リンク、戻る/進む | +| 設定 | TOMLによるキー・配色・補助パネル・文字サイズ・性能上限 | + +## 原要件を補完する設計提案 + +本文検索、読書位置の再開、少数の最近使った文書、エラー詳細表示、簡易ヘルプを提案する。長い書籍をキーボードで読むための補助手段として初期設計に含めるが、R01〜R15を満たすうえで独立した追加要件であり、開発計画上は削減可能である。本文検索は既存の文字情報だけを対象とし、OCRを暗黙に追加しない。 + +アプリの日本語UIを初期値とし、文字列を翻訳可能な資源へ分離する。読書状態・履歴はローカルに保存する。ネットワークアカウントやサーバーは必要としない。 + +## 表示互換性の境界 + +「PDFを正確に表示する」は、対応対象の文書について文字、画像、図形、クリッピング、透明度、ページ寸法・向きが読書に影響する欠落なく再現されることを意味する。代表コーパスと目視による判定を行う。異なる表示器間のアンチエイリアス差をそのまま欠陥と判定しない。 + +電子署名の検証、添付ファイルの実行、動画・3D・JavaScript、動的XFA、DRM解除、印刷色校正、OCRは追加機能として扱い、初期版に含めない。既存の注釈・フォームの静的外観は読書内容なので描画対象とする。検出した未対応機能や読込失敗には警告または開けない理由を示す。検出可能なXFA等は事前分類する。一方、描画APIだけですべての視覚的欠落を実行時検出できるとは想定せず、検出不能な差は代表文書の比較試験と既知制約で管理する。 + +HTML・EPUBの文書スクリプトと外部通信は初期版では無効とする。ローカルにそろった静的コンテンツを対応の中心とし、スクリプトや遠隔資源が必須の資料は互換性制限を表示する。EPUB規格のすべての機能への適合を宣言する設計ではない。 + +## 主要ユースケース + +1. 文書を開く。型と権限を確認し、処理中も画面操作と取消しを受け付け、最初の本文を表示する。 +2. j/k、ページ移動、倍率変更で読む。描画中のページは場所と進捗を示し、操作受付を止めない。 +3. 目次を出し、階層をたどって項目に移動し、本文へフォーカスを戻す。 +4. 見出し単位で進む。目次と本文見出しの出典を区別し、情報がない場合は短く通知する。 +5. 設定ファイルを編集して再読込する。不正なら現在の有効設定を維持し、行・項目・理由を表示する。 +6. 終了して同じ文書を再度開く。提案機能の位置復元が有効なら最後の位置へ戻る。 + +## 設計上の未確定事項 + +| 論点 | 本版の既定案 | 確定時点 | +|---|---|---| +| 配布形態・ライセンス | オープンソース依存の条件を満たす動的リンク配布を候補とする | G0・配布方式決定時 | +| 実際の書籍の種類・最大容量 | 08の代表・負荷コーパスで開始 | G0で実利用文書を追加 | +| PDF構造タグの位置精度 | 公開PDFium APIで見出し→ページ・矩形を抽出 | G0で採否判断 | +| Linuxの配布先拡大・ARM64 | 初期のx64対象とは別の検証枠 | 初期版の品質成立後 | +| 初期画面の配色 | 暗色UI・PDF原色の既定、設定で変更 | 操作レビュー時 | + +これらは設計書作成を妨げる未回答事項ではない。実装時の判断を再現できるよう、既定案・判断時点・影響を記録する。 diff --git a/docs/design/02-architecture.md b/docs/design/02-architecture.md new file mode 100644 index 0000000..628cc7d --- /dev/null +++ b/docs/design/02-architecture.md @@ -0,0 +1,99 @@ +# 02 全体アーキテクチャ + +## 採用構成 + +Qt 6 / C++20 / Qt Quickをアプリケーション基盤とし、PDFは独立したPDFiumワーカー、HTML・EPUBはQt WebEngineで描画する。CMakeで構成し、両OSの依存バージョンを同じリリース単位で固定する。具体的なQtパッチ版・PDFiumコミットは実装開始時に選ぶ。 + +Qt PDFはアプリのPDF処理には使わない。PDFiumの公開APIにアクセスする窓口を一つにし、構造タグの読取りも同じワーカーに閉じ込める。Qt WebEngine内蔵のPDFビューアは無効化する。Qt WebEngine自身の内部依存に含まれるPDFiumまで配布物から除去できるとは想定しない。 + +```mermaid +flowchart TB + UI[Qt Quick Reader Shell] --> APP[Application Services] + APP --> BROKER[Main Process / File Broker] + BROKER --> PDF[Sandboxed PDFWorker / PDFium] + BROKER --> ARCHIVE[Sandboxed ArchiveWorker] + BROKER --> WEB[Qt WebEngine / Isolated Content] + APP --> STORE[Config and State Store] + PDF --> UI + ARCHIVE --> BROKER + BROKER --> ROOT[Document Scoped Resources] + ROOT --> WEB +``` + +矢印は論理的な要求・応答を表す。PDFWorker・ArchiveWorkerは別OSプロセスであり、Qt WebEngineは独自のマルチプロセス構成を持つ。主プロセスから子プロセスを起動するだけではOSによるアクセス制限は成立しないため、07の隔離条件を別途満たす。 + +## 責務分割 + +| 層・要素 | 責務 | 保有しない責務 | +|---|---|---| +| Reader Shell | 本文領域、目次、一覧、ステータス、コマンド欄、アクセシブル名 | 形式固有の解析、任意ファイル読取り | +| Input Router | モード・フォーカス・IMEを考慮したキー解釈、コマンド生成 | PDFページ番号への直接変換 | +| Document Controller | セッション、遷移、位置、戻る/進む、エラーの統括 | 描画エンジンの内部API露出 | +| Navigation Service | アダプターの目次・見出し・リンクを共通モデルへ変換 | 見た目だけによる見出しの推測 | +| Render Scheduler | 可視域優先、先読み、取消し、キャッシュ、世代管理 | ファイルの権限判定 | +| Format Adapter | PDF/HTML/EPUB固有の能力・位置・描画の変換 | 他形式の特例をUIへ要求すること | +| File Broker | 開く操作で許可されたファイル、範囲読取り、仮想資源の提供 | PDF/XML/HTML/画像の主プロセス内解析 | +| PDFWorker | PDFium初期化、文書解析、ページ描画、文字・構造・リンク抽出 | 外部通信、原本文書の書込み | +| ArchiveWorker | ZIP検証・展開、EPUB XML解析、文書資源索引 | 任意パスへの書込み、外部実体参照 | +| Web Content Host | docスキーム、通信遮断、DOMナビゲーション、本文表示 | アプリ全体の権限や設定へのアクセス | +| Settings / State Store | 検証済み設定とローカル読書状態の保存 | 文書由来スクリプトの評価 | + +依存の向きはUI→アプリケーション→抽象インターフェース。形式固有コードは抽象インターフェースを実装する。PDFium、WebEngine、OS APIはそれぞれの境界内へ閉じ込める。 + +## 文書を開く流れ + +1. UIがOSファイル選択または明示されたローカルパスをFile Brokerへ渡す。Brokerは通常ファイル・権限・実体パス・容量を確認する。 +2. 拡張子と先頭の限定的なシグネチャを照合する。ZIPとEPUBの詳細判定はArchiveWorkerで行う。HTMLは拡張子/MIMEと文字コード情報から扱い、文書本文を主プロセスで解釈しない。 +3. 新しいsessionIdとgenerationを発行する。既存の文書は新文書の入口検証が済むまで保持し、失敗時に元の位置へ戻れるようにする。 +4. PDFは読取り専用ハンドルまたは範囲読取りチャネルをPDFWorkerへ渡す。HTMLは選択ファイルの親ディレクトリを許可ルート候補にし、ZIP/EPUBは検証済みのセッション用資源集合を構築する。 +5. 最初に必要な本文と最低限のメタデータを要求する。全ページのサムネイル、全文字抽出、全ファイルのハッシュ計算が終わるまで本文表示を待たせない。 +6. 初期本文が表示できた時点でReadyへ移行し、目次・見出し・先読みを低優先で進める。準備途中の目次は「読込中」とし、「目次なし」と区別する。 +7. 新文書のコミット後に旧セッションを破棄し、旧要求・共有メモリ・一時資源を解放する。旧generationの応答は採用しない。 + +複数文書を常駐させない。切替中の旧文書保持は一時的な例外とし、メモリ圧迫時は最後の表示画像と復帰位置だけを保つ。 + +## 描画・スケジューリング + +PDFiumのAPI呼出しはワーカー内で直列化する。複数スレッドから無保護で呼び出さない。最初はアクティブ文書につきPDFWorker一つとし、追加並列化は性能実測後の変更とする。PDFiumのスレッド条件は[公開API](https://pdfium.googlesource.com/pdfium/+/refs/heads/main/public/fpdfview.h)を参照する。 + +優先度は「新しい可視ページ→可視ページの高解像度化→移動方向の隣接ページ→逆方向→サムネイル→検索索引/構造抽出」。先読みはまず前後1ページまでとし、キャッシュ余裕とキー操作から調整する。解析中にUIスレッドを同期的に待たせない。 + +拡大したページは512×512デバイスピクセルのタイルを基本とし、外周に2ピクセルの描画余白を持たせて表示時に切り落とす。CropBox、回転、倍率、DPRを含む変換行列をタイルとリンク・文字の当たり判定で共有する。タイルの継ぎ目・端の欠落は08の必須試験で確認する。 + +倍率変更中は既存画像を暫定拡大し、新しい倍率で再描画する。旧倍率の応答が新しい画面を上書きしないようgenerationとrenderRevisionを検証する。リサイズだけでは全文書を再解析しない。 + +PDFの段階描画を利用できる処理は短い区間で取消しを確認する。中断不能なデコードや解析はワーカー監視の期限で扱い、UI側の取消し完了とワーカーの実処理終了を同一視しない。 + +## キャッシュと資源管理 + +| 資源 | 方針 | 初期提案 | +|---|---|---| +| PDF画像タイル | バイト数基準LRU、可視タイル優先 | 256 MiB、設定可能 | +| PDFページオブジェクト | 現在・前後を保持し不要分を閉じる | 前後1ページ | +| サムネイル | パネル表示時に可視行から作る | 64 MiB以内で画像予算と調整 | +| WebEngine | 文書セッション用off-the-record profile | 永続Cookie・遠隔cacheなし | +| ZIP展開 | 安全検証済み専用ディレクトリ、一時資源 | 上限は03・07 | +| 読書状態 | 小さなJSON、画像や本文は保存しない | 06を参照 | + +PDFの画像キャッシュ予算はアプリ全体のRSS上限を意味しない。WebEngine、GPU、パーサー、一時バッファは別に消費するため、08で子プロセス込みの総使用量を測る。メモリ圧迫時は先読み停止→不可視キャッシュ解放→解像度抑制の通知→文書処理停止の順で対応する。 + +## 拡張の契約 + +新形式の追加はFormatAdapterの実装、型判定の登録、能力情報、位置型の追加、共通適合試験への登録で行う。UIは能力情報を見てページ一覧・見出し・検索の可否を決める。画面内へ形式名による条件分岐を散在させない。 + +キーはCommand Registry内の安定したcommandIdに対応する。新しい操作は同じ入口へ登録し、既定キーがない操作もコマンド欄から実行できる。設定ファイルはスキーマに定義された値だけを受け付ける。 + +初期版の拡張は配布元がビルド時に組み込むモジュールに限る。第三者の任意コードを読み込むプラグイン機構は将来設計とする。将来導入する場合も、文書がプラグインや設定を自動的に選択・実行することは許可しない。 + +## 主な依存とビルド境界 + +| 部分 | 採用案 | 管理単位 | +|---|---|---| +| UI・共通基盤 | Qt Core / Gui / Quick / Qml / Quick Controls、C++20 | 同一Qtリリース | +| HTML・EPUB本文 | Qt WebEngine / WebEngineQuick | Qtと整合したChromium依存 | +| PDF | PDFium公開C API、V8/XFA無効ビルドを基本 | 固定コミット・ビルドオプション | +| ZIP・XML | 維持管理されているライブラリをArchiveWorkerへ組込み | G0でZIP64・文字コード・制限設定を確認し固定 | +| 設定 | TOML 1.0互換パーサー、独自スキーマ検証 | parser版とschema_versionを別管理 | +| IPC | 長さ付きCBORメッセージと制御された共有バッファ | protocolVersion | + +ZIP・TOML等のライブラリ名は、制限付き処理・ライセンス・更新頻度を確認してG0で固定する。ライブラリ選定未了でも、受け入れるデータと制限は03・05・06・07で固定する。 diff --git a/docs/design/03-formats.md b/docs/design/03-formats.md new file mode 100644 index 0000000..c63c4e3 --- /dev/null +++ b/docs/design/03-formats.md @@ -0,0 +1,159 @@ +# 03 フォーマット・取込み設計 + +DocView アプリケーション設計書 | 版 1.0 | 2026-09-19 + +対象要件: R01-R04、R06、R07、R09、R11、R14。 + +## 1. 対応方針 + +PDFを最優先とし、PDFiumを呼ぶ専用PDFWorkerで描画する。HTMLとEPUBの本文はQt WebEngineで描画する。TUI風の配色・枠線・文字中心の操作UIはアプリの領域に適用し、PDFや著者指定の本文レイアウトを変更しない。 + +| 形式 | 初期リリースの対応範囲 | 制約の伝え方 | +|---|---|---| +| PDF | 読取り専用表示、拡大縮小、回転、ページ一覧、outline、リンク | 対応限界・破損を文書情報とエラーで明示 | +| 単独HTML | ローカルHTMLと許可された文書フォルダー内のCSS・画像・font・リンク | 外部依存資源とスクリプト依存部分は表示できない旨を通知 | +| ZIP化HTML | `index.html` と相対参照資源を内部展開して表示 | 入口不明時は候補選択。危険なアーカイブは取込み中止 | +| EPUB 2 / 3 | DRMのない静的本文、NCX/nav、spine、縦書き、RTL、リフロー、固定レイアウト | 音声・動画・media overlays・動的対話は初期版対象外 | + +静的閲覧、DRM非対応、音声・動画非対応は設計上の初期範囲であり、原要件から直接指定された制限ではない。EPUBの全機能適合を宣言するものではない。PDFの編集、注釈追加、フォーム入力、保存による文書変更は行わない。既存の可視要素の表示は、編集機能とは分けて扱う。 + +## 2. 共通の取込み手順 + +1. MainのFileBrokerが利用者の選んだファイルを読取り専用で開く。拡張子と固定長のシグネチャを候補判定に利用するが、複雑な内容解析は行わない。 +2. PDFはPDFWorker、ZIP/EPUBはArchiveWorkerへ読取り専用handleを渡す。単独HTMLは許可rootと資源IDを登録する。形式の確定は担当の解析器で行う。 +3. Workerが返すメタデータは、Mainでも型・長さ・件数・参照IDを検証する。Workerは信頼境界の内側ではない。 +4. 新しい文書セッションに世代番号と推測困難なtokenを割り当てる。1ウィンドウのアクティブ文書は1つとする。切替候補の入口検証中は旧文書を維持し、新文書のコミット時に旧tokenと旧要求を失効させる。 +5. 最初の表示位置に必要な情報・資源を優先して読み込む。目次とページ一覧は段階的に更新する。取込み・描画は取消可能とする。 +6. 正常終了、文書切替、取消、失敗のすべてでhandle、共有画像、仮想URL、展開資源を解放する。画面に残っている旧文書の結果を新文書へ混ぜない。 + +位置とナビゲーションの形式非依存モデルは [データ・内部契約設計](05-data-contracts.md)、隔離と入力制限は [セキュリティ・異常系設計](07-security-errors.md) に定義する。 + +## 3. PDFの表示契約 + +### 3.1 正確性 + +PDFのページ寸法、CropBox/MediaBox、ページ回転、font、文字配置、画像、透明度、クリッピング、注釈外観を尊重する。座標はPDF文書座標を基準に保持し、device pixel ratio、ズーム、表示回転を一つの変換で適用する。CropBox左下が原点でないページ、文書のRotateと利用者の回転が重なるページも同じ変換行列で扱う。リンク矩形と文字選択の当たり判定はその逆行列を利用する。ページ本文をHTMLへ変換して表示しない。 + +画面は必要解像度のページ/タイル画像を使用する。倍率は論理画面上で計算し、DPRを一度だけ掛けて描画バッファ寸法を得る。低解像度の暫定画像を拡大表示している間は置換用の再描画を要求する。文字・図形の欠落、既存注釈の欠落、透明度の破綻は「表示できた」と判定しない。注釈の静的外観とフォームwidget外観は必要な描画経路を分けて確認し、編集を無効にしただけで外観も消える状態を避ける。具体的な評価方法は試験設計とPDF技術判断に従う。 + +未埋込みfontにはOS差が残り得る。代替fontの集合と探索順を管理し、両OSの比較対象に含める。任意PDFに対する完全一致を保証せず、検証済みの互換性範囲をリリースごとに記録する。 + +### 3.2 注釈・フォーム外観・フォント + +通常のページ描画へFPDF_ANNOTを付けるだけではwidgetとpopupは描画されない。保存済みフォーム外観は、PDFiumのform environmentを用いるFPDF_FFLDraw系の描画経路を別途用意し、ページ本文と同じ回転・縮尺・タイル原点へ合わせて合成する。form environmentの初期化、ページ読込/破棄通知、終了処理はワーカー内で管理する。フォーム入力、action実行、JavaScript、保存APIは公開しない。タイル描画とフォーム描画の座標が一致しない場合は、可視領域単位の合成方式へ修正する。[PDFium描画API](https://pdfium.googlesource.com/pdfium/+/main/public/fpdfview.h)、[フォーム描画API](https://pdfium.googlesource.com/pdfium/+/main/public/fpdf_formfill.h) + +既存外観が欠落したフォームは、暗黙にユーザー入力や文書保存で補修せず、表示できない可能性を通知する。popup内の注釈本文は選択時の読取り専用補助表示として扱い、紙面外に隠れたコメントの存在を見落とさない。静的注釈、widget、popup、appearance欠落をT18とG-RENDERで個別確認する。 + +埋込fontを優先し、未埋込みfontは許諾を確認した同梱候補と明示的なOS font索引を用いる。OS font索引はBrokerが管理し、PDFWorkerへは選択済みfontの読取り専用handleまたはバイト列だけを渡す。font探索目的でWorkerの任意パスアクセスを許可しない。共通font集合を固定しても、元fontが欠けた文書の完全一致を保証するものではない。 + +### 3.3 ナビゲーション + +- PDF outlineは目次として保持し、明示destinationsとnamed destinationsをページ位置へ解決する。 +- タグ構造に有効なH/H1-H6があれば見出しジャンプを提供する。矩形まで解決できれば見出し位置、ページだけ解決できる場合はページ先頭へ移動し精度を表示する。見出しからページへの対応も解決不能なら該当項目に理由を付け、全体を「見出しなし」としない。座標が得られずページだけ分かる場合はprecision=pageで移動する。有効な構造見出しがない場合は文書内宛先を持つoutlineを代用し、「目次項目移動」と表示する。目次そのものはoutlineを使用する。文字サイズだけから推測した見出しは初期版で生成しない。 +- ページ番号は内部の0始まりindexと画面の1始まり表示を分ける。文書のPageLabelsがあれば併記し、`iv` や `1` と物理ページ番号を混同しない。 +- ページ内リンクは現在の文書へ移動する。外部HTTP(S)リンクは宛先を提示し、利用者の明示操作で既定ブラウザーへ渡す。Launch action、JavaScript action、自動外部起動は実行しない。 +- パスワードが必要な文書は専用入力を求め、値はメモリーだけに保持する。DRM等の未対応保護や未対応機能は理由を返す。 + +## 4. 単独HTMLとZIP化HTML + +### 4.1 入口の選択 + +単独HTMLは選んだファイルを入口、その親ディレクトリーを初期rootとする。`../assets` 等でroot外の資源が必要な場合、許可範囲を自動拡大しない。「文書フォルダーを指定」で利用者が共通親を指定した場合だけrootを変更する。ブラウザーには絶対ローカルパスを渡さない。 + +ZIPの入口は次の順序で決める。 + +| 条件 | 動作 | +|---|---| +| root直下に完全一致の `index.html` がある | 採用 | +| rootにはなく、共通トップディレクトリー直下に `index.html` が一つある | 採用 | +| 上記以外でHTML候補がある | 相対パス一覧からキーボードで選択 | +| HTML候補がない | `E_HTML_ENTRY_MISSING` | + +`index.htm`、大小文字違いの名称、複数の深いディレクトリー内のindexは候補一覧で扱い、勝手に最初の候補へ決めない。選択結果は文書fingerprintと関連付ける。ZIPでは入口が深い場所にあってもZIP全体をrootとし、正当な相対参照を維持する。 + +### 4.2 内部展開 + +ArchiveWorkerがZIP索引を解析し、安全検査後に必要資源をストリーム展開する。Mainはエントリー記述を再検証し、専用領域へのhandle単位の書込みと実バイト数の上限監視だけを担当する。ZIP/XMLパーサーをMainへ持ち込まない。 + +最初に入口HTMLを、その後はWebEngineから要求されたCSS、画像、font等を展開する。CRCと上限の検査を終えた資源だけを確定して公開する。展開中の不完全ファイルを描画エンジンへ渡さない。全アーカイブを一括でメモリーへ展開せず、内側のZIPも自動展開しない。 + +### 4.3 URLと依存資源 + +資源URLは `doc:///` とする。`QWebEngineUrlScheme::Syntax::Host` を使い、文書ごとのhostをoriginとする。URLは当該文書のResourceBrokerだけが解決できる。 + +| 入力 | 解決規則 | +|---|---| +| HTMLのsrc/href/srcset | 当該HTMLのURLを基準 | +| CSSの `url()` / `@import` | 当該CSSのURLを基準 | +| font-face、SVG資源 | 対応する資源の基準URLと同じ規則 | +| `#fragment` | 現在の資源内の移動先 | +| 相対URLの `..` | URL解決後にroot内なら許可 | +| query | URL情報として保持し、OSファイル名へ直接連結しない | +| 別origin、file URL、UNC、未知のスキーム | 読込みを拒否 | + +percent-encodingは一箇所で一度だけ復号する。符号化された区切り・NUL・二重復号に依存するパスを拒否し、ZIPエントリー名の `%` を索引作成時にURL復号しない。HTMLの `base` は同一文書root内に解決できるものだけ許可する。外部baseはCSPと資源gateで拒否し、影響する依存資源の欠落を通知する。黙って別の場所へ読み替えない。 + +HTML、CSS、SVGの解析はQt WebEngineの描画側で行う。Mainの資源ハンドラーは内容を書き換えるためのHTML/CSSパーサーを持たない。文書タイトルや目次ラベルをアプリUIへ渡す際は文字列として表示し、HTMLとして再解釈しない。 + +### 4.4 本文の静的閲覧 + +文書JavaScript、form送信、meta refreshによる自動移動、外部iframe、外部画像・CSS・fontを無効にする。ローカルの許可資源は表示する。欠落画像・fontは代替表示、CSS欠落は本文を維持した警告とする。制限の詳細は文書情報から確認できる。 + +一般のHTMLメニューバーを自動で目次と決め付けない。`role=doc-toc` 等の意味が明確なnavを目次として読み、それ以外はh1-h6と有効なARIA見出しから「見出し一覧」を作る。見出しがないときは情報なしを示す。文書にIDを恒久追加せず、索引側に一時識別子を持つ。 + +## 5. EPUB + +### 5.1 packageと読書順 + +ZIPの安全検査後、ArchiveWorkerが `mimetype`、`META-INF/container.xml`、OPF、manifest、spineを解析する。XMLは非検証パーサーとし、外部DTD・外部実体・実体の膨張を禁止する。先頭rootfileを既定packageとし、他のrenditionの存在もメタデータに記録する。 + +通常の次/前移動は `linear != no` のspine順で行う。`linear=no` は本文リンクや目次から到達でき、ジャンプ履歴で元へ戻れる。manifestの未知媒体には定義済みfallbackを辿り、循環参照と深さを制限する。利用可能なfallbackがなければ該当章をエラーにし、他の章へ移動可能にする。 + +### 5.2 目次・章・ページの区別 + +| 優先順/情報 | 使用方法 | +|---|---| +| EPUB 3 navのtoc | 著者の目次として使用 | +| EPUB 2 NCX、またはEPUB 3 nav破損時のNCX | 目次として使用。破損fallback時は通知 | +| spineから生成する章一覧 | 目次がない場合の補助。「章一覧」と明示 | +| XHTMLの見出し | 見出しジャンプ用。章を開く際に段階的索引化 | +| navのpage-list | 出版物のページラベルとして保持 | +| landmarks | 補助ナビゲーションとして保持 | + +著者のページラベル、固定レイアウトのページ、リフロー後の画面単位は異なる。リフロー表示では章内進捗を標準とし、画面番号を本の確定ページ番号として保存しない。navとspineの順序が違っても書き換えず、目次操作はnav、通常読書はspineに従う。 + +### 5.3 縦書き・RTL・固定レイアウト + +CSSの `writing-mode`、`text-orientation`、`dir`、rubyを尊重する。`page-progression-direction` はページの進行方向であり、本文の文字方向と別に扱う。「次の読書位置」は論理方向で進め、h/j/k/lによる物理スクロールと分ける。 + +リフロー型は表示幅・文字サイズ変更後に再レイアウトし、保存した論理位置へ戻す。固定レイアウト型はXHTML viewportまたはSVG viewBoxを基準に拡縮し、本文fontサイズや行幅を強制しない。spine一項目を一ページとして扱い、見開き・RTL・項目ごとのrendition上書きを反映する。混在書籍は章の切替時に表示方式も切り替える。 + +### 5.4 位置の復元 + +保存情報はDocumentIdentityの内容識別情報と `packagePath / spineIdref / href / fragment / cfi / progression / textQuote` を基礎とする。対象packageとspineを確定した後の復元順はCFI、ID/fragment、textQuote、章内progression。CFIが壊れていても他の情報から近似復元し、近似であることを通知する。文書fingerprintが変わった場合は一致を確認してから古い位置を使う。 + +CFI解決と見出し抽出は、原文のDOM構造を基準にする。読書補助の要素を本文DOMへ挿入してCFIの番地を変えない。アプリが管理するUIはQt Quick側へ置く。fontや画像の読込みで位置が動く場合は、レイアウト確定後に同じ論理位置へ再配置する。縦書き・RTLのprogressionは論理読書方向へ正規化する。 + +### 5.5 保護と破損 + +`encryption.xml` があるだけでDRMと断定しない。IDPFの標準font難読化を認識して描画用に復元する。それ以外の暗号化方式、保護された本文、パスワード付きZIPは初期版非対応とし、`E_DRM_UNSUPPORTED` または `E_ARCHIVE_ENCRYPTED` を返す。 + +container/OPFが読めず読書順を確定できないときは開かない。nav破損はNCX・章一覧へfallbackする。spineの一部欠落は欠落章を明示して他の章を読めるようにする。正式な解析に成功していないEPUBを、ZIP内の最初のHTMLを開くだけで成功扱いにしない。 + +## 6. Qt WebEngineによる制御 + +`JavascriptEnabled=false` でMainWorldの文書スクリプトを停止する。アプリ所有の固定コードは `ApplicationWorld` に置き、DOMの見出し取得、CFI解決、表示位置測定、スクロールを行う。Qtのworld分離ではDOMへアクセスできる一方、JavaScript変数はworld間で共有されない。[QWebEngineSettings](https://doc.qt.io/qt-6/qwebenginesettings.html)、[QWebEngineScript](https://doc.qt.io/qt-6/qwebenginescript.html) + +Qt Quickの `WebEngineView.runJavaScript(script, worldId, callback)` でworldを必ず指定する。文書文字列をコードへ連結せず、検証済みの型付きデータとして渡す。非同期callbackには要求ID・文書世代番号を関連付け、古い結果を捨てる。MainWorld無効化とApplicationWorld実行の組合せは、採用するQt版のG0検証で確認する。[WebEngineView](https://doc.qt.io/qt-6/qml-qtwebengine-webengineview.html) + +本文WebEngineViewには汎用QWebChannelやOS APIを公開しない。設定ファイルから任意JavaScriptを実行する機能も初期版には設けない。位置・見出しの測定結果も未信頼データとしてMainで検査する。 + +## 7. 参照資料 + +確認日: 2026-09-19。規格の要点を基に、DocView独自の静的閲覧プロファイル・制限・回復動作を設計した。 + +- [EPUB 3.3](https://www.w3.org/TR/epub-33/) - package、spine、nav、rendition、font難読化。 +- [EPUB Reading Systems 3.3](https://www.w3.org/TR/epub-rs-33/) - rootfile選択、file URL、XML処理、script fallback。 +- [EPUB CFI 1.1](https://idpf.org/epub/linking/cfi/epub-cfi.html) - EPUB内の論理位置の表現。 +- [QWebEngineUrlScheme](https://doc.qt.io/qt-6/qwebengineurlscheme.html) - Host構文とorigin。 +- [QWebEngineUrlSchemeHandler](https://doc.qt.io/qt-6/qwebengineurlschemehandler.html) - 独自スキームの資源応答。 diff --git a/docs/design/04-ui-navigation.md b/docs/design/04-ui-navigation.md new file mode 100644 index 0000000..d70e374 --- /dev/null +++ b/docs/design/04-ui-navigation.md @@ -0,0 +1,232 @@ +# 04 画面・操作設計 + +## 1. 設計方針 + +本文を中心にしたデスクトップGUIとし、アプリの周辺表示に等幅文字・細い区切り線・キーボード中心の操作を使う。PDFの紙面は文字端末に置き換えず、原文の色・文字・図形を保持して描画する。 + +R01〜R14は提供要件、R15は「デスクトップGUIで、見た目とキーボード操作をTUI風にする」という確定補足に対応する。本書の具体的なキー割当、初期レイアウト、タイムアウトは設計提案であり、利用者から指定された固定値ではない。要件一覧は01、位置・能力・コマンドのデータ契約は05、設定スキーマは06に置く。 + +初期版は1ウィンドウ・1アクティブ文書とする。ファイルはOSの選択画面、起動引数のローカルパス、ドラッグ&ドロップから開ける。Qt Quick側がUIとキーモードを管理し、PDFiumの表示結果とQt WebEngineの文書内容へ操作を振り分ける。文書内スクリプトにアプリのキー操作を管理させない。 + +## 2. 画面構成 + +### 2.1 初期画面 + +文書未選択時は、中央に「Ctrl+O: 文書を開く」「?: 操作一覧」の2項目を表示する。メニューバー、ツールバー、常時表示の装飾ボタンは置かない。OSのウィンドウ枠は通常どおり使用する。画面の余白、文字、区切り線は設定テーマの色を使う。 + +### 2.2 閲覧画面 + +```text +┌─ 目次 ─────────┬──────────────────────────────────────┐ +│ 第1章 │ │ +│ > 第2章 │ 原文どおりの文書描画領域 │ +│ 2.1 │ │ +│ 2.2 │ │ +├─ ページ一覧 ────┤ │ +│ 12 │ │ +│ >13 │ │ +│ 14 │ │ +├────────────────┴──────────────────────────────────────┤ +│ book.pdf 13 / 248 125% 内容 │ +└───────────────────────────────────────────────────────┘ +``` + +図はすべての領域を表示した例。初期値では**文書領域と1行のステータス行だけ**を表示し、目次とページ一覧は隠す。目次とページ一覧は独立に表示・非表示を切り替えられる。両方を表示した場合は左側の上下に配置し、片方だけなら左側の高さ全体を使う。各幅・高さの下限を確保し、小さいウィンドウではパネルを自動的に増やさない。 + +- **文書領域**: PDFのページは連続縦スクロールを初期値とする。ページの背景色や埋込フォントは文書どおりに描く。アプリのテーマを変更しても文書の色を勝手に反転しない。 +- **目次パネル**: 項目を階層表示する。現在位置を含む最も近い項目を示す「現在位置」と、キーボードで選択中の行を示す「選択位置」を区別する。選択を動かすだけで本文を移動させない。 +- **ページ一覧パネル**: PDFは実ページ番号の一覧を基本とする。ページラベルがある場合は「付録-3(実ページ 213)」のように表示できる。サムネイルは必須にせず、将来追加候補とする。HTMLには表示しない。固定レイアウトEPUBではspine項目のページ一覧、リフローEPUBではpage-listがあれば出版物由来のページ参照、なければspine由来の章一覧を表示する。見出しは「ページ一覧」「出版物のページ」「章一覧」と内容に合わせる。 +- **ステータス行**: ファイル名、位置、ズーム倍率、フォーカス領域を簡潔に表示する。PDFは実ページ番号/総ページ数、HTMLは見出し名または「文書」、EPUBは章位置を表示する。HTMLやリフローEPUBに固定総ページ数を捏造しない。非表示にできる。 +- **通知**: 文書読込中・エラー・利用できない操作は短い通知として表示する。通常の閲覧時に通知領域の高さを占有しない。自動消去される通知と、明示的な対処を要するエラーを区別する。 + +ページ一覧・目次が提供されない文書で対応キーを押した場合は「この文書にはページ一覧がありません」等を表示する。空のパネルを常設しない。マウスによるスクロール、リンク選択、パネル操作も可能とするが、主要操作はすべてキーボードで到達できる。 + +### 2.3 読込と異常時 + +1. 文書を開く操作から、OS標準のファイル選択を表示する。 +2. ファイル確定後、読込状態を即座に表示する。目次や総ページ数の確定を待ってから初めて画面を更新する実装にしない。 +3. 最初に必要なページ・章を表示し、他ページや目次は準備できた時点で反映する。 +4. 読込が失敗した場合は、対象名、原因の分類、利用者が可能な対処を示す。内部例外・スタックトレースは画面に出さない。 +5. 既存文書から別文書への切替では、新文書の最低限の表示準備が完了するまで旧文書を保持する。失敗時は旧文書へ戻る。 + +PDFパスワード入力等の文字入力が必要な画面は、閲覧キーを奪わない専用ダイアログにする。対応暗号方式・パスワード保持方針は03・07に従う。 + +## 3. 操作状態とフォーカス + +Vimの通常モードに相当する閲覧操作を採用するが、文書編集用の挿入モード・ビジュアルモードは作らない。 + +| 状態 | 入力の扱い | 遷移・終了 | +|---|---|---| +| 内容閲覧 | 文書用キー、リンクのTab移動、マウススクロール | パネルへフォーカス移動、ファイル選択等 | +| 目次フォーカス | 行移動・階層開閉・選択確定 | Enterで本文へ移動して内容に戻る、Escで内容へ戻る | +| ページ一覧フォーカス | 行移動・選択確定 | Enterで対象へ移動して内容に戻る、Escで内容へ戻る | +| キー列待機 | `g`、`z`、`[`、`]`、`Ctrl+W`、数値カウントの後続を待つ | 完成で実行、Escで破棄、期限切れで破棄 | +| 操作一覧表示 | スクロール・閉じる操作のみ | Escまたは?で閉じて元のフォーカスへ戻る | +| コマンド入力 | `:`から入力欄を表示し、文字入力・IME・左右移動・削除を優先 | Enterで検証して実行、Escで取消して元のフォーカスへ戻る | +| ダイアログ/文字入力 | OS標準の編集操作とIMEを優先 | 確定・取消で元のフォーカスへ戻る | +| 読込中 | UIは応答し、不要になった読込の取消または別文書選択を可能にする | 成功、失敗、取消 | + +### 3.1 キー処理の優先順位 + +1. OSまたはネイティブダイアログが占有する操作。 +2. IMEの変換・確定、ダイアログ内の文字入力と標準編集操作。 +3. 最前面にある操作一覧等の一時表示。 +4. 現在のフォーカス領域に限定された割当。 +5. アプリ共通割当。 + +`Ctrl+O`等の共通操作も、文字入力中・IME変換中に無条件で横取りしない。少なくとも `isComposing` 相当の状態にあるキーイベントは閲覧コマンドへ配送しない。コマンド入力、ファイル選択、パスワード入力、OSの標準ダイアログでは、`j`、`k`、数字、`?`等を通常の文字として入力できる。 + +### 3.2 フォーカス規則 + +- 文書を開き終えたときは内容にフォーカスする。 +- パネルを表示しても直ちに本文位置やフォーカスを変更しない。 +- フォーカス中のパネルを隠した場合は内容に戻す。 +- `Ctrl+W`の後に`w`を押すと「内容→目次→ページ一覧→内容」の順に循環する。非表示領域は飛ばす。 +- リンクの `Tab` / `Shift+Tab` は内容領域内のリンクを順に選択する。リンクがない場合は本文位置を変えず、内容にフォーカスを残す。パネル間の移動は専用キーで行う。 +- PDF内・HTML内・EPUB内の読取専用文書要素は、アプリの通常操作モードを勝手に切り替えない。フォーム記入や文書内スクリプトによるキーフックは設けない。 +- フォーカス枠、選択行、現在位置は色だけに依存せず、線・記号・濃淡で区別する。 + +## 4. 既定キーバインド案 + +キー表記は論理キー名を使う。Shiftを要する大文字は別のキーとして扱い、日本語配列/英語配列で文字を入力する物理キーが異なっても、設定上は `?`、`+` 等の文字で識別する。OSが予約するキーは上書きできない場合があるため、設定チェックと操作一覧で知らせる。 + +### 4.1 内容領域 + +| キー | 動作 | 境界・備考 | +|---|---|---| +| `j` / `k` | 小さく下/上へスクロール | 設定可能な移動量。キーリピートに対応 | +| `h` / `l` | 横方向にスクロール | 横の余りがない場合は何もしない | +| `Ctrl+D` / `Ctrl+U` | 表示領域の半分だけ進む/戻る | PDFは下/上。HTML/EPUBは書字方向に沿った読書軸を使う | +| `Ctrl+F` / `Ctrl+B` | 表示領域のほぼ1画面分進む/戻る | 読書軸に沿って移動し、少量の重なりを残す | +| `gg` / `G` | 文書の先頭/末尾へ | EPUBは読書順序の先頭/末尾。パネルでは先頭/末行 | +| `数字G` | PDFの指定実ページへ | 例: `42G`。HTML/EPUBでは利用不可と通知 | +| `J` / `K` | 次/前のPDF実ページへ | PDFのみ。移動先ページの先頭へ | +| `]]` / `[[` | 次/前の見出しへ | 見出し情報がある場合のみ | +| `]c` / `[c` | 次/前のEPUB章へ | EPUBのspine順に移動。非linear項目は通常順送りから除外 | +| `+` / `-` | 拡大/縮小 | PDFは表示倍率、HTML/リフローEPUBはズーム。原文は変更しない | +| `=` | 幅に合わせる | PDFまたは固定レイアウトに適用。リフロー形式では標準倍率へ戻す | +| `Tab` / `Shift+Tab` | 次/前のリンクを選ぶ | リンク数が多い場合もスクロール位置を選択先へ追従 | +| `Enter` | 選択リンクを実行 | 文書内移動・別文書/外部リンクの扱いは基盤仕様に従う | +| `Esc` | 未完成のキー列またはリンク選択を解除。読込・索引待機中は要求を取り消す | 文字入力/IMEの取消を優先し、文書自体は閉じない | + +このキー案では `Ctrl+F` をVim流の画面送りに使う。追加提案である本文検索は `/` で開始する。原要件を満たす基本操作と、検索・位置復元等の補助機能を区別する。 + +### 4.2 目次・ページ一覧 + +| キー | 目次 | ページ一覧 | +|---|---|---| +| `j` / `k` | 次/前の可視行 | 次/前の行 | +| `h` | 展開中なら折りたたみ、そうでなければ親へ | 何もしない | +| `l` | 子があれば展開、展開済みなら最初の子へ | 何もしない | +| `gg` / `G` | 先頭/末尾の可視行 | 先頭/末尾の行 | +| `Ctrl+D` / `Ctrl+U` | 半画面分の行を移動 | 同左 | +| `Enter` | 選択項目へ移動 | 選択ページへ移動 | +| `Esc` | 内容へフォーカスを戻す | 同左 | + +### 4.3 共通操作 + +| キー | 動作 | +|---|---| +| `Ctrl+O` | 文書を開く | +| `t` | 目次の表示/非表示を切り替える | +| `p` | ページ一覧の表示/非表示を切り替える | +| `zs` | ステータス行の表示/非表示を切り替える | +| `Ctrl+W` `w` | 表示中の領域間でフォーカスを移す | +| `:` | コマンド入力欄を表示する | +| `?` | 現在の設定を反映した操作一覧を表示する | + +`t`、`p`、`J`、`K`には文書リーダー独自の役割を割り当てる。Vimの完全互換ではないことを操作一覧で示す。`q`単押しで終了する操作は誤操作を避けて既定に含めず、OS標準のウィンドウ終了を使う。 + +### 4.4 キー列と設定検証 + +- 接頭辞待機の既定値は800 msとする設計提案。06に従い200〜3,000 msへ変更でき、0は無期限待機とする。 +- 単一キーと、そのキーを接頭辞とする長いキー列を同じ有効コンテキストへ割り当てた場合はエラーにする。例: `g` と `gg` の併存は不可。 +- 同じ有効コンテキストで同じキーを複数動作へ割り当てる設定は拒否する。共通割当と領域別割当で同じキー列がある場合は領域別を優先する。利用者設定は(mode, context, keys)が一致する既定定義を置換する。異なる長さの接頭辞衝突は、同時に有効なcontext間でも拒否する。 +- 設定変更はファイル全体の構文・値・キー衝突検証が成功した後、一括で適用する。失敗時は最後に有効だった設定を維持し、行番号と修正可能な理由を表示する。 +- 数字は内容領域のPDFページ指定にのみ使い、先行ゼロを除き1以上の整数に制限する。入力中の数字は必要時だけ通知する。末尾が `G` 以外または期限切れなら入力列を破棄して、直前キーを別のコマンドとして勝手に実行しない。 +- フォーカス変更、文書切替、ダイアログ表示時は未完成キー列を破棄する。 +- リピートイベントで接頭辞待機を何重にも作らない。`j`等の連続スクロールはリピートを受け付けるが、パネル表示切替は長押しで点滅しない。 + +## 5. 形式別の位置と移動 + +### 5.1 PDF + +- 内部のページ番号は0始まり、利用者向けの実ページ番号は1始まりとし、変換箇所を位置型の境界へ限定する。 +- `42G` はラベル「42」ではなく42枚目を開く。ページラベルと実ページが異なる場合は、ステータスと一覧に両方を表示する。 +- 現在ページは、文書表示領域の中央線に交わるページを基本とする。中央線がページ間の余白にある場合は近いページ、同距離なら前のページとする。これにより、ページ端で次/前操作が不安定になることを避ける。 +- 次/前ページは現在ページ番号に±1を適用し、移動先ページの上端へ移動する。末尾・先頭ではその位置にとどまり、短く境界を通知する。 +- PDFの目次は文書内のアウトライン情報を利用する。構造タグのH/H1〜H6から有効なページ位置を取得できる場合は、これを見出しジャンプへ用いる。構造タグの有効な見出しがない場合は、文書内宛先を持つアウトラインを代わりに用い、表示名を「目次項目移動」とする。構造タグ由来の見出しとアウトライン由来の項目は共通位置型へ変換しても、情報源の種別を保存する。両方を単純に併合して同一の項目へ二度移動させない。文字サイズ推定・OCRによる見出し生成は行わない。 +- ズーム変更時は、表示領域の中心にある文書上の点をできる限り保持する。異なる大きさや回転を持つページへ移動しても、そのページ固有の幾何情報を尊重する。 + +### 5.2 HTMLとZIP HTML + +- 通常HTMLはそのファイルを入口とする。ZIP HTMLは現在表示するHTMLを基準に相対URLを解決する。CSS内の参照はそのCSS自身の場所を基準とする。入口が複数ある場合・存在しない場合の選択規則は基盤仕様に従う。 +- `h1`〜`h6` と有効なARIA見出しをDOM順で収集して見出し移動を提供する。`display:none`等で表示されない要素は対象外とする。見出し文言が空の場合は「見出し(レベルN)」と表示する。 +- HTMLの目次は、明示的な文書内目次のリンク構造が認識可能な場合にそれを採用する。認識不能でも見出しから作成した**見出し一覧**を利用できる。この一覧を著者作成の目次と混同しないようにラベルを区別する。 +- ZIP内の別HTMLへのリンクは同じ出版物コンテキストで開く。ページ一覧をファイル一覧として流用しない。HTMLを開いた順を「章順」として推定しない。 +- `gg` / `G` は現在のHTMLリソースの先頭/末尾である。ZIP全体の先頭/末尾とは解釈しない。 +- リフロー・ズーム変更後は、先頭可視要素とその要素内の相対位置を基準に位置を回復する。DOMに安定した位置がない場合はスクロール比率へフォールバックする。 + +### 5.3 EPUB + +- 章の通常移動はパッケージのspine順に従う。目次の表示順から章の読書順を推定しない。 +- リフロー形式は表示領域と作者の書字方向に従って組版する。横書きの通常文書は縦スクロール、日本語縦書き等はwriting-modeとdirectionに合った読書軸を使う。`j`/`k`と`h`/`l`は物理方向、半画面/1画面移動は読書方向に対応する。次/前章は次/前の通常読書対象spine項目の先頭へ移動する。 +- 目次はEPUB 3のnavigation document、EPUB 2はNCXを利用する。両方ある場合の優先順位はEPUB基盤仕様で固定する。 +- EPUB内の `h1`〜`h6` と有効なARIA見出しを見出しジャンプの対象とする。章境界を越える見出し移動は、次/前の通常読書対象spine項目の最初/最後の有効見出しへ移る。先読みは必要最小限とし、全章を描画してから読書開始する構成にしない。 +- 章単位の位置と章内アンカーを持ち、画面幅の変更でページ数が変わっても同じ箇所に留まる。EPUB page-listのページ番号は書籍が提供した参照であり、現在の画面分割数ではない。 +- 固定レイアウトはそのviewportと読み方向を尊重する。日本語縦書き・RTLを含め、作者のCSSと出版物の方向情報を使う。固定レイアウトとリフロー文書では倍率・幅合わせの意味を分ける。 + +### 5.4 見出し移動の共通定義 + +「次の見出し」は現在の読書位置より後ろにある最初の有効な見出し、「前の見出し」は現在の読書位置より前にある最後の有効な見出しとする。同一の目的地へ重複する見出しは一度にまとめる。見出しの先頭ぴったりにいる場合、その見出しを再選択しない。PDFで見出しのページだけが確定し座標が確定しない場合は、`precision=page`としてページ先頭へ移動し、その精度を表示する。確定していない位置を正確な見出し位置と扱わない。目的地が不正な目次項目は選択時に理由を示し、他の目次項目まで使えなくしない。 + +## 6. コマンド契約と文字入力 + +設定ファイル、キー入力、コマンド入力は同じコマンドIDへ解決する。コマンド入力欄は必要時だけ最下部に現れ、ステータス行が非表示でも利用できる。入力中は等幅文字で `:` と文字列、エラーがあればその理由を示す。 + +| ID | 既定キー/文字列 | 引数・適用対象 | +|---|---|---| +| `file.open` | `Ctrl+O`、`:open` | 引数なしはファイル選択。`:open "path"` は指定ローカルパスを開く | +| `app.quit` | `:q` | 終了。OS標準の終了操作も使用可 | +| `operation.cancel` | 読込/索引待機中の`Esc`、`:cancel` | 現在の待機要求を取り消す。新文書のコミット前なら旧文書へ戻る。文字入力/IMEの取消が優先 | +| `document.root.choose` | `:root` | 単独HTMLの許可フォルダーをOSの選択画面で指定。新root内へ資源を再解決 | +| `document.info` | `:info` | 形式、検出した制限、欠落資源、エラー詳細を表示 | +| `history.clear` | `:history clear` | 保存した履歴・位置を件数確認後に消去。補助機能 | +| `scroll.down` / `scroll.up` | `j` / `k` | 内容領域の縦スクロール | +| `scroll.left` / `scroll.right` | `h` / `l` | 内容領域の横スクロール | +| `scroll.half_down` / `scroll.half_up` | `Ctrl+D` / `Ctrl+U` | 表示領域の半分 | +| `scroll.page_down` / `scroll.page_up` | `Ctrl+F` / `Ctrl+B` | 表示領域のほぼ全体 | +| `nav.document_start` / `nav.document_end` | `gg` / `G` | 形式別に定める文書範囲 | +| `nav.page` | `数字G`、`:page N` | 1始まりのPDF実ページ番号。範囲外はエラー、暗黙に末尾へ丸めない | +| `nav.page_next` / `nav.page_previous` | `J` / `K` | PDFのみ | +| `nav.heading_next` / `nav.heading_previous` | `]]` / `[[` | 有効な見出しまたは代替目次項目がある文書 | +| `nav.chapter_next` / `nav.chapter_previous` | `]c` / `[c` | EPUBのみ | +| `panel.toc.toggle` | `t`、`:toctoggle` | 目次または見出し一覧の表示を切り替える | +| `panel.pages.toggle` | `p`、`:pagestoggle` | 利用可能なページ/章一覧の表示を切り替える | +| `panel.status.toggle` | `zs`、`:statusbar` | ステータス行の表示を切り替える | +| `focus.next` | `Ctrl+W` `w` | 表示中の領域を循環 | +| `zoom.in` / `zoom.out` | `+` / `-` | 現在位置を保持して拡大/縮小 | +| `zoom.fit_width` | `=` | 固定ページでは幅合わせ、リフローでは標準倍率 | +| `view.rotate` | `:rotate 90`、`:rotate -90` | PDFの表示回転のみ。原本へ保存しない | +| `command.open` | `:` | コマンド入力状態へ移行 | +| `config.reload` | `:reload` | 06で定義する検証と一括再読込 | +| `help.toggle` | `?`、`:help` | 現在のキー設定に対応する操作一覧 | +| `nav.back` / `nav.forward` | `Alt+Left` / `Alt+Right` | 同一文書コンテキスト内の明示的なジャンプ履歴 | +| `search.open` | `/` | 本文検索の入力欄。補助機能の提案 | +| `search.next` / `search.previous` | `n` / `N` | 検索結果。補助機能の提案 | + +`nav.back` / `nav.forward` は目次・リンク・指定ページ等のジャンプを戻す。小さなスクロールのすべてを履歴へ積まない。新しい文書を開くことと、同じZIP内HTMLへの移動は、文書コンテキストの境界によって区別する。 + +コマンドの引数はアプリ内パーサーで処理し、シェルへ渡さない。パスは空白を含む場合に二重引用符で囲み、Windowsのバックスラッシュは文字どおり扱う。シェル変数、ワイルドカード、`!`による外部コマンド実行は展開しない。未知のコマンド、引数不足、余分な引数は入力欄に理由を表示し、文書状態を変更しない。IME確定のEnterはコマンド実行と兼用せず、変換確定後のEnterで実行する。 + +## 7. 補助機能の提案 + +本文検索、読書位置の再開、最近使った文書は原要件の独立した追加提案である。実装順の判断により削減できる。これらを省略してもR01〜R15の試験を省略しない。 + +- **本文検索**: `/`で検索欄を開く。入力中はIMEと文字編集を優先する。Enterで検索し、`n`/`N`で結果を移動する。PDFの既存テキストとHTML/EPUBの表示テキストを対象とし、画像へOCRを適用しない。検索処理は取消可能にし、結果なしと文字情報なしを区別する。 +- **読書位置の再開**: 文書を閉じる前の論理位置・倍率をローカル保存し、同じ文書を開いたときに回復する。原文の変更やウィンドウ幅の違いによる復元精度の低下を考慮し、保存先と識別方式は05・06に従う。HTML/リフローEPUBの画面上のページ数を永続キーにしない。 +- **最近使った文書**: 初期画面の追加表示として設定で有効化できる。初期画面を履歴の大量表示で埋めない。消去機能と保存上限を持ち、原本を変更しない。 + +## 8. UI実装前の確認項目 + +Qt QuickとQt WebEngineの間のフォーカス、IMEのcompositionイベント、日本語/英語キーボード配列、Wayland/X11、Windowsの表示スケール差を実機で確認する。PDFのアウトラインと構造タグ由来の見出し位置は出典を区別して保持する。これらは実装開始時の技術検証対象とする。 diff --git a/docs/design/05-data-contracts.md b/docs/design/05-data-contracts.md new file mode 100644 index 0000000..acadd7e --- /dev/null +++ b/docs/design/05-data-contracts.md @@ -0,0 +1,128 @@ +# 05 データ・インターフェース設計 + +## 設計原則 + +文書の位置は形式ごとの意味を保持した直和型で表現する。内部のページ番号は0始まり、画面の物理ページ番号は1始まりとする。ページラベルは文字列であり数値として計算しない。ドメインモデルはQtやPDFiumのオブジェクトを直接含めず、非同期操作の全応答に文書の世代を付ける。 + +## 主要モデル + +| モデル | 主なフィールド | 制約・意味 | +|---|---|---| +| DocumentIdentity | documentId、canonicalPath、fileIdentity、size、mtime、contentHash? | documentIdは端末内UUID。物理ファイルIDは利用可能時のみ。ハッシュ未計算を許容 | +| DocumentSession | sessionId、generation、format、state、capabilities | sessionIdは起動ごとの予測困難な識別子。保存IDとは別 | +| DocumentMetadata | title、author?、language?、pageCount?、spineCount? | 未判明と0を区別。文字列長を制限しUIへプレーンテキスト表示 | +| PageInfo | pageIndex、label?、widthPt、heightPt、cropBox、rotation | PDFの元座標と表示変換を保持 | +| TocNode | id、parentId?、title、target?、source、children | sourceはpdf-outline / epub-nav / epub-ncx / html-toc / html-heading / spine | +| Heading | id、level?、title、target、source、precision | level不明可。precisionはexact / page / approximate | +| Link | region?、target、label?、kind | kindはinternal / external / blocked。文字列をコマンドとして実行しない | +| ViewState | location、zoomMode、zoomValue、rotation、panels、focus | ズームはpercent / fit-width / fit-page。本文フォントサイズとは別 | +| ReaderState | documentId、savedLocation、updatedAt、formatVersion | 文書が変わった場合は位置の再解決が必要 | + +配列は必要に応じページングして応答する。巨大な目次や数万項目を一度にUIへ投入しない。titleの空白・制御文字を整理する際も原本は変更しない。 + +## 位置の表現 + +| 型 | 保存する値 | 再解決 | +|---|---|---| +| PdfLocation | pageIndex、pageLabel?、xPt、yPt、viewRotation、fitMode | PDFユーザー空間の位置をCropBox・回転・DPRで画面へ変換。変更文書ではページ範囲を検証 | +| HtmlLocation | resourcePath、fragment?、domPath?、textQuote?、progression | fragment→DOMと文字列照合→資源内進捗の順。progressionは0〜1 | +| EpubLocation | packagePath、spineIdref、href、cfi?、fragment?、textQuote?、progression | パッケージ内のspine→CFI→fragment→文字列→進捗の順で解決 | + +PDFのxPt/yPtは元PDF座標系を明示して保存し、画面左上基準のCSS座標とは混ぜない。常に変換行列と逆行列を使う。CropBox外の指定は最寄りの可視領域へ調整し、精度をpageまたはapproximateとして通知する。 + +HTML/EPUBのスクロール軸はwriting-modeとdirectionから決める。progressionは読み進めた割合であり、scrollTop/scrollHeightだけでは定義しない。書体や幅が変わっても文字列アンカーを先に試す。レイアウト完了前の仮サイズから復元位置を確定しない。 + +textQuoteは短い前後文脈を持つが保存を無効化できる。ローカル状態には文書パスと短い本文断片が含まれ得るため、06の履歴消去対象に含める。スクリプト、DOMオブジェクト、任意XPath評価式は保存しない。 + +## 能力情報 + +能力はsupported / unavailable / loading / unsupportedの4状態を基本とし、単純な真偽値で「未取得」と「存在しない」を同一視しない。 + +| 能力 | 意味 | +|---|---| +| pageNavigation | 安定した物理ページに移動できる | +| chapterNavigation | spineや複数HTML資源に沿った移動がある | +| outline | 文書に目次として使える構造がある | +| headings | 本文または構造情報から節の起点を得られる | +| textSearch | 既存文字情報を検索できる。画像PDFではunavailableになる場合がある | +| textSelection | 内容が文字選択を許容し、テキストを取得できる | +| reflow | 本文の文字サイズ・行幅変更を反映できる | +| password | 現在パスワードを要求している | + +unsupportedには理由コードを付ける。UIは理由をヘルプ/一時通知に出し、無効ボタンを常時並べない。 + +## FormatAdapterの論理契約 + +以下は実装言語に依存しないインターフェース定義であり、アプリケーションコードではない。操作は非同期、sessionIdと取消しトークンを受け取る。 + +| 操作 | 入力 | 結果 | 特記事項 | +|---|---|---|---| +| probe | 制限付きファイル情報・先頭バイト | format、confidence、理由 | ZIP内部の詳しい判定は隔離側 | +| open | DocumentSource、policy、password? | session、metadata、capabilities | パスワード再入力と破損を区別 | +| getOutline | cursor、limit | TocNodeの部分集合、nextCursor | 同一文書で安定したID | +| getHeadings | resource/page範囲、cursor | Headingの部分集合、完了状態 | 抽出途中のジャンプ要求は待機/取消し可能 | +| resolveLocation | 保存位置、復元方針 | 解決済み位置、precision | 失敗時は先頭へ黙って移さず通知 | +| navigate | target、alignment | 到達位置、renderRevision | jump履歴への記録はControllerが担当 | +| renderPdfTiles | page、matrix、clip、revision | TileBatch | PDF専用描画ポート。WebEngineは可視域通知ポートを使う | +| setViewport | size、DPR、readingStyle | layoutRevision | reflow後に位置再解決 | +| findText | query、direction、cursor、limit | match位置、nextCursor | 提案機能。平文検索が既定 | +| close | sessionId | released | 繰返し呼出しは無害 | + +共通契約は操作の意図をそろえるためのもので、PDFの画像タイルとHTMLのDOMを同一の描画データへ変換しない。 + +## コマンドと入力の境界 + +Command RegistryはcommandId、引数スキーマ、実行可能モード、必要な能力、取消し可否を持つ。Input Routerからはキー名そのものではなく検証済みCommandを受け取る。マウス操作も同じCommandを経由する。 + +コマンド入力欄は定義済み文法だけを解釈する。シェル、外部コマンド、JavaScriptの実行機能は持たない。パスの引用符は構文として扱うが、変数展開・コマンド置換・グロブ実行はしない。`:open`のパスは明示操作による新しい許可対象としてBrokerで検証する。 + +## プロセス間メッセージ + +IPCは長さ付きCBORフレームをローカル専用チャネルで送る。外部待受ポートを開かない。子プロセスの標準出力を信頼済みのコマンドストリームとして解釈しない。実装時にパイプ/ローカルソケットを選び、OSごとのアクセス制御を適用する。 + +| フィールド | 内容 | +|---|---| +| protocolVersion | 整数。未知版は接続を拒否し依存不整合を報告 | +| requestId | セッション内一意、応答・取消しの対応づけ | +| sessionId / generation | 文書と世代。旧文書の応答を破棄 | +| operation | ホワイトリスト列挙。任意メソッド名を実行しない | +| payload | 操作別の有限スキーマ。整数範囲・文字列長・配列数を検証 | +| result / error | どちらか一方。errorはコード、再試行可否、利用者向け説明キー | + +制御フレームの上限は1 MiB、PDFタイル/資源データは別の分割チャネルで1片8 MiB以下を提案する。1タイルの最大寸法・stride・ピクセル形式・バッファ長を受信側で再検証し、乗算の桁あふれを拒否する。数値は08で測定し、07の入力制限と合わせて変更する。 + +共有メモリを使う場合はBrokerが割り当てた短命のバッファIDだけをワーカーへ渡し、サイズ上限・所有セッション・読書き方向を管理する。ワーカーが示す任意パスやハンドル値を主プロセスの権限で開かない。close/取消し/クラッシュ時に参照数を解放し、解放済み応答を表示しない。 + +## 状態と遷移 + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Opening: open + Ready --> Opening: open another + Opening --> PasswordRequired: encrypted + PasswordRequired --> Opening: submit + Opening --> Ready: initial content + Opening --> Failed: invalid or unsupported + Ready --> Recovering: worker crash + Recovering --> Ready: explicit retry + Recovering --> Failed: retry fails + Ready --> Closing: close + Closing --> Empty: resources released +``` + +OpeningとPasswordRequiredの取消しは、旧文書があればReadyへ戻し、なければEmptyへ戻す。図のFailedはエラー状態を表し、元文書を保持している場合はエラー表示後に旧Readyへ復帰できる。終了要求はどの状態からでも受け付ける。 + +ReadyにはnavigationIndex=loadingという独立した副状態を持てる。スクロール・ズームのたびにOpeningへ戻さない。パネル表示は文書状態と独立し、読み込み途中に目次を開いても競合しない。 + +## 整合性と取消し + +- 文書切替でgeneration、倍率や向きの変更でrenderRevision、HTML/EPUBの再配置でlayoutRevisionを進める。 +- PDFium処理は直列キューとし、取消し要求は予約済み処理を除去する。実行中の処理は安全な区切りで止める。 +- closeは読取チャネルの失効→新規要求停止→実行処理取消し→ワーカー終了→一時資源回収の順。停止不能時は期限付きで強制終了する。 +- 読書位置の保存は最後に画面へ反映された位置だけを対象とする。未到達のジャンプ要求で保存位置を更新しない。 +- 戻る/進む履歴は明示ジャンプとリンク移動を単位に記録する。連続スクロールの全フレームは記録しない。 + +## エラーの責務 + +ワーカーは技術的な原因と位置を返し、Controllerは回復操作へ翻訳する。UIへOSの例外やスタックトレースをそのまま表示しない。利用者が必要とするファイル名・ページ・理由は表示し、ログ側ではパスや本文を原則省く。コード体系と回復策は07を正とする。 diff --git a/docs/design/06-config-storage.md b/docs/design/06-config-storage.md new file mode 100644 index 0000000..9a117dd --- /dev/null +++ b/docs/design/06-config-storage.md @@ -0,0 +1,150 @@ +# 06 設定・保存設計 + +## 設定方式 + +利用者が編集する設定はUTF-8のTOMLとし、`config.toml`一つを入口にする。初期設定の全項目を記載しなくても、差分だけで上書きできる。`schema_version = 1`で構造の版を識別する。設定から任意のコードやシェルを実行しない。 + +優先順位は「組込み既定値 < 利用者設定 < 起動引数で明示した一時上書き」。`--config PATH`は読む利用者設定ファイルを置き換え、二つの利用者設定を暗黙に合成しない。起動引数はファイルパス、設定パス、初期ページ等の限定された項目だけを許可する。読書状態は表示位置の復元にのみ使い、キーや配色の設定より優先させない。 + +文書と同じディレクトリにある設定やZIP内部の設定は自動読込みしない。ファイルを開くだけでキーや許可範囲が変更されることを防ぐ。 + +## OSごとの配置 + +| 種類 | Linux | Windows | +|---|---|---| +| 設定 | `$XDG_CONFIG_HOME/docview/config.toml` | `%APPDATA%\DocView\config.toml` | +| 読書状態・履歴 | `$XDG_STATE_HOME/docview/state.json` | `%LOCALAPPDATA%\DocView\state\state.json` | +| キャッシュ | `$XDG_CACHE_HOME/docview/` | `%LOCALAPPDATA%\DocView\cache\` | +| ログ | `$XDG_STATE_HOME/docview/logs/` | `%LOCALAPPDATA%\DocView\logs\` | +| ZIP等の作業領域 | cache配下の`sessions//` | cache配下の`sessions\\` | + +Linuxの未設定/空のXDG変数は順に`~/.config`、`~/.local/state`、`~/.cache`へフォールバックする。相対パスのXDG値は仕様に従い無効とする。Windowsは文字列置換した環境変数だけに頼らず、Known Folders APIでRoamingAppDataとLocalAppDataを解決する。QtのQStandardPathsは共通の経路解決を補助するが、AppConfigLocationのWindows既定はLocal側であるため、上記Roaming側設定と混同しない。[XDG仕様](https://specifications.freedesktop.org/basedir/latest/)、[QStandardPaths](https://doc.qt.io/qt-6/qstandardpaths.html)、[Windows Known Folders](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid)を参照。 + +すべてユーザー専用領域とし、Linuxではディレクトリ0700・状態ファイル0600相当、Windowsでは現在ユーザー中心のACLを継承・確認する。インストール先、実行ファイル隣、現在ディレクトリへ暗黙に設定を書かない。 + +## 設定項目 + +数値は初期提案であり、08の性能測定に合わせて調整する。セキュリティの絶対上限・隔離の有効化は利用者設定で解除できない。 + +| キー | 型・既定値 | 範囲/効果 | +|---|---|---| +| schema_version | integer = 1 | 未知の将来版は適用しない | +| ui.theme | string = "dark" | dark / light / system | +| ui.status_bar | boolean = true | 状態1行を表示。zsで一時切替 | +| ui.toc_visible | boolean = false | 初期の目次表示 | +| ui.pages_visible | boolean = false | 初期のページ/章一覧表示 | +| ui.font_size | number = 14 | 10〜28論理px、補助UIの文字サイズ | +| ui.panel_width | integer = 280 | 160〜600論理px、画面幅でも上限を制約 | +| pdf.zoom_mode | string = "fit-width" | fit-width / fit-page / percent | +| pdf.zoom_percent | integer = 100 | 25〜800、percent時のみ使用 | +| pdf.preserve_colors | boolean = true | 本文の原色を維持。falseは表示フィルターを使う追加提案 | +| reading.font_size | number = 18 | 12〜40論理px、流し込みHTML/EPUBの利用者表示設定 | +| reading.line_height | number = 1.6 | 1.0〜2.5。著者指定維持との切替を持つ | +| reading.use_publisher_style | boolean = true | 既定は著者スタイル。利用者上書き時もwriting-mode等を保持 | +| keys.sequence_timeout_ms | integer = 800 | 0(無期限)または200〜3000、複数打鍵の待機。IME合成中は解釈しない | +| performance.tile_cache_mib | integer = 256 | 64〜512、PDFタイル予算。総RSS制限ではない | +| performance.prefetch_pages | integer = 1 | 0〜3、前後ページ。メモリ圧迫時は自動抑制 | +| privacy.remember_position | boolean = true | 読書位置を保存する提案機能 | +| privacy.recent_documents | integer = 20 | 0〜100、0で最近使った一覧を無効化 | +| privacy.store_text_anchor | boolean = false | 本文断片の永続保存。無効でもCFI/fragment等で復元可能 | + +`pdf.preserve_colors=false`の表示フィルターは原色を変えるため、正確な再現性の受入はtrueで行う。初期実装でフィルターを採用しない場合、falseを未対応として検証エラーにし、黙って受け付けない。 + +## 設定例 + +以下は設計上の設定例であり、アプリケーションのプログラムではない。通常は変更した項目だけを記述する。 + +```toml +schema_version = 1 + +[ui] +theme = "dark" +status_bar = true +toc_visible = false +pages_visible = false +font_size = 14 +panel_width = 280 + +[pdf] +zoom_mode = "fit-width" +zoom_percent = 100 +preserve_colors = true + +[reading] +use_publisher_style = true +font_size = 18 +line_height = 1.6 + +[keys] +sequence_timeout_ms = 800 + +[performance] +tile_cache_mib = 256 +prefetch_pages = 1 + +[privacy] +remember_position = true +recent_documents = 20 +store_text_anchor = false + +[[keybindings]] +mode = "normal" +context = "content" +keys = ["j"] +command = "scroll.down" + +[[keybindings]] +mode = "normal" +context = "global" +keys = ["t"] +command = "panel.toc.toggle" + +[[keybindings]] +mode = "normal" +context = "content" +keys = ["]", "]"] +command = "nav.heading_next" +``` + +## キー設定の仕様 + +1. 一つのキーは`j`、`J`、`Ctrl+D`、`Alt+Left`、`Esc`のような標準表記とする。大文字と小文字を区別し、印字キーはキーボード配列上の文字として解釈する。物理スキャンコードへの依存は初期版で導入しない。 +2. `keys`配列は順番に押すキーを表す。`["g", "g"]`、`["Ctrl+W", "w"]`のように記述する。修飾キーの同時押しは一要素に含める。 +3. `mode`はnormal / command / search / dialogとし、入力欄で合成中のIMEイベントは対応付けより優先する。`context`はglobal / content / toc / pagesとする。commandとsearchの文字入力はユーザーキー設定で奪わない。 +4. 適用キーは(mode, context, keys)で同定する。利用者設定の同じ組は既定定義を一つ置換する。既定を無効化する場合は`command = "unbound"`とする。同じファイルに同じ組が二つあれば検証エラーとする。 +5. 現在のパネルcontextを先に、globalを次に評価する。通常モードのtはどのパネルからも目次を切り替えるが、文字入力中のtは入力文字になる。 +6. `g`と`gg`のように実行コマンドが互いに前方一致する定義は、同時に有効になるcontext間も含めて拒否する。既定のgは未実行の接頭辞なのでggと共存できる。部分列の待機中に不一致のキーが来たら接頭辞を破棄し、新しいキーを一度だけ再評価する。ただし数字Gの入力中は不一致キーも消費し、意図しない操作を起こさない。 +7. Escは複数打鍵・検索・コマンド・一時モードを抜ける回復手段として予約し、再割当て不可とする。OSが予約するキーやファイル選択ダイアログの標準操作はアプリが横取りしない。 +8. コマンドID、引数、必要能力を検証する。存在しないIDは保存成功として扱わない。現在の文書で能力がない操作は理由を短く表示する。 + +数値付きページ移動`42G`はInput Routerの限定文法として扱い、数字全般の自由なキー再割当てとは衝突させない。詳細なキー一覧・文法・フォーカス動作は04を正とする。 + +## 再読込みとエラー + +設定は起動時と`:reload`時に読み込む。初期版はファイル監視による自動適用を行わず、保存途中の内容による揺れを防ぐ。最大設定サイズは1 MiB、入れ子深度やキー数にも上限を設ける。 + +読込み→TOML解析→型と値域→キー衝突→コマンド能力→適用計画の順に検証する。全検証が成功した場合だけ設定スナップショットを一括交換する。失敗した場合は直前の有効設定を保持し、行番号・キー・修正理由を表示する。初回起動なら組込み既定値を使う。 + +未知のキーは綴り間違いを見逃さないためエラーとする。将来版のschema_versionは読取り専用の説明を表示し、利用者ファイルを自動で書き換えない。移行が必要な場合は変換案とバックアップを用意し、明示操作で保存する。 + +配色・パネル・キー・キャッシュ予算は即時反映し、再配置を伴う文字サイズは現在の論理位置を保持して反映する。適用中はキー列を取消し、二重実行を防ぐ。再起動が必要な項目を将来追加するときは、効果発生時点をスキーマに明示する。 + +## 読書状態と履歴 + +状態は`state.json`へ保存し、`state_version`、文書ID、ファイル識別情報、最終位置、倍率、更新時刻を持つ。アプリ設定は自動で書き換えない。パネルの一時切替はセッション内状態であり、次回起動はconfigの既定へ戻す。 + +元ファイルの完全ハッシュは初期表示を妨げない低優先処理にする。最初は正規化したパス・サイズ・更新時刻・利用可能なファイルIDで候補を探す。文書の同一性が確認できない場合は「以前の位置を復元できない」とし、似たファイルへ位置を自動転用しない。ファイルが変更された場合は保存アンカーを再検証し、曖昧なら位置選択を提示する。 + +位置は停止後2秒のdebounceと最大10秒間隔を提案し、正常終了時にも保存する。急な終了では直前10秒程度の位置更新を失う可能性がある。保存先と同じディレクトリ内の一時ファイルへ書き、flush後に原子的な置換を使う。前回正常版を1世代だけ残し、破損時はバックアップへ戻す。 + +アプリはユーザーごとに一つの状態Writerを持つ。二重起動は既存インスタンスへファイルオープン要求を送る。IPCは同じユーザーのプロセスだけに制限する。ロックが得られない場合は読書専用モードで起動し、設定・状態を競合上書きしない。 + +`:history clear`は最近使った一覧、保存位置、textQuote、状態バックアップを消去する提案コマンドとする。実行前に対象件数を表示し、文書原本は含めない。`privacy.remember_position=false`は将来の保存を止める設定であり、既存データの消去とは分ける。 + +## 一時データとログ + +ZIP/EPUB作業領域はsessionIdごとに分離し、正常終了で削除する。起動時は自分の未使用セッション領域だけを回収し、他インスタンスが使用中の領域を削除しない。削除対象の実体・所有者・ロックを確認し、シンボリックリンクを追跡しない。 + +ログは日時、エラーコード、形式、処理時間、匿名の要求IDを中心とし、原則として本文・パスワード・完全パスを記録しない。ローカルに5 MiB×3世代を提案する。外部送信は行わない。詳細診断を保存する機能を将来設ける場合も、利用者が内容を確認して書き出す方式とする。 + +キャッシュは削除しても原本文書と設定に影響しない。履歴や設定の破損で本文が開けなくならないよう、保存処理のエラーは表示処理から切り離す。 diff --git a/docs/design/07-security-errors.md b/docs/design/07-security-errors.md new file mode 100644 index 0000000..a8f29f2 --- /dev/null +++ b/docs/design/07-security-errors.md @@ -0,0 +1,166 @@ +# 07 セキュリティ・異常系設計 + +DocView アプリケーション設計書 | 版 1.0 | 2026-09-19 + +対象要件: R01-R04、R09、R11、R12、R14。文書を開く操作が、原本の変更、文書外のファイル読取り、外部送信、任意プログラム実行へ広がらないようにする。 + +## 1. 信頼境界 + +ローカルにあるPDF、HTML、ZIP、EPUBも未信頼入力として扱う。文書の名前・本文・メタデータに書かれた指示をアプリの命令や設定として解釈しない。文書処理から設定ファイルを読み替えたり、拡張コードを自動ロードしたりしない。 + +| 領域 | 許可する処理 | 禁止する処理 | +|---|---|---| +| Main / FileBroker | UI、検証済み設定、利用者が開いたファイルのhandle管理、限定範囲のIO、IPC検証 | PDF、ZIP、EPUB XML、HTML、CSS、画像の内容解析 | +| PDFWorker | 渡されたPDFの解析・描画、限定的なfont資源利用 | ネットワーク、任意ファイル、原本への書込み、外部起動 | +| ArchiveWorker | 渡されたZIPの索引・展開、EPUB構造XML、font難読化の処理 | 任意パス作成、ネットワーク、XML外部実体、外部起動 | +| WebEngine文書側 | 文書セッション内資源の表示、アプリ管理のDOM位置操作 | 設定・履歴・任意OS API、別文書、遠隔通信、文書JavaScript | + +MainがWorkerから受けた索引・ラベル・URL・画像を無条件に信用すると境界が失われる。型、長さ、整数範囲、参照先、文書世代番号を検査し、描画画像についてもstride、幅、高さ、共有バッファ長を照合する。制御IPCの上限は1 MiB、データチャネルは1チャンク8 MiBを初期値とし、詳細は [データ・内部契約設計](05-data-contracts.md) に従う。 + +## 2. OSによる隔離と配布条件 + +別プロセスに分けただけではファイルやネットワークへのアクセスを防げない。PDFWorkerとArchiveWorkerにはOSによる制限を設定する。実装前のG0では起動可能性だけでなく、境界の外にある試験ファイルへのアクセス拒否、通信拒否、子プロセス起動拒否、資源上限超過時の停止を実証する。未達の対象OS版は配布しない。 + +| OS/実行系 | 採用方針 | G0で確かめること | +|---|---|---| +| Linux Worker | seccompとLandlockを組み合わせる。要件を満たせない環境は同等の隔離launcherを利用 | 必要ABI・syscall、font読込み、handle受渡し、全threadへの制限、socket・process作成・root外IOの拒否 | +| Windows Worker | AppContainerを権限境界とし、Job Objectで寿命・資源・子プロセスを管理 | network capabilityなし、必要handleだけの継承、font依存、DACL、停止・再起動 | +| Qt WebEngine | Qt/Chromiumのsandboxを有効のまま利用 | 両OSの配布形態、実際のrenderer隔離、必要なLinux kernel機能、無効化フラグの検知 | + +Landlock単独で全種類の通信制限が揃うとは想定せず、socket等の操作はseccomp側でも制限する。Job Object単独もファイル権限の代替にしない。Workerの許可資源は読取り専用文書handle、限定IPC、必要なfont/runtimeのみとし、ホーム全体や任意ディレクトリーを許可しない。[Linux Landlock](https://docs.kernel.org/userspace-api/landlock.html)、[AppContainerの起動](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) + +`--no-sandbox`、`QTWEBENGINE_DISABLE_SANDBOX` 等で保護を外す運用を製品の回避策にしない。sandboxが必要条件を満たさない場合は `E_SANDBOX_UNAVAILABLE` を表示し、該当形式を開かない。Qt WebEngineはrendererのsandboxを提供するが、独立したPDFWorkerやArchiveWorkerの隔離までは提供しない。[Qt WebEngine Platform Notes](https://doc.qt.io/qt-6/qtwebengine-platform-notes.html) + +## 3. ZIP・一時資源 + +### 3.1 検証と書込み + +ArchiveWorkerは展開前に索引を検査し、Mainも公開対象entryを再検証する。Mainは検証済みの相対名を文書rootのdirectory handleに結び付け、OSのNOFOLLOW相当の手段でファイルを開く。文字列の前方一致だけでroot内と判定しない。Workerはディスク上の任意展開先を指定できない。 + +- 絶対パス、ドライブ名、UNC、NUL、`..` セグメント、バックスラッシュ、ADSを作るコロン、Windowsデバイス名を拒否する。 +- symlink、hardlink、junction/reparse point、device、FIFOを拒否する。通常ファイルとディレクトリーだけを受け入れる。 +- 正規化後の重複、大小文字・Unicode正規化による衝突、ファイルとディレクトリーの衝突、末尾のドット/空白で曖昧になる名前を拒否する。 +- アーカイブの所有権、実行権限、ACLを再現しない。ユーザー専用の新しい一時ディレクトリーを作り、他セッションの内容を混ぜない。 +- 申告サイズだけでなく、Mainへ届いた実展開バイト数を計測する。各entryのCRC・長さ・完了状態が正常になるまで仮想URLから公開しない。 +- 内側のZIPは再帰展開しない。エラー時に展開途中ファイルを残して再利用しない。 + +これらはZIP Slip、リンクを使う脱出、解凍による資源枯渇を別々に防ぐための方針である。[OWASPアーカイブ試験](https://wstg.owasp.org/latest/4-Web_Application_Security_Testing/10-Business_Logic/09-Upload_of_Malicious_Files/) + +### 3.2 上限の初期提案 + +数値は原要件にはなく、代表書籍と負荷試験で妥当性を確認する設計値である。初期版では06の利用者設定による緩和を提供せず、ビルド時の有限上限とする。値の変更時は安全試験を再実施し、パス・リンクの検査そのものは無効化できない。 + +| 上限対象 | 初期値 | 判定する場所 | +|---|---:|---| +| ZIP entry数 | 20,000 | Worker索引、Main索引受信 | +| 1資源の実展開量 | 256 MiB | WorkerとMainストリーム受信 | +| 1文書の実展開量 | 2 GiB | WorkerとMainセッション合計 | +| path階層 | 64 | WorkerとMain | +| pathのUTF-8長 | 1,024 bytes | WorkerとMain | +| 膨張率 | 200倍。実展開量32 MiB超のentryに適用 | Workerの入力消費量/出力量 | +| EPUB構造XML 1資源 | 16 MiB | Worker | +| XML深さ/要素数 | 128/200,000 | Worker | +| 同時資源展開 | 2 | スケジューラー | +| 1資源の展開時間 | 10秒で継続表示、30秒で取消 | Main watchdog | +| PDFの1要求 | 30秒で長時間処理を通知、120秒でワーカー停止 | Main watchdog。操作取消は待たずに受理 | +| PDF/Archiveワーカーのメモリー | 1プロセスあたり1.5 GiBを初期の停止上限とする | OS制限とMain監視。通常時の全体1 GiB目標とは別 | + +絶対サイズを主要防御とし、圧縮率だけで判定しない。CPU・メモリーはWorkerおよびWebEngineの資源監視でも制限する。WebEngineでの画像デコード前に全ピクセル数を安全に判定できるとは想定せず、rendererの応答監視とメモリー超過時の停止を併用する。WebEngineの停止閾値は初期1.5 GiB/rendererを提案し、監視間隔による瞬間的な超過はあり得る。展開先の空き容量不足は上限超過と別のエラーにする。上限を変更した版での再試行時も入口から同じ検査を行う。 + +終了時はそのセッションの管理領域だけを削除する。次回起動時の残骸清掃は、所有マーカーと管理下の実体を確認して行う。文書由来のパスを再帰削除の起点にしない。 + +## 4. HTML・EPUBの隔離 + +### 4.1 独自URLと資源提供 + +`doc:///...` を `Syntax::Host` で起動初期に登録し、QQuickWebEngineProfileへ専用scheme handlerを入れる。文書ごとのoff-the-record profileを使い、Cookie、localStorage、WebEngine履歴を文書間で継承しない。文書を閉じるとprofileとtokenを破棄する。 + +`LocalAccessAllowed`、`ContentSecurityPolicyIgnored`、`ServiceWorkersAllowed`、`CorsEnabled`、`FetchApiAllowed` は付けない。`NoAccessAllowed` によって全資源が別originになる構成も使わず、同一文書内のCSS/font等の互換性を保つ。安全なschemeフラグだけに依存せず、handlerはセッションtoken・initiator・method・相対パス・resource kindを照合する。 + +許可methodは読取要求のみ。空initiatorはアプリが発行した既知のトップレベル要求に限定して受け付ける。文書から別tokenを当てられても、tokenの一致と許可資源集合の両方で拒否する。文書URLのtokenを長期履歴やログへ残さない。 + +### 4.2 3箇所の制御 + +| 制御点 | 役割 | +|---|---| +| QWebEngineUrlRequestInterceptor | Chromiumのネットワーク層に届く前に、同一文書の許可資源以外を拒否 | +| QWebEngineUrlSchemeHandler | 実体root・許可entry・MIME・サイズ・寿命を検査し、読取専用資源を応答 | +| WebEngineView.navigationRequested / newWindowRequested | トップレベル遷移、自動遷移、外部ウィンドウ作成を制御 | + +request interceptor内で展開完了を同期的に待たない。許可判定を短時間で終え、資源の非同期応答をhandlerへ委ねる。欠落や取消ではrequest jobを失敗として終了し、jobの破棄に合わせてQIODeviceの寿命も終える。 + +外部HTTP(S)リンクはユーザーが選択したときだけMainへ通知し、宛先を表示したうえで「外部ブラウザーで開く」操作によりOSへ渡す。WebEngine内で外部サイトへ遷移しない。未知のスキーム、OSコマンド、メール送信、file URLを一般のURLとして起動しない。 + +### 4.3 本文スクリプトとCSP + +MainWorldのJavaScriptを無効にし、ApplicationWorldのアプリ固定コードだけで読書位置・見出しを制御する。汎用WebChannelを公開せず、結果は限定されたcallbackから返す。document内の文字列をJavaScriptソースへ埋め込む方法は使わない。 + +scheme handlerの追加応答ヘッダーでCSPを設定する。基本方針は `default-src 'none'`、`script-src 'none'`、`connect-src 'none'`、`object-src 'none'`、`frame-src 'none'`、`form-action 'none'`。画像・fontは同一文書内、CSSは同一文書内と著者のinline styleを許可する。画像のdata URLはimage用途に限定して許可し、top-level documentやSVG scriptの抜け道にしない。`base-uri 'self'` とナビゲーションgateを組み合わせる。 + +応答ヘッダーのAPIはQt 6.6以降に存在する。CSPが独自schemeとApplicationWorldで意図どおり動くことをG0で確認し、CSPを迂回するフラグを付けない。CSPを差し込むためにMainで本文HTMLを解析・改変する設計は採らない。[QWebEngineUrlRequestJob](https://doc.qt.io/qt-6/qwebengineurlrequestjob.html) + +カメラ、マイク、位置、通知、screen capture、clipboard、file picker、download、protocol登録は文書からの要求を拒否する。DNS prefetch、link auditing、local-file access、local-to-remote access、WebGL、plugins、WebEngine内蔵PDF viewerを無効にする。WebEngineが要求する権限は既定拒否とし、document内の設定で上書きしない。 + +## 5. PDFに固有の制約 + +PDFiumはV8とXFAを無効にした構成を基本とする。JavaScript action、Launch action、添付実行、外部自動起動は提供しない。ファイルは読取り専用で開き、PDF保存APIをアプリの操作として公開しない。既存の注釈・フォーム値の静的外観は描画するが、文書へ入力しない。 + +暗号化PDFのパスワードはその場の入力だけで受け取り、設定・履歴・ログ・クラッシュ情報に残さない。明示的な再入力を許し、誤りを破損ファイルと混同しない。署名が見えるPDFでも、署名検証を実施したと表示しない。 + +ワーカーへ渡すfontは許可集合に限定する。未埋込みfontに対応するためホーム全体を読めるようにしない。悪意あるfont、巨大画像、再帰的なPDF構造もPDFWorkerの時間・メモリー制限で扱う。 + +## 6. エラーの共通モデル + +エラーは `code / severity / operation / documentGeneration / resourceId / retryable / userMessage / diagnosticId` を持つ。本文や秘密を含む生データをmessageに連結しない。短い説明と復帰操作を主表示とし、詳細は必要時に開く。繰返し発生する欠落資源は件数をまとめ、キー操作を通知で奪わない。 + +| ID | 表示と復帰 | +|---|---| +| E_OPEN_FAILED | ファイルを開けない。場所・権限を確認し別ファイルを選択 | +| E_FORMAT_UNSUPPORTED | 対応形式でない、またはシグネチャ不一致。元文書へ戻る | +| E_PASSWORD_REQUIRED | PDFパスワードを入力。取消で元文書へ戻る | +| E_PASSWORD_INVALID | パスワードが一致しない。再入力可能 | +| E_PDF_CORRUPT | PDFを解析できない。元文書へ戻る | +| E_PDF_FEATURE_UNSUPPORTED | 動的XFA等の未対応内容。静的部分が読める場合も制限を通知 | +| E_SANDBOX_UNAVAILABLE | 安全な文書処理環境を作れない。該当形式の読込みを停止 | +| E_ARCHIVE_UNSAFE_PATH | root外へ出るパス・リンク・衝突を検出。取込みを停止 | +| E_ARCHIVE_LIMIT | 件数・実展開量・深さ等の上限。項目と上限を提示 | +| E_ARCHIVE_CORRUPT | 索引・長さ・CRCが破損。取込みを停止 | +| E_ARCHIVE_ENCRYPTED | パスワード付きZIPは非対応 | +| E_HTML_ENTRY_MISSING | HTMLの入口がない。入口候補があれば選択 | +| E_RESOURCE_MISSING | 一部資源がない。代替表示で読書を継続 | +| E_RESOURCE_BLOCKED | root外・遠隔資源等を遮断。読める部分を維持 | +| E_EPUB_PACKAGE_INVALID | package/読書順が確定できない。読込みを停止 | +| E_EPUB_NAV_INVALID | nav破損。NCXまたは章一覧へfallback | +| E_EPUB_SPINE_MISSING | 該当章が欠落。他の章へ移動可能 | +| E_DRM_UNSUPPORTED | 未対応の暗号化・保護方式。解除処理は行わない | +| E_LOCATOR_UNRESOLVED | 正確な位置に戻れない。近い位置へ戻ったことを通知 | +| E_WORKER_TIMEOUT | 処理期限超過。取消/再読込み/別文書 | +| E_WORKER_CRASH | Worker停止。UIを維持し、利用者の明示操作で再試行。繰返す停止ではセッションを閉じる | +| E_STORAGE_FULL | 一時領域・状態保存先の空き不足。展開を止めて清掃 | +| E_CONFIG_INVALID | 設定エラー。現在の有効設定を維持 | +| E_STATE_SAVE | 読書状態を保存できない。閲覧を継続し保存失敗を通知 | + +ファイル切替中の失敗は、コミット前なら元文書と元位置を維持する。操作取消は通常結果として扱い、エラー通知を出さない。深刻な境界違反や繰返すクラッシュでは再試行ループに入らず、その文書セッションを閉じる。 + +## 7. 診断情報・更新 + +ログは時刻、エラーID、操作、件数、処理時間、依存版を中心とする。本文、パスワード、URL token、任意のローカル絶対パスを既定ログへ記録しない。必要なファイル名も利用者の診断操作で確認できる範囲に留める。クラッシュレポートや利用統計を自動送信しない。 + +Qt、Chromium、PDFium、ZIP/XMLライブラリーの依存一覧と版をリリースごとに残す。脆弱性修正時は内容解析器を優先して更新し、PDF描画の回帰と文書隔離試験を行う。更新で無効になった保護を性能改善の名目で省略しない。 + +## 8. 必須の境界試験 + +受入試験に、path traversal、symlink、大小文字衝突、ZIP bomb、偽の申告サイズ、CRC不一致、XML実体、CSS外部import、外部font、script、iframe、form、meta refresh、file URL、別token、二重percent、Worker偽応答、サイズ不正画像を含める。 + +期待結果はUIが落ちないことに加えて、許可外ファイルの読取り・書込み・送信・プログラム起動が発生しないこと。G0では監視用ファイル・通信先を用いて観測する。通常のCSS相対参照やIDPF難読化fontも同時に試験し、防御が正当な書籍を不必要に壊さないことを確認する。 + +## 9. 参照資料 + +確認日: 2026-09-19。制限値と配布ゲートはDocView独自の設計判断である。 + +- [QWebEngineUrlRequestInterceptor](https://doc.qt.io/qt-6/qwebengineurlrequestinterceptor.html) - ネットワーク層より前の要求制御。 +- [QQuickWebEngineProfile](https://doc.qt.io/qt-6/qquickwebengineprofile.html) - 文書用profileとhandler登録。 +- [QWebEngineUrlScheme](https://doc.qt.io/qt-6/qwebengineurlscheme.html) - originと権限フラグ。 +- [QWebEngineSettings](https://doc.qt.io/qt-6/qwebenginesettings.html) - 文書スクリプト・local資源等の設定。 +- [WebEngineView](https://doc.qt.io/qt-6/qml-qtwebengine-webengineview.html) - 遷移・権限・renderer停止の通知。 +- [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) - 展開後サイズの制限。 diff --git a/docs/design/08-performance-tests.md b/docs/design/08-performance-tests.md new file mode 100644 index 0000000..f16bf13 --- /dev/null +++ b/docs/design/08-performance-tests.md @@ -0,0 +1,128 @@ +# 08 性能・受入試験設計 + +## 1. 検証方針 + +PDFの正確な表示を最初の品質判断とし、その後にキーボード操作、HTML/ZIP/EPUB、設定、Linux/Windowsでの動作を確認する。要件IDは01に対応する。本書は試験計画であり、アプリケーションの実装、性能計測、各試験の合格を報告するものではない。 + +数値は未測定の性能予算である。最低環境、実利用文書、PDFium・Qt WebEngineの組合せを固定して測定し、目標変更が必要な場合は09の意思決定記録へ理由と影響を残す。 + +## 2. 受入試験 + +### 2.1 試験の読み方 + +以下は試験の**設計**であり、合格済みの結果ではない。PDFを優先して実装・検証するが、全要件に対応したリリースの完了判断にはHTML/ZIP/EPUBと両OSの試験が必要となる。 + +試験データは再配布権を確認し、ファイルのハッシュ、出典、フォント条件、期待されるページ数・リンク先を固定する。PDF描画の正解画像は評価対象と同じ描画エンジンから作らず、独立した既知の表示環境または原版から用意し、その作成環境を記録する。 + +### 2.2 要件対応試験表 + +| 試験ID | 要件 | 入力・操作 | 合格条件 | +|---|---|---|---| +| T01 | R01 | 埋込フォントあり/なし、日本語・縦書き、ベクター、透明合成、画像、各種ページ寸法、回転、CropBoxを含むPDFを開く | 期待する文字・図形・画像が欠落/置換/不正配置されず、ページ寸法・回転・可視範囲が正しい。指定倍率・両OSで確認 | +| T02 | R01 | PDFの1ページ目、途中、最終ページへ移動し、ズームを変更する | 正しいページを描画し、古いページの描画結果が現在ページへ混入しない。位置保持規則を満たす | +| T03 | R01, R11 | 数千ページのPDFを開いて末尾へ直接移動する | 全ページの描画を待たずに要求ページを処理する。UIが応答し、不要な要求を取り消せる | +| T04 | R02 | 相対CSS、画像、文書内アンカー、別HTMLリンクを含むローカルHTMLを開く | 仕様で対応対象の資源を表示し、相対参照とアンカーが正しく解決される | +| T05 | R03 | ディレクトリ構造を持つZIP内index.htmlからCSS・画像・別HTMLへ進む | アーカイブ内で参照が解決し、リンク先が正しく表示される。入口不在/複数時は定義した結果になる | +| T06 | R04 | EPUB 2/3の目次順とspine順が異なる本、縦書き/RTL、固定レイアウトを開く | 次/前章はspine、目次は目次宛先に従う。章境界、nonlinear項目、書字方向、固定viewportが仕様どおり | +| T07 | R05, R15 | マウスなしで開く、スクロール、指定PDFページ移動、目次選択、パネル切替、別文書を開く | すべて実施でき、入力待ちとフォーカスが分かる | +| T08 | R05 | 同じキーを内容、目次、ページ一覧、文字入力の各状態で入力する | 状態表どおりに振り分けられ、文字入力中に閲覧位置が変わらない | +| T09 | R05, R12 | 日本語IME変換中にj/k、数字、Enter、Escを入力し、日英配列で記号キーを操作する | IME操作が閲覧コマンドにならない。日英配列で設定された論理キーを入力できる | +| T10 | R05, R10 | キー列の期限切れ、Esc、フォーカス変更、接頭辞の長押しを試す | 未完のキー列が残らず、予期せぬ操作・連続した表示切替が起きない | +| T11 | R06 | PDFの構造見出しのみ/アウトラインのみ/双方あり/双方なし、HTML/EPUBの見出し、同宛先の重複見出しで次/前を操作する | 出典と位置を区別し、04の優先順位で移動する。見出しなしは明示し、推測の見出しを生成しない | +| T12 | R07 | 多階層目次を展開/折りたたみ、項目を選択する | 選択だけでは本文を動かさず、確定時に正しい宛先へ移動する。不正な宛先でも他項目を操作できる | +| T13 | R08 | 目次・ページ一覧・ステータスを個別に切り替える | 個別に反映し、フォーカス中のパネルを隠すと内容へ戻る。隠れた領域が空白として残らない | +| T14 | R08, R15 | 明暗テーマ、100%/200%表示スケールでPDFを表示する | アプリ外観のみテーマが変わり、PDF原文の色を変更しない。主要操作・文字が欠けない | +| T15 | R10 | 有効なキー設定と表示設定を書き、再読込する | 全体の検証後に一括適用し、操作一覧も新設定になる | +| T16 | R10 | 構文誤り、型誤り、範囲外値、重複/接頭辞衝突のキー設定を読む | 行/項目と理由を知らせ、直前の有効な設定を維持する。部分適用しない | +| T17 | R12, R13 | LinuxとWindowsの初回起動、非ASCIIユーザー名、パスに空白、読み取り専用設定ディレクトリで起動する | 各OSの配置規則を満たし、パスを破損しない。書けない場合でも利用可能な既定設定で開始し、対処可能な通知を示す | +| T18 | R14 | 静的注釈・popup・widget・保存済みフォーム外観・外観欠落を持つPDFを表示して操作し、終了する | 文書に既にある内容は対応描画範囲に従って表示するが、編集・入力・保存を提供しない。原本ハッシュが変わらない | +| T19 | R09 | 設計レビューで別形式アダプターの追加手順と依存関係を追跡する | UI固有処理へパーサーを埋め込まず、文書能力・位置・ナビゲーション契約で接続できる。採否は設計レビュー記録で残す | +| T20 | R11 | 次節の代表文書と操作列を両OSで測定する | 合意された測定条件と性能予算を満たす。実測値・分位値・失敗数を記録する | +| T21 | R01-R04, R05 | 破損、読めないファイル、必要資源欠損、読込途中でEscを押す、または別文書を開く | 明確なエラーまたは部分表示の状態を示す。クラッシュせず、旧要求の完了が新文書を上書きしない | + +### 2.3 PDF描画の判定 + +PDFの正確性を単一の画素一致率だけで判定しない。アンチエイリアスや色管理の違いがあるため、少なくとも以下を確認する。 + +1. ページ数、ページ寸法、回転、切り抜き範囲の一致。 +2. 文字・グリフの欠落、豆腐表示、順序違い、画像欠損、合成順序違いがないこと。 +3. 正解画像との重ね合わせによる図形・文字位置と可視領域の一致。 +4. 透明・マスク・グラデーション・色空間など、PDFエンジン依存箇所の目視比較。 +5. 低倍率・標準倍率・高倍率および表示スケール差の確認。 + +差分画像は検出補助として保存し、許容できる縁の差と本文の欠落を分けて判定する。画素差の閾値は代表コーパスで校正してから確定する。現時点で「99%一致なら正確」等の数値を保証値として置かない。 + +## 3. 性能測定設計 + +### 3.1 指標と暫定目標 + +以下の数値は、実測前の**提案する性能予算**であり、現在の達成値ではない。基盤選定時の試作測定で達成可能性を確認し、製品の最低動作環境とともに確定する。 + +| 指標 | 起点と終点 | 標準コーパスでの暫定予算 | +|---|---|---| +| 入力への視覚応答 | キーイベント受領→移動/選択/読込中表示が画面へ提示される | p95 ≤ 50 ms | +| 開く操作後の最初の内容表示 | ファイル選択確定→最初に読む内容を判読できるフレーム提示 | アプリキャッシュなしp95: PDF 2 s、HTML 2 s、ZIP HTML 5 s、EPUB 5 s | +| キャッシュ済みPDFページ移動 | ページ移動キー受領→対象ページの最終品質フレーム提示 | p95 ≤ 100 ms | +| 未キャッシュPDFページ移動 | ページ移動キー受領→対象ページの最終品質フレーム提示 | 標準コーパスp95 ≤ 500 ms | +| 継続スクロール | 同一の操作列のフレーム間隔を取得する | 60 Hz環境でp95 ≤ 33 msを目安とする | +| プロセス群のメモリ | アプリ・PDFワーカー・WebEngine等を含むRSS合計 | 標準コーパスの通常閲覧で1 GiB以下を目標とする。厳密なハード上限ではない | +| キャッシュメモリ | 文書描画キャッシュの実使用量 | 設定上限を超えない。プロセス群のRSSとは別に記録 | + +PDFを開く際に小さいプレビューだけを先に描く設計なら、「最初に判読可能な表示」と「最終品質到達」を別々に測る。HTML/EPUBでは最初の文字だけを出してスタイル適用を遅らせた状態を完了にしない。必要なCSSと表示範囲内の主要な内容が反映された時点を記録する。 + +### 3.2 コーパスの構成 + +| 区分 | 代表例 | 目的 | +|---|---|---| +| 標準PDF | 100 MiB以下/500ページ以下の代表群、文字中心、画像混在、埋込日本語フォント | 日常の読書操作と開く時間 | +| 重いPDF | 1 GiB/5,000ページ規模の代表群、大きいスキャン画像、複雑な透明/ベクター | 長時間停止を防ぐ処理、キャッシュと取消 | +| HTML | 100 MiB以内の参照資源群を持つ長い見出し構造、CSS、ローカル画像 | レイアウト変更と見出し移動 | +| ZIP HTML | 圧縮時100 MiB以下、展開後500 MiB以下、複数階層の相対リンク、画像資源が多い書籍 | 展開・入口解決・内部リンク | +| EPUB | 100 MiB以下/500 spine項目以下、画像混在、日本語、章数が多い例 | spine移動、章の切替、位置保持 | + +これらのページ数や容量は試験セットの選定目安であり、それだけで入力上限を決定しない。アーカイブ展開量等の安全上限はセキュリティ仕様と整合させる。極端な文書には標準コーパスと同じ表示時間を一律に保証せず、応答・取消・資源上限の維持を優先して別枠で評価する。実際に採用した各ファイルのハッシュと特性を試験報告に固定する。 + +### 3.3 再現可能な測定手順 + +1. 暫定の基準機は物理4コア相当のCPU、メモリ8 GiB、SSD、内蔵GPU、1,920×1,080/60 Hzとする。LinuxとWindowsの対象バージョン、CPU型番、コア数、メモリ、ストレージ、GPU、ドライバー、表示解像度/倍率、リフレッシュレート、アプリと描画エンジンのバージョンを記録する。同じ構成の比較を基本にする。 +2. 冷起動はアプリのキャッシュなしで測る。OSファイルキャッシュを消していない場合は、その事実を明記して「完全コールド」と呼ばない。アプリを開き直すだけの試験と区別する。 +3. 読込試験は文書ごと30回を目安とし、p50/p95、最小/最大、失敗数を報告する。p95の安定性が不足する場合は追加測定する。 +4. 操作試験は固定したページ/見出し/章の移動列を最低100操作実施し、キャッシュヒットとミスを分離する。入力時刻と実際の画面提示時刻を計測し、要求を送っただけの時刻で完了にしない。 +5. キー長押し、遠いページへの連続ジャンプ、ウィンドウのリサイズ、別文書への切替を含め、古い仕事を破棄した回数、描画待ちの数、UI停止時間も記録する。 +6. メモリはアプリ単体と補助プロセスを含むプロセス群を分けて測る。開始時、最大、文書閉鎖後の使用量を記録し、同じ文書を10回開閉して無制限増加しないか確認する。OSのRSSが直ちに基準値へ戻らないことだけでリークと断定しない。 +7. 最低動作環境の正式決定後、その環境で受入判定する。高性能な開発機だけの達成を製品保証としない。 + +## 4. 補助機能の試験 + +以下は01・04の追加提案を採用した場合の受入条件である。必須要件の合否と区別する。 + +| 試験ID | 対象 | 合格条件 | +|---|---|---| +| T-P01 | 本文検索 | 日本語/英語で検索でき、`n`/`N`で正しい位置へ移動する。画像PDFは文字情報なしを示し、勝手にOCRを開始しない | +| T-P02 | 検索中の入力 | IME変換・Enter/Escが閲覧操作にならない。検索取消後に古い結果が新しい文書へ反映されない | +| T-P03 | 位置復元 | 同じ文書の再開位置が復元される。リフロー時は論理位置を使い、原本が変化した場合は不正な位置を適用しない | +| T-P04 | 最近使った文書 | 保存上限・無効化・履歴消去が機能する。削除済みの原本は理由を示し、別ファイルとして誤って開かない | + +## 5. 異常系・非機能の追加確認 + +07の安全境界の設計が実際に有効であることを、通常文書の試験とは独立して確認する。以下の拒否は仕様に基づく挙動であり、アプリケーションのクラッシュを合格としない。 + +| 試験ID | 対象 | 合格条件 | +|---|---|---| +| T-S01 | ZIPの絶対パス・親ディレクトリ参照・シンボリックリンク・展開量超過 | 07の規則で拒否し、許可ルート外へファイルを書かない。中断した一時領域を回収する | +| T-S02 | HTML/EPUBの外部URL・文書スクリプト・フォーム・ローカルルート外参照 | 07の通信/参照規則を守る。文書から任意のファイルやOS機能へ到達できない | +| T-S03 | PDFワーカーの異常終了・固まった読込・不正な応答 | UIは応答し、対象文書の失敗を示す。古いワーカー応答を新文書へ適用しない | +| T-S04 | データ消去・一時ファイル回収 | 読書履歴等の対象を区別して消去し、原本を消さない。異常終了後の一時領域も起動時の規則で回収する | +| T-S05 | 配布物からの初回起動 | WindowsとLinuxの対象環境で依存ライブラリ、サンドボックス、WebEngine資源、ライセンス同梱が成立する | + +## 6. 合否の記録と完了条件 + +試験報告には、試験ID、要件ID、対象ビルド、OS/表示環境、文書ハッシュ、手順、期待結果、実結果、証拠画像または計測記録、判定、既知制限、再試験条件を記載する。未実施と失敗を区別し、将来機能の保留を必須要件の合格に置き換えない。 + +完成版の受入条件は次のとおり。 + +1. R01〜R15に対応するT01〜T21が、対象OSで合格している。PDF描画の対応範囲は03と一致し、検出可能な未対応要素を通知する。実行時に検出できない描画差はコーパスの比較試験で判定する。 +2. T-S01〜T-S05の隔離・入出力・回復・配布確認が完了している。 +3. 合意された基準機・コーパスで性能予算を達成している。未達項目があれば、制限と判断を記録して受入判断を行う。黙って目標値を実績値へ書き換えない。 +4. 補助機能を採用した場合は対応するT-P系列の試験が完了している。 +5. 残っている問題が、原文の欠落、データ破損、キー操作不能、原本書換え、UI長時間停止を引き起こさない。 diff --git a/docs/design/09-decisions-roadmap.md b/docs/design/09-decisions-roadmap.md new file mode 100644 index 0000000..89098e1 --- /dev/null +++ b/docs/design/09-decisions-roadmap.md @@ -0,0 +1,95 @@ +# 09 技術選定・設計判断・実装ロードマップ + +対象: DocView(仮称)/設計日: 2026-09-19/状態: 設計基準案 + +## 1. 採用構成 + +**Qt 6 / C++20 / Qt Quick を用いたデスクトップ GUI とし、PDF は独立プロセスの PDFium、HTML と EPUB は Qt WebEngine で表示する。** UI は TUI 風の簡潔な外観と Vim 風キーボード操作を備える。今回の成果物は設計書であり、アプリケーションの実装・試作・性能測定は含まない。 + +Qt、PDFium、WebEngine、ZIP ライブラリ、フォント、ビルドツールの具体的な版は、実装開始時にサポート状況と検証結果を確認して固定する。PDFium は公開 C API を使用し、取得元、コミット、ビルドオプション、成果物のハッシュを記録する。最新版へ無条件に追従せず、修正版の評価と回帰試験を伴って更新する。 + +## 2. 候補比較 + +下表の評価は本アプリケーションの要件に対する設計判断であり、各製品の公式な優劣評価ではない。描画精度の順位を示す比較試験は未実施である。 + +| 候補 | 適合する点 | 本件で負担となる点 | 判断 | +|---|---|---|---| +| Electron + PDF.js | Web 技術による UI、HTML、EPUB の統合が容易。PDF.js を個別更新できる | Electron / Chromium / Node.js / PDF.js の更新と権限境界を管理する。PDF 互換性は別途検証が必要 | 代替候補として保存 | +| Qt Quick + Qt PDF + Qt WebEngine | Qt PDF の目次、リンク、検索、ビューア部品を利用できる | 確認した Qt PDF 公開 API に PDF 構造タグの取得 API がなく、アウトラインなしの tagged PDF の見出し対応が不足する | 不採用 | +| Qt Quick + 直接 PDFium + Qt WebEngine | PDF 描画、構造、座標の制御を一つのアダプターに集約できる。PDF 処理の障害を GUI から分離できる | ワーカー通信、キャッシュ、表示部品、ビルドと更新管理が必要。構造関連 API の一部は Experimental | 採用設計 | +| Qt Quick + MuPDF + Qt WebEngine | PDFium と異なる描画系として比較できる | AGPL または商用ライセンスと配布方針の整合を検討する必要がある。別途統合と検証が必要 | 採用再検討時の候補 | + +公式の機能・ライセンス・API 根拠は [参考資料](10-references.md) の S01-S14 を参照する。PDFium が PDF.js より常に正確、または Qt を使うだけで軽量になるとは判断していない。Qt WebEngine を含めた配布サイズとメモリ量を測定対象にする。 + +## 3. 設計判断記録 + +| ID | 決定 | 根拠と結果 | 要件 | +|---|---|---|---| +| ADR-001 | デスクトップ GUI と TUI 風 UI を採用 | 利用者が GUI を選択済み。PDF は本来のレイアウトを保持して描画し、周囲の UI を簡素化する | R01, R05, R08, R15 | +| ADR-002 | PDFium の公開 C API を独立ワーカーに集約 | tagged PDF の構造情報と描画を同じ PDF バックエンドで扱う。Qt PDF の private API は利用しない | R01, R06, R07, R09 | +| ADR-003 | PDF ワーカー内の PDFium 呼び出しを直列化 | PDFium API は thread-safe ではない。UI と独立したキューで実行し、採用版で確認した中断方法とプロセス終了による回復を設計する | R11, R12 | +| ADR-004 | HTML / EPUB に Qt WebEngine を使用 | 現代的な CSS、縦書き、フォント等をブラウザ描画系に委ねる。EPUB のコンテナー、目次、読書順序はアプリ側で扱う | R02, R03, R04, R06, R07 | +| ADR-005 | PDF 描画ルートを一本化 | Qt WebEngine 内蔵 PDF ビューアを無効にし、PDF リンクはアプリの PDF オープン処理へ渡す。Qt PDF と直接 PDFium を併用しない | R01, R05, R09 | +| ADR-006 | 文書内容とアプリ操作の権限を分離 | 文書を未信頼入力として扱い、文書のスクリプトや設定が任意ファイル・外部通信・OS コマンドへ到達しない境界を設ける | R02, R03, R04, R09, R10 | +| ADR-007 | 既存の見出し情報を使う | PDF outline と構造タグ、HTML 見出し、EPUB の目次を区別する。文字サイズからの見出し推定を必須機能にしない | R06, R07 | +| ADR-008 | PDF は閲覧専用とする | 元ファイルを書き換えない。既存の注釈や保存済みフォーム appearance の表示と、編集操作の提供を分ける | R01, R14 | +| ADR-009 | 設定と文書アダプターを分離 | キー入力はコマンドへ変換し、フォーマット固有処理はアダプターへ委譲する。設定ファイルは OS の慣習に沿って配置する | R05, R09, R10, R13 | +| ADR-010 | 初期サポート OS を明示する | Linux 全般という無限定な互換性保証を避け、実機検証済みの組合せをサポート表で公開する | R12 | + +Qt WebEngine 自体にも PDFium 由来コードが含まれ得るため、ADR-005 は配布物全体から PDFium の重複がなくなることを意味しない。独自 PDF ワーカーと WebEngine は、それぞれの依存部品と更新責任を記録する。 + +## 4. 実装前の技術ゲート + +G0は以下の技術ゲート一式を実施する段階の総称である。以下は後続実装で採用構成を確定するための条件である。本設計時点で合格を確認したものではない。画質判定と性能の定量基準は品質・試験設計に定義する。 + +| ゲート | 検証内容 | 合格条件・証跡 | 不合格時の判断 | +|---|---|---|---| +| G-RENDER | 代表 PDF の描画。日本語横組み・縦組み、埋込/非埋込フォント、CID/CMap、透明、クリップ、画像、回転/CropBox、ICC/CMYK、既存注釈、保存済みフォーム appearance | 人が承認した正解画像との比較と差分レビューを完了。文字・図の欠落、誤配置、読めない描画が残っていない | PDFium の設定・版を見直す。要件を満たせなければ別エンジンを比較し ADR を更新 | +| G-HEADINGS | outline なしの H/H1..H6、RoleMap、複数 MCID、Form XObject、回転、ページにまたがる構造 | 見出し列と順序が妥当で、正しいページ・位置へ移動する。座標が得られない場合のページ単位移動を区別して報告できる | 必要な公開 API の不足を特定。黙ってタグ対応を省略せず、アダプター/エンジンの再選定へ戻る | +| G-SANDBOX | Linux と Windows の PDF ワーカー権限制限、クラッシュ回復、IPC 境界 | 許可された入力と通信のみ成功。任意パスの読取・書込、外部通信、子プロセス生成を拒否する試験の記録。GUI が継続しワーカーを再作成できる | 配布対象ごとに制限方式を再設計。sandbox を無効化する起動オプションを通常運用の解決策にしない | +| G-WEB | 文書別 origin、文書スクリプト抑止、アプリ所有の isolated world 操作、読み取り範囲、ページ遷移、リンク、ダウンロード、通信遮断、内蔵 PDF ビューア無効 | HTML / EPUB の表示と見出し操作が機能し、書籍からアプリ権限や許可外リソースへ到達しない。通常 HTML と ZIP / EPUB の双方で確認 | Qt WebEngine 設定・scheme handler・操作経路を修正。文書 JavaScript を広く許可して回避しない | +| G-DISTRIBUTION | クリーン OS 環境での配布と依存・ライセンス確認 | ランタイム、プラグイン、WebEngine 補助プロセス、フォント、PDFium 等の不足なし。配布物と対応ソース・告知・依存一覧の対応が確認済み | パッケージと採用条件を見直して再試験 | + +PDF 正解画像は一つのビューアの出力だけで無条件に決めない。仕様に基づく期待、複数の独立した描画系、元文書の作成条件、人による確認を使う。アンチエイリアスの軽微な差と内容の欠落を同じ問題として扱わない。特殊な印刷表現や壊れた文書は、対応範囲・既知制約を明記する。 + +PDFium の構造タグ API には Experimental な項目がある。必要な API の一覧、固定コミット、文字列長・寿命・エラー処理、MCID と座標の対応を検証記録に残す。見出しのラベルを得ても、正しい位置へ移動できたとは判定しない。 + +## 5. OS と配布方針 + +| 環境 | 初期方針 | 必須確認 | +|---|---|---| +| Windows 11 x64 | 正式サポートの提案対象 | クリーン環境、表示倍率、IME、非 ASCII パス、ワーカー権限、署名済み配布物、アンインストール | +| Ubuntu 24.04 x64 | 正式サポートの提案対象。X11 / Wayland を個別に確認 | GPU と software rendering、フォント、IME、ファイル選択、sandbox、Qt / WebEngine の共有ライブラリ依存 | +| Fedora x64 | 追加検証対象 | 対象版を実装時に選定。Wayland、SELinux、ライブラリ、sandbox、パッケージ形式を検証してからサポートを宣言 | +| その他 Linux、Windows on ARM、旧 Windows、macOS | 初期の正式サポートに含めない | 要望と検証コストに基づき別途追加 | + +Qt の一般的なサポート対象と、本アプリケーションで検証した対象は区別する。Qt WebEngine には独自のビルド制約があり、一般的な Qt 構成のすべてを利用できるわけではない。確認した資料では Windows の MinGW ビルドは Qt WebEngine に適合せず、静的ビルドも非対応である。[Qt WebEngine Platform Notes](https://doc.qt.io/qt-6/qtwebengine-platform-notes.html) + +Windows は MSVC 系の Qt と互換性のあるツールチェーンを使い、PDFium のビルドは upstream の Clang 系要件と整合させる。Linux では対象環境に合う共有ライブラリと sandbox を梱包・依存解決する。具体的なパッケージ形式は G-DISTRIBUTION で比較し、初期対象ごとに一つを決める。単一実行ファイル化や任意 Linux での起動を前提にしない。 + +ライセンスはライブラリ名だけで結論を出さず、実際に同梱する版・モジュール・ビルド方式で確認する。Qt 商用ライセンスの採用だけで Chromium 等の第三者条件が消えるとは扱わない。MuPDF は代替評価対象にとどめ、ライセンス整理を行わず自動フォールバックとして同梱しない。 + +## 6. 後続の実装順序 + +| 段階 | 完了させるもの | 次へ進む条件 | +|---|---|---| +| 0. 設計基準の固定 | 対応範囲、テスト文書、参照画像、初期 OS、設定形式、依存版、ライセンス方針 | 未決事項の担当と期限が決まり、上記ゲートの評価環境が用意されている | +| 1. PDF 基盤の検証 | 描画、構造見出し、座標、ワーカー制御、権限制限、最小配布経路 | G-RENDER / G-HEADINGS / G-SANDBOX を満たし、配布成立の見通しがある | +| 2. PDF 閲覧機能 | 通常表示、移動、拡大縮小、目次、見出し、ページ一覧、設定、状態復元 | PDF の機能・性能・操作性の受入試験に合格 | +| 3. HTML / ZIP HTML / EPUB | パッケージ読込、リソース解決、目次、見出し、読書順序、位置復元 | G-WEB と各形式の受入試験に合格 | +| 4. 製品化 | OS 別配布、クリーンインストール、更新、障害回復、ヘルプ、告知文書 | 全対象 OS の回帰試験と G-DISTRIBUTION に合格 | + +PDF 最優先は開発・検証順序を意味する。最終的な初期リリースが要件を満たすためには、HTML、ZIP HTML、EPUB を含む全必須要件を受け入れる必要がある。PDF のみの中間成果物を要件一式の完成とは扱わない。 + +## 7. 継続管理する不確実性 + +| 項目 | 設計時点の扱い | 解消時点 | +|---|---|---| +| 実際の書籍での PDF 描画互換性 | 代表コーパスは後続で収集・承認。完全一致は未保証 | G-RENDER | +| tagged PDF の全構造と座標 | 公開 API による経路を採用し、特殊構造は要検証 | G-HEADINGS | +| PDFium の固定版と更新頻度 | 実装開始時にコミットを固定し、脆弱性・互換性を継続評価 | 段階 0 以降 | +| ワーカーの OS 別権限制限 | 別プロセス化と sandbox を別の要件として扱う | G-SANDBOX | +| WebEngine の文書操作と無権限化の両立 | 文書の JavaScript とアプリ所有の操作を区別して検証 | G-WEB | +| 配布ライセンスとパッケージ方式 | 実際の依存集合に基づいて固定 | G-DISTRIBUTION | +| 性能の成立 | 品質・試験設計の測定条件と合格値を使用。未測定 | 各段階の受入試験 | + diff --git a/docs/design/10-references.md b/docs/design/10-references.md new file mode 100644 index 0000000..95505f5 --- /dev/null +++ b/docs/design/10-references.md @@ -0,0 +1,64 @@ +# 10 参考資料 + +確認日: 2026-09-19 + +一次資料を用いて API、機能、ライセンス表記、サポート条件を確認した。Web 上の最新資料は変更されるため、実装開始時には採用版に対応した資料・ソースコミットも固定する。以下の資料は設計の根拠であり、本アプリケーションの描画品質、性能、ライセンス適合性を実証するものではない。 + +## 1. PDF と技術選定 + +| ID | 資料 | 本設計で確認した事項 | +|---|---|---| +| S01 | [PDFium README](https://pdfium.googlesource.com/pdfium/+/refs/heads/main/README.md) | Linux / Windows のビルド経路、JavaScript / XFA のビルド選択、公開 API の範囲、ピクセル試験の存在 | +| S02 | [PDFium fpdfview.h](https://pdfium.googlesource.com/pdfium/+/main/public/fpdfview.h) | 公開 C API、読込と描画、API が thread-safe ではないこと | +| S03 | [PDFium fpdf_structtree.h](https://pdfium.googlesource.com/pdfium/+/main/public/fpdf_structtree.h) | ページの構造ツリー、要素型、子要素、MCID の取得。一部 API が Experimental であること | +| S04 | [PDFium fpdf_edit.h](https://pdfium.googlesource.com/pdfium/+/main/public/fpdf_edit.h) | ページオブジェクトの読取、MCID と境界情報へのアクセス。ファイル名に edit を含むが、読取 API の参照に使う | +| S05 | [PDFium LICENSE](https://pdfium.googlesource.com/pdfium/+/refs/heads/main/LICENSE) | PDFium のライセンス本文。依存部品の条件を含めて確認する起点 | +| S06 | [Qt PDF](https://doc.qt.io/qt-6/qtpdf-index.html) | Qt PDF の描画、ビューア、目次、リンク、検索等の機能 | +| S07 | [QPdfDocument](https://doc.qt.io/qt-6/qpdfdocument.html) | 読込、描画、ページラベル、テキスト、エラーの公開 API。構造タグ API の有無を評価する対象 | +| S08 | [Qt PDF Licensing](https://doc.qt.io/qt-6/qtpdf-licensing.html) | Qt PDF の商用 / LGPLv3 / GPLv2 表記と、PDFium スナップショット・第三者ライセンス | +| S09 | [PDF.js 公式サイト](https://mozilla.github.io/pdf.js/) | PDF.js の位置づけと Apache 2.0 の表記 | +| S10 | [PDF.js FAQ](https://github.com/mozilla/pdf.js/wiki/Frequently-Asked-Questions) | ブラウザ環境による対応差、Worker と本体のバージョン整合、可視ページを優先するメモリ上の考え方 | +| S11 | [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security) | Node.js 能力の分離、context isolation、sandbox、IPC 検証、依存更新の必要性 | +| S12 | [Electron Releases](https://www.electronjs.org/docs/latest/tutorial/electron-timelines) | 直近三つの stable major release というサポート方針と更新周期 | +| S13 | [What is MuPDF?](https://mupdf.readthedocs.io/en/latest/guide/what-is-mupdf.html) | PDF 等の閲覧・変換・操作のためのライブラリとツールという位置づけ | +| S14 | [MuPDF License](https://mupdf.readthedocs.io/en/latest/license.html) | AGPL または商用ライセンスという提供形態 | + +## 2. Qt WebEngine と配布 + +| ID | 資料 | 本設計で確認した事項 | +|---|---|---| +| S15 | [QWebEngineSettings](https://doc.qt.io/qt-6/qwebenginesettings.html) | MainWorld の JavaScript、ローカルファイル/外部 URL、DNS prefetch、内蔵 PDF ビューア等の設定 | +| S16 | [Qt WebEngine Platform Notes](https://doc.qt.io/qt-6/qtwebengine-platform-notes.html) | Chromium renderer の sandbox、Linux の実行条件、C++20、Windows ビルド制約、静的ビルド非対応 | +| S17 | [Qt WebEngine Licensing](https://doc.qt.io/qt-6/qtwebengine-licensing.html) | Qt 部分と Chromium 側の両方の条件が関係すること、第三者ライセンスの一覧 | +| S18 | [Deploying Qt WebEngine Applications](https://doc.qt.io/qt-6/qtwebengine-deploying.html) | 補助プロセス、リソース、翻訳等を含む配布の検討事項 | +| S19 | [Qt Supported Platforms](https://doc.qt.io/qt-6/supported-platforms.html) | Windows 11 / Ubuntu 24.04 の掲載、版ごとのサポート条件、モジュール固有の例外 | + +## 3. 文書形式・隔離・保存 + +| ID | 資料 | 本設計で確認した事項 | +|---|---|---| +| S20 | [PDFium fpdf_formfill.h](https://pdfium.googlesource.com/pdfium/+/main/public/fpdf_formfill.h) | フォーム環境とwidget描画。通常ページ描画との違い | +| S21 | [EPUB 3.3](https://www.w3.org/TR/epub-33/) | package、spine、nav、rendition、font難読化 | +| S22 | [EPUB Reading Systems 3.3](https://www.w3.org/TR/epub-rs-33/) | rootfile、XML処理、スクリプトfallback、ローカル参照の制約 | +| S23 | [EPUB CFI 1.1](https://idpf.org/epub/linking/cfi/epub-cfi.html) | 文書の論理位置表現 | +| S24 | [QWebEngineScript](https://doc.qt.io/qt-6/qwebenginescript.html) | ApplicationWorld、DOMアクセスと変数の分離 | +| S25 | [WebEngineView](https://doc.qt.io/qt-6/qml-qtwebengine-webengineview.html) | runJavaScriptのworld、ナビゲーション・権限の通知 | +| S26 | [QWebEngineUrlScheme](https://doc.qt.io/qt-6/qwebengineurlscheme.html) | Host構文によるoriginとscheme権限 | +| S27 | [QWebEngineUrlSchemeHandler](https://doc.qt.io/qt-6/qwebengineurlschemehandler.html) | 文書資源の応答 | +| S28 | [QWebEngineUrlRequestJob](https://doc.qt.io/qt-6/qwebengineurlrequestjob.html) | Qt 6.6以降の追加応答ヘッダー | +| S29 | [QWebEngineUrlRequestInterceptor](https://doc.qt.io/qt-6/qwebengineurlrequestinterceptor.html) | 要求の事前検査 | +| S30 | [QQuickWebEngineProfile](https://doc.qt.io/qt-6/qquickwebengineprofile.html) | 文書ごとのprofileとhandler | +| S31 | [Linux Landlock](https://docs.kernel.org/userspace-api/landlock.html) | OSアクセス制限とABIによる機能差 | +| S32 | [Windows AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) | 制限されたプロセス環境の構成 | +| S33 | [OWASP Archive Testing](https://wstg.owasp.org/latest/4-Web_Application_Security_Testing/10-Business_Logic/09-Upload_of_Malicious_Files/) | 不正アーカイブの試験観点 | +| S34 | [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) | 解凍後サイズの制限 | +| S35 | [XDG Base Directory](https://specifications.freedesktop.org/basedir/latest/) | 設定・状態・キャッシュの配置と既定値 | +| S36 | [QStandardPaths](https://doc.qt.io/qt-6/qstandardpaths.html) | OS別保存経路、AppConfigLocationとAppDataLocationの差 | +| S37 | [Windows Known Folders](https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid) | RoamingAppDataとLocalAppData | + +## 4. 参照の読み方 + +- **資料の事実:** 公開 API の存在、スレッド安全性の注意、ライセンス表記、サポート対象等。上表に対応する一次資料で確認する。 +- **設計判断:** Qt Quick と直接 PDFium の組合せ、ワーカー分離、初期 OS、PDF 描画ルートの一本化等。[技術選定・設計判断](09-decisions-roadmap.md) の ADR に記録する。 +- **未検証事項:** 対象書籍の描画互換性、特殊な構造見出しの座標、実機性能、OS 別 sandbox、最終配布物。公式資料に機能があることだけで合格とせず、受入試験で確認する。 + diff --git a/docs/design/sha256.json b/docs/design/sha256.json new file mode 100644 index 0000000..2281350 --- /dev/null +++ b/docs/design/sha256.json @@ -0,0 +1,13 @@ +{ + "00-guide.md": "179fa4a28b5ba994d3d71ffca310da78f071520f9fc8b18f529e5e5f2210ac38", + "01-requirements.md": "ee35438cf3c749b7410db2b1e4b941dd3a5fc1e3b4bb1cd6c0e3cf6056c34961", + "02-architecture.md": "afbc10d91dc6ddcd9727f3af1b861535c6d9e6da977ac19a9e2ad55c49bf83c3", + "03-formats.md": "5590019c96f8935175670b9d1a4876d5f365303e63c53953de006cde105eaa53", + "04-ui-navigation.md": "9ccb90ad0549c8a8dc41b2d883842ebcf28358796398cf62c36e218980387456", + "05-data-contracts.md": "2b9d28523e68950b48d57db363c663416c957145ad3f4e460774ac3740287a71", + "06-config-storage.md": "c7e00e61f686c36288a87f01dc00429a68174001984def78b75446b1e6c954ed", + "07-security-errors.md": "855450a9791dce0e6afb9f0e18f2f5bbd7b6cecfe6cf8c5ad677ac2af5608c8a", + "08-performance-tests.md": "eb852e6b6016e732c715ed061ea25ed1a911c092bb6de6e71aabbf4acb4f6483", + "09-decisions-roadmap.md": "55c176637d017acc66b46ba1780100854f671442d465c329b5d258f4c8fb2ebb", + "10-references.md": "4d0614abd7104a1e68c3b061fc797c3e1576d1127907a08c75c6e008da2ca0da" +} diff --git a/docs/font-broker-contract.md b/docs/font-broker-contract.md new file mode 100644 index 0000000..3a70b9d --- /dev/null +++ b/docs/font-broker-contract.md @@ -0,0 +1,107 @@ +# Font broker contract + +This implementation supplements design 03 §3.2 and 05. The selected font is +transferred as an immutable, bounded byte snapshot through the existing IPC +connection. No filesystem path or OS font handle crosses the boundary. The +main process selects fonts through Fontconfig on Linux and GDI on Windows; +PDFium and SFNT/TTC table parsing remain inside the sandboxed PDF worker. +See [the API investigation](font-broker-plan.md) for pinned upstream evidence. +Windows implementation and native Windows execution are separate claims; +consult [the validation record](VALIDATION.md) for actual execution coverage. + +## Request envelope and identity + +Only a `WorkerProcess` with an explicitly registered font handler accepts +`font.map`, `font.read`, or `font.close` requests. Production registers the +handler only for PDF workers. Requests use the current parent operation's +`sessionId`, `generation`, and `requestId`. The payload contains a positive, +monotonically increasing `fontRequestId`; the result or error repeats it. +Only one font request may be outstanding. These are control frames, with no +streaming `more` field and the existing 1 MiB encoded control-frame limit. +Unknown fields, wrong types, stale identities and unissued handles are rejected. + +The main process routes font requests before ordinary PDF responses. It never +finishes or replaces the active PDF operation or extends its deadline as a +side effect. The callback API removes `fontRequestId` from request/result maps; +the transport layer owns that field. Broker completion returns either the +inner result map or `{error: {code, message}}`. Cancellation discards pending +completion and invalidates issued handles. A parent timeout error can end a +pending font request without interpreting it as successful font acquisition. + +| Operation | Additional payload fields | Additional result fields | +| --- | --- | --- | +| `font.map` | `faceLocal` bytes, `weight` integer, `italic` boolean, `charset` integer, `pitchFamily` integer | `found` boolean. If true: `fontId`, `actualFaceLocal`, `charset`, `size`, `sha256`, `substituted`, and exactly one of `faceIndex` / `faceOffset` | +| `font.read` | `fontId`, `offset`, `length` | Same `fontId` / `offset`, and exactly `length` bytes in `data` | +| `font.close` | `fontId` | `released: true` | + +Names use the system local encoding as required by the pinned PDFium public +API, contain no NUL, and are at most 256 bytes. Requested names may be empty; +selected names must not be empty. Weight is 0–1000. Charset is one of +0, 1, 2, 128, 129, 134, 136, 161, 163, 177, 178, 204, 222, 238. +Pitch/family accepts family values 0x00–0x50 in steps of 0x10 with pitch 0–2. +Names are typed family values, never fontconfig pattern expressions or paths. +The font ID is 32 lowercase hexadecimal characters, scoped to one service. +SHA-256 is 32 raw bytes. Face index is 0–65535; face offset is inside the snapshot. + +## Limits and errors + +| Resource | Bound | +| --- | --- | +| Snapshot | 64 MiB per font/collection | +| Read | 1–65536 bytes; checked against the issued snapshot's actual size | +| Open font handles | 16 per session | +| Different selection requests | 256 per session, including misses | +| Total transferred font bytes | 512 MiB per session | +| Worker snapshot cache | 128 MiB; live callback references cannot be evicted | +| Broker snapshots | 256 MiB across active, staging and revoked services; global LRU of unreferenced snapshots | +| OS font threads | Two per application process; excess services fail explicitly | +| Broker task queue | Eight entries per service (production wire permits only one pending request) | +| One reply / complete font fetch | 5 seconds / 15 seconds, within the unchanged parent deadline | + +`E_FONT_LIMIT` is a new registered error for these resource bounds. It is not +reported as a missing font. `E_FONT_FAILED` is a new registered error for OS +font acquisition, unsupported/invalid font data. A snapshot hash or wire mismatch uses the protocol-failure path. +Existing `E_WORKER_TIMEOUT` and `E_WORKER_CRASH` represent transport/deadline +failure. All four font error maps contain `fontRequestId`, `code`, and a +nonempty message of at most 4096 characters. Malformed reverse RPC uses the +existing worker protocol-failure path. A genuine absent match is the success +result `found: false`; PDFium may use its built-in fallback and the adapter +records a warning. A communication/resource error must fail the PDF operation. +Fatal font errors remain sticky for the adapter lifetime, because PDFium may +have cached an internal substitute during the failed call. Retry requires a +new worker/document; clearing only the error would hide that failed selection. + +## Execution and font data + +Normal PDF operations run from a queued invocation after the transport's +receive stack unwinds. Font responses are routed during the bounded nested +wait without reentering PDFium or starting another PDF operation. Main-thread +GUI processing never waits for Fontconfig/GDI. Revocation prevents new work +and drops late callbacks, while an already running OS call retains its bounded +thread and snapshot charges until cleanup. Snapshot eviction considers unused +entries across services, so an old document cannot monopolize reclaimable +cache space while a new document opens. In-process test/low-memory constructor +arguments may lower the cache bound; they cannot raise the production cap. + +The worker always installs `FPDF_SYSFONTINFO` version 2. A missing provider +does not reactivate PDFium's default filesystem font mapper. The production +Linux worker receives no font-directory grants. Downloaded chunks must match +declared size and SHA-256 before the immutable snapshot reaches PDFium. + +For a TTC, `ttcf` returns the complete collection and table zero returns the +bytes from the selected face offset to the end. A normal table tag resolves +through that face's checked directory. A short non-null caller buffer is +zero-initialized and filled with the available prefix; the required size is +returned. This also supports the pinned PDFium mapper's 1024-byte checksum +read. Font parsing rejects out-of-range tables, invalid collection indices and +duplicate table tags. Static TTF/OpenType/collections are the initial scope; +named variable instances are not silently mapped to face zero. A collection +may contain at most 256 faces and each face at most 4096 table entries. These +parser bounds are separate from the wire field range for an OS face index. + +Font substitution depends on installed candidates. Fixed aliases and family +priorities are maintained in `src/broker/font_backend.cpp`; selected family, +charset, face metadata and substitution status describe the actual result. +The synthetic unembedded Japanese H/V corpus exercises native CID writing +modes. Its independent-renderer comparison is additional evidence, not a +human-approved golden image or a claim about every PDF CMap. diff --git a/docs/font-broker-plan.md b/docs/font-broker-plan.md new file mode 100644 index 0000000..3cb1bbd --- /dev/null +++ b/docs/font-broker-plan.md @@ -0,0 +1,300 @@ +# PDF font broker 実装計画 + +調査日: 2026-09-19。対象: PDFium 155.0.8057.0、固定 commit +`a5a7089234f121990b336b3841008009dca143bf`、Qt 6.11.2。 +この文書は実装前の調査・計画を保存したものである。現在の実装契約は[font-broker-contract.md](font-broker-contract.md)、実行結果は[VALIDATION.md](VALIDATION.md)を参照。以下の「現行」「未実装」は調査時点を指し、Windowsの実行確認を示すものではない。 + +## 結論 + +最小の推奨構成は、Main 側の FontBroker が OS font 索引と選択を管理し、 +PDFWorker の公開 `FPDF_SYSFONTINFO` version 2 callback から、既存 IPC を使う +限定的な逆方向 RPC で選択済み font のバイト列を取得する方式である。 +font の表・glyph の解析は PDFWorker 内に残す。Main に PDFium をリンクしない。 +新しい socket、継承 handle、任意パス指定、font ディレクトリーの Worker 読取り権限は追加しない。 + +バイト列方式なら Linux の SCM_RIGHTS と Windows の起動後 handle 複製を別実装にせず、 +現在の socket/匿名 pipe で共通化できる。全 font の先行転送ではなく、要求された font のみを +64 KiB ごとの明示的な read 要求で取得する。巨大な TTC の一括 IPC frame も避けられる。 + +現行 `src/pdf/main.cpp:16` は `/usr/share/fonts`、`/usr/local/share/fonts`、 +`/etc/fonts`、`/var/cache/fontconfig` を Worker に許可している。これは削除対象である。 +固定版 Linux PDFium の既定実装は fontconfig ではなく、フォルダー列挙と独自の +CJK 代替候補を使う。したがって Broker を fontconfig に替えるだけで、同一 Linux 上でも +未埋込み font の選択が変化し得る。 +[固定版 Linux 実装](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/linux/fx_linux_impl.cpp) + +## PDFium 公開 API の適用 + +ローカル `.deps/pdfium/include/fpdf_sysfontinfo.h` の 49–52 行に version 2 の +per-request matching があり、86–87 行では EnumFonts を呼ばないことが明記されている。 +version 2 は experimental のため、この固定版への依存と更新時の回帰試験を明記する。 +private header の include や private symbol の利用は不要である。 + +| Callback/公開関数 | 推奨実装 | +| --- | --- | +| `FPDF_SetSystemFontInfo` | `FPDF_InitLibraryWithConfig` 後、文書読込み前に Worker 専用 adapter を登録 | +| `MapFont` | weight/italic/charset/pitch-family/face を Broker へ送り、選択済みバイト列を得て Worker 内 `FontBlob` を返す | +| `GetFont` | version 2 では主経路でないが、同じ選択処理への限定 wrapper として用意 | +| `GetFontData` | Worker の immutable blob から全体または選択 face の table を返す。パスを開かない | +| `GetFaceName`/`GetFontCharset` | Broker の選択結果を返す。要求名を実際の選択名として偽装しない | +| `DeleteFont` | Worker の callback handle を破棄。PDFium が保持するコピーとは寿命を分離 | +| `Release` | `FPDF_DestroyLibrary` まで adapter 自体を生存させる。二重解放しない | + +`MapFont` の face は公開 header 上「system local encoding」であり、無条件に UTF-8 と +解釈しない。callback 側で最大 256 bytes まで NUL を探し、raw bytes を RPC に載せる。 +Broker が当該 OS の同一規約で解釈し、別名表に照合する。埋込み font の処理は既存 PDFium +のまま維持する。custom adapter が欠けた状態で既定 filesystem font mapper に戻す経路は設けない。 +[公開 API と固定版 wrapper](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/fpdfsdk/fpdf_sysfontinfo.cpp) + +想定する内部 API は次の程度に限定する。名前は実装時に確定する。 + +```cpp +struct FontRequest { + QByteArray faceLocal; // <= 256 bytes, no embedded NUL + int weight; // 0..1000 + bool italic; + int charset; // supported FXFONT_* values + int pitchFamily; // accepted pitch/family bits only +}; +struct SelectedFont { + QByteArray fontId; // opaque, scoped to this worker session/generation + QByteArray actualFaceLocal; + int charset; + quint64 size; + QByteArray sha256; + int faceIndex; // Linux FC_INDEX for a supported static face + quint64 faceOffset; // Windows GDI-derived TTC face offset; otherwise unset + bool substituted; +}; +// Broker-owned asynchronous service; no PDFium dependency. +// select(request), read(fontId, offset, length), release(fontId), revoke(session) +// Worker-owned synchronous callback adapter over the routed async transport. +// fetchSelected(request) -> immutable FontBlob +``` + +## RPC 契約 + +新 operation は `font.map`、`font.read`、`font.close` の三つとする。 +既存 envelope の `protocolVersion/sessionId/generation/requestId/operation` と +`payload` XOR `result/error` を維持する。`requestId` は現在実行中の親 PDF 要求の ID、 +各 payload と result/error の `fontRequestId` は Worker が単調増加させる副要求 ID とする。 +同時に保留する副要求は一つだけである。 + +| Operation | Worker → Broker payload | Broker → Worker result | 主な照合 | +| --- | --- | --- | --- | +| `font.map` | `fontRequestId, faceLocal, weight, italic, charset, pitchFamily` | `fontRequestId, found`。found=true のとき `fontId, actualFaceLocal, charset, size, sha256, faceIndex/faceOffset, substituted` | PDFWorker にだけ有効。現在の親 requestId/session/generation 一致。文字列をパスや fontconfig pattern 構文として解釈しない | +| `font.read` | `fontRequestId, fontId, offset, length` | `fontRequestId, fontId, offset, data` | ID は同セッションに発行済み、`1 <= length <= 65536`、加算 overflow と `offset+length <= size` を確認。返却サイズは要求どおり | +| `font.close` | `fontRequestId, fontId` | `fontRequestId, released:true` | 発行済み ID を一度だけ解放。revoke 後の ID は無効 | + +全応答は control frame のまま 1 MiB 以下とし、64 KiB を超える data は拒否する。 +`more` は使わず、一回の read に一回の応答を返す。最終 size と SHA-256 が一致してから +blob を PDFium に公開する。CRC 等の font 内容検証を Main で行う意味ではなく、転送を +途中で使わないための完整性確認である。 + +Broker の受信側は通常 PDF 応答を照合する前に、`payload` を持つ三つの font operation を +専用分岐へ送る。通常の `active_` を完了させず、キューを進めず、親 deadline を延長しない。 +不正な role/ID/offset/連続要求は既存の protocol failure として Worker を停止する。 +ArchiveWorker と試験用一般 worker には、このサービスを明示的に登録しない。 +ファイル名や fontconfig の `FC_FILE` は応答に含めない。 + +font が存在しない場合は `found:false` として PDFium 内部代替と UI の代替警告を許す。 +通信破損、資源上限超過、timeout は font 不在と区別し、当該 PDF 操作を失敗させる。 +新規 error code を増やす場合は data contract に登録し、未定義の code を先行実装しない。 + +## 同期 callback、deadlock、取消 + +現在の `src/pdf/main.cpp` は `IpcChannel::messageReceived` の direct slot 内で PDFium +を呼ぶ。そのまま callback 内で `QEventLoop` を回して font を待ってはいけない。 +Qt の `readyRead` は再帰的に再送されず、Windows の現 `WindowsPipeDevice::completeRead` +も `emit readyRead()` の後に次の `beginRead()` を置いている。いずれも受信処理の +stack を抜ける前に次の受信を待つ構成が停止の原因になる。 +[QIODevice の通知規約](https://doc.qt.io/qt-6/qiodevice.html#readyRead) + +1. Worker の受信 slot は envelope と状態を検証する router にする。通常の PDF 要求は + 一件だけ保存し、`Qt::QueuedConnection` または queued invocation で後から実行する。 + queued 実行が始まる前から reserved 状態とし、二件目の通常要求を通さない。 +2. PDFium 処理は受信通知の stack を抜けた後、引き続き同じ Worker thread で実行する。 + PDFium の並列呼出しや新 Worker thread は導入しない。 +3. callback の同期待ちは、該当 font 応答・transport failure・副要求 deadline だけで解決する。 + router は font 応答を通常要求の handling guard より先に処理する。待機中に他の PDF + 操作を実行しない。callback から C++ exception を C ABI 越しに送出しない。 +4. Main の選択/ファイル読取りは専用 Broker thread に非同期で渡す。HDC と fontconfig + config はその thread が所有する。Main UI thread で同期待ちをせず、font 選択処理から + Worker の応答を待たない。送信時に Worker の生存・session・generation を再確認する。 +5. Worker 停止/新 generation/staging 取消で Broker の ID と待機処理を revoke する。 + 進行中 OS API の終了を GUI が待つ必要はない。遅れて来た結果は捨てる。既存の process + kill と親 deadline が最終的な中断手段になる。単なる検索キュー破棄では、現在の render + に必要な font を revoke しない。 + +QueuedConnection は受信側 event loop へ制御が戻ってから slot を実行する。 +同じ thread で `BlockingQueuedConnection` を使う方式は採らない。 +[Qt connection type](https://doc.qt.io/qt-6/qt.html#ConnectionType-enum) + +## OS ごとの Broker 選択 + +### Linux + +Broker が `FcInitLoadConfigAndFonts`/`FcFontList` または `FcConfigGetFonts` で許可索引を作り、 +font のファミリー・style・charset coverage・`FC_FILE`・`FC_INDEX` を内部に保存する。 +Worker の値は `FcPatternAddString` 等の typed API の値として渡し、`FcNameParse` に流さない。 +固定 alias と優先順位を先に適用し、`FcConfigSubstitute`、`FcDefaultSubstitute`、 +`FcFontMatch`/必要なら限定した `FcFontSort` の順で選ぶ。OpenType weight は +`FcWeightFromOpenType` で変換し、固定幅/serif/italic と charset に対応する言語を保持する。 +`FcFontMatch` は事前 substitution が必要であり、`FC_INDEX` はファイル内の face index である。 +[Fontconfig 一次リファレンス](https://fontconfig.pages.freedesktop.org/fontconfig/fontconfig-devel/) + +結果は Broker が構築した索引内の候補に再照合する。Worker からファイル、ディレクトリー、 +fontconfig config、`FC_FT_FACE`、任意 pattern オブジェクトを受け取らない。選ばれた索引の +ファイルを Broker が read-only で開き、通常ファイルとサイズを確認して bounded snapshot +を作る。索引作成時と file identity が変わっていれば、その候補を再索引または拒否する。 +font 更新との競合でバイト列が壊れても、font 内容を解析するのは隔離 Worker である。 +索引の入力は OS/アプリの font 設定だけであり、PDF 内の添付 font を fontconfig に登録しない。 + +単に `sans:lang=ja` に委ねることは固定順序の代わりにならない。今回の環境では +`fc-match 'sans:lang=ja'` と `serif:lang=ja` が両方 `FORM UDPGothic` を返した。 +これは環境の観測であり一般的な fontconfig の保証ではない。 + +### Windows + +Broker が `EnumFontFamiliesExW` で名前・style・charset の索引を作る。候補を +`LOGFONTW` と `CreateFontIndirectW` で作成し、Broker 専有 HDC へ `SelectObject` して +`GetTextFaceW`/`GetTextMetricsW` で実際の選択を検証する。要求名がそのまま選ばれる保証は +ないので、許可索引と alias 規則に照合する。HDC と HFONT は Broker thread だけで使い、 +元の選択 object を戻してから `DeleteObject`/`DeleteDC` する。 +[列挙](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-enumfontfamiliesexw)、 +[論理 font の選択](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-createfontindirectw)、 +[LOGFONTW](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-logfontw) + +`GetFontData` で選択済み font をバイト列にする。collection は GDI の `'ttcf'` +`0x66637474` で全体を取得し、table 0 のサイズとの差から選択 face の offset を求める。 +通常の TTF/OTF は table 0。`GDI_ERROR`、0、上限超過を失敗として扱う。Worker に HFONT +や HDC は渡さない。font ファイルパスの探索も Worker へ移さない。必要追加 library は +Broker target の `gdi32` だけである。 +[GetFontData の全体・collection・サイズ取得](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getfontdata) + +OS font のバイト列はプロセス内の表示用途とし、文書への再埋込みや配布用ファイル生成を +この機能に含めない。同梱 font は別途、配布許諾・LICENSE・hash を固定する。 + +## TTC、table、short buffer の具体的な注意 + +公開 API に faceIndex を直接 PDFium へ渡す欄はない。固定版 `GetCachedTTCFace` は +`ttc_size - data_size` を face offset とし、それを collection 内の face index に戻している。 +そのため Linux の `FC_INDEX` も Windows の選択済み HFONT も、下表の callback の振舞いへ +変換しなければならない。全ファイルだけを table 0 として返す実装では collection の +選択 face が失われる。 +[固定版 mapper の TTC 経路](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/cfx_fontmapper.cpp#784) + +| 入力/状況 | Worker adapter の契約 | +| --- | --- | +| 単独 TTF/OTF、table 0 | ファイル全体の size/bytes | +| 単独 TTF/OTF、`0x74746366` (`ttcf`) | 0、collection ではない | +| TTC、`ttcf` | collection 全体の size/bytes | +| TTC、table 0 | `collectionSize - selectedFaceOffset`。必要な bytes は selectedFaceOffset から末尾まで | +| 通常の table tag | 選択 face の directory を参照。tag は PDFium 側の big-endian 数値、offset/length は checked arithmetic で blob 内に制限 | +| `buffer == nullptr` または size 0 | 必要サイズだけ返す | +| buffer が足りる | 全対象 bytes をコピーし、コピーしたサイズを返す | +| 小さい非 NULL buffer | 最大 buf_size だけ prefix を埋め、公開 header に従い必要サイズを返す。後述の固定版 1024-byte 読取りを試験する | + +TTC header の offsets と各 SFNT table directory を Worker の bounded reader で解析する。 +offset は collection の先頭を基準とする。face count、table count、offset + length、重複 tag、 +選択 index を検証し、Main にこの font parser を置かない。 +[OpenType collection と table directory](https://learn.microsoft.com/en-us/typography/opentype/spec/otff#font-collections) + +固定版 `GetChecksumFromTT` は 1024 bytes の buffer を渡し、返却サイズを使わず、その +buffer から checksum を計算する。したがって「短い buffer では何も書かず必要サイズだけ +返す」という解釈では未初期化 bytes が残る。adapter は buffer 範囲を初期化し、存在する +prefix を必ず埋める。短い collection の場合の余りはゼロにする。この挙動は公開 API +記述だけから推定せず、固定版実装を対象とする回帰試験にする。 +[固定版 checksum 呼出し](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/cfx_fontmapper.cpp#333) + +Windows GDI は tag の整数 byte order が異なる。PDFium の既定 Windows adapter も +`FromBE32(table)` で変換してから GDI に渡している。Worker の SFNT reader と GDI の +`GetFontData` を同じ tag 値で無条件に呼ばない。 +[固定版 Windows adapter](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/win32/cwin32_platform.cpp#456) + +初期対応は static TTF/OpenType/TTC/OTC とする案を推奨する。variable font の named +instance、色付き font、Type1 の扱いを実装せずに対応済みとはしない。索引にはあっても +未対応なら別の静的候補を選び、代替を記録する。variable instance を黙って face 0 として +渡す方式は採らない。これらは既存 mapper に対する潜在的な互換性縮小である。 + +## 上限と lifetime の初期案 + +以下は設計書に既存の値ではなく、font broker 向けの提案値である。CJK collection の実測と +性能試験で妥当性を確認してから contract に固定する。 + +| 対象 | 初期上限案/扱い | +| --- | --- | +| face 名 | raw bytes 256、embedded NUL 拒否 | +| 1 font/collection snapshot | 64 MiB。超過時は別候補、なければ明示的制限 | +| 1 read | 64 KiB、同時副要求 1、write queue に全体を先積みしない | +| font 選択 handle | session 内 16、通常は map→read 全体→close で短く保持 | +| Broker snapshot cache | 全体 256 MiB、参照中を避ける LRU。複数 session 合計で計測 | +| Worker font blob cache | 128 MiB。callback handle が参照中の blob を捨てない | +| session の異なる選択数/転送累計 | 256/512 MiB。repeated malicious names による無制限列挙を防ぐ | +| RPC 待機 | 1応答 5秒、1 font 取得全体 15秒。成功 chunk ごとに全体期限を延長しない | +| 全体の処理 | 既存親要求期限と 1.5 GiB Worker 制限を維持。font 待機もその中に含める | + +64 MiB は未検証の大きな CJK collection を切り捨て得るため、上限到達を「font が無い」と +黙って扱わない。PDFium 自身の font cache も bytes を複製するので、adapter cache のみを +数えて 1.5 GiB を守れたと判断しない。Broker の snapshot と OS index は Worker から +呼び出せる任意ファイル読み取りサービスに拡張しない。 + +## 固定 alias と回帰 corpus + +fontconfig の全ホスト設定を置き換えるのではなく、アプリが管理する候補順を先に試す。 +例として Base14 の Courier/Helvetica/Times と各 bold/italic を個別の entry にし、 +承認した同梱書体、既知の metrically compatible OS 書体、最後に charset/family に沿う +OS 選択という順を固定する。Symbol/ZapfDingbats は generic sans に置換せず、既存の +PDFium 内蔵経路または専用の承認候補を維持する。 + +日本語は Gothic/Mincho、等幅/比例、weight/italic、英語名/ローカル名を分ける。 +`@` で始まる縦書き face、Shift-JIS に由来する face 名、CJK の charset を別ケースとして +試験する。alias の具体的な font を決める前に、対象 OS に存在することと配布条件を確認する。 +試験用 BIZ UDPGothic の埋込み許諾が確認済みでも、それだけで製品の全 fallback 資産が揃った +ことにはならない。 + +| Corpus/試験 | 固定する観測と合格条件 | +| --- | --- | +| 既存埋込み日本語/画像/透明合成/CropBox PDF | font broker を入れる前後で選択・glyph・配置が変わらない。埋込み font を system font に置換しない | +| 既存 `unembedded-font.pdf` | 現在は Standard-14 の限定 corpus。選択 family/file hash/style を記録し、Poppler 対照と文字位置・幅を比較 | +| 新規未埋込み日本語 | 正しい CID encoding を持つ horizontal/vertical の原本を生成。単に subset font stream を削除する方式は glyph ID が変わるため用いない | +| alias/weight/italic/symbol | exact installed name、Base14 alias、不在名、日本語ローカル名、記号をそれぞれ検証。Broker が選んだ実名と hash も期待値に含める | +| TTC の複数 face | 同一 collection の異なる faceIndex を描画し、face 0 固定にならないこと。table offset/collection read/1024-byte prefix を単体試験 | +| malformed font bytes | truncated directory、過大 table count、範囲外 offset、加算 overflow、重複 tag、短い buffer を Worker adapter 単体で拒否 | +| sandbox 攻撃 | font broker 正常動作中でも Worker から font directory/設定/無関係ファイルを open できない。path 風 face 値を使っても任意パスを読まない | +| RPC/取消 | 偽 fontId、別 generation、重複応答、途中切断、読取り範囲超過、response timeout、font 取得中の close/kill、遅い応答を投入。UI が待ち合わせで停止しない | +| lifecycle/メモリー | 一覧にない要求の連打、font 名の大量変化、TTC 再利用、長時間読書を測定。Broker と Worker 両方のメモリー、送信 queue、UI 応答を記録 | +| 両 OS | alias policy、選択結果、PDFium adapter が同じでも OS font の bytes が違う点を記録。Windows は native 環境で実施するまで未検証 | + +この検証は固定 corpus に対する互換性範囲を増やすもので、欠けた元 font の完全再現や +任意 PDF の一致を保証しない。既存 Linux/Poppler 比較は embedded 日本語と Standard-14 を +中心にしており、未埋込み日本語・TTC・variable instance の新経路の合格証拠にはならない。 + +## 必要変更箇所と実装順 + +| 場所 | 変更 | +| --- | --- | +| 新 `src/common/font_contract.*` | font RPC の型・値・サイズ・role・ID 検証。QtCore のみ | +| 新 `src/broker/font_broker.*` | OS index、alias、選択、snapshot と quota。PDFium 非依存。OS backend は別ファイルに分割 | +| 新 `src/pdf/system_font_adapter.*` | 公開 SYSFONTINFO、immutable blob、checked SFNT/TTC reader。PDFWorker/adapter test にだけリンク | +| `src/common/ipc.*` | 三 operation の許可と envelope の限定拡張。control/data 上限は増やさない | +| `src/common/worker_process.*` | font request の role 分岐、親 active 保持、応答送信、deadline、stop 時 revoke | +| `src/pdf/main.cpp` | router と queued PDF 実行、font response 待機、font directory 権限の全削除 | +| `src/pdf/pdf_document.*` | adapter を init 時登録し destroy まで保持。テストは explicit in-memory font provider を注入可能にする | +| `src/app/controller.*` | PDF session だけに Broker を登録。代替・制限警告と寿命管理 | +| `CMakeLists.txt` | Broker Linux backend に fontconfig、Windows backend に gdi32。Main に `docview_pdf`/PDFium を追加しない | +| tests/fixtures/results | 契約、callback、実 IPC、sandbox denial、両 OS 描画比較を追加 | + +実装順は、(1) memory-only callback と TTC 単体試験、(2) bounded font 契約+偽 provider で +同一 transport の応答/取消/deadlock 試験、(3) Linux index と alias、(4) font directory +権限を外した PDFWorker で回帰、(5) Windows GDI backend と native gate とする。 +最初の callback 実装と IPC 試験は独立して進められるが、権限削除だけを先行して +既定 font mapper の失敗を内部 fallback で隠さない。 + +## 調査の検証範囲 + +公開 local header、固定 commit の PDFium 公式ソース、Fontconfig・Qt・Microsoft の一次資料を +読んで確認した。Linux の fontconfig 2.18.3 と現在の `fc-match` 選択を読み取りで確認した。 +この調査では production code を変更せず、callback prototype の実行、逆方向 RPC、GDI の +native 実行は行っていない。上限値・alias の最終集合・描画閾値は、実装と corpus 計測で +確定する必要がある。 diff --git a/docs/memory-pressure.md b/docs/memory-pressure.md new file mode 100644 index 0000000..a2bd609 --- /dev/null +++ b/docs/memory-pressure.md @@ -0,0 +1,44 @@ +# メモリ圧迫への対応 + +原設計02「キャッシュと資源管理」と06「先読みの自動抑制」を実装する。 +描画キャッシュの設定値とは別に、OSが報告する利用可能な物理メモリを監視する。 +Linuxでは `/proc/meminfo` の `MemTotal` / `MemAvailable`、Windowsでは +`GlobalMemoryStatusEx` の物理メモリ値を用いる。1秒間隔で読み、圧迫状態の変化を +Controllerへ通知する。パーサーワーカーにはこの監視を追加しない。 + +## 閾値と遷移 + +閾値は実装時に決めた暫定値であり、原設計08の性能予算を変更しない。 + +- 空きが総物理メモリの10%未満、かつ1 GiB未満になったら先読みを止める。 +- 同条件が続くと、5サンプルごとに不可視キャッシュ回収、解像度抑制、文書処理停止へ進む。 +- 空きが3%未満、かつ256 MiB未満の場合は、毎サンプル1段階ずつ進む。処置の順を飛ばさない。 +- 空きが15%以上、または1.5 GiB以上の状態が5サンプル続いたら通常状態へ戻す。 +- 取得失敗、不正な数値、欠損、重複、オーバーフローは状態を維持し、連続判定をリセットする。 + 不明な値を空きゼロや回復として扱わない。 + +監視はOSの物理メモリ報告に基づく。cgroup固有の割当量、GPUメモリ、仮想アドレス空間の +残量をこのサンプルから推測しない。これらを含む全種類の割当失敗を予知する保証ではない。 +既存のworker資源上限、renderer監視、キャッシュ予算と併用する。 + +## 文書処理 + +PDFの先読み停止は設定値を書き換えず、既存の描画世代を失効させる。 +不可視キャッシュは位置が変わった後も回収し、表示中の画像を優先する。 +解像度抑制では論理倍率とページ・注釈座標を変えず、描画時の画素密度を原則半分にする。 +workerが受け付ける最小倍率・DPRを下回らないよう制限する。読書位置を維持し、通知する。 +低解像度画像が揃った後に旧画像を回収する。回復時は通常の解像度で再描画する。 + +HTML/ZIP/EPUBのWebEngine内部キャッシュや描画解像度を外部から操作したとは扱わない。 +これらは圧迫継続の通知と、最終段階での資源提供・renderer・展開処理の停止を適用する。 +最終段階では読込中の新文書を取り消し、現在の文書の処理を止め、PDF画像も回収する。 +状態は `Recovering` とし、原本へ書き込まない。圧迫中の新規openを拒否する。 +メモリが回復しても停止した文書は自動で再読込せず、利用者が明示的に開き直す。 + +## 検証方法 + +メモリを実際に枯渇させず、同じ監視・Controller・Canvas経路へ有限のサンプルを注入する。 +状態遷移、閾値境界、破損サンプル、ヒステリシス、先読み・キャッシュ・解像度・座標の保持、 +取消、原本ハッシュ、停止後の明示的再openを試験する。Linuxの実サンプル取得も別に確認する。 +この方法をOS全体のOOM耐性試験やWindows実行の証拠とはしない。結果は +[検証記録](VALIDATION.md)へ記録する。 diff --git a/docs/pdf-structure-adapter-plan.md b/docs/pdf-structure-adapter-plan.md new file mode 100644 index 0000000..841e1a2 --- /dev/null +++ b/docs/pdf-structure-adapter-plan.md @@ -0,0 +1,64 @@ +# PDF Form構造見出しアダプターの実装契約 + +状態: **実装済み、Linux回帰確認済み**(2026-09-19)。当初のForm-only構造を取得できない制約は、Worker内の共有qpdfメタデータ読取で解消した。本書は実装範囲を記録し、全OS・任意の実文書に対するG-HEADINGS合格を宣言するものではない。[試験記録](../tests/results/pdf-structure/README.md)、[合成資料の期待値](../tests/fixtures/pdf/headings/manifest.json)を参照。 + +## 構成と所有権 + +`PdfDocument`は一つの`PdfMetadataContext`を作り、`LinkBorderReader`と`StructureReader`で共有する。qpdf 12.4.1はPDFWorkerだけのprivate依存であり、MainにはPDF解析オブジェクトを渡さない。元の読み取り専用QFileとパスワードをPDFium/qpdfの双方が使用し、パスからの再openや原本への保存を行わない。qpdf入力の論理オフセットは独立し、各読取でQFileをseekする。終了時は両reader、context、PDFium document、入力の順で破棄する。 + +構造解析は`headings(page)`時だけ行う。open時に全構造・全ページを走査しない。共有contextは生のページ木を順番に検証し、読んだページ参照を保持する。探索の例外後はそのcontextのページ探索を停止し、次要求で壊れたノードを飛ばしてページ番号を詰めることを禁止する。 + +qpdf 12.4.1の`setMaxWarnings(nonzero)`には、初期parseでページ木を走査・修復する副作用があるため使用しない。公開`QPDFLogger::setWarn`の破棄Pipelineで、初期解析を含め警告32行・64KiBを制限する。sinkは警告本文を保持・出力せず、超過はstickyにして以降の入力読取でも停止する。qpdfはsinkを呼ぶ直前に警告を内部へ追加するため、境界を越えた最後の1件は内部に存在し得る。xref回復は無効である。この選択はnull Kid保持、32/33警告、初期警告bytes超過の回帰で検証した。[qpdf 12.4.1 Objects::parse](https://github.com/qpdf/qpdf/blob/v12.4.1/libqpdf/QPDF_objects.cc)、[Pages::cache](https://github.com/qpdf/qpdf/blob/v12.4.1/libqpdf/QPDF_pages.cc) + +## 構造の所有元と順序 + +対象ページと実際に`Do`で呼ばれたFormの`StructParents`から、`ParentTree`のNums/Kidsを解決する。リソース辞書にあるだけの未使用Formは見出し候補にしない。候補の`P`をたどってH/H1–H6を得て、各祖先の順序付き`K`内の位置から表示順を決める。RoleMapは循環と深さを検証する。文字の大きさから見出しを推測しない。 + +`K`の整数MCID、MCR、配列、子StructElemを同じ順序で展開する。`Pg`は最寄りの構造祖先から継承し、局所指定を優先する。`Stm`付きMCRの所有元はそのForm object/generation、なしならページである。**MCID単独をキーにせず、所有元object/generationと組にする。** 各参照をParentTreeへ逆照合し、その所有元のMCIDが同じ見出しの祖先経路に属することを確認する。子要素のP/K不一致も拒否する。 + +跨ページのHはページ別fragmentとして返す。同一ページ内では構造オブジェクト同一性で重複を除き、同名の別見出しは統合しない。これにより、ページ、別Form、別ページに同じ数値MCIDがあっても文字や位置を借用しない。 + +## PDFiumオブジェクトとの対応 + +固定PDFium公開APIはFormの元stream object番号を返さない。このためqpdfの公開tokenizerで、ページと使用Formの`q/Q/cm/Do`、`BDC/BMC/EMC`、名前付きProperties、Form MatrixとResourcesを解析する。画像Doは構造Formに数えず、inline imageは画素を復号しない。未知演算子は対応の信頼性を下げ、壊れたgraphics stackは制約応答にする。 + +PDFium側は公開Form/PageObject APIで親子関係、局所行列、直接のMCID集合を集める。親ごとに行列とMCID集合が一意に合うFormだけを対応させ、列挙順で決めない。固定版ではFormオブジェクトのGetMatrixは呼出元CTMを示し、そのForm自身のMatrixは子オブジェクトの行列に含まれる。非identity Matrixを持つ入れ子の資料で、この扱いとページ座標を検証している。 + +`FPDFText_GetTextObject`から文字の所有Formを得て、対応済みの所有元付きMCIDへ文字とbboxを集める。`FPDFText_GetCharBox`はページuser spaceなのでForm行列を再適用しない。exactのx/y/rectは従来のPDF-point契約を保つ。位置の往復変換はCropBox、ページRotate、ユーザー回転0/90/180/270度で0.5pt以内を検証する。 + +## 応答とfallback + +| 条件 | 応答 | +|---|---| +| 構造上のH、ページ、所有元、文字bboxを一意に確認 | `source=pdf-tag, precision=exact`、x/y/rect | +| Hとページを確認したがForm対応が曖昧、同じFormを複数回呼出、文字bboxなし | `precision=page`と具体的reason。確認済みの文字、ActualText/T/Alt、名称なしの順でtitleを選ぶ。推測した座標は付けない | +| ページ/所有元を確認できない、ParentTree不整合、P/K/RoleMap/Form循環 | 空見出しと`structureLimited=true`、固定の`unavailableReason`。未確認を正常な「見出しなし」にしない | +| 解析quota超過 | `structureLimited=true, truncated=true, complete=false` | +| 応答件数/CBOR bytes超過 | 検証済み先頭項目を残し、上と同じ未完了フラグ | +| OBJRまたはannotation-owned StmOwn | 明示的制約。今回の通常Form対応に含めない | + +同じFormの複数描画先を大きな矩形にunionしてexactとはしない。同一行列・MCID集合の別Formも列挙順で解決せずpage precisionにする。見出しの存在まで確定できない場合はpage移動先も作らない。 + +## 上限 + +| 対象 | 上限 | +|---|---| +| 共有qpdf入力 | 32MiB/操作、256MiB/文書、10秒/操作 | +| 警告 | 32行かつ64KiB/文書。初期解析から適用、超過後継続不可 | +| ページ木 | 深さ64、訪問200,000、未処理ノード100,000 | +| 内容展開 | 16MiB/stream、32MiB/要求ページ。展開中のPipelineで停止 | +| content token | 1,000,000/ページ、通常token64KiB、複合operand1MiB、operand256件、入れ子64 | +| Form呼出 | 10,000/ページ、深さ64 | +| PDFiumオブジェクト/文字 | 100,000オブジェクト、深さ64、1,000,000文字/ページ | +| 構造探索 | 共通step10,000、ParentTree未処理ノード10,000、深さ64 | +| 見出し応答 | 1,000項目、title4,096 UTF-16単位、CBOR512KiB(envelope余裕込み) | + +streamの復号は公開`pipeStreamData`からbounded Pipelineへ行い、無制限のgetStreamData後検査をしない。復号済みstream・対応表・ParentTree参照のキャッシュは一要求の寿命だけで、別の文書全体構造キャッシュを設けない。OS側Workerメモリ制限、監視、取消時終了も維持する。Windowsは継承handleを包む同じQFile契約を使用するが、native実行の証拠は本記録に含まない。 + +## 検証範囲と残る制限 + +26個の構造資料で、元の6資料、Form-only、RoleMap/名前付きproperty、非identity入れ子、別Form同MCID、構造順、回転/CropBox、跨ページfragment、vector fallbackを検証した。陰性資料は循環、MCR/Pg/Stm不正、ParentTree所有矛盾、graphics stack破損、16MiB直前/超過、CBOR部分応答を含む。共有context専用資料4個と既存null Kid資料で、ページ番号保持と診断上限も確認した。 + +実Worker/FontBroker/IPCはexact、曖昧・繰返しpage fallback、quota、部分応答、取消を検証した。暗号化Formはqpdf CLIで試験時に作り、誤password拒否・正passwordの同じ借用QFileによるexact抽出・原本不変を確認した。画面上のForm-only heading移動はApp試験が別に担う。全体のrenderer比較、性能、配置、OS別受入記録は総合検証文書を参照する。 + +OBJR/StmOwnは未対応で、曖昧な描画instanceはpage fallbackに留まる。未検証の制作ソフト由来構造やnative Windowsの結果を、この合成資料の成功から推定しない。より広いexact対応には、元stream IDを公開するPDFium API等の別評価が必要である。 diff --git a/docs/resource-warning-contract.md b/docs/resource-warning-contract.md new file mode 100644 index 0000000..b8fc479 --- /dev/null +++ b/docs/resource-warning-contract.md @@ -0,0 +1,33 @@ +# 資源の警告集計 + +HTML・HTML ZIP・EPUBで読み込めない資源があっても、表示可能な本文を維持する。最初の検出時に一度だけ通知し、`:info`で理由別の検出件数を確認できる。件数は同じ資源の再試行も含み、固有URLの数ではない。 + +検出は三つの経路を使う。 + +- ResourceHandler: `ResourceFailure` の有限分類を使い、不在、危険な参照、OSアクセス拒否、許可外の資源形式、サイズ上限、アーカイブの展開上限・CRC/長さ破損、容量不足、保存失敗、読取I/O失敗、Worker停止・期限超過を区別する。OSやWorkerの生のmessage・パスは受け渡さない。 +- DocumentInterceptor: 通信・文書外参照の拒否。Chromiumの割込み処理では上限付きatomicカウンターだけを更新し、250msごとにMainで集約する。 +- ApplicationWorldの固定スクリプト: ブラウザー自身が発生させた`securitypolicyviolation`のうち、強制適用されたもの。画像、CSS、フォント等のdirective分類だけを取り出す。 + +集計は文書セッションのメモリー内だけに置く。URL、ファイル名、blockedURI、sourceFile、policy本文、sampleは収集しない。理由は固定24種類(broker 12、network 1、CSP 11)、各件数は999,999で飽和し、上限では「以上」と表示する。ブラウザーからの報告は固定11キー、有限の正整数、上限を全件検証してから反映する。不正な一項目があれば報告全体を捨てる。 + +| 検出 | 表示コード | +|---|---| +| 索引にない資源、OSが返す存在しないファイル・ディレクトリー | `E_RESOURCE_MISSING` | +| root/型/リンク等の安全検査拒否、OSの権限・共有拒否、許可外MIME(理由は別集計) | `E_RESOURCE_BLOCKED` | +| 1資源256 MiBまたは1書込64 KiBの上限 | `E_RESOURCE_LIMIT` | +| アーカイブの累積・資源展開上限 | `E_ARCHIVE_LIMIT` | +| CRC・圧縮データ・長さの不整合 | `E_ARCHIVE_CORRUPT` | +| OSが明示するディスク容量・割当量不足 | `E_STORAGE_FULL` | +| その他の一時資源の作成・書込・同期・確定失敗 | `E_STORAGE_FAILED` | +| その他の資源open・read失敗 | `E_RESOURCE_IO` | +| 不正/未知のworker失敗、期限超過 | `E_WORKER_CRASH` / `E_WORKER_TIMEOUT` | + +`E_RESOURCE_LIMIT`、`E_STORAGE_FAILED`、`E_RESOURCE_IO` はこの追加契約のコードである。未知のOS失敗を欠落や容量不足と推測しない。WindowsではNTSTATUSをOSの変換APIで分類し、reparse/type/link/sizeは開いたhandleの情報から判定する。QFileは両OSとも所有済みfdを採用する。open後のread失敗は一度だけqueued signalでMainへ伝え、破棄・失効済みセッションには反映しない。 + +展開のcompletionは成功・取消・原因分類を保持する。WorkerProcessが実際に停止してRPC callbackを破棄した場合も、failed通知で保留中の資源jobへ停止理由を渡してからセッションを失効する。明示的な取消・文書切替の失効はCancelledのままとし、件数を追加しない。CRC・展開上限・不正worker応答では後続展開を停止し、既に表示した本文を保持する。失敗した途中ファイルは公開しない。保存容量不足とCRC不整合を同じコードへまとめない。汎用の章load失敗は`E_OPEN_FAILED`とし、資源不在と決め付けない。 + +取り消した文書、終了した文書、失効したセッションの報告は反映しない。固定EPUBの見開きでは現在表示する両ページから同じ文書へ集約する。履歴・保存位置には件数を保存しない。すべてのブラウザー内拒否が必ずCSPイベントを発生させるとは限らないため、一覧は実際に検出した事象を表す。 + +[Qtのinterceptor契約](https://doc.qt.io/qt-6/qwebengineurlrequestinterceptor.html)に従い、interceptRequest内でprofileのAPIやUIを呼ばない。[CSP仕様](https://www.w3.org/TR/CSP3/#securitypolicyviolationevent)のイベントを利用し、合成イベントは`isTrusted`で除外する。Qt 6.11.2での実際のイベント配送と通信拒否は[試験記録](../tests/results/resource-warnings/README.md)に残す。 + +分類の追加回帰は[resource-classification](../tests/results/resource-classification/README.md)に記録する。Windows nativeのアクセス拒否・容量不足・I/O配送は未検証であり、補助コンパイルはその代わりにならない。 diff --git a/docs/validation-record.json b/docs/validation-record.json new file mode 100644 index 0000000..b7cc2f3 --- /dev/null +++ b/docs/validation-record.json @@ -0,0 +1,1820 @@ +{ + "schemaVersion": 7, + "recordedAtUtc": "2026-09-19T20:32:40.417660+00:00", + "goalComplete": false, + "scope": "Linux v2 candidate integrated into dedicated Arch and Ubuntu builds and a local validation deb. Earlier evidence is retained by immutable parent references. Target Windows/physical/human/release gates remain incomplete.", + "previousValidation": { + "path": "tests/results/pdfium-intent-build/record.json", + "sha256": "47b4dc2efbf8e671f904610b3047400dbd3ceb0fa10d6bd53fb04f3727943e7e" + }, + "candidateBuildAnchor": { + "path": "tests/results/pdfium-intent-context/record.json", + "sha256": "80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b" + }, + "pdfiumRenderingIntentCandidate": { + "sourceRevision": "a5a7089234f121990b336b3841008009dca143bf", + "source": "build-pdfium-intent-context/source", + "library": "/home/kamat/Documents/ChatGPT/docview/build-pdfium-intent-context/source/out/patched/libpdfium.so", + "librarySha256": "cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403", + "patch": "cmake/patches/pdfium-rendering-intent-context.patch", + "patchSha256": "470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616", + "patchSnapshot": "tests/results/pdfium-intent-context/patches/470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616.patch", + "report": "tests/results/pdfium-intent-context/arch/report.json", + "reportSha256": "a35d03acaa586ab45ad91d48c4a4a1f7b395f767d7616f70d047c7aeefbfb34d", + "productionDependencyReplaced": false, + "comparisonAccepted": false, + "candidateProbesPass": true, + "candidateProbeCount": 972, + "upstreamTestCount": 1979, + "docviewCTestGroups": 22, + "oracleRevision": 2, + "supersedes": "The old shading-pattern-inherit expectation was incorrect. The historical v1 pass remains a numerical result against that old expectation, not pattern conformance evidence." + }, + "candidateIntegration": { + "selectedLibrary": { + "path": ".deps/pdfium-context/lib/libpdfium.so", + "sha256": "cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403" + }, + "originalProviderPreserved": true, + "buildInfo": { + "path": ".deps/pdfium-context/BUILDINFO.json", + "sha256": "6fbb6f24a7ca241150f8abf8d9ad031c76d9cfffc809026a61068c357d47ae18" + }, + "reviewedLock": { + "path": "cmake/pdfium-candidate-v2.lock.json", + "sha256": "a5b470d1647ab206cd6a3298e2aa85f17eab9ba0715a51efe764050c4cb50034" + }, + "archExecutable": "build-context/docview", + "archInstalledExecutable": "build-context/install/bin/docview", + "ubuntuPackageRecord": { + "path": "tests/results/pdfium-intent-context/ubuntu-package/report.json", + "sha256": "75f39a4acbaca51b0205d8d5fb97bfcc8355bf9538685d2cd6c6d0745fc0057c" + } + }, + "executables": { + "docview": "b69f80dfb88f45bd07b024536ffaee6d23e2a13f93feaf433e6fb5958988b047", + "docview-pdf-worker": "a8b02413a0a7e27f84c9e93c839fe82f9c75be6bb9f85147f94b1e5068713f23", + "docview-archive-worker": "6d5572068a0994dc97dee5d54ea42a1868e03448bbf10fbd162bcb841519dbd2", + "pdf-worker-evidence": "6337508078d83cdcf179b8ac18339ca73a94fb243b30addde6e023406e8dd08a" + }, + "validationReports": { + "archCandidate": { + "path": "tests/results/pdfium-intent-context/arch/report.json", + "sha256": "a35d03acaa586ab45ad91d48c4a4a1f7b395f767d7616f70d047c7aeefbfb34d" + }, + "ubuntuCandidate": { + "path": "tests/results/pdfium-intent-context/ubuntu/report.json", + "sha256": "68d642cc41157e75418d3174bde1727e93ad5c0159bb330e331d6816d3c80601" + }, + "archNewBuild": { + "path": "tests/results/pdfium-intent-context/arch-integration/report.json", + "sha256": "46061fd90e3dfa4114eee9c5d4297df587f36b520b6555f46698f907adbe5671" + }, + "ubuntuNewBuildAndPackage": { + "path": "tests/results/pdfium-intent-context/ubuntu-package/report.json", + "sha256": "75f39a4acbaca51b0205d8d5fb97bfcc8355bf9538685d2cd6c6d0745fc0057c" + }, + "sdkFreeUbuntu": { + "path": "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/report.json", + "sha256": "e1969d6a68ec31ca3343cfdf4e4ad7fec52be56d859c80ad0c2f5af013ebf3ed" + }, + "performance": { + "path": "tests/results/pdfium-intent-context/performance/report.json", + "sha256": "bac1ff9cb7c6488b9082d78b1e6426072ecafa22a3b18f02d45af9e64b96598b" + }, + "renderReview": { + "path": "tests/results/pdfium-intent-context/render-review/report.json", + "sha256": "f470004f1f8162066243333d09c4b56a8504f066a4ee1bb4cc7861dfb5e045a5" + } + }, + "oracleCorrection": { + "path": "tests/fixtures/pdf/rendering-intents-context/manifest.json", + "sha256": "ed2433cac54ce8b3cbe854c73ebd4a433f23924cab5263ae7486b775e48444cf" + }, + "historicalOracleLimitation": "The v1 shading-pattern-inherit expectations selected paint-time RI incorrectly. Its numerical pass is preserved as historical evidence, not pattern conformance. Revision 2 keeps the original PDF/profile bytes and corrects eight rows.", + "referenceCmm": { + "path": "tests/results/pdfium-intent-ubuntu/reference-cmm/verification.json", + "sha256": "0d995faa6493cf5dcc5bd572ffdef730865365a0fad80e4980a7f28590bc4e8d" + }, + "remainingAcceptance": [ + "Windows 11 native build, sandbox/pipe/IME execution and distribution tests", + "Target-OS physical GPU/display/input, reference hardware and long-duration acceptance", + "Human approval of G-RENDER reference images and documented comparison differences", + "Final public distribution/license decision and complete corresponding-source/notice assessment" + ], + "sourceSha256": { + "CMakeLists.txt": "80fc60813ea63085c42552111aaef7806c0f29262a82f43480906882ca37353a", + "cmake/InstallWindowsWorker.cmake": "3a7becbaa3826fbf847ebbb51d274a8a383be2a2eef2a720230bac83152f2c2e", + "cmake/LinuxDevelopmentPackage.cmake": "83fd6c9f13a9b777867e13bd4c2e267a2fb54c0a8d0a7ae313dd7caa7bbf3794", + "cmake/Pdfium.cmake": "a518ca44d6b79bfeb7d2d3b1a1d109bef373ae567459114a0800407d041fe47d", + "cmake/PdfiumSupplementalNotices.cmake": "e374fb8ad26c8f05ece8be368c331e03c9975350e018a94120c513f94c661842", + "cmake/StageWindowsWorker.cmake": "9b6158d667053af9fac89ca30c9e7d6fac6e037209a73c41177e1d5c6464ba3f", + "cmake/WindowsDeployment.cmake": "ec6731f9e6763b08bc8fc20b35e4cb9ff3fd4bd4447ab9ad0699fcfc1b62d645", + "cmake/WriteWindowsRuntimeManifest.cmake": "bf970736c12a2fb8049bf8d6f0e61293a55662198916b3e6453f87e27d922f6a", + "cmake/fetch_pdfium.py": "7951261ebfa307ab6d4552d10b6a9b1a82eea93bf8e0ab60ace1aeed7dff9659", + "cmake/patches/pdfium-rendering-intent-context.patch": "470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616", + "cmake/patches/pdfium-rendering-intent.patch": "96554cc0e89f808ea90718f14f62c3a7313b2bb4c010bc67b6434c48c11f26f7", + "cmake/pdfium-candidate-v2.lock.json": "a5b470d1647ab206cd6a3298e2aa85f17eab9ba0715a51efe764050c4cb50034", + "cmake/pdfium.lock.json": "7dee9b9cde0acc2b0eb80e8973473ccce644927886c590b5edfb17af1716088a", + "qml/Main.qml": "93ee69dc1d7a64ebbf6e54786d499299b16ac89b75fbd92f38325ca2aaa6048c", + "qml/WebPane.qml": "ce09579241ca79021b19f1066e19b573e3f8f3895b4b0532f10b73b49cadc640", + "resources/config.example.toml": "79223337c07e4c647d34cf5934867cfcebe26108d2d53b6e9d0b0d3a1aded904", + "resources/i18n/docview_ja.ts": "ed35d6fa6065c5c6b81b0f65fc462028f64cfd52af373e90f32e471d1e8e9053", + "resources/licenses/README.md": "22ddcff871dd7af399bdb4ac0bcad16b60c1afa41ce62b66041b5cb96c4c7a99", + "resources/licenses/linux-supplemental/e2fsprogs/NOTICE": "5da5ef153e559c1d990d4c3eedbedd4442db892d37eae1f35fff069de8ec9020", + "resources/licenses/linux-supplemental/libidn2/COPYING": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "resources/licenses/linux-supplemental/libidn2/COPYING.LESSERv3": "e3a994d82e644b03a792a930f574002658412f62407f5fee083f2555c5f23118", + "resources/licenses/linux-supplemental/libidn2/COPYING.unicode": "01d621eef165cf4d3d3dbb737aa0699178d94c6f18cf87e9dde6db3ca7790f46", + "resources/licenses/linux-supplemental/libidn2/COPYINGv2": "edaef632cbb643e4e7a221717a6c441a4c1a7c918e6e4d56debc3d8739b233f6", + "resources/licenses/linux-supplemental/opencore-amr/LICENSE": "8b3f1762349248d444ab9acbafe73941254e36e1064954da56bb9ddbd5873ddb", + "resources/licenses/linux-supplemental/sources.json": "458c77ff13a675db73ecfecb9bf14e7670aa1467f9500d2017c320e7aab0ed08", + "resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt": "539dd7aed86e8a4f12cbdd0e6c50c189c7d74847e4fecc64ce2c6ee3a01da38b", + "resources/licenses/pdfium-supplemental/libcxxabi-LICENSE.txt": "e2b35be49f7284a45b7baca8fc7b3ab7440e7902392b2528a457816b5bb2a15c", + "resources/licenses/pdfium-supplemental/sources.json": "97d75f0b50e5ad8114229ec4e5cece147272563dfed4fd434087b70d871a114a", + "resources/licenses/qpdf/LICENSE.txt": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "resources/licenses/qpdf/README.txt": "4f30a334d1396f3f7154e2ec5447ec461ee27410c87905f7123a4a1404fc1926", + "resources/licenses/qt-embedded-notices/20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0.txt": "20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0", + "resources/licenses/qt-embedded-notices/23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44.txt": "23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44", + "resources/licenses/qt-embedded-notices/3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282.txt": "3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282", + "resources/licenses/qt-embedded-notices/4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed.txt": "4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed", + "resources/licenses/qt-embedded-notices/7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c.txt": "7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c", + "resources/licenses/qt-embedded-notices/9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528.txt": "9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528", + "resources/licenses/qt-embedded-notices/c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49.txt": "c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49", + "resources/licenses/qt-embedded-notices/sources.json": "8d90e93aa487eddef4b81722a53b89953cbb07b45d02488e8ea0413f300fd723", + "resources/linux/deb-postinst": "f2f1ea47e142072aa16c01c5b447c66096ade341541fcb4eaf6f2ff174f41063", + "resources/linux/deb-prerm": "6993a2366dec68329d9fdf98271f8b33cf4bad5bfb4bbb544302434425a93bb0", + "resources/linux/docview-launcher": "72183e4f22af1c5fe20e296e7be06a9315b7b58383dbdc0148d42992a3a55eb2", + "resources/linux/docview.apparmor": "89c8fd5cb9ec2d8e4d671ddbdbb6606b1b355fd30191c7a631670600eddc7908", + "resources/linux/docview.desktop": "14e08b5af596ad54425fd6d6bb4a882e78e20513eefa02205afd04e27aa36655", + "resources/reader.js": "d0f5bcd8f1a5576ec9765cf50e59590c3b855ef1804d8643459486a7fe0e2d58", + "resources.qrc": "65fef465bb7061d2764b14df3fa3fa12b4e614a2b26468e2a10a4fc3aba8d32f", + "src/app/benchmark.cpp": "638feb7c0d79509277d82639bffadbca4eb39ffa990fe99c209ec1498d16a93b", + "src/app/benchmark.h": "82b8c28741da1835a35a03d2a9bdc28757216240980fc355b4de77d7966eddfe", + "src/app/controller.cpp": "4364dbee61b54be2c3b393e05fcfddf51256beccd523aa91ea5773a400321350", + "src/app/controller.h": "7e92890708e3a582342085cd5ddc6ca8959b5e8efea8927a3be3ff05ff84b393", + "src/app/main.cpp": "0fa6abc7ae88ea28569dac83f4cff5b1c913344e4483abe6c49dd3142716f57d", + "src/app/pdf_canvas.cpp": "c87798e4795d0972c1d7e23090bca62be364421cd179d69e22e4b1813d04ab1b", + "src/app/pdf_canvas.h": "6669757a179949972a9b57664c28fa6e7349ae14f34ebbe3d47787b81c9cf804", + "src/app/translations.cpp": "5727ec00ad99c57d0ea46214ad320b1188157719d9f4563098eaf59ce4ad0de1", + "src/app/translations.h": "f660f430377a8e42b2c9278399cfc8800c587c2edd0196e9cfade53258784adc", + "src/archive/archive.cpp": "81ac533c7ebabbfa8cce033c87773b31ffc14794b15aaad5acfb95c25259b505", + "src/archive/archive.h": "d13cac68deb8d3bfc7c7f5afa69fe1202a8bd85bdc683d1dccc00e6b80d0c279", + "src/archive/main.cpp": "e3e84b3f6a38ddf5efadeddd74a1d92ab3e1eb84ea046b0ff5f43f12768f4ebc", + "src/broker/font_backend.cpp": "bebf3babdc2eca5fb407b1727806ae0bd0165283419bbbcdb17fda1ed5a6db4e", + "src/broker/font_backend.h": "cecad00f7a6b73dce07187210ec4ecd8149309da55fca37bc42cc1d3675febbd", + "src/broker/font_backend_linux.cpp": "7cf6c263a063578a2f3c374cf0cba4b3466f08f35532a9b24d8d6b89e996acc2", + "src/broker/font_backend_win.cpp": "d5d47f42dae5d11411d21cdcb259751f3f594ffbc4b95ae9164760dc91caa4df", + "src/broker/font_broker.cpp": "37f2da9694ef03b353bcec4d1839dfdd9f1f66eb378ed36804ec4dbd1292bb3a", + "src/broker/font_broker.h": "9b17b36ba22a14e999addf90f3ebb51d7a096c102da28ca4d241cd3719790729", + "src/common/contracts.cpp": "958a8e9a3f73966a967556bec22a13c1cb949538e0fd3ec0c2e8b7bd0595d7bf", + "src/common/contracts.h": "480159466937b853ed7b7a43143e691d47a545902f682958e1bb0ed9f3b39092", + "src/common/font_contract.cpp": "7dd2da716ab4d2897d1ab222b934bae2ef8b867951a26be15a951967cff7b0a9", + "src/common/font_contract.h": "edf7fcca4c80a0b7de00a5ac67888ea7ee091c6e4bd3d1114edfe99099938370", + "src/common/ipc.cpp": "db96d9e4cc07b45d29c8df88c733b4449cdbaab25eeaceb2dedd161f808a829a", + "src/common/ipc.h": "005bf06f8c60d7060fc2398513a8b281bd3572e7e0831b90908f600c18c20679", + "src/common/memory_pressure.cpp": "913ba4c4d4c1d0e03f0b339251e0228ec003d217d6c8df9b02146b6e3c175fea", + "src/common/memory_pressure.h": "623aefe8c38992708839ff20afa5bb64ba091ff93678cc54d3d7c9e98820b72a", + "src/common/sandbox.cpp": "de302197d6c242c2321996697c210f38c4d32fff83d64e198116a56da508d4a2", + "src/common/sandbox.h": "032f394511eb443d39c59de0e53a4d540a1522667262a481cd11118c67491ccb", + "src/common/session_storage.cpp": "a4c54c7a47e192d6377f65d71294b743d6b90c00fbec5ef3117214d5a826388d", + "src/common/session_storage.h": "bf3e047f2bbca88d7e52f189342030d708b605f86097e2aeb24d45720a24492a", + "src/common/single_instance.cpp": "3143feb8729a0ca29b06dda513b79731a296a9a7f8d975360f29c2ca35bba6ca", + "src/common/single_instance.h": "3cd854fb73c78b31ec044cd748ca845920a9583cbb2846307f7f85a96a9c80f2", + "src/common/windows_pipe.cpp": "95bdea2d4f6a240cd70cd11e87d6cf5e7f478e3bca2c11fa2cb6ccda55493ac3", + "src/common/windows_pipe.h": "a28b83520ac39d12d9e8ad58b770f1fd1a621ad56cd567fd3cb48a8c7c2d58ab", + "src/common/windows_runtime_staging.cpp": "700cda6d762e7a0038e1980e24112657d93c812331e92552564afa1136b1e6f3", + "src/common/windows_runtime_staging.h": "5eb22dea4584c84235fb8a4f8d7cee0f2f81be40bd4713782d9b1f30a555d078", + "src/common/windows_sandbox.cpp": "b00cee815013c1ca5b62d1c93475448ebf85a1c8b64b4da61a33388bf3cd0330", + "src/common/windows_sandbox.h": "ef7bf1de8bf41f05b74710f7453d62800ec3e20a1bb0fbe152b14dc765210ad8", + "src/common/worker_font_client.cpp": "3030f6b5edf441e6409e0f4ef18d7c8e6193f16132dc4676f3a9388e7b85a7dc", + "src/common/worker_font_client.h": "2f21de458080f2470f661ad37aa8d465ff9cbf50442f288c4e51a8b0c52db651", + "src/common/worker_process.cpp": "ad3240beeca2382e0440be4f7639837153edbbbcccfb38ffe329bee00b07331f", + "src/common/worker_process.h": "a096f568e31d486095ec6018e18f827f3784830d9cb7c34ca8bc5749a4af60be", + "src/common/worker_runtime.cpp": "5d9b2cbd0b23be9638703716cbe5f8da78f24a66a751f404b282111ac2932d9e", + "src/common/worker_runtime.h": "74110fd39f462aeb151360fb04844d60aa9476287b3043025c8faed3e3946f94", + "src/core/config.cpp": "cd7087f76e8a15d5009105418a088450fcac6b63549e80c4653313fdd12b5805", + "src/core/config.h": "1770700b65869d88ee8df9a15c97896f2e13fe594c664deb9a7b56456f7408ca", + "src/core/input.cpp": "af49105d0cee8c3f14d6ae06745878ff24e24f66fbc5db1faec1f75a87e9bd98", + "src/core/input.h": "2ce5c7837db05c8d7b6f292941985173f21913c57bd63ccb2b2024e37d18f12b", + "src/core/state.cpp": "c8e110d635402e32a8b267677e63b63502874a5286174a9b6a40e42354361108", + "src/core/state.h": "9ff7d0ac39df36096b80b45b4d5556dca58c4ab50a35bf3082a9b04f5b0607a5", + "src/core/types.cpp": "49069ea52e54a56cd7330c71c5501925a232f302f871d4fed939b2d246536cb0", + "src/core/types.h": "328570a10ea336c871fe2356f2bf911d5ca896b8fe375842df87acccefddd153", + "src/pdf/link_border_reader.cpp": "c487bb8139b9f562b3d3b2baedfe7bcbf1dd949784b006c372883b3f1351874e", + "src/pdf/link_border_reader.h": "eed63b0d79d4c5f8a3e395bba1ce853bfa6125ad0613a5f04eff57281cfcd8eb", + "src/pdf/main.cpp": "dbdaf7507b46a3b355eac3ca2ca578bf3997bae0a02ab7907e536f20e3b59a79", + "src/pdf/pdf_document.cpp": "533121d2d6c4c7f71a0a2a3635562319cbcf503da44888cc45304973d4b7b7f6", + "src/pdf/pdf_document.h": "f695ca8df578d5d1edae92e111ac677c374f66c599d4a6427d4d3d7b65090b26", + "src/pdf/pdf_metadata_context.cpp": "8ba6478075538d9dcc754a0548f98fc29a2629ee1797ab91e9e950e849557ebf", + "src/pdf/pdf_metadata_context.h": "5afec3c64d3dea29428c571bba7287ba31a30319666c941fb4f668b33dbf3b2b", + "src/pdf/structure_reader.cpp": "b9dd4a6dcb63dc8ab18f449da6089bb3051b90c26dbcf35783f1435682ef5719", + "src/pdf/structure_reader.h": "2413a6d4a504a628eed3b05ce9208b1fcf3f5dd56ccf5747096e3af2dcc2f7e0", + "src/pdf/system_font_adapter.cpp": "50ec61986506f4d1d64c6423686beeac5bd8a170cfa37dc49448b2f29ef8b242", + "src/pdf/system_font_adapter.h": "2aaf9ed80bc967a4fff44120509f498675ea56a3fbcda2c68d8631286476b043", + "src/web/renderer_watchdog.cpp": "9930f28043ebaa734aef1fcd1f6f39769ee3d251cafd050c573b426cab0d29d5", + "src/web/renderer_watchdog.h": "a16439b33d9d6121575e3a36fb94d76ff4caf001d4e2aa2db6d2e39703659db4", + "src/web/resource_policy.cpp": "ca43049a006cc27161ca64cdbe9b7b9451dace2f57c71d850be3ba967846c04d", + "src/web/resource_policy.h": "6598b86c4226661ad8be2641422b621fd47200fe738d7d81d8fc818796a6969a", + "src/web/web_profile.cpp": "b6cef1e8eaedd31df233d4d427929adf50b6107fed1b9e84420ee1e6054c8d93", + "src/web/web_profile.h": "120ed9759152175651df26d555f005ff106568b82bd9ce66320f890394ea180a", + "src/web/windows_renderer_identity.h": "03fc850a7adac1e679313a24ab2cbac81edf5ba2a4cfbee5bef4cef89082b407", + "src/web/windows_resource_handles.h": "b08d65a88da7a9a5f198951553770411163d60a9ad5bbb913208cdf6d6abbeae", + "tests/cmyk_lut/correct_intent_oracle.py": "1137d7193f36d008c6021ba71b9b8a73faf6df2198935605b0eff491df2e685f", + "tests/cmyk_lut/diagnose.py": "dabab4d6284232a5c4ed2650284c493d0a6f696640c3584cab5c73e1e6af6cb6", + "tests/cmyk_lut/export_pdfium_context_patch.py": "c4c3f542d7301f0c9309394e211c95215dbdf36bab491ebe87dce16fd66ac62b", + "tests/cmyk_lut/export_pdfium_patch.py": "edc237f5656b9a94536c63ca503f0b8aec4b2f6ece1aff90ba8b9f56e617e99c", + "tests/cmyk_lut/fetch_pdfium_build_tools.py": "920c5a1ffdaab5c308514a8ef4058dd31f640db641fa4e0aca47c4e20b7ab9f2", + "tests/cmyk_lut/intents.py": "0e98b53c93878aa905b5c5a4b8c168e5ad4783ed3ff96dc9e892c253d23b1ec4", + "tests/cmyk_lut/prepare_pdfium_source.py": "ddc9c78bc96a0d11503420db40e437fba562421cadcc33ee5366e33f29ad4dd2", + "tests/cmyk_lut/prepare_pdfium_tools.py": "04fe9611fe6f43cad7cf9598297246574d24b424e30573e0d8603eb628027809", + "tests/cmyk_lut/prepare_reference_cmm.py": "aafa319b918a989e64e317ca1546c32d0c220409ab765877bcdd7658f103ea07", + "tests/cmyk_lut/record.py": "e234ea6db013584b1b774133b853da40d84bc078897e0c6f782459f9fae1e525", + "tests/cmyk_lut/record_candidate.py": "685687f50a7ce6c123d311589c871e69f7603aa05f6457c6600b261ac6da3144", + "tests/cmyk_lut/record_context_candidate.py": "2bfc8fc1dba9062326689d1544ba9a82246fd0dfe0722a66df7e6cd790f326ec", + "tests/cmyk_lut/record_context_integration.py": "14902f4acf9bb20791ca6346cf392f4d2ecb3cfd544fba622c941cd5cc20ec15", + "tests/cmyk_lut/run.py": "ee1f64a813cdcabc24913b3fdff90bdb646bbf6cd65cea8f0918ac6551cf32c9", + "tests/cmyk_lut/run_ubuntu_candidate.py": "4cea6701b483b6dfb4a59d1f201c7aa806f0d60709d9a70ae15e5bf230d7b46e", + "tests/cmyk_lut/transparency.py": "170ff61b7f1a556053832e07deca7149d5ef0a5784b5b7b77b7da243aad5d459", + "tests/cmyk_lut/validate_context_candidate.py": "64fb41afbbd294ff1aa994e63d8bd9fa3f97f16073267aec963080a75b016dcb", + "tests/cmyk_lut/validate_pdfium_candidate.py": "061e172e44cd2c0423089e161f571180ac97ab1d9c43448bfb461b5a29d2ba3b", + "tests/compare_pdf_extended.py": "2434b1d85825d795ca998c8de40839eb903bc1e3da5957bc7db167c3e4e708d6", + "tests/fixtures/pdf/generate_cmyk_lut.py": "ed93e85cdf4e398409d49bbf941abfe79062c5a35bde359d75f14e4a8746c8f8", + "tests/fixtures/pdf/generate_rendering_intents.py": "822244e98fd6f10ed550882dd5c300609a87c8b9d5552bfbe5157925342849b5", + "tests/generate_render_review.py": "8aa276bf4e61571d3ba6e4942b9c3db5d94dd389a9cfe06bad995c28080a357c", + "tests/portal/record_build.py": "b32c62c5df1c0d8d8c38dee5eb104e69c44bdc648eab3d0b9f7d15e72f33399a", + "tests/portal/record_validation.py": "a7b897f508a109eb9f62fd616687d24aaeff317ca0ab1caef87bc072671a7058", + "tests/portal/run.py": "4a2e2db3994286490702c85ab4daf87c12b7bb7f111fe41792cb8ddbe37a93d4", + "tests/portal/run_performance.py": "b8eaba4d213ff073da416e09bee050b4e8954c51507bc50f71893b66ad5943e8", + "tests/portal/summarize_performance.py": "17f5cde0ed37e63e2022b0efcb438b52ebb9710ec450a845f4f25281374a64e3", + "tests/render_pdf_worker.cpp": "0ebbe0f2f66ba4642a8ec7f7ed96a1827a0421974c97d53f4280d0599af257e4", + "tests/source_archives/collect_qt_sdk_supplement.py": "9fa070a85bdd9ce58b2e6aa105f15d09133cb8703ac5c4e0fcff23ccb40a955f", + "tests/source_archives/record_qt_supplement.py": "12e0d77c0a6f00b52e893610458149f5db5df7d6ab945e3384d594ab56b0a6a6", + "tests/test_pdfium_candidate_integration.py": "067bd6833e9497f9d3ac2a495980d72b5ace6fa11505c56442d09bbf3d13ef90", + "tests/test_stage_pdfium_candidate.py": "874b8d7feb821eeb8cc0e67fb3a3d8bcb45de39e067622a7ffa219a28b6b1e6a", + "tests/test_ubuntu_package.py": "bdbad0712d5b1af77d096a18eaa489deec2fe1ccb23ee0055618314cf231268d", + "tests/ubuntu_vm/clean_package.py": "131756596ec295deec4ac979b84553a6e2251b8c49807548d9554ca2a66a035b", + "tests/ubuntu_vm/context_package.py": "e5774b1cd6265ed6a9adcdf97aab2a6fe8f106a3288fda58f6ebb91b7f35be30", + "tests/ubuntu_vm/package_smoke.py": "b5c313d13a220532b1968a1305ff5510dde18cc1f50061ce9029396403cfe845", + "tools/collect_linux_dependencies.py": "7ff07fe004af80002dccd4f0a364446eebc4339359fc114c11d14bcbfc6b0ca5", + "tools/collect_ubuntu_validation_runtime.py": "2df891ef458c43499147784a1de8af14e444f1efdb518f32028d120d9bb359ee", + "tools/package_linux_development.py": "e53ee4d54aca268f9568565bd6e5f35b7523ecd8b60a1e1cadd9818f94ce3141", + "tools/package_ubuntu.py": "0507e154a2b066a13428e8dc946ec5a3794ecf2225691704a24153cdcea4350e", + "tools/record_dependency_provenance.py": "37702fe0e3791297f6cbe8539a97aa8a2e181d1df5cf59d23e14a54c47486e7f", + "tools/stage_pdfium_candidate.py": "a1bd059eb77d1853b753268987310e85015fabaa781a8697f7c6f2d82c0150c7", + "tools/verify_pdfium_candidate.py": "f132c68e42bc137ca4f6a414bd7f12933c349fdad08a2e06e8794ee6e0b99ba8", + "tools/verify_ubuntu_package.py": "a00c5c8ca8290907316330392fe046e277cbe62f4ce71df0a01bf83536f77868" + }, + "changedSincePreviousRecord": [ + { + "path": "cmake/PdfiumSupplementalNotices.cmake", + "previousSha256": "c662fe4937489d8d274af60366703e5fff442409a8d077a46787d080243aa195", + "currentSha256": "e374fb8ad26c8f05ece8be368c331e03c9975350e018a94120c513f94c661842" + }, + { + "path": "CMakeLists.txt", + "previousSha256": "e8fb6f8069f605bdd71c297c3a75391d2ffba9bfa42689ec48f26f5297b015ed", + "currentSha256": "80fc60813ea63085c42552111aaef7806c0f29262a82f43480906882ca37353a" + }, + { + "path": "tools/package_ubuntu.py", + "previousSha256": "8adf3ffb78b5a65d9696f393ef62ae0172be18e1ae6fa3740a5d86e670686ba7", + "currentSha256": "0507e154a2b066a13428e8dc946ec5a3794ecf2225691704a24153cdcea4350e" + }, + { + "path": "tools/verify_ubuntu_package.py", + "previousSha256": "22db578f6e0eed25c9810853ce27c866639092d0907460a147791d3840325f79", + "currentSha256": "a00c5c8ca8290907316330392fe046e277cbe62f4ce71df0a01bf83536f77868" + }, + { + "path": "tests/cmyk_lut/intents.py", + "previousSha256": "6c455e29a03aa7c4450a3cb44dc469b6794ba7a2a38492f99ceeeb59e1aee8fd", + "currentSha256": "0e98b53c93878aa905b5c5a4b8c168e5ad4783ed3ff96dc9e892c253d23b1ec4" + } + ], + "documentationSha256": { + "README.md": "92b1070df1f706190a00aa52e4d78b63d4608b3466c95b39d28d4ae9913fb186", + "docs/PDFIUM-CANDIDATE.md": "d37e19169fa4843dc79220611244036f9872397fb61fdd9fe24835e446af7844", + "docs/VALIDATION.md": "b8d8e63484ef191c890ddabd20244182ae96dd967f7b9de8cc151cb27dea5646", + "docs/ACCEPTANCE-AUDIT.md": "ad6f3eb8be365a5e683c0c555c4c544cb736f90599dd5e6a3370f22e69d1110c", + "docs/UBUNTU-PACKAGE.md": "cb938b5c40970e8c9993afa3ea4d8795a65180f25c91c930bcf866c3124e6515", + "tests/PDF-VALIDATION.md": "7b887970e83065a7b039df0dfd11ee4d8e09579f551a3a346c896d2824dea29d" + }, + "evidenceSha256": { + "tests/results/pdfium-intent-context/README.md": "f2e5f9507fa40556135ff3e13d6d93b09a873b5b6704ab207f547a7f8466f899", + "tests/results/pdfium-intent-context/arch/LastTest.log": "9c8df674a0a0fce02a9892bc1e790aff5094daf7b97af697cf920b88dd851d52", + "tests/results/pdfium-intent-context/arch/cmyk/overview.png": "ace0deadc442a59670077c63f50d733278d83cbcbfbc214cbf1663fe30f2d375", + "tests/results/pdfium-intent-context/arch/cmyk/qpdf-check.log": "eb43ef19f895d445ce9c6c3c1ab88ad9ec6cb41fa8bfe06878bc7d56fc6dfa96", + "tests/results/pdfium-intent-context/arch/cmyk/report.json": "05d04eb04f359a5f0239c333b34faa3e6a4071fa3c25b16c9ab04a46100f73d0", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5/comparison.png": "b791b81433d208c3f49185a03683de537a12ed452acd3982172ba0a6f534c6f3", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5/difference.png": "9edfb3a63cdc2b3e0933d2574d824933676a5cbc7af16cecf13f09f8b2272a61", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5/pdfium/metadata.json": "b91ea871f6d2ffe17ea21c7e70836c1961e8877eccaa7828b4d72c532cfea0cc", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5/pdfium/page-1.png": "16900605fd4b016a0d304ee8349d723134f65715c85da1ef8ded795baaebacce", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5/poppler.png": "3cd6868997d5eee5eaa430ff50d0a297f946cfc0977326132846e60cd5d61694", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0/comparison.png": "f1f21a3442bef843685cf30e825ee7ff0e49058680a9e7acc64df2af1fc424cd", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0/difference.png": "9033300ff3dd7523fb928c5acf61ef26d78d9864805b0303cfc996971b9ac5aa", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0/pdfium/metadata.json": "b6aacf5fa0aff7b1a5eec9bb2d6446b8afd8572013a9ae148a67862a61b50d16", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0/pdfium/page-1.png": "4acce048167b19cda75d59dd5d0dc4c9ed0e94c15dbf1aa2841391de2d2945ae", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0/poppler.png": "808c5911c2250d72f8e2057414cad97e6d8ce599282e3beb6d99d877ffd18263", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0/comparison.png": "489c68b96bc9d8d8d07029e8382380305a17c35a4d2e463fdf226b10a0898171", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0/difference.png": "0d290a5776c4cc9c67ed31c95633dcb25da5f798fc39d54a893ee31c346d3867", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0/pdfium/metadata.json": "40a582667fb8739f1e270be94d58791b71c316617b4295e154277a24b8df0a80", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0/pdfium/page-1.png": "db1f61196ba73adfcfa2544840ef797137fad45b4d1af1f08708be3a4bd2ba81", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0/poppler.png": "38fcd6f54746dbf61a233e4290e9a0d4132f0f015d0f40d76847150f43c6346d", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/cmyk.log": "2e2f249f075131825a21eb4b5e6656dcac738b2d020681a23b389701ec54f490", + "tests/results/pdfium-intent-context/arch/ctest.log": "5e6bdf3b1ccd88d15cb38a06c8516ccb6ac4a84b852dcc4ae432b461d4b56678", + "tests/results/pdfium-intent-context/arch/intents/qpdf-check.log": "13c203853063e94a0fbbd44e8930e0859d47a065c667c7a18c0fd94d1c30998e", + "tests/results/pdfium-intent-context/arch/intents/report.json": "1f2efc6514a389d15f7ed119d063a5df5262b46a8fc0c1b47c646d8aa4a10c58", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-1.png": "9edf9fd62ad30db1eb1add30aa78fce7ca7fc3d73fd0e34860caf3c3c03b0266", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-2.png": "37bcd4ffc2b199ba608d0ca18e0b78c22adde11f7406490e1f0184c3e0622dcb", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-3.png": "a3706be012b833d78bc900cd3821c95ed8e3b928a7439eb6478738ab21e5400a", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-4.png": "a552e21e39ccb68238d4b2b40d6d006ba42753b7e40cbfea4109fa2861dc64b7", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-5.png": "d4585bcf645120d28ce4071641e3297567a2d6bf985f76bc4221762e832fe2e6", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-6.png": "deadf307a9dd7fb85064f185e6db4401257616b2d2d0602ab2b48deb36729641", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-7.png": "63a577f5346114d01829e851a17976068bd8935f8835c1748f5b806aac71f14b", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/comparison-8.png": "1727616e561cbb5bcfc3afb564fa9a61794761ae6302dea070ea5686368dc87c", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-1.png": "cdc3e7ac153d066e5c706a874a22b0c91613e4e4948a07db93ca94edc53ec570", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-2.png": "485777cde1a6138566ba27d68802cd675fccdc19ca1cc2f31e9b648b7a4d3aab", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-3.png": "ba4cf95b166f5379601502515bb9fa7af43f2dd320a64c12afd486efa165c793", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-4.png": "3a9737368d48362279b92eecbf18d1069ee6a608f389c21d093522d80b433bf4", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-5.png": "e4345c2c4c82757437a2de428e5e46b0c536d72d602d75fb47a390e74e48ca9e", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-6.png": "f7bcca0b3243a18af5443caab4bbebcdb80fc5d0d87bfe60c61186c6d5fab837", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-7.png": "10d1827e8c8e1696796b386cebf31c0ac2aec75900288eef0a3b1944233e8c43", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/difference-8.png": "4d554703897d5f13f50015f11cb9d4ef1f9a27ea79b7aeeb8d18bb0cbf131a03", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/metadata.json": "4a01f6e7bfc16aabf3cc096fee4e8d2bcc4959c1667425f7d70ae492a31c98cc", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-1.png": "141a3b02404af17231bc658401cbdab52198f5bfec8f7f635625eff59def1722", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-2.png": "bdc774a7d521888534403fad2ef7d44638a5debd53a083d3442c9ec91dfcf20d", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-3.png": "9a8a0cb6e51e7a680f0c969cd456434f3641f2a60a6a21e812410ce8f50fd842", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-4.png": "82a45cfe9ce2a9c89ce848d9215666737696b146e5200ede2387c395245e594e", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-5.png": "9f3b06274de35c9b5ce2c38c75eaffbcbe3d88c0d07bb05059548cd30aaa3a5a", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-6.png": "e8cef29741aa6f54dd73adc4698880c578dcdf58a7d919b1ca7a26df7a793089", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-7.png": "282a8a482072aae16b26a53ffcc15a0d817336454b9beb1e435d57e4587b9c31", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/pdfium/page-8.png": "5938bc52e3992940b5c9a70a9066b3076952acddbb5fa43e39673dc6c2f33921", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-1.png": "1db5c7d27465a5853df264a164b5d58e06774e145a378d70a9545155af45d3e1", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-2.png": "800335019562128a64a182094dd9d1875bb2a3a5602e1e11dd532566beb26d85", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-3.png": "de1aa6474b0f7b55c84274263b1efdf49b10f8d2b603b41aeb515b4b8fe83555", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-4.png": "18da14701f8b853acf6dddc3136bfc9967cd96a010edd654d1d11004655987ac", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-5.png": "8a4ae32993817c899dbe3ba3a0d7e83130062ed03558b3e79111c314036fc088", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-6.png": "e04a32de3c430e3b30aee3e9486bd5f0914975fe451b38f33d02b4a64670f847", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-7.png": "fbd71439c4f1158a4b01910771ae307385b53df59c33d25b755063c8556a5231", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5/poppler-8.png": "673a7cccf70744f5e20d82313035a71ec93f7f1752f546636b41280aaa1564d4", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-1.png": "c05be424dea1b86930e5ac8f65fa2468ea711f743dda0095a2b642c564eaaf56", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-2.png": "4bcd8a33ac210d489d55bae38823bd3bcb0824a9df24577d269db9c00f88be39", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-3.png": "5c695fbc4b112ebb598ff9c116c74048cd080b4732cff970594c5bb1172aea1a", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-4.png": "a2b36b049c006b95c7c5a3f8edb6aefcaa34d2c719a81df43a49267095a8babc", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-5.png": "214c0991c3cb5d41498342d4ceefcdee83da9fb4376d8a4a596078094e82ab16", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-6.png": "7ddcbebe0aeb8e7814b4c46740465480e5a0acbcd5a01d44e20de9029d5311d7", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-7.png": "d53ecaf956699a6ecb282e0f2fab28ea2dfd24651a3fdc06c2e02b159ba18882", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/comparison-8.png": "db0bdd67df70889f445e0913be91c5dcd8a4af1ae1c3f968445040136554d65e", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-1.png": "2cadc6ca07e93ab4613c4831bfe6c6841f87288d5fdfa096eb6463dbff56330e", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-2.png": "ff508bafb39762c31587f2c068cef34075e6369188fbc14cdd590881d02b6627", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-3.png": "07b0add44ba6f3a2174a401d7abd931ee86a3c4838626fb019548f51cf6f6200", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-4.png": "e058afd16019ccd7be2221f9c95fce12d38f3fe9d4011b5e20ad365358c7ab40", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-5.png": "b500c25cd7829e27d51b27ff823d5417b2cfc1aa6e1301d0637265dd6bb88a96", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-6.png": "3197e97064091d2eba6ba12d684505a0ed972eef19e0bd5ad349c666333e09e7", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-7.png": "9a1578acca537243f4582c26573288fce44bc1f46cef64407a061ba36ece8f5e", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/difference-8.png": "d6a614f0c459896fb8d505cfbb7254222fd484f34dc384ba46e0173c19a46952", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/metadata.json": "3160df3e3a31e8c7e278ddece9e7b15d92f078d36c4e5f7a7b892d6c00d354a4", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-1.png": "1d7cf550282dcd792f09da3a22326e878e1d7067a8e390daf037fff2b924d046", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-2.png": "baaf4a717d8395671354a8dab36b2795dd7849da895b39a747f8c1118db905a7", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-3.png": "b295c01c016f46efabe3293354f764b390f951b281229fe0cb910b663be8196a", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-4.png": "c654c9297a2c4b68c14de2ff6598ff3779a290b495a01ad146be0a4208a99e2c", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-5.png": "72a097e9c5e28ea4383b473e1bcc0b43ebb8c29ff2e23ac95b723757d5c19d13", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-6.png": "0f883a73df9639e50fc0e987144ca6b7fd93f7c9437db6d4e4d5a07091c3cdf9", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-7.png": "1ea6b00bfaf57b4b549bf7e162814faf2ebf226f4371cf7b6a7671c980fe0498", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/pdfium/page-8.png": "d5e1fd1c03e44b5724431f063a6a71c36002b8813cc7daeec55d7f2959d745db", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-1.png": "9cad38340bcc57a31cc11730a32c67f929b2d8417ca61b8b5b112ce87cff3f59", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-2.png": "8bdcd4f07cfa30217139a00e014d7d271b16e99f432df10fb5a7a213917f8107", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-3.png": "d67194cf49d395364ad811817fae855d1be4e3ee9922807f93de9ab5bd6dff91", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-4.png": "948f15992bec5f7d9c36145b537249bcbfe9e3b84611eeb31101d6d40e1ed592", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-5.png": "85b647161c8590dee0e9f4c9a2fd3aec7cf0b1cfb2d54c5aefb48adfdcd7bd6a", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-6.png": "a1c4ba8dcb204dea9bda9b3b008f67a2f2ed928f1d3cac6d25a1ea5edafcd103", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-7.png": "b2f81a9ff8cd1ec4554421fb81b44feb7013f48f621e12c18a88645e16712a2c", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0/poppler-8.png": "8f3d9b2ab08a9331db361216ea9974f5cc37bbd16c651f2d8a128d1444ec5fc0", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-1.png": "5e1c248fb8c98161e0f3685b9190969728e18628bd846dbc91c25f647ba651d1", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-2.png": "92a8eadfa02cf7d436ffdef2dcc8b1915fd3aebb1e65e386276118e63df4c5b8", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-3.png": "ef652e6ddd6904795d2b2d57863be6ec0ab9086742b1b205715b288633585efd", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-4.png": "4f70b5d775811c51fd41f80fd4262cf51cf8689dda2d2710c72861e86749237f", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-5.png": "e9c6e08cfaf110c44be7e03f4bc382935770a742744871079c3ec5b50c72fe85", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-6.png": "ac366de94737cb05b648229347101b6e1b788bd4ed609975f111e0b0e038651c", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-7.png": "88878984e4c5e505c821276eb85891ded9632c4d88d12fdad028e695fab0f52a", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/comparison-8.png": "b54d1ca21c15bded6859dfac01351b11bb8c7de9e7af40f2f2fd851260a6d4b2", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-1.png": "9a2dfbd115b50cda43da61f5e331a79f514ce407ef7a459bf66993e13c0587be", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-2.png": "f08c3a1fedc4fa7240e271da7ca4b2517d0d157691c649c0dd8841b7a12b3192", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-3.png": "946c235ed2a48f4785f4f60da1d315021492b0971468409830a3dae2603a37a8", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-4.png": "5e9e8e9a22e49a25198199bd7189cf828b6b6f14c56e2467bafe5682af55f81e", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-5.png": "372b7cc5dd23984252efd3bbcabd924244e5e4ebbebfc34886c57c7c9a0c7636", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-6.png": "de09d3df899544e4c2ed8c9b5bfcc88e427fbd2fd8ff0a60673ca4ce4dfabe83", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-7.png": "6f26d6120bc2e43117f2d55354ebcfa6201d7e9ab4e13527d1fb7b2fe191c32f", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/difference-8.png": "efff95fa611e2d576168245fbec47073a0550be996c57acff21a564df78f7662", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/metadata.json": "0c6a51d3871cf43b8bc991fbb6c0c8f6f1421cf6e32c0b881855aade833fbe79", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-1.png": "a0176cbd2724fb3b88eba86c610b9546f45202096bb9d21a613080094ae6767f", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-2.png": "140de5e2e7b19ed00d83cb3ee6f46c2fae5b473479ceacb2564a20f4b59883cd", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-3.png": "400a80b294183cf1751d85f228cd7fbffb7c9f2f30d5e79bdac156240f718423", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-4.png": "d84516e60d7dcc9c91dfeef48090fcc333d39941d082a8753adf3a99f45e1484", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-5.png": "76d65a12cfe1da220902e4bf367ebc130e5c2312bde0e3b5ff9286c5d5cb49b9", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-6.png": "31baec17d0a17f11bab60ce6f36155ee81ffe5213924ba22d05404c62e477c38", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-7.png": "f0a0ebfb0fe078d40cda90abce98b4b5b8f3705f3aa324d8dcf34ecd2da88505", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/pdfium/page-8.png": "083ae6e51ddfa91eb4edeb1f9cffb2ac742e8d06ecb7c77f92709229b7067d19", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-1.png": "fa10f7b34329c500896183b84f3dbf06cf08f826d6bce9ba31c100a38966a496", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-2.png": "b4347e800a3ea7231285c654d1330360db27b29944e923da682875675677a7b1", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-3.png": "f8a72fa16b84f7865bfe2651e5be9123adc0afe014af9feec48d57d664df2c30", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-4.png": "a4e6fdfe6e00334aa9bd2d7ff602a40f29ebb8d155cd456f2c88fac7f201956f", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-5.png": "409030f8181b109d686fb0367b139ad817c58a66ed68d02dc3baa4b7640199d0", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-6.png": "3b2e80444952fd632510a16b456f2f19c77fbb3f174a2e33fa67868a1f1c9ad7", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-7.png": "474ed2ae0e1212e38b2afde9cb3f128e7b4e03a57b5750a88752627a79df1b5f", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0/poppler-8.png": "1afa7bfb55ee734b07bd877fdb10304f3707660617991a8a29a00d23c5e22215", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/intents.log": "51f300b09e3b1e541a66d9219cd20169490cead22eccc020f5dbd2f0cdf51ec5", + "tests/results/pdfium-intent-context/arch/report.json": "a35d03acaa586ab45ad91d48c4a4a1f7b395f767d7616f70d047c7aeefbfb34d", + "tests/results/pdfium-intent-context/arch/tests.xml": "6011047ba34ad2195fea9cdd141b41ab6b11a482e41761ad3ce02718d43366e4", + "tests/results/pdfium-intent-context/arch/transparency/qpdf-check.log": "6078752ae1de338385fae8ba5ae37cf726dcf6a3cd1acfc087ee4d3edf12bddf", + "tests/results/pdfium-intent-context/arch/transparency/report.json": "1b306f2d5be2a442ef3fea62efaa34451218ac8f09c1b3a9422da75dbf37196d", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/comparison-1.png": "82001d459e7df892b20face00fa3fad3e0bdeda8bda2b4b1f11cef5d517cbdb1", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/comparison-2.png": "6cfd17aedfe6f2c2de2731af5f489c2c78871ea8a8170e07861d7060284d9c04", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/comparison-3.png": "06dab7d58455114031368a12844af2d4d0658fccee8b796f680f5392b1f53feb", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/comparison-4.png": "f3afe4982ac88fdda5c8a3d1a93d55d3f6b84bd137be65e1dcd16f7aa3a73fdb", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/difference-1.png": "1cda3f0a045526a0a73e68c743fe9c4174318442e4eacd934b45c4a0b8560561", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/difference-2.png": "6a163f947586533e0b855958249168b7acba3798032521da101c34eca0c16b86", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/difference-3.png": "cca1cf1b7bb1f8141eab2b31d55fcd926b63a9550978b2934d3095b4388c7769", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/difference-4.png": "967d3c01f432eb83d951737dc84277344d6c49de45abad571892c71509760e9e", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/pdfium/metadata.json": "1a0c4b653def09d1916981023ff238728a3d8471f7a2a399e70aafa21f628afa", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/pdfium/page-1.png": "347a0c598280368aa5446b1738325b6b9794ededb784febed5f33f7a02c44528", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/pdfium/page-2.png": "9f96a8dd81e93f9588676da34d8af53935cc6d0176795d0b77e8d8c5cb7e9163", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/pdfium/page-3.png": "b123bffe6d7b0f1b33a22ec2ef6c6c0ebb22dceaba8a1a797edd0aa98b5b03d2", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/pdfium/page-4.png": "43b36e9d7920e4852bde94b31e0f063b411299edef9908333459e23eeb850214", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/poppler-1.png": "47a470383476bb151e5223e823e5fe71238fd56d20caa3c40ca0feb7af14c9e3", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/poppler-2.png": "6ac261598c00d3c8eb438997e2cab11caeedc6fc4c8370b01acca1940609dee4", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/poppler-3.png": "4f9d8a71e07696a084696bfa76e76a781d9cd0bd779e32fe74203f57ef944aeb", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5/poppler-4.png": "6853e437f022c47793bcc1a1b0977061fdb79222ef5f7296792dd8e56acbc895", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/comparison-1.png": "2a76680690c174103ce6c1e0b6a6c7edaccacb43803df0acd033ac8b491ba837", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/comparison-2.png": "fb0ec7870578eeb53ab95fa298ed4e84ebd6a31c008f3d1bd12416e6565a743e", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/comparison-3.png": "a34674484c0da56196a27b614ea270a095cccae581ab62bbcfff08866bafdb12", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/comparison-4.png": "c331797f808ebd443a1c7892f8cbd74253e3a4277268b72c307019d5fed6bd35", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/difference-1.png": "0c33d131468eb0453ae4083fb163e727e8036121767e0e1616a82c560ccc46da", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/difference-2.png": "2225cee30f965003192a62e6737f79ec80875cb6e30434e9c00f9e288e557c4c", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/difference-3.png": "29420cf084689598728b608b43c11e507112da89f1a0284855eff3a0e273b525", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/difference-4.png": "53b31b93c79829187d49094a3c503dfa43cfaec37071539374817f23b1ee4c2d", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/pdfium/metadata.json": "a304c514cd928afe6247172c2c03f882bd198e41c920c8f3dbf36a8f1d197cb6", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/pdfium/page-1.png": "a4a6fc48cc4d63ed77dba9456b28cbf7f5de7854757ad9fb71ec2cc91923c5f8", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/pdfium/page-2.png": "1da9ade78a51412ba6e6722ed681505aeea5b93382bbaebd5c30a8bd46d5a813", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/pdfium/page-3.png": "346965080ddba6e5a7056f303b3451fda4cc967504d0400112ce378c9f43da5f", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/pdfium/page-4.png": "fac147a50f5f3e1f87224e598989a0c271df7242102e8dcbe2b0f8347c2838d3", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/poppler-1.png": "010675361c718971cb29fb232c8308d893248fbdac1534422caf27dc4ff949f8", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/poppler-2.png": "45f0c8fac3a80aefc3316963c13a92e8b4ecdb41219a13936a9c0b71693560aa", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/poppler-3.png": "bd3d4aa1b89b4a8fefaa376714ed76bb58c49bee6d995f30999018ff2dbf6758", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0/poppler-4.png": "8f973831eec795bc7af701a6aad3898a15163867590ded8cd264fd6775762f3b", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/comparison-1.png": "14ccf5b39063def59879fa80ae0646454368bfe6c6c475bbfdbf943014774e8c", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/comparison-2.png": "c75d1260d63c90656065cb7a3b50f9f97d71302cbd3005b8742d75763b41ce88", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/comparison-3.png": "e10dff609323b30481945dcaca8a289b434890b2e347e4bc98bda57137cbd39f", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/comparison-4.png": "3511022cc7c11162e5ce699705c9b98597e458ac160f5f0365d0543833f9978d", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/difference-1.png": "4bf3bfdb3ef69d670d14d9e17751242975fa010fe7283f93777d727cf664349e", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/difference-2.png": "013171581ca849d3d2d0fe2cf328108ccfea6e402a7c2c2c7bbf52741d481bcb", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/difference-3.png": "aacdc1420d95503a8411c1c01404206c448bbf913672dec1936a148522e81ecd", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/difference-4.png": "73ca8699b2d99bc53eb65b9def2402f248373c2d05c482e45a2a8a2be4ff49eb", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/pdfium/metadata.json": "8fb1c5e0f87ff005c2dc2613764e2ac8e3710d7df2ef9db06cfffe1a6f1cd1d7", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/pdfium/page-1.png": "f07cd43e7b27fb4a199b8a4fcbb951194be8831eb530e9fc45deb0f3ce21a6dc", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/pdfium/page-2.png": "219f31104a358a089a21d1149577dfc4262fcd966d1c522f8cafcec08d889240", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/pdfium/page-3.png": "8966bf2f2f1c906dfa58121fc4891534002022f70d9998671503e324970fd937", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/pdfium/page-4.png": "c1263c0586c6e0e23c1d21f4d18cd5a454ec2631c509b1d10cd56a320393ca19", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/poppler-1.png": "e98256bc67e2f2bb99e9f877c3b312c291bd13cf15b2f5b470c4b74234a13ac1", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/poppler-2.png": "352bd7a448d4d135aac399bdd3f6f2b351d3d0b3c3a7db0820582cdcd3b4e555", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/poppler-3.png": "f74a57c02f7f653190db27366e62bdec9195549730474c4f3a0ae8d1c2961718", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0/poppler-4.png": "212a24838f64384637237ce70eb88efb23deb65525e806e23820388c09c7896c", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency/scale-2_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/arch/transparency.log": "25f1a67b437b4ebda2fd18916fa04e17b7464e3539e460e8f0d9f86b0de25dc5", + "tests/results/pdfium-intent-context/arch/worker-ldd.txt": "becf55ad943b3a95ad85069b67fd4fa10811100368df664b5b7096b8c8e821b1", + "tests/results/pdfium-intent-context/arch-integration/build.json": "784ca6c8b3bca23a073cb413a4e92ede8cfacfaef6c163d0b0963b4a8252643a", + "tests/results/pdfium-intent-context/arch-integration/build.log": "689fa40febb4bcf0852403d5f2fac4d3f52ff64580d283e8421dbba77b4e9634", + "tests/results/pdfium-intent-context/arch-integration/candidate-boundaries.log": "2db10028ab37e4584db92a53af76a295375fe3271a79755ecfc5b107f8478b65", + "tests/results/pdfium-intent-context/arch-integration/configure.log": "689cd8618ca8b133d65a09cb621c5f95cee2826fc34c6ca7f039f689866e0224", + "tests/results/pdfium-intent-context/arch-integration/ctest.log": "a7d3947a9918fc7988d829b5eea1e686b308409131f926a391389afd9aacf9b5", + "tests/results/pdfium-intent-context/arch-integration/execution.json": "f6410743487d4dcc60d352a7b52b4d10243f7b254201d1d869b4129c98d184dd", + "tests/results/pdfium-intent-context/arch-integration/install-guard-configure.log": "6c170026c8947865f7979bea427b84837d4bdc8bd04d82e58baa12b91ce30070", + "tests/results/pdfium-intent-context/arch-integration/install.log": "96310bea4e9a851c3b02cd1cd4a602b1cca9a2e2307178d6e17271fb3a20e4c1", + "tests/results/pdfium-intent-context/arch-integration/post-install-check.json": "2a2e364b9da35294eef635b48cdaf32c64af26a278a325a6c0cb4af7f4dd3d26", + "tests/results/pdfium-intent-context/arch-integration/report.json": "46061fd90e3dfa4114eee9c5d4297df587f36b520b6555f46698f907adbe5671", + "tests/results/pdfium-intent-context/arch-integration/tests.xml": "8aa7c0f806e73fc516ca9ef3a099600cb8f67477911d4c81ad6aad7f9a4ccbcb", + "tests/results/pdfium-intent-context/arch-integration/worker-ldd.txt": "c439f52c09b73f0d3ae77c46f03236fd4541583e79745fcccf4d1c8911d0e914", + "tests/results/pdfium-intent-context/build-1.json": "4352507558bb2cbb9c5fe47d34bd871127c33b100983fa74c8a73311980a6f5b", + "tests/results/pdfium-intent-context/build-1.log": "2077dd7801cbd994ec9f222583de212d8e795c9020da671309a5c1338161cf7e", + "tests/results/pdfium-intent-context/build-2.json": "de46bb168f2a8f2f0b6d9c68c581686efacc0fbfa010f54c7ec9ff90758d497f", + "tests/results/pdfium-intent-context/build-2.log": "cfa242bdf1ab2888e04a8f005b1337b40f6ced0635332efbc6cd43808d9eabed", + "tests/results/pdfium-intent-context/candidate-patch.json": "3de89760e0d7ff96069a862ff51878ef700ecffe4444f6224d3ea6c840fc1267", + "tests/results/pdfium-intent-context/gn.json": "b7f7e4721cf767b6ea445a4ff19a7178d07668a3ffc7ebba2c1eedb0acf7b113", + "tests/results/pdfium-intent-context/gn.log": "a51fe8a1718b1f8839e8771d00b61cab9d8c94c772a31991c2df0bc6528508f7", + "tests/results/pdfium-intent-context/implementation.json": "80a101c018a6989fd78c12f62ede8b2f237cf22b97f7110a4741bfd208754766", + "tests/results/pdfium-intent-context/library-transfer.json": "241270cb2849df490c1fbd7937870a4cc95494cfd74ea65eb6792c488ca0e92e", + "tests/results/pdfium-intent-context/oracle-self-check/report.json": "026262ba52f327b704d2f50cb882b90d18a0eb7f4aaca32d059e3b674f115eae", + "tests/results/pdfium-intent-context/patches/470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616.patch": "470ca6dd5c83e06ee82c788d1e34b19766f7c592473b80607f0d7bb09d761616", + "tests/results/pdfium-intent-context/pdfium_embeddertests.json": "6516f89ea1169e00a12a2ef7af9c35d8e1d2195225c2dccd48afecd2cf039991", + "tests/results/pdfium-intent-context/pdfium_embeddertests.log": "70e23737d03ca6829758beb72c664f401f7de2b76f9673844173f8692fc80371", + "tests/results/pdfium-intent-context/pdfium_unittests.json": "e8672d480effc3e15f2bccd1a3ee1fd37f6093034e084ed25a63fae68429cd46", + "tests/results/pdfium-intent-context/pdfium_unittests.log": "a32edb1c94eb191c7164c831b1b02b1ab5b732e1c2bb251a030d3bfb6ac37f59", + "tests/results/pdfium-intent-context/performance/README.md": "409619c5b44969c078e83ab0a7b67a216b4a7d27a1ca63ebc2346f508859931c", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/001.json": "8414538217768cb676a568c7204f69f74626148b2ddeb9f14a017761cf1c3a7f", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/002.json": "4cd5171b18657e78b3adad1c3d0a241d5c4b9057a0a9fb8d2301fd68cf393299", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/003.json": "b16a0af26a9006513b16236057de0562bfe8c7ad11cb8b00d4322a8348e5f783", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/004.json": "9b1820c8779123023c3bee52d6d4a6072223bd734d3d2c93fec1ab59f502edf3", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/005.json": "2108b445ef6b5fbea0ca6f2274cad25d304192d115558e62114a30a7fc79e071", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/006.json": "c6a15815409af7ec10715a3c1e78c003624e7da3b95e5c5703fc44c0b7b515ce", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/007.json": "cdf602fae74e38266b86ed437a5f8ca46f515de906bbc4314bc9585cf76fc3e8", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/008.json": "f181668bbdff5d73831182ded3940d565c111eac88545ed44efc7ea085b30d0d", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/009.json": "3127b32fcded3bdcb6932b1f17a4b3bf3cda80bb79a53fdb1332bd9db62d5f47", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/010.json": "c4186997c814f3fc8b1783784963dd9e32c358e88864c84c3a4614097ba7834e", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/011.json": "8f7fbf9e5ed5ac118d8c4e1e74c1446134caf1c95c0d4ae72a848463c8c2e4db", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/012.json": "89f77e17768f1cd9730c089590d5582b1e90ffb65f375cc4a1b3d8edf8af2ba1", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/013.json": "ae22db60c008b8e73b74fca50d422f00337a7e8fe08b68ae3238d5e3f4a5d091", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/014.json": "b20ad0a7d15057e82a4b042780a35ade5df193e3ef284ed1b26e67ad5c02b52f", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/015.json": "1d6468279bbf27c2d70378b71ddd2b68a7548bab596c81802d21db8427c54097", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/016.json": "ba3ee71465b050039ee3324458a39c91b04c4a3ce5df0b2b5d77d898ce5e73a6", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/017.json": "8b97734eaa7de46f6f066aa7bfc88502240838c218459bcea64d700156225d11", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/018.json": "83e1da99bda81980c4703d88d55a1ba03a2aac8be5795be4c2f42c4bb7e40e65", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/019.json": "aa1ecca0959d01b21ed5084a54b17ee1c89b98f2b4cd9553d31c45559563a961", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/020.json": "fd8fdd17e0bdfa79c59e7a2c3c4d11fc2419792cad60871eedb43bced4665501", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/021.json": "7de9406d21ad3164d4802772034984377bf2fb9cad262c6cb562fd2fe563a09a", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/022.json": "1d638a00fcbc25ab2aa93a2688f430cd913c278d0838e4412ce5e5fb78e299fb", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/023.json": "45543ee85fed43fefcd1ecdc844f151e43e1c58e6ff24bbef7947945148269f7", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/024.json": "9cc8be9a6af74155a10dd58617661f66a7192e5c77b12af7aa1b4c70510acb7b", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/025.json": "edd5efc47a939511d4c149c96fc56e9ce13935456d811352835ba20b7a8a0d3a", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/026.json": "8f0727b79187e7dff6743a0486bed3472d84916f059b7a88a4085893b8ae2102", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/027.json": "1115d259cbbf716499f7498e61bd940bcbbd20bb53e668959081046f97a2e79d", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/028.json": "f62e8093517e23ec5705b6a47707ae879af3f3c6986a4c53cb5edbdc9eca78e0", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/029.json": "92047795e6c8c5334b90edd254b37951bddb59e1e521b72d916963f222658543", + "tests/results/pdfium-intent-context/performance/fonts/cold/pdf/030.json": "fa4f5d2bcedb056fed59d4428555ad1ae5e3190fed0e3d48fbdfcd2ccad3f5eb", + "tests/results/pdfium-intent-context/performance/fonts/pdf.json": "a6bddd5d5c05bc4b834867acae38bd843e71244a92e18250ddda8b2af5811e21", + "tests/results/pdfium-intent-context/performance/fonts.log": "ec8f8c74be8cb8c63f56dff00204b9bc26d4cdf3acae4320b437c50b63b1143c", + "tests/results/pdfium-intent-context/performance/preflight.json": "1387159253ae35d68a71c3d58d0643eeb75019b44d67f2dc8283098f34f30b2e", + "tests/results/pdfium-intent-context/performance/progress.json": "e688ee23a4c36a5e1c4f2898dd393a00120b12a5d1f6375ac56632cfa9aa3e64", + "tests/results/pdfium-intent-context/performance/report.json": "bac1ff9cb7c6488b9082d78b1e6426072ecafa22a3b18f02d45af9e64b96598b", + "tests/results/pdfium-intent-context/performance/run.py": "f818510b6b74063284addddf0cd47a2d21dc8437fa41be86cc5c0acc0d6819e0", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/001.json": "db2b363d07fdb12cd1df1e4f7dac4888a4565ae574263e9ac7a0f1135cef65a3", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/002.json": "40a87ba86ed9b38530fb7b1228e42e8aeda7199a3f1db84b39487572f4388e6c", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/003.json": "4010f27f10b80bcf45d069953d6e738c8e5d196e55d98fe8ac423e663e287d8f", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/004.json": "a16b56ccfe07d7a954bf06b6c2219d791c8c3ea83d64327ce206f811ab0586c3", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/005.json": "f73cef3c2e3e2c8b57dbfb30cf0c9a7479270525ea1217f47ca2f2f517b211a4", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/006.json": "d96dc6dbc7a998560edfc8f4075a2eb8d9cef4cfc939e3676e00da51f9f1f407", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/007.json": "d74cfd1de3a8588bf679224b7820603fb8646ce411b7fbf1ff2c7de3bca18a43", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/008.json": "508058a62e4a61983c19e1fa276ff2b4c6b2449ab795ae32d554272259479476", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/009.json": "63e39be02b744a7e7c7fcd7d377276d67f86c719e4a1c0ddac88febb9d5dbc60", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/010.json": "11f6adf40c01d316da43ca950ff9845913d5978410e83f77f52772e57418538c", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/011.json": "3407367b05a11ae42ee9e761da8efabf0065aaf6acb7dbc3057494461a190f8b", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/012.json": "1d644c4fb4f20afc7912817b3f2e785fd291d6165385b5f88e720e93ecb4e3d8", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/013.json": "330a806bb958067c3e1967f9bf0d6273d964195e404bb0f6fa0e6e68a6175d08", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/014.json": "9a5cb6ff41f83cd80a7446926e60cb58abae7f37224fc58d889d2a5535cafe25", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/015.json": "aac3ff5959b7fa9e2cbca668f71db14e5dbcecbe42a809a383143ab6ab9f2ac8", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/016.json": "6dbc44c8f4612c5882662c76d4abea4ec36db18003eec26c53402affeb7a8f03", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/017.json": "8ecb5b0945c4eb48ff5652ab9dd81206ee8113bc3b15ba18d56c3c1dbdb4185e", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/018.json": "618f36e81b48bb84cd740d33c2a2a23ba4dab0110200b836383ec29c826b9ad5", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/019.json": "cae1b3b184339083119b4af242ac6def47ed20eebc8980716c75d42b57f064c2", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/020.json": "0d013b7961bbd99ddab5cac387a19ac42a4b4cbee646e399b30001ad5c9988cd", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/021.json": "b8ed77d04f3f03222c9bbd332faa775cf83f19112a87c39da3fcd9aacea75859", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/022.json": "50bb0bc13e7898dc0a41a456067c0c437427294d26bda9783ebfafd58100c2f1", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/023.json": "8b38166a75ad387b1a9c0ceb6a7a22ad2c766fbef27dd09790903f3faf467624", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/024.json": "16312092c3ceda1b8a20daeb2df651e4d83dd60c7a9f7297ce5de28a21205210", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/025.json": "0d1332a02bc8817c327482b3f37c08a76b1dde11eff2865742d906e2fcd6b315", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/026.json": "021a5a2515e76aef0639d731d5d6acf6ec97d7cc0b1cff23f6eb5d24a9eafbc1", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/027.json": "c57ea416fa0f27907f05940ce7438afb72f20ac2fa9f43d1b34be00bd1574ff7", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/028.json": "ae25b4c8d6bc4e5d762434923162bbe8ea52f8bb18d2aab6fef664ac5fab93dc", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/029.json": "65eadd118b219393fc224e396cc944da785814d12f095a2f94524c1e16cd10bd", + "tests/results/pdfium-intent-context/performance/smoke/cold/pdf/030.json": "ed84abcb9af6efe3d75d49cc4ab49d164a4bd44551aea77d296e5c4944e51515", + "tests/results/pdfium-intent-context/performance/smoke/pdf.json": "b0bce97821224e8e74742434a3b6d550c6af1d6a6424a341cc0806a34e6870e7", + "tests/results/pdfium-intent-context/performance/smoke.log": "9c80270a2dd8946c7b86ada0369e66df4c601c0125f30a01ca41eb5cda443fa8", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/001.json": "05dba98193954f5d06bec5e33736fe5387cb141f1706acc5e0db868a095f03ee", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/002.json": "d672a73b226a8975be553be4a4a68301248e2dc1659291444609cc42942e15c4", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/003.json": "15f760181f240cb331bde918aaa9ab42b6bfc2960156676d7b3b45e3907e1b65", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/004.json": "9553f519672e612ccf643eb486c806fb2fd49fbd6a020856b3e3b3d3c6b47bd0", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/005.json": "a860db8b5b231be0aab564a118a3c9c0a59e20a814ec9308f53d7e3986cd15bf", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/006.json": "9206fbb6419bf7d7b9caf47332138e3a28c0f53ad983030be11a45e97c4507ec", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/007.json": "d504c30515ef3b4c854ec851317044460924ccfee976b961d196221caf7fbdc9", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/008.json": "9b0dbc6cf0eb6f67f2b7f14b2e4b517ed3308808dc6942efddad8f31b8bc5fdf", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/009.json": "cfc82376f9a771f1d62cb963fa74f71ebc400493b80f8e7773be10400df1970b", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/010.json": "7f324e580dffd9fa7bcfbb47d0b5e08fbc345e26489fc1e817633a35ab7456b3", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/011.json": "b178ff79c99105ba7eee26e660754bd4c07ade7624181b1ce6b50a441f3d5150", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/012.json": "3224d438cb2d382733a9ac72527fdd1906f08a62bdf1a2ed83bee677f68d0461", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/013.json": "f4d75b7cae848159fba6144a82f023e99298faf81e32e07234819b5ff607ec50", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/014.json": "45a8d9ce848954f6b8e8fea5fb2b677e884f859f66e34d5179dc52a05afb77f7", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/015.json": "6b2768601048f4217bf3a37d83fcbe84e5bd2801fb4f7f940dcc005eee1c3f44", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/016.json": "4f806a97f33115ed5fc1140c64d49dca99b8195abb0cf80a0283078663f90741", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/017.json": "4911ed2d86c75dcbf9ca728423a85ef59d1a87d70f6cec494dd01a73fc863037", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/018.json": "c51b62cd951f674c0c6f669cfa14cede88098c20ecdd14f554e64c58f94c2ca3", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/019.json": "5c5998b881d9bb52cc7ee6ec4fd868305abf06fed0dc231127fd5614da42c03c", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/020.json": "fabd05f3ec043fb4fecc0c324d10ae43e9f48b5bd708680e0814ddc29e38d469", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/021.json": "36554cb3b99588a01386379d260c18cfea3bcc89fc575a9b344f03f7ef847f03", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/022.json": "bafddb8d1829e45dcb088f3e7a0f31fc89965d98d7fac44246790a10bdc68309", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/023.json": "2e8188c69d5ec4685ceea26e7adaed22a510c59e698378dbc2a8e92497a7769c", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/024.json": "31c5e1207f5b579df7421694f50fa6ee979ca34c24398858711683190dd1653c", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/025.json": "a415211c249e584cc6afc9044bc680404bde65d51de47729daa588b6a52d656e", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/026.json": "742a33ffcca6077cb0bc0c7c564e00f78451473f582eab6e195335f95cefb4be", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/027.json": "8849867a1c871fd0571b5b48136c82efeaa73995d41a68d65001064baaf04376", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/028.json": "386324917fc8e72f12c6a1c64cd47c7bc0349a6725bff178b6924dddf336f46f", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/029.json": "76c0f0022819b9dc1ebf26ce0e34df70655e887e1cccb9e2968f484f71242af5", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf/030.json": "e96b03bcc5437d1b4221d3e2d0d35c3b8cd0351601024f3102a23ab707147e11", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/001.json": "3e984b4a6feb56ba3bb6cce1aac9ce26634923f3279005e10627de00543f31ae", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/002.json": "7d9d75a090e326bc1aa4f56d89b9c11cd1d952ddb70a3e468db8b55e80bf9bc1", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/003.json": "fcb1a9d58cb65779c915f2324f4864895e3f901f7a3c563a7847963982e9a2b1", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/004.json": "3f63602428fc400d996896b5562f504dfa4c448d82fa42fd8b43a387fa9898b0", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/005.json": "014dede3ffb3252396c4221e7a00e2eaab1b8de9cef626d98395f21b6774611f", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/006.json": "99ba8dbf4bb030bc0ae26c1e7745e69e54e6f677bacaae9ed1ef93f35016fb39", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/007.json": "6d94c8d86dbc6e10124d60cd2e71ea4f6cc7722eec9f235701f609e5f5c4b205", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/008.json": "6c27c39fd12af32e2cbefdc5582f55adf3f08f4efbf878ec23a043be3e47fb40", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/009.json": "0638ecd28bb825d2d74e3a5e271e8781f13a39466a5b522304a31297b1ebf801", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/010.json": "30d032bae12bf7d8383b6d043c27898f5d32fb4be18a9eb8fa0655f7e18a3b2f", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/011.json": "d3e3d5f6251804eb23916a1c7a7e882f7f638873b30d6698985ab8be525dbd10", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/012.json": "258fb3ad5984ec55039f13c0116ee8f3eff76c9224e21ba4e84099dd563f31fd", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/013.json": "07f995891deb481388c5d06c8747404db001c96bc128326c9646a5200bc2743e", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/014.json": "9641ecfe89b7744e9df726044c97922e11fe6193e833c6c6af51f2414b0da620", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/015.json": "9efdb51289baec2239cb186d4a0a03426466b43151b810fc91940cc1dea1eedd", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/016.json": "128d3b741970cb5f0db8940b9c2b519521bd37efe3c0606b081bee6d03f07700", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/017.json": "1c699119af1b6bacb7fa593d0a9ff44bd687aeb31a171520894204caf5ef41cb", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/018.json": "82ab1cc0fefbfd93b81644e3e916d3a82284ee685003fb54b9f1a6ef8c2ea8c7", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/019.json": "40e6f483fca0c4545aee0ed081adc4768faf7d0fdbbdf8bf40ed061edfe7e1c2", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/020.json": "fd8ddaa2f0acb575a941c08ce1f951725a8aac1c381b408bdfe6a2863e9007d7", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/021.json": "0d02a5ddc0579dd4f7536f5744a07b215c4d7ec43cf2c0815b12de5039e3e3e8", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/022.json": "010a63c408e9bf830da1208ec3c06721c8f7121e8a1bd941e4620ffbb39c14d6", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/023.json": "68e0a653903e8d529980c5d8f5a5cbf5d9bdd8ad64e06c79acf46a03b299622e", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/024.json": "35ed3ca0fc37c699c231999c3750d88d6a58f55fcfb0b2e1044eaea99fd993cc", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/025.json": "1153900dab1735186618a069ea2749626c60586cfe473be0e107237e488ad333", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/026.json": "bec154191013ffe8180caaf3d5842924d66ab0f24cb9e8730c9a8a5b7ded9d93", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/027.json": "2532a795c4b112c717faea2be558c3f09885d30c8ccc2d8bbd156940c9ad1550", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/028.json": "79eca876b4b45a06cbc2bb4f5265e055ade742fe746263e66cf3bdcb800ae8c4", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/029.json": "bcb5430777f6ec493419717874f9a3c167b20e70b3815efe2d30e1a4839f94e9", + "tests/results/pdfium-intent-context/performance/standard/cold/pdf-headings/030.json": "960d45022e81736707f15f87478e8a033de2e164ba2aa5616235ecb6fceba2cd", + "tests/results/pdfium-intent-context/performance/standard/pdf-headings.json": "1c2c6771ff8f0a04f76887fcc7517c93746c0a1091e4ed399ebe8cf3ecc632cb", + "tests/results/pdfium-intent-context/performance/standard/pdf.json": "3582e2d42eeca89d8fe0b50cfdc64550c072b2484088989c9159247b452aedd0", + "tests/results/pdfium-intent-context/performance/standard.log": "6f1919495ccece968f2ec0641f86205fa52045f693600f35410483be1bce154b", + "tests/results/pdfium-intent-context/performance/stress/benchmark.json": "1e25739a00300d5fe37878adaa7d46ebe7f743cb6018952682a4e55f3964c041", + "tests/results/pdfium-intent-context/performance/stress/benchmark.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/performance/stress/last-page/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/performance/stress/last-page/screen.png": "41c01d1edbaf338411e764f2d7cec90df6556dcef026870dccb120f4226319dc", + "tests/results/pdfium-intent-context/performance/stress/last-page/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/performance/stress/last-page.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/performance/stress/pdfinfo.txt": "3bfcda8cdb898d202bb44ac3619571ec1b7d983ec076016434c8fdfae9e7d270", + "tests/results/pdfium-intent-context/performance/stress/report.json": "f91f7ebfa93651f4e350d71992004372b5fd52730bddcdd8851db9f378a47c7f", + "tests/results/pdfium-intent-context/performance/stress/source-manifest.json": "dc71ab9f3f4e7e61ca159c2569ccfade91c2d2a76a989c7d15a71c63dc444b2a", + "tests/results/pdfium-intent-context/performance/stress.log": "cb8923f179d27f3a317aba5b04f326e57c5cd801047624b13771b768422d9ce2", + "tests/results/pdfium-intent-context/performance/visual-review.json": "cbb9c722c6335fcd04f1ce32a28bdf3df0253c2b796e0c313bec97f1e61c0e0b", + "tests/results/pdfium-intent-context/performance/worker-ldd.txt": "5297dd7a0682020752bdb9874403258a0f8dae3548321653c6b478a204c3a9c3", + "tests/results/pdfium-intent-context/performance/worker-runtime-loader.txt": "69958b8bbb6796f4f78b9f0cac5832bc842816ade3ff823dc2bede5a3e143cc7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/qpdf-check.log": "13c203853063e94a0fbbd44e8930e0859d47a065c667c7a18c0fd94d1c30998e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/report.json": "27ae59ed3468b7a1a6c297930d59b3ebed0235b8bdb0c8f61464900c181f96d0", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-1.png": "9edf9fd62ad30db1eb1add30aa78fce7ca7fc3d73fd0e34860caf3c3c03b0266", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-2.png": "37bcd4ffc2b199ba608d0ca18e0b78c22adde11f7406490e1f0184c3e0622dcb", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-3.png": "f110e06af644e3b048765ffa78289ad2f3ed325ac7bde0f57669cf2ee1a1e1e6", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-4.png": "a552e21e39ccb68238d4b2b40d6d006ba42753b7e40cbfea4109fa2861dc64b7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-5.png": "d4585bcf645120d28ce4071641e3297567a2d6bf985f76bc4221762e832fe2e6", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-6.png": "deadf307a9dd7fb85064f185e6db4401257616b2d2d0602ab2b48deb36729641", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-7.png": "24673a3cafebd79506fe1e894654b9ff9866505adc5e01be28fbe619a950ba07", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/comparison-8.png": "1727616e561cbb5bcfc3afb564fa9a61794761ae6302dea070ea5686368dc87c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-1.png": "cdc3e7ac153d066e5c706a874a22b0c91613e4e4948a07db93ca94edc53ec570", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-2.png": "485777cde1a6138566ba27d68802cd675fccdc19ca1cc2f31e9b648b7a4d3aab", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-3.png": "556ee227fcff26a127a5fdf1404b3944c61a3257b341d30af067f53178277b06", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-4.png": "3a9737368d48362279b92eecbf18d1069ee6a608f389c21d093522d80b433bf4", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-5.png": "e4345c2c4c82757437a2de428e5e46b0c536d72d602d75fb47a390e74e48ca9e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-6.png": "f7bcca0b3243a18af5443caab4bbebcdb80fc5d0d87bfe60c61186c6d5fab837", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-7.png": "ca08d8c1df4c46b88a554cf362bff98c15af3dab57d07d47dd1ae1dc7ec310d0", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/difference-8.png": "4d554703897d5f13f50015f11cb9d4ef1f9a27ea79b7aeeb8d18bb0cbf131a03", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/metadata.json": "4a01f6e7bfc16aabf3cc096fee4e8d2bcc4959c1667425f7d70ae492a31c98cc", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-1.png": "141a3b02404af17231bc658401cbdab52198f5bfec8f7f635625eff59def1722", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-2.png": "bdc774a7d521888534403fad2ef7d44638a5debd53a083d3442c9ec91dfcf20d", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-3.png": "c9df46a507dce298edec6a2cc5151f393bfdcb46f6a37a7a1e16863f19c6fad1", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-4.png": "82a45cfe9ce2a9c89ce848d9215666737696b146e5200ede2387c395245e594e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-5.png": "9f3b06274de35c9b5ce2c38c75eaffbcbe3d88c0d07bb05059548cd30aaa3a5a", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-6.png": "e8cef29741aa6f54dd73adc4698880c578dcdf58a7d919b1ca7a26df7a793089", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-7.png": "df58f319df38367f8bb817425d1760ace0b2bf846e52f3c917029d31907e6ab6", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/pdfium/page-8.png": "5938bc52e3992940b5c9a70a9066b3076952acddbb5fa43e39673dc6c2f33921", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-1.png": "1db5c7d27465a5853df264a164b5d58e06774e145a378d70a9545155af45d3e1", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-2.png": "800335019562128a64a182094dd9d1875bb2a3a5602e1e11dd532566beb26d85", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-3.png": "de1aa6474b0f7b55c84274263b1efdf49b10f8d2b603b41aeb515b4b8fe83555", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-4.png": "18da14701f8b853acf6dddc3136bfc9967cd96a010edd654d1d11004655987ac", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-5.png": "8a4ae32993817c899dbe3ba3a0d7e83130062ed03558b3e79111c314036fc088", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-6.png": "e04a32de3c430e3b30aee3e9486bd5f0914975fe451b38f33d02b4a64670f847", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-7.png": "fbd71439c4f1158a4b01910771ae307385b53df59c33d25b755063c8556a5231", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5/poppler-8.png": "673a7cccf70744f5e20d82313035a71ec93f7f1752f546636b41280aaa1564d4", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-1.png": "c05be424dea1b86930e5ac8f65fa2468ea711f743dda0095a2b642c564eaaf56", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-2.png": "4bcd8a33ac210d489d55bae38823bd3bcb0824a9df24577d269db9c00f88be39", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-3.png": "202846c689765d9ef6af1d28b2ddd08bcb88b70c74dc2a36f61c3cfa3506b67f", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-4.png": "a2b36b049c006b95c7c5a3f8edb6aefcaa34d2c719a81df43a49267095a8babc", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-5.png": "214c0991c3cb5d41498342d4ceefcdee83da9fb4376d8a4a596078094e82ab16", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-6.png": "7ddcbebe0aeb8e7814b4c46740465480e5a0acbcd5a01d44e20de9029d5311d7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-7.png": "7ede9cd241f0b1a16abfd6baee56473e430023b6ad3a83dd65a4b1d77ecd402d", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/comparison-8.png": "db0bdd67df70889f445e0913be91c5dcd8a4af1ae1c3f968445040136554d65e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-1.png": "2cadc6ca07e93ab4613c4831bfe6c6841f87288d5fdfa096eb6463dbff56330e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-2.png": "ff508bafb39762c31587f2c068cef34075e6369188fbc14cdd590881d02b6627", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-3.png": "e028f4306d03b6896214134b44e2d9544bb9a677c4e23031cd75129ef93c2ca6", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-4.png": "e058afd16019ccd7be2221f9c95fce12d38f3fe9d4011b5e20ad365358c7ab40", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-5.png": "b500c25cd7829e27d51b27ff823d5417b2cfc1aa6e1301d0637265dd6bb88a96", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-6.png": "3197e97064091d2eba6ba12d684505a0ed972eef19e0bd5ad349c666333e09e7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-7.png": "6e58fb6cf11f485ea5d1b1ce5580e3b2058e3fbb1b6d63b9dc530fc1e7707744", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/difference-8.png": "d6a614f0c459896fb8d505cfbb7254222fd484f34dc384ba46e0173c19a46952", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/metadata.json": "3160df3e3a31e8c7e278ddece9e7b15d92f078d36c4e5f7a7b892d6c00d354a4", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-1.png": "1d7cf550282dcd792f09da3a22326e878e1d7067a8e390daf037fff2b924d046", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-2.png": "baaf4a717d8395671354a8dab36b2795dd7849da895b39a747f8c1118db905a7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-3.png": "2c8974c9416e834302f96f116652a81b9c2e5449cdb52ffd285f410115494524", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-4.png": "c654c9297a2c4b68c14de2ff6598ff3779a290b495a01ad146be0a4208a99e2c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-5.png": "72a097e9c5e28ea4383b473e1bcc0b43ebb8c29ff2e23ac95b723757d5c19d13", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-6.png": "0f883a73df9639e50fc0e987144ca6b7fd93f7c9437db6d4e4d5a07091c3cdf9", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-7.png": "f044397ff99d78e5181cfd58e936dbcb27a23d8793458f59092557d80d3907ab", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/pdfium/page-8.png": "d5e1fd1c03e44b5724431f063a6a71c36002b8813cc7daeec55d7f2959d745db", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-1.png": "9cad38340bcc57a31cc11730a32c67f929b2d8417ca61b8b5b112ce87cff3f59", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-2.png": "8bdcd4f07cfa30217139a00e014d7d271b16e99f432df10fb5a7a213917f8107", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-3.png": "d67194cf49d395364ad811817fae855d1be4e3ee9922807f93de9ab5bd6dff91", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-4.png": "948f15992bec5f7d9c36145b537249bcbfe9e3b84611eeb31101d6d40e1ed592", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-5.png": "85b647161c8590dee0e9f4c9a2fd3aec7cf0b1cfb2d54c5aefb48adfdcd7bd6a", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-6.png": "a1c4ba8dcb204dea9bda9b3b008f67a2f2ed928f1d3cac6d25a1ea5edafcd103", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-7.png": "b2f81a9ff8cd1ec4554421fb81b44feb7013f48f621e12c18a88645e16712a2c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0/poppler-8.png": "8f3d9b2ab08a9331db361216ea9974f5cc37bbd16c651f2d8a128d1444ec5fc0", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-1.png": "5e1c248fb8c98161e0f3685b9190969728e18628bd846dbc91c25f647ba651d1", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-2.png": "92a8eadfa02cf7d436ffdef2dcc8b1915fd3aebb1e65e386276118e63df4c5b8", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-3.png": "79552f316c242507373f43a01eb754e204b09ecaa2c938f587a8f9258d8b2044", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-4.png": "4f70b5d775811c51fd41f80fd4262cf51cf8689dda2d2710c72861e86749237f", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-5.png": "e9c6e08cfaf110c44be7e03f4bc382935770a742744871079c3ec5b50c72fe85", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-6.png": "ac366de94737cb05b648229347101b6e1b788bd4ed609975f111e0b0e038651c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-7.png": "7af8e9a62d39013db1f73e87cfcbaf98ab8dd71f7a9b9bf1f990685a93f8737c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/comparison-8.png": "b54d1ca21c15bded6859dfac01351b11bb8c7de9e7af40f2f2fd851260a6d4b2", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-1.png": "9a2dfbd115b50cda43da61f5e331a79f514ce407ef7a459bf66993e13c0587be", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-2.png": "f08c3a1fedc4fa7240e271da7ca4b2517d0d157691c649c0dd8841b7a12b3192", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-3.png": "9d6e126c85287ae413e202d1654cc4dffa89dd6061aa22f8f61ecac61faaa704", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-4.png": "5e9e8e9a22e49a25198199bd7189cf828b6b6f14c56e2467bafe5682af55f81e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-5.png": "372b7cc5dd23984252efd3bbcabd924244e5e4ebbebfc34886c57c7c9a0c7636", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-6.png": "de09d3df899544e4c2ed8c9b5bfcc88e427fbd2fd8ff0a60673ca4ce4dfabe83", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-7.png": "d38a8c3d919a7f435b71a2849c034a940743d71fd5b4e08c9f48453c14fd012e", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/difference-8.png": "efff95fa611e2d576168245fbec47073a0550be996c57acff21a564df78f7662", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/metadata.json": "0c6a51d3871cf43b8bc991fbb6c0c8f6f1421cf6e32c0b881855aade833fbe79", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-1.png": "a0176cbd2724fb3b88eba86c610b9546f45202096bb9d21a613080094ae6767f", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-2.png": "140de5e2e7b19ed00d83cb3ee6f46c2fae5b473479ceacb2564a20f4b59883cd", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-3.png": "8ee1bc5be8b9ed8dd6a02f10c6995f4a871110bcb24dc7e434c76764cf12897c", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-4.png": "d84516e60d7dcc9c91dfeef48090fcc333d39941d082a8753adf3a99f45e1484", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-5.png": "76d65a12cfe1da220902e4bf367ebc130e5c2312bde0e3b5ff9286c5d5cb49b9", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-6.png": "31baec17d0a17f11bab60ce6f36155ee81ffe5213924ba22d05404c62e477c38", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-7.png": "b20f42b6df69f6a2f379b403e8ef5beccb4ae9b8dc61608cc7413cb54befe344", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/pdfium/page-8.png": "083ae6e51ddfa91eb4edeb1f9cffb2ac742e8d06ecb7c77f92709229b7067d19", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-1.png": "fa10f7b34329c500896183b84f3dbf06cf08f826d6bce9ba31c100a38966a496", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-2.png": "b4347e800a3ea7231285c654d1330360db27b29944e923da682875675677a7b1", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-3.png": "f8a72fa16b84f7865bfe2651e5be9123adc0afe014af9feec48d57d664df2c30", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-4.png": "a4e6fdfe6e00334aa9bd2d7ff602a40f29ebb8d155cd456f2c88fac7f201956f", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-5.png": "409030f8181b109d686fb0367b139ad817c58a66ed68d02dc3baa4b7640199d0", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-6.png": "3b2e80444952fd632510a16b456f2f19c77fbb3f174a2e33fa67868a1f1c9ad7", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-7.png": "474ed2ae0e1212e38b2afde9cb3f128e7b4e03a57b5750a88752627a79df1b5f", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0/poppler-8.png": "1afa7bfb55ee734b07bd877fdb10304f3707660617991a8a29a00d23c5e22215", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/prior-candidate-corrected-oracle/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/record.json": "80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b", + "tests/results/pdfium-intent-context/render-review/README.md": "b69f6784d27b228c6a8a54048729914a2233013e323aab36a082c585926a5834", + "tests/results/pdfium-intent-context/render-review/comparison-pixel-regression.json": "041159c561c36753a4af7529f215ea5098763c540ab3334792d469fd3a93bfdf", + "tests/results/pdfium-intent-context/render-review/comparison-run.json": "2a9925b07ed601b290cad8f76bc99e06fece1beca79847155c36544da7b8a641", + "tests/results/pdfium-intent-context/render-review/index.html": "3ddf1754e7a1e54ed9ada08b5250777c8a6b80256ee03c7e14f070c623f9da77", + "tests/results/pdfium-intent-context/render-review/inspection/link-borders-1.png": "f5d946e645815ece8baf5360040cf9e4f54b26ec4834e754ab4450414bd60965", + "tests/results/pdfium-intent-context/render-review/inspection/link-borders-2.png": "a1d3febbcaa37a0efcdadcd3fc487b86d19bc4709723d1cf5cb06e159b01825f", + "tests/results/pdfium-intent-context/render-review/inspection/link-borders-3.png": "cd8292ee9579d70c879c47cd6d14d0808f355e1683a7a1e905ba276e55c0c6c6", + "tests/results/pdfium-intent-context/render-review/inspection/link-borders-4.png": "eb19322388888120c7eef17d02558604955c5666d6bca1ad1dd1d3b81694319c", + "tests/results/pdfium-intent-context/render-review/inspection/ttc-existing-standard14.png": "e1d6118b3e8c2d960cbd894b80563d552851369166588307eb8d3d17cdd18d07", + "tests/results/pdfium-intent-context/render-review/inspection/ttc-paired-styles.png": "3fefa6245ae152532212a725026fbcd95f95a1212a07a847c38658d357e0c748", + "tests/results/pdfium-intent-context/render-review/logs/font-collection.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/render-review/logs/font-collection.stdout.log": "89cd47aca5060480661c7c90131fb6c4cfadb32b54bd3142bc1be0e522fc23cf", + "tests/results/pdfium-intent-context/render-review/logs/gallery.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/render-review/logs/gallery.stdout.log": "bfc040628befa7f78198d0b96344b69bcd29c397f930a80f5fb1f614616ba1c5", + "tests/results/pdfium-intent-context/render-review/logs/link-borders.stderr.log": "1c9b3f593256f504f13d352518b4e816ae2dd40d924fa9ee40f9fe5d65889c6d", + "tests/results/pdfium-intent-context/render-review/logs/link-borders.stdout.log": "78f4749373233ccb14c5ea8b305bb02191f636007463596bf8a19c6e78dcb994", + "tests/results/pdfium-intent-context/render-review/logs/pdf-extended.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/render-review/logs/pdf-extended.stdout.log": "3038e72c6cc76095bf148b230eaad0998a5619a9ec8a01a6801973ad4ce67cce", + "tests/results/pdfium-intent-context/render-review/logs/pdf-fonts.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/render-review/logs/pdf-fonts.stdout.log": "057159610803d89ba6237f0b87152f022e1a98b4b336fa0beb77bd367ca71be0", + "tests/results/pdfium-intent-context/render-review/logs/pdf-icc.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/render-review/logs/pdf-icc.stdout.log": "2817897b756997a8941f3d3689fbab7025c7938857d44d7b400fb0dfe1cb9ebb", + "tests/results/pdfium-intent-context/render-review/logs/pdf.stderr.log": "d4b41b85f910d78c624af334afcc33880c22ef6b6eddf92b786a1cc6b588e990", + "tests/results/pdfium-intent-context/render-review/logs/pdf.stdout.log": "4069508121ed0ed85ba912bc38211572bea071779d554f42fd8f18987df8f5fd", + "tests/results/pdfium-intent-context/render-review/manifest.json": "fe8c9285b8168e0b67debdce5d6213b35fb3a0ff6c68d23702f04864e7cee223", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/collection/existing-standard14/metadata.json": "cec904275e7fa95897c5d9468de80ae126b2314714020eac2a0d0f4e486a0a33", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/collection/existing-standard14/page-1.png": "c2023d5906b3ed12ed1fc49497726ca12acc6d895329dcbede9b3ac7770d7284", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/collection/paired-styles/metadata.json": "672798b3cd99eb1d9852af526c100b3c820bb8110828c13f3b88361c337bb3a7", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/collection/paired-styles/page-1.png": "73db5c8a849b6e6b8bd864773fe4df1fcfcefe1a6c3eb409b9ffcc98acec7310", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/comparison.json": "89cd47aca5060480661c7c90131fb6c4cfadb32b54bd3142bc1be0e522fc23cf", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/regular-bold.pdf": "6435b4f272e1456df365f0548e11b18ba7060ff791186a89963990a8560ef2f8", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/standalone/existing-standard14/metadata.json": "1303dc4f1c8d3c1c65ff897b1216ca6fce8f4fc235cc38dabc0eabbe54095282", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/standalone/existing-standard14/page-1.png": "c2023d5906b3ed12ed1fc49497726ca12acc6d895329dcbede9b3ac7770d7284", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/standalone/paired-styles/metadata.json": "61bbb341dac14c0a08023a00ba78391dfca29388fc6fe4d66b329d5eec6ebef0", + "tests/results/pdfium-intent-context/render-review/raw/font-collection/standalone/paired-styles/page-1.png": "73db5c8a849b6e6b8bd864773fe4df1fcfcefe1a6c3eb409b9ffcc98acec7310", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/comparison.json": "858077d2d1dbac47d752801c6176e7fc230e24d8a9b9579b933eefef5c0946ac", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/difference-1.png": "f77b1691bfeb2daedfc496fe02aeb9b86bb08ad974592e1623ed1cf773d6a617", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/difference-2.png": "324e63d3341bba84f7a5412f82c2888cf0faceef557fcc3f636d07db0a35d684", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/difference-3.png": "caf61a26cfd5d14c863a0c7c92a8c4945ee4ba67cf0b940ed2e1966782c71673", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/difference-4.png": "e21ad520b02e81211ffe6a21cdf7d94b2a946117dc3177c1d377a2aee120d81d", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/navigation-poppler.png": "271e61cd88804a664c66674f47b1689d96a905a3f9acaf5a4f2be86f586adb2f", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/navigation-worker/metadata.json": "764a19aa9eed49a2b89e3b60878e3835e825526d84e5394064dfcd1bc1ce851d", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/navigation-worker/page-1.png": "c9877f9e2f2210db85a8b4fb48587093e0f2599357275b1073db718e4e43cc1d", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/navigation-worker/page-2.png": "730f502a870ecf9e810e35df6450ed47f90027821496a62dc6000b88679ca87e", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/poppler-1.png": "985470b05a0431bd5710f2a16859ce698303e4333e3930984b328911f361aa17", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/poppler-2.png": "2b916330dbb87f472ab1be0a2930f3d773e1318a2ab4d3c602f509722a3bfdd8", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/poppler-3.png": "41e3cd0ec41b7e321a269fa5506ce9f87ef9cce1a1004c1347695b5d9119a22a", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/poppler-4.png": "936fadd92586bf73936ad46db1ff3720c4be48a3a8847ad11d2468cf3b6318a1", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/poppler-stderr.txt": "efae62819739bd226f4f94bf7509869482630afb3d81ad74f74d78a4f19ac422", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/worker/metadata.json": "1d9a11066735790965202be212753fb3bd4ebe1081d50bb688af958b1e8779c0", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/worker/page-1.png": "fdd1383ad7a87a32c40b2df200b710fc5ff6d133293cb2045b01cafe0a681a4c", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/worker/page-2.png": "c54ead37c7f49f7ad0dd586a4be16647294da6c15d08ddc0c53edacde5699ffa", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/worker/page-3.png": "5eee0243a05b3faf4a29b1e76dfd439c5050343eaa9e932a96d371ba4670a7e4", + "tests/results/pdfium-intent-context/render-review/raw/link-borders/worker/page-4.png": "91b01c668784ececd52d2e2256fd4aa2b6447a66df868835f27a60bada1cdebe", + "tests/results/pdfium-intent-context/render-review/raw/pdf/comparison.json": "4069508121ed0ed85ba912bc38211572bea071779d554f42fd8f18987df8f5fd", + "tests/results/pdfium-intent-context/render-review/raw/pdf/comparison.png": "bc550646f7d72fe59dfe89b897637909f374fbda5bd4e7105cbdbfa7685e0789", + "tests/results/pdfium-intent-context/render-review/raw/pdf/difference.png": "a438bdeb9392c337833a8b2392e5d4bcad40937cbf9d480400a4093c74ee86e1", + "tests/results/pdfium-intent-context/render-review/raw/pdf/pdfium.png": "c9877f9e2f2210db85a8b4fb48587093e0f2599357275b1073db718e4e43cc1d", + "tests/results/pdfium-intent-context/render-review/raw/pdf/poppler.png": "271e61cd88804a664c66674f47b1689d96a905a3f9acaf5a4f2be86f586adb2f", + "tests/results/pdfium-intent-context/render-review/raw/pdf/worker/metadata.json": "764a19aa9eed49a2b89e3b60878e3835e825526d84e5394064dfcd1bc1ce851d", + "tests/results/pdfium-intent-context/render-review/raw/pdf/worker/page-1.png": "c9877f9e2f2210db85a8b4fb48587093e0f2599357275b1073db718e4e43cc1d", + "tests/results/pdfium-intent-context/render-review/raw/pdf/worker/page-2.png": "730f502a870ecf9e810e35df6450ed47f90027821496a62dc6000b88679ca87e", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/comparison.json": "53858938f6edc6665e675c5eb5794dd040b5c32a572d6240631da309649174ad", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/comparison-1.png": "cb201a8af5f6b9d8f03ec3020e3dd7d823304af15a0a9c8ef33ce28297c21620", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/difference-1.png": "e885deb658f2915a3aff380b4ae4ab3bd6ee5b53f4e1ee2ee3395326a3e69cdd", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/fonts.txt": "46336417d77834f9d5472829ddea5eb899e25320131efc83da25a6826d54f912", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/pdfium/metadata.json": "55657b4a04d5252dea3587e54c64101100769cb82110bdd36dcef5f1f86a18ff", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/pdfium/page-1.png": "e95ccfc8f0bec34d7ad46a29512d9b6c370c028507c8c7b3cf58d36dbc46ed40", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/poppler/page-1.png": "cdcaaa1518846604f72984a7929dd4d873f53fde05ef80c0953f7214ff468880", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/graphics-compositing/poppler/text.txt": "f24522fcfd34c9146bf64095a419e0692aa59028bc6ed8b0bade47adb124d390", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/comparison-1.png": "501273826b3b00cae03a1f571b5df6f15b253fb2fbc02a682583798f8aa45d71", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/difference-1.png": "1f2484a4f862e4293f87d6c6f81e5f09269b3ef7ff1d8a5dcdd0e5fd3f7dcb49", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/fonts.txt": "fab98d6ff070836f65bf9bf54abcbf6eed908942d4dc239121f8048861fd199d", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/pdfium/metadata.json": "d1a279f5c1d286c1c6af540031b1bbb009692acd28579355a9aa97d402ea3f61", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/pdfium/page-1.png": "fc3840d3952fe17580e12bba0f0a6e76f1ec478bb57f612e7182d5f2f8b322d2", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/poppler/page-1.png": "e93b625834584e568f2687de748dff02da451e4ad3eaa8c063a7e3e9e7135e2a", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/icc-linear-rgb/poppler/text.txt": "8903041752bde15e483e6f1341b80a2d4256120b9d567f9d8d5cd3615b0b3789", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/comparison-1.png": "f8c6146d7b782971b4b30adf1586d83943854c9976dd2914cee0a7f5c373ae41", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/comparison-2.png": "224834d9da89bc3624ba4444757d592781655601201dacb1c8d047a104865f30", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/difference-1.png": "b7521ff1b8595bdfd927fb5cc2f65af7df92583c51876d1f29010280c292282b", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/difference-2.png": "4a5610359d18db20b7afb6a10fcc49b09b5465f3362652cc38ac634ff23e96be", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/fonts.txt": "5f9b6326e2b50575face81bc29a474b7b9100cfa2534e4ea445451cd75910c05", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/pdfium/metadata.json": "83a49080b18c82a3eb6fdca8ccd5227df1a2e879f970095194824f03cf3bcc2f", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/pdfium/page-1.png": "3435c15f99dcc175e761fac7b3262a07375050b38a53af0f784c709614f58796", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/pdfium/page-2.png": "2ac590efc5f545c9fbae35810d73a2bdd78f298bf208fbdd0ebcd0a4acba9068", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/poppler/page-1.png": "b55213ef5ab2f89a94d8601191064dfc045a12e7f273b81b35c43c0cf0ff6e61", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/poppler/page-2.png": "24d0e2642e4fe32a7ace07e5c6e2cba0b6f068cca79af3f15c03befad1464e8c", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/japanese-layout/poppler/text.txt": "8ef98020c8b1cb3593a96d8ed6a273bb308733a06077031adf7fb89d6901b3bd", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/overview.png": "f378c4b91bd944efe1bc9a308a8b448695c2239d27c17617dab3efde0a3811d6", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/comparison-1.png": "cd413a462a3189765afde3307518b4f8d6654e192767d806b46da1787c878d7c", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/comparison-2.png": "5bd25ee74da57e341551a36e8209d10bd4c84f04a65b67a0693170a875aa702e", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/comparison-3.png": "71ac560447140af79f8de746edc39fef80a292c23dee7928cc001cdb150f72c6", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/comparison-4.png": "00e778f1ea04c4c2cdf22f141b6f1e57cc2fe737c61a87f9c41e376955e51e28", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/difference-1.png": "8d9909abbe8826eb104713c33dc3823b9d4e92abf446df5639a69bcc825ed4a2", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/difference-2.png": "b68ae527dc8f5e9b72db6bb08e68b1d54282c8fb2c90d6c55cc76e44539a6edf", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/difference-3.png": "1cce81243b9dc3e6759ff95478c6185f620abcb5ee4eabd1d13c9e9a9988dafb", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/difference-4.png": "b9d89ff6c5d9a082f0b54cb4ddc172291510851d0cede868941e945e5667e836", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/fonts.txt": "1670c3248bc2c783f6814bf3d038d3c4b2eb45e9616368b3df56678d6c9e2834", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/pdfium/metadata.json": "1700ed8a389fab677081729a15104d2fcff6ff844c58f80edb22f4544cd42c2a", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/pdfium/page-1.png": "845957107f03bb915d0fd2348ce5025b089a6fda84d86a56b857873b8a2c882f", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/pdfium/page-2.png": "f1732b549e0bcc003bc430570415db7f5d53ac1fc80ed54b33c60472d2502d00", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/pdfium/page-3.png": "cb957dc34593a13bbc1a1fbb810120730ba7af629225c81c2c0785df76e48ec1", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/pdfium/page-4.png": "122ae1a09375ec233e6a936fe0b69fbd79f447cd9adbf7eaf47b45fc1609e95e", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/poppler/page-1.png": "1d8722efd2bd2b8f8d916a8e96402105659661552f05385795023fbbdb425384", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/poppler/page-2.png": "acfeaf23408b3b23a5dadbb79af0df38721ed804b9ecaf8538c15c352393ac81", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/poppler/page-3.png": "e9bc2c18b7d158855433724ca5461af699b1f12c431fb52a218cedc0e993239c", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/poppler/page-4.png": "7a307ae92445e5320534217efdcff533466262a7db0b2591ac9882fc5a583913", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/page-geometry/poppler/text.txt": "c025a1c9f45129238c708f9e0d83959c0d93c160993bcb8b14296f757bb0defd", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/comparison-1.png": "ca317291944c66232cd19c11025f6cfa1bff82f03021542ec7810b0004ab1aed", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/difference-1.png": "59940adfa8a36b07ff437a3a11078a5684d441fd105909db689b29bb7505f3d6", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/fonts.txt": "1f5bf2eee92bb025ab2b7327619a343d608339c3904a9bc0a5531aafea64182f", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/pdfium/metadata.json": "cda8669c45b18616472d11687cfb9d4f0349103ed1686fe5d0e1177f61b7c221", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/pdfium/page-1.png": "fc51d411d321c974072740182aae0b4a2af5529aef5a506daf023bf74aa61ce9", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/poppler/page-1.png": "689cef720d688a914eb0925f716743a20336304cc667189a4a6504542d35324d", + "tests/results/pdfium-intent-context/render-review/raw/pdf-extended/unembedded-font/poppler/text.txt": "36dee91ce4cba95aebefd3149d47dea60b6fbfbaf69eb7380bc4c452049885d5", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/comparison-1.png": "dc77a22a886d87abc50637822b3c457e46246e846b3222918b9f6cc77fa113cb", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/comparison-2.png": "1ecda5461bc7289c7f4ea2796bbaf0b00232b1b2002467b27a8480b540a7cfd0", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/comparison.json": "057159610803d89ba6237f0b87152f022e1a98b4b336fa0beb77bd367ca71be0", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/difference-1.png": "d0260ecaca7f31db54016d1c72973ff0c3b487984c696d9573ece49727dbd374", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/difference-2.png": "11cfd2653345fec81b31c749a83aff67d9a51ff38c1b5bc4af3a72d2e886c328", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/font-list.txt": "233810b100738d1fd7f30ac252fec141980b00097da972bfb03adc4179926aec", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/poppler-1.png": "389271bc3db68b65d157f65ffdde8dd992a60b3edfdade48ec42fe0e6480181b", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/poppler-2.png": "763ff938525b2ac8c8ca8da4cb1b37c8ea532fc2996e66b79a3c55fc6d8405f2", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/poppler-text.txt": "e0929cda9bbbcd4bb357cb0f9229718c5fc76a90637a7e7171b71d710a081127", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/worker/metadata.json": "53bb4c2779150fb445cb841e6c38e4d3f763a07eecb4a015716e1a3e6fe5c2a8", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/worker/page-1.png": "73547fb4ab2be0a21d703907aeb3b25291c13da8ba9282a23a7db589b527c57e", + "tests/results/pdfium-intent-context/render-review/raw/pdf-fonts/worker/page-2.png": "7f62cae50c7458b145ec1dc96e8a1b756b60cd54b9abf75854b8dc304f091d04", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/comparison.json": "fce005452d90703f5a3fc089a1434cbdf08f26165f26e4fc3cf31d4bd48f39ae", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/overview.png": "44826835207c1b7b0e204e90f62e776a8e91c250283e5d558086e1e448f1628b", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-0_5/comparison.png": "5e9c1c1759a0ff28ebe53ef3a6787eb9fb812f884789a4ca144a9d44f05f4f37", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-0_5/difference.png": "5164934b53920349a905b05a4a56d147022088c03229b97cc8f1d630e12011a2", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-0_5/pdfium/metadata.json": "df35b8dc8166896401b95eca8d71d4afaa914b047445acfeab77834cd5b46897", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-0_5/pdfium/page-1.png": "1d2375f1e1446aa8833ce4f0724aa60953f140ff329c1512240cdca534977409", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-0_5/poppler.png": "ab0c1e8f5a369d416e87fa267f0c24b10278e83111979b946a8630d19421a79d", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-1_0/comparison.png": "07098a73c635e050618c8a5b6671049886cb6f34e6949c8de812cfe17d7e3faa", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-1_0/difference.png": "3057635a5d6a21095ce6c3e846e5b1ee8816161756a99e3ff01d1bc303d89067", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-1_0/pdfium/metadata.json": "2e759c63b612941c8e4d9b13c95fcc373ecbb36713db5fd0db6ecf748f0e224d", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-1_0/pdfium/page-1.png": "3faff6dcfadbbd891980d9280a489bb937dfb1fd30272c418054526ad56d32e3", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-1_0/poppler.png": "7547dfd33d2d936c9e6c11ba78e161f71caddd464fe75a9f14e40b4a832ac89f", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-4_0/comparison.png": "7d3695482f925a55725fa5002969711f7cced0c30a1c704b36f3dfa2114e5c90", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-4_0/difference.png": "3daf757a15bfebb638234185fa82a5d5eb503a348a661d6cd4093cefe466a03d", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-4_0/pdfium/metadata.json": "25f82423e8f953cd6ce7c0f40b874d33eebdc04fdd0aa6041ca9ff25b6287dfc", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-4_0/pdfium/page-1.png": "a0508edbe41620f306f5e5e5149dd4c8ab37e39c3934d53d76ebe24820390d8c", + "tests/results/pdfium-intent-context/render-review/raw/pdf-icc/scale-4_0/poppler.png": "1f9b1f463492455d9aafcae2399c55fa8de59166ba2d92702b974c43e6c190cb", + "tests/results/pdfium-intent-context/render-review/report.json": "f470004f1f8162066243333d09c4b56a8504f066a4ee1bb4cc7861dfb5e045a5", + "tests/results/pdfium-intent-context/render-review/run_comparisons.py": "229b11fe27459308aa40ccf165ea53d2cec6a4ae8e5f102d8b36e014111709d5", + "tests/results/pdfium-intent-context/render-review/source-before/compare_pdf_extended.py": "d4f6e678fc63d8349d4d488713afcf9ef37844478b273beba134c8dd2adce942", + "tests/results/pdfium-intent-context/render-review/source-before/generate_render_review.py": "050c6f3d387e9f6829485abab910ddaeda975ce46bdff7d29138824bac7fd2f0", + "tests/results/pdfium-intent-context/render-review/source-change.json": "b3c522e4b61566e483c449ebe35f9f19b83ec95b790e1199af48156c80ecb609", + "tests/results/pdfium-intent-context/render-review/source-options.patch": "b7dfeb9f7d34c7f679d996638440883713aebdd7b9caee83744c2f12f0f178e6", + "tests/results/pdfium-intent-context/render-review/worker-ldd.txt": "a7ffb471f960cd4e49db149a23114a4bd29a1dc755ba26b4ded280dbdfd0b94d", + "tests/results/pdfium-intent-context/source-copy.json": "9ce3f937f8634db0930612a856f64e7cf230f0f888e51257c6c5283c988374b9", + "tests/results/pdfium-intent-context/stage_pdfium_candidate-v1.py": "44df77c7f01a40189b97314002b080a705ff555006bd9a8d1fcc4ab6efd03409", + "tests/results/pdfium-intent-context/staged-notices.json": "2c1ba32cee7c3e477ae8345a5dcc1ce09af75414c339b7b7c6fe0ee29a1e0cc4", + "tests/results/pdfium-intent-context/stager-tests.log": "ff61016adda9133edd2e695b68d45729d780e5258ae5e15517b42d61235b1613", + "tests/results/pdfium-intent-context/stager-v2-verify.log": "7c80343da358035409df7bed72d73e38b9c5f68062ee5fcb06f2bcad3c622b38", + "tests/results/pdfium-intent-context/stager-verification.json": "d59532f35170d1b9a076a4a3195f6d9dd9c4ae5f4cb8af4eac7fd3bd6721e4ef", + "tests/results/pdfium-intent-context/ubuntu/LastTest.log": "8e92f2fec8cb796f87db6e9f422a8cdc8777d02bb48d80bcea4695eb2b894da8", + "tests/results/pdfium-intent-context/ubuntu/cmyk/overview.png": "a652e2cab2568d476b8c6c4b0c3b17114b2d67c5985c73b8d75c607dba383e90", + "tests/results/pdfium-intent-context/ubuntu/cmyk/qpdf-check.log": "eb3599409d4908cb1171848414bcf98c8af522ef8c4ecc6058089d4650f72336", + "tests/results/pdfium-intent-context/ubuntu/cmyk/report.json": "db98bb794eb83a25542ba4fdbfe96f2e9ec2606388b643c3f3c475754167b55f", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5/comparison.png": "ffc720a5f902d601330cffc4db11302c9aae1a7ebd300940c114abef9f329e4a", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5/difference.png": "102f1102260926b0004a44e8aeb90ea46427b19bbe210f8c0f4fbc503113f248", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5/pdfium/metadata.json": "b91ea871f6d2ffe17ea21c7e70836c1961e8877eccaa7828b4d72c532cfea0cc", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5/pdfium/page-1.png": "16900605fd4b016a0d304ee8349d723134f65715c85da1ef8ded795baaebacce", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5/poppler.png": "481a41394a6ecaf16fff600db9af52bab896c05407de445438b0ebcd077c3335", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0/comparison.png": "1b982111c33951d862f586db73153a9abc0210875d581450f8cf9b0d06d1546c", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0/difference.png": "d63bbc4c5e78685c5017776f1984973f41d29f1f3a3f38b980a2d92147e36624", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0/pdfium/metadata.json": "b6aacf5fa0aff7b1a5eec9bb2d6446b8afd8572013a9ae148a67862a61b50d16", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0/pdfium/page-1.png": "4acce048167b19cda75d59dd5d0dc4c9ed0e94c15dbf1aa2841391de2d2945ae", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0/poppler.png": "48048bb581e2013a1df13a66c26ba2e164191ec0c7e4ac50d7c86503c0e3e5f0", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0/comparison.png": "203d85946d750a6979114a339da8a6391d0070bdc4524673ce9d90d2b74e45b9", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0/difference.png": "7d5871dd9f1abc45d930b566d92e4011288d57423c3ddfacf4b956ca589924e8", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0/pdfium/metadata.json": "40a582667fb8739f1e270be94d58791b71c316617b4295e154277a24b8df0a80", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0/pdfium/page-1.png": "db1f61196ba73adfcfa2544840ef797137fad45b4d1af1f08708be3a4bd2ba81", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0/poppler.png": "ce76ff2f960c5c3becfa33ed58a0a675097b8f2a8db3c6f9880e8836c8de1c23", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/cmyk.log": "2e2f249f075131825a21eb4b5e6656dcac738b2d020681a23b389701ec54f490", + "tests/results/pdfium-intent-context/ubuntu/ctest.log": "8a2b7ce485e60bd30f61f38d3d1c161b388fc1539ab3b50e3a1ebc544c8d4bfa", + "tests/results/pdfium-intent-context/ubuntu/intents/qpdf-check.log": "a42782a82a29204c31c83c3ef9a77c669e6abb720dfd1926d63029054b386e64", + "tests/results/pdfium-intent-context/ubuntu/intents/report.json": "7627ab37f521d5b265b135942090c5dcd7aaa7c66fab75c035bc39584513d0f2", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-1.png": "01cc49cc6c7843bc0860c2baec6f5a1dc3820541b6ba320b8aa238065849dde9", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-2.png": "6965dae81cf3d9877102bdcd71dee3e101c2e9496fc62c9acfde19ef670bf827", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-3.png": "d2b89a34694c3dbdf486f5d12c7d858dc3b50481a5212b7e0254a06e9cb73f9c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-4.png": "4d40d331ab2549479dfa3e94d5db5f20de8f668e2ef58f3b52de6465b5f57b76", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-5.png": "a7e3f204e3f81fb655e06635489f6f1a75d940d14f21b96312bbbca9966e5337", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-6.png": "a968ee5d8e20357ac47ef457245e86e37f85321a4d6f945e209b0513e5370bcb", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-7.png": "7f99c5a0d24b522d022b7ee4cdc82912290ed2704250f22484a9cc9e2946a984", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/comparison-8.png": "df4307a41ca0dd1da3f8a41dd2d4893211fe2e0ed8c2536bf84621eb1a71c0ce", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-1.png": "1626a81c326e8abf9b8e222b75e247b02c095dac46ee2bb3db297c4512f7e79e", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-2.png": "120a8a4517c7fc6a833ee17669718208a50f3a8ff813bf49e7a2b796f6ef6429", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-3.png": "5b883cb407f651fed3b0c73e6a02938e7beb8357f462d0b31ade461e0efbee4c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-4.png": "6fd3bd855171d801fade354a5967913186d064e15aa32207f70e4179045b9f5d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-5.png": "9e64b036f6371fbc848d83a5aeb0ea12d029d0d1e498a97e224840d20eeb214c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-6.png": "6f96b9c7175a154b6152a212b2ed52d954cc6e16fd4f88e2800e50fc6857cc3a", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-7.png": "96bf4e632063eaf37ea8c719a80b79eb9227b07c68d62276a493bc302e67e52d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/difference-8.png": "8ec22909c200a0f0f173e96ba7e16d66850ac059b2aac1519e1683f9493f519d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/metadata.json": "4a01f6e7bfc16aabf3cc096fee4e8d2bcc4959c1667425f7d70ae492a31c98cc", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-1.png": "141a3b02404af17231bc658401cbdab52198f5bfec8f7f635625eff59def1722", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-2.png": "bdc774a7d521888534403fad2ef7d44638a5debd53a083d3442c9ec91dfcf20d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-3.png": "9a8a0cb6e51e7a680f0c969cd456434f3641f2a60a6a21e812410ce8f50fd842", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-4.png": "82a45cfe9ce2a9c89ce848d9215666737696b146e5200ede2387c395245e594e", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-5.png": "9f3b06274de35c9b5ce2c38c75eaffbcbe3d88c0d07bb05059548cd30aaa3a5a", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-6.png": "e8cef29741aa6f54dd73adc4698880c578dcdf58a7d919b1ca7a26df7a793089", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-7.png": "282a8a482072aae16b26a53ffcc15a0d817336454b9beb1e435d57e4587b9c31", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/pdfium/page-8.png": "5938bc52e3992940b5c9a70a9066b3076952acddbb5fa43e39673dc6c2f33921", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-1.png": "f627abc2e44e34c9dd66eccc2cdecf16932a80c5bc2caa7e1004856bb1b1d0ec", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-2.png": "fcfbb9596560ef3991262635515f48ae75e90c157cbed44351976c7fb0e6aa99", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-3.png": "47c391bf6e3d4ae777a8d498690456931b0f8955eddfaeb0ff4ae250bb713a35", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-4.png": "57fcaab52ce323a9946879b9d99cd18d79cf8cd1e82414c0ea93c3a2a0a03097", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-5.png": "51f4eb2181e3bbaaa3d4cbcbdf5bdb4ef009433675ed3bb2d11165c42508e9d3", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-6.png": "89c5d9e256897619dddb2f5aaad4362d08ac2da95ae6a4ad6282700c3a741d1b", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-7.png": "761a01df1ddbb9356820431f1dbd579cac89d4a020ebf2bab2add4292fc221ea", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5/poppler-8.png": "b1e772270d148ec5d779b67ccd5ec709c984048b6dd8bc3cbc1f5e57dd3efefd", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-1.png": "259bc8c179d5b8d9695e3df2e990ba72d89ee5f1a99f74e339881e48f1d58ee7", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-2.png": "374b9a9b58efc2b8d2966b341dafc5c7e577a71086f4feede0fbbb7cc2355aed", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-3.png": "16acaae93e11e4bd39e852f3711a288fc5aac57f140beae142e6b5da5828a876", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-4.png": "577b6d3ad4ad1543a02f85e39d0162feb902cdafc7a26b60f7c1b267f983c080", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-5.png": "89aa2a1264da06debe1aac3127dcecd1b46db1fb287df816295d92c2757d0c1d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-6.png": "45e39fb7b740c74421b52e77f836889144879c03f883a49d3a68ab8e5c2f1cf3", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-7.png": "2eab164050b7170d548bbd07df32e3998a3dcf6b0d315a4a23e4ade248515ca7", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/comparison-8.png": "3231fb07c07620e9801c8632214882ebcd3c546dc9bb0d18a78218c34bc434f1", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-1.png": "53dd1d79afd3eb3f549dce518ed34c5cdc91f69720900a75c4ec8e8ac756be78", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-2.png": "81f8f013f90a308aa561dd10b06d6603dd86ca22260fa4410524f89eb598542e", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-3.png": "466ab1c3f23fe97d02fdb9a9447136fae41a649bad0e6959156f615e5cb3d66a", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-4.png": "cb202f94f5f40e46a63248a05cd910551519ff36674ea5b29266a5ce27706b57", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-5.png": "fcaac2b39c6968388c07575f55f9f7614430241e21b59755c37c2f4a55e17b33", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-6.png": "c23a654b39b15208858e20447a31bf47b2c471816f8e074198ace19629c23a67", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-7.png": "5e5a5eab0d9f8de71e0383d48cdfa18bc0e58298043e2e0a76a69c315ba22941", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/difference-8.png": "d25cec5a338ef4fa85788f57a36545cbe6b7b91d0d92887c5cfd437804af8436", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/metadata.json": "3160df3e3a31e8c7e278ddece9e7b15d92f078d36c4e5f7a7b892d6c00d354a4", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-1.png": "1d7cf550282dcd792f09da3a22326e878e1d7067a8e390daf037fff2b924d046", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-2.png": "baaf4a717d8395671354a8dab36b2795dd7849da895b39a747f8c1118db905a7", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-3.png": "b295c01c016f46efabe3293354f764b390f951b281229fe0cb910b663be8196a", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-4.png": "c654c9297a2c4b68c14de2ff6598ff3779a290b495a01ad146be0a4208a99e2c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-5.png": "72a097e9c5e28ea4383b473e1bcc0b43ebb8c29ff2e23ac95b723757d5c19d13", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-6.png": "0f883a73df9639e50fc0e987144ca6b7fd93f7c9437db6d4e4d5a07091c3cdf9", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-7.png": "1ea6b00bfaf57b4b549bf7e162814faf2ebf226f4371cf7b6a7671c980fe0498", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/pdfium/page-8.png": "d5e1fd1c03e44b5724431f063a6a71c36002b8813cc7daeec55d7f2959d745db", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-1.png": "b773b85d6c3eed94c27b449d48a91764b7e4c3e991df4ed28c8994eb8f168055", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-2.png": "29e15538ac0b4b6404e5df118aa47fa07fcb8d8d787c78a694bada9b49cb7c01", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-3.png": "9b35e979fd1701ee66399811a34c08a258c8fa61ccb54cd939892e77ca329b3e", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-4.png": "adf6301ab13030f77137026bfca68a8bafbdd08029f3c0b7d5421cd3f0f33d1d", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-5.png": "21c969593397a2eaf9a6af0c2236ffb563ed6df37736a1d46f5e90dd77d3838c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-6.png": "0837b613d2e27505decef0f88a96c672137ea349eaa224e8ea804b8f997d4c3c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-7.png": "717361466b4f2290a4bd1edd87212e07373d73bd888864cd1e8a612616f8c3f8", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0/poppler-8.png": "ae8b27e5e710e1fa89610a83739d0bcc87e7700dbb902588d84799533e5b76fd", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-1.png": "a011e323775114e3de18ef603421bd10ab5d9b824b400e12f5e072acf8ea982c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-2.png": "b7d0428c50cfabd9e0402f015e8ac87c14889285facad49f11ca2ee0867a5e6a", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-3.png": "28afe620764d8f21e4951318caea409c6f60c95736aeb36989e4cb33cac46b26", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-4.png": "207d61686800ab8ebb4593adc49ddbcae6a0fe5ec2b6e7f45d84217daefdbb8f", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-5.png": "b93721353e4ab53947605b8c93de0f4674f84df918969d3d5e1a1db97e311406", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-6.png": "9b2266a0842ce2f9a04465343e131816400a8a785d9d9797c733fbb24955a380", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-7.png": "4fe05b1ef8cd42492501e124aef8c88db20a8928851b973bd23c5a03d6d67028", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/comparison-8.png": "01ec49cb1b776f4f22495123dfe2540b5db193c30d6d5e90d060abdf706332cf", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-1.png": "bf851eed341fddc0673e3269c9672876ced4bc9d040fc8e7af5a33828e31e5c7", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-2.png": "355a8f8e5810a0d5c66b7c68f0e95dae29304e115a67a6dd87fe9c0c63977715", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-3.png": "c1b9c34d2483889d953014a1afaba9a40545efb89b9adde3b5a4b1cf27917219", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-4.png": "6793f25858559eb4d971540a6478a76b61ee3f7941c5a07567a665a4e9418f9e", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-5.png": "0c44f31bdf1f4a596e5e9b7b20cc2c41d8e7e2ccfbb93567c0d0a2467a87a81c", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-6.png": "12522b1a5c6321cd48a05a41eb53f22728c2249b9db7cd5fe924817da545a02f", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-7.png": "35ccc5b67fdb40db47a5d24b3457ab89af205ccc3271172232b6d3f718ddf884", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/difference-8.png": "24e5265dc0d25b11c75a5418ea15393e4ad0ee308931d2be409bdef6e7d23ed6", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/metadata.json": "0c6a51d3871cf43b8bc991fbb6c0c8f6f1421cf6e32c0b881855aade833fbe79", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-1.png": "a0176cbd2724fb3b88eba86c610b9546f45202096bb9d21a613080094ae6767f", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-2.png": "140de5e2e7b19ed00d83cb3ee6f46c2fae5b473479ceacb2564a20f4b59883cd", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-3.png": "400a80b294183cf1751d85f228cd7fbffb7c9f2f30d5e79bdac156240f718423", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-4.png": "d84516e60d7dcc9c91dfeef48090fcc333d39941d082a8753adf3a99f45e1484", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-5.png": "76d65a12cfe1da220902e4bf367ebc130e5c2312bde0e3b5ff9286c5d5cb49b9", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-6.png": "31baec17d0a17f11bab60ce6f36155ee81ffe5213924ba22d05404c62e477c38", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-7.png": "f0a0ebfb0fe078d40cda90abce98b4b5b8f3705f3aa324d8dcf34ecd2da88505", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/pdfium/page-8.png": "083ae6e51ddfa91eb4edeb1f9cffb2ac742e8d06ecb7c77f92709229b7067d19", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-1.png": "394eb80073e0ca9b920143d0bd4c8ef9658d6ca44e17386197af48bb779f9de2", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-2.png": "cc82274d50aebf4e6a11cf30b7451e808a226bf74e7923714ff0577fba974253", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-3.png": "3fc6d8706f2b5a88cf32e7fa17f6a3cda12ab5edc7fb358bdce84dae3a4c346f", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-4.png": "bdd0bd87d33953662d75b58af0cf080f89ec06075c4b7f5e7026523178b3ce53", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-5.png": "9bca1d3a07fc9c14363961ebe7451a5bd58e8ca9652d2f939b2b28895d7e11f0", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-6.png": "3187591772e0fefdb3b7eade015c3438da88d6d621227b7de3ea15e097fef8d4", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-7.png": "6fec3022f68d6fa8d20864fc55d5135bac3fb3cbefc33e9e1e9fb497657fdba0", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0/poppler-8.png": "c6496f8be3c7fbabab17261b71fa006ad130936cfc8357f40a33c42d3409defa", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/intents.log": "51f300b09e3b1e541a66d9219cd20169490cead22eccc020f5dbd2f0cdf51ec5", + "tests/results/pdfium-intent-context/ubuntu/report.json": "68d642cc41157e75418d3174bde1727e93ad5c0159bb330e331d6816d3c80601", + "tests/results/pdfium-intent-context/ubuntu/tests.xml": "6b5c061682b2c124207c5ec725f505f02452d51434a994b3cdcd2459d8640e14", + "tests/results/pdfium-intent-context/ubuntu/transparency/qpdf-check.log": "22b091081f18ab53975fa762aeb9b239e6f2c214e266d9377ab05f91c1ebea2a", + "tests/results/pdfium-intent-context/ubuntu/transparency/report.json": "0d03afcce8bb4ac900b5478bad94e3e528adf25b98c69085b1a82e95914728b7", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/comparison-1.png": "f399bdb9635276e71caffc5b1d88293ad42ab204e93b8301d98a7b8866ec1eed", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/comparison-2.png": "056f6f9db9a0df5ccb0c54ff0cc8935c7e950acf86ca4144f1497476856bc298", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/comparison-3.png": "d289696172997e515f36bc1397b6221b61b38380cc5efa7a88d609a6161b844b", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/comparison-4.png": "4fcdb872b9030a9184e478fd9208d157a9db800784e8e4fdccf8284066b7ea95", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/difference-1.png": "7a0bf04b191f045b8b49810e077d12fa4a74b9d1989cd9db9f269c8c0fbefa89", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/difference-2.png": "4928d1b9c645e295126374305a7382d694a729b710631e513685cea27d483d83", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/difference-3.png": "0585b9e08380cce8360c07e487eec6dd7ac23de793744824f6d424f37679ae5f", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/difference-4.png": "587d5976ec573e154475f303847650026e6460bd874e3d14b17dce9b3a3bffc3", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/pdfium/metadata.json": "1a0c4b653def09d1916981023ff238728a3d8471f7a2a399e70aafa21f628afa", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/pdfium/page-1.png": "347a0c598280368aa5446b1738325b6b9794ededb784febed5f33f7a02c44528", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/pdfium/page-2.png": "9f96a8dd81e93f9588676da34d8af53935cc6d0176795d0b77e8d8c5cb7e9163", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/pdfium/page-3.png": "b123bffe6d7b0f1b33a22ec2ef6c6c0ebb22dceaba8a1a797edd0aa98b5b03d2", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/pdfium/page-4.png": "43b36e9d7920e4852bde94b31e0f063b411299edef9908333459e23eeb850214", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/poppler-1.png": "294f81c0c94a3dda00944bc95bf91245dfde4725554909224c1bd83010faed8d", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/poppler-2.png": "fc5f82dd64d4289195ccbabd657c29aa37cac7a8cf4a0a8b7bb6ec058b375003", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/poppler-3.png": "51f8291e91b804d9c983af640419cd8f0a38c283e37b67c77640c9b9957bd4d8", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5/poppler-4.png": "7962e3d8118ceda851dc7976e0229e90de8a08de4ef13156f08d4c9ee9066acc", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/comparison-1.png": "795444833a13f6b0ba3676fcf7907a47fd2db20d41c567d89d45453864975529", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/comparison-2.png": "54ef6595d9d009347af126c37f5d0a8b31e794be2d4b63f416b9c6553108c29e", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/comparison-3.png": "b68ec0c7037fb2d535bb850b99ab58b4de899a09589b029d0cc88f477dd8ee50", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/comparison-4.png": "201c8e9a9f352e1062a34a3d64c666a7f6d1b66aec24887214e36f07b77ab2bb", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/difference-1.png": "d92dc75f37acd055cc7a29245e6280e21534b700c933c19225adb4aa35c1c3ee", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/difference-2.png": "880efd576e6762b4a3ab3e6f10047640c4a646c2068fb7d9a9c18a94df9ec789", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/difference-3.png": "3dda62e33255ebaaadc3055b08f0cb1324fd7093ffe773671e7595d88441d743", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/difference-4.png": "abe608b09147f4a71f94429a00ff22f28523f1848482cc929a968a8df02f2eaf", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/pdfium/metadata.json": "a304c514cd928afe6247172c2c03f882bd198e41c920c8f3dbf36a8f1d197cb6", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/pdfium/page-1.png": "a4a6fc48cc4d63ed77dba9456b28cbf7f5de7854757ad9fb71ec2cc91923c5f8", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/pdfium/page-2.png": "1da9ade78a51412ba6e6722ed681505aeea5b93382bbaebd5c30a8bd46d5a813", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/pdfium/page-3.png": "346965080ddba6e5a7056f303b3451fda4cc967504d0400112ce378c9f43da5f", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/pdfium/page-4.png": "fac147a50f5f3e1f87224e598989a0c271df7242102e8dcbe2b0f8347c2838d3", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/poppler-1.png": "4794be49fdfdd2c5631bd3c3d47284cbc896c13b500408afc699f2adfe240d89", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/poppler-2.png": "7e21f26212b061133a653b69737ef4c8c20b86b27bdd8142115c7d481cb9ae30", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/poppler-3.png": "38f17856f6ef77b6db612053840a8809faa58c4dcff00031a9223108e935ef47", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0/poppler-4.png": "754a5b6623a3bb2f15a6102f8192c8311f81d82435dae65c098e6517eabe1ebb", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/comparison-1.png": "441134fb34bf5b0c0cf06c9e507bd966d7b90a100a1d40cb85007ac378b9345a", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/comparison-2.png": "c97b0886a084397bd167881c19187a6facea0a8b966740b7ec02335d5b09b525", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/comparison-3.png": "0f9f9d56c142bd6ba0269e8b420b62537d447bddc867d34d92d296c2179465a3", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/comparison-4.png": "261b91a5969950914054d71d402427bf58e4ed7722c7bc5b893adab868c50925", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/difference-1.png": "00c1de36d6d1b82af6cd454a0ad7716ff54e92d4318d6751e296237c1d5c54a4", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/difference-2.png": "54fb52e313bd01b04d6caf5bcf744dc77b08e21e7a64aa69cb937b73d1672f52", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/difference-3.png": "a8c1f5054295aa9291e125f4f702f6112136bbff92c6e57b7b9275acb5762f7b", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/difference-4.png": "ce79426a4ef2394c7b63c1e28ce01800f41ab8f2af812fc7d1fce1207159f714", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/pdfium/metadata.json": "8fb1c5e0f87ff005c2dc2613764e2ac8e3710d7df2ef9db06cfffe1a6f1cd1d7", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/pdfium/page-1.png": "f07cd43e7b27fb4a199b8a4fcbb951194be8831eb530e9fc45deb0f3ce21a6dc", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/pdfium/page-2.png": "219f31104a358a089a21d1149577dfc4262fcd966d1c522f8cafcec08d889240", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/pdfium/page-3.png": "8966bf2f2f1c906dfa58121fc4891534002022f70d9998671503e324970fd937", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/pdfium/page-4.png": "c1263c0586c6e0e23c1d21f4d18cd5a454ec2631c509b1d10cd56a320393ca19", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/poppler-1.png": "ea2861fa0981f3c5313004688e725169efe5ef5c93fe41574a62d0ee5e85aa18", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/poppler-2.png": "b8dd8b15a31a79db17defec13c983526110e4e73290d7b3398b84e31003f7592", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/poppler-3.png": "b1a49a60a5fea95339cb52fe3f16c65e38e997afab1ca574772532f4a27fb63b", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0/poppler-4.png": "8c390102bfe367d2fa3260db1777d3364b1f352b39bc2b9b74909edbf277e771", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency/scale-2_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu/transparency.log": "25f1a67b437b4ebda2fd18916fa04e17b7464e3539e460e8f0d9f86b0de25dc5", + "tests/results/pdfium-intent-context/ubuntu/worker-ldd.txt": "47d9669a0b96b323d40e974998130474601267d2dfcd850b146088fd34fc0e2e", + "tests/results/pdfium-intent-context/ubuntu-package/README.md": "a131f8bce3567db905b100f646310c25f97718a9cd536a8748860c0978f8d53d", + "tests/results/pdfium-intent-context/ubuntu-package/apply-transfer.py": "320b6550f15a6521a8dbc17319c61839fc29934ba40ca770be8afeeade8dcaa2", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/README.md": "48f829888a16197ab74fccfe555264a08cb1158e2a3a6b52fa8734191ed0fef9", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/apply-inputs.py": "e73afcb746fe5911cd25809654afbcb1ebe699d301d542f176406130b3280dcd", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/export-results.py": "eee12a589089d05302bc4fed5378bf3e068ee4fb3699b7b441aaea7746415428", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/candidate-identity.json": "ea622e466d121c735167d75b0e60d5e70227e505db3d6b275a6b69c4343c4ce5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/final-inputs.json": "4540c365f103caf2a6059b70767b077bc7ececbca015609756749e46a59a313b", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/apparmor-installed.log": "5221840a58e27aafed79e69ff71175b0a13202a8b1b20dcc4665982f0c7e88e2", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/apt-install.log": "0dbf351d87c0e06fe1b0bb02b370a84d72f83a35883dc54d2627d752e139dd2b", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/apt-plan.log": "8e5205f0c929c3c8b865d5faa426d1b2c490114059f82cfe9b386b35f08741ea", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/apt-test-helpers.log": "22f573c232fa5e1eeb5d9c47163b51ead05351cf6fcad081d4f800d7fcf4d281", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/apt-update.log": "559e5438a08efed27efefbf47a8efc03471232903a7c14071f47792f4d26e029", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/packages-after-install.log": "885ab0567cbedf595022bcdfd982be482aee01276f05b1baa997f8a7687df509", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/packages-after-test-helpers.log": "885ab0567cbedf595022bcdfd982be482aee01276f05b1baa997f8a7687df509", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/packages-before.log": "2b8c63c622682c7f421eece0613f0155ece7a72738b69507296ac0fe1028cac0", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/report.json": "e3094f8cdfcb17d1f35d91ef16a2cb3879443d62ceaa8097bd6a73f2102e5af8", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/install/userns-before.log": "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/remove/apparmor-removed.log": "41d964f71bc8c09616a5016d6d023212a80342cd0ed22f2783af76289d4ad4f5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/remove/apt-purge.log": "9d4435f25206627c4f130fd5ae5dc9309995bb8d1dbdb3a9e1174737030bc864", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/remove/apt-remove.log": "956acd4ce4e9e8d94591d8e0f34c177e1cba14298d82c5bbb147036ead650f30", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/remove/report.json": "e703fffcef832b0160fd2084cad6c291afd665dcb36e300f81a93ff1727fc809", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/remove/userns-after.log": "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/report.json": "235744b8977a02c235cf51d088c3bee3c589ec59a60540ce9b9fa0dc574dc193", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/compositor.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/report.json": "fe9f1480c7dc1d9a96e3d6560655f5d3cd025e558ffbe0ae76e4c51c10e2b979", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/runner-at-start.py": "0c00403ff21d869fec32f46d97ec1e248cbe88a88a712ff1762b4b614e62aed1", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/epub-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/epub-dark-100/screen.png": "79d077bbf900d9e8ea134424f2315cc36695a1e909d04fd97212bbfeb4d5871c", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/epub-dark-100/state.txt": "e038ad47bc9fcd25b4597c5c110163298425d0fd43d2d72048cc39c583abdc86", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/html-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/html-dark-100/screen.png": "45dee68ee81c8c1a3b39bc8c4b5626e611b767eb3cedf12a13065738ac7ca4c6", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/html-dark-100/state.txt": "310ecb41e45485308ae7c2ec0482572ca8c5892e99768d36a0b941edb858305f", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-dark-100/screen.png": "b6e06b25d89ec626f0de4c88e8077a0020ab0bc2af116ba98ca89426c9c57e08", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-dark-100/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-fonts-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-fonts-dark-100/screen.png": "15491986d9840b4ea75a3c455226da47114a7b69963e49278f1bb32e3694be18", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-fonts-dark-100/state.txt": "8833b8d0895dd929fd4db9969e6ba309f18ad0e3254134e2b8f760e32eba669e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-light-200/runtime.json": "374adb23237d8cd11344d6204be3d9c44c5b59dea904d530bc13bb20328dad6e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-light-200/screen.png": "7f677ba333c7028b6a52f86a1ac766ebd9cb0f4d0580f23938e01a3f83f2ef6e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/pdf-light-200/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/results.json": "1277d5cec34d535a1a9ed9db35c0dadad70bc9fd623da86377b525a58fc4c65b", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/zip-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/zip-dark-100/screen.png": "5e762c21cb8f1b3cd14dab8d33518b3d3006cb267751b70511a011f518aad69e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke/zip-dark-100/state.txt": "4ebf72a5a921265b68b87670836ba707ca5d41ab8d3218df362e943a2380cca9", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/smoke.log": "c39af78278c1f52eab8c050078b38e958a472b2fe2082600a1f624527a652fb6", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland/sway.conf": "c46c671eecfc1f4a467453a63d08ba565761ac6d1b479aec7a4994b3321cfe2a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/wayland.log": "ff8e13c9bc21bbfce815d6687d3dd227f9487dd4186cdd0e2bfdd1a29eb5b990", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/epub-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/epub-dark-100/screen.png": "1d22fdee041b28e7f9bc4cacfa9322316e4e97e6089eb9ed396f999cccf583c6", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/epub-dark-100/state.txt": "e038ad47bc9fcd25b4597c5c110163298425d0fd43d2d72048cc39c583abdc86", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/html-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/html-dark-100/screen.png": "ee7b745bbd79275a698242aec0b935c62def1665d18fa158f01a490c04ef315f", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/html-dark-100/state.txt": "310ecb41e45485308ae7c2ec0482572ca8c5892e99768d36a0b941edb858305f", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-dark-100/screen.png": "c509318c4125a0c434b0b862c42ddb0ac9a82d09bbda11c32aa4ce6b49717f2a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-dark-100/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-fonts-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-fonts-dark-100/screen.png": "fb5672882cf2b0f526a40f49fe9490b2a819357206bffc09f5539a6e1d290122", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-fonts-dark-100/state.txt": "8833b8d0895dd929fd4db9969e6ba309f18ad0e3254134e2b8f760e32eba669e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-light-200/runtime.json": "67005e1317b1eca5de3cd48e37d7d1886368164d02934a826b8e0a14552ccdcb", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-light-200/screen.png": "2f433afbcfe1be96f579747ea557f29f88b9dd5a3a0c8fdb8c69b4b64a763156", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/pdf-light-200/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/results.json": "178b9aab380e8d9544637ab5a2514dc16e257fd17c69b03eb24b21a67db569ef", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/zip-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/zip-dark-100/screen.png": "1db80d00c73431d462899923de45280b29c204763715718af47adb1f6ce9e149", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11/zip-dark-100/state.txt": "4ebf72a5a921265b68b87670836ba707ca5d41ab8d3218df362e943a2380cca9", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/guest/smoke/x11.log": "0357a75a3af5b325230f149b4c076e766f840316430d7ca28105cf75f35fc2ac", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/candidate-identity.log": "62f1d458945305fd4cb0a0e6c22a175160544052652f668f8e86324a7d2c07cb", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/export.log": "05951f30d729f96208180e1bfa90e9178efb47bf1362dab6612e107bfd87a1c9", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/install.log": "aecf5fe65032113fcfc6f435def1ece42d30a58183be56d6a4472eeb58fa09f5", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/preflight.log": "40127c020c1b2f01ccf984e0860779f7eefcf2a03879c62c787febe7e2bc2ebc", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/remove.log": "3722ec9e3c36c4d45fea5b007ae7d41beadba9e547dbf6e83846d5f8347ca94e", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/smoke.log": "543757b78486fb8ababa5d85c73fc32f473f4f1983199bcd5e34a7283444af2f", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/host-command-logs/transfer.log": "ac00707f4d5e0a5397f05bb47d9195a2585786854d979766f0eb843b109b4f03", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/inputs.json": "d56b85834af4367acdf4e45ac50161a256d1b4473bd435cff536ba46cfceb633", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/inputs.tar.gz": "117993c2b024bbdea3c279e864cc3b67cad75c152ba88b2f66c1c38fa9e93cd1", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/report.json": "e1969d6a68ec31ca3343cfdf4e4ad7fec52be56d859c80ad0c2f5af013ebf3ed", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/result-transfer-verification.json": "3d417bb8cc88776feab903ff5c07e1ad4cabf94ba4852e67ac0042ea1e3d856c", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/result-transfer.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/results.json": "e18c7a997fb3f09073dce64720ae515d99d9ea74c4796a2739c00a7ae88b8403", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/results.tar.gz": "d30ee99bf21de188a2e7b81f3ab1b6acc0d0577a7afc711939e23c46aa2d380c", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/summarize.py": "a60ab2aba04cde3e505b0bfbc9922015d88abab9824923c19691023f05f0efa7", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/transfer-result.json": "cf32384196d7d02f4012203a93de7ae7ff77c9bf9773c805b3d4d34e0ebd78c3", + "tests/results/pdfium-intent-context/ubuntu-package/clean-vm/transfer.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/color-console.log": "a0d1eb88c1c60b6cfbcfe2df393de5b98818d54d98d2d18de5e340b21ab0a7f8", + "tests/results/pdfium-intent-context/ubuntu-package/export-guest.py": "b21447463fe9155f30f993d8693ba915f0fc2fbe6fe007c1ef210d3c1260f737", + "tests/results/pdfium-intent-context/ubuntu-package/export.log": "22483e4a5f1c07569a99459393f1aa33fa76891617e416e1d795fffabc68cf0d", + "tests/results/pdfium-intent-context/ubuntu-package/fixture-mode-fix.json": "c5c060648052323ec9d8be75a4e5f4d45de5396227b49fed1bf807e99c8b1465", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build/build.log": "e9c084a2917886d03dd34b2f1169c3f1f4250c7132dbd4dac00dd945bac61bf4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build/candidate-integration-tests.log": "b59a4f716a02c7107fef51be4d89e818db8055d4761cb973bf3c30f80cb4123b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build/configure.log": "0f2f468fa3e986e4f7056f910eb432e7e44df3e66e1676575d1500e01a04a3bc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build/report.json": "4359b3b1d4e80be3ec5df7a8addeca63c5d1bdbbacc8bee26da4849cf3cc4db3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/LastTest.log": "eabbfa34195539aa466b8435bffb305c032b0b729ae11894261994eb0e5722ab", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/build.log": "d7ef7b54b982abe3b02f98517478c3fb1f2aedc735d8094d039996a727bc3eeb", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/candidate-integration-tests.log": "b74a38a9bb07153ffc539e398927d8b3123139d9ec0ea0a823c2073d28daa413", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/configure.log": "67236dd20f41ef101207cb556cc72814435285aa83dfc5bb92ef245fa18c838a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/ctest.log": "fd07397db23b59a0ddc2c256eef863c9ee377cbc57f4a84562c8958466980808", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/install.log": "edd7f55b6aa0d6f01d90427352248b8c7edf89ab0e93a8a2c36cab7cdfbd71d4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/report.json": "5674a511b48eac19c4411baf75022a00e8bb3c21acf31ee9b0b8fa347d1fb648", + "tests/results/pdfium-intent-context/ubuntu-package/guest/build-fixed/tests.xml": "8b17e1b469e2b627bbb247e43481be2fb459b770415ac8280de1073b8d27f517", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/overview.png": "a652e2cab2568d476b8c6c4b0c3b17114b2d67c5985c73b8d75c607dba383e90", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/qpdf-check.log": "8a7cfb986fdc9c7f1a9503e395bf01eba97de9906055f80a8656ff7af8da0f8c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/report.json": "eb86c3befaa1e1385df99582d8819c3d56f81b0a13eee9c624efa2427ac8537c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5/comparison.png": "ffc720a5f902d601330cffc4db11302c9aae1a7ebd300940c114abef9f329e4a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5/difference.png": "102f1102260926b0004a44e8aeb90ea46427b19bbe210f8c0f4fbc503113f248", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5/pdfium/metadata.json": "b91ea871f6d2ffe17ea21c7e70836c1961e8877eccaa7828b4d72c532cfea0cc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5/pdfium/page-1.png": "16900605fd4b016a0d304ee8349d723134f65715c85da1ef8ded795baaebacce", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5/poppler.png": "481a41394a6ecaf16fff600db9af52bab896c05407de445438b0ebcd077c3335", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0/comparison.png": "1b982111c33951d862f586db73153a9abc0210875d581450f8cf9b0d06d1546c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0/difference.png": "d63bbc4c5e78685c5017776f1984973f41d29f1f3a3f38b980a2d92147e36624", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0/pdfium/metadata.json": "b6aacf5fa0aff7b1a5eec9bb2d6446b8afd8572013a9ae148a67862a61b50d16", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0/pdfium/page-1.png": "4acce048167b19cda75d59dd5d0dc4c9ed0e94c15dbf1aa2841391de2d2945ae", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0/poppler.png": "48048bb581e2013a1df13a66c26ba2e164191ec0c7e4ac50d7c86503c0e3e5f0", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0/comparison.png": "203d85946d750a6979114a339da8a6391d0070bdc4524673ce9d90d2b74e45b9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0/difference.png": "7d5871dd9f1abc45d930b566d92e4011288d57423c3ddfacf4b956ca589924e8", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0/pdfium/metadata.json": "40a582667fb8739f1e270be94d58791b71c316617b4295e154277a24b8df0a80", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0/pdfium/page-1.png": "db1f61196ba73adfcfa2544840ef797137fad45b4d1af1f08708be3a4bd2ba81", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0/poppler.png": "ce76ff2f960c5c3becfa33ed58a0a675097b8f2a8db3c6f9880e8836c8de1c23", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/cmyk.log": "2e2f249f075131825a21eb4b5e6656dcac738b2d020681a23b389701ec54f490", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/qpdf-check.log": "fee68aecf902681c860194e537b66cc9c67bda771cc0536a0e34d83a47d652d9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/report.json": "3fb8326bd112e73f91482c92be751f0c669f448ebd0c6b9488d77cde8e50d4f9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-1.png": "01cc49cc6c7843bc0860c2baec6f5a1dc3820541b6ba320b8aa238065849dde9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-2.png": "6965dae81cf3d9877102bdcd71dee3e101c2e9496fc62c9acfde19ef670bf827", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-3.png": "d2b89a34694c3dbdf486f5d12c7d858dc3b50481a5212b7e0254a06e9cb73f9c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-4.png": "4d40d331ab2549479dfa3e94d5db5f20de8f668e2ef58f3b52de6465b5f57b76", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-5.png": "a7e3f204e3f81fb655e06635489f6f1a75d940d14f21b96312bbbca9966e5337", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-6.png": "a968ee5d8e20357ac47ef457245e86e37f85321a4d6f945e209b0513e5370bcb", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-7.png": "7f99c5a0d24b522d022b7ee4cdc82912290ed2704250f22484a9cc9e2946a984", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/comparison-8.png": "df4307a41ca0dd1da3f8a41dd2d4893211fe2e0ed8c2536bf84621eb1a71c0ce", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-1.png": "1626a81c326e8abf9b8e222b75e247b02c095dac46ee2bb3db297c4512f7e79e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-2.png": "120a8a4517c7fc6a833ee17669718208a50f3a8ff813bf49e7a2b796f6ef6429", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-3.png": "5b883cb407f651fed3b0c73e6a02938e7beb8357f462d0b31ade461e0efbee4c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-4.png": "6fd3bd855171d801fade354a5967913186d064e15aa32207f70e4179045b9f5d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-5.png": "9e64b036f6371fbc848d83a5aeb0ea12d029d0d1e498a97e224840d20eeb214c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-6.png": "6f96b9c7175a154b6152a212b2ed52d954cc6e16fd4f88e2800e50fc6857cc3a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-7.png": "96bf4e632063eaf37ea8c719a80b79eb9227b07c68d62276a493bc302e67e52d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/difference-8.png": "8ec22909c200a0f0f173e96ba7e16d66850ac059b2aac1519e1683f9493f519d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/metadata.json": "4a01f6e7bfc16aabf3cc096fee4e8d2bcc4959c1667425f7d70ae492a31c98cc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-1.png": "141a3b02404af17231bc658401cbdab52198f5bfec8f7f635625eff59def1722", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-2.png": "bdc774a7d521888534403fad2ef7d44638a5debd53a083d3442c9ec91dfcf20d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-3.png": "9a8a0cb6e51e7a680f0c969cd456434f3641f2a60a6a21e812410ce8f50fd842", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-4.png": "82a45cfe9ce2a9c89ce848d9215666737696b146e5200ede2387c395245e594e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-5.png": "9f3b06274de35c9b5ce2c38c75eaffbcbe3d88c0d07bb05059548cd30aaa3a5a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-6.png": "e8cef29741aa6f54dd73adc4698880c578dcdf58a7d919b1ca7a26df7a793089", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-7.png": "282a8a482072aae16b26a53ffcc15a0d817336454b9beb1e435d57e4587b9c31", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/pdfium/page-8.png": "5938bc52e3992940b5c9a70a9066b3076952acddbb5fa43e39673dc6c2f33921", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-1.png": "f627abc2e44e34c9dd66eccc2cdecf16932a80c5bc2caa7e1004856bb1b1d0ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-2.png": "fcfbb9596560ef3991262635515f48ae75e90c157cbed44351976c7fb0e6aa99", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-3.png": "47c391bf6e3d4ae777a8d498690456931b0f8955eddfaeb0ff4ae250bb713a35", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-4.png": "57fcaab52ce323a9946879b9d99cd18d79cf8cd1e82414c0ea93c3a2a0a03097", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-5.png": "51f4eb2181e3bbaaa3d4cbcbdf5bdb4ef009433675ed3bb2d11165c42508e9d3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-6.png": "89c5d9e256897619dddb2f5aaad4362d08ac2da95ae6a4ad6282700c3a741d1b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-7.png": "761a01df1ddbb9356820431f1dbd579cac89d4a020ebf2bab2add4292fc221ea", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5/poppler-8.png": "b1e772270d148ec5d779b67ccd5ec709c984048b6dd8bc3cbc1f5e57dd3efefd", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-1.png": "259bc8c179d5b8d9695e3df2e990ba72d89ee5f1a99f74e339881e48f1d58ee7", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-2.png": "374b9a9b58efc2b8d2966b341dafc5c7e577a71086f4feede0fbbb7cc2355aed", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-3.png": "16acaae93e11e4bd39e852f3711a288fc5aac57f140beae142e6b5da5828a876", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-4.png": "577b6d3ad4ad1543a02f85e39d0162feb902cdafc7a26b60f7c1b267f983c080", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-5.png": "89aa2a1264da06debe1aac3127dcecd1b46db1fb287df816295d92c2757d0c1d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-6.png": "45e39fb7b740c74421b52e77f836889144879c03f883a49d3a68ab8e5c2f1cf3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-7.png": "2eab164050b7170d548bbd07df32e3998a3dcf6b0d315a4a23e4ade248515ca7", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/comparison-8.png": "3231fb07c07620e9801c8632214882ebcd3c546dc9bb0d18a78218c34bc434f1", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-1.png": "53dd1d79afd3eb3f549dce518ed34c5cdc91f69720900a75c4ec8e8ac756be78", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-2.png": "81f8f013f90a308aa561dd10b06d6603dd86ca22260fa4410524f89eb598542e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-3.png": "466ab1c3f23fe97d02fdb9a9447136fae41a649bad0e6959156f615e5cb3d66a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-4.png": "cb202f94f5f40e46a63248a05cd910551519ff36674ea5b29266a5ce27706b57", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-5.png": "fcaac2b39c6968388c07575f55f9f7614430241e21b59755c37c2f4a55e17b33", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-6.png": "c23a654b39b15208858e20447a31bf47b2c471816f8e074198ace19629c23a67", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-7.png": "5e5a5eab0d9f8de71e0383d48cdfa18bc0e58298043e2e0a76a69c315ba22941", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/difference-8.png": "d25cec5a338ef4fa85788f57a36545cbe6b7b91d0d92887c5cfd437804af8436", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/metadata.json": "3160df3e3a31e8c7e278ddece9e7b15d92f078d36c4e5f7a7b892d6c00d354a4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-1.png": "1d7cf550282dcd792f09da3a22326e878e1d7067a8e390daf037fff2b924d046", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-2.png": "baaf4a717d8395671354a8dab36b2795dd7849da895b39a747f8c1118db905a7", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-3.png": "b295c01c016f46efabe3293354f764b390f951b281229fe0cb910b663be8196a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-4.png": "c654c9297a2c4b68c14de2ff6598ff3779a290b495a01ad146be0a4208a99e2c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-5.png": "72a097e9c5e28ea4383b473e1bcc0b43ebb8c29ff2e23ac95b723757d5c19d13", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-6.png": "0f883a73df9639e50fc0e987144ca6b7fd93f7c9437db6d4e4d5a07091c3cdf9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-7.png": "1ea6b00bfaf57b4b549bf7e162814faf2ebf226f4371cf7b6a7671c980fe0498", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/pdfium/page-8.png": "d5e1fd1c03e44b5724431f063a6a71c36002b8813cc7daeec55d7f2959d745db", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-1.png": "b773b85d6c3eed94c27b449d48a91764b7e4c3e991df4ed28c8994eb8f168055", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-2.png": "29e15538ac0b4b6404e5df118aa47fa07fcb8d8d787c78a694bada9b49cb7c01", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-3.png": "9b35e979fd1701ee66399811a34c08a258c8fa61ccb54cd939892e77ca329b3e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-4.png": "adf6301ab13030f77137026bfca68a8bafbdd08029f3c0b7d5421cd3f0f33d1d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-5.png": "21c969593397a2eaf9a6af0c2236ffb563ed6df37736a1d46f5e90dd77d3838c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-6.png": "0837b613d2e27505decef0f88a96c672137ea349eaa224e8ea804b8f997d4c3c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-7.png": "717361466b4f2290a4bd1edd87212e07373d73bd888864cd1e8a612616f8c3f8", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0/poppler-8.png": "ae8b27e5e710e1fa89610a83739d0bcc87e7700dbb902588d84799533e5b76fd", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-1.png": "a011e323775114e3de18ef603421bd10ab5d9b824b400e12f5e072acf8ea982c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-2.png": "b7d0428c50cfabd9e0402f015e8ac87c14889285facad49f11ca2ee0867a5e6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-3.png": "28afe620764d8f21e4951318caea409c6f60c95736aeb36989e4cb33cac46b26", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-4.png": "207d61686800ab8ebb4593adc49ddbcae6a0fe5ec2b6e7f45d84217daefdbb8f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-5.png": "b93721353e4ab53947605b8c93de0f4674f84df918969d3d5e1a1db97e311406", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-6.png": "9b2266a0842ce2f9a04465343e131816400a8a785d9d9797c733fbb24955a380", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-7.png": "4fe05b1ef8cd42492501e124aef8c88db20a8928851b973bd23c5a03d6d67028", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/comparison-8.png": "01ec49cb1b776f4f22495123dfe2540b5db193c30d6d5e90d060abdf706332cf", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-1.png": "bf851eed341fddc0673e3269c9672876ced4bc9d040fc8e7af5a33828e31e5c7", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-2.png": "355a8f8e5810a0d5c66b7c68f0e95dae29304e115a67a6dd87fe9c0c63977715", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-3.png": "c1b9c34d2483889d953014a1afaba9a40545efb89b9adde3b5a4b1cf27917219", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-4.png": "6793f25858559eb4d971540a6478a76b61ee3f7941c5a07567a665a4e9418f9e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-5.png": "0c44f31bdf1f4a596e5e9b7b20cc2c41d8e7e2ccfbb93567c0d0a2467a87a81c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-6.png": "12522b1a5c6321cd48a05a41eb53f22728c2249b9db7cd5fe924817da545a02f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-7.png": "35ccc5b67fdb40db47a5d24b3457ab89af205ccc3271172232b6d3f718ddf884", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/difference-8.png": "24e5265dc0d25b11c75a5418ea15393e4ad0ee308931d2be409bdef6e7d23ed6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/metadata.json": "0c6a51d3871cf43b8bc991fbb6c0c8f6f1421cf6e32c0b881855aade833fbe79", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-1.png": "a0176cbd2724fb3b88eba86c610b9546f45202096bb9d21a613080094ae6767f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-2.png": "140de5e2e7b19ed00d83cb3ee6f46c2fae5b473479ceacb2564a20f4b59883cd", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-3.png": "400a80b294183cf1751d85f228cd7fbffb7c9f2f30d5e79bdac156240f718423", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-4.png": "d84516e60d7dcc9c91dfeef48090fcc333d39941d082a8753adf3a99f45e1484", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-5.png": "76d65a12cfe1da220902e4bf367ebc130e5c2312bde0e3b5ff9286c5d5cb49b9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-6.png": "31baec17d0a17f11bab60ce6f36155ee81ffe5213924ba22d05404c62e477c38", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-7.png": "f0a0ebfb0fe078d40cda90abce98b4b5b8f3705f3aa324d8dcf34ecd2da88505", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/pdfium/page-8.png": "083ae6e51ddfa91eb4edeb1f9cffb2ac742e8d06ecb7c77f92709229b7067d19", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-1.png": "394eb80073e0ca9b920143d0bd4c8ef9658d6ca44e17386197af48bb779f9de2", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-2.png": "cc82274d50aebf4e6a11cf30b7451e808a226bf74e7923714ff0577fba974253", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-3.png": "3fc6d8706f2b5a88cf32e7fa17f6a3cda12ab5edc7fb358bdce84dae3a4c346f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-4.png": "bdd0bd87d33953662d75b58af0cf080f89ec06075c4b7f5e7026523178b3ce53", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-5.png": "9bca1d3a07fc9c14363961ebe7451a5bd58e8ca9652d2f939b2b28895d7e11f0", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-6.png": "3187591772e0fefdb3b7eade015c3438da88d6d621227b7de3ea15e097fef8d4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-7.png": "6fec3022f68d6fa8d20864fc55d5135bac3fb3cbefc33e9e1e9fb497657fdba0", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0/poppler-8.png": "c6496f8be3c7fbabab17261b71fa006ad130936cfc8357f40a33c42d3409defa", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/intents.log": "51f300b09e3b1e541a66d9219cd20169490cead22eccc020f5dbd2f0cdf51ec5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/report.json": "d216d22bae652e39ddfdea68773f79f874eb1dbabea069b864c5f6d0821352d4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/qpdf-check.log": "1e4b75a8b7b0bb0271ed9aed2e741c736ff2b5401b9d0aac10ded519009be041", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/report.json": "2f4af74bd74ae44ac6cf80123a975bc0bb2e0a5407fa6c4c9301037b7fd4b9c3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/comparison-1.png": "f399bdb9635276e71caffc5b1d88293ad42ab204e93b8301d98a7b8866ec1eed", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/comparison-2.png": "056f6f9db9a0df5ccb0c54ff0cc8935c7e950acf86ca4144f1497476856bc298", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/comparison-3.png": "d289696172997e515f36bc1397b6221b61b38380cc5efa7a88d609a6161b844b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/comparison-4.png": "4fcdb872b9030a9184e478fd9208d157a9db800784e8e4fdccf8284066b7ea95", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/difference-1.png": "7a0bf04b191f045b8b49810e077d12fa4a74b9d1989cd9db9f269c8c0fbefa89", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/difference-2.png": "4928d1b9c645e295126374305a7382d694a729b710631e513685cea27d483d83", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/difference-3.png": "0585b9e08380cce8360c07e487eec6dd7ac23de793744824f6d424f37679ae5f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/difference-4.png": "587d5976ec573e154475f303847650026e6460bd874e3d14b17dce9b3a3bffc3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/pdfium/metadata.json": "1a0c4b653def09d1916981023ff238728a3d8471f7a2a399e70aafa21f628afa", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/pdfium/page-1.png": "347a0c598280368aa5446b1738325b6b9794ededb784febed5f33f7a02c44528", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/pdfium/page-2.png": "9f96a8dd81e93f9588676da34d8af53935cc6d0176795d0b77e8d8c5cb7e9163", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/pdfium/page-3.png": "b123bffe6d7b0f1b33a22ec2ef6c6c0ebb22dceaba8a1a797edd0aa98b5b03d2", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/pdfium/page-4.png": "43b36e9d7920e4852bde94b31e0f063b411299edef9908333459e23eeb850214", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/poppler-1.png": "294f81c0c94a3dda00944bc95bf91245dfde4725554909224c1bd83010faed8d", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/poppler-2.png": "fc5f82dd64d4289195ccbabd657c29aa37cac7a8cf4a0a8b7bb6ec058b375003", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/poppler-3.png": "51f8291e91b804d9c983af640419cd8f0a38c283e37b67c77640c9b9957bd4d8", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5/poppler-4.png": "7962e3d8118ceda851dc7976e0229e90de8a08de4ef13156f08d4c9ee9066acc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/comparison-1.png": "795444833a13f6b0ba3676fcf7907a47fd2db20d41c567d89d45453864975529", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/comparison-2.png": "54ef6595d9d009347af126c37f5d0a8b31e794be2d4b63f416b9c6553108c29e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/comparison-3.png": "b68ec0c7037fb2d535bb850b99ab58b4de899a09589b029d0cc88f477dd8ee50", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/comparison-4.png": "201c8e9a9f352e1062a34a3d64c666a7f6d1b66aec24887214e36f07b77ab2bb", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/difference-1.png": "d92dc75f37acd055cc7a29245e6280e21534b700c933c19225adb4aa35c1c3ee", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/difference-2.png": "880efd576e6762b4a3ab3e6f10047640c4a646c2068fb7d9a9c18a94df9ec789", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/difference-3.png": "3dda62e33255ebaaadc3055b08f0cb1324fd7093ffe773671e7595d88441d743", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/difference-4.png": "abe608b09147f4a71f94429a00ff22f28523f1848482cc929a968a8df02f2eaf", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/pdfium/metadata.json": "a304c514cd928afe6247172c2c03f882bd198e41c920c8f3dbf36a8f1d197cb6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/pdfium/page-1.png": "a4a6fc48cc4d63ed77dba9456b28cbf7f5de7854757ad9fb71ec2cc91923c5f8", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/pdfium/page-2.png": "1da9ade78a51412ba6e6722ed681505aeea5b93382bbaebd5c30a8bd46d5a813", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/pdfium/page-3.png": "346965080ddba6e5a7056f303b3451fda4cc967504d0400112ce378c9f43da5f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/pdfium/page-4.png": "fac147a50f5f3e1f87224e598989a0c271df7242102e8dcbe2b0f8347c2838d3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/poppler-1.png": "4794be49fdfdd2c5631bd3c3d47284cbc896c13b500408afc699f2adfe240d89", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/poppler-2.png": "7e21f26212b061133a653b69737ef4c8c20b86b27bdd8142115c7d481cb9ae30", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/poppler-3.png": "38f17856f6ef77b6db612053840a8809faa58c4dcff00031a9223108e935ef47", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0/poppler-4.png": "754a5b6623a3bb2f15a6102f8192c8311f81d82435dae65c098e6517eabe1ebb", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/comparison-1.png": "441134fb34bf5b0c0cf06c9e507bd966d7b90a100a1d40cb85007ac378b9345a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/comparison-2.png": "c97b0886a084397bd167881c19187a6facea0a8b966740b7ec02335d5b09b525", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/comparison-3.png": "0f9f9d56c142bd6ba0269e8b420b62537d447bddc867d34d92d296c2179465a3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/comparison-4.png": "261b91a5969950914054d71d402427bf58e4ed7722c7bc5b893adab868c50925", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/difference-1.png": "00c1de36d6d1b82af6cd454a0ad7716ff54e92d4318d6751e296237c1d5c54a4", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/difference-2.png": "54fb52e313bd01b04d6caf5bcf744dc77b08e21e7a64aa69cb937b73d1672f52", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/difference-3.png": "a8c1f5054295aa9291e125f4f702f6112136bbff92c6e57b7b9275acb5762f7b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/difference-4.png": "ce79426a4ef2394c7b63c1e28ce01800f41ab8f2af812fc7d1fce1207159f714", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/pdfium/metadata.json": "8fb1c5e0f87ff005c2dc2613764e2ac8e3710d7df2ef9db06cfffe1a6f1cd1d7", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/pdfium/page-1.png": "f07cd43e7b27fb4a199b8a4fcbb951194be8831eb530e9fc45deb0f3ce21a6dc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/pdfium/page-2.png": "219f31104a358a089a21d1149577dfc4262fcd966d1c522f8cafcec08d889240", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/pdfium/page-3.png": "8966bf2f2f1c906dfa58121fc4891534002022f70d9998671503e324970fd937", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/pdfium/page-4.png": "c1263c0586c6e0e23c1d21f4d18cd5a454ec2631c509b1d10cd56a320393ca19", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/poppler-1.png": "ea2861fa0981f3c5313004688e725169efe5ef5c93fe41574a62d0ee5e85aa18", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/poppler-2.png": "b8dd8b15a31a79db17defec13c983526110e4e73290d7b3398b84e31003f7592", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/poppler-3.png": "b1a49a60a5fea95339cb52fe3f16c65e38e997afab1ca574772532f4a27fb63b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0/poppler-4.png": "8c390102bfe367d2fa3260db1777d3364b1f352b39bc2b9b74909edbf277e771", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency/scale-2_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/colors/transparency.log": "25f1a67b437b4ebda2fd18916fa04e17b7464e3539e460e8f0d9f86b0de25dc5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/final-inputs.json": "52b06c4714893bb685b9ca4f1d18ad274791d81204e167503de1047eea7ad354", + "tests/results/pdfium-intent-context/ubuntu-package/guest/fixture-mode-fix/record.json": "a0b915c2ba0bb66776177ff233e8cfc112e21961c87aaef2f4ab9f01477661c6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/fixture-mode-fix/tests/test_pdfium_candidate_integration.py": "892480fc7ae48654dfacb5a9bae1b8de36a13abddc6cfe0fdf01b8359ff9f24e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/fixture-mode-fix/tools/verify_ubuntu_package.py": "7c7615f284bf93ad99a5b4303d92b079211d8e29960c8d1da560a494fe7e858a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package/docview_0.1.0~validation4_amd64.deb": "bd4f00912078d406cb466cd6910ed01c0c9fdd32b76cb2f47b74cfb9719f1bd9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package/report.json": "ae35fb9452c8bc01a1c616554112ff559dd7ae3dec89d7d2a527af6a32146dac", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package/runtime/runtime.json": "df531c245cbdb3ae5c4420840e64d27d15ec9f4cd47d9e3559e4e56dfb341d50", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package/verification.json": "fa3fb76b39c47e65b4f548965a9fe6cb18808c96055f0ae7e14d250d3bfe2cc0", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package-verify.log": "138331f9c190d1d99e7569e341f4494cd21b5652e5b0221c4385ced329bfcf3c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/package.log": "dec5f204123f6e53b0ac9daec2a4c5733f79f527c6f7c283940de23803a9aaf3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/repeat/report.json": "ae35fb9452c8bc01a1c616554112ff559dd7ae3dec89d7d2a527af6a32146dac", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/repeat/runtime/runtime.json": "df531c245cbdb3ae5c4420840e64d27d15ec9f4cd47d9e3559e4e56dfb341d50", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/repeat/verification.json": "fa3fb76b39c47e65b4f548965a9fe6cb18808c96055f0ae7e14d250d3bfe2cc0", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/repeat-verify.log": "138331f9c190d1d99e7569e341f4494cd21b5652e5b0221c4385ced329bfcf3c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/repeat.log": "441fab013481e0433f8a315f536f6308406a3cd4adda0de1e524a1e20201c4ef", + "tests/results/pdfium-intent-context/ubuntu-package/guest/package/report.json": "9f47b09d2c6513455fbeff4946b62699382e85a6b80da1d588ab67bbeb1f658f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/apt-install.log": "d6952883d14909e03550a591f92d80e799218f71622543d368cb54458f52389b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/report.json": "12546e30d5033870bfbe717ebd7e833421952599cc37e01fdd8b7fa1b7449bbe", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/compositor.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/report.json": "2985710b82761886a727ed019f8365b761234eef4113db587b9437de615d29de", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/runner-at-start.py": "0c00403ff21d869fec32f46d97ec1e248cbe88a88a712ff1762b4b614e62aed1", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/epub-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/epub-dark-100/screen.png": "79d077bbf900d9e8ea134424f2315cc36695a1e909d04fd97212bbfeb4d5871c", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/epub-dark-100/state.txt": "e038ad47bc9fcd25b4597c5c110163298425d0fd43d2d72048cc39c583abdc86", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/html-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/html-dark-100/screen.png": "45dee68ee81c8c1a3b39bc8c4b5626e611b767eb3cedf12a13065738ac7ca4c6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/html-dark-100/state.txt": "310ecb41e45485308ae7c2ec0482572ca8c5892e99768d36a0b941edb858305f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-dark-100/screen.png": "8f4cfc4938a795bf813d6ad1007cc35f93eb7a47bcde04bccacb2bad71b506cc", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-dark-100/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-fonts-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-fonts-dark-100/screen.png": "94a9228185a330544f9d1990458d2dba2c09aff10fba7c4d902b36f8a41300f8", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-fonts-dark-100/state.txt": "8833b8d0895dd929fd4db9969e6ba309f18ad0e3254134e2b8f760e32eba669e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-light-200/runtime.json": "374adb23237d8cd11344d6204be3d9c44c5b59dea904d530bc13bb20328dad6e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-light-200/screen.png": "5bed716b7f2e7324260b4aa6d41c7ed07f9ef77c38a68a5dba04a2116b0fbfa2", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/pdf-light-200/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/results.json": "1277d5cec34d535a1a9ed9db35c0dadad70bc9fd623da86377b525a58fc4c65b", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/zip-dark-100/runtime.json": "ab43d528dd11ed4b3ed93651098b1aaa42b8d8161776e07dcb78e6809c61a4ec", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/zip-dark-100/screen.png": "5e762c21cb8f1b3cd14dab8d33518b3d3006cb267751b70511a011f518aad69e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke/zip-dark-100/state.txt": "4ebf72a5a921265b68b87670836ba707ca5d41ab8d3218df362e943a2380cca9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/smoke.log": "c39af78278c1f52eab8c050078b38e958a472b2fe2082600a1f624527a652fb6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland/sway.conf": "c46c671eecfc1f4a467453a63d08ba565761ac6d1b479aec7a4994b3321cfe2a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/wayland.log": "e8769f30cba90861a565c482dfda3654653d509eed7ba6649f5c45c3466da829", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/epub-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/epub-dark-100/screen.png": "1d22fdee041b28e7f9bc4cacfa9322316e4e97e6089eb9ed396f999cccf583c6", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/epub-dark-100/state.txt": "e038ad47bc9fcd25b4597c5c110163298425d0fd43d2d72048cc39c583abdc86", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/html-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/html-dark-100/screen.png": "ee7b745bbd79275a698242aec0b935c62def1665d18fa158f01a490c04ef315f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/html-dark-100/state.txt": "310ecb41e45485308ae7c2ec0482572ca8c5892e99768d36a0b941edb858305f", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-dark-100/screen.png": "634f6e2e40e8a483a4933c50667081f781a7f29ba27d6b59622f13ac75b98346", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-dark-100/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-fonts-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-fonts-dark-100/screen.png": "f459958afc8aca8eff1b4bbe8ef30e23e3c62dd548350c804d3bd5933b3c4d6e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-fonts-dark-100/state.txt": "8833b8d0895dd929fd4db9969e6ba309f18ad0e3254134e2b8f760e32eba669e", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-light-200/runtime.json": "67005e1317b1eca5de3cd48e37d7d1886368164d02934a826b8e0a14552ccdcb", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-light-200/screen.png": "ae32e17154640947da671a33ad001573e9fbb05d96bbce18093316879663fa41", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/pdf-light-200/state.txt": "9ca4dedfc3b20e185914bf033b0c895b6189fc75e03509c0b250091168a574d5", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/results.json": "178b9aab380e8d9544637ab5a2514dc16e257fd17c69b03eb24b21a67db569ef", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/zip-dark-100/runtime.json": "73b0cdfe4ea8cc5bd3ab0d3d876f02813ecd2cc93e7d5bb20684e5021be8cd6a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/zip-dark-100/screen.png": "1db80d00c73431d462899923de45280b29c204763715718af47adb1f6ce9e149", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11/zip-dark-100/state.txt": "4ebf72a5a921265b68b87670836ba707ca5d41ab8d3218df362e943a2380cca9", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke/x11.log": "0357a75a3af5b325230f149b4c076e766f840316430d7ca28105cf75f35fc2ac", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/installed-smoke.log": "a24b07793dae4e2cbf12fc9839e2ad130d488191373a8962f77799157a4c0698", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/apt-purge.log": "1f2cb350800214ce360edc51272abda747160ebdfae6ff47cf699add460946f3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/apt-remove.log": "c7b1a715a5286645281e7f8ed37c654142b35e0b5141878facd5492d09a5dc36", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/package-installed.log": "be3f252e0f35ba8bfab398c1847fae84e04d3a605d627bc8e28451376629e0c3", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/profiles-installed.log": "799cfb2c0c00ad8d817025889c38d603c3c6119f209b96ee0c91d5f33db3725a", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/profiles-removed.log": "faf6fd8c93b9f2f601cd661f715751f2a6c5b468732e6d3427808cab07635757", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/report.json": "3765ce392c4ffcb587b15bd51fe6aff038c95e228821ca0637fae4468583edfa", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/userns-after.log": "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle/userns-before.log": "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/lifecycle.log": "cc95e060c018ac29a3897ce2f111f48dceb812a5ea36918994ff82b7efbb15ff", + "tests/results/pdfium-intent-context/ubuntu-package/guest/smoke/report.json": "c8dda26566fa73b62898714d1110af97401e0a4b6c41b375cfd32e0916189a3b", + "tests/results/pdfium-intent-context/ubuntu-package/prefix-input.json": "ca07f19f7c5e01031c407c69811f1e12e7708a63df513ac8235decc2a79380b6", + "tests/results/pdfium-intent-context/ubuntu-package/report.json": "75f39a4acbaca51b0205d8d5fb97bfcc8355bf9538685d2cd6c6d0745fc0057c", + "tests/results/pdfium-intent-context/ubuntu-package/result-transfer-verification.json": "5eacc9d48e628d9f3154d2d83f389ddf9bad2bf5c96be271e70e41ba2b982900", + "tests/results/pdfium-intent-context/ubuntu-package/results.json": "935aca317ece9e8e8776f76b493510722e6cc9bf6306db46752cf744babe97a5", + "tests/results/pdfium-intent-context/ubuntu-package/results.tar.gz": "7e9d12e4617ebbedae52fcd3ca9efb3fadaa2ac87232624323d2d5e808846b47", + "tests/results/pdfium-intent-context/ubuntu-package/retained-input-correspondence.json": "ce44cb8f8a40a548e20b0daed891555b32dac04e0f57515168ded319e3cbe944", + "tests/results/pdfium-intent-context/ubuntu-package/run-color-probes.py": "b817d990409a42b4fb824bc9073058ae2a5fa04a96023d6c141195b1ed23f6aa", + "tests/results/pdfium-intent-context/ubuntu-package/source-input.json": "cddb0808bae7fd05a36cf7d9e5faf9c87cfa8faf930dbbd18a494464453cabb4", + "tests/results/pdfium-intent-context/ubuntu-package/summarize.py": "fa6caedb515e5ca1bb312e50ce5a0290c9a61591c9c5dd7353101c0c03ffcad6", + "tests/results/pdfium-intent-context/ubuntu-package/transfer-result.json": "6380a89c796c038b53d3dd9b94abbb4e4a6ee0bf4a1cde42bad4c98fae38c9a7", + "tests/results/pdfium-intent-context/ubuntu-package/transfer.log": "6af1700f47baa72d36de2647e2974a46fda93ac261c5a364445c50100ad38dd1", + "tests/results/pdfium-intent-context/ubuntu-transfer.json": "7b0127f45567d5d56dc2517dbfd6446ede453b149c07a2a6060cf34ab73fd163", + "tests/results/pdfium-intent-context/unit-focused-execution.json": "b3c0f097086e162b9a653f97e360c01c65a08a3e549940a730eb437e8ae1aa08", + "tests/results/pdfium-intent-context/unit-focused.json": "36304a7a8602e97ed40dccf4ba80a9a413aea0aa2f935e2e49a76e47f9817948", + "tests/results/pdfium-intent-context/unit-focused.log": "1476e9c13e2025462f8209787015ce0382c2255c1e3949ece3c19033eab9894f", + "tests/results/pdfium-intent-context/upstream-tests-execution.json": "dabdff50a042f8ea2c729c5e6cdd03e1443f2c88210d0191b35daadd7547628d", + "tests/results/pdfium-intent-context/vm-shutdown.json": "3ae70cb58c8bb6730b4a4725afe22a6971c54b7230b71bc66e4c3d616734a8f5", + "tests/results/pdfium-intent-transparency/README.md": "f8ef2bbe56cdc3e5ad8209b05aed6504bd0e914d802c8a0d1222bc3356d2be91", + "tests/results/pdfium-intent-transparency/baseline/qpdf-check.log": "6078752ae1de338385fae8ba5ae37cf726dcf6a3cd1acfc087ee4d3edf12bddf", + "tests/results/pdfium-intent-transparency/baseline/report.json": "e2dd704db849d7c178dd67a68147e186c78cd303f5e70e318a3738cd94c2f56f", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/comparison-1.png": "b62b3c315dad924fdf3e6e0e1a1fb5628c526aed6e6be3f724d7846e7778aac9", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/comparison-2.png": "85e86e66c789b15620d314a7c9c9530460fff027bc5ecbafccc6e453656fb62d", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/comparison-3.png": "4a56a842465419264f20bc0654d75b32e6c5cfd643dac25e828fba9f76edba94", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/comparison-4.png": "51684110dca87f0b8cf69c3fba2559b512e589f60bf65aa6d8f44d8771d09500", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/difference-1.png": "72663a175df934eaacfdb4b3792a688e4328b56fb0fcacc99b89e9a44fdd4803", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/difference-2.png": "0f8a5e9e73dff2b54091eb0c01f52bb3a7368d3e0a85917713f294985ca44d34", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/difference-3.png": "3cfc6175a98a15e6f30df821dffeb835b4fa54230bdb023bf9e06f64d0da80b7", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/difference-4.png": "02e289480fc6d3f218a729328ad0bd789c44821e5603f3b5a9f6cf8f4db20ba0", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/pdfium/metadata.json": "1a0c4b653def09d1916981023ff238728a3d8471f7a2a399e70aafa21f628afa", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/pdfium/page-1.png": "4051d8db092250d033aad43e50bc6790b9f83915fd8e196d06bde58149753af1", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/pdfium/page-2.png": "63bc0734b7c520096cb95750e94213b0e499ab2867924f1a087fec4ca0013a2b", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/pdfium/page-3.png": "4dc80f2e776369ffd1eb2248870ab100350bd7a5f6a794c659cd85238f1561df", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/pdfium/page-4.png": "71eae1fbaf4e92b4768ae0857c170598bf3d29631d7b7fc959d93958ee96616c", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/poppler-1.png": "47a470383476bb151e5223e823e5fe71238fd56d20caa3c40ca0feb7af14c9e3", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/poppler-2.png": "6ac261598c00d3c8eb438997e2cab11caeedc6fc4c8370b01acca1940609dee4", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/poppler-3.png": "4f9d8a71e07696a084696bfa76e76a781d9cd0bd779e32fe74203f57ef944aeb", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5/poppler-4.png": "6853e437f022c47793bcc1a1b0977061fdb79222ef5f7296792dd8e56acbc895", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/baseline/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/comparison-1.png": "c2e12d0be8bc552292a92dd815ad03612e81a2e131d82db10975b31b22b6760a", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/comparison-2.png": "5ca4a751352de34482b47c786ec363a6c7c4c0e5a3e4e3115a58972d6e19f985", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/comparison-3.png": "43579b9edf1d20b7448031b78093342970d202aa33188f169a4fed049fc0b3a6", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/comparison-4.png": "c86a5111b1a16d7d0fff07befebce0c6d43531551edc3e67fc924edc077a7f6a", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/difference-1.png": "311d790e7dd82e4d5a2a8369c8a29fe45619e3fb5c80676a9a179924638da845", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/difference-2.png": "7fc9b2d10fddcf60435d47610e894071dec6f3846d6c534ebfe2466972dcf8bf", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/difference-3.png": "31bdebf5c50af09a65e99fc9cb55edb720f760c198b02ebe3282904c3ad09271", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/difference-4.png": "c2ee1c0db583a5698f14fbd81e0472306453f09d354baaf86cd06a0a9ecb407b", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/pdfium/metadata.json": "a304c514cd928afe6247172c2c03f882bd198e41c920c8f3dbf36a8f1d197cb6", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/pdfium/page-1.png": "385d9f9f9a3a0f60f3302109f2f1d93a5d99d4d35cdde326478887a325c63211", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/pdfium/page-2.png": "c019549872c6e7b24571a3688108813c22cdbc638e26ae535d1a51abfd2d9476", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/pdfium/page-3.png": "fc46edb8cd4fa01a8242635bc11382ba158536ff6eb8f6381485e6379c5e6a3c", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/pdfium/page-4.png": "b8cc7ff5f934242fc7d3fc48f4fb20dbe7271e3028971b2c5481b0d35e9b3d7f", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/poppler-1.png": "010675361c718971cb29fb232c8308d893248fbdac1534422caf27dc4ff949f8", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/poppler-2.png": "45f0c8fac3a80aefc3316963c13a92e8b4ecdb41219a13936a9c0b71693560aa", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/poppler-3.png": "bd3d4aa1b89b4a8fefaa376714ed76bb58c49bee6d995f30999018ff2dbf6758", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0/poppler-4.png": "8f973831eec795bc7af701a6aad3898a15163867590ded8cd264fd6775762f3b", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/baseline/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/comparison-1.png": "c0204c8e015cc09e11007ee47b67ec588ebc76a0d9eaf8e4e30ee51497ceb9b5", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/comparison-2.png": "4812a89a530c6b4df5117d82d5ea4c981903d1c55f8c126714229801f63c2ef5", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/comparison-3.png": "575c8e4897493b1ed7e6f91e72582c57a5026172e4adbb5770536225dbc710d8", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/comparison-4.png": "e1727b604e3687274890e4e42b680b624758eebc9a419f88f40018a6885a3f8e", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/difference-1.png": "5a95ff9a057c482a5e1c374f966f9b87a4dfdae23595c8c5682ef34e929adf65", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/difference-2.png": "bad9778e5b639bd7d5627a3d18f660a6f5b87e44cc9da19f426a9824c31244db", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/difference-3.png": "0e7b12ba7b2277f4aa0f94282dc9089022fb2590b190fd3d68bb1f7552baeefb", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/difference-4.png": "bb7793237d885cdd89997bd479308856975e619418b972fd61690f2342f61483", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/pdfium/metadata.json": "8fb1c5e0f87ff005c2dc2613764e2ac8e3710d7df2ef9db06cfffe1a6f1cd1d7", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/pdfium/page-1.png": "0f563ea2a9d31092652979cc242a0efb4114ae178d52515efe187761507fc29f", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/pdfium/page-2.png": "fb9311985aceb23682a0097cdebd26338ff51ed6e7016363931354718f9ae46a", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/pdfium/page-3.png": "96efbc0dfa7e8e6e2bb45d4ec7c1ce9d69bd281d7b17264513f7e8e509b9024e", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/pdfium/page-4.png": "edd8ad107d58534a4af43dbc3e985a6ccb9042d4eeb8fe785e0d01d84f5c8d37", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/poppler-1.png": "e98256bc67e2f2bb99e9f877c3b312c291bd13cf15b2f5b470c4b74234a13ac1", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/poppler-2.png": "352bd7a448d4d135aac399bdd3f6f2b351d3d0b3c3a7db0820582cdcd3b4e555", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/poppler-3.png": "f74a57c02f7f653190db27366e62bdec9195549730474c4f3a0ae8d1c2961718", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0/poppler-4.png": "212a24838f64384637237ce70eb88efb23deb65525e806e23820388c09c7896c", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/baseline/scale-2_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/gallery.html": "76f2769f47f42cbb5f5d255325c02412256de6a22a3cef852563d5132336cc3d", + "tests/results/pdfium-intent-transparency/master5/qpdf-check.log": "6078752ae1de338385fae8ba5ae37cf726dcf6a3cd1acfc087ee4d3edf12bddf", + "tests/results/pdfium-intent-transparency/master5/report.json": "349736077880c2c244c004a161a9bc7e6e27da86ee103e20540de09796deaf64", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/comparison-1.png": "82001d459e7df892b20face00fa3fad3e0bdeda8bda2b4b1f11cef5d517cbdb1", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/comparison-2.png": "97e2cfcee276cdc1d7a87977651f180b157f6eae6ab12774d6706031309b5b70", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/comparison-3.png": "97a306a07d4f3aad1d972e542ac7e7ce0d8be59a41835893f9ab607d94f69c34", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/comparison-4.png": "2ead476d45aed877fababc2b4a31b5aaae2bbffce4d3d667a7765deec0822591", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/difference-1.png": "1cda3f0a045526a0a73e68c743fe9c4174318442e4eacd934b45c4a0b8560561", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/difference-2.png": "c8d1a85c500b15d3a08bd3621890691f841cd5eab896ddd3382273d49af111a9", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/difference-3.png": "e0c36d0f955c63d59be1e5ae5098133af020f9f6f16b3a29640fd87b76087056", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/difference-4.png": "1989f420400cd036f085f1bdbea6c44e2aff1dcbf8de2aeece54c26e9ada4fd5", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/pdfium/metadata.json": "1a0c4b653def09d1916981023ff238728a3d8471f7a2a399e70aafa21f628afa", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/pdfium/page-1.png": "347a0c598280368aa5446b1738325b6b9794ededb784febed5f33f7a02c44528", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/pdfium/page-2.png": "860e7c0158675383a7dd37c28dd3f30e7df82a61c03d343c6b87a69f93812693", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/pdfium/page-3.png": "64ac5e6a6399248f39e8c18e0c9c32448c1f24117a96b25f05c5760ec0b7513b", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/pdfium/page-4.png": "6d3932dc5fb28b006f2caf1aaa2e2c2915208e5f5c2f66e5902968c6d1389988", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/poppler-1.png": "47a470383476bb151e5223e823e5fe71238fd56d20caa3c40ca0feb7af14c9e3", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/poppler-2.png": "6ac261598c00d3c8eb438997e2cab11caeedc6fc4c8370b01acca1940609dee4", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/poppler-3.png": "4f9d8a71e07696a084696bfa76e76a781d9cd0bd779e32fe74203f57ef944aeb", + "tests/results/pdfium-intent-transparency/master5/scale-0_5/poppler-4.png": "6853e437f022c47793bcc1a1b0977061fdb79222ef5f7296792dd8e56acbc895", + "tests/results/pdfium-intent-transparency/master5/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/master5/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/comparison-1.png": "2a76680690c174103ce6c1e0b6a6c7edaccacb43803df0acd033ac8b491ba837", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/comparison-2.png": "56275e0b655d432c9e85c240e5fdff6dd4912866a07b1aeecda062c984765c11", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/comparison-3.png": "7673ccc421f1b4398ddb04662936057396632ae264f308e10b85da0d301f0fff", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/comparison-4.png": "a936d4cff2addc7990fbd6fc7fda4c5f5534fb9fd78901ff57d378f938e9e740", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/difference-1.png": "0c33d131468eb0453ae4083fb163e727e8036121767e0e1616a82c560ccc46da", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/difference-2.png": "e3611a580abe7615150b66779c4e43661975e6b5485a19601de726c276708608", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/difference-3.png": "cd9b8219fccd97d03dd300c38b4565316b403f7f81b9d9a1978d4ab5453971af", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/difference-4.png": "4ffde0656b45a84e753b34f5178656bf1a0feb554e587444be6fed511ee84f8f", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/pdfium/metadata.json": "a304c514cd928afe6247172c2c03f882bd198e41c920c8f3dbf36a8f1d197cb6", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/pdfium/page-1.png": "a4a6fc48cc4d63ed77dba9456b28cbf7f5de7854757ad9fb71ec2cc91923c5f8", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/pdfium/page-2.png": "6a95ff40e44513a58349f91871754e11f7f14fc845efd7e2f7b4844d356add19", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/pdfium/page-3.png": "490503f7200ab3bdfebbfed2b7e982b7657e4aaa718aa07fcd74b8274d30e22a", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/pdfium/page-4.png": "5cd6cabe003492f1a5c88d1fbe44a6fa9ce344b5787078a897b7e5b3241ab7b4", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/poppler-1.png": "010675361c718971cb29fb232c8308d893248fbdac1534422caf27dc4ff949f8", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/poppler-2.png": "45f0c8fac3a80aefc3316963c13a92e8b4ecdb41219a13936a9c0b71693560aa", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/poppler-3.png": "bd3d4aa1b89b4a8fefaa376714ed76bb58c49bee6d995f30999018ff2dbf6758", + "tests/results/pdfium-intent-transparency/master5/scale-1_0/poppler-4.png": "8f973831eec795bc7af701a6aad3898a15163867590ded8cd264fd6775762f3b", + "tests/results/pdfium-intent-transparency/master5/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/master5/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/comparison-1.png": "14ccf5b39063def59879fa80ae0646454368bfe6c6c475bbfdbf943014774e8c", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/comparison-2.png": "0f8631716c16a8777cf12f1d096b8e66023a42ddd8659e046ce54deab5ef0d4e", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/comparison-3.png": "020d410fccb0b25d5e9bdf57b9d8b6725596a5800ef58a23776278b3564a5e06", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/comparison-4.png": "b80cf02924bf6315b52bb897abdcfb6522b3ead91724cd4b01f336a035fd5e24", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/difference-1.png": "4bf3bfdb3ef69d670d14d9e17751242975fa010fe7283f93777d727cf664349e", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/difference-2.png": "e497d050db94f1939a9d1ca0da99413617d37ef28dffd22c02025e37b6aa4786", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/difference-3.png": "fe6f154ffd81098cc75da550d23b0238dd94cf84fabf0ffc43920db809acd75e", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/difference-4.png": "efe40a3a91742439e51738de60dcf994aca2e3a92f85162cf764e87c6b8611cd", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/pdfium/metadata.json": "8fb1c5e0f87ff005c2dc2613764e2ac8e3710d7df2ef9db06cfffe1a6f1cd1d7", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/pdfium/page-1.png": "f07cd43e7b27fb4a199b8a4fcbb951194be8831eb530e9fc45deb0f3ce21a6dc", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/pdfium/page-2.png": "77d6422d79dc49998eb54f1d7b2ab1c70954505b65859cf5294c4a89e3b6d795", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/pdfium/page-3.png": "e5b5bee1b4c441b56ceb748cd85d3440b0f85cae7d15d70deb8fee59b5ada9be", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/pdfium/page-4.png": "eaf914a9340b7c796895dba42052a9fb1d06f12c7b00c86c9afaa252574a596c", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/poppler-1.png": "e98256bc67e2f2bb99e9f877c3b312c291bd13cf15b2f5b470c4b74234a13ac1", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/poppler-2.png": "352bd7a448d4d135aac399bdd3f6f2b351d3d0b3c3a7db0820582cdcd3b4e555", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/poppler-3.png": "f74a57c02f7f653190db27366e62bdec9195549730474c4f3a0ae8d1c2961718", + "tests/results/pdfium-intent-transparency/master5/scale-2_0/poppler-4.png": "212a24838f64384637237ce70eb88efb23deb65525e806e23820388c09c7896c", + "tests/results/pdfium-intent-transparency/master5/scale-2_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/master5/scale-2_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-transparency/self-check/report.json": "b2bc4c27b51cba34dcf6f98bc0447f377c7adfefd83464c714a9ee11aae14c59", + "tests/results/pdfium-intent-transparency/self-check-pypdf-compatible/report.json": "592adb27792ed390cf94d9bf49cb62bdbaeb4d4f681031e5020577395f5d7078", + "tests/results/pdfium-intent-transparency/summary.json": "95d86d4c763ad62b3e82e5640aa8b340f6de15fa33f3cdde9a71973c03e18e10", + "tests/results/pdfium-intent-transparency/verification.json": "2173c2625bdefee2dc4037e9344e68ecbc2b5aef876a94ea0a70d34f282e74e1", + "tests/results/pdfium-intent-ubuntu/host-fixture-compat/report.json": "eb7652f796b18b334c8a1ec6892af062811aed89afc48b8258e27befb88f1ab5", + "tests/results/pdfium-intent-ubuntu/intents-run3/qpdf-check.log": "df4d2c01821aa82475690dd860aaca76e58db990551a484bf056ca373ec86bab", + "tests/results/pdfium-intent-ubuntu/intents-run3/report.json": "9d0aa77c43627beee03f3349b4b547bad4dd472ad7cceea2c6db971a9ed89545", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-1.png": "9c6717d2ba94387a69f3e854ac578caac9e10c8244afe32d8709dbea28039426", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-2.png": "729da42225cb3caa537c41d339af54cabf330e2f8db2fc3b4ee1b528bae5ac46", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-3.png": "6ccefaf4c04b81ad97f20b045245bedc2d77c73e12cac4cd683aafbb6009a1f4", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-4.png": "f9ecce02fa369c9c9dbec119e1b484fa8c7e2bc2c99965e978a9e9be96634bc7", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-5.png": "853e4970235bceac0eba8c7a7cebc6ba12adbdbfd596d679ad1a5be9c38d63d1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-6.png": "2e6c6e78e95fc39c8705fcfd9310a1825e027c2b32efee8bf59a1c00efa39c9e", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-7.png": "49500c0894346da0ffc8015c3bdcf002c5b625951f2bec98d12d9ad63e69ae04", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/comparison-8.png": "8269abd0922783801bfda7baaec15044f54fe9d6b4f08bf58a1444890dc456f4", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-1.png": "a53ca67801af5c43c9a4334b28febf47ac4d95fe66b226900e1b82ce68b0e4ae", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-2.png": "c3242e3745dd5a411e612f48d85d7d4fbe510ff2bbdfb89be05884083ea63753", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-3.png": "f06e9ddbb5e4c4f26575e9bc3364c2a57f47f8d039d211f72d1cd309a3fda496", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-4.png": "49c28cedbc9fb651c364d625a1e75257f39710d9477a9228375874123550556f", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-5.png": "af154ce9407c48cf9cd8fb116fb490e45911af4a006a49eae19646c71e5a83ce", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-6.png": "ccf541d262542fbeee6d4c6cd67bd41a455e8bf07d2a95d2f4f68af07507f02a", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-7.png": "b995e3c2e6344a533d4205286ae9ad29ad680a824f2711055972728c9f5eceb1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/difference-8.png": "488199fecbb668496fb9d0d9153233add1b1a7f3db8912be3b8a9b03bd1f9725", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/metadata.json": "4a01f6e7bfc16aabf3cc096fee4e8d2bcc4959c1667425f7d70ae492a31c98cc", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-1.png": "141a3b02404af17231bc658401cbdab52198f5bfec8f7f635625eff59def1722", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-2.png": "bdc774a7d521888534403fad2ef7d44638a5debd53a083d3442c9ec91dfcf20d", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-3.png": "c9df46a507dce298edec6a2cc5151f393bfdcb46f6a37a7a1e16863f19c6fad1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-4.png": "82a45cfe9ce2a9c89ce848d9215666737696b146e5200ede2387c395245e594e", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-5.png": "9f3b06274de35c9b5ce2c38c75eaffbcbe3d88c0d07bb05059548cd30aaa3a5a", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-6.png": "e8cef29741aa6f54dd73adc4698880c578dcdf58a7d919b1ca7a26df7a793089", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-7.png": "df58f319df38367f8bb817425d1760ace0b2bf846e52f3c917029d31907e6ab6", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/pdfium/page-8.png": "5938bc52e3992940b5c9a70a9066b3076952acddbb5fa43e39673dc6c2f33921", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-1.png": "c5d71771ff58399d7fb350ea5145abee97008a81bddf637afff0699cc5289178", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-2.png": "033c2f7cc1a9b1b3a127533f8dade1d08b457c0820914081e0dbef524017877c", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-3.png": "1a12dfacf0c6dbcac00393f89e6d86d2e7748b41cd91340547a9cac223f34bee", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-4.png": "f76d35ba9c1c83590af05673466d236d45b5039c990b736b63a45016009135cb", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-5.png": "bb42112193f8f4774e01f47f538578cc70a5e351e2303fbf91370bd54b390e45", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-6.png": "d241b9b06d5112b77138809c11398bb4435c2198979edf1fd91bc9363b498831", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-7.png": "a7cb9e96a900596b292d7399569bebdcac55156b6ee817e3d8ba58f97d259e87", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5/poppler-8.png": "0ea05e4da48dc45e024387bce8793d1b4542214832681570017ef24fe11f4290", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-1.png": "1c25d125580795463851016f8ae9e8100649f8e24fcfb8b7fefa0f7af3b5a580", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-2.png": "56cf0aca0b36d5ebbcf569e1b16e5ed4871b9c451087c11be4d4425c54063c08", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-3.png": "945408687086eef34471f72ed8b2e693013bb939775382e2887a33cc710fffef", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-4.png": "5d3bd1895e4b7eac51dcf63c4bc416f83cf336e30f3192136793fbf85fdac750", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-5.png": "e8e3210acd1b62a0ea59410c48c15b09eb50d5e081674124cab592c845ab504c", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-6.png": "3e1266b5c79ec7f4cdab1ee2bd0cf52afd993d9e96235e8cef0f1d4de052fef0", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-7.png": "bed680be747164bfc591b6a730287c0dde56831b15f48997f9456c54b9603255", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/comparison-8.png": "32e78deccf2477ace551a5527280f9dc7442d549546fa4aef3c6dd16e5dc5b78", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-1.png": "705be78dcf39ac825f7ffa16d37f8e363ed9ff7a184a3962eff0771a45d61cd6", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-2.png": "efca79df4e020e081eb60fa5fe6f5823db3dd772eef49b432328a95ebb2f0c98", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-3.png": "16190cca559a2de25ad2cbd6f468a7b4486cae01d29c6166cdf6ba354085695c", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-4.png": "8c868a1f8128ae1ddb1bdf68d0b3861ef29f0840bc6e2299a25c53ba3c9cfe7b", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-5.png": "09fc6bb3d8e46886be901434dad3ede70e78f128c104108f68b6f3afafb56305", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-6.png": "45d7b129e72427f1960a02239de428cde602cb175d66f456a3a0f674f93e7442", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-7.png": "bb0244ed472495647429b37ed5dd8f129381933dfd845094a8de47479ce1e40a", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/difference-8.png": "268b90a8d19115095efd90df83476d151f65a50f95c12e641dc6e6fd9d34b839", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/metadata.json": "3160df3e3a31e8c7e278ddece9e7b15d92f078d36c4e5f7a7b892d6c00d354a4", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-1.png": "1d7cf550282dcd792f09da3a22326e878e1d7067a8e390daf037fff2b924d046", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-2.png": "baaf4a717d8395671354a8dab36b2795dd7849da895b39a747f8c1118db905a7", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-3.png": "2c8974c9416e834302f96f116652a81b9c2e5449cdb52ffd285f410115494524", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-4.png": "c654c9297a2c4b68c14de2ff6598ff3779a290b495a01ad146be0a4208a99e2c", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-5.png": "72a097e9c5e28ea4383b473e1bcc0b43ebb8c29ff2e23ac95b723757d5c19d13", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-6.png": "0f883a73df9639e50fc0e987144ca6b7fd93f7c9437db6d4e4d5a07091c3cdf9", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-7.png": "f044397ff99d78e5181cfd58e936dbcb27a23d8793458f59092557d80d3907ab", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/pdfium/page-8.png": "d5e1fd1c03e44b5724431f063a6a71c36002b8813cc7daeec55d7f2959d745db", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-1.png": "6d97a93b1c3f3121892918fa7ee53bb3db2a0ef434dba2d32ea6cfa1b9172740", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-2.png": "800cfc5f9a8c8ad6efb998a4cafd394961f83e9e3b997951a516e8ece2505071", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-3.png": "0e53f923ca65d84ece40bc2b104e2ae9f99952b68a4bd36bd7b1bd0401636f2e", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-4.png": "1a880c4e40a72ec251e956b3cd85eb099ce878f4f23ef820e2943eb3d6086451", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-5.png": "2a0ad01515eb67c28c1aae0c23d30eb046caa4fd1e15eab823da2188dc7623b1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-6.png": "12b0fe6a9a0ea13276cb1e403abb5f68ce73ab60d33eab0acab6adc7cfd591b1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-7.png": "c58d8631e3fa022e442507755453919dfc511a11b0497e8e6df2a1fe7b082614", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0/poppler-8.png": "177de1e9cfe37d855b766b1c4cb77fd0ffb9724b26ea4ba778b3e9d116e90abe", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-1.png": "3eea21e98bf875cf7bffa73e286a5bd377c2e1640510dc03b6c335845feb67a6", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-2.png": "a162fdc68d62fbf8cf189c10b6c0c2e99e679512b6de8c133c4182973fe940f1", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-3.png": "9633c991a73548de192ace15f2507e4aa60501d711735bc63dfa72db03ae4160", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-4.png": "6b29211fc796853b974ac887c6aa3a9b415ed670b2b5e7782ecd8fd89f3b2872", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-5.png": "5cc19f9a58a8d3b3956ea52db8cc11cf571c76522bf07eff05479416e3ce9041", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-6.png": "b7664eca6e408dd0b02fa759274432e92ac75a1d2e2f7f104f9d64d85e115cd0", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-7.png": "e7288ba79c0e970fdde6c9c0e747609e2fe6c64d8ccfe5cda53954e84ae49928", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/comparison-8.png": "9015d3d7ef52446322f1871b66fef19e45858cfaf23867b42bcb473a36c03f12", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-1.png": "3b2670052fbd160cf3459cee29cb82e019f0f2c88b14e95305be2160a64248ae", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-2.png": "de88acf297cdbe572f4eaa858f6723d83ea8ceb4cc159c86c3ecb3361d19df26", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-3.png": "89f51c9031f2034ae8e59eb9d5b05b847d431735d1566f4340376df903833cdd", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-4.png": "281d9e637e07fff9eb4a8a501647ff82689526c4eea342890bcd35a3bc686232", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-5.png": "4fd39c766ddd986c141f9cf4db042700fdc95ababf9e572c6a4fa6f3e01e8e2e", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-6.png": "7e0c7f8685f1e586c11570542cfd3600b5d5ff42a897bd78e20312fe77b5bc22", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-7.png": "7ea5dd5eff13dd234036edbc0e498be9db28297f6440579fdb2df5c8a49d2aba", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/difference-8.png": "1dc5c5d846432970e3c38d786bc4fe17042fd51a5034b0d96a073b19a4818a70", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/metadata.json": "0c6a51d3871cf43b8bc991fbb6c0c8f6f1421cf6e32c0b881855aade833fbe79", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-1.png": "a0176cbd2724fb3b88eba86c610b9546f45202096bb9d21a613080094ae6767f", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-2.png": "140de5e2e7b19ed00d83cb3ee6f46c2fae5b473479ceacb2564a20f4b59883cd", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-3.png": "8ee1bc5be8b9ed8dd6a02f10c6995f4a871110bcb24dc7e434c76764cf12897c", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-4.png": "d84516e60d7dcc9c91dfeef48090fcc333d39941d082a8753adf3a99f45e1484", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-5.png": "76d65a12cfe1da220902e4bf367ebc130e5c2312bde0e3b5ff9286c5d5cb49b9", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-6.png": "31baec17d0a17f11bab60ce6f36155ee81ffe5213924ba22d05404c62e477c38", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-7.png": "b20f42b6df69f6a2f379b403e8ef5beccb4ae9b8dc61608cc7413cb54befe344", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/pdfium/page-8.png": "083ae6e51ddfa91eb4edeb1f9cffb2ac742e8d06ecb7c77f92709229b7067d19", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-1.png": "4b5b05077a5808919dbc995ba8cfd4b037f08e0b9f8a163fd057300dc8f03b59", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-2.png": "ec027287c92b06f8690385324a587b99a849bec02c48259b5bafec5ca0c0378a", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-3.png": "a02a404cd8aa57cdf6d742bb2004aa432229936d8869a78949a08243a61f2b74", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-4.png": "1429cdfec6d9f2978c6381327744ead436a90b5c95e44dd050aee5928cd43ddb", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-5.png": "ac45e6faad7876fcfd245938a0e588926e75489f7c3b95d1e9fc8bbf9fd15748", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-6.png": "b65cf5a0d4eee06b1ba632338f4227a52926ea3a44b64e893406aa4a5c893071", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-7.png": "4e4256776bdf8cb0d0eed73d8267c63c177089d3e3f60f1ac6f84f2a62c65f42", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0/poppler-8.png": "af6943479e2ec661fcc2f370565983c35503e5eb03dfaff1e9c5ed2aa8b10e77", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/intents-run3/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/library-transfer.json": "ff5a5478fa592c8a2dcc868cf0ed0fd34a0e2e677c6f77d17c2ff1c5067149f0", + "tests/results/pdfium-intent-ubuntu/prior-intents-pillow-compat.py": "4563f85e79c2f45d9649c1101b1ef7bf46cc8620498d1ecca32ebca7be617619", + "tests/results/pdfium-intent-ubuntu/prior-intents.py": "6c455e29a03aa7c4450a3cb44dc469b6794ba7a2a38492f99ceeeb59e1aee8fd", + "tests/results/pdfium-intent-ubuntu/prior-validation-record.json": "47b4dc2efbf8e671f904610b3047400dbd3ceb0fa10d6bd53fb04f3727943e7e", + "tests/results/pdfium-intent-ubuntu/reference-cmm/README.md": "261d37689bec1c39edb5020809e1f1d2eb5f5c4de5d755203fb8929fdb95ffeb", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/allocator-restore.log": "a2ea311af8818329b2ebe577cab26f2feda071551c87e9b363d57ec704b32cf7", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/build.json": "a932625c553f1aa72934f4c980dac82e3cfcbf0b6dd667f8882d02cca24f23ba", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/compile.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/compiler-target.log": "08cababdf891ccbf96eb376f1e84f279f70302fb13d84e5f98637becdfc6d989", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/compiler-version.log": "8497f84b2b8334c413d9c6166bd39d009ba55d2ff60a8f23e66dfa5819c2b048", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/ldd.log": "e386a5dc0078b3ac24d49b6c3d2061f6d7516e63a02d5068e24dc3b91684e7ce", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/lib/liblcms2.so.2.0.19": "08623c121f1afa05f4153d602ab2440226c1c8695c9b4b9484836f7ebcce3478", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/proof-pinned219/proof.json": "a6be14da752c3fe92189c083f4d58c1d1ba75266fae0a31152064a9e79e4b1f9", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/proof-system214/proof.json": "e6f243bc21d707ea9949870f19c9bf206ab7b1ea65a531a6601787d00b186414", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/readelf.log": "7fb8851df1423ed87e31faeb77ad17747dc627ea2d32dfaa161f0cd575fb9591", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/restore-standard-allocator.patch": "61ab2ab22e054421beeec9c671486ca2c601e52fe40ecda2e39ff7ccc4a15a9c", + "tests/results/pdfium-intent-ubuntu/reference-cmm/guest-build/runtime-version.log": "1227c0d6d6d15678f030ff357ed9c5f25a54530697790e95680730511f1cd3f9", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input/bundle.json": "5b5f730c7b3ccc818c6e301517e27f683b7a27ab759391b970a88f056d770783", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input/cross-os-before.json": "78500a0d21b68bfc3b4f5e00e3d092a8568480808cd950ba26b4ec799a43de65", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input/reference-lcms219.tar": "1822ed259dc0d5d735c63dc65a180683808c8aa8c7f05fdb7b09679770c0559d", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-final/bundle.json": "3fd6998210a128cb36ecfd8a4bb14191a30857807a4b815bad03268472c7979d", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-final/cross-os-before.json": "78500a0d21b68bfc3b4f5e00e3d092a8568480808cd950ba26b4ec799a43de65", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-final/reference-lcms219.tar": "fccd1e446fff42cdb480283f019d2207a46255f3636b17b8890fa4b1215bc005", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-repeat/bundle.json": "3fd6998210a128cb36ecfd8a4bb14191a30857807a4b815bad03268472c7979d", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-repeat/cross-os-before.json": "78500a0d21b68bfc3b4f5e00e3d092a8568480808cd950ba26b4ec799a43de65", + "tests/results/pdfium-intent-ubuntu/reference-cmm/input-repeat/reference-lcms219.tar": "fccd1e446fff42cdb480283f019d2207a46255f3636b17b8890fa4b1215bc005", + "tests/results/pdfium-intent-ubuntu/reference-cmm/reference-cmm-build-final.txt": "aeaf9ba2cbe022f998003c5e7da4baaedd6e6a29425df02c44f7fd2b3504fc14", + "tests/results/pdfium-intent-ubuntu/reference-cmm/reference-cmm-build.txt": "3b3e2bbaf3395e40ef78e7a24ec5e5ea9fd24bb91b2dc9b70942c1bb4cf54ada", + "tests/results/pdfium-intent-ubuntu/reference-cmm/reference-cmm-preflight.txt": "5a7bce4bc0bce090deeb7e30f342e2ae2fd9a794e9f62ed4d27e7347757f23bc", + "tests/results/pdfium-intent-ubuntu/reference-cmm/reference-cmm-proof214.txt": "13c995542256a0abc4f41cc237f452dc9a7e3dca6e2608a0d55bb5abd4f4ae44", + "tests/results/pdfium-intent-ubuntu/reference-cmm/reference-cmm-proof219.txt": "fef00f7a8c640431089b43e649d68028b7c0e3d0ead6e89abad258e769f73424", + "tests/results/pdfium-intent-ubuntu/reference-cmm/verification.json": "0d995faa6493cf5dcc5bd572ffdef730865365a0fad80e4980a7f28590bc4e8d", + "tests/results/pdfium-intent-ubuntu/ubuntu/LastTest.log": "1b8bb99954370d6e97563e7250511d0c8cf7c11b662c1800ef9f1268618df3f8", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/overview.png": "a652e2cab2568d476b8c6c4b0c3b17114b2d67c5985c73b8d75c607dba383e90", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/qpdf-check.log": "eb3599409d4908cb1171848414bcf98c8af522ef8c4ecc6058089d4650f72336", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/report.json": "740a2adb3a77e29aa05fecc5b8cf0915c07e4777488d04a9135e8cc9b281474c", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5/comparison.png": "ffc720a5f902d601330cffc4db11302c9aae1a7ebd300940c114abef9f329e4a", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5/difference.png": "102f1102260926b0004a44e8aeb90ea46427b19bbe210f8c0f4fbc503113f248", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5/pdfium/metadata.json": "b91ea871f6d2ffe17ea21c7e70836c1961e8877eccaa7828b4d72c532cfea0cc", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5/pdfium/page-1.png": "16900605fd4b016a0d304ee8349d723134f65715c85da1ef8ded795baaebacce", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5/poppler.png": "481a41394a6ecaf16fff600db9af52bab896c05407de445438b0ebcd077c3335", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-0_5-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0/comparison.png": "1b982111c33951d862f586db73153a9abc0210875d581450f8cf9b0d06d1546c", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0/difference.png": "d63bbc4c5e78685c5017776f1984973f41d29f1f3a3f38b980a2d92147e36624", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0/pdfium/metadata.json": "b6aacf5fa0aff7b1a5eec9bb2d6446b8afd8572013a9ae148a67862a61b50d16", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0/pdfium/page-1.png": "4acce048167b19cda75d59dd5d0dc4c9ed0e94c15dbf1aa2841391de2d2945ae", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0/poppler.png": "48048bb581e2013a1df13a66c26ba2e164191ec0c7e4ac50d7c86503c0e3e5f0", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-1_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0/comparison.png": "203d85946d750a6979114a339da8a6391d0070bdc4524673ce9d90d2b74e45b9", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0/difference.png": "7d5871dd9f1abc45d930b566d92e4011288d57423c3ddfacf4b956ca589924e8", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0/pdfium/metadata.json": "40a582667fb8739f1e270be94d58791b71c316617b4295e154277a24b8df0a80", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0/pdfium/page-1.png": "db1f61196ba73adfcfa2544840ef797137fad45b4d1af1f08708be3a4bd2ba81", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0/poppler.png": "ce76ff2f960c5c3becfa33ed58a0a675097b8f2a8db3c6f9880e8836c8de1c23", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0-pdfium.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk/scale-4_0-poppler.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tests/results/pdfium-intent-ubuntu/ubuntu/cmyk.log": "2e2f249f075131825a21eb4b5e6656dcac738b2d020681a23b389701ec54f490", + "tests/results/pdfium-intent-ubuntu/ubuntu/ctest.log": "a00bc6aabdfbff9bfa9426e4741015b8c03efc576695754d2b87aa42f9a950c3", + "tests/results/pdfium-intent-ubuntu/ubuntu/intents/report.json": "e5785a3fbb7fd4bb330000bc0dd7a8f0a772596cb544d7f60eb53b4afabbfc78", + "tests/results/pdfium-intent-ubuntu/ubuntu/intents.log": "34ef2ff535621c797924e4aea480e3cf38be30cb8d15c9c38f35fe5df96cd18c", + "tests/results/pdfium-intent-ubuntu/ubuntu/report.json": "10b462d440b5b6e6792b92660df6b082a893d9a87c6f579b6ea62202667dc846", + "tests/results/pdfium-intent-ubuntu/ubuntu/tests.xml": "1e5602ba577838c62a3818374a89cad5262e1579a9c09a9dba4658a096ab09ed", + "tests/results/pdfium-intent-ubuntu/ubuntu/worker-ldd.txt": "99495548af358e7c12f884d80f72cade7b260ada1cf8a46485945eed37085593" + } +} diff --git a/docs/windows-sandbox-port.md b/docs/windows-sandbox-port.md new file mode 100644 index 0000000..4c1cc80 --- /dev/null +++ b/docs/windows-sandbox-port.md @@ -0,0 +1,53 @@ +# Windows worker sandbox port + +Status: the Windows broker, bounded handle transport, and PDF/archive worker bootstrap are connected in source. Native Windows execution and an MSVC/Windows Qt build have **not** been performed. Successful Linux regression tests and a compile-only check against Wine declarations are not Windows security or compatibility evidence. R12, G-SANDBOX on Windows, and distribution acceptance remain open. + +## Launch and verification + +`WorkerProcess` uses `WindowsRuntimeStaging`, `createWindowsPipePair`, and `launchWindowsWorker` on Windows. There is no unprotected `QProcess` fallback. The broker opens the original document with read access, checks the file type and reparse attributes, and supplies only duplicated handles to the worker. The shared installation tree and original document ACL are never changed. + +The launcher creates a unique temporary AppContainer profile and requires the **LPAC** process attribute, zero capability SIDs, low integrity, a one-process Job, a 1.5 GiB per-process committed-memory limit, kill-on-close, no Job breakaway, and desktop/clipboard restrictions. The child-process policy attribute also blocks child creation. The process starts suspended; token identity and Job limits are checked before it resumes. Environment variables are reduced to the private runtime/System32 search path and SystemRoot. Arguments use Windows C runtime quoting without a shell. + +Handle inheritance is limited to a read-only document handle, two directional overlapped pipes, and one inert `NUL` handle for standard streams. The child receives `--document-handle`, `--ipc-read-handle`, `--ipc-write-handle`, and `--sandbox-sid`; it rejects `--source` and `--socket` on Windows. `startWorkerRuntime` verifies the actual kernel token, LPAC state, SID, capability count, integrity level, immediate Job membership and limits, and private-profile access restrictions. A flag or SID argument alone cannot establish protection. A failed bootstrap exits with code 5 before parsing. + +The document handle is duplicated again with only read rights and the inherited original is closed. `_open_osfhandle` transfers the restricted handle to a CRT descriptor; `QFile` owns that descriptor. PDFium's file-access callback and libzip's seekable source callback use this already-open `QFile`. They never reopen the original path. Source callbacks retain bounded index and entry checks; archive output is streamed as provisional chunks to the Main broker, which alone verifies and commits files. + +`WindowsPipeDevice` uses overlapped I/O, `QWinEventNotifier`, a 256 KiB transport read buffer and a 16 MiB write queue. CBOR framing retains the 1 MiB control / 8 MiB render limits. Worker extraction drains pending transport writes above 128 KiB, with a five-second timeout; synchronous parser callbacks reject reentrant commands during this drain. `WorkerProcess` waits for a protected bootstrap ping before reporting the Windows worker ready. + +## Private profile and runtime storage + +Creating an AppContainer also creates writable private storage. That default is insufficient for a read-only parser. The launcher replaces DACLs only on its newly generated profile and broker-created runtime copies: the owner retains full access, the package receives read/execute access, and explicit package deny entries block file creation, writes, deletion, ACL changes, and ownership changes. Existing descendant entries are treated similarly. Reparse points and files with multiple hard links are rejected. The profile filesystem is checked again while the child is suspended. + +The broker briefly impersonates the suspended child's token to obtain its own profile registry HKEY with `GetAppContainerRegistryLocation`, then returns to the broker identity before applying protected DACLs recursively. Package registry access is limited to read; creating keys/links, setting values, deleting keys and changing ACLs/ownership are explicitly denied. Unexpected registry links, oversized trees, and API failures stop launch. The child independently attempts to acquire each write permission on its profile directory and registry root and requires access-denied results. These checks do not create files or values. + +The launcher exposes no writable worker output directory. The profile is deleted on normal `NativeProcess` destruction after Job termination and handle closure; recovery after interrupted broker shutdown still needs native validation. Runtime staging uses a finite DLL/executable manifest with hashes and private owner permissions. It does not copy user font directories or grant access to document parents, user configuration/state, or shared installation directories. + +## Font behavior and remaining validation + +The PDF worker now installs the public `FPDF_SYSFONTINFO` version 2 adapter and requests selected font bytes through `font.map`, `font.read`, and `font.close` on its existing protected IPC transport. It does not reactivate PDFium's default filesystem font provider. Main does not link PDFium or parse SFNT/TTC tables. See the [font broker contract](font-broker-contract.md). + +The source Windows backend builds an OS font index with `EnumFontFamiliesExW`, selects an indexed family using `CreateFontIndirectW` and a thread-owned HDC, verifies `GetTextFaceW`/`GetTextMetricsW`, and takes a bounded snapshot through `GetFontData`. A TTC includes its collection bytes and the selected face offset derived from GDI's collection and table-zero sizes. Only an opaque session ID, selected metadata, and 64 KiB byte chunks reach the worker; HDC/HFONT and paths stay in Main. GDI calls run on dedicated background threads, limited to two across active and revoked services, with 256 MiB of total snapshot charges. A shared LRU can reclaim unused snapshots from either service while protecting issued handles. Revocation drops late replies without joining an in-progress OS call. Variable-font instances are skipped and subsequent static candidates are tried. No extra LPAC font capability or font-directory ACL grant was added. + +This Windows backend passed only a supplementary object-compilation check against Wine declarations and Linux Qt headers. An MSVC/Windows Qt build, actual GDI selection and TTC behavior, font RPC through LPAC, Japanese fallback, glyph metrics, and non-ASCII family names remain **unverified on native Windows**. Linux broker and drawing results are not Windows fallback-font evidence. The implementation follows the pinned [PDFium Windows adapter](https://pdfium.googlesource.com/pdfium/+/a5a7089234f121990b336b3841008009dca143bf/core/fxge/win32/cwin32_platform.cpp) and Microsoft's [GetFontData contract](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getfontdata); their semantics still need native execution tests. + +Native acceptance must first prove successful PDF/ZIP/EPUB operation, then reject source writes and path reopen, sibling/configuration/state reads, external and loopback connections, child creation, unrelated inherited handles, runtime mutation, and writes to **own profile, its Temp descendants, own registry storage, and arbitrary temporary directories**. Tests must check actual API error codes and distinguish denied access from malformed test setup. Also verify memory enforcement, broker termination, watchdog/restart, profile cleanup, non-ASCII paths, resource reparse protection, renderer restrictions, and clean packaged installs. Each protection setup failure must leave the worker stopped. + +## Evidence recorded on the Linux development host + +After the shared bootstrap and libzip handle callback changes: Archive **57 passed**, including reading an unlinked-but-open source, rejecting writable/null sources, source-lifetime failure, and unchanged source hashes; PDF **15 passed** through the production `WorkerProcess` route; Canvas **11 passed** with real sandboxed PDF worker IPC. These exercise the Linux path and platform-independent callback behavior only. + +`windows_sandbox.cpp` also passed a C++ syntax-only check using Linux Qt declarations and Wine Win32 headers. The local Wine headers lack current AppContainer declarations/constants, so documented Microsoft declarations were supplied in a temporary compile-only prefix. This is not a link test, ABI check, Windows Qt build, or native execution, and is not an acceptance gate. + +## API references + +- [Microsoft: Launch an AppContainer and LPAC](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) +- [Microsoft: UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute) +- [Microsoft: GetTokenInformation](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-gettokeninformation) +- [Microsoft: QueryInformationJobObject](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-queryinformationjobobject) +- [Microsoft: Job Object extended limits](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_extended_limit_information) +- [Microsoft: AppContainer profile folder](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-getappcontainerfolderpath) +- [Microsoft: AppContainer registry location](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-getappcontainerregistrylocation) +- [Microsoft: ImpersonateLoggedOnUser](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-impersonateloggedonuser) +- [Microsoft: SetSecurityInfo](https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo) +- [Microsoft: `_open_osfhandle` ownership](https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/open-osfhandle?view=msvc-170) +- [libzip: seekable source callback](https://libzip.org/documentation/zip_source_function/) diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..9d6f6cb --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,403 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Dialogs +import DocView 1.0 + +ApplicationWindow { + id: window + width: 1100; height: 800; minimumWidth: 480; minimumHeight: 360 + visible: true + title: reader.title + (reader.title === "DocView" ? "" : " — DocView") + property bool dark: reader.settings.ui.theme === "system" + ? Qt.styleHints.colorScheme !== Qt.ColorScheme.Light : reader.settings.ui.theme !== "light" + property color backgroundColor: dark ? "#181c20" : "#f1f3f4" + property color surfaceColor: dark ? "#22282e" : "#ffffff" + property color textColor: dark ? "#d8e0e8" : "#24303b" + property color mutedColor: dark ? "#97a4b1" : "#586775" + property color accentColor: dark ? "#abc8de" : "#215f8e" + property bool tocVisible: reader.settings.ui.toc_visible + property bool pagesVisible: reader.settings.ui.pages_visible + property bool statusVisible: reader.settings.ui.status_bar + property var collapsed: ({}) + property var tocRows: [] + readonly property int visibleCurrentOutline: { + var current = reader.currentOutline, visible = -1 + for (var i = 0; i < tocRows.length; ++i) { + if (tocRows[i].originalIndex === current) return i + // A collapsed ancestor represents the current section below it. + if (tocRows[i].originalIndex < current && collapsed[tocRows[i].originalIndex]) { + var depth = tocRows[i].depth || 0, within = true + for (var j = tocRows[i].originalIndex + 1; j <= current; ++j) + if ((reader.outline[j].depth || 0) <= depth) { within = false; break } + if (within) visible = i + } + } + return visible + } + property bool searchInput: false + property string previousContext: "" + property var commandNames: ({ + "file.open": qsTr("文書を開く"), "app.quit": qsTr("終了"), + "operation.cancel": qsTr("処理を取り消す"), "document.root.choose": qsTr("文書フォルダーを指定"), + "document.info": qsTr("文書情報"), "history.clear": qsTr("履歴を消去"), + "scroll.down": qsTr("下へスクロール"), "scroll.up": qsTr("上へスクロール"), + "scroll.left": qsTr("左へスクロール"), "scroll.right": qsTr("右へスクロール"), + "scroll.half_down": qsTr("半画面進む"), "scroll.half_up": qsTr("半画面戻る"), + "scroll.page_down": qsTr("一画面進む"), "scroll.page_up": qsTr("一画面戻る"), + "nav.document_start": qsTr("文書の先頭"), "nav.document_end": qsTr("文書の末尾"), + "nav.page": qsTr("指定ページへ移動"), "nav.page_next": qsTr("次のPDFページ"), + "nav.page_previous": qsTr("前のPDFページ"), "nav.heading_next": qsTr("次の見出し"), + "nav.heading_previous": qsTr("前の見出し"), "nav.chapter_next": qsTr("次の章"), + "nav.chapter_previous": qsTr("前の章"), "panel.toc.toggle": qsTr("目次の表示切替"), + "panel.pages.toggle": qsTr("ページ一覧の表示切替"), "panel.status.toggle": qsTr("ステータスの表示切替"), + "focus.next": qsTr("操作する領域を切替"), "focus.content": qsTr("本文に戻る"), + "zoom.in": qsTr("拡大"), "zoom.out": qsTr("縮小"), "zoom.fit_width": qsTr("幅に合わせる"), + "view.rotate": qsTr("PDFを回転"), "command.open": qsTr("コマンドを入力"), + "config.reload": qsTr("設定を再読込"), "help.toggle": qsTr("操作一覧"), + "nav.back": qsTr("移動履歴を戻る"), "nav.forward": qsTr("移動履歴を進む"), + "search.open": qsTr("本文を検索"), "search.next": qsTr("次の検索結果"), + "search.previous": qsTr("前の検索結果"), "panel.next": qsTr("次の項目を選択"), + "panel.previous": qsTr("前の項目を選択"), "panel.collapse": qsTr("階層を折りたたむ"), + "panel.expand": qsTr("階層を展開"), "panel.first": qsTr("先頭項目を選択"), + "panel.last": qsTr("末尾項目を選択"), "panel.half_down": qsTr("項目を半画面進む"), + "panel.half_up": qsTr("項目を半画面戻る"), "panel.activate": qsTr("選択項目へ移動"), + "link.next": qsTr("次のリンク・PDF注釈を選択"), "link.previous": qsTr("前のリンク・PDF注釈を選択"), + "link.activate": qsTr("選択リンク・PDF注釈を開く"), "unbound": qsTr("割り当てなし") + }) + function contextName(value) { + return value === "content" ? qsTr("本文") : value === "toc" ? qsTr("目次") + : value === "pages" ? qsTr("ページ一覧") : qsTr("共通") + } + color: backgroundColor + font.family: "monospace" + font.pixelSize: reader.settings.ui.font_size + + function focusContent() { + previousContext = "" // Explicit navigation takes precedence over temporary UI restoration. + reader.context = "content" + if (reader.format === "pdf") pdf.forceActiveFocus() + else if (reader.format !== "") web.focusContent() + else if (recentList.count > 0) recentList.forceActiveFocus() + else content.forceActiveFocus() + } + function beginTemporary() { + if (!previousContext) previousContext = reader.context + } + function endTemporary() { + reader.mode = "normal" + var target = previousContext + previousContext = "" + if (!target) return // A successful action already selected its own focus. + if (target === "toc" && tocVisible && tocList.count > 0) { + reader.context = "toc"; tocList.forceActiveFocus() + } else if (target === "pages" && pagesVisible && pageList.count > 0) { + reader.context = "pages"; pageList.forceActiveFocus() + } else focusContent() + } + function setInputMode(mode) { reader.mode = mode } + function updateToc(resetSelection) { + var previous = resetSelection ? null : tocRows[tocList.currentIndex] + let list = [], skipDepth = -1 + for (let i = 0; i < reader.outline.length; ++i) { + let row = reader.outline[i], depth = row.depth || 0 + if (skipDepth >= 0 && depth > skipDepth) continue + skipDepth = -1 + let copy = Object.assign({}, row) + copy.originalIndex = i + copy.hasChildren = i + 1 < reader.outline.length && (reader.outline[i+1].depth || 0) > depth + list.push(copy) + if (collapsed[i]) skipDepth = depth + } + tocRows = list + var selected = 0 + if (previous) { + for (var row = 0; row < list.length; ++row) + if (list[row].originalIndex === previous.originalIndex && list[row].title === previous.title && + list[row].href === previous.href && list[row].page === previous.page) { selected = row; break } + } + tocList.currentIndex = list.length ? selected : -1 + } + function panelCommand(id) { + let list = reader.context === "toc" ? tocList : pageList + if (list.count === 0) return + let next = list.currentIndex + if (id === "panel.next") next++ + if (id === "panel.previous") next-- + if (id === "panel.first") next = 0 + if (id === "panel.last") next = list.count - 1 + if (id === "panel.half_down") next += Math.max(1, Math.floor(list.height / 56)) + if (id === "panel.half_up") next -= Math.max(1, Math.floor(list.height / 56)) + if (id === "panel.activate") { reader.activateItem(list.model[list.currentIndex]); return } + if (reader.context === "toc" && (id === "panel.collapse" || id === "panel.expand")) { + let row = tocRows[list.currentIndex] + let c = Object.assign({}, collapsed) + if (id === "panel.collapse") { + if (row.hasChildren && !c[row.originalIndex]) c[row.originalIndex] = true + else for(let i = list.currentIndex-1; i >= 0; --i) if ((tocRows[i].depth || 0) < (row.depth || 0)) { next = i; break } + } else { + if (c[row.originalIndex]) delete c[row.originalIndex] + else if (row.hasChildren) next++ + } + collapsed = c; updateToc() + } + list.currentIndex = Math.max(0, Math.min(list.count-1,next)) + list.positionViewAtIndex(list.currentIndex,ListView.Contain) + } + function beginInput(search) { + beginTemporary(); searchInput = search + reader.mode = search ? "search" : "command" + commandField.text = ""; commandField.forceActiveFocus() + } + function recentCommand(id) { + if (!recentList.count || reader.state !== "Empty") return + if (id === "link.next" || id === "link.previous") { + if (clearRecent.activeFocus) recentList.forceActiveFocus() + else clearRecent.forceActiveFocus() + return + } + if (id === "link.activate") { + if (clearRecent.activeFocus) reader.execute("history.clear") + else if (recentList.currentIndex >= 0) reader.openRecent(recentList.model[recentList.currentIndex].documentId) + return + } + let next = recentList.currentIndex + if (id === "scroll.down") next++ + if (id === "scroll.up") next-- + if (id === "nav.document_start") next = 0 + if (id === "nav.document_end") next = recentList.count - 1 + if (id === "scroll.half_down" || id === "scroll.page_down") next += Math.max(1, Math.floor(recentList.height / 52)) + if (id === "scroll.half_up" || id === "scroll.page_up") next -= Math.max(1, Math.floor(recentList.height / 52)) + recentList.currentIndex = Math.max(0, Math.min(recentList.count - 1, next)) + recentList.positionViewAtIndex(recentList.currentIndex, ListView.Contain) + recentList.forceActiveFocus() + } + function endInput() { endTemporary() } + Connections { + target: reader + function onNavigationChanged() { window.updateToc() } + function onDocumentChanged() { window.collapsed = ({}); window.updateToc(true) } + function onRecentDocumentsChanged() { + Qt.callLater(function() { if (reader.format === "" && reader.mode === "normal") window.focusContent() }) + } + function onUiCommand(id,args) { + if(id === "file.open") { beginTemporary(); reader.mode = "dialog"; fileDialog.open() } + else if(id === "panel.toc.toggle") { tocVisible = !tocVisible; if(!tocVisible && reader.context === "toc") focusContent() } + else if(id === "panel.pages.toggle") { pagesVisible = !pagesVisible; if(!pagesVisible && reader.context === "pages") focusContent() } + else if(id === "panel.status.toggle") statusVisible = !statusVisible + else if(id.startsWith("panel.")) panelCommand(id) + else if(id === "focus.next") { + previousContext = ""; let areas=["content"]; if(tocVisible && reader.outline.length)areas.push("toc");if(pagesVisible && reader.pages.length)areas.push("pages") + reader.context=areas[(areas.indexOf(reader.context)+1)%areas.length] + if(reader.context === "toc")tocList.forceActiveFocus();else if(reader.context === "pages")pageList.forceActiveFocus();else focusContent() + } + else if(id === "focus.content") focusContent() + else if(id === "command.open") beginInput(false) + else if(id === "search.open") beginInput(true) + else if(id === "help.toggle") { beginTemporary(); reader.mode="dialog"; helpDialog.open() } + else if(id === "document.info") { beginTemporary(); infoText.text=args.text; infoDialog.title=args.title || qsTr("文書情報"); reader.mode="dialog"; infoDialog.open() } + else if(id === "document.root.choose") { beginTemporary(); reader.mode="dialog"; folderDialog.open() } + else if(id === "history.clear") { beginTemporary(); reader.mode="dialog"; historyDialog.open() } + else if(id === "recent.navigate") recentCommand(args.command) + } + function onPasswordNeeded(name) { beginTemporary(); reader.mode="dialog"; passwordDialog.title=name; passwordField.text="";passwordDialog.open();passwordField.forceActiveFocus() } + function onEntryNeeded(candidates) { beginTemporary(); candidateList.model=candidates; reader.mode="dialog"; entryDialog.open();candidateList.forceActiveFocus() } + function onExternalLinkRequested(url) { beginTemporary(); externalDialog.destination=url;reader.mode="dialog";externalDialog.open() } + function onSettingsChanged() { + tocVisible=reader.settings.ui.toc_visible;pagesVisible=reader.settings.ui.pages_visible;statusVisible=reader.settings.ui.status_bar + if ((!tocVisible && reader.context === "toc") || (!pagesVisible && reader.context === "pages")) focusContent() + } + } + ColumnLayout { + anchors.fill: parent; spacing: 0 + RowLayout { + Layout.fillWidth: true; Layout.fillHeight: true; spacing: 1 + ColumnLayout { + objectName: "navigationSidebar" + visible: (tocVisible && (reader.outline.length > 0 || reader.outlineLoading)) || (pagesVisible && reader.pages.length > 0) + readonly property real panelWidth: Math.min(reader.settings.ui.panel_width,window.width*0.4) + Layout.preferredWidth: panelWidth + Layout.minimumWidth: panelWidth + Layout.maximumWidth: panelWidth + Layout.fillWidth: false + Layout.fillHeight: true; spacing: 1 + Rectangle { + visible: tocVisible && (reader.outline.length > 0 || reader.outlineLoading) + Layout.fillWidth:true;Layout.fillHeight:true;color:surfaceColor + border.color:reader.context === "toc" ? accentColor : surfaceColor + ColumnLayout { + anchors.fill:parent;anchors.margins:8;spacing:6 + Label { objectName:"outlineTitle";text:reader.outlineTitle;color:mutedColor;font.pixelSize:12 } + Label { objectName:"outlineLoading";visible:reader.outlineLoading;text:qsTr("目次を読み込んでいます…");color:mutedColor;wrapMode:Text.Wrap;Layout.fillWidth:true;textFormat:Text.PlainText } + ListView { + id:tocList;objectName:"tocList";Layout.fillWidth:true;Layout.fillHeight:true;clip:true;model:window.tocRows;currentIndex:0 + delegate: Rectangle { + required property var modelData;required property int index + objectName:"tocRow";property bool readingCurrent:window.visibleCurrentOutline === index + property bool keyboardSelected:tocList.currentIndex === index + width:ListView.view.width;height:32;color:tocList.currentIndex === index ? (dark ? "#35414b" : "#dce6ee") : "transparent" + border.width:keyboardSelected ? 1 : 0;border.color:mutedColor + Label { anchors.fill:parent;anchors.leftMargin:4+(modelData.depth||0)*14;verticalAlignment:Text.AlignVCenter;color:textColor;elide:Text.ElideRight;text:(readingCurrent ? "> " : " ")+(modelData.hasChildren ? (window.collapsed[modelData.originalIndex] ? "+ " : "− ") : " ")+(modelData.title||qsTr("見出し"));textFormat:Text.PlainText } + MouseArea { anchors.fill:parent;onClicked:{tocList.currentIndex=index;reader.context="toc";reader.activateItem(modelData)} } + } + ScrollBar.vertical:ScrollBar{} + Accessible.name:qsTr("目次一覧") + } + } + } + Rectangle { + visible:pagesVisible && reader.pages.length > 0 + Layout.fillWidth:true;Layout.fillHeight:true;color:surfaceColor + border.color:reader.context === "pages" ? accentColor : surfaceColor + ColumnLayout { + anchors.fill:parent;anchors.margins:8;spacing:6 + Label{text:reader.listTitle;color:mutedColor;font.pixelSize:12} + ListView { + id:pageList;Layout.fillWidth:true;Layout.fillHeight:true;clip:true;model:reader.pages;currentIndex:0 + delegate:Rectangle { + required property var modelData;required property int index + width:ListView.view.width;height:32;color:pageList.currentIndex === index ? (dark ? "#35414b" : "#dce6ee") : "transparent" + Label{anchors.fill:parent;anchors.leftMargin:4;verticalAlignment:Text.AlignVCenter;color:textColor;elide:Text.ElideRight;text:(reader.format === "pdf" && pdf.currentPage === index ? "> " : " ")+(modelData.title||modelData.href||String(index+1));textFormat:Text.PlainText} + MouseArea{anchors.fill:parent;onClicked:{pageList.currentIndex=index;reader.context="pages";reader.activateItem(modelData)}} + } + ScrollBar.vertical:ScrollBar{} + Accessible.name:reader.listTitle + } + } + } + } + Item { + id:content;objectName:"documentContent";Layout.fillWidth:true;Layout.fillHeight:true;focus:true + PdfCanvas { id:pdf;anchors.fill:parent;visible:reader.format === "pdf";backgroundColor:window.backgroundColor;Component.onCompleted:reader.attachCanvas(pdf);Accessible.name:qsTr("PDF本文") } + WebPane { id:web;anchors.fill:parent;visible:reader.format !== "pdf" && reader.format !== "" } + ColumnLayout { + id:initialScreen;objectName:"initialScreen" + readonly property real availableHeight:content.height-(noticeBanner.visible ? noticeBanner.height : 0) + anchors.centerIn:parent;width:Math.min(580,parent.width-48);spacing:8;visible:reader.format === "" + anchors.verticalCenterOffset:noticeBanner.visible ? -noticeBanner.height/2 : 0 + Label { text:qsTr("Ctrl+O: 文書を開く");color:textColor;Layout.fillWidth:true } + Label { text:qsTr("?: 操作一覧");color:mutedColor;Layout.fillWidth:true } + Label { visible:recentList.count>0;text:qsTr("最近使った文書");color:textColor;Layout.topMargin:12;Layout.fillWidth:true } + ListView { + id:recentList;objectName:"recentDocumentsList" + readonly property real rowHeight: Math.max(52, window.font.pixelSize+34) + Layout.fillWidth:true;Layout.preferredHeight:Math.min(260,Math.max(rowHeight,initialScreen.availableHeight-4*window.font.pixelSize-154),count*rowHeight) + visible:count>0;enabled:reader.state === "Empty";clip:true + model:reader.recentDocuments;currentIndex:0;keyNavigationEnabled:true + onCountChanged:currentIndex=count>0 ? Math.max(0,Math.min(currentIndex,count-1)) : -1 + Keys.onPressed:event=>{ + if (event.key === Qt.Key_Home) currentIndex=0 + else if (event.key === Qt.Key_End) currentIndex=count-1 + else return + positionViewAtIndex(currentIndex,ListView.Contain);event.accepted=true + } + delegate:ItemDelegate { + required property var modelData;required property int index + width:ListView.view.width;height:recentList.rowHeight;padding:6;focusPolicy:Qt.NoFocus + highlighted:recentList.currentIndex===index + background:Rectangle { + color:recentList.currentIndex===index ? (dark ? "#35414b" : "#dce6ee") : surfaceColor + border.color:recentList.activeFocus && recentList.currentIndex===index ? accentColor : "transparent" + } + contentItem:Column { + Label { width:parent.width;text:modelData.title;textFormat:Text.PlainText;color:textColor;elide:Text.ElideRight } + Label { width:parent.width;text:modelData.path;textFormat:Text.PlainText;color:mutedColor;font.pixelSize:12;elide:Text.ElideMiddle } + } + Accessible.name:modelData.title+" — "+modelData.path + onClicked: { recentList.currentIndex=index;recentList.forceActiveFocus();reader.openRecent(modelData.documentId) } + } + ScrollBar.vertical:ScrollBar{} + Accessible.name:qsTr("最近使った文書。上下キーで選択し、Enterで開きます") + } + RowLayout { + visible:recentList.count>0;Layout.fillWidth:true + Label { text:qsTr("↑↓: 選択 Enter: 開く");color:mutedColor;font.pixelSize:12;Layout.fillWidth:true } + Button { id:clearRecent;objectName:"clearRecentDocuments";text:qsTr("履歴を消去…");enabled:reader.state === "Empty";onClicked:reader.execute("history.clear") } + } + } + Rectangle { + id:noticeBanner;objectName:"noticeBanner" + anchors.left:parent.left;anchors.right:parent.right;anchors.bottom:parent.bottom + visible:reader.notice.length > 0;height:noticeLabel.implicitHeight+20;color:surfaceColor + Label { id:noticeLabel;anchors.left:parent.left;anchors.right:parent.right;anchors.margins:12;anchors.verticalCenter:parent.verticalCenter;text:reader.notice;textFormat:Text.PlainText;wrapMode:Text.Wrap;color:textColor;Accessible.role:Accessible.AlertMessage } + } + DropArea { anchors.fill:parent;onDropped:drop=>{if(drop.hasUrls){reader.open(drop.urls[0]);drop.acceptProposedAction()}} } + } + } + Rectangle { + visible:statusVisible;Layout.fillWidth:true;Layout.preferredHeight:28;color:surfaceColor + RowLayout { + anchors.fill:parent;anchors.leftMargin:10;anchors.rightMargin:10 + Label { Layout.fillWidth:true;text:reader.title;elide:Text.ElideMiddle;color:mutedColor;textFormat:Text.PlainText } + Label { text:reader.state === "Opening" ? qsTr("読込中") : reader.position;color:textColor } + Label { text:reader.pending;color:accentColor } + Label { text:reader.context === "content" ? qsTr("内容") : reader.context === "toc" ? qsTr("目次") : reader.listTitle;color:mutedColor;leftPadding:18 } + } + } + RowLayout { + visible:reader.mode === "command" || reader.mode === "search" + Layout.fillWidth:true;spacing:0 + Label { text:window.searchInput ? "/" : ":";color:textColor;leftPadding:10 } + TextField { + id:commandField;Layout.fillWidth:true;selectByMouse:true;color:textColor;background:Rectangle{color:surfaceColor}Accessible.name:window.searchInput ? qsTr("本文検索") : qsTr("コマンド") + placeholderText:window.searchInput ? (reader.format === "epub" ? qsTr("表示中の章を検索") : reader.format === "pdf" ? qsTr("本文を検索") : qsTr("表示中のHTMLを検索")) : "" + onAccepted:{if(inputMethodComposing)return;if(window.searchInput){reader.search(text);endInput()}else if(reader.command(text)){if(reader.mode === "command")endInput();else text=""}} + Keys.onEscapePressed:event=>{if(!inputMethodComposing){endInput();event.accepted=true}} + } + } + } + FileDialog { id:fileDialog;title:qsTr("文書を開く");nameFilters:[qsTr("文書 (*.pdf *.html *.htm *.xhtml *.zip *.epub)"),qsTr("すべて (*)")];onAccepted:{reader.mode="normal";reader.open(selectedFile);endTemporary()}onRejected:endTemporary() } + FolderDialog { id:folderDialog;title:qsTr("文書フォルダーを指定");onAccepted:{reader.mode="normal";reader.setHtmlRoot(selectedFolder);endTemporary()}onRejected:endTemporary() } + Dialog { + id:passwordDialog;anchors.centerIn:parent;modal:true;standardButtons:Dialog.Ok|Dialog.Cancel + TextField{id:passwordField;width:300;echoMode:TextInput.Password;Accessible.name:qsTr("PDFパスワード");onAccepted:if(!inputMethodComposing)passwordDialog.accept()} + onAccepted:{reader.mode="normal";let value=passwordField.text;passwordField.text="";reader.password(value);endTemporary()} + onRejected:{passwordField.text="";reader.mode="normal";reader.cancel();endTemporary()} + } + Dialog { + id:entryDialog;objectName:"entryDialog";anchors.centerIn:parent;modal:true;title:qsTr("入口HTMLを選択");width:Math.min(600,window.width-48);height:Math.min(440,window.height-60);standardButtons:Dialog.Ok|Dialog.Cancel + ListView{id:candidateList;objectName:"entryCandidates";anchors.fill:parent;clip:true;currentIndex:0;delegate:ItemDelegate{required property string modelData;required property int index;width:ListView.view.width;text:modelData;highlighted:candidateList.currentIndex===index;onClicked:{candidateList.currentIndex=index;entryDialog.accept()}}Keys.onReturnPressed:entryDialog.accept()} + onAccepted:{reader.mode="normal";if(candidateList.currentIndex>=0)reader.chooseEntry(candidateList.model[candidateList.currentIndex]);endTemporary()} + onRejected:{reader.cancel();endTemporary()} + } + Dialog { + id:externalDialog;property url destination;anchors.centerIn:parent;modal:true;title:qsTr("外部ブラウザーで開く");width:Math.min(640,window.width-48);standardButtons:Dialog.Open|Dialog.Cancel + Label{width:parent.width;text:externalDialog.destination.toString();textFormat:Text.PlainText;wrapMode:Text.WrapAnywhere} + onAccepted:{reader.openExternal(destination);endTemporary()} + onRejected:endTemporary() + } + Dialog { + id:infoDialog;objectName:"infoDialog";anchors.centerIn:parent;modal:true;title:qsTr("文書情報") + width:Math.min(640,window.width-48);height:Math.min(implicitHeight,window.height-60) + standardButtons:Dialog.Close + contentItem:ScrollView { + id:infoScroll;objectName:"infoScroll";clip:true + contentWidth:availableWidth;implicitHeight:infoText.implicitHeight + Label{id:infoText;objectName:"infoText";width:infoScroll.availableWidth;wrapMode:Text.WrapAnywhere;textFormat:Text.PlainText} + } + onClosed:endTemporary() + } + Dialog { + id:historyDialog;objectName:"historyDialog";anchors.centerIn:parent;modal:true + title:qsTr("履歴と保存位置を消去");width:Math.min(580,window.width-48);standardButtons:Dialog.Ok|Dialog.Cancel + Label { id:historyConfirmation;objectName:"historyConfirmation";width:parent.width;wrapMode:Text.Wrap } + onAboutToShow:historyConfirmation.text=qsTr("%1 件の履歴と読書位置を消去します。原本は変更されません。").arg(reader.historyCount()) + onAccepted:{reader.clearHistory();endTemporary()} + onRejected:endTemporary() + } + Dialog { + id:helpDialog;anchors.centerIn:parent;modal:true;title:qsTr("操作一覧");width:Math.min(760,window.width-48);height:Math.min(620,window.height-60);standardButtons:Dialog.Close + ColumnLayout { + anchors.fill:parent + Label{text:qsTr("Vim風の閲覧操作です。q単押しでは終了しません。:q で終了します。");wrapMode:Text.Wrap;Layout.fillWidth:true} + ListView { + Layout.fillWidth:true;Layout.fillHeight:true;clip:true;model:reader.settings.keybindings + delegate:Label{required property var modelData;width:ListView.view.width;text:modelData.keys.join(" ")+" "+(window.commandNames[modelData.command] || modelData.command)+" ["+window.contextName(modelData.context)+"]";textFormat:Text.PlainText;font.family:"monospace";height:26;elide:Text.ElideRight} + ScrollBar.vertical:ScrollBar{} + } + } + onClosed:endTemporary() + } + Shortcut { sequence:"?";enabled:helpDialog.visible;onActivated:helpDialog.close() } + Component.onCompleted:{updateToc();focusContent()} +} diff --git a/qml/WebPane.qml b/qml/WebPane.qml new file mode 100644 index 0000000..64152f6 --- /dev/null +++ b/qml/WebPane.qml @@ -0,0 +1,594 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtWebEngine + +Item { + id: pane + clip: true + property var views: ({}) + property var activeView: null + property var pendingView: null + property string searchQuery: "" + + property var companionView: null + property var spreadSelection: ({}) + property string failedCompanionUrl: "" + property bool arrangingPages: false + property real spreadZoom: 1 + property bool spreadFitWidth: false + property real pagePanX: 0 + property real pagePanY: 0 + property real maxPagePanX: 0 + property real maxPagePanY: 0 + + // Navigation requests expose a display string, while signal arguments and + // QML url properties can use percent encoding. Compare the same URL type. + function canonicalUrl(value) { return String(Qt.url(value)) } + + // A spread always follows spine order; CSS direction does not reorder pages. + // Unspecified first pages occupy the trailing slot (the conventional cover). + function spreadFor(spine, current, rtl, landscape) { + var leading = rtl ? "right" : "left", trailing = rtl ? "left" : "right" + var groups = [], pending = null, first = true + function flush() { if (pending) { groups.push(pending); pending = null } } + for (var i = 0; i < spine.length; ++i) { + var row = spine[i] + if (row.linear === false) continue + if (row.missing || !row.href) { flush(); continue } + var policy = row.renditionSpread || "auto" + var eligible = row.layout === "fixed" && policy !== "none" && (policy === "both" || landscape) + if (!eligible || row.spread === "center") { + flush(); groups.push({center:i}); first = false; continue + } + var side = row.spread === "left" || row.spread === "right" ? row.spread : (pending ? trailing : (first ? trailing : leading)) + first = false + if (side === leading) { flush(); pending = {}; pending[side] = i } + else { var group = pending || {}; group[side] = i; groups.push(group); pending = null } + } + flush() + for (var j = 0; j < groups.length; ++j) + if (groups[j].left === current || groups[j].right === current || groups[j].center === current) return groups[j] + return {center:current} // nonlinear items are independently navigable. + } + function resourcePath(url) { + return decodeURIComponent(String(url).replace(/^doc:\/\/[^/]+\//, "").split("#")[0]) + } + function retireView(view) { + if (!view) return + resetHeartbeat(view) + view.alive = false + view.stop() + view.visible = false + if (typeof reader.registerRenderer === "function") reader.registerRenderer(view.documentToken, 0, view.rendererSlot) + view.destroy() + } + function closeCompanion() { + var old = companionView + companionView = null + if (old) retireView(old) + } + function synchronizeSpread() { + var view = activeView + if (!view || !view.alive || !view.documentReady) return + var opts = optionsFor(view), spine = opts.spine || [], current = -1 + for (var i = 0; i < spine.length; ++i) + if (resourcePath(view.url) === decodeURIComponent(String(spine[i].href || "").split("#")[0])) { current = i; break } + spreadSelection = current >= 0 ? spreadFor(spine, current, !!opts.rtl, width > height) : ({center:-1}) + var other = spreadSelection.left === current ? spreadSelection.right : spreadSelection.left + if (other === undefined || other === current || !view.fixedLayout || pendingView) { + closeCompanion(); arrangePages(); return + } + var url = "doc://" + view.documentToken + "/" + spine[other].href + if (canonicalUrl(url) === canonicalUrl(failedCompanionUrl) || !reader.navigationAllowed(url, WebEngineView.TypedNavigation, view.documentToken)) { + closeCompanion(); arrangePages(); return + } + if (!companionView) { + companionView = webComponent.createObject(pane, {profile:view.profile, documentToken:view.documentToken, + rendererSlot:1, isCompanion:true, permittedUrl:url}) + if (!companionView) { arrangePages(); return } + companionView.url = url + } else if (canonicalUrl(companionView.url) !== canonicalUrl(url)) { + companionView.documentReady = false + companionView.pagePlaced = false + companionView.fixedViewport = ({}) + companionView.permittedUrl = url + companionView.url = url + } + arrangePages() + } + function arrangePages() { + if (arrangingPages || !activeView) return + arrangingPages = true + var view = activeView, other = companionView + var a = view.fixedViewport, b = other ? other.fixedViewport : ({}) + if (!view.fixedLayout || !(a.width > 0) || !(a.height > 0)) { + view.x = 0; view.y = 0; view.width = width; view.height = height; view.scale = 1 + } else { + var currentLeft = spreadSelection.left !== undefined && resourcePath(view.url) === decodeURIComponent(String((optionsFor(view).spine || [])[spreadSelection.left].href).split("#")[0]) + var paired = other && other.documentReady && b.width > 0 && b.height > 0 + var slots = spreadSelection.center === undefined + var leftSize = paired && !currentLeft ? b : a, rightSize = paired && currentLeft ? b : a + var totalWidth = slots ? leftSize.width + rightSize.width : a.width + var totalHeight = paired ? Math.max(a.height, b.height) : a.height + var factor = (spreadFitWidth ? width / totalWidth : Math.min(width / totalWidth, height / totalHeight)) * spreadZoom + maxPagePanX = Math.max(0, totalWidth * factor - width) + maxPagePanY = Math.max(0, totalHeight * factor - height) + pagePanX = Math.max(0, Math.min(maxPagePanX, pagePanX)) + pagePanY = Math.max(0, Math.min(maxPagePanY, pagePanY)) + var origin = Math.max(0, (width - totalWidth * factor) / 2) - pagePanX + function place(page, size, left) { + page.zoomFactor = Math.max(0.25, Math.min(5, factor)) + page.scale = factor / page.zoomFactor + page.width = size.width * page.zoomFactor; page.height = size.height * page.zoomFactor + page.x = origin + left * factor; page.y = Math.max(0, (height - size.height * factor) / 2) - pagePanY + page.pagePlaced = true + } + place(view, a, slots && !currentLeft ? leftSize.width : 0) + if (paired) place(other, b, currentLeft ? leftSize.width : 0) + } + arrangingPages = false + } + function collectResourceIssues(view) { + if (typeof reader.webResourceIssues !== "function") return + run(view, "resource.issues", {}, function(value) { if (value) reader.webResourceIssues(view.documentToken, value) }) + } + WheelHandler { + target: null + enabled: !!pane.activeView && pane.activeView.fixedLayout + onWheel: function(event) { + pane.pagePanX -= event.angleDelta.x / 2 + pane.pagePanY -= event.angleDelta.y / 2 + pane.arrangePages() + } + } + onWidthChanged: Qt.callLater(synchronizeSpread) + onHeightChanged: Qt.callLater(synchronizeSpread) + + function focusContent() { + if (activeView) activeView.forceActiveFocus() + } + function optionsFor(view) { + var opts = typeof reader.webOptions === "function" ? reader.webOptions(view.documentToken) : ({}) + var reading = opts.reading || reader.settings.reading || ({}) + var currentPath = decodeURIComponent(String(view.url).replace(/^doc:\/\/[^/]+\//, "").split("#")[0]) + var spine = opts.spine || [] + for (var i = 0; i < spine.length; ++i) { + if (decodeURIComponent(String(spine[i].href || "").split("#")[0]) === currentPath) { opts.fixed = spine[i].layout === "fixed"; break } + } + opts.publisherStyle = reading.use_publisher_style !== false + opts.fontSize = reading.font_size || 18 + opts.lineHeight = reading.line_height || 1.6 + return opts + } + function resetHeartbeat(view) { + if (view.heartbeatPending && typeof reader.finishRendererProbe === "function") + reader.finishRendererProbe(view.documentToken, view.rendererSlot, view.heartbeatPending) + view.heartbeatPending = 0 + } + function trackRenderer(view, pid) { + if (!view || !view.alive || view.registeredRendererPid === pid || typeof reader.registerRenderer !== "function") return + resetHeartbeat(view) + view.registeredRendererPid = pid + reader.registerRenderer(view.documentToken, pid, view.rendererSlot) + } + function heartbeat(view) { + if (!view || !view.alive) return + // Qt can expose the initial process through the getter before delivering + // a PID notification. Reconcile once, without resetting healthy probes. + trackRenderer(view, view.renderProcessPid) + if (!view || !view.alive || view.heartbeatPending || view.renderProcessPid <= 0 || + typeof reader.beginRendererProbe !== "function") return + var probe = reader.beginRendererProbe(view.documentToken, view.rendererSlot) + if (!probe) return + view.heartbeatPending = probe + var serial = view.documentSerial, token = view.documentToken + // The response requires the renderer main thread, including while the + // document is loading. Document JavaScript remains disabled. + view.runJavaScript(String(probe), WebEngineScript.ApplicationWorld, function(result) { + if (!view || !view.alive || view.documentSerial !== serial || view.documentToken !== token || + view.heartbeatPending !== probe || result !== probe) return + reader.finishRendererProbe(token, view.rendererSlot, probe) + view.heartbeatPending = 0 + // Polling work is queued only after the previous heartbeat returns; + // a stalled renderer cannot accumulate periodic DOM operations. + if (view === activeView) updateLocation(view) + collectResourceIssues(view) + }) + } + function run(view, id, args, callback) { + if (!view || !view.alive || !view.documentReady) return + var serial = view.documentSerial + var token = view.documentToken + // Only JSON data is interpolated; all executable source is app-owned. + var data = JSON.stringify({id: id, args: args || ({})}) + var script = "(function(data){return globalThis.__docviewReader ? globalThis.__docviewReader.command(data.id,data.args) : null;})(" + data + ")" + view.runJavaScript(script, WebEngineScript.ApplicationWorld, function(result) { + if (!view || !view.alive || view.documentSerial !== serial || view.documentToken !== token) return + if (callback) callback(result) + }) + } + function updateLocation(view) { + run(view, "location", {}, function(value) { + if (!value || typeof value.url !== "string") return + value.zoom = (view.fixedLayout ? spreadZoom : view.zoomFactor) * 100 + if (view.fixedLayout) { + value.fitWidth = spreadFitWidth + value.panX = maxPagePanX > 0 ? pagePanX / maxPagePanX : 0 + value.panY = maxPagePanY > 0 ? pagePanY / maxPagePanY : 0 + } + view.lastLocation = value + if (view === activeView) reader.webLocation(view.documentToken, value) + }) + } + function configureNavigation(view) { + if (!view || view !== activeView || typeof reader.webNavigationTargets !== "function") return + var args = reader.webNavigationTargets(view.documentToken, view.url) + run(view, "navigation.configure", args, function() { updateLocation(view) }) + } + function collectIndex(view) { + run(view, "index", {}, function(value) { + if (!value) return + if (view === activeView) reader.webIndex(view.documentToken, value) + if (view === activeView && value.truncated) reader.notify(qsTr("見出し一覧は先頭2,000件を表示しています")) + view.fixedViewport = value.fixedViewport || ({}) + if (view.fixedLayout) arrangePages() + if (view === activeView) configureNavigation(view) + }) + } + function fitFixed(view, widthOnly) { + var size = view.fixedViewport + if (!view.fixedLayout || !(size.width > 0) || !(size.height > 0)) return + var factor = widthOnly ? view.width / size.width : Math.min(view.width / size.width, view.height / size.height) + view.zoomFactor = Math.max(0.25, Math.min(5, factor)) + } + function applyLayout(view, callback) { + if (!view || !view.documentReady) return + var opts = optionsFor(view) + var wasFixed = view.fixedLayout + view.fixedLayout = !!opts.fixed + if (wasFixed && !view.fixedLayout) view.zoomFactor = 1 + run(view, "layout", opts, function(result) { + if (!result) return + view.fixedViewport = result.fixedViewport || ({}) + if (view === activeView) synchronizeSpread() + else if (view.isCompanion) arrangePages() + if (callback) callback() + }) + } + function restoreAfterLayout(view, saved, restoreDisplay, applied) { + if (!saved || !view || !view.alive) return + if (restoreDisplay) { + if (typeof saved.zoom === "number" && isFinite(saved.zoom) && saved.zoom >= 25 && saved.zoom <= 500) { + if (view.fixedLayout) spreadZoom = saved.zoom / 100 + else view.zoomFactor = saved.zoom / 100 + } + if (view.fixedLayout) { + spreadFitWidth = saved.fitWidth === true + arrangePages() + pagePanX = (saved.panX || 0) * maxPagePanX + pagePanY = (saved.panY || 0) * maxPagePanY + arrangePages() + } + } + run(view, "location.restore", saved, function(result) { + if (result && result.approximate) reader.notify(qsTr("E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました")) + if (applied) applied() + updateLocation(view) + }) + } + function applyBoundary(view) { + if (!view || !view.documentReady || !view.pendingBoundary) return + var boundary = view.pendingBoundary + var request = view.pendingBoundaryRequest + view.pendingBoundary = "" + if (view !== activeView || request !== view.commandSerial) return + run(view, boundary, {}, function() { + if (view !== activeView || request !== view.commandSerial) return + reader.webApplied(view.documentToken, boundary, request) + updateLocation(view) + }) + } + function command(id, args) { + var view = activeView + if (!view) return + var request = ++view.commandSerial + var operation = id === "navigate" && args.boundary ? args.boundary : id + reader.webStarted(view.documentToken, operation, request) + view.cancelResizeRestore() + view.restorePending = null // A newer user command supersedes a deferred restoration. + view.pendingBoundary = "" + if (id === "navigate") { + view.pendingBoundary = ["nav.document_start", "nav.document_end", "heading.first", "heading.last"].indexOf(args.boundary) >= 0 ? args.boundary : "" + view.pendingBoundaryRequest = request + view.permittedUrl = String(args.url) + var sameResource = canonicalUrl(view.url).split("#")[0] === canonicalUrl(args.url).split("#")[0] + // A following CFI restoration supplies the exact position. Avoid + // an identical-URL reload or fragment scroll overwriting that jump. + if (sameResource && args.preserveLocation) { view.permittedUrl = ""; return } + // Assigning an identical URL may reload the page. Keep an endpoint + // jump inside this DOM so a new load cannot consume its callback. + if (sameResource && view.documentReady && view.pendingBoundary) { + view.permittedUrl = "" + applyLayout(view, function() { applyBoundary(view) }) + return + } + if (!sameResource) { view.documentReady = false; failedCompanionUrl = ""; pagePanX = 0; pagePanY = 0; closeCompanion() } + view.url = args.url + return + } + if (view.fixedLayout && id.indexOf("scroll.") === 0) { + var distance = id.indexOf("half_") >= 0 ? height / 2 : id.indexOf("page_") >= 0 ? height * 0.9 : 48 + if (id === "scroll.left") pagePanX -= 48 + else if (id === "scroll.right") pagePanX += 48 + else if (id.indexOf("up") >= 0) pagePanY -= distance + else if (id.indexOf("down") >= 0) pagePanY += distance + arrangePages() + return + } + if (id === "location.restore") { + if (!view.documentReady) view.restorePending = args + else applyLayout(view, function() { + if (view === activeView && request === view.commandSerial) + restoreAfterLayout(view, args, true, function() { + if (view === activeView && request === view.commandSerial) reader.webApplied(view.documentToken, id, request) + }) + }) + return + } + if (id === "layout") { applyLayout(view); return } + if (id === "zoom.in" || id === "zoom.out" || id === "zoom.fit_width") { + if (view.fixedLayout) { + spreadFitWidth = id === "zoom.fit_width" + spreadZoom = spreadFitWidth ? 1 : Math.max(0.25, Math.min(5, spreadZoom * (id === "zoom.in" ? 1.15 : 1 / 1.15))) + arrangePages() + updateLocation(view) + return + } + var saved = view.lastLocation + if (id === "zoom.fit_width") { + if (view.fixedLayout) fitFixed(view, true) + else view.zoomFactor = 1 + } else view.zoomFactor = Math.max(0.25, Math.min(5, view.zoomFactor * (id === "zoom.in" ? 1.15 : 1 / 1.15))) + Qt.callLater(function() { + if (view !== activeView || !view.alive || request !== view.commandSerial) return + if (saved) restoreAfterLayout(view, saved) + else updateLocation(view) + }) + return + } + if (id === "search.cancel") { + ++view.searchRevision + view.findText("") + return + } + if (id === "search" || id === "search.next" || id === "search.previous") { + if (id === "search") searchQuery = String(args.query || "").slice(0, 4096) + var serial = view.documentSerial + var revision = ++view.searchRevision + view.findText(searchQuery, id === "search.previous" ? WebEngineView.FindBackward : 0, function(found) { + if (!view || !view.alive || view !== activeView || view.documentSerial !== serial || view.searchRevision !== revision) return + if (!found) reader.notify(qsTr("一致する文字がありません")) + updateLocation(view) + }) + return + } + run(view, id, args, function(result) { + if (view !== activeView || request !== view.commandSerial) return + if (typeof reader.webApplied === "function") reader.webApplied(view.documentToken, id, request) + if (id === "link.activate" && result) { + if (result.url && reader.navigationAllowed(result.url, WebEngineView.LinkClickedNavigation, view.documentToken)) + pane.command("navigate", {url: result.url}) + else if (result.blocked) reader.notify(qsTr("E_RESOURCE_BLOCKED: このリンクは開けません")) + else if (result.empty) reader.notify(qsTr("Tabでリンクを選択してください")) + } else if ((id === "link.next" || id === "link.previous") && result && result.empty) + reader.notify(qsTr("この文書にはリンクがありません")) + if (result && result.boundary) { + if (reader.format === "epub") { + reader.execute(result.boundary > 0 ? "nav.chapter_next" : "nav.chapter_previous", + {headingBoundary: result.boundary > 0 ? "heading.first" : "heading.last"}) + } else reader.notify(result.empty ? qsTr("この文書には見出しがありません") : qsTr("見出しの端です")) + } + if (id === "location.restore" && result && result.approximate) reader.notify(qsTr("E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました")) + updateLocation(view) + }) + } + Connections { + target: reader + function onStageWeb(profile, url, token) { + closeCompanion() + var view = webComponent.createObject(pane, {profile: profile, documentToken: token, permittedUrl: String(url)}) + if (!view) { reader.webLoaded(token, false); return } + views[token] = view + pendingView = view + view.url = url + } + function onActivateWeb(token) { + if (activeView) { activeView.isCurrent = false; activeView.enabled = false } + activeView = views[token] || null + if (activeView) { + spreadZoom = 1; spreadFitWidth = false; pagePanX = 0; pagePanY = 0 + activeView.isCurrent = true + activeView.enabled = true + if (pendingView === activeView) pendingView = null + Qt.callLater(function() { if (activeView && activeView.documentToken === token) { applyLayout(activeView); collectIndex(activeView) } }) + } + } + function onDisposeWeb(token) { + var view = views[token] + if (!view) return + if (companionView && companionView.documentToken === token) closeCompanion() + if (activeView === view) activeView = null + if (pendingView === view) pendingView = null + delete views[token] + retireView(view) + if (activeView && !pendingView) Qt.callLater(synchronizeSpread) + } + function onWebCommand(id, args) { pane.command(id, args) } + function onNavigationChanged() { pane.configureNavigation(pane.activeView) } + function onSettingsChanged() { + if (!activeView) return + var view = activeView + run(view, "location", {}, function(saved) { + applyLayout(view, function() { restoreAfterLayout(view, saved) }) + }) + } + } + Timer { + interval: 400 + running: !!pane.activeView || !!pane.pendingView || !!pane.companionView + repeat: true + onTriggered: { + pane.heartbeat(pane.activeView) + pane.heartbeat(pane.pendingView) + pane.heartbeat(pane.companionView) + } + } + Component { + id: webComponent + WebEngineView { + id: web + width: pane.width + height: pane.height + transformOrigin: Item.TopLeft + property int rendererSlot: 0 + property bool isCompanion: false + property bool pagePlaced: false + property string documentToken: "" + property string permittedUrl: "" + property bool alive: true + property bool isCurrent: false + property bool documentReady: false + property int documentSerial: 0 + property double heartbeatPending: 0 + property double registeredRendererPid: 0 + property int searchRevision: 0 + property double commandSerial: 0 + property double pendingBoundaryRequest: 0 + property var lastLocation: null + property var resizeLocation: null + property var restorePending: null + property var fixedViewport: ({}) + property bool fixedLayout: false + property string pendingBoundary: "" + opacity: isCurrent || (isCompanion && documentReady && pagePlaced) ? 1 : 0 + z: isCurrent || isCompanion ? 1 : 0 + enabled: isCurrent || (isCompanion && pagePlaced) + audioMuted: true + backgroundColor: "white" + webChannel: null + activeFocusOnPress: true + settings.javascriptEnabled: false + settings.javascriptCanOpenWindows: false + settings.javascriptCanAccessClipboard: false + settings.javascriptCanPaste: false + settings.localStorageEnabled: false + settings.localContentCanAccessRemoteUrls: false + settings.localContentCanAccessFileUrls: false + settings.hyperlinkAuditingEnabled: false + settings.pluginsEnabled: false + settings.fullScreenSupportEnabled: false + settings.screenCaptureEnabled: false + settings.webGLEnabled: false + settings.accelerated2dCanvasEnabled: false + settings.autoLoadIconsForPage: false + settings.touchIconsEnabled: false + settings.allowRunningInsecureContent: false + settings.allowGeolocationOnInsecureOrigins: false + settings.allowWindowActivationFromJavaScript: false + settings.dnsPrefetchEnabled: false + settings.pdfViewerEnabled: false + settings.navigateOnDropEnabled: false + settings.playbackRequiresUserGesture: true + settings.webRTCPublicInterfacesOnly: true + settings.unknownUrlSchemePolicy: WebEngineSettings.DisallowUnknownUrlSchemes + settings.focusOnNavigationEnabled: false + userScripts.collection: [{ + name: "DocView Reader", + injectionPoint: WebEngineScript.DocumentCreation, + worldId: WebEngineScript.ApplicationWorld, + runsOnSubFrames: false, + sourceCode: reader.script("reader") + }] + onActiveFocusChanged: if (activeFocus) reader.context = "content" + onNavigationRequested: function(request) { + var type = request.navigationType + var known = type === WebEngineView.LinkClickedNavigation || + ((type === WebEngineView.OtherNavigation || type === WebEngineView.TypedNavigation) && pane.canonicalUrl(request.url) === pane.canonicalUrl(permittedUrl)) + if (isCompanion && type === WebEngineView.LinkClickedNavigation) { + request.action = WebEngineView.IgnoreRequest + if (reader.navigationAllowed(request.url, type, documentToken)) pane.command("navigate", {url:request.url}) + return + } + request.action = known && reader.navigationAllowed(request.url, type, documentToken) + ? WebEngineView.AcceptRequest : WebEngineView.IgnoreRequest + if (known && type !== WebEngineView.LinkClickedNavigation) permittedUrl = "" + } + onLoadingChanged: function(info) { + if (info.status === WebEngineView.LoadStartedStatus) { pane.resetHeartbeat(web); ++documentSerial; documentReady = false } + else if (info.status === WebEngineView.LoadSucceededStatus) { + documentReady = true + if (!isCompanion) { + reader.webLoaded(documentToken, true) + if (typeof reader.webDocumentLoaded === "function") reader.webDocumentLoaded(documentToken, url) + } + pane.applyLayout(web, function() { + pane.applyBoundary(web) + if (restorePending) { var saved = restorePending; restorePending = null; pane.restoreAfterLayout(web, saved, true) } + }) + pane.collectIndex(web) + pane.collectResourceIssues(web) + } else if (info.status === WebEngineView.LoadFailedStatus && alive) { + if (isCompanion) { pane.failedCompanionUrl = String(url); pane.closeCompanion(); pane.arrangePages() } + else reader.webLoaded(documentToken, false) + } + } + onNewWindowRequested: function(request) { + if (request.userInitiated && reader.navigationAllowed(request.requestedUrl, WebEngineView.LinkClickedNavigation, documentToken)) { + pane.command("navigate", {url:request.requestedUrl}) + } + } + onJavaScriptConsoleMessage: function(level, message, lineNumber, sourceId) { /* Document text is not written to application logs. */ } + onPermissionRequested: function(permissionRequest) { permissionRequest.deny() } + onFeaturePermissionRequested: function(origin, feature) { grantFeaturePermission(origin, feature, false) } + onFileDialogRequested: function(request) { request.accepted = true; request.dialogReject() } + onColorDialogRequested: function(request) { request.accepted = true; request.dialogReject() } + onAuthenticationDialogRequested: function(request) { request.accepted = true; request.dialogReject() } + onJavaScriptDialogRequested: function(request) { request.accepted = true; request.dialogReject() } + onContextMenuRequested: function(request) { request.accepted = true } + onFullScreenRequested: function(request) { request.reject() } + onCertificateError: function(error) { error.rejectCertificate() } + onQuotaRequested: function(request) { request.reject() } + onRegisterProtocolHandlerRequested: function(request) { request.reject() } + onFileSystemAccessRequested: function(request) { request.reject() } + onSelectClientCertificate: function(selection) { selection.selectNone() } + onDesktopMediaRequested: function(request) { request.cancel() } + onWebAuthUxRequested: function(request) { request.cancel() } + onRenderProcessTerminated: function(status, code) { + if (!alive) return + documentReady = false + if (isCurrent || isCompanion) reader.renderStopped() + else reader.webLoaded(documentToken, false) + } + onRenderProcessPidChanged: function(pid) { + pane.trackRenderer(web, pid) + } + Timer { + id: resizeDelay + interval: 150 + onTriggered: { + if (web.fixedLayout) pane.arrangePages() + else pane.restoreAfterLayout(web, web.resizeLocation) + web.resizeLocation = null + } + } + function preserveResizeLocation() { + if (!documentReady || pane.arrangingPages) return + if (!resizeDelay.running) resizeLocation = lastLocation + resizeDelay.restart() + } + function cancelResizeRestore() { resizeDelay.stop(); resizeLocation = null } + onWidthChanged: preserveResizeLocation() + onHeightChanged: preserveResizeLocation() + } + } +} diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..c52fa8b --- /dev/null +++ b/resources.qrc @@ -0,0 +1 @@ +qml/Main.qmlqml/WebPane.qmlresources/reader.js diff --git a/resources/config.example.toml b/resources/config.example.toml new file mode 100644 index 0000000..afc9edf --- /dev/null +++ b/resources/config.example.toml @@ -0,0 +1,38 @@ +schema_version = 1 + +[ui] +theme = "dark" +status_bar = true +toc_visible = false +pages_visible = false +font_size = 14 +panel_width = 280 + +[pdf] +zoom_mode = "fit-width" +zoom_percent = 100 +preserve_colors = true + +[reading] +use_publisher_style = true +font_size = 18 +line_height = 1.6 + +[keys] +sequence_timeout_ms = 800 + +[performance] +tile_cache_mib = 256 +prefetch_pages = 1 + +[privacy] +remember_position = true +recent_documents = 20 +store_text_anchor = false + +# Example: replace a binding. Remove the leading # characters to enable it. +# [[keybindings]] +# mode = "normal" +# context = "content" +# keys = ["j"] +# command = "scroll.down" diff --git a/resources/i18n/docview_ja.ts b/resources/i18n/docview_ja.ts new file mode 100644 index 0000000..43876db --- /dev/null +++ b/resources/i18n/docview_ja.ts @@ -0,0 +1,2810 @@ + + + + + Archive + + + ZIPの索引が見つかりません。 + ZIPの索引が見つかりません。 + + + + ZIPの索引が不正、または分割アーカイブです。 + ZIPの索引が不正、または分割アーカイブです。 + + + + + ZIP64の索引位置が不正です。 + ZIP64の索引位置が不正です。 + + + + + ZIP64の索引が不正です。 + ZIP64の索引が不正です。 + + + + + アーカイブ内の項目数が上限を超えています。 + アーカイブ内の項目数が上限を超えています。 + + + + ZIPの索引がファイルの範囲を超えています。 + ZIPの索引がファイルの範囲を超えています。 + + + + ZIPの索引が途中で切れています。 + ZIPの索引が途中で切れています。 + + + + アーカイブ内のパスが長さの上限を超えています。 + アーカイブ内のパスが長さの上限を超えています。 + + + + アーカイブ内のパスにNUL文字が含まれています。 + アーカイブ内のパスにNUL文字が含まれています。 + + + + ZIPの拡張フィールドが途中で切れています。 + ZIPの拡張フィールドが途中で切れています。 + + + + + ZIPの拡張フィールドが不正です。 + ZIPの拡張フィールドが不正です。 + + + + アーカイブ内のリンクは許可されていません。 + アーカイブ内のリンクは許可されていません。 + + + + ZIPの索引の長さが不正です。 + ZIPの索引の長さが不正です。 + + + + 文書構造のXMLがサイズの上限を超えています。 + 文書構造のXMLがサイズの上限を超えています。 + + + + XMLの実体参照は許可されていません。 + XMLの実体参照は許可されていません。 + + + + XMLの実体宣言は許可されていません。 + XMLの実体宣言は許可されていません。 + + + + 文書構造のXMLが階層数または要素数の上限を超えています。 + 文書構造のXMLが階層数または要素数の上限を超えています。 + + + + 文書構造のXMLが不正です。 + 文書構造のXMLが不正です。 + + + + アーカイブを読み取れません。 + アーカイブを読み取れません。 + + + + アーカイブには位置を指定して読み取れる読取り専用ファイルが必要です。 + アーカイブには位置を指定して読み取れる読取り専用ファイルが必要です。 + + + + ZIPの索引に不整合があります。 + ZIPの索引に不整合があります。 + + + + アーカイブ内の項目情報が不正です。 + アーカイブ内の項目情報が不正です。 + + + + アーカイブ内の項目のパスが安全ではありません。 + アーカイブ内の項目のパスが安全ではありません。 + + + + アーカイブ内の属性を読み取れません。 + アーカイブ内の属性を読み取れません。 + + + + アーカイブ内のリンクや特殊ファイルは許可されていません。 + アーカイブ内のリンクや特殊ファイルは許可されていません。 + + + + パスワードで保護されたアーカイブには対応していません。 + パスワードで保護されたアーカイブには対応していません。 + + + + アーカイブの展開量が上限を超えています。 + アーカイブの展開量が上限を超えています。 + + + + アーカイブ内のディレクトリーにファイルデータがあります。 + アーカイブ内のディレクトリーにファイルデータがあります。 + + + + アーカイブ内の項目名が重複しています。 + アーカイブ内の項目名が重複しています。 + + + + アーカイブ内のファイル名とディレクトリー名が重複しています。 + アーカイブ内のファイル名とディレクトリー名が重複しています。 + + + + アーカイブ内の資源を利用できません。 + アーカイブ内の資源を利用できません。 + + + + アーカイブ内の資源がサイズの上限を超えています。 + アーカイブ内の資源がサイズの上限を超えています。 + + + + アーカイブ内の資源は同時に複数展開できません。 + アーカイブ内の資源は同時に複数展開できません。 + + + + + 資源の展開が時間制限を超えました。 + 資源の展開が時間制限を超えました。 + + + + アーカイブ内の資源を展開できません。 + アーカイブ内の資源を展開できません。 + + + + アーカイブ内の資源のCRCまたは展開結果の検証に失敗しました。 + アーカイブ内の資源のCRCまたは展開結果の検証に失敗しました。 + + + + 展開した資源が宣言されたサイズまたは上限を超えています。 + 展開した資源が宣言されたサイズまたは上限を超えています。 + + + + 資源の実読取量に対する展開量が倍率の上限を超えています。 + 資源の実読取量に対する展開量が倍率の上限を超えています。 + + + + 資源を一時保存先へ書き込めません。 + 資源を一時保存先へ書き込めません。 + + + + アーカイブ内の資源の検証に失敗しました。 + アーカイブ内の資源の検証に失敗しました。 + + + + 展開した資源の長さが一致しません。 + 展開した資源の長さが一致しません。 + + + + + + アーカイブ内の資源が見つかりません。 + アーカイブ内の資源が見つかりません。 + + + + + 文書全体の展開量が上限を超えています。 + 文書全体の展開量が上限を超えています。 + + + + 一時資源の保存ルートを利用できません。 + 一時資源の保存ルートを利用できません。 + + + + 一時資源のディレクトリーを作成できません。 + 一時資源のディレクトリーを作成できません。 + + + + 一時資源のディレクトリーが安全ではありません。 + 一時資源のディレクトリーが安全ではありません。 + + + + 一時資源の名前が使用済み、またはファイルを作成できません。 + 一時資源の名前が使用済み、またはファイルを作成できません。 + + + + この環境では安全なアーカイブ展開を利用できません。 + この環境では安全なアーカイブ展開を利用できません。 + + + + アーカイブが開かれていません。 + アーカイブが開かれていません。 + + + + アーカイブ内に入口となるHTMLがありません。 + アーカイブ内に入口となるHTMLがありません。 + + + + EPUBのメディア型が不明、または不正です。 + EPUBのメディア型が不明、または不正です。 + + + + EPUBのコンテナーのルートが不正です。 + EPUBのコンテナーのルートが不正です。 + + + + EPUBのコンテナーにパッケージがありません。 + EPUBのコンテナーにパッケージがありません。 + + + + EPUBの表示版の数が上限を超えています。 + EPUBの表示版の数が上限を超えています。 + + + + EPUBのパッケージのパスが不正です。 + EPUBのパッケージのパスが不正です。 + + + + この版のEPUBパッケージには対応していません。 + この版のEPUBパッケージには対応していません。 + + + + EPUBのパッケージ構造に不足があります。 + EPUBのパッケージ構造に不足があります。 + + + + EPUBのmanifestに重複または不正な識別子があります。 + EPUBのmanifestに重複または不正な識別子があります。 + + + + EPUBの読書順序の項目数が上限を超えています。 + EPUBの読書順序の項目数が上限を超えています。 + + + + EPUBの読書順序の識別子が不正です。 + EPUBの読書順序の識別子が不正です。 + + + + EPUBの読書順序に表示できる本文がありません。 + EPUBの読書順序に表示できる本文がありません。 + + + + このEPUBの暗号化方式には対応していません。 + このEPUBの暗号化方式には対応していません。 + + + + EPUBの標準的なフォント難読化のみ対応しています。 + EPUBの標準的なフォント難読化のみ対応しています。 + + + + EPUBのナビゲーション索引が項目数の上限を超えています。 + EPUBのナビゲーション索引が項目数の上限を超えています。 + + + + ArchiveWorker + + + アーカイブの要求が別の文書に属しています。 + アーカイブの要求が別の文書に属しています。 + + + + アーカイブの索引範囲が不正です。 + アーカイブの索引範囲が不正です。 + + + + アーカイブの項目情報の範囲が不正です。 + アーカイブの項目情報の範囲が不正です。 + + + + このアーカイブ操作は利用できません。 + このアーカイブ操作は利用できません。 + + + + Commands + + + 不明なコマンドです: %1 + 不明なコマンドです: %1 + + + + file.open には空でないパスを1つ指定してください + file.open には空でないパスを1つ指定してください + + + + nav.page には1以上の整数でページ番号を指定してください + nav.page には1以上の整数でページ番号を指定してください + + + + view.rotate の角度は -270、-180、-90、90、180、270 のいずれかを指定してください + view.rotate の角度は -270、-180、-90、90、180、270 のいずれかを指定してください + + + + + %1 に引数は指定できません + %1 に引数は指定できません + + + + コマンドが長すぎるか、NUL文字が含まれています + コマンドが長すぎるか、NUL文字が含まれています + + + + ダブルクォーテーションが閉じられていません + ダブルクォーテーションが閉じられていません + + + + コマンドを入力してください + コマンドを入力してください + + + + 空白を含むパスはダブルクォーテーションで囲んでください + 空白を含むパスはダブルクォーテーションで囲んでください + + + + %1 には整数の引数を1つ指定してください + %1 には整数の引数を1つ指定してください + + + + コマンドまたは引数が無効です: %1 + コマンドまたは引数が無効です: %1 + + + + Configuration + + + %1行、%2: %3 + %1行、%2: %3 + + + + 仕様に合う数値を指定してください + 仕様に合う数値を指定してください + + + + 値が設定範囲(%1〜%2)を超えています + 値が設定範囲(%1〜%2)を超えています + + + + true または false を指定してください + true または false を指定してください + + + + 原文の色を保持するため、preserve_colors は true にしてください + 原文の色を保持するため、preserve_colors は true にしてください + + + + 次のいずれかを指定してください: %1 + 次のいずれかを指定してください: %1 + + + + この設定項目はありません + この設定項目はありません + + + + 設定ファイルが見つかりません: %1 + 設定ファイルが見つかりません: %1 + + + + 設定ファイルを読み取れません: %1 + 設定ファイルを読み取れません: %1 + + + + + 設定ファイルが1 MiBの上限を超えています + 設定ファイルが1 MiBの上限を超えています + + + + %1行: 設定の構文に誤りがあります。詳細(解析器): %2 + %1行: 設定の構文に誤りがあります。詳細(解析器): %2 + + + + 設定項目が多すぎるか、入れ子が8階層を超えています + 設定項目が多すぎるか、入れ子が8階層を超えています + + + + schema_version は 1 のみ対応しています。ファイルは変更していません + schema_version は 1 のみ対応しています。ファイルは変更していません + + + + 不明なセクション、またはテーブル以外の値です + 不明なセクション、またはテーブル以外の値です + + + + キーバインドは512件以内の配列で指定してください + キーバインドは512件以内の配列で指定してください + + + + 各キーバインドをテーブルで指定してください + 各キーバインドをテーブルで指定してください + + + + このキーバインド項目はありません + このキーバインド項目はありません + + + + %1 は文字列で指定してください + %1 は文字列で指定してください + + + + 指定されたモードまたは操作領域はありません + 指定されたモードまたは操作領域はありません + + + + 文字入力モードには閲覧コマンドを割り当てられません + 文字入力モードには閲覧コマンドを割り当てられません + + + + 1〜4個の論理キーを指定してください + 1〜4個の論理キーを指定してください + + + + キー名が無効、または予約済みのEscです。シフト記号には入力される文字を指定してください(例: Shift+1ではなく !) + キー名が無効、または予約済みのEscです。シフト記号には入力される文字を指定してください(例: Shift+1ではなく !) + + + + 先頭の数字はPDFの実ページ番号入力に使うため予約されています + 先頭の数字はPDFの実ページ番号入力に使うため予約されています + + + + 引数をテーブルで指定してください + 引数をテーブルで指定してください + + + + 引数には文字列・数値・真偽値のみ指定できます + 引数には文字列・数値・真偽値のみ指定できます + + + + ファイル内でキーの割り当てが重複しています + ファイル内でキーの割り当てが重複しています + + + + %1行、keybindings.keys: %2 と %3 の接頭辞が衝突しています + %1行、keybindings.keys: %2 と %3 の接頭辞が衝突しています + + + + Contracts + + + E_WORKER_CRASH: 文書の能力情報が不正です + E_WORKER_CRASH: 文書の能力情報が不正です + + + + E_WORKER_CRASH: ナビゲーションの応答が不正です + E_WORKER_CRASH: ナビゲーションの応答が不正です + + + + FontBroker + + + フォント要求が無効です。 + フォント要求が無効です。 + + + + + フォントの参照が無効です。 + フォントの参照が無効です。 + + + + フォントの読取り範囲が無効です。 + フォントの読取り範囲が無効です。 + + + + フォント転送量が上限を超えました。 + フォント転送量が上限を超えました。 + + + + フォント参照数が上限を超えました。 + フォント参照数が上限を超えました。 + + + + フォント選択数が上限を超えました。 + フォント選択数が上限を超えました。 + + + + OSフォント索引を利用できません。 + OSフォント索引を利用できません。 + + + + OSフォントを取得できません。 + OSフォントを取得できません。 + + + + フォントのサイズが上限を超えました。 + フォントのサイズが上限を超えました。 + + + + フォントを読み取れません。 + フォントを読み取れません。 + + + + フォントキャッシュが上限に達しました。 + フォントキャッシュが上限に達しました。 + + + + フォントの読取りが完了しませんでした。 + フォントの読取りが完了しませんでした。 + + + + OSフォント情報が無効です。 + OSフォント情報が無効です。 + + + + OSフォント処理に失敗しました。 + OSフォント処理に失敗しました。 + + + + フォント処理の待機数が上限を超えました。 + フォント処理の待機数が上限を超えました。 + + + + Main + + + + 文書を開く + 文書を開く + + + + 終了 + 終了 + + + + 処理を取り消す + 処理を取り消す + + + + + 文書フォルダーを指定 + 文書フォルダーを指定 + + + + + + 文書情報 + 文書情報 + + + + 履歴を消去 + 履歴を消去 + + + + 下へスクロール + 下へスクロール + + + + 上へスクロール + 上へスクロール + + + + 左へスクロール + 左へスクロール + + + + 右へスクロール + 右へスクロール + + + + 半画面進む + 半画面進む + + + + 半画面戻る + 半画面戻る + + + + 一画面進む + 一画面進む + + + + 一画面戻る + 一画面戻る + + + + 文書の先頭 + 文書の先頭 + + + + 文書の末尾 + 文書の末尾 + + + + 指定ページへ移動 + 指定ページへ移動 + + + + 次のPDFページ + 次のPDFページ + + + + 前のPDFページ + 前のPDFページ + + + + 次の見出し + 次の見出し + + + + 前の見出し + 前の見出し + + + + 次の章 + 次の章 + + + + 前の章 + 前の章 + + + + 目次の表示切替 + 目次の表示切替 + + + + ページ一覧の表示切替 + ページ一覧の表示切替 + + + + ステータスの表示切替 + ステータスの表示切替 + + + + 操作する領域を切替 + 操作する領域を切替 + + + + 本文に戻る + 本文に戻る + + + + 拡大 + 拡大 + + + + 縮小 + 縮小 + + + + 幅に合わせる + 幅に合わせる + + + + PDFを回転 + PDFを回転 + + + + コマンドを入力 + コマンドを入力 + + + + 設定を再読込 + 設定を再読込 + + + + + 操作一覧 + 操作一覧 + + + + 移動履歴を戻る + 移動履歴を戻る + + + + 移動履歴を進む + 移動履歴を進む + + + + + 本文を検索 + 本文を検索 + + + + 次の検索結果 + 次の検索結果 + + + + 前の検索結果 + 前の検索結果 + + + + 次の項目を選択 + 次の項目を選択 + + + + 前の項目を選択 + 前の項目を選択 + + + + 階層を折りたたむ + 階層を折りたたむ + + + + 階層を展開 + 階層を展開 + + + + 先頭項目を選択 + 先頭項目を選択 + + + + 末尾項目を選択 + 末尾項目を選択 + + + + 項目を半画面進む + 項目を半画面進む + + + + 項目を半画面戻る + 項目を半画面戻る + + + + 選択項目へ移動 + 選択項目へ移動 + + + + 次のリンク・PDF注釈を選択 + 次のリンク・PDF注釈を選択 + + + + 前のリンク・PDF注釈を選択 + 前のリンク・PDF注釈を選択 + + + + 選択リンク・PDF注釈を開く + 選択リンク・PDF注釈を開く + + + + 割り当てなし + 割り当てなし + + + + 本文 + 本文 + + + + + 目次 + 目次 + + + + ページ一覧 + ページ一覧 + + + + 共通 + 共通 + + + + 目次を読み込んでいます… + 目次を読み込んでいます… + + + + 見出し + 見出し + + + + 目次一覧 + 目次一覧 + + + + PDF本文 + PDF本文 + + + + Ctrl+O: 文書を開く + Ctrl+O: 文書を開く + + + + ?: 操作一覧 + ?: 操作一覧 + + + + 最近使った文書 + 最近使った文書 + + + + 最近使った文書。上下キーで選択し、Enterで開きます + 最近使った文書。上下キーで選択し、Enterで開きます + + + + ↑↓: 選択 Enter: 開く + ↑↓: 選択 Enter: 開く + + + + 履歴を消去… + 履歴を消去… + + + + 読込中 + 読込中 + + + + 内容 + 内容 + + + + 本文検索 + 本文検索 + + + + コマンド + コマンド + + + + 表示中の章を検索 + 表示中の章を検索 + + + + 表示中のHTMLを検索 + 表示中のHTMLを検索 + + + + 文書 (*.pdf *.html *.htm *.xhtml *.zip *.epub) + 文書 (*.pdf *.html *.htm *.xhtml *.zip *.epub) + + + + すべて (*) + すべて (*) + + + + PDFパスワード + PDFパスワード + + + + 入口HTMLを選択 + 入口HTMLを選択 + + + + 外部ブラウザーで開く + 外部ブラウザーで開く + + + + 履歴と保存位置を消去 + 履歴と保存位置を消去 + + + + %1 件の履歴と読書位置を消去します。原本は変更されません。 + %1 件の履歴と読書位置を消去します。原本は変更されません。 + + + + Vim風の閲覧操作です。q単押しでは終了しません。:q で終了します。 + Vim風の閲覧操作です。q単押しでは終了しません。:q で終了します。 + + + + PdfAnnotations + + + PDFの注釈アイコンが安全上限を超えました。 + PDFの注釈アイコンが安全上限を超えました。 + + + + PDFの注釈アイコンを解析できません。 + PDFの注釈アイコンを解析できません。 + + + + PDFのリンク枠が解析・描画の安全上限を超えました。 + PDFのリンク枠が解析・描画の安全上限を超えました。 + + + + このリンク枠のスタイルには対応していません。 + このリンク枠のスタイルには対応していません。 + + + + PDFのリンク枠の構造が無効です。 + PDFのリンク枠の構造が無効です。 + + + + PdfDocument + + + E_PDF_METADATA_LIMIT: 目次は安全上限の先頭20000件まで表示します。 + E_PDF_METADATA_LIMIT: 目次は安全上限の先頭20000件まで表示します。 + + + + E_PDF_METADATA_LIMIT: 64階層を超える目次の枝を省略しました。 + E_PDF_METADATA_LIMIT: 64階層を超える目次の枝を省略しました。 + + + + E_PDF_METADATA_LIMIT: 循環または重複参照のある目次の枝を省略しました。 + E_PDF_METADATA_LIMIT: 循環または重複参照のある目次の枝を省略しました。 + + + + 目次の取得範囲が無効です。 + 目次の取得範囲が無効です。 + + + + 目次項目(名称が長さ制限を超えています) + 目次項目(名称が長さ制限を超えています) + + + + 目次項目(名称なし) + 目次項目(名称なし) + + + + E_PDF_METADATA_LIMIT: 長すぎる目次名を代替表示しています。 + E_PDF_METADATA_LIMIT: 長すぎる目次名を代替表示しています。 + + + + PDFを読み取れません。 + PDFを読み取れません。 + + + + PDFの入力が無効です。 + PDFの入力が無効です。 + + + + PDFのパスワードを入力してください。 + PDFのパスワードを入力してください。 + + + + PDFを解析できません。 + PDFを解析できません。 + + + + PDFのページ数が制限を超えています。 + PDFのページ数が制限を超えています。 + + + + + PDFが開かれていません。 + PDFが開かれていません。 + + + + ページ情報の取得範囲が無効です。 + ページ情報の取得範囲が無効です。 + + + + PDFのページ構造が破損しています。 + PDFのページ構造が破損しています。 + + + + ページの寸法が無効です。 + ページの寸法が無効です。 + + + + E_PDF_METADATA_LIMIT: 長すぎるページラベルを省略しています。実ページ番号で移動できます。 + E_PDF_METADATA_LIMIT: 長すぎるページラベルを省略しています。実ページ番号で移動できます。 + + + + E_PDF_FEATURE_UNSUPPORTED: 動的XFAの表示には対応していません。 + E_PDF_FEATURE_UNSUPPORTED: 動的XFAの表示には対応していません。 + + + + E_PDF_METADATA_LIMIT: 長すぎる文書名または著者名を省略しています。 + E_PDF_METADATA_LIMIT: 長すぎる文書名または著者名を省略しています。 + + + + 文書情報が安全な応答サイズを超えました。 + 文書情報が安全な応答サイズを超えました。 + + + + PDF描画要求が範囲外です。 + PDF描画要求が範囲外です。 + + + + + + + + ページを解析できません。 + ページを解析できません。 + + + + ページ寸法が制限を超えています。 + ページ寸法が制限を超えています。 + + + + 描画バッファを確保できません。 + 描画バッファを確保できません。 + + + + + ページ番号が無効です。 + ページ番号が無効です。 + + + + E_PDF_METADATA_LIMIT: ページ内リンクは安全上限の先頭1000件まで表示します。残りのリンク情報を省略しました。 + E_PDF_METADATA_LIMIT: ページ内リンクは安全上限の先頭1000件まで表示します。残りのリンク情報を省略しました。 + + + + E_PDF_METADATA_LIMIT: ページ内の注釈情報は安全上限の先頭1000件まで確認します。残りのコメントやフォームの情報を省略しました。 + E_PDF_METADATA_LIMIT: ページ内の注釈情報は安全上限の先頭1000件まで確認します。残りのコメントやフォームの情報を省略しました。 + + + + 保存された外観がない一部の注釈では、倍率固定の表示を再現できません。 + 保存された外観がない一部の注釈では、倍率固定の表示を再現できません。 + + + + 注釈本文は長さ制限を超えているため表示できません。 + 注釈本文は長さ制限を超えているため表示できません。 + + + + 保存されたフォーム外観がないため、一部の項目を正しく表示できない可能性があります。 + 保存されたフォーム外観がないため、一部の項目を正しく表示できない可能性があります。 + + + + E_PDF_METADATA_LIMIT: 長さ制限を超えた注釈本文を省略しています。注釈の存在は一覧に表示します。 + E_PDF_METADATA_LIMIT: 長さ制限を超えた注釈本文を省略しています。注釈の存在は一覧に表示します。 + + + + E_PDF_METADATA_LIMIT: ページ内のリンク・注釈情報が応答サイズの安全上限を超えたため、一部を省略しています。 + E_PDF_METADATA_LIMIT: ページ内のリンク・注釈情報が応答サイズの安全上限を超えたため、一部を省略しています。 + + + + 検索条件が無効です。 + 検索条件が無効です。 + + + + 座標変換要求が無効です。 + 座標変換要求が無効です。 + + + + ページ寸法が無効です。 + ページ寸法が無効です。 + + + + PdfFontAdapter + + + フォントの応答または表構造が無効です。 + フォントの応答または表構造が無効です。 + + + + + + フォントの取得が時間制限を超えました。 + フォントの取得が時間制限を超えました。 + + + + 使用中のフォントがメモリー上限を超えています。 + 使用中のフォントがメモリー上限を超えています。 + + + + フォント選択の種類が上限を超えています。 + フォント選択の種類が上限を超えています。 + + + + 代替フォントを取得できないため内蔵フォントを使用します。 + 代替フォントを取得できないため内蔵フォントを使用します。 + + + + 要求されたフォントが見つからないため内蔵の代替を使用します。 + 要求されたフォントが見つからないため内蔵の代替を使用します。 + + + + フォント転送量が上限を超えています。 + フォント転送量が上限を超えています。 + + + + フォントの表構造が無効、または未対応です。 + フォントの表構造が無効、または未対応です。 + + + + 使用中のフォント数が上限を超えています。 + 使用中のフォント数が上限を超えています。 + + + + 原本に埋め込まれていないフォントを代替しています。 + 原本に埋め込まれていないフォントを代替しています。 + + + + フォントの処理に失敗しました。 + フォントの処理に失敗しました。 + + + + PdfMetadata + + + PDFメタデータの解析が安全上限を超えました。 + PDFメタデータの解析が安全上限を超えました。 + + + + PDFメタデータの構造を解析できません。 + PDFメタデータの構造を解析できません。 + + + + PdfStructure + + + 構造の所有元が間接参照ではありません。 + 構造の所有元が間接参照ではありません。 + + + + Formまたはページ内容の圧縮形式を解析できません。 + Formまたはページ内容の圧縮形式を解析できません。 + + + + Form XObjectの呼出が循環しています。 + Form XObjectの呼出が循環しています。 + + + + RoleMapの見出し名が循環しています。 + RoleMapの見出し名が循環しています。 + + + + 構造要素の親参照が循環または欠落しています。 + 構造要素の親参照が循環または欠落しています。 + + + + 構造要素の親と子の順序を確認できません。 + 構造要素の親と子の順序を確認できません。 + + + + MCR所有元のStructParentsを確認できません。 + MCR所有元のStructParentsを確認できません。 + + + + ParentTreeと見出しMCRの所有元が一致しません。 + ParentTreeと見出しMCRの所有元が一致しません。 + + + + 見出しのページ所有元を確認できません。 + 見出しのページ所有元を確認できません。 + + + + 注釈所有のOBJR/StmOwn構造には対応していません。 + 注釈所有のOBJR/StmOwn構造には対応していません。 + + + + MCRのページ参照を確認できません。 + MCRのページ参照を確認できません。 + + + + MCRのストリーム所有元に対応していません。 + MCRのストリーム所有元に対応していません。 + + + + 見出しの子構造が循環または破損しています。 + 見出しの子構造が循環または破損しています。 + + + + 見出しの子構造と親参照が一致しません。 + 見出しの子構造と親参照が一致しません。 + + + + 見出しのParentTreeがありません。 + 見出しのParentTreeがありません。 + + + + ParentTreeからの親参照が循環または破損しています。 + ParentTreeからの親参照が循環または破損しています。 + + + + ParentTreeが示すページに見出しのMCRが存在しません。 + ParentTreeが示すページに見出しのMCRが存在しません。 + + + + MCRが参照するFormまたはMCIDは、このページの実際の呼出で確認できません。 + MCRが参照するFormまたはMCIDは、このページの実際の呼出で確認できません。 + + + + Formと描画オブジェクトの対応が一意でないため、ページ先頭へ移動します。 + Formと描画オブジェクトの対応が一意でないため、ページ先頭へ移動します。 + + + + 同じFormが複数回呼び出されているため、ページ先頭へ移動します。 + 同じFormが複数回呼び出されているため、ページ先頭へ移動します。 + + + + 見出し(名称なし) + 見出し(名称なし) + + + + 所有元付きMCIDの文字座標を取得できないため、ページ先頭へ移動します。 + 所有元付きMCIDの文字座標を取得できないため、ページ先頭へ移動します。 + + + + 見出しの応答サイズが安全上限を超えたため、先頭の項目だけを表示します。 + 見出しの応答サイズが安全上限を超えたため、先頭の項目だけを表示します。 + + + + PDFの構造・Form内容が解析の安全上限を超えました。 + PDFの構造・Form内容が解析の安全上限を超えました。 + + + + PDFの構造・Form参照が破損しているため見出しを確認できません。 + PDFの構造・Form参照が破損しているため見出しを確認できません。 + + + + PdfWorker + + + PDF操作の引数が無効です。 + PDF操作の引数が無効です。 + + + + パスワードが長すぎます。 + パスワードが長すぎます。 + + + + 未対応のPDF操作です。 + 未対応のPDF操作です。 + + + + 文書の索引情報が応答サイズの上限を超えています。 + 文書の索引情報が応答サイズの上限を超えています。 + + + + QObject + + + Vim風の読み取り専用文書リーダー + Vim風の読み取り専用文書リーダー + + + + 設定ファイル + 設定ファイル + + + + PDFの初期実ページ番号 + PDFの初期実ページ番号 + + + + 試験用: 画面をPNG保存して終了 + 試験用: 画面をPNG保存して終了 + + + + 試験用: フレーム提示とRSSをJSONへ記録 + 試験用: フレーム提示とRSSをJSONへ記録 + + + + 試験用: 開く回数 + 試験用: 開く回数 + + + + 試験用: 各ページ・見出し・章系列の操作回数 + 試験用: 各ページ・見出し・章系列の操作回数 + + + + 試験用: 最小の明示的な開閉回数 + 試験用: 最小の明示的な開閉回数 + + + + 試験用: 16ms間隔の継続スクロール入力数 + 試験用: 16ms間隔の継続スクロール入力数 + + + + ローカル文書のパス + ローカル文書のパス + + + + 状態保存を読み取り専用で開始しました + 状態保存を読み取り専用で開始しました + + + + 文書内の資源が見つかりません + 文書内の資源が見つかりません + + + + 安全でない文書内の参照を遮断しました + 安全でない文書内の参照を遮断しました + + + + 資源へのアクセスがOSに拒否されました + 資源へのアクセスがOSに拒否されました + + + + 資源の形式を許可できません + 資源の形式を許可できません + + + + 資源がサイズ上限を超えました + 資源がサイズ上限を超えました + + + + アーカイブの展開上限を超えました + アーカイブの展開上限を超えました + + + + 資源のCRC・圧縮データ・長さが不正です + 資源のCRC・圧縮データ・長さが不正です + + + + 一時保存先の空き容量または割当量が不足しています + 一時保存先の空き容量または割当量が不足しています + + + + 一時資源の書込み・確定に失敗しました + 一時資源の書込み・確定に失敗しました + + + + 資源の読取りに失敗しました + 資源の読取りに失敗しました + + + + 資源を処理するワーカーが停止しました + 資源を処理するワーカーが停止しました + + + + 資源の処理期限を超えました + 資源の処理期限を超えました + + + + 通信・文書外の参照を遮断しました + 通信・文書外の参照を遮断しました + + + + 画像の読込みを表示制限で遮断しました + 画像の読込みを表示制限で遮断しました + + + + スタイルシートの読込みを表示制限で遮断しました + スタイルシートの読込みを表示制限で遮断しました + + + + フォントの読込みを表示制限で遮断しました + フォントの読込みを表示制限で遮断しました + + + + 文書スクリプトを無効にしました + 文書スクリプトを無効にしました + + + + 埋込みフレームを表示制限で遮断しました + 埋込みフレームを表示制限で遮断しました + + + + 文書からの通信を遮断しました + 文書からの通信を遮断しました + + + + フォーム送信を遮断しました + フォーム送信を遮断しました + + + + 文書外を基準とする参照を遮断しました + 文書外を基準とする参照を遮断しました + + + + 埋込みオブジェクトを表示制限で遮断しました + 埋込みオブジェクトを表示制限で遮断しました + + + + 音声・動画の読込みを表示制限で遮断しました + 音声・動画の読込みを表示制限で遮断しました + + + + その他の表示制限で資源を遮断しました + その他の表示制限で資源を遮断しました + + + + Sandbox + + + E_SANDBOX_UNAVAILABLE: 保護を無効にする起動設定が指定されています + E_SANDBOX_UNAVAILABLE: 保護を無効にする起動設定が指定されています + + + + WebPane + + + 見出し一覧は先頭2,000件を表示しています + 見出し一覧は先頭2,000件を表示しています + + + + + E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました + E_LOCATOR_UNRESOLVED: 近い読書位置へ戻りました + + + + 一致する文字がありません + 一致する文字がありません + + + + E_RESOURCE_BLOCKED: このリンクは開けません + E_RESOURCE_BLOCKED: このリンクは開けません + + + + Tabでリンクを選択してください + Tabでリンクを選択してください + + + + この文書にはリンクがありません + この文書にはリンクがありません + + + + この文書には見出しがありません + この文書には見出しがありません + + + + 見出しの端です + 見出しの端です + + + + WindowsPipe + + + E_SANDBOX_UNAVAILABLE: 専用通信ハンドルを作成できません + E_SANDBOX_UNAVAILABLE: 専用通信ハンドルを作成できません + + + + E_SANDBOX_UNAVAILABLE: 継承通信ハンドルが不正です + E_SANDBOX_UNAVAILABLE: 継承通信ハンドルが不正です + + + + docview::Controller + + + 本文の処理を安全のため停止しました。文書を開き直せます + 本文の処理を安全のため停止しました。文書を開き直せます + + + + 注釈 + 注釈 + + + + ページ一覧 + ページ一覧 + + + + 出版物のページ + 出版物のページ + + + + + 章一覧 + 章一覧 + + + + + %1(実ページ %2) + %1(実ページ %2) + + + + + 目次 + 目次 + + + + 見出し一覧 + 見出し一覧 + + + + メモリの空きが不足したため文書処理を停止しました。空きが回復してから文書を開き直してください + メモリの空きが不足したため文書処理を停止しました。空きが回復してから文書を開き直してください + + + + メモリの空きが回復しました。停止した文書を開き直せます + メモリの空きが回復しました。停止した文書を開き直せます + + + + メモリの空きが回復し、通常の描画に戻しました + メモリの空きが回復し、通常の描画に戻しました + + + + メモリの空きが少ないため、先読みを一時停止しました + メモリの空きが少ないため、先読みを一時停止しました + + + + メモリの空きが少ないため、画面外の描画キャッシュを解放しました + メモリの空きが少ないため、画面外の描画キャッシュを解放しました + + + + + メモリの空きが少ないため、描画解像度を一時的に抑えています + メモリの空きが少ないため、描画解像度を一時的に抑えています + + + + + メモリの空きが少なくなっています。圧迫が続く場合は文書処理を停止します + メモリの空きが少なくなっています。圧迫が続く場合は文書処理を停止します + + + + + メモリ圧迫時に描画解像度を一時的に抑制しました + メモリ圧迫時に描画解像度を一時的に抑制しました + + + + 一部の資源を表示できません。:infoで理由と検出件数を確認できます + 一部の資源を表示できません。:infoで理由と検出件数を確認できます + + + + 読み込みを取り消しました。前の文書は開き直す必要があります + 読み込みを取り消しました。前の文書は開き直す必要があります + + + + 読み込みを取り消しました + 読み込みを取り消しました + + + + E_OPEN_FAILED: ローカルファイルを選択してください + E_OPEN_FAILED: ローカルファイルを選択してください + + + + E_RESOURCE_LIMIT: メモリの空きが回復してから文書を開いてください + E_RESOURCE_LIMIT: メモリの空きが回復してから文書を開いてください + + + + %1% + %1% + + + + (幅合わせ) + (幅合わせ) + + + + (ページ合わせ) + (ページ合わせ) + + + + 章 %1 / %2 %3 + 章 %1 / %2 %3 + + + + 文書 %1 + 文書 %1 + + + + 読込中 · %1 + 読込中 · %1 + + + + 作業領域を作成できません + 作業領域を作成できません + + + + 作業領域をロックできません + 作業領域をロックできません + + + + 作業領域を準備できません + 作業領域を準備できません + + + + 資源領域を準備できません + 資源領域を準備できません + + + + フォントの供給処理は終了しています。 + フォントの供給処理は終了しています。 + + + + 文書を処理中です。Escで取り消せます + 文書を処理中です。Escで取り消せます + + + + + ナビゲーションの応答が不正です + ナビゲーションの応答が不正です + + + + + 文書の能力情報が不正です + 文書の能力情報が不正です + + + + + + PDF目次の応答が不正です + PDF目次の応答が不正です + + + + PDFの応答が不正です + PDFの応答が不正です + + + + + 文書を開き直してください。 + 文書を開き直してください。 + + + + 最初のページを表示できません + 最初のページを表示できません + + + + アーカイブの応答が不正です + アーカイブの応答が不正です + + + + 資源パスが不正です + 資源パスが不正です + + + + 資源一覧を読み込めません + 資源一覧を読み込めません + + + + 資源一覧が不正です + 資源一覧が不正です + + + + 資源一覧が進行しません + 資源一覧が進行しません + + + + 文書の入口がありません + 文書の入口がありません + + + + 資源を安全に読み込めないため処理を停止しました + 資源を安全に読み込めないため処理を停止しました + + + + 資源の応答が一致しません + 資源の応答が一致しません + + + + 資源の応答が不正です + 資源の応答が不正です + + + + 展開量が安全上限を超えました + 展開量が安全上限を超えました + + + + 資源を書き込めないため展開を停止しました + 資源を書き込めないため展開を停止しました + + + + 資源の長さが一致しません + 資源の長さが一致しません + + + + 資源を安全に確定できません + 資源を安全に確定できません + + + + 本文を表示できません + 本文を表示できません + + + + E_OPEN_FAILED: この章を表示できません。:infoで資源の警告を確認できます + E_OPEN_FAILED: この章を表示できません。:infoで資源の警告を確認できます + + + + E_LOCATOR_UNRESOLVED: 原本が変更または置換されているため、以前の位置を復元できません。先頭から表示します。 + E_LOCATOR_UNRESOLVED: 原本が変更または置換されているため、以前の位置を復元できません。先頭から表示します。 + + + + 文書の内容を解析できません + 文書の内容を解析できません + + + + 原本が正しく表示できるか確認し、別の正常なファイルを選択してください。 + 原本が正しく表示できるか確認し、別の正常なファイルを選択してください。 + + + + パスワード付きZIPには対応していません + パスワード付きZIPには対応していません + + + + この暗号化・保護方式には対応していません + この暗号化・保護方式には対応していません + + + + 提供元の対応ビューアーを使用するか、対応する形式の文書を選択してください。 + 提供元の対応ビューアーを使用するか、対応する形式の文書を選択してください。 + + + + EPUBの文書構造または読書順を確認できません + EPUBの文書構造または読書順を確認できません + + + + 正常なEPUBファイルか確認し、別の原本を選択してください。 + 正常なEPUBファイルか確認し、別の原本を選択してください。 + + + + このPDFの内容には対応していません + このPDFの内容には対応していません + + + + 提供元の対応ビューアーで内容を確認してください。 + 提供元の対応ビューアーで内容を確認してください。 + + + + 対応する形式またはファイルの先頭情報ではありません + 対応する形式またはファイルの先頭情報ではありません + + + + PDF、HTML、HTML ZIP、EPUBの対応形式か確認してください。 + PDF、HTML、HTML ZIP、EPUBの対応形式か確認してください。 + + + + 処理に必要な資源または安全上限の制限に達しました + 処理に必要な資源または安全上限の制限に達しました + + + + メモリの空きや文書の大きさを確認し、小さい文書を選ぶか開き直してください。 + メモリの空きや文書の大きさを確認し、小さい文書を選ぶか開き直してください。 + + + + 作業領域へ保存できません + 作業領域へ保存できません + + + + 保存先の空き容量と書き込み権限を確認してから開き直してください。 + 保存先の空き容量と書き込み権限を確認してから開き直してください。 + + + + 安全な文書処理を開始できません + 安全な文書処理を開始できません + + + + 対応する実行環境とインストール状態を確認してください。 + 対応する実行環境とインストール状態を確認してください。 + + + + 文書処理が時間内に完了しませんでした + 文書処理が時間内に完了しませんでした + + + + 文書処理が停止しました + 文書処理が停止しました + + + + 文書を開き直してください。再び失敗する場合は別の文書を選択してください。 + 文書を開き直してください。再び失敗する場合は別の文書を選択してください。 + + + + 安全に読み取れない資源が含まれています + 安全に読み取れない資源が含まれています + + + + 文書の構成と読み取り権限を確認し、正常な原本を選択してください。 + 文書の構成と読み取り権限を確認し、正常な原本を選択してください。 + + + + 表示に必要な本文が見つかりません + 表示に必要な本文が見つかりません + + + + 本文と関連資源がそろっているか確認してから開き直してください。 + 本文と関連資源がそろっているか確認してから開き直してください。 + + + + ファイルを開けません + ファイルを開けません + + + + ファイルの場所と読み取り権限を確認し、Ctrl+Oで選択してください。 + ファイルの場所と読み取り権限を確認し、Ctrl+Oで選択してください。 + + + + 選択した文書 + 選択した文書 + + + + %1: %2 +対象: %3 +対処: %4 + %1: %2 +対象: %3 +対処: %4 + + + + ページ情報を読み込めません + ページ情報を読み込めません + + + + 設定を再読込しました + 設定を再読込しました + + + + 形式: %1 +文書スクリプト・外部資源・フォーム入力は無効です。 +PDFは原色・読み取り専用で表示します。 + + 形式: %1 +文書スクリプト・外部資源・フォーム入力は無効です。 +PDFは原色・読み取り専用で表示します。 + + + + + + +資源の表示制限(検出件数。同じ資源の再試行を含みます) + + + +資源の表示制限(検出件数。同じ資源の再試行を含みます) + + + + + — %1件%2 + + — %1件%2 + + + + + 以上 + 以上 + + + + + +最後に開こうとした文書(表示中の文書とは別の試行情報) + + + +最後に開こうとした文書(表示中の文書とは別の試行情報) + + + + + この形式ではこの操作を利用できません + この形式ではこの操作を利用できません + + + + この文書には対象の情報がありません + この文書には対象の情報がありません + + + + この文書には目次がありません + この文書には目次がありません + + + + この文書にはページ一覧がありません + この文書にはページ一覧がありません + + + + 文書を開いてください + 文書を開いてください + + + + + + 文書処理は停止しています。文書を開き直してください + 文書処理は停止しています。文書を開き直してください + + + + 移動履歴の端です + 移動履歴の端です + + + + 実ページの移動はPDFで利用できます + 実ページの移動はPDFで利用できます + + + + ページ番号が範囲外です + ページ番号が範囲外です + + + + 章移動はEPUBで利用できます + 章移動はEPUBで利用できます + + + + 章の端です + 章の端です + + + + + + E_EPUB_SPINE_MISSING: この章がありません + E_EPUB_SPINE_MISSING: この章がありません + + + + 回転はPDFで利用できます + 回転はPDFで利用できます + + + + 通常の読書順に含まれる章がありません + 通常の読書順に含まれる章がありません + + + + E_LOCATOR_UNRESOLVED: 保存された読書位置をこの文書で解決できません + E_LOCATOR_UNRESOLVED: 保存された読書位置をこの文書で解決できません + + + + E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。 + E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。 + + + + E_OPEN_FAILED: 最近使った文書が見つからないか、読み取れません。Ctrl+O で場所を確認してください。 +%1 + E_OPEN_FAILED: 最近使った文書が見つからないか、読み取れません。Ctrl+O で場所を確認してください。 +%1 + + + + E_OPEN_FAILED: 文書が変更または置換されているため、以前の位置を復元できません。現在のファイルを開くには Ctrl+O で選択してください。 +%1 + E_OPEN_FAILED: 文書が変更または置換されているため、以前の位置を復元できません。現在のファイルを開くには Ctrl+O で選択してください。 +%1 + + + + E_OPEN_FAILED: この文書は最近使った文書の一覧にありません。Ctrl+O で選択してください。 + E_OPEN_FAILED: この文書は最近使った文書の一覧にありません。Ctrl+O で選択してください。 + + + + 履歴と保存位置を消去しました + 履歴と保存位置を消去しました + + + + 目次の移動先が無効です + 目次の移動先が無効です + + + + ページ先頭へ移動しました + ページ先頭へ移動しました + + + + E_RESOURCE_BLOCKED: 移動先は文書内ではありません + E_RESOURCE_BLOCKED: 移動先は文書内ではありません + + + + E_RESOURCE_BLOCKED: 文書の索引が上限を超えています + E_RESOURCE_BLOCKED: 文書の索引が上限を超えています + + + + 文書のスクリプトは無効です。静的な内容を表示しています + 文書のスクリプトは無効です。静的な内容を表示しています + + + + E_RESOURCE_BLOCKED: 読書位置の応答が不正です + E_RESOURCE_BLOCKED: 読書位置の応答が不正です + + + + E_PDF_INDEX_PARTIAL: 目次・見出しの容量上限に達したため、索引を一部省略しました + E_PDF_INDEX_PARTIAL: 目次・見出しの容量上限に達したため、索引を一部省略しました + + + + E_PDF_INDEX_PARTIAL: 一部のページで見出しを読み込めませんでした + E_PDF_INDEX_PARTIAL: 一部のページで見出しを読み込めませんでした + + + + + 見出しの応答が不正です + 見出しの応答が不正です + + + + E_PDF_INDEX_PARTIAL: 見出しの容量上限に達したため、索引を一部省略しました + E_PDF_INDEX_PARTIAL: 見出しの容量上限に達したため、索引を一部省略しました + + + + 見出し索引を読み込んでいます。Escで待機を取り消せます + 見出し索引を読み込んでいます。Escで待機を取り消せます + + + + この文書には見出し情報がありません + この文書には見出し情報がありません + + + + 目次項目へ移動しました + 目次項目へ移動しました + + + + 見出しの端です + 見出しの端です + + + + 選択フォルダーに現在のHTMLが含まれていません + 選択フォルダーに現在のHTMLが含まれていません + + + + 検索結果がありません + 検索結果がありません + + + + 検索できる文字情報がありません + 検索できる文字情報がありません + + + + 一致する箇所へ移動しました + 一致する箇所へ移動しました + + + + E_WORKER_CRASH: 本文の表示処理が停止しました。文書を開き直してください + E_WORKER_CRASH: 本文の表示処理が停止しました。文書を開き直してください + + + + メタデータの件数が不正です + メタデータの件数が不正です + + + + メタデータの応答が不正です + メタデータの応答が不正です + + + + メタデータの文字列が不正です + メタデータの文字列が不正です + + + + メタデータの上限を超えました + メタデータの上限を超えました + + + + 入口候補の応答が不正です + 入口候補の応答が不正です + + + + 本文プロセスの安全監視を開始できません + 本文プロセスの安全監視を開始できません + + + + docview::PdfCanvas + + + E_LOCATION_INVALID: ページ番号が範囲外です + E_LOCATION_INVALID: ページ番号が範囲外です + + + + E_LOCATION_INVALID: PDFの座標が無効です + E_LOCATION_INVALID: PDFの座標が無効です + + + + 保存位置をページの可視範囲内へ近似復元しました + 保存位置をページの可視範囲内へ近似復元しました + + + + ページ %1 · 読込中 + ページ %1 · 読込中 + + + + 不正な描画画像 + 不正な描画画像 + + + + E_LINK_BLOCKED: このリンクの操作は許可されていません + E_LINK_BLOCKED: このリンクの操作は許可されていません + + + + リンクと注釈を読込中です。もう一度操作してください + リンクと注釈を読込中です。もう一度操作してください + + + + このページにはリンクや注釈がありません + このページにはリンクや注釈がありません + + + + 注釈 %1 / %2 · Enterで本文を開きます + 注釈 %1 / %2 · Enterで本文を開きます + + + + ページ外の注釈 %1 / %2 · Enterで本文を開きます + ページ外の注釈 %1 / %2 · Enterで本文を開きます + + + + リンクまたは注釈を選択してください + リンクまたは注釈を選択してください + + + + docview::StateStore + + + 読書状態の保存先にはシンボリックリンクを使用できません。 + 読書状態の保存先にはシンボリックリンクを使用できません。 + + + + 専用の読書状態フォルダーを作成できないため、状態は読み取り専用です。 + 専用の読書状態フォルダーを作成できないため、状態は読み取り専用です。 + + + + 別のアプリが保存先を使用しているため、読書状態は読み取り専用です。 + 別のアプリが保存先を使用しているため、読書状態は読み取り専用です。 + + + + 読書状態を読み取れません。文書は通常どおり開けます。 + 読書状態を読み取れません。文書は通常どおり開けます。 + + + + 読書状態が破損していたため、前回の保存内容を復元しました。 + 読書状態が破損していたため、前回の保存内容を復元しました。 + + + + + 保存先のロックを取得できないため、読書状態は読み取り専用です。 + 保存先のロックを取得できないため、読書状態は読み取り専用です。 + + + + 文書の同一性を確認できないため、読書状態を保存できません。 + 文書の同一性を確認できないため、読書状態を保存できません。 + + + + E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。 + E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。 + + + + 倍率が範囲外のため、読書状態を保存できません。 + 倍率が範囲外のため、読書状態を保存できません。 + + + + 読書位置のデータが保存容量の上限を超えています。 + 読書位置のデータが保存容量の上限を超えています。 + + + + アーカイブの入口が無効なため、選択内容を保存できません。 + アーカイブの入口が無効なため、選択内容を保存できません。 + + + + アーカイブが変更されたため、入口の選択内容を保存しません。 + アーカイブが変更されたため、入口の選択内容を保存しません。 + + + + + 読書状態は読み取り専用です。 + 読書状態は読み取り専用です。 + + + + 読書状態から以前の一時 URL を除去できません。保存先の権限と空き容量を確認してください。 + 読書状態から以前の一時 URL を除去できません。保存先の権限と空き容量を確認してください。 + + + + 読書状態が 8 MiB の保存上限を超えています。 + 読書状態が 8 MiB の保存上限を超えています。 + + + + 前回の読書状態のバックアップを保存できません。 + 前回の読書状態のバックアップを保存できません。 + + + + 読書状態のバックアップから以前の一時 URL を除去できません。 + 読書状態のバックアップから以前の一時 URL を除去できません。 + + + + 読書状態を保存できません。保存先の権限と空き容量を確認してください。 + 読書状態を保存できません。保存先の権限と空き容量を確認してください。 + + + + 保存された履歴を消去できません。 + 保存された履歴を消去できません。 + + + + 履歴は消去しましたが、以前の読書状態のバックアップを削除できませんでした。 + 履歴は消去しましたが、以前の読書状態のバックアップを削除できませんでした。 + + + + docview::WorkerFontClient + + + + フォント取得の通信が停止しました + フォント取得の通信が停止しました + + + + フォント取得の親要求が不正です + フォント取得の親要求が不正です + + + + フォント取得中に親要求が終了しました + フォント取得中に親要求が終了しました + + + + フォント取得の応答が一致しません + フォント取得の応答が一致しません + + + + フォント取得のエラー応答が不正です + フォント取得のエラー応答が不正です + + + + フォント取得の応答内容が不正です + フォント取得の応答内容が不正です + + + + フォント取得要求の状態が不正です + フォント取得要求の状態が不正です + + + + + フォント取得が時間制限を超えました + フォント取得が時間制限を超えました + + + + フォント取得要求の識別子が不正です + フォント取得要求の識別子が不正です + + + + フォント取得要求の内容が不正です + フォント取得要求の内容が不正です + + + + フォント取得要求を送信できません + フォント取得要求を送信できません + + + + フォント取得が完了前に中断されました + フォント取得が完了前に中断されました + + + + docview::WorkerProcess + + + 文書処理が時間制限を超えました + 文書処理が時間制限を超えました + + + + 文書処理プロセスを起動できません + 文書処理プロセスを起動できません + + + + 文書処理から不正な応答を受信しました + 文書処理から不正な応答を受信しました + + + + 文書処理の応答が一致しません + 文書処理の応答が一致しません + + + + 応答の分割形式が不正です + 応答の分割形式が不正です + + + + フォント処理中にサービスが変更されました + フォント処理中にサービスが変更されました + + + + フォント取得要求が不正です + フォント取得要求が不正です + + + + フォント取得の識別子または範囲が不正です + フォント取得の識別子または範囲が不正です + + + + フォントサービスのエラー応答が不正です + フォントサービスのエラー応答が不正です + + + + フォントサービスのエラー内容が不正です + フォントサービスのエラー内容が不正です + + + + フォントサービスの識別子が不正です + フォントサービスの識別子が不正です + + + + フォントサービスの応答内容が不正です + フォントサービスの応答内容が不正です + + + + フォントサービスの選択数または識別子が不正です + フォントサービスの選択数または識別子が不正です + + + + フォントサービスの応答を送信できません + フォントサービスの応答を送信できません + + + + この環境では文書処理の保護を有効にできません。安全のため文書を開けません + この環境では文書処理の保護を有効にできません。安全のため文書を開けません + + + + 文書処理が停止しました。再読込できます + 文書処理が停止しました。再読込できます + + + + 文書処理の起動引数が不正です + 文書処理の起動引数が不正です + + + + 文書の場所が不正です + 文書の場所が不正です + + + + 文書を読取り専用で開けません + 文書を読取り専用で開けません + + + + 文書処理の通信が停止しました + 文書処理の通信が停止しました + + + + 文書処理の起動応答が不正です + 文書処理の起動応答が不正です + + + + 文書処理の通信を準備できません + 文書処理の通信を準備できません + + + + 文書処理の要求を送信できません + 文書処理の要求を送信できません + + + diff --git a/resources/licenses/README.md b/resources/licenses/README.md new file mode 100644 index 0000000..fbc7160 --- /dev/null +++ b/resources/licenses/README.md @@ -0,0 +1,73 @@ +# Third-party material in the Linux development package + +DocView's public license has not been selected. These notices do not grant a +license to DocView, declare a license choice for a dependency, or authorize a +public release. + +`cmake --install` includes the independently distributed PDFium binary's +`LICENSE` and `licenses/` directory, plus the locally verified qpdf license text. +The Linux install also includes `pdfium-supplemental/`'s full libc++ and +libc++abi notices. The provider's collection script omits these names, while +the fixed build settings and inspected library identify their use. The +supplement's `sources.json` binds each text to its exact upstream revision and +hash, and to the inspected PDFium library hash. Configure and packaging reject +a changed PDFium version, source pin, binary or notice pending a new review. +This adds two texts to the provider's 15; it does not prove all component +attribution is complete. +`tools/package_linux_development.py` additionally creates +`share/doc/docview/third-party/NOTICE.txt`, `dependency-manifest.json`, and a +`licenses/` tree from the actual Arch Linux packages on the packaging host. +Every copied text has a source path or resource attribution and SHA-256 in the manifest. Package license +labels are recorded verbatim; a list of alternatives is not a selected license. + +For the current e2fsprogs 1.47.4, libidn2 2.3.8 and opencore-amr 0.1.6 packages, +the installed package has no specific license-text directory. The +`linux-supplemental/` tree supplies top-level notice/license texts from the +versioned upstream sources. `sources.json` records the URLs, downloaded-object +hashes and each text's hash. Packaging rejects a changed upstream version or +modified text until this supplement is reviewed. Downloaded source archives +were used only to obtain these texts and are not included as corresponding +source. This does not establish complete component-specific attribution. + +The package contains DocView's three executables and the independent PDFium +shared library. Qt, Qt Quick, Qt WebEngine, libqpdf, libzip, toml++, libseccomp, +Fontconfig and their resolved system libraries remain system dependencies. +Compiled-in toml++ code is also recorded. Copying a system dependency's notice +does not mean that its runtime binary is bundled. + +The manifest separates pinned PDFium source/binary provenance from Arch package +version, upstream home and packaging-repository pointers. Corresponding source +trees and complete build materials are **not included in this package**. +Separate repository evidence records selected exact Arch recipes/patches, +PDFium provider metadata and fixed-source notice comparisons. That bounded +collection is not a complete corresponding-source archive. A source URL or an +installed license text does not complete source-delivery requirements. +Each component therefore retains `source.status = not-collected` for the +package's complete-source collection. + +Qt WebEngine includes Chromium components whose terms are separate from the Qt +module's terms. The installed Arch `LICENSE.chromium` is copied, but it is a +top-level license, not the complete build-specific Chromium attribution set. +The Linux packager also copies the seven complete notice comments in +`qt-embedded-notices/`, extracted from 13 inspected QtWebEngine DataPack entries. +The reviewed `sources.json` binds the texts and exact resource spans to Qt +6.11.2, Arch qt6-webengine 6.11.2-1, the two DataPack paths/hashes and the +extraction evidence. Packaging checks the fixed metadata digest, Qt/package +versions, actual system-file inventory, and every notice's size/hash. A changed +variant or missing/modified text fails packaging pending a new review. +Only the texts and source metadata are copied; Qt's runtime/resources remain +system dependencies. These seven texts do not complete Chromium attribution +or corresponding sources. That collection and review remain open. See the +[Qt WebEngine licensing documentation](https://doc.qt.io/qt-6/qtwebengine-licensing.html). + +The bounded inventory includes the executables' ELF closure, Qt WebEngine helper +and resources, selected QML modules, and candidate styles/platform/image plugins. +It records system files without copying them into the runtime. It is not a +dynamic-load trace or an inventory of every optional IME, GPU, theme or system +font provider. No system font files are bundled. + +The generated package is for local development on the recorded Arch Linux ABI. +It does not satisfy the clean Ubuntu/Windows distribution gate. Before public +distribution, settle the application license and target packaging policy, +resolve all actual bundled-component conditions, complete required notices and +source arrangements, and test the resulting target-OS package. diff --git a/resources/licenses/linux-supplemental/e2fsprogs/NOTICE b/resources/licenses/linux-supplemental/e2fsprogs/NOTICE new file mode 100644 index 0000000..da98a3e --- /dev/null +++ b/resources/licenses/linux-supplemental/e2fsprogs/NOTICE @@ -0,0 +1,849 @@ +This package, the EXT2 filesystem utilities, are made available under +the GNU Public License version 2, with the exception of the lib/ext2fs +and lib/e2p libraries, which are made available under the GNU Library +General Public License Version 2, the lib/uuid library which is made +available under a BSD-style license and the lib/et and lib/ss +libraries which are made available under an MIT-style license. Please +see lib/uuid/COPYING for more details for the license for the files +comprising the libuuid library, and the source file headers of the +libet and libss libraries for more information. + +The most recent officially distributed version can be found at +http://e2fsprogs.sourceforge.net. If you need to make a distribution, +that's the one you should use. If there is some reason why you'd like +a more recent version that is still in ALPHA testing (i.e., either +using the "WIP" test distributions or one from the hg or git +repository from the development branch, please contact me +(tytso@mit.edu) before you ship. The release schedules for this +package are flexible, if you give me enough lead time. + + + Theodore Ts'o + 23-June-2007 + +---------------------------------------------------------------------- + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. + +---------------------------------------------------------------------- + + GNU LIBRARY GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the library GPL. It is + numbered 2 because it goes with version 2 of the ordinary GPL.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Library General Public License, applies to some +specially designated Free Software Foundation software, and to any +other libraries whose authors decide to use it. You can use it for +your libraries, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if +you distribute copies of the library, or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link a program with the library, you must provide +complete object files to the recipients so that they can relink them +with the library, after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + Our method of protecting your rights has two steps: (1) copyright +the library, and (2) offer you this license which gives you legal +permission to copy, distribute and/or modify the library. + + Also, for each distributor's protection, we want to make certain +that everyone understands that there is no warranty for this free +library. If the library is modified by someone else and passed on, we +want its recipients to know that what they have is not the original +version, so that any problems introduced by others will not reflect on +the original authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that companies distributing free +software will individually obtain patent licenses, thus in effect +transforming the program into proprietary software. To prevent this, +we have made it clear that any patent must be licensed for everyone's +free use or not licensed at all. + + Most GNU software, including some libraries, is covered by the ordinary +GNU General Public License, which was designed for utility programs. This +license, the GNU Library General Public License, applies to certain +designated libraries. This license is quite different from the ordinary +one; be sure to read it in full, and don't assume that anything in it is +the same as in the ordinary license. + + The reason we have a separate public license for some libraries is that +they blur the distinction we usually make between modifying or adding to a +program and simply using it. Linking a program with a library, without +changing the library, is in some sense simply using the library, and is +analogous to running a utility program or application program. However, in +a textual and legal sense, the linked executable is a combined work, a +derivative of the original library, and the ordinary General Public License +treats it as such. + + Because of this blurred distinction, using the ordinary General +Public License for libraries did not effectively promote software +sharing, because most developers did not use the libraries. We +concluded that weaker conditions might promote sharing better. + + However, unrestricted linking of non-free programs would deprive the +users of those programs of all benefit from the free status of the +libraries themselves. This Library General Public License is intended to +permit developers of non-free programs to use free libraries, while +preserving your freedom as a user of such programs to change the free +libraries that are incorporated in them. (We have not seen how to achieve +this as regards changes in header files, but we have achieved it as regards +changes in the actual functions of the Library.) The hope is that this +will lead to faster development of free libraries. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, while the latter only +works together with the library. + + Note that it is possible for a library to be covered by the ordinary +General Public License rather than by this special one. + + GNU LIBRARY GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library which +contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Library +General Public License (also called "this License"). Each licensee is +addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also compile or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + c) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + d) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the source code distributed need not include anything that is normally +distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Library General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/resources/licenses/linux-supplemental/libidn2/COPYING b/resources/licenses/linux-supplemental/libidn2/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/resources/licenses/linux-supplemental/libidn2/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/resources/licenses/linux-supplemental/libidn2/COPYING.LESSERv3 b/resources/licenses/linux-supplemental/libidn2/COPYING.LESSERv3 new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/resources/licenses/linux-supplemental/libidn2/COPYING.LESSERv3 @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/resources/licenses/linux-supplemental/libidn2/COPYING.unicode b/resources/licenses/linux-supplemental/libidn2/COPYING.unicode new file mode 100644 index 0000000..292bc7f --- /dev/null +++ b/resources/licenses/linux-supplemental/libidn2/COPYING.unicode @@ -0,0 +1,91 @@ +A. Unicode Copyright. + + Copyright © 1991-2016 Unicode, Inc. All rights reserved. + Certain documents and files on this website contain a legend indicating that "Modification is permitted." Any person is hereby authorized, without fee, to modify such documents and files to create derivative works conforming to the Unicode® Standard, subject to Terms and Conditions herein. + Any person is hereby authorized, without fee, to view, use, reproduce, and distribute all documents and files solely for informational purposes and in the creation of products supporting the Unicode Standard, subject to the Terms and Conditions herein. + Further specifications of rights and restrictions pertaining to the use of the particular set of data files known as the "Unicode Character Database" can be found in the License. + Each version of the Unicode Standard has further specifications of rights and restrictions of use. For the book editions (Unicode 5.0 and earlier), these are found on the back of the title page. The online code charts carry specific restrictions. All other files, including online documentation of the core specification for Unicode 6.0 and later, are covered under these general Terms of Use. + No license is granted to "mirror" the Unicode website where a fee is charged for access to the "mirror" site. + Modification is not permitted with respect to this document. All copies of this document must be verbatim. + +B. Restricted Rights Legend. + Any technical data or software which is licensed to the United States of America, its agencies and/or instrumentalities under this Agreement is commercial technical data or commercial computer software developed exclusively at private expense as defined in FAR 2.101, or DFARS 252.227-7014 (June 1995), as applicable. For technical data, use, duplication, or disclosure by the Government is subject to restrictions as set forth in DFARS 202.227-7015 Technical Data, Commercial and Items (Nov 1995) and this Agreement. For Software, in accordance with FAR 12-212 or DFARS 227-7202, as applicable, use, duplication or disclosure by the Government is subject to the restrictions set forth in this Agreement. + +C. Warranties and Disclaimers. + This publication and/or website may include technical or typographical errors or other inaccuracies . Changes are periodically added to the information herein; these changes will be incorporated in new editions of the publication and/or website. Unicode may make improvements and/or changes in the product(s) and/or program(s) described in this publication and/or website at any time. + If this file has been purchased on magnetic or optical media from Unicode, Inc. the sole and exclusive remedy for any claim will be exchange of the defective media within ninety (90) days of original purchase. + EXCEPT AS PROVIDED IN SECTION C.2, THIS PUBLICATION AND/OR SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND EITHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. UNICODE AND ITS LICENSORS ASSUME NO RESPONSIBILITY FOR ERRORS OR OMISSIONS IN THIS PUBLICATION AND/OR SOFTWARE OR OTHER DOCUMENTS WHICH ARE REFERENCED BY OR LINKED TO THIS PUBLICATION OR THE UNICODE WEBSITE. + +D. Waiver of Damages. + In no event shall Unicode or its licensors be liable for any special, incidental, indirect or consequential damages of any kind, or any damages whatsoever, whether or not Unicode was advised of the possibility of the damage, including, without limitation, those resulting from the following: loss of use, data or profits, in connection with the use, modification or distribution of this information or its derivatives. + +E. Trademarks & Logos. + The Unicode Word Mark and the Unicode Logo are trademarks of Unicode, Inc. “The Unicode Consortium” and “Unicode, Inc.” are trade names of Unicode, Inc. Use of the information and materials found on this website indicates your acknowledgement of Unicode, Inc.’s exclusive worldwide rights in the Unicode Word Mark, the Unicode Logo, and the Unicode trade names. + The Unicode Consortium Name and Trademark Usage Policy (“Trademark Policy”) are incorporated herein by reference and you agree to abide by the provisions of the Trademark Policy, which may be changed from time to time in the sole discretion of Unicode, Inc. + All third party trademarks referenced herein are the property of their respective owners. + +F. Miscellaneous. + Jurisdiction and Venue. This server is operated from a location in the State of California, United States of America. Unicode makes no representation that the materials are appropriate for use in other locations. If you access this server from other locations, you are responsible for compliance with local laws. This Agreement, all use of this site and any claims and damages resulting from use of this site are governed solely by the laws of the State of California without regard to any principles which would apply the laws of a different jurisdiction. The user agrees that any disputes regarding this site shall be resolved solely in the courts located in Santa Clara County, California. The user agrees said courts have personal jurisdiction and agree to waive any right to transfer the dispute to any other forum. + Modification by Unicode Unicode shall have the right to modify this Agreement at any time by posting it to this site. The user may not assign any part of this Agreement without Unicode’s prior written consent. + Taxes. The user agrees to pay any taxes arising from access to this website or use of the information herein, except for those based on Unicode’s net income. + Severability. If any provision of this Agreement is declared invalid or unenforceable, the remaining provisions of this Agreement shall remain in effect. + Entire Agreement. This Agreement constitutes the entire agreement between the parties. + + + +EXHIBIT 1 +Unicode Data Files include all data files under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and +http://www.unicode.org/utility/trac/browser/. + +Unicode Data Files do not include PDF online code charts under the +directory http://www.unicode.org/Public/. + +Software includes any source code published in the Unicode Standard +or under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and +http://www.unicode.org/utility/trac/browser/. + +NOTICE TO USER: Carefully read the following legal agreement. +BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S +DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), +YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. +IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE +THE DATA FILES OR SOFTWARE. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2016 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in http://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. diff --git a/resources/licenses/linux-supplemental/libidn2/COPYINGv2 b/resources/licenses/linux-supplemental/libidn2/COPYINGv2 new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/resources/licenses/linux-supplemental/libidn2/COPYINGv2 @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/resources/licenses/linux-supplemental/opencore-amr/LICENSE b/resources/licenses/linux-supplemental/opencore-amr/LICENSE new file mode 100644 index 0000000..5ec4bf0 --- /dev/null +++ b/resources/licenses/linux-supplemental/opencore-amr/LICENSE @@ -0,0 +1,191 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty +percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, +and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation +or translation of a Source form, including but not limited to compiled +object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that +is included in or attached to the work (an example is provided in the +Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, +that is based on (or derived from) the Work and for which the editorial +revisions, annotations, elaborations, or other modifications represent, as +a whole, an original work of authorship. For the purposes of this License, +Derivative Works shall not include works that remain separable from, or +merely link (or bind by name) to the interfaces of, the Work and Derivative +Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the +purposes of this definition, "submitted" means any form of electronic, +verbal, or written communication sent to the Licensor or its +representatives, including but not limited to communication on electronic +mailing lists, source code control systems, and issue tracking systems that +are managed by, or on behalf of, the Licensor for the purpose of discussing +and improving the Work, but excluding communication that is conspicuously +marked or otherwise designated in writing by the copyright owner as "Not a +Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare Derivative Works of, publicly display, publicly perform, +sublicense, and distribute the Work and such Derivative Works in Source or +Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this +License, each Contributor hereby grants to You a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in +this section) patent license to make, have made, use, offer to sell, sell, +import, and otherwise transfer the Work, where such license applies only to +those patent claims licensable by such Contributor that are necessarily +infringed by their Contribution(s) alone or by combination of their +Contribution(s) with the Work to which such Contribution(s) was submitted. +If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or +contributory patent infringement, then any patent licenses granted to You +under this License for that Work shall terminate as of the date such +litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or +Derivative Works thereof in any medium, with or without modifications, and +in Source or Object form, provided that You meet the following conditions: + + 1. You must give any other recipients of the Work or Derivative Works a +copy of this License; and + + 2. You must cause any modified files to carry prominent notices stating +that You changed the files; and + + 3. You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from +the Source form of the Work, excluding those notices that do not pertain to +any part of the Derivative Works; and + + 4. If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must include a +readable copy of the attribution notices contained within such NOTICE file, +excluding those notices that do not pertain to any part of the Derivative +Works, in at least one of the following places: within a NOTICE text file +distributed as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, within a +display generated by the Derivative Works, if and wherever such third-party +notices normally appear. The contents of the NOTICE file are for +informational purposes only and do not modify the License. You may add Your +own attribution notices within Derivative Works that You distribute, +alongside or as an addendum to the NOTICE text from the Work, provided that +such additional attribution notices cannot be construed as modifying the +License. + +You may add Your own copyright statement to Your modifications and may +provide additional or different license terms and conditions for use, +reproduction, or distribution of Your modifications, or for any such +Derivative Works as a whole, provided Your use, reproduction, and +distribution of the Work otherwise complies with the conditions stated in +this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any +Contribution intentionally submitted for inclusion in the Work by You to +the Licensor shall be under the terms and conditions of this License, +without any additional terms or conditions. Notwithstanding the above, +nothing herein shall supersede or modify the terms of any separate license +agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, except +as required for reasonable and customary use in describing the origin of +the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to +in writing, Licensor provides the Work (and each Contributor provides its +Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied, including, without limitation, any +warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for +determining the appropriateness of using or redistributing the Work and +assume any risks associated with Your exercise of permissions under this +License. + +8. Limitation of Liability. In no event and under no legal theory, whether +in tort (including negligence), contract, or otherwise, unless required by +applicable law (such as deliberate and grossly negligent acts) or agreed to +in writing, shall any Contributor be liable to You for damages, including +any direct, indirect, special, incidental, or consequential damages of any +character arising as a result of this License or out of the use or +inability to use the Work (including but not limited to damages for loss of +goodwill, work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor has been +advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the +Work or Derivative Works thereof, You may choose to offer, and charge a fee +for, acceptance of support, warranty, indemnity, or other liability +obligations and/or rights consistent with this License. However, in +accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if +You agree to indemnify, defend, and hold each Contributor harmless for any +liability incurred by, or claims asserted against, such Contributor by +reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included +on the same "printed page" as the copyright notice for easier +identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); you may + not use this file except in compliance with the License. You may obtain a + copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable + law or agreed to in writing, software distributed under the License is + distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. diff --git a/resources/licenses/linux-supplemental/sources.json b/resources/licenses/linux-supplemental/sources.json new file mode 100644 index 0000000..af0372c --- /dev/null +++ b/resources/licenses/linux-supplemental/sources.json @@ -0,0 +1,32 @@ +[ + { + "package": "e2fsprogs", + "version": "1.47.4", + "url": "https://raw.githubusercontent.com/tytso/e2fsprogs/v1.47.4/NOTICE", + "downloadSha256": "5da5ef153e559c1d990d4c3eedbedd4442db892d37eae1f35fff069de8ec9020", + "files": { + "NOTICE": "5da5ef153e559c1d990d4c3eedbedd4442db892d37eae1f35fff069de8ec9020" + } + }, + { + "package": "libidn2", + "version": "2.3.8", + "url": "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz", + "downloadSha256": "f557911bf6171621e1f72ff35f5b1825bb35b52ed45325dcdee931e5d3c0787a", + "files": { + "COPYING": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "COPYING.LESSERv3": "e3a994d82e644b03a792a930f574002658412f62407f5fee083f2555c5f23118", + "COPYING.unicode": "01d621eef165cf4d3d3dbb737aa0699178d94c6f18cf87e9dde6db3ca7790f46", + "COPYINGv2": "edaef632cbb643e4e7a221717a6c441a4c1a7c918e6e4d56debc3d8739b233f6" + } + }, + { + "package": "opencore-amr", + "version": "0.1.6", + "url": "https://downloads.sourceforge.net/project/opencore-amr/opencore-amr/opencore-amr-0.1.6.tar.gz", + "downloadSha256": "483eb4061088e2b34b358e47540b5d495a96cd468e361050fae615b1809dc4a1", + "files": { + "LICENSE": "8b3f1762349248d444ab9acbafe73941254e36e1064954da56bb9ddbd5873ddb" + } + } +] diff --git a/resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt b/resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt new file mode 100644 index 0000000..e159d28 --- /dev/null +++ b/resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt @@ -0,0 +1,311 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== + +The libc++ library is dual licensed under both the University of Illinois +"BSD-Like" license and the MIT license. As a user of this code you may choose +to use it under either license. As a contributor, you agree to allow your code +to be used under both. + +Full text of the relevant licenses is included below. + +============================================================================== + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2019 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +============================================================================== + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/resources/licenses/pdfium-supplemental/libcxxabi-LICENSE.txt b/resources/licenses/pdfium-supplemental/libcxxabi-LICENSE.txt new file mode 100644 index 0000000..b75c044 --- /dev/null +++ b/resources/licenses/pdfium-supplemental/libcxxabi-LICENSE.txt @@ -0,0 +1,311 @@ +============================================================================== +The LLVM Project is under the Apache License v2.0 with LLVM Exceptions: +============================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +============================================================================== +Software from third parties included in the LLVM Project: +============================================================================== +The LLVM Project contains third party software which is under different license +terms. All such code will be identified clearly using at least one of two +mechanisms: +1) It will be in a separate directory tree with its own `LICENSE.txt` or + `LICENSE` file at the top containing the specific license and restrictions + which apply to that software, or +2) It will contain specific license and restriction terms at the top of every + file. + +============================================================================== +Legacy LLVM License (https://llvm.org/docs/DeveloperPolicy.html#legacy): +============================================================================== + +The libc++abi library is dual licensed under both the University of Illinois +"BSD-Like" license and the MIT license. As a user of this code you may choose +to use it under either license. As a contributor, you agree to allow your code +to be used under both. + +Full text of the relevant licenses is included below. + +============================================================================== + +University of Illinois/NCSA +Open Source License + +Copyright (c) 2009-2019 by the contributors listed in CREDITS.TXT + +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +============================================================================== + +Copyright (c) 2009-2014 by the contributors listed in CREDITS.TXT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/resources/licenses/pdfium-supplemental/sources.json b/resources/licenses/pdfium-supplemental/sources.json new file mode 100644 index 0000000..8b96b66 --- /dev/null +++ b/resources/licenses/pdfium-supplemental/sources.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "scope": "Additional fixed-source notices for the inspected Linux x64 PDFium binary; not a complete dependency or legal-compliance assessment", + "pdfiumVersion": "155.0.8057.0", + "pdfiumUpstreamCommit": "a5a7089234f121990b336b3841008009dca143bf", + "pdfiumLibrarySha256": "08f6ea53a64f6fbb9f5ba8d9c02e0051987c6a9175f3fb5ba25f219174731aaa", + "providerRecipeCommit": "f2e9a1c45bb17b85b540abf1af30146ef65416ac", + "reason": "Provider notice collection ignores libc; the pinned build selects custom libc++ and the inspected binary contains libc++/libc++abi evidence. Retain both full source license files.", + "files": [ + { + "name": "libcxx-LICENSE.txt", + "component": "libc++", + "sourceRevision": "97b436da4c33663581d394f4ee0a5977fc38c2f4", + "sourceUrl": "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxx.git/+/97b436da4c33663581d394f4ee0a5977fc38c2f4/LICENSE.TXT?format=TEXT", + "sha256": "539dd7aed86e8a4f12cbdd0e6c50c189c7d74847e4fecc64ce2c6ee3a01da38b", + "size": 16703 + }, + { + "name": "libcxxabi-LICENSE.txt", + "component": "libc++abi", + "sourceRevision": "fc1897a2c12aa27e703c3ed48b62eba8abf4ce19", + "sourceUrl": "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxxabi.git/+/fc1897a2c12aa27e703c3ed48b62eba8abf4ce19/LICENSE.TXT?format=TEXT", + "sha256": "e2b35be49f7284a45b7baca8fc7b3ab7440e7902392b2528a457816b5bb2a15c", + "size": 16706 + } + ] +} diff --git a/resources/licenses/qpdf/LICENSE.txt b/resources/licenses/qpdf/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/resources/licenses/qpdf/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/resources/licenses/qpdf/README.txt b/resources/licenses/qpdf/README.txt new file mode 100644 index 0000000..e7e4ea9 --- /dev/null +++ b/resources/licenses/qpdf/README.txt @@ -0,0 +1,12 @@ +qpdf 12.4.1 +Upstream: https://github.com/qpdf/qpdf/tree/v12.4.1 +Project licensing information: https://qpdf.readthedocs.io/en/stable/license.html + +LICENSE.txt is an unmodified copy from the qpdf 12.4.1-1 package used for +the local Linux build (/usr/share/licenses/qpdf/LICENSE.txt). +DocView uses the public libqpdf API inside PDFWorker to read annotation +border dictionaries. It does not modify or embed qpdf source code. + +The local Linux install uses the system libqpdf shared library. A Windows +distribution must include the notices and licenses for the actual qpdf +binary and its linked dependencies as part of the release packaging review. diff --git a/resources/licenses/qt-embedded-notices/20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0.txt b/resources/licenses/qt-embedded-notices/20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0.txt new file mode 100644 index 0000000..7391262 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0.txt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2010 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44.txt b/resources/licenses/qt-embedded-notices/23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44.txt new file mode 100644 index 0000000..8a65567 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44.txt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2009 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282.txt b/resources/licenses/qt-embedded-notices/3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282.txt new file mode 100644 index 0000000..ce0d3e9 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282.txt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2012 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH + * DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed.txt b/resources/licenses/qt-embedded-notices/4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed.txt new file mode 100644 index 0000000..f110476 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed.txt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2006 Apple Computer, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE COMPUTER, INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE COMPUTER, INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c.txt b/resources/licenses/qt-embedded-notices/7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c.txt new file mode 100644 index 0000000..75743a9 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c.txt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2012 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528.txt b/resources/licenses/qt-embedded-notices/9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528.txt new file mode 100644 index 0000000..2469873 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528.txt @@ -0,0 +1,26 @@ +/* + * The default style sheet used to render SVG. + * + * Copyright (C) 2005, 2006 Apple Computer, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE COMPUTER, INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE COMPUTER, INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49.txt b/resources/licenses/qt-embedded-notices/c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49.txt new file mode 100644 index 0000000..59bce02 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49.txt @@ -0,0 +1,26 @@ +/* + * The default style sheet used to render MathML. + * + * Copyright (C) 2014 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE COMPUTER, INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE COMPUTER, INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ \ No newline at end of file diff --git a/resources/licenses/qt-embedded-notices/sources.json b/resources/licenses/qt-embedded-notices/sources.json new file mode 100644 index 0000000..c380c75 --- /dev/null +++ b/resources/licenses/qt-embedded-notices/sources.json @@ -0,0 +1,198 @@ +{ + "comparisonScope": "These seven complete legal texts have no byte-identical, normalized-identical, or normalized-contained match among the 523 system and 17 PDFium notices in the previously inspected package; similar terms and legal sufficiency were not assessed.", + "completeChromiumNotices": false, + "dataPacks": [ + { + "package": "qt6-webengine", + "packageVersion": "6.11.2-1", + "path": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "sha256": "e51fbb20ff31cd4407ab34338f9f9f1afc7eadd270ce6e3cf9901f3622647b96", + "size": 2452411 + }, + { + "package": "qt6-webengine", + "packageVersion": "6.11.2-1", + "path": "/usr/share/qt6/resources/qtwebengine_devtools_resources.pak", + "sha256": "a27b04ae3810f73c6f9a5aea02bb0b35fac626b4b94572109e423bab797b1bc0", + "size": 11698477 + } + ], + "extractionEvidence": { + "path": "tests/results/source-correspondence/qt-embedded-notices/first/report.json", + "sha256": "0c14b488c230465958dfcb34bfcc46ce5a4c1fc20f37fe2e585859c1100e8792" + }, + "files": [ + { + "name": "20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0.txt", + "sha256": "20a973990b2f574890615dcda764a32f1087dfb0c93eeab041f8edbb1ee461c0", + "size": 1562, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_devtools_resources.pak", + "endByteExclusive": 1562, + "entrySha256": "f558156d8ac09451f5666e3ffafa8e2dad3707f9a85b0b4c76c521aedb4edd4a", + "entrySize": 61820, + "resourceId": 50485, + "startByte": 0 + } + ] + }, + { + "name": "23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44.txt", + "sha256": "23ed0ee0cf026ef6601ba5c92c1189f225dad76068d3cc1902ed43391bb07d44", + "size": 1562, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1562, + "entrySha256": "b5b4aecf18d7f38c6a2167460c76af23a6d0e71d778127ae6709e610237aee3b", + "entrySize": 3136, + "resourceId": 44825, + "startByte": 0 + } + ] + }, + { + "name": "3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282.txt", + "sha256": "3050e356873d44a733913871ab7f15267af5898d58f2d6ab272371adb4c95282", + "size": 1355, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1369, + "entrySha256": "2c90178ecfefc9fa6421a0038b9d41477eec3cecbf09ff29ca2cf211ec9d97b8", + "entrySize": 12904, + "resourceId": 44844, + "startByte": 14 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1355, + "entrySha256": "e3eb8a1d4f20b4409996047cda8d59943bf4d42b4dec4262556e887b21253e74", + "entrySize": 1518, + "resourceId": 44845, + "startByte": 0 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1369, + "entrySha256": "87a5e63aad7f15d1ca9ab0f26f82b4e260f635edbb5429a2cf29f7ab0e3278c3", + "entrySize": 13506, + "resourceId": 44853, + "startByte": 14 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1355, + "entrySha256": "9c421afcc6128a04e75c27e38cef7479deb1846bff47487d7c1fe171a282003c", + "entrySize": 4100, + "resourceId": 44855, + "startByte": 0 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1369, + "entrySha256": "b9b6201609c8c37c624858108bad546e5def00898f3c5cb4c2f380df5c2eaf9b", + "entrySize": 7877, + "resourceId": 44856, + "startByte": 14 + } + ] + }, + { + "name": "4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed.txt", + "sha256": "4cfb67cb718c860a0fbfb311917d358380cdae89647a03b5d1553635a2b5eaed", + "size": 1291, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1291, + "entrySha256": "f53caf157c449529b882b3ffe1fe218b8f7f39974735a8876445c92a8a61a21e", + "entrySize": 4437, + "resourceId": 44822, + "startByte": 0 + } + ] + }, + { + "name": "7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c.txt", + "sha256": "7faa9d1d99e7c8104fb2c1241051905186376aa0d5275d812c9cd24861cc458c", + "size": 1562, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1562, + "entrySha256": "68b477e85e06e84b92c4cb956b6072fc44c77e0418718ae0732645d659ae98d4", + "entrySize": 2687, + "resourceId": 44823, + "startByte": 0 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1562, + "entrySha256": "730d9a36ff0539952fb6d3300c7b27a6bb1586d5b36d7d10f1b0050ac10157d5", + "entrySize": 18530, + "resourceId": 44846, + "startByte": 0 + }, + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1576, + "entrySha256": "084c7fe91b2f3198862918884a47043690ed8d6eaee6b97564b580bcc754b255", + "entrySize": 140644, + "resourceId": 44847, + "startByte": 14 + } + ] + }, + { + "name": "9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528.txt", + "sha256": "9a33e7247adcc6a4a8e5277c1ed1a5108ac4171799e6c691d928fc574467b528", + "size": 1418, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1418, + "entrySha256": "bcf80bc68a433419b2d9024f8cae288399582b796a61a67bfc53c4c9f6b25240", + "entrySize": 3723, + "resourceId": 44832, + "startByte": 0 + } + ] + }, + { + "name": "c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49.txt", + "sha256": "c727e9f69804169e68131c4a8d09b3b059a6f6484a34fa3a0342e601354bbf49", + "size": 1404, + "sourceResources": [ + { + "compression": "brotli", + "dataPackPath": "/usr/share/qt6/resources/qtwebengine_resources.pak", + "endByteExclusive": 1404, + "entrySha256": "569cb8621956590633cc5bdc69c274d319abf18a15ecfaebbb3bc6992e9240f1", + "entrySize": 3859, + "resourceId": 44834, + "startByte": 0 + } + ] + } + ], + "package": "qt6-webengine", + "packageVersion": "6.11.2-1", + "qtVersion": "6.11.2", + "runtimeDistribution": "system-not-bundled", + "schemaVersion": 1, + "scope": "Partial notice texts extracted from 13 resources in the inspected QtWebEngine DataPacks; not complete Chromium attributions or corresponding sources" +} diff --git a/resources/linux/deb-postinst b/resources/linux/deb-postinst new file mode 100644 index 0000000..3bb59ec --- /dev/null +++ b/resources/linux/deb-postinst @@ -0,0 +1,10 @@ +#!/bin/sh +set -e +case "$1" in + configure) + if [ -d /sys/module/apparmor ]; then + /usr/sbin/apparmor_parser -r /etc/apparmor.d/docview + fi + ;; +esac +exit 0 diff --git a/resources/linux/deb-prerm b/resources/linux/deb-prerm new file mode 100644 index 0000000..969ac8d --- /dev/null +++ b/resources/linux/deb-prerm @@ -0,0 +1,10 @@ +#!/bin/sh +set -e +case "$1" in + remove|deconfigure) + if [ -d /sys/module/apparmor ]; then + /usr/sbin/apparmor_parser -R /etc/apparmor.d/docview + fi + ;; +esac +exit 0 diff --git a/resources/linux/docview-launcher b/resources/linux/docview-launcher new file mode 100644 index 0000000..1cd7d40 --- /dev/null +++ b/resources/linux/docview-launcher @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +docview_root=/opt/docview +export LD_LIBRARY_PATH="$docview_root/lib:$docview_root/qt/lib" +export QT_PLUGIN_PATH="$docview_root/qt/plugins" +export QT_QPA_PLATFORM_PLUGIN_PATH="$docview_root/qt/plugins/platforms" +export QML_IMPORT_PATH="$docview_root/qt/qml" +export QML2_IMPORT_PATH="$docview_root/qt/qml" +export QTWEBENGINEPROCESS_PATH="$docview_root/qt/libexec/QtWebEngineProcess" +export QTWEBENGINE_RESOURCES_PATH="$docview_root/qt/resources" +export QTWEBENGINE_LOCALES_PATH="$docview_root/qt/translations/qtwebengine_locales" +exec "$docview_root/bin/docview" "$@" diff --git a/resources/linux/docview.apparmor b/resources/linux/docview.apparmor new file mode 100644 index 0000000..2129110 --- /dev/null +++ b/resources/linux/docview.apparmor @@ -0,0 +1,7 @@ +abi , +include + +profile docview-bundled-qtwebengine /opt/docview/qt/libexec/QtWebEngineProcess flags=(unconfined) { + userns, + include if exists +} diff --git a/resources/linux/docview.desktop b/resources/linux/docview.desktop new file mode 100644 index 0000000..b194163 --- /dev/null +++ b/resources/linux/docview.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=DocView +Comment=Read PDF, HTML and EPUB documents +Comment[ja]=PDF・HTML・EPUB文書を閲覧 +Exec=/usr/bin/docview %f +Icon=text-x-generic +Terminal=false +Categories=Office;Viewer; +MimeType=application/pdf;text/html;application/xhtml+xml;application/epub+zip;application/zip; diff --git a/resources/reader.js b/resources/reader.js new file mode 100644 index 0000000..9c71390 --- /dev/null +++ b/resources/reader.js @@ -0,0 +1,516 @@ +(function () { + "use strict"; + // This fixed program runs only in Qt's isolated ApplicationWorld. It adds + // no DOM nodes, IDs, bridges, document scripts, or host capabilities. + if (globalThis.__docviewReader) return; + const MAX_INDEX = 2000; + let headings = []; + let options = { fixed: false, rtl: false, publisherStyle: true }; + let originalTypography = null; + let focusedLink = null; + let linkOutline = null; + let restoredRange = null; + let layoutAnchor = null, resourceRestoreFrame = 0; + let userScrollPending = false, userScrollFrame = 0; + let navigationTargets = [], navigationRevision = ""; + let resourceIssues = Object.create(null); + // Only aggregate directive classes. Never copy blockedURI, sourceFile, + // originalPolicy or sample into the application or its saved state. + document.addEventListener("securitypolicyviolation", event => { + if (!event.isTrusted || event.disposition !== "enforce") return; + const directive = String(event.effectiveDirective || "").split("-")[0]; + const category = ({img:"image", style:"style", font:"font", script:"script", frame:"frame", + child:"frame", connect:"connect", form:"form", base:"base", object:"object", media:"media"})[directive] || "other"; + const key = "csp." + category; + resourceIssues[key] = Math.min(999999, (resourceIssues[key] || 0) + 1); + }); + + function text(node, limit = 512) { + return String(node ? node.textContent || "" : "").replace(/\s+/g, " ").trim().slice(0, limit); + } + function visible(element) { + if (!element || !element.isConnected || element.hidden || element.closest("[hidden],[aria-hidden='true']")) return false; + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse" && element.getClientRects().length > 0; + } + function axis() { + const style = getComputedStyle(document.body || document.documentElement); + const vertical = /^(vertical|sideways)/.test(style.writingMode); + return { vertical, reverse: vertical && /-rl$/.test(style.writingMode), writingMode: style.writingMode }; + } + function progression() { + const root = document.scrollingElement || document.documentElement; + const a = axis(); + const total = a.vertical ? root.scrollWidth - innerWidth : root.scrollHeight - innerHeight; + const position = a.vertical ? (a.reverse ? -scrollX : scrollX) : scrollY; + return total > 0 ? Math.max(0, Math.min(1, position / total)) : 0; + } + function setProgression(value) { + const root = document.scrollingElement || document.documentElement; + const a = axis(); + const p = Math.max(0, Math.min(1, Number(value) || 0)); + const total = a.vertical ? root.scrollWidth - innerWidth : root.scrollHeight - innerHeight; + if (a.vertical) scrollTo({ left: Math.max(0, total) * p * (a.reverse ? -1 : 1), top: 0, behavior: "instant" }); + else scrollTo({ left: 0, top: Math.max(0, total) * p, behavior: "instant" }); + } + function hrefFor(reference, base = document.baseURI) { + try { + const url = new URL(reference, base); + if (url.protocol !== "doc:" || url.host !== location.host || url.username || url.password) return ""; + return url.pathname.replace(/^\//, "") + url.search + url.hash; + } catch (_) { return ""; } + } + function headingIndex() { + headings = []; + const nodes = document.querySelectorAll("h1,h2,h3,h4,h5,h6,[role='heading']"); + let truncated = false; + for (const node of nodes) { + if (!visible(node)) continue; + const name = node.localName.toLowerCase(); + const nativeLevel = /^h[1-6]$/.test(name) ? Number(name[1]) : 0; + const ariaLevel = Number(node.getAttribute("aria-level")); + const level = nativeLevel || (Number.isInteger(ariaLevel) && ariaLevel > 0 && ariaLevel <= 6 ? ariaLevel : 0); + if (!level) continue; + if (headings.length === MAX_INDEX) { truncated = true; break; } + headings.push({ element: node, level, title: text(node) || "見出し(レベル" + level + ")" }); + } + return { truncated, rows: headings.map((h, index) => ({ index, title: h.title, level: h.level, depth: h.level - 1, + href: h.element.id ? hrefFor("#" + encodeURIComponent(h.element.id), location.href) : "", source: "html-heading", precision: "exact" })) }; + } + function index() { + const result = headingIndex(); + const outline = []; + const navs = document.querySelectorAll("[role='doc-toc'],nav"); + for (const nav of navs) { + // XHTML keeps epub:type in its XML namespace; HTML retains the + // prefixed attribute name. CSS attribute selectors do not match both. + const epubType = nav.getAttributeNS("http://www.idpf.org/2007/ops", "type") || nav.getAttribute("epub:type") || ""; + if (nav.getAttribute("role") !== "doc-toc" && !epubType.split(/\s+/).includes("toc")) continue; + for (const link of nav.querySelectorAll("a[href]")) { + if (!visible(link) || outline.length >= MAX_INDEX) continue; + let depth = 0; + for (let parent = link.parentElement; parent && parent !== nav; parent = parent.parentElement) + if (parent.localName.toLowerCase() === "ol" || parent.localName.toLowerCase() === "ul") ++depth; + const href = hrefFor(link.getAttribute("href")); + outline.push({ title: text(link) || "目次項目", href, depth: Math.max(0, depth - 1), missing: !href, source: "html-toc" }); + } + } + return { title: String(document.title || "").slice(0, 4096), headings: result.rows, outline, + truncated: result.truncated, scriptCount: document.scripts.length, + fixedViewport: viewport(), writingMode: axis().writingMode }; + } + function viewport() { + const element = document.querySelector("meta[name='viewport']"); + const value = element ? element.getAttribute("content") || "" : ""; + const w = /(?:^|[,;\s])width\s*=\s*([0-9.]+)/i.exec(value); + const h = /(?:^|[,;\s])height\s*=\s*([0-9.]+)/i.exec(value); + let width = w ? Number(w[1]) : 0, height = h ? Number(h[1]) : 0; + if ((!width || !height) && document.documentElement.localName === "svg") { + const box = document.documentElement.viewBox && document.documentElement.viewBox.baseVal; + if (box) { width = box.width; height = box.height; } + } + return width > 0 && height > 0 && width <= 100000 && height <= 100000 ? { width, height } : {}; + } + function cfiFor(node, offset = 0) { + const steps = []; + let target = node; + if (!target) return ""; + if (target.nodeType === Node.TEXT_NODE) { + let elements = 0, previousText = 0; + for (const sibling of target.parentNode.childNodes) { + if (sibling === target) break; + if (sibling.nodeType === Node.ELEMENT_NODE) { ++elements; previousText = 0; } + if (sibling.nodeType === Node.TEXT_NODE) previousText += sibling.length; + } + steps.unshift(elements * 2 + 1); + offset += previousText; + target = target.parentElement; + } + while (target && target !== document.documentElement && steps.length < 128) { + let order = 1; + for (let sibling = target.previousElementSibling; sibling; sibling = sibling.previousElementSibling) ++order; + steps.unshift(order * 2); + target = target.parentElement; + } + return target === document.documentElement ? "epubcfi(/" + steps.join("/") + ":" + Math.max(0, offset) + ")" : ""; + } + function resolveCfi(cfi) { + if (typeof cfi !== "string" || cfi.length > 4096 || !/^epubcfi\(.*\)$/.test(cfi)) return null; + let value = cfi.slice(8, -1); + if (value.includes("!")) value = value.slice(value.lastIndexOf("!") + 1); + value = value.replace(/\[(?:\^.|[^\]])*\]/g, ""); + if (!/^\/(?:[1-9][0-9]*\/)*[1-9][0-9]*(?::[0-9]+)?$/.test(value)) return null; + const parts = value.split(":"); + const steps = parts[0].slice(1).split("/").map(Number); + if (steps.length > 128) return null; + let node = document.documentElement; + let offset = Number(parts[1] || 0); + for (let i = 0; i < steps.length; ++i) { + const step = steps[i]; + if (step % 2 === 0) node = node && node.children && node.children[step / 2 - 1]; + else { + if (!node || i !== steps.length - 1) return null; + const slot = (step - 1) / 2; + let elements = 0, found = null; + for (const child of node.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE) ++elements; + else if (child.nodeType === Node.TEXT_NODE && elements === slot) { + if (offset <= child.length) { found = child; break; } + offset -= child.length; + } + } + node = found; + } + if (!node) return null; + } + const range = document.createRange(); + if (node.nodeType === Node.TEXT_NODE) range.setStart(node, Math.min(offset, node.length)); + else range.setStart(node, Math.min(offset, node.childNodes.length)); + range.collapse(true); + return range; + } + function visibleRange() { + const a = axis(); + const x = Math.max(1, Math.min(innerWidth - 1, a.vertical && a.reverse ? innerWidth - 20 : 20)); + const y = Math.max(1, Math.min(innerHeight - 1, 20)); + let range = document.caretRangeFromPoint ? document.caretRangeFromPoint(x, y) : null; + if (range && range.startContainer.nodeType === Node.TEXT_NODE) return range; + const element = document.elementFromPoint(x, y) || document.body || document.documentElement; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node, inspected = 0; + while ((node = walker.nextNode()) && ++inspected < 1000) { + if (!node.textContent.trim() || !visible(node.parentElement)) continue; + range = document.createRange(); range.setStart(node, 0); range.collapse(true); return range; + } + range = document.createRange(); range.selectNodeContents(element); range.collapse(true); return range; + } + function locate() { + // A font can change layout before its loadingdone event, and a location + // poll can run before the image's animation-frame restoration. Retain + // the pre-layout anchor until that restoration has used it. + if (layoutAnchor && layoutMoved(layoutAnchor)) resourcesSettled(); + // Reflow can put a different character at the viewport sampling point. + // Keep the restored logical character until the reader actually scrolls. + const keep = restoredRange && document.documentElement.contains(restoredRange.range.startContainer) && + Math.abs(scrollX - restoredRange.x) < 1 && Math.abs(scrollY - restoredRange.y) < 1; + if (!keep) restoredRange = null; + const range = keep ? restoredRange.range : visibleRange(); + const node = range.startContainer; + const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + const rect = range.getBoundingClientRect(); + const result = { url: location.href, cfi: cfiFor(node, range.startOffset), fragment: element && element.id || "", + progression: progression(), textQuote: node.nodeType === Node.TEXT_NODE ? node.textContent.slice(range.startOffset, range.startOffset + 160) : text(element, 160), + offsetX: rect.left / Math.max(1, innerWidth), offsetY: rect.top / Math.max(1, innerHeight), writingMode: axis().writingMode, + currentOutline: currentNavigation(range), navigationRevision }; + if (!resourceRestoreFrame) layoutAnchor = { range: range.cloneRange(), saved: result, + x:scrollX, y:scrollY, documentX:rect.left + scrollX, documentY:rect.top + scrollY, + width:innerWidth, height:innerHeight }; + return result; + } + function layoutMoved(anchor) { + if (!anchor.range.startContainer.isConnected || anchor.width !== innerWidth || anchor.height !== innerHeight) return false; + const rect = anchor.range.getBoundingClientRect(); + return Math.abs(rect.left + scrollX - anchor.documentX) > 1 || Math.abs(rect.top + scrollY - anchor.documentY) > 1; + } + function cancelResourceRestore() { + if (resourceRestoreFrame) cancelAnimationFrame(resourceRestoreFrame); + resourceRestoreFrame = 0; + layoutAnchor = null; + } + function resourcesSettled() { + if (resourceRestoreFrame || !layoutAnchor || options.fixed) return; + const anchor = layoutAnchor; + resourceRestoreFrame = requestAnimationFrame(() => { + resourceRestoreFrame = 0; + // Retain the character and its viewport fraction across a decoded + // image or font swap. Never insert helper nodes into the source DOM. + // Clear the old snapshot before locate(), otherwise it would see + // the same completed layout change and schedule another frame. + const current = layoutAnchor === anchor; + if (current) layoutAnchor = null; + if (current && anchor.range.startContainer.isConnected) + positionRange(anchor.range, anchor.saved); + userScrollPending = false; + locate(); + }); + } + document.addEventListener("load", event => { + if (event.isTrusted && event.target instanceof HTMLImageElement) resourcesSettled(); + }, true); + document.addEventListener("error", event => { + if (event.isTrusted && event.target instanceof HTMLImageElement) resourcesSettled(); + }, true); + if (document.fonts) { + document.fonts.addEventListener("loadingdone", resourcesSettled); + document.fonts.addEventListener("loadingerror", resourcesSettled); + } + for (const type of ["wheel", "touchstart", "touchmove", "pointerdown", "pointermove", "keydown"]) + document.addEventListener(type, event => { + if (!event.isTrusted || (type === "pointermove" && !event.buttons)) return; + cancelResourceRestore(); + // Clicking without scrolling must not leave a 400 ms gap without + // any anchor. A following input scroll still takes precedence. + if (document.body) locate(); + userScrollPending = true; + // Input intent applies to this frame, not to an unrelated native + // anchor adjustment long after a click that did not scroll. + if (userScrollFrame) cancelAnimationFrame(userScrollFrame); + userScrollFrame = requestAnimationFrame(() => { userScrollFrame = 0; userScrollPending = false; }); + }, {capture:true, passive:true}); + document.addEventListener("scroll", () => { + if (layoutAnchor && Math.abs(scrollX - layoutAnchor.x) < 1 && Math.abs(scrollY - layoutAnchor.y) < 1) return; + if (!userScrollPending && layoutAnchor && layoutMoved(layoutAnchor)) { + // Native overflow-anchor can move the scroll offset as resources + // reflow. This is not a new reader navigation. + resourcesSettled(); + return; + } + userScrollPending = false; + cancelResourceRestore(); + restoredRange = null; + locate(); + }, {passive:true}); + window.addEventListener("hashchange", () => { cancelResourceRestore(); locate(); }); + function configureNavigation(args) { + navigationTargets = []; navigationRevision = ""; + if (!args || typeof args.revision !== "string" || !/^[0-9]{1,19}$/.test(args.revision) || + !Array.isArray(args.targets) || args.targets.length > 20000) return false; + const targets = []; + for (const item of args.targets) { + if (!item || !Number.isInteger(item.row) || item.row < 0 || item.row >= 20000) return false; + let element; + if (Number.isInteger(item.heading) && item.heading >= 0 && item.heading < headings.length) + element = headings[item.heading].element; + else if (typeof item.fragment === "string" && item.fragment.length <= 2048) + element = item.fragment ? document.getElementById(item.fragment) : document.body || document.documentElement; + if (!visible(element)) continue; + const range = document.createRange(); range.selectNodeContents(element); range.collapse(true); + targets.push({ row:item.row, element, range }); + } + targets.sort((a, b) => a.range.compareBoundaryPoints(Range.START_TO_START, b.range) || a.row - b.row); + navigationTargets = targets; navigationRevision = args.revision; + return true; + } + function currentNavigation(position) { + // DOM order is stable across zoom/reflow. Avoid measuring every target's + // layout at each 400 ms location poll, even for a 20,000-item EPUB nav. + let low = 0, high = navigationTargets.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (navigationTargets[mid].range.compareBoundaryPoints(Range.START_TO_START, position) <= 0) low = mid + 1; + else high = mid; + } + for (let i = low - 1; i >= 0; --i) + if (visible(navigationTargets[i].element)) return navigationTargets[i].row; + return -1; + } + function positionRange(range, saved) { + const node = range.startContainer; + const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + if (!element || !visible(element)) return false; + element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" }); + const rect = range.getBoundingClientRect(); + const a = axis(); + const offset = a.vertical ? Number(saved.offsetX) : Number(saved.offsetY); + const fraction = Number.isFinite(offset) && Math.abs(offset) <= 2 ? offset : 0; + if (a.vertical) scrollBy({ left: rect.left - fraction * innerWidth, behavior: "instant" }); + else scrollBy({ top: rect.top - fraction * innerHeight, behavior: "instant" }); + restoredRange = { range: range.cloneRange(), x: scrollX, y: scrollY }; + return true; + } + function restore(saved) { + if (!saved || typeof saved !== "object") return { restored: false, approximate: true }; + let range = resolveCfi(saved.cfi); + if (range && positionRange(range, saved)) return { restored: true, approximate: false }; + const fragment = typeof saved.fragment === "string" ? saved.fragment.slice(0, 1024) : ""; + const element = fragment ? document.getElementById(fragment) : null; + if (element && visible(element)) { + restoredRange = null; + element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" }); + // A failed character-level CFI resolved only to its containing ID. + return { restored: true, approximate: Boolean(saved.cfi) }; + } + const quote = typeof saved.textQuote === "string" ? saved.textQuote.slice(0, 160) : ""; + if (quote) { + const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT); + let node, inspected = 0; + while ((node = walker.nextNode()) && ++inspected < 100000) { + const offset = node.textContent.indexOf(quote); + if (offset >= 0 && visible(node.parentElement)) { + range = document.createRange(); range.setStart(node, offset); range.collapse(true); + if (positionRange(range, saved)) return { restored: true, approximate: true }; + } + } + } + setProgression(saved.progression); + return { restored: true, approximate: true }; + } + function heading(direction) { + if (!headings.length) headingIndex(); + const a = axis(); + const candidates = direction > 0 ? headings : headings.slice().reverse(); + const seen = new Set(); + for (const h of candidates) { + const rect = h.element.getBoundingClientRect(); + const distance = a.vertical ? (a.reverse ? innerWidth - rect.right : rect.left) : rect.top; + const position = Math.round(distance); + if (seen.has(position)) continue; + seen.add(position); + if ((direction > 0 && distance > 3) || (direction < 0 && distance < -3)) { + const x = scrollX, y = scrollY; + h.element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" }); + // Several final headings can clamp to the same destination. + // Check the current layout's actual result, so a repeated + // destination cannot masquerade as movement or block a boundary. + const after = h.element.getBoundingClientRect(); + if (Math.abs(scrollX - x) < 1 && Math.abs(scrollY - y) < 1 && + Math.abs(after.left - rect.left) < 1 && Math.abs(after.top - rect.top) < 1) continue; + return { moved: true, title: h.title }; + } + } + return { moved: false, boundary: direction, empty: !headings.length }; + } + function applyOptions(next) { + options = Object.assign({}, options, next || {}); + if (!options.fixed && options.publisherStyle === false) { + const size = Number(options.fontSize), height = Number(options.lineHeight); + if (!originalTypography) { + const body = document.body || document.documentElement; + const base = parseFloat(getComputedStyle(body).fontSize) || 16; + const nodes = [document.documentElement, body]; + // Inline priorities override authored fixed-pixel declarations. + // Relative heading/ruby sizes survive; DOM nodes and CFI paths do not change. + for (const node of body.querySelectorAll("*")) { + if (node.namespaceURI !== "http://www.w3.org/1999/xhtml") continue; + if (Array.from(node.childNodes).some(child => child.nodeType === Node.TEXT_NODE && child.textContent.trim())) nodes.push(node); + } + originalTypography = Array.from(new Set(nodes)).map(node => ({ node, + hadStyle: node.hasAttribute("style"), + font: node.style.getPropertyValue("font-size"), fontPriority: node.style.getPropertyPriority("font-size"), + line: node.style.getPropertyValue("line-height"), linePriority: node.style.getPropertyPriority("line-height"), + ratio: node === document.documentElement || node === body ? 1 : (parseFloat(getComputedStyle(node).fontSize) || base) / base })); + } + for (const entry of originalTypography) { + if (size >= 12 && size <= 40) entry.node.style.setProperty("font-size", size * entry.ratio + "px", "important"); + if (height >= 1 && height <= 2.5) entry.node.style.setProperty("line-height", String(height), "important"); + } + } else if (originalTypography) { + for (const entry of originalTypography) { + if (entry.font) entry.node.style.setProperty("font-size", entry.font, entry.fontPriority); + else entry.node.style.removeProperty("font-size"); + if (entry.line) entry.node.style.setProperty("line-height", entry.line, entry.linePriority); + else entry.node.style.removeProperty("line-height"); + if (!entry.hadStyle && !entry.node.getAttribute("style")) entry.node.removeAttribute("style"); + } + originalTypography = null; + } + return { fixed: !!options.fixed, fixedViewport: viewport() }; + } + function restoreLinkOutline() { + if (!focusedLink || !linkOutline) return; + for (const entry of linkOutline.properties) { + if (entry.value) focusedLink.style.setProperty(entry.name, entry.value, entry.priority); + else focusedLink.style.removeProperty(entry.name); + } + if (!linkOutline.hadStyle && !focusedLink.getAttribute("style")) focusedLink.removeAttribute("style"); + linkOutline = null; + } + function clearLinkSelection() { + restoreLinkOutline(); + focusedLink = null; + // selectedLink also supports a natively focused anchor. Clear both + // routes so Enter cannot follow a link after selection is cancelled. + const active = document.activeElement; + if (active && active.localName === "a") active.blur(); + return { cleared: true }; + } + function focusLink(direction) { + const links = Array.from(document.querySelectorAll("a[href]")).filter(visible).slice(0, 20000); + if (!links.length) return { empty: true }; + const current = links.indexOf(focusedLink || document.activeElement); + const index = current < 0 ? (direction > 0 ? 0 : links.length - 1) : (current + direction + links.length) % links.length; + restoreLinkOutline(); + focusedLink = links[index]; + linkOutline = { hadStyle: focusedLink.hasAttribute("style"), + properties: ["outline-color", "outline-style", "outline-width", "outline-offset"].map(name => ({ + name, value: focusedLink.style.getPropertyValue(name), priority: focusedLink.style.getPropertyPriority(name) + })) }; + focusedLink.style.outline = "2px solid #008577"; + focusedLink.style.outlineOffset = "3px"; + focusedLink.focus({ preventScroll: false }); + return { focused: true, title: text(focusedLink), index, count: links.length }; + } + function selectedLink() { + const element = focusedLink && focusedLink.isConnected ? focusedLink : document.activeElement; + if (!element || element.localName !== "a" || !element.hasAttribute("href")) return { empty: true }; + try { + const url = new URL(element.getAttribute("href"), document.baseURI); + if (!["doc:", "http:", "https:"].includes(url.protocol) || url.username || url.password) return { blocked: true }; + if (url.protocol === "doc:" && url.host !== location.host) return { blocked: true }; + return { url: url.href.slice(0, 8192), title: text(element) }; + } catch (_) { return { blocked: true }; } + } + function executeCommand(id, args) { + args = args && typeof args === "object" ? args : {}; + const a = axis(); + const step = Math.max(1, Math.min(100, Number(args.count) || 1)); + switch (id) { + case "resource.issues": { + const result = resourceIssues; + resourceIssues = Object.create(null); + return result; + } + case "index": return index(); + case "location": return locate(); + case "navigation.configure": return configureNavigation(args); + case "location.restore": return restore(args); + case "layout": return applyOptions(args); + case "link.next": return focusLink(1); + case "link.previous": return focusLink(-1); + case "link.clear": return clearLinkSelection(); + case "link.activate": return selectedLink(); + case "scroll.down": scrollBy({ top: 48 * step, behavior: "instant" }); break; + case "scroll.up": scrollBy({ top: -48 * step, behavior: "instant" }); break; + case "scroll.left": scrollBy({ left: -48 * step, behavior: "instant" }); break; + case "scroll.right": scrollBy({ left: 48 * step, behavior: "instant" }); break; + case "scroll.half_down": case "scroll.half_up": case "scroll.page_down": case "scroll.page_up": { + const forward = id.endsWith("down") ? 1 : -1; + const size = id.includes("half") ? 0.5 : 0.9; + scrollBy(a.vertical ? { left: innerWidth * size * forward * (a.reverse ? -1 : 1), behavior: "instant" } + : { top: innerHeight * size * forward, behavior: "instant" }); break; + } + case "nav.document_start": setProgression(0); break; + case "nav.document_end": setProgression(1); break; + case "nav.heading_next": return heading(1); + case "nav.heading_previous": return heading(-1); + case "heading.first": if (!headings.length) headingIndex(); if (headings.length) headings[0].element.scrollIntoView({block:"start",inline:"start"}); return {moved:!!headings.length}; + case "heading.last": if (!headings.length) headingIndex(); if (headings.length) headings[headings.length-1].element.scrollIntoView({block:"start",inline:"start"}); return {moved:!!headings.length}; + case "heading.index": { + const index = Number(args.index); + if (Number.isInteger(index) && index >= 0 && index < headings.length) headings[index].element.scrollIntoView({ block: "start", inline: "start", behavior: "instant" }); + break; + } + default: return { handled: false }; + } + return { handled: true }; + } + function command(id, args) { + const passive = ["location", "index", "navigation.configure", "resource.issues"].includes(id); + if (!passive) { cancelResourceRestore(); userScrollPending = false; } + try { return executeCommand(id, args); } + finally { if (!passive && document.body) locate(); } + } + function block(event) { event.preventDefault(); event.stopImmediatePropagation(); } + document.addEventListener("submit", block, true); + document.addEventListener("reset", block, true); + document.addEventListener("beforeinput", block, true); + document.addEventListener("drop", block, true); + document.addEventListener("click", event => { + if (event.target.closest && event.target.closest("button,input,select,textarea,[contenteditable]")) block(event); + }, true); + document.addEventListener("keydown", event => { + if (event.target.closest && event.target.closest("button,input,select,textarea,[contenteditable]") && !(event.ctrlKey && /^(a|c)$/i.test(event.key))) block(event); + }, true); + document.addEventListener("play", event => { if (event.target.pause) event.target.pause(); }, true); + globalThis.__docviewReader = Object.freeze({ command, index, location: locate, restore }); +})(); diff --git a/src/app/benchmark.cpp b/src/app/benchmark.cpp new file mode 100644 index 0000000..c0ba6bf --- /dev/null +++ b/src/app/benchmark.cpp @@ -0,0 +1,386 @@ +#include "benchmark.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#endif +namespace docview { +static qint64 monotonicNs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} +static QJsonObject residentSnapshot() { +#ifdef Q_OS_LINUX + // PPid traversal includes children created by ANY application thread. The + // main-thread /proc/PID/task/PID/children file misses Chromium descendants. + QHash processes; + const auto pageSize = quint64(sysconf(_SC_PAGESIZE)); + for (const auto &name : QDir("/proc").entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + bool ok = false; const auto pid = name.toLongLong(&ok); if (!ok) continue; + QFile file("/proc/" + name + "/stat"); RendererProcessInfo info; + if (file.open(QIODevice::ReadOnly) && parseRendererProcStat(file.readAll(), pageSize, &info)) processes[pid] = info; + } + const auto root = QCoreApplication::applicationPid(); + if (!processes.contains(root)) return {{"available", false}}; + QMultiHash children; + for (auto it = processes.begin(); it != processes.end(); ++it) children.insert(it->parentPid, it.key()); + QSet seen; QList pending{root}; qint64 total = 0; + while (!pending.isEmpty()) { + const auto pid = pending.takeLast(); if (seen.contains(pid)) continue; + seen.insert(pid); total += qint64(processes[pid].residentBytes); + for (auto child : children.values(pid)) pending.append(child); + } + return {{"available", true}, {"applicationBytes", qint64(processes[root].residentBytes)}, + {"processGroupBytes", total}, {"processCount", seen.size()}}; +#else + // Unsupported platforms are not reported as a fictitious zero-byte sample. + return {{"available", false}, {"reason", "RSS sampling is currently implemented on Linux only"}}; +#endif +} +Benchmark::Benchmark(Controller *r, QQuickWindow *w, PdfCanvas *c, QString source, QString output, + int runs, int operations, int closeCycles, int scrollSteps, QObject *parent) + : QObject(parent), reader_(r), window_(w), canvas_(c), source_(std::move(source)), output_(std::move(output)), + runs_(runs), operations_(operations), closeCycles_(closeCycles), scrollSteps_(scrollSteps) { + connect(w, &QQuickWindow::frameSwapped, this, [this] { + const auto timestamp = monotonicNs(); + QMetaObject::invokeMethod(this, [this, timestamp] { frame(timestamp); }, Qt::QueuedConnection); + }, Qt::DirectConnection); + connect(r, &Controller::positionChanged, this, &Benchmark::observePosition); + connect(r, &Controller::webActionStarted, this, [this](const QString &command, quint64 request) { + if (phase_ == Phase::Scrolling && (command == "scroll.down" || command == "scroll.up")) scrollRequests_.insert(request); + if ((phase_ == Phase::Preparing || phase_ == Phase::Action) && command == awaitedCommand_) awaitedRequest_ = request; + }); + connect(r, &Controller::webActionAcknowledged, this, [this](const QString &command, quint64 request) { + if ((phase_ == Phase::Scrolling || phase_ == Phase::Draining) && scrollRequests_.remove(request)) ++scrollApplied_; + if (request != awaitedRequest_ || command != awaitedCommand_ || !request) return; + if (phase_ == Phase::Preparing || phase_ == Phase::Action) commandApplied_ = true; + if (phase_ == Phase::Action && series_ == "page-scroll") { + applied_ = true; appliedNs_ = monotonicNs(); frames_ = 0; window_->update(); + } + }); + connect(r, &Controller::documentChanged, this, [this] { frames_ = 0; window_->update(); }); + connect(c, &PdfCanvas::tileRequested, this, [this](int, bool prefetch, qint64) { + if (phase_ == Phase::Action && !prefetch) ++visibleRequests_; + pendingPeak_ = qMax(pendingPeak_, qint64(canvas_->pendingRenderCount())); + }); + connect(c, &PdfCanvas::frameReady, this, [this] { + if (canvas_->viewportReady()) { + qualityReadyNs_ = monotonicNs(); + if (phase_ == Phase::Opening) { applied_ = true; appliedNs_ = qualityReadyNs_; } + } + }); + deadline_.setSingleShot(true); + connect(&deadline_, &QTimer::timeout, this, [this] { finish(false, "phase deadline exceeded: " + series_); }); + memoryTimer_.setInterval(100); + connect(&memoryTimer_, &QTimer::timeout, this, [this] { sampleMemory(); }); + uiTimer_.setInterval(16); uiTimer_.setTimerType(Qt::PreciseTimer); + connect(&uiTimer_, &QTimer::timeout, this, [this] { + const auto now = monotonicNs(); + if (lastUiNs_ && uiIntervals_.size() < 100000) uiIntervals_.append((now - lastUiNs_) / 1e6); + lastUiNs_ = now; + const auto state = reader_->benchmarkSnapshot(); + queuedRenderPeak_ = qMax(queuedRenderPeak_, state.value("queuedRenders").toLongLong()); + activeRenderPeak_ = qMax(activeRenderPeak_, state.value("activeRenders").toLongLong()); + if (canvas_) pendingPeak_ = qMax(pendingPeak_, qint64(canvas_->pendingRenderCount())); + }); + scrollTimer_.setInterval(16); scrollTimer_.setTimerType(Qt::PreciseTimer); + connect(&scrollTimer_, &QTimer::timeout, this, &Benchmark::scrollStep); +} +void Benchmark::sampleMemory(const QString &label) { + lastMemory_ = residentSnapshot(); + const int pressure = canvas_ ? canvas_->memoryPressureLevel() : 0; + maximumMemoryPressureLevel_ = qMax(maximumMemoryPressureLevel_, pressure); + lastMemory_["memoryPressureLevel"] = pressure; + lastMemory_["elapsedMs"] = (monotonicNs() - benchmarkStartNs_) / 1e6; + lastMemory_["phase"] = label.isEmpty() ? QString::number(int(phase_)) : label; + if (lastMemory_.value("available").toBool()) { + rssPeak_ = qMax(rssPeak_, lastMemory_.value("processGroupBytes").toInteger()); + appRssPeak_ = qMax(appRssPeak_, lastMemory_.value("applicationBytes").toInteger()); + } + if (canvas_) cachePeak_ = qMax(cachePeak_, canvas_->cacheCostBytes()); + if (memorySamples_.size() < 10000) memorySamples_.append(lastMemory_); +} +void Benchmark::start() { + benchmarkStartNs_ = lastUiNs_ = monotonicNs(); + window_->requestActivate(); sampleMemory("start"); startMemory_ = lastMemory_; + memoryTimer_.start(); uiTimer_.start(); nextOpen(); +} +void Benchmark::nextOpen() { + phase_ = Phase::Opening; frames_ = 0; visibleRequests_ = 0; + applied_ = false; qualityReadyNs_ = 0; + operationStartNs_ = appliedNs_ = monotonicNs(); deadline_.start(30000); + reader_->openPath(source_); window_->update(); +} +void Benchmark::closeCurrent(bool final) { + deadline_.stop(); finalClose_ = final; phase_ = Phase::Closing; + sampleMemory("before-close"); openingMemory_ = lastMemory_; + const auto snapshot = reader_->benchmarkSnapshot(); + discardedTotal_ += snapshot.value("discardedQueuedRequests").toLongLong() + snapshot.value("queuedRequests").toLongLong(); + closeStartNs_ = monotonicNs(); reader_->closeDocument(); + // Deferred WebEngine/profile/process teardown must run before the post-close + // sample. Allocator/browser caches may remain; their retention is measured. + QTimer::singleShot(1000, this, [this] { + if (finished_ || phase_ != Phase::Closing) return; + sampleMemory("after-close"); + closeRecords_.append(QJsonObject{{"cycle", openCount_}, {"beforeClose", openingMemory_}, + {"afterClose", lastMemory_}, {"settleMs", (monotonicNs() - closeStartNs_) / 1e6}, + {"workload", finalClose_ ? "navigation-and-scroll" : "open-first-visible-only"}, + {"stateEmpty", reader_->state() == "Empty" && reader_->format().isEmpty()}, + {"cacheBytesAfterClose", canvas_ ? canvas_->cacheCostBytes() : 0}}); + if (reader_->state() != "Empty" || !reader_->format().isEmpty()) finish(false, "explicit close did not reach Empty"); + else if (finalClose_) finish(true); + else nextOpen(); + }); +} +QVariantMap Benchmark::locator() const { return reader_->benchmarkSnapshot().value("location").toMap(); } +bool Benchmark::samePosition(const QVariantMap &a, const QVariantMap &b) { + for (const auto *key : {"pageIndex", "xPt", "yPt", "resourcePath", "cfi", "progression"}) + if (a.value(key) != b.value(key)) return false; + return true; +} +bool Benchmark::readyForPresentation() const { + return reader_->state() == "Ready" && (format_ == "pdf" ? canvas_ && canvas_->viewportReady() : !locator().isEmpty()); +} +void Benchmark::observePosition() { + if (phase_ == Phase::Opening && reader_->format() != "pdf" && readyForPresentation() && !applied_) { + applied_ = true; appliedNs_ = monotonicNs(); frames_ = 0; window_->update(); + } + if (phase_ == Phase::Preparing && commandApplied_) { + applied_ = true; appliedNs_ = monotonicNs(); frames_ = 0; window_->update(); + } + if (phase_ != Phase::Action || (series_ != "heading" && series_ != "chapter")) return; + if (format_ != "pdf" && series_ == "heading" && !commandApplied_) return; + const auto after = locator(); + const bool moved = series_ == "chapter" ? after.value("resourcePath") != before_.value("resourcePath") + : !samePosition(before_, after); + if (!after.isEmpty() && moved && !applied_) { + applied_ = true; appliedNs_ = monotonicNs(); frames_ = 0; window_->update(); + } +} +void Benchmark::prepareSeries() { + ++seriesIndex_; seriesCount_ = 0; seriesTargets_ = 0; seriesTargetSource_.clear(); + const QStringList families{"page-scroll", "heading", "chapter"}; + if (seriesIndex_ >= families.size()) { startScroll(); return; } + series_ = families[seriesIndex_]; + if (!operations_ || (series_ == "chapter" && format_ != "epub")) { + seriesRecords_.append(QJsonObject{{"series", series_}, {"status", "not-requested-or-not-applicable"}, {"count", 0}}); + prepareSeries(); return; + } + phase_ = Phase::Preparing; frames_ = 0; preparationAligned_ = false; commandApplied_ = false; applied_ = false; + awaitedCommand_ = "nav.document_start"; awaitedRequest_ = 0; + if (format_ == "pdf") applied_ = true; + operationStartNs_ = monotonicNs(); deadline_.start(30000); + appliedNs_ = operationStartNs_; + reader_->execute("nav.document_start"); window_->update(); +} +void Benchmark::dispatch(const QString &id, int page, bool repeat) { + auto key = [this, repeat](Qt::Key code, Qt::KeyboardModifiers modifiers, const QString &text) { + QKeyEvent press(QEvent::KeyPress, code, modifiers, text, repeat); + QCoreApplication::sendEvent(window_, &press); + QKeyEvent release(QEvent::KeyRelease, code, modifiers, text); + QCoreApplication::sendEvent(window_, &release); + }; + if (id == "nav.page") { + for (const QChar digit : QString::number(page)) key(Qt::Key(Qt::Key_0 + digit.digitValue()), {}, QString(digit)); + key(Qt::Key_G, Qt::ShiftModifier, "G"); + } else if (id == "zoom.in") key(Qt::Key_Plus, {}, "+"); + else if (id == "zoom.fit_width") key(Qt::Key_Equal, {}, "="); + else if (id.startsWith("nav.heading") || id.startsWith("nav.chapter")) { + const bool forward = id.endsWith("next"); + key(forward ? Qt::Key_BracketRight : Qt::Key_BracketLeft, {}, forward ? "]" : "["); + if (id.startsWith("nav.chapter")) key(Qt::Key_C, {}, "c"); + else key(forward ? Qt::Key_BracketRight : Qt::Key_BracketLeft, {}, forward ? "]" : "["); + } else if (id == "scroll.down" || id == "scroll.up") key(id.endsWith("down") ? Qt::Key_J : Qt::Key_K, {}, id.endsWith("down") ? "j" : "k"); + else key(id == "scroll.page_down" ? Qt::Key_F : Qt::Key_B, Qt::ControlModifier, {}); +} +void Benchmark::nextOperation() { + if (seriesCount_ >= operations_) { + seriesRecords_.append(QJsonObject{{"series", series_}, {"status", "measured"}, {"count", seriesCount_}, + {"availableTargets", seriesTargets_}, {"targetSource", seriesTargetSource_}, {"traversalSpan", span_}}); + prepareSeries(); return; + } + if (QGuiApplication::focusWindow() != window_) { finish(false, "benchmark window lost focus"); return; } + phase_ = Phase::Action; frames_ = visibleRequests_ = 0; responseRecorded_ = false; + commandApplied_ = false; + before_ = locator(); applied_ = format_ == "pdf" && series_ == "page-scroll"; + const int n = seriesCount_; QString id; int page = 0; + if (series_ == "heading" || series_ == "chapter") + id = "nav." + series_ + ((n / span_) % 2 ? "_previous" : "_next"); + else if (format_ == "pdf") { + const int count = qMax(1, int(reader_->pages().size())); + if (n % 10 == 0) { + pageA_ = ((n / 10 * 97) + 1) % count; + if (pageA_ == before_.value("pageIndex").toInt() && count > 1) pageA_ = (pageA_ + 1) % count; + pageB_ = (pageA_ + 1) % count; + } + id = "nav.page"; page = 1 + (n % 2 ? pageB_ : pageA_); + if (n % 10 == 8) id = "zoom.in"; + if (n % 10 == 9) id = "zoom.fit_width"; + } else id = n % 2 ? "scroll.page_up" : "scroll.page_down"; + operation_ = {{"series", series_}, {"command", id}, {"index", n}}; + if (series_ == "heading" || series_ == "chapter") { + operation_["availableTargets"] = seriesTargets_; + operation_["targetSource"] = seriesTargetSource_; + operation_["traversalSpan"] = span_; + } + awaitedCommand_ = id; awaitedRequest_ = 0; + if (page) operation_["requestedPhysicalPage"] = page; + operationStartNs_ = appliedNs_ = monotonicNs(); deadline_.start(30000); + qualityReadyNs_ = 0; + dispatch(id, page); observePosition(); + if (format_ == "pdf") { + if (applied_) appliedNs_ = monotonicNs(); + if (canvas_->viewportReady()) qualityReadyNs_ = monotonicNs(); + } + window_->update(); +} +void Benchmark::startScroll() { + if (!scrollSteps_) { closeCurrent(true); return; } + phase_ = Phase::Scrolling; series_ = "continuous-scroll"; + scrollSent_ = scrollApplied_ = 0; lastScrollFrameNs_ = lastScrollDispatchNs_ = 0; + scrollRequests_.clear(); + operationStartNs_ = monotonicNs(); deadline_.start(qMax(30000, scrollSteps_ * 50)); scrollTimer_.start(); +} +void Benchmark::scrollStep() { + if (phase_ != Phase::Scrolling) return; + const auto now = monotonicNs(); + if (lastScrollDispatchNs_) scrollDispatch_.append((now - lastScrollDispatchNs_) / 1e6); + lastScrollDispatchNs_ = now; + dispatch((scrollSent_ / 30) % 2 ? "scroll.up" : "scroll.down", 0, scrollSent_ != 0); + ++scrollSent_; window_->update(); + if (scrollSent_ >= scrollSteps_) { scrollTimer_.stop(); phase_ = Phase::Draining; frames_ = 0; } +} +void Benchmark::frame(qint64 presentedNs) { + if (finished_ || presentedNs < operationStartNs_) return; + if (phase_ == Phase::Scrolling || phase_ == Phase::Draining) { + if (lastScrollFrameNs_) scrollFrames_.append((presentedNs - lastScrollFrameNs_) / 1e6); + lastScrollFrameNs_ = presentedNs; + if (phase_ == Phase::Draining && readyForPresentation() && (format_ == "pdf" || scrollApplied_ >= scrollSent_)) { + if (++frames_ >= 2) { deadline_.stop(); closeCurrent(true); } else window_->update(); + } + return; + } + if (phase_ == Phase::Opening) { + format_ = reader_->format(); + if (!readyForPresentation() || !applied_ || presentedNs < appliedNs_) { window_->update(); return; } + if (++frames_ < 2) { window_->update(); return; } + ++openCount_; if (opens_.size() < runs_) opens_.append((presentedNs - operationStartNs_) / 1e6); + sampleMemory("after-open"); rssAfterOpen_.append(lastMemory_.value("processGroupBytes")); + openRecords_.append(QJsonObject{{"cycle", openCount_}, {"openFrameMs", (presentedNs - operationStartNs_) / 1e6}, {"memory", lastMemory_}}); + deadline_.stop(); + if (openCount_ < qMax(runs_, closeCycles_ + 1)) closeCurrent(false); + else prepareSeries(); + return; + } + if (phase_ == Phase::Preparing) { + const auto snapshot = reader_->benchmarkSnapshot(); + if (!readyForPresentation() || (series_ == "heading" && !snapshot.value("headingsReady").toBool())) { window_->update(); return; } + if (!applied_ || presentedNs < appliedNs_) { window_->update(); return; } + const auto location = locator(); + if (!preparationAligned_ && ((format_ == "pdf" && location.value("pageIndex").toInt() != 0) || + (format_ != "pdf" && (location.value("progression").toDouble() > .001 || + (format_ == "epub" && snapshot.value("chapterIndex").toInt() != 0))))) { window_->update(); return; } + if (++frames_ < 2) { window_->update(); return; } + int targets = series_ == "chapter" ? snapshot.value("chapterCount").toInt() + : series_ == "heading" ? snapshot.value("headingCount").toInt() + : format_ == "pdf" ? int(reader_->pages().size()) : 0; + const auto firstHeading = snapshot.value("firstHeading").toMap(); + const QString targetSource = series_ == "chapter" ? QStringLiteral("epub-linear-spine") + : series_ == "heading" ? firstHeading.value("source").toString() + : format_ == "pdf" ? QStringLiteral("pdf-physical-pages") : QStringLiteral("viewport-scroll"); + // A Web table of contents is not the DOM heading index. Otherwise + // heading commands can merely cross chapter boundaries while falsely + // being counted as measured heading navigation. + if (series_ == "heading" && format_ != "pdf" && targetSource != "html-heading") targets = 0; + if (series_ != "page-scroll" && targets < 2) { + seriesRecords_.append(QJsonObject{{"series", series_}, {"status", "insufficient-corpus-targets"}, + {"count", 0}, {"availableTargets", targets}, {"targetSource", targetSource}}); + deadline_.stop(); prepareSeries(); return; + } + if (series_ == "heading" && !preparationAligned_) { + preparationAligned_ = true; frames_ = 0; commandApplied_ = false; applied_ = format_ == "pdf"; + appliedNs_ = monotonicNs(); + awaitedCommand_ = "heading.index"; awaitedRequest_ = 0; + auto first = firstHeading; + // Use the known DOM heading index for setup, obtaining the normal + // command completion callback instead of an unacknowledged hash URL. + if (format_ != "pdf") first.remove("href"); + reader_->activateItem(first); window_->update(); return; + } + if (series_ == "heading" && !applied_) { window_->update(); return; } + span_ = qMax(1, qMin(50, targets - 1)); + seriesTargets_ = targets; seriesTargetSource_ = targetSource; + deadline_.stop(); QTimer::singleShot(20, this, [this] { nextOperation(); }); phase_ = Phase::Idle; + return; + } + if (phase_ != Phase::Action || !applied_ || presentedNs < appliedNs_ || reader_->state() != "Ready") return; + if (!responseRecorded_) { operation_["firstResponseFrameMs"] = (presentedNs - operationStartNs_) / 1e6; responseRecorded_ = true; } + if (++frames_ < 2 || !readyForPresentation() || (format_ == "pdf" && (!qualityReadyNs_ || presentedNs < qualityReadyNs_))) { window_->update(); return; } + operation_["finalQualityFrameMs"] = (presentedNs - operationStartNs_) / 1e6; + operation_["positionChanged"] = !samePosition(before_, locator()); + if (format_ == "pdf") { operation_["visibleTileRequests"] = visibleRequests_; operation_["cacheHit"] = visibleRequests_ == 0; } + actions_.append(operation_); ++seriesCount_; phase_ = Phase::Idle; deadline_.stop(); + QTimer::singleShot(20, this, [this] { nextOperation(); }); +} +void Benchmark::finish(bool success, const QString &reason) { + if (finished_) return; + if (!success) { + const auto diagnosticPosition = [](const QVariantMap &location) { + QJsonObject result; + for (const auto *field : {"resourcePath", "pageIndex", "progression", "xPt", "yPt"}) + if (location.contains(field)) result.insert(field, QJsonValue::fromVariant(location.value(field))); + return result; + }; + operation_["beforePosition"] = diagnosticPosition(before_); + operation_["observedPosition"] = diagnosticPosition(locator()); + operation_["commandAcknowledged"] = commandApplied_; + } + finished_ = true; phase_ = Phase::Done; deadline_.stop(); memoryTimer_.stop(); uiTimer_.stop(); scrollTimer_.stop(); + sampleMemory("finish"); QFile input(source_); QByteArray hash; + if (input.open(QIODevice::ReadOnly)) { QCryptographicHash sha(QCryptographicHash::Sha256); sha.addData(&input); hash = sha.result().toHex(); } + QJsonObject display{{"windowLogicalWidth", window_->width()}, {"windowLogicalHeight", window_->height()}, + {"windowDevicePixelRatio", window_->devicePixelRatio()}}; + if (const auto screen = window_->screen()) { + display.insert("screenLogicalWidth", screen->geometry().width()); + display.insert("screenLogicalHeight", screen->geometry().height()); + display.insert("screenDevicePixelRatio", screen->devicePixelRatio()); + display.insert("screenReportedRefreshRateHz", screen->refreshRate()); + } + QJsonObject record{{"schemaVersion", 2}, {"success", success}, {"failureReason", reason}, {"qt", qVersion()}, + {"display", display}, {"maximumMemoryPressureLevel", maximumMemoryPressureLevel_}, + {"qtPlatform", QGuiApplication::platformName()}, {"sceneGraphBackend", QQuickWindow::sceneGraphBackend()}, + {"requestedQtQuickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, + {"requestedRhiBackend", qEnvironmentVariable("QSG_RHI_BACKEND")}, + {"os", QSysInfo::prettyProductName()}, {"kernel", QSysInfo::kernelVersion()}, {"cpuArchitecture", QSysInfo::currentCpuArchitecture()}, + {"documentSha256", QString::fromLatin1(hash)}, {"format", format_}, {"openFrameMs", opens_}, {"operations", actions_}, + {"operationSeries", seriesRecords_}, {"openCount", openCount_}, {"requestedOpenTimings", runs_}, {"requestedCloseCycles", closeCycles_}, + {"openRecords", openRecords_}, + {"memoryStart", startMemory_}, {"memoryAfterFinalClose", finalClose_ && reader_->state() == "Empty" ? QJsonValue(lastMemory_) : QJsonValue()}, {"openCloseCycles", closeRecords_}, + {"failedOperation", success ? QJsonValue() : QJsonValue(operation_)}, + {"notMeasured", QJsonArray{"rapid overlapping distant-page jumps", "resize latency", "replacement by a different document", "OS-cache-cold startup", "reference-hardware acceptance"}}, + {"timingSemantics", "Qt frameSwapped is queued-for-presentation, not physical scanout. Web command/sequence ACK plus validated logical location and two subsequent Qt frames do not identify a Chromium compositor frame or prove target-pixel presentation. Xvfb results are reference measurements only."}, + {"processGroupRssPeakBytes", rssPeak_}, {"applicationRssPeakBytes", appRssPeak_}, {"processGroupRssAfterEachOpenBytes", rssAfterOpen_}, + {"memorySamples", memorySamples_}, {"rssSampleIntervalMs", 100}, {"closeSettleIntervalMs", 1000}, + {"uiTimerExpectedIntervalMs", 16}, {"uiTimerIntervalMs", uiIntervals_}, + {"continuousScroll", QJsonObject{{"requestedInputs", scrollSteps_}, {"dispatchedInputs", scrollSent_}, {"webAppliedCallbacks", scrollApplied_}, + {"inputIntervalMs", 16}, {"frameIntervalMs", scrollFrames_}, {"dispatchIntervalMs", scrollDispatch_}}}, + {"pendingRenderPeak", pendingPeak_}, {"queuedRenderPeak", queuedRenderPeak_}, {"activeRenderPeak", activeRenderPeak_}, + {"discardedQueuedRequests", discardedTotal_}, {"tileCachePeakChargedBytes", cachePeak_}, + {"tileCacheBudgetBytes", canvas_ ? canvas_->cacheBudgetBytes() : 0}, + {"visibleTileRequests", canvas_ ? qint64(canvas_->visibleRenderRequestCount()) : 0}, + {"prefetchTileRequests", canvas_ ? qint64(canvas_->prefetchRenderRequestCount()) : 0}, + {"staleRenderResponses", canvas_ ? qint64(canvas_->staleRenderResponseCount()) : 0}, + {"method", "Focused-window synthetic keys; subsequent frameSwapped timestamps. PDF requires every visible final tile. Heading/chapter operations require a changed validated logical position before presentation. Web page-scroll waits for its command callback. Every open is explicitly closed; post-close RSS waits 1 s. Linux RSS snapshots traverse all PPids, sampled every 100 ms, so peaks are sampled lower bounds. Continuous scroll injects repeat keys every 16 ms without waiting for rendering. OS file cache retained. Runtime success is not performance-budget or reference-hardware acceptance."}}; + QSaveFile file(output_); const auto bytes = QJsonDocument(record).toJson(); + const bool saved = file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size() && file.commit(); + QCoreApplication::exit(success && saved ? 0 : 4); +} +} // namespace docview diff --git a/src/app/benchmark.h b/src/app/benchmark.h new file mode 100644 index 0000000..73171b3 --- /dev/null +++ b/src/app/benchmark.h @@ -0,0 +1,56 @@ +#pragma once +#include "controller.h" +#include +#include +#include +namespace docview { +// Developer harness. Timings use readiness-qualified Qt frameSwapped events; +// these are presentation-queue timestamps, not physical scanout timestamps. +class Benchmark : public QObject { + public: + Benchmark(Controller *, QQuickWindow *, PdfCanvas *, QString source, QString output, + int runs, int operations, int closeCycles, int scrollSteps, QObject *parent = nullptr); + void start(); + private: + enum class Phase { Idle, Opening, Closing, Preparing, Action, Scrolling, Draining, Done }; + void frame(qint64); + void nextOpen(); + void closeCurrent(bool final); + void prepareSeries(); + void nextOperation(); + void startScroll(); + void scrollStep(); + void sampleMemory(const QString &label = {}); + void observePosition(); + void dispatch(const QString &, int page = 0, bool repeat = false); + void finish(bool success, const QString &reason = {}); + bool readyForPresentation() const; + QVariantMap locator() const; + static bool samePosition(const QVariantMap &, const QVariantMap &); + Controller *reader_; + QQuickWindow *window_; + PdfCanvas *canvas_; + QString source_, output_, format_, series_; + QString awaitedCommand_; + quint64 awaitedRequest_ = 0; + QSet scrollRequests_; + int pageA_ = 0, pageB_ = 0; + int seriesTargets_ = 0; + QString seriesTargetSource_; + int runs_, operations_, closeCycles_, scrollSteps_, openCount_ = 0, seriesIndex_ = -1, seriesCount_ = 0; + int frames_ = 0, visibleRequests_ = 0, span_ = 1, scrollSent_ = 0, scrollApplied_ = 0; + bool responseRecorded_ = false, applied_ = false, finalClose_ = false, finished_ = false, preparationAligned_ = false, commandApplied_ = false; + Phase phase_ = Phase::Idle; + QJsonArray opens_, actions_, rssAfterOpen_, memorySamples_, closeRecords_, seriesRecords_, openRecords_; + QJsonArray scrollFrames_, scrollDispatch_, uiIntervals_; + QJsonObject operation_, openingMemory_, startMemory_, lastMemory_; + QVariantMap before_; + QTimer deadline_, memoryTimer_, uiTimer_, scrollTimer_; + qint64 rssPeak_ = -1, appRssPeak_ = -1, cachePeak_ = 0, operationStartNs_ = 0, appliedNs_ = 0; + qint64 lastUiNs_ = 0, lastScrollFrameNs_ = 0, lastScrollDispatchNs_ = 0, closeStartNs_ = 0; + qint64 benchmarkStartNs_ = 0; + qint64 qualityReadyNs_ = 0; + qint64 pendingPeak_ = 0, queuedRenderPeak_ = 0, activeRenderPeak_ = 0, discardedTotal_ = 0; + int maximumMemoryPressureLevel_ = 0; +}; +} // namespace docview diff --git a/src/app/controller.cpp b/src/app/controller.cpp new file mode 100644 index 0000000..91c02f0 --- /dev/null +++ b/src/app/controller.cpp @@ -0,0 +1,2206 @@ +#include "controller.h" +#include "broker/font_broker.h" +#include "common/contracts.h" +#include "common/sandbox.h" +#include "common/session_storage.h" +#include "web/resource_policy.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace docview { +static QString keyName(QKeyEvent *e) { + QString base; + bool named = true; + switch (e->key()) { + case Qt::Key_Escape: + base = "Esc"; + break; + case Qt::Key_Return: + case Qt::Key_Enter: + base = "Enter"; + break; + case Qt::Key_Backtab: + case Qt::Key_Tab: + base = "Tab"; + break; + case Qt::Key_Space: + base = "Space"; + break; + case Qt::Key_Backspace: + base = "Backspace"; + break; + case Qt::Key_Delete: + base = "Delete"; + break; + case Qt::Key_Insert: + base = "Insert"; + break; + case Qt::Key_Home: + base = "Home"; + break; + case Qt::Key_End: + base = "End"; + break; + case Qt::Key_PageUp: + base = "PageUp"; + break; + case Qt::Key_PageDown: + base = "PageDown"; + break; + case Qt::Key_Left: + base = "Left"; + break; + case Qt::Key_Right: + base = "Right"; + break; + case Qt::Key_Up: + base = "Up"; + break; + case Qt::Key_Down: + base = "Down"; + break; + default: + if (e->key() >= Qt::Key_F1 && e->key() <= Qt::Key_F24) + base = "F" + QString::number(e->key() - Qt::Key_F1 + 1); + else { + base = e->text(); + named = false; + } + } + QString prefix; + auto modifiers = e->modifiers(); + if (e->key() == Qt::Key_Backtab) + modifiers |= Qt::ShiftModifier; + if (modifiers & Qt::ControlModifier) + prefix += "Ctrl+"; + if (modifiers & Qt::AltModifier) + prefix += "Alt+"; + if ((modifiers & Qt::ShiftModifier) && + (named || (modifiers & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier)))) + prefix += "Shift+"; + if (modifiers & Qt::MetaModifier) + prefix += "Meta+"; + if (!prefix.isEmpty() && e->key() >= Qt::Key_A && e->key() <= Qt::Key_Z) + base = QChar(e->key()); + if (base.isEmpty()) + return {}; + return prefix + base; +} +Controller::Controller(QObject *p) : QObject(p), memoryPressureMonitor_(this) { + memoryPressureMonitor_.setObjectName("docviewMemoryPressureMonitor"); + connect(&memoryPressureMonitor_, &MemoryPressureMonitor::levelChanged, + this, &Controller::applyMemoryPressure); + auto rendererFailure = [this](const QString &token, const QString &code) { + Session *s = active_ && active_->token == token ? active_ + : staging_ && staging_->token == token ? staging_ + : nullptr; + if (!s) + return; + if (s == active_) { + if (s->handler) + s->handler->revoke(); + emit disposeWeb(token); + } + fail(s, code, tr("本文の処理を安全のため停止しました。文書を開き直せます")); + }; + connect(&rendererWatchdog_, &RendererWatchdog::limitExceeded, this, rendererFailure); + connect(&rendererWatchdog_, &RendererWatchdog::monitoringFailed, this, rendererFailure); + connect(&rendererWatchdog_, &RendererWatchdog::responseTimedOut, this, rendererFailure); + noticeTimer_.setSingleShot(true); + connect(¬iceTimer_, &QTimer::timeout, this, [this] { + notice_.clear(); + emit noticeChanged(); + }); + connect(&input_, &InputRouter::commandReady, this, &Controller::execute); + connect(&input_, &InputRouter::pendingChanged, this, &Controller::pendingChanged); + connect(&config_, &ConfigManager::errorOccurred, this, &Controller::notify); + connect(&config_, &ConfigManager::changed, this, [this] { + input_.setConfig(config_); + if (canvas_) { + canvas_->setCacheBudget(config_.value("performance.tile_cache_mib").toInt()); + canvas_->setPrefetchPages(config_.value("performance.prefetch_pages").toInt()); + } + emit settingsChanged(); + }); + indexTimer_.setInterval(20); + connect(&indexTimer_, &QTimer::timeout, this, &Controller::indexPdfHeadings); + connect(this, &Controller::positionChanged, this, &Controller::updateCurrentOutline); +} +Controller::~Controller() { + memoryPressureMonitor_.stop(); + savePosition(); + dispose(staging_); + dispose(active_); +} +void Controller::initialize(const QString &p, bool readOnly, const StoragePaths &paths) { + storagePaths_ = paths; + config_.reload(p); + input_.setConfig(config_); + cleanExpiredSessions(storagePaths_.cacheDirectory + "/sessions"); + store_ = new StateStore(storagePaths_.stateFile, this); + if (readOnly) + store_->forceReadOnly(); + store_->setConfig(config_); + connect(store_, &StateStore::errorOccurred, this, &Controller::notify); + connect(store_, &StateStore::changed, this, &Controller::recentDocumentsChanged); + connect(&config_, &ConfigManager::changed, store_, [this] { + store_->setConfig(config_); + emit recentDocumentsChanged(); + }); + emit recentDocumentsChanged(); + if (!store_->lastError().isEmpty()) + notify(store_->lastError()); + memoryPressureMonitor_.start(); +} +void Controller::attachWindow(QQuickWindow *w) { + window_ = w; + qApp->installEventFilter(this); + connect( + w, &QQuickWindow::frameSwapped, this, + [this] { + if (!active_ || state_ != "Ready") + return; + QVariantMap presented; + if (format() == "pdf") { + if (!canvas_ || !canvas_->viewportReady()) + return; + presented = canvas_->location(); + } else + presented = webLocation_; + if (!presented.isEmpty() && presented != active_->lastPresented) { + active_->lastPresented = presented; + active_->stateCleared = false; + savePosition(); // StateStore owns the 2s debounce and 10s maximum interval. + } + }, + Qt::QueuedConnection); +} +void Controller::attachCanvas(PdfCanvas *c) { + canvas_ = c; + c->setCacheBudget(config_.value("performance.tile_cache_mib").toInt()); + c->setPrefetchPages(config_.value("performance.prefetch_pages").toInt()); + c->setMemoryPressureLevel(int(memoryPressureLevel_)); + connect(c, &PdfCanvas::positionChanged, this, [this] { emit positionChanged(); }); + connect(c, &PdfCanvas::pageNeeded, this, &Controller::loadPdfPages); + connect(c, &PdfCanvas::renderFailed, this, &Controller::notify); + connect(c, &PdfCanvas::fatalWorkerError, this, [this](const QString &code, const QString &message) { + if (active_ && active_->format == "pdf" && !active_->resourcesStopped) + failPdfWorker(active_, {{"code", code}, {"message", message}}); + }); + connect(c, &PdfCanvas::frameReady, this, &Controller::framePresented); + connect(c, &PdfCanvas::documentWarning, this, [this](const QString &message) { + if (active_) { + const auto before = active_->meta.value("warnings").toStringList(); + recordWarnings(active_, {message}); + if (before != active_->meta.value("warnings").toStringList()) notify(message.left(1024)); + } + }); + connect(c, &PdfCanvas::internalLinkRequested, this, &Controller::activateItem); + connect(c, &PdfCanvas::externalLinkRequested, this, &Controller::externalLinkRequested); + connect(c, &PdfCanvas::annotationRequested, this, + [this](const QString &text) { emit uiCommand("document.info", {{"text", text}, {"title", tr("注釈")}}); }); +} +bool Controller::eventFilter(QObject *o, QEvent *e) { + if (o == window_ && e->type() == QEvent::WindowDeactivate) { + input_.reset(); + composing_ = false; + } + if (!window_ || QGuiApplication::focusWindow() != window_) + return false; + if (e->type() == QEvent::InputMethod) { + composing_ = !static_cast(e)->preeditString().isEmpty(); + return false; + } + if (e->type() != QEvent::KeyPress || mode_ != "normal" || composing_) + return false; + auto *k = static_cast(e); + const QString name = keyName(k); + if (name.isEmpty()) + return false; + const bool wasPending = !input_.pending().isEmpty(); + auto result = input_.feed(name, context_, false, k->isAutoRepeat()); + return !result.isEmpty() || wasPending || !input_.pending().isEmpty(); +} +QString Controller::title() const { + return active_ ? QFileInfo(active_->source).fileName() : "DocView"; +} +QString Controller::format() const { + return active_ ? active_->format : QString{}; +} +QString Controller::listTitle() const { + if (!active_) + return {}; + if (format() == "pdf" || active_->meta.value("fixed").toBool()) + return tr("ページ一覧"); + if (!active_->meta.value("pageList").toList().isEmpty()) + return tr("出版物のページ"); + return tr("章一覧"); +} +QString Controller::position() const { + if (!active_) + return {}; + if (format() == "pdf" && canvas_) { + const int page = canvas_->currentPage(); + const auto label = page >= 0 && page < pages_.size() ? pages_[page].toMap().value("label").toString() : QString{}; + const auto pageText = label.isEmpty() || label == QString::number(page + 1) + ? QString::number(page + 1) : tr("%1(実ページ %2)").arg(label).arg(page + 1); + return QString("%1 / %2 %3%") + .arg(pageText) + .arg(active_->meta.value("pageCount").toInt()) + .arg(qRound(canvas_->zoom())); + } + const auto zoom = qRound(webLocation_.value("zoom", 100).toDouble()); + QString zoomText = tr("%1%").arg(zoom); + if (webLocation_.contains("fitWidth")) + zoomText += webLocation_.value("fitWidth").toBool() ? tr("(幅合わせ)") : tr("(ページ合わせ)"); + if (format() == "epub") + return tr("章 %1 / %2 %3").arg(chapter_ + 1).arg(active_->meta.value("spine").toList().size()).arg(zoomText); + return tr("文書 %1").arg(zoomText); +} +QString Controller::outlineTitle() const { + if (outline_.isEmpty()) return tr("目次"); + const auto source = outline_.first().toMap().value("source").toString(); + if (source == "html-heading") return tr("見出し一覧"); + if (source == "spine") return tr("章一覧"); + return tr("目次"); +} +bool Controller::outlineLoading() const { + return active_ && active_->meta.value("capabilities").toMap().value("outline").toString() == "loading"; +} +void Controller::publishNavigation() { + ++outlineRevision_; + webCurrentOutline_ = -1; + updateCurrentOutline(); + emit navigationChanged(); +} +void Controller::updateCurrentOutline() { + int selected = -1; + if (active_ && format() == "pdf" && canvas_) { + const int page = canvas_->location().value("pageIndex", -1).toInt(); + int bestPage = -1; + double bestY = -std::numeric_limits::infinity(); + for (int i = 0; i < outline_.size(); ++i) { + const auto item = outline_[i].toMap(); + const int targetPage = item.value("page", -1).toInt(); + if (item.value("missing").toBool() || targetPage < 0 || targetPage > page || targetPage < bestPage) continue; + double y = canvas_->navigationY(targetPage, item); + // A preceding page is already before the reading position even if + // its geometry has never been requested. Keep page-level certainty. + if (!std::isfinite(y) && targetPage < page) y = 0; + if (!std::isfinite(y) || (targetPage == page && y > 16.5)) continue; + if (targetPage > bestPage || y >= bestY) { selected = i; bestPage = targetPage; bestY = y; } + } + } else if (active_) { + selected = webCurrentOutline_; + if (selected < 0 && format() == "epub") { + const auto spine = active_->meta.value("spine").toList(); + QHash order; + for (int i = 0; i < spine.size(); ++i) order[QUrl(spine[i].toMap().value("href").toString()).path()] = i; + int best = -1; + for (int i = 0; i < outline_.size(); ++i) { + const auto item = outline_[i].toMap(); const QUrl target(item.value("href").toString()); + const int chapter = order.value(target.path(), -1); + if (item.value("missing").toBool() || chapter < 0 || chapter > chapter_ || chapter < best || + (chapter == chapter_ && !target.fragment().isEmpty())) continue; + selected = i; best = chapter; + } + } + } + if (selected != currentOutline_) { currentOutline_ = selected; emit currentOutlineChanged(); } +} +QVariantMap Controller::webNavigationTargets(const QString &token, const QUrl &url) const { + if (!active_ || active_->token != token || active_->resourcesStopped || format() == "pdf") return {}; + const auto path = pathFromDocumentUrl(url, token); + if (path.isEmpty() || (!active_->entries.isEmpty() && !active_->entries.contains(path))) return {}; + if (cachedNavigationRevision_ == outlineRevision_ && cachedNavigationPath_ == path) + return cachedNavigationTargets_; + cachedNavigationRows_.clear(); + QUrl base; base.setScheme("doc"); base.setHost(token); base.setPath("/"); + QVariantList targets; + for (int i = 0; i < outline_.size(); ++i) { + const auto item = outline_[i].toMap(); + if (item.value("missing").toBool()) continue; + const auto href = item.value("href").toString(); + if (href.isEmpty() && item.value("source").toString() == "html-heading") { + targets.append(QVariantMap{{"row", i}, {"heading", item.value("index")}}); + cachedNavigationRows_.insert(i); + continue; + } + const auto target = base.resolved(QUrl(href, QUrl::StrictMode)); + if (href.isEmpty() || pathFromDocumentUrl(target, token) != path) continue; + targets.append(QVariantMap{{"row", i}, {"fragment", target.fragment(QUrl::FullyDecoded)}}); + cachedNavigationRows_.insert(i); + } + cachedNavigationRevision_ = outlineRevision_; cachedNavigationPath_ = path; + cachedNavigationTargets_ = {{"revision", QString::number(outlineRevision_)}, {"targets", targets}}; + return cachedNavigationTargets_; +} +void Controller::setContext(const QString &v) { + if (context_ == v) + return; + context_ = v; + input_.reset(); + emit contextChanged(); +} +void Controller::setMode(const QString &v) { + if (mode_ == v) + return; + mode_ = v; + input_.setMode(v); + composing_ = false; + emit modeChanged(); +} +void Controller::notify(const QString &m) { + notice_ = m.left(2048); + emit noticeChanged(); + noticeTimer_.start(m.startsWith("E_") ? 15000 : 4500); +} +void Controller::applyMemoryPressure(MemoryPressureLevel level) { + if (memoryPressureLevel_ == level) return; + memoryPressureLevel_ = level; + if (canvas_) canvas_->setMemoryPressureLevel(int(level)); + if (level == MemoryPressureLevel::Stop) { + savePosition(); + cancel(); + indexTimer_.stop(); + pageRequests_.clear(); + restorePending_.clear(); + if (canvas_) canvas_->setDocument(nullptr, {}, 0); + const QString message = tr("メモリの空きが不足したため文書処理を停止しました。空きが回復してから文書を開き直してください"); + if (active_) { + rendererWatchdog_.removeSession(active_->token); + emit disposeWeb(active_->token); + fail(active_, "E_RESOURCE_LIMIT", message); + } else notify("E_RESOURCE_LIMIT: " + message); + return; + } + if (!active_) return; + QString message; + if (level == MemoryPressureLevel::Normal) + message = active_->resourcesStopped + ? tr("メモリの空きが回復しました。停止した文書を開き直せます") + : tr("メモリの空きが回復し、通常の描画に戻しました"); + else if (active_->format == "pdf") { + if (level == MemoryPressureLevel::NoPrefetch) + message = tr("メモリの空きが少ないため、先読みを一時停止しました"); + else if (level == MemoryPressureLevel::Trim) + message = tr("メモリの空きが少ないため、画面外の描画キャッシュを解放しました"); + else if (level == MemoryPressureLevel::Reduced) + message = tr("メモリの空きが少ないため、描画解像度を一時的に抑えています"); + } else if (level == MemoryPressureLevel::Reduced) + message = tr("メモリの空きが少なくなっています。圧迫が続く場合は文書処理を停止します"); + if (!message.isEmpty()) { + notify(message); + if (level == MemoryPressureLevel::Reduced && active_->format == "pdf") + recordWarnings(active_, {tr("メモリ圧迫時に描画解像度を一時的に抑制しました")}); + } +} +void Controller::dispose(Session *s) { + if (!s) + return; + rendererWatchdog_.removeSession(s->token); + stopResources(s); + emit disposeWeb(s->token); + s->deleteLater(); +} +void Controller::stopResources(Session *s) { + s->resourcesStopped = true; + if (s->handler) + s->handler->revoke(); + if (s->fontBroker) + s->fontBroker->revoke(); + if (s->worker) + s->worker->stop(); + auto pending = std::move(s->extractWaiters); + s->extractWaiters.clear(); + for (auto &waiters : pending) + for (auto &callback : waiters) + callback(ResourceFailure::Cancelled); +} +void Controller::recordWarnings(Session *s, const QStringList &incoming) { + QStringList warnings; + const auto combined = s->meta.value("warnings").toStringList() + incoming; + for (const auto &message : combined) { + const QString bounded = message.left(1024); + if (!bounded.isEmpty() && !warnings.contains(bounded)) + warnings << bounded; + if (warnings.size() >= 32) + break; + } + s->meta["warnings"] = warnings; +} +static QString resourceIssueDescription(const QString &key) { + const QHash labels{ + {"broker.missing", QObject::tr("文書内の資源が見つかりません")}, + {"broker.blocked", QObject::tr("安全でない文書内の参照を遮断しました")}, + {"broker.denied", QObject::tr("資源へのアクセスがOSに拒否されました")}, + {"broker.type", QObject::tr("資源の形式を許可できません")}, + {"broker.limit", QObject::tr("資源がサイズ上限を超えました")}, + {"broker.archive_limit", QObject::tr("アーカイブの展開上限を超えました")}, + {"broker.corrupt", QObject::tr("資源のCRC・圧縮データ・長さが不正です")}, + {"broker.storage_full", QObject::tr("一時保存先の空き容量または割当量が不足しています")}, + {"broker.storage_failed", QObject::tr("一時資源の書込み・確定に失敗しました")}, + {"broker.io", QObject::tr("資源の読取りに失敗しました")}, + {"broker.worker_failed", QObject::tr("資源を処理するワーカーが停止しました")}, + {"broker.worker_timeout", QObject::tr("資源の処理期限を超えました")}, + {"network.blocked", QObject::tr("通信・文書外の参照を遮断しました")}, + {"csp.image", QObject::tr("画像の読込みを表示制限で遮断しました")}, + {"csp.style", QObject::tr("スタイルシートの読込みを表示制限で遮断しました")}, + {"csp.font", QObject::tr("フォントの読込みを表示制限で遮断しました")}, + {"csp.script", QObject::tr("文書スクリプトを無効にしました")}, + {"csp.frame", QObject::tr("埋込みフレームを表示制限で遮断しました")}, + {"csp.connect", QObject::tr("文書からの通信を遮断しました")}, + {"csp.form", QObject::tr("フォーム送信を遮断しました")}, + {"csp.base", QObject::tr("文書外を基準とする参照を遮断しました")}, + {"csp.object", QObject::tr("埋込みオブジェクトを表示制限で遮断しました")}, + {"csp.media", QObject::tr("音声・動画の読込みを表示制限で遮断しました")}, + {"csp.other", QObject::tr("その他の表示制限で資源を遮断しました")}}; + return labels.value(key); +} +static QString resourceIssueCode(const QString &key) { + for (auto failure : {ResourceFailure::Missing, ResourceFailure::Blocked, ResourceFailure::AccessDenied, + ResourceFailure::UnsupportedType, ResourceFailure::ResourceLimit, ResourceFailure::ArchiveLimit, + ResourceFailure::ArchiveCorrupt, ResourceFailure::StorageFull, ResourceFailure::StorageFailed, + ResourceFailure::ReadFailed, ResourceFailure::WorkerFailed, ResourceFailure::WorkerTimeout}) + if (resourceFailureKey(failure) == key) return resourceFailureCode(failure); + return "E_RESOURCE_BLOCKED"; // Known network/CSP reasons. +} +void Controller::recordResourceIssue(Session *s, const QString &key, int count) { + if (!s || (s != active_ && s != staging_) || s->resourcesStopped || count <= 0 || count > 999999 || + resourceIssueDescription(key).isEmpty()) return; + const bool first = s->resourceIssues.isEmpty(); + s->resourceIssues[key] = qMin(999999, s->resourceIssues.value(key) + count); + if (first) { + const QString message = tr("一部の資源を表示できません。:infoで理由と検出件数を確認できます"); + recordWarnings(s, {message}); + notify(message); + } +} +void Controller::webResourceIssues(const QString &token, const QVariantMap &counts) { + Session *s = active_ && active_->token == token ? active_ : + staging_ && staging_->token == token ? staging_ : nullptr; + if (!s || s->resourcesStopped || counts.isEmpty() || counts.size() > 11) return; + // Validate the whole report before applying any renderer-provided count. + for (auto it = counts.begin(); it != counts.end(); ++it) { + if (!it.key().startsWith("csp.") || resourceIssueDescription(it.key()).isEmpty()) return; + const int type = it.value().metaType().id(); + if (type != QMetaType::Int && type != QMetaType::UInt && type != QMetaType::Double && + type != QMetaType::LongLong && type != QMetaType::ULongLong) return; + const double value = it.value().toDouble(); + if (!std::isfinite(value) || value < 1 || value > 999999 || std::floor(value) != value) return; + } + for (auto it = counts.begin(); it != counts.end(); ++it) + recordResourceIssue(s, it.key(), it.value().toInt()); +} +void Controller::cancel() { + if (canvas_) { + canvas_->cancelPendingNavigation(); + canvas_->clearSelection(); + } + if (staging_) { + auto *s = staging_; + staging_ = nullptr; + dispose(s); + state_ = active_ ? (active_->resourcesStopped ? "Recovering" : "Ready") : "Empty"; + emit stateChanged(); + notify(active_ && active_->resourcesStopped ? tr("読み込みを取り消しました。前の文書は開き直す必要があります") + : tr("読み込みを取り消しました")); + } + passwordPath_.clear(); + headingDirection_ = 0; + ++searchRevision_; + emit webCommand("search.cancel", {}); + emit webCommand("link.clear", {}); + if (active_ && active_->worker) + active_->worker->discardQueued("search"); + input_.reset(); + setContext("content"); +} +void Controller::open(const QUrl &u) { + if (u.isLocalFile()) + openPath(u.toLocalFile()); + else + notify(tr("E_OPEN_FAILED: ローカルファイルを選択してください")); +} +void Controller::closeDocument() { + savePosition(); + auto *old = active_; auto *pending = staging_; + active_ = staging_ = nullptr; + ++generation_; ++searchRevision_; + indexTimer_.stop(); noticeTimer_.stop(); + headingDirection_ = indexPage_ = chapter_ = 0; headingsComplete_ = false; + searchPage_ = searchStart_ = -1; searchHasText_ = false; + passwordPath_.clear(); query_.clear(); restorePending_.clear(); pageRequests_.clear(); + outline_.clear(); pages_.clear(); headings_.clear(); webLocation_.clear(); back_.clear(); forward_.clear(); + webCurrentOutline_ = -1; + input_.reset(); setMode("normal"); setContext("content"); + if (canvas_) canvas_->setDocument(nullptr, {}, 0); + dispose(pending); dispose(old); + state_ = "Empty"; notice_.clear(); + emit activateWeb({}); + emit documentChanged(); publishNavigation(); emit positionChanged(); emit stateChanged(); emit noticeChanged(); +} +QVariantMap Controller::benchmarkSnapshot() const { + if (!active_) return {}; + int chapters = 0; + for (const auto &value : active_->meta.value("spine").toList()) { + const auto row = value.toMap(); + if (row.value("linear", true).toBool() && !row.value("missing").toBool()) ++chapters; + } + const auto *worker = active_->worker; + const auto navigationHeadings = active_->format == "pdf" ? pdfHeadingCandidates() : headings_; + return {{"location", currentLocation()}, {"capabilities", active_->meta.value("capabilities")}, + {"headingCount", navigationHeadings.size()}, + {"firstHeading", navigationHeadings.value(0)}, + {"headingsReady", active_->format != "pdf" || headingsComplete_}, {"chapterCount", chapters}, + {"chapterIndex", chapter_}, + {"queuedRequests", worker ? worker->queuedRequestCount() : 0}, + {"activeRequests", worker ? worker->activeRequestCount() : 0}, + {"queuedRenders", worker ? worker->queuedRequestCount("render") : 0}, + {"activeRenders", worker ? worker->activeRequestCount("render") : 0}, + {"discardedQueuedRequests", worker ? qint64(worker->discardedQueuedRequestCount()) : 0}}; +} +void Controller::openPath(const QString &path, const QString &passwordText, int initialPage) { + lastOpenFailure_.clear(); + if (memoryPressureLevel_ == MemoryPressureLevel::Stop) { + recordOpenFailure(path, "E_RESOURCE_LIMIT"); + return; + } + cancel(); + QFileInfo info(path); + const QString canonical = info.canonicalFilePath(); + QFile source(canonical); + if (canonical.isEmpty() || !info.isFile() || !source.open(QIODevice::ReadOnly)) { + recordOpenFailure(path, "E_OPEN_FAILED"); + return; + } + const auto head = source.read(8); + const QString suffix = info.suffix().toLower(); + QString kind; + if (suffix == "pdf" && head.startsWith("%PDF-")) + kind = "pdf"; + else if ((suffix == "zip" || suffix == "epub") && head.startsWith("PK")) + kind = suffix == "epub" ? "epub" : "htmlzip"; + else if (QStringList{"html", "htm", "xhtml"}.contains(suffix)) + kind = "html"; + else { + recordOpenFailure(path, "E_FORMAT_UNSUPPORTED"); + return; + } + QString sandboxError; + if (kind != "pdf" && !webSandboxEnvironmentSafe(&sandboxError)) { + recordOpenFailure(path, "E_SANDBOX_UNAVAILABLE"); + return; + } + auto *s = new Session(this); + s->token = QUuid::createUuid().toString(QUuid::Id128); + s->generation = ++generation_; + s->initialPage = initialPage; + s->source = canonical; + s->format = kind; + s->sourceIdentity = StateStore::fileIdentity(canonical); + staging_ = s; + state_ = "Opening"; + input_.reset(); + emit stateChanged(); + notify(tr("読込中 · %1").arg(info.fileName())); + if (kind == "html") { + s->root = QFileInfo(canonical).absolutePath(); + s->entry = QFileInfo(canonical).fileName(); + prepareWeb(s); + return; + } + QStringList args = {"--source", canonical}; + if (kind != "pdf") { + const QString base = storagePaths_.cacheDirectory + "/sessions"; + QDir().mkpath(base); + QFile::setPermissions(base, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); + s->temporary = std::make_unique(base + "/docview-XXXXXX"); + if (!s->temporary->isValid()) { + fail(s, "E_STORAGE_FULL", tr("作業領域を作成できません")); + return; + } + const QString owned = s->temporary->path(); + s->sessionLock = std::make_unique(owned + "/.lock"); + if (!s->sessionLock->tryLock()) { + fail(s, "E_STORAGE_FULL", tr("作業領域をロックできません")); + return; + } + QFile marker(owned + "/.docview-session"); + if (!marker.open(QIODevice::WriteOnly) || marker.write("docview-session-v1\n") != 19) { + fail(s, "E_STORAGE_FULL", tr("作業領域を準備できません")); + return; + } + marker.close(); + s->root = owned + "/resources"; + if (!QDir().mkdir(s->root)) { + fail(s, "E_STORAGE_FULL", tr("資源領域を準備できません")); + return; + } + args << "--output" << s->root; + } + s->worker = new WorkerProcess(s->token, s->generation, s); + if (kind == "pdf") { + s->fontBroker = new FontBroker(s); + QPointer broker = s->fontBroker; + s->worker->setFontRequestHandler( + [broker](const QString &operation, const QCborMap &payload, + std::function completion) { + if (broker) + broker->request(operation, payload, std::move(completion)); + else + completion({{"error", QCborMap{{"code", "E_FONT_FAILED"}, + {"message", tr("フォントの供給処理は終了しています。")}}}}); + }); + } + connect(s->worker, &WorkerProcess::failed, this, + [this, s](const QString &c, const QString &m) { + // WorkerProcess has discarded its RPC callbacks by this point. + // Complete resource jobs with the real failure before revocation; + // explicit dispose/cancel still drains them as Cancelled. + if ((s == active_ || s == staging_) && !s->resourcesStopped) { + const auto failure = c == "E_WORKER_TIMEOUT" ? ResourceFailure::WorkerTimeout : ResourceFailure::WorkerFailed; + auto pending = std::move(s->extractWaiters); s->extractWaiters.clear(); + for (auto &waiters : pending) + for (auto &callback : waiters) callback(failure); + } + fail(s, c, m); + }); + connect(s->worker, &WorkerProcess::slow, this, + [this] { notify(tr("文書を処理中です。Escで取り消せます")); }); + QPointer weak = s; + connect(s->worker, &WorkerProcess::ready, this, [this, weak, passwordText] { + if (!weak || weak != staging_) + return; + QCborMap payload{{"password", passwordText}, {"epub", weak->format == "epub"}}; + weak->worker->request("open", payload, [this, weak](const QCborMap &m) { + if (!weak || weak != staging_) + return; + auto *s = weak.data(); + if (m.contains(QStringLiteral("error"))) { + const auto e = m.value("error").toMap(); + if (failPdfWorker(s, e)) return; + QString code = e.value("code").toString(); + if (code == "E_PASSWORD_REQUIRED" || code == "E_PASSWORD_INVALID") { + passwordPath_ = s->source; + passwordInitialPage_ = s->initialPage; + state_ = "PasswordRequired"; + emit stateChanged(); + emit passwordNeeded(QFileInfo(s->source).fileName()); + notify(code + ": " + e.value("message").toString()); + return; + } + fail(s, code, e.value("message").toString()); + return; + } + s->meta = m.value("result").toMap().toVariantMap(); + recordWarnings(s, {}); + for (const QString &kind : QStringList{"outline", "spine", "pageList", "landmarks", "headings"}) { + QVariantList clean; + if (!sanitizeNavigation(s->meta.value(kind).toList(), &clean, &s->navigationBytes)) { + fail(s, "E_WORKER_CRASH", tr("ナビゲーションの応答が不正です")); + return; + } + s->meta[kind] = clean; + } + if (s->format == "pdf") { + if (!validateCapabilities(s->meta.value("capabilities"), + s->meta.value("capabilityReasons", QVariantMap{}))) { + fail(s, "E_WORKER_CRASH", tr("文書の能力情報が不正です")); + return; + } + int count = s->meta.value("pageCount").toInt(); + const int outlineCount = s->meta.value("outlineCount").toInt(); + const int outlineCursor = s->meta.value("nextOutlineCursor").toInt(); + const auto initialOutline = s->meta.value("outline").toList(); + if (outlineCount < 0 || outlineCount > 20000 || initialOutline.size() > outlineCount || + outlineCursor != (initialOutline.size() < outlineCount ? initialOutline.size() : -1)) { + fail(s, "E_WORKER_CRASH", tr("PDF目次の応答が不正です")); + return; + } + if (count < 1 || count > 100000 || s->meta.value("pages").toList().isEmpty() || + !validatePageMetadata(s->meta.value("pages").toList(), count, 0)) { + fail(s, "E_WORKER_CRASH", tr("PDFの応答が不正です")); + return; + } + s->worker->request( + "render", + {{"page", 0}, + {"scale", .25}, + {"rotation", 0}, + {"x", 0}, + {"y", 0}, + {"width", 128}, + {"height", 128}, + {"renderRevision", 0}}, + [this, weak](const QCborMap &first) { + if (!weak || weak != staging_) + return; + const auto r = first.value("result").toMap(); + if (first.contains(QStringLiteral("error"))) { + const auto error = first.value("error").toMap(); + if (failPdfWorker(weak, error)) return; + fail(weak, error.value("code").toString(), error.value("message").toString() + + tr(" 文書を開き直してください。")); + return; + } + if (r.value("width").toInteger() != 128 || + r.value("height").toInteger() != 128 || r.value("stride").toInteger() != 512 || + r.value("data").toByteArray().size() != 65536) { + fail(weak, "E_PDF_CORRUPT", tr("最初のページを表示できません")); + return; + } + recordWarnings(weak, r.value("warnings").toVariant().toStringList()); + commit(weak); + }); + } else { + const QString resultFormat = s->meta.value("format").toString(); + if (resultFormat != "htmlzip" && resultFormat != "epub") { + fail(s, "E_WORKER_CRASH", tr("アーカイブの応答が不正です")); + return; + } + s->format = resultFormat; + s->entry = s->meta.value("entry").toString(); + for (const auto &v : s->meta.value("entries").toList()) { + const auto e = v.toMap(); + const QString p = e.value("path").toString(); + if (!e.value("directory").toBool()) { + if (!safeResourcePath(p)) { + fail(s, "E_ARCHIVE_UNSAFE_PATH", tr("資源パスが不正です")); + return; + } + s->entries.insert(p); + s->mimeTypes.insert(p, e.value("mime").toString().toLatin1()); + } + } + int next = s->meta.value("nextOffset", -1).toInt(); + if (next >= 0 && next < s->meta.value("entryCount").toInt()) + archiveEntries(s, next); + else + archiveMetadata(s); + } + }); + }); + s->worker->start(QCoreApplication::applicationDirPath() + + (kind == "pdf" ? "/docview-pdf-worker" : "/docview-archive-worker"), + args); +} +void Controller::archiveEntries(Session *s, int offset) { + QPointer w = s; + s->worker->request("entries", {{"offset", offset}, {"count", 128}}, [this, w, offset](const QCborMap &m) { + if (!w || w != staging_) + return; + if (m.contains(QStringLiteral("error"))) { + fail(w, "E_ARCHIVE_CORRUPT", tr("資源一覧を読み込めません")); + return; + } + auto r = m.value("result").toMap(); + for (const auto &v : r.value("entries").toArray()) { + const auto e = v.toMap(); + QString path = e.value("path").toString(); + if (e.value("directory").toBool()) + continue; + if (!safeResourcePath(path) || w->entries.size() >= 20000 || w->entries.contains(path)) { + fail(w, "E_ARCHIVE_UNSAFE_PATH", tr("資源一覧が不正です")); + return; + } + w->entries.insert(path); + w->mimeTypes.insert(path, e.value("mime").toString().toLatin1()); + } + int next = int(r.value("nextOffset").toInteger(-1)); + if (next > offset && next < r.value("total").toInteger()) + archiveEntries(w, next); + else if (next != r.value("total").toInteger()) + fail(w, "E_WORKER_CRASH", tr("資源一覧が進行しません")); + else + archiveMetadata(w); + }); +} +void Controller::password(const QString &t) { + const QString path = passwordPath_; + const int page = passwordInitialPage_; + if (!path.isEmpty()) + openPath(path, t, page); +} +void Controller::chooseEntry(const QString &p) { + if (!staging_ || staging_->format != "htmlzip" || !safeResourcePath(p) || + !staging_->meta.value("candidates").toList().contains(p) || !staging_->entries.contains(p) || + !QStringList{"html", "htm", "xhtml"}.contains(QFileInfo(p).suffix().toLower())) + return; + staging_->entry = p; + staging_->entrySelected = true; + prepareWeb(staging_); +} +void Controller::prepareWeb(Session *s) { + if (s != staging_ || !safeResourcePath(s->entry) || + (!s->entries.isEmpty() && !s->entries.contains(s->entry))) { + fail(s, "E_HTML_ENTRY_MISSING", tr("文書の入口がありません")); + return; + } + ResourceHandler::Extract extract; + if (s->worker) { + QPointer w = s; + extract = [this, w](QString path, ResourceHandler::Completion done) { + if (!w || w->resourcesStopped) { done(ResourceFailure::Cancelled); return; } + if (!w->entries.contains(path)) { done(ResourceFailure::Missing); return; } + if (w->extracted.contains(path)) { done(ResourceFailure::None); return; } + if (w->extractWaiters.contains(path)) { + w->extractWaiters[path].append(std::move(done)); return; + } + w->extractWaiters[path].append(std::move(done)); + auto writer = std::make_shared(w->root, path); + auto finish = [w, path](ResourceFailure failure) { + if (!w) return; + auto waiters = w->extractWaiters.take(path); + if (failure == ResourceFailure::None) w->extracted.insert(path); + for (auto &callback : waiters) callback(failure); + }; + if (!writer->isValid()) { finish(writer->failure()); return; } + w->worker->request( + "extract", {{"path", path}}, [this, w, path, writer, finish](const QCborMap &m) { + if (!w || w->resourcesStopped) return; + if (m.contains(QStringLiteral("error"))) { + const auto code = m.value("error").toMap().value("code").toString(); + const QHash known{ + {"E_RESOURCE_MISSING", ResourceFailure::Missing}, {"E_RESOURCE_BLOCKED", ResourceFailure::Blocked}, + {"E_ARCHIVE_UNSAFE_PATH", ResourceFailure::Blocked}, {"E_ARCHIVE_LIMIT", ResourceFailure::ArchiveLimit}, + {"E_ARCHIVE_CORRUPT", ResourceFailure::ArchiveCorrupt}, {"E_STORAGE_FULL", ResourceFailure::StorageFull}, + {"E_WORKER_TIMEOUT", ResourceFailure::WorkerTimeout}, {"E_WORKER_CRASH", ResourceFailure::WorkerFailed}}; + const auto failure = known.value(code, ResourceFailure::WorkerFailed); + finish(failure); + // Corrupt/archive-limit input stops further extraction, retaining visible content. + if (failure == ResourceFailure::ArchiveCorrupt || failure == ResourceFailure::ArchiveLimit || + failure == ResourceFailure::WorkerFailed || failure == ResourceFailure::WorkerTimeout) + fail(w, resourceFailureCode(failure), tr("資源を安全に読み込めないため処理を停止しました")); + return; + } + const auto r = m.value("result").toMap(); + if (r.value("path").toString() != path) { + finish(ResourceFailure::WorkerFailed); + fail(w, "E_WORKER_CRASH", tr("資源の応答が一致しません")); return; + } + if (r.value("more").toBool()) { + const auto data = r.value("data"); const QByteArray bytes = data.toByteArray(); + if (!data.isByteArray() || bytes.isEmpty() || bytes.size() > 65536) { + finish(ResourceFailure::WorkerFailed); + fail(w, "E_WORKER_CRASH", tr("資源の応答が不正です")); return; + } + if (w->expandedBytes + bytes.size() > 2ll * 1024 * 1024 * 1024 || writer->size() + bytes.size() > 256ll * 1024 * 1024) { + finish(ResourceFailure::ArchiveLimit); + fail(w, "E_ARCHIVE_LIMIT", tr("展開量が安全上限を超えました")); return; + } + w->expandedBytes += bytes.size(); + if (!writer->write(bytes)) { + const auto failure = writer->failure(); finish(failure); + fail(w, resourceFailureCode(failure), tr("資源を書き込めないため展開を停止しました")); + } + return; + } + const auto size = r.value("size"); + if (!size.isInteger() || size.toInteger() != writer->size()) { + finish(ResourceFailure::ArchiveCorrupt); + fail(w, "E_ARCHIVE_CORRUPT", tr("資源の長さが一致しません")); return; + } + if (!writer->commit()) { + const auto failure = writer->failure(); finish(failure); + fail(w, resourceFailureCode(failure), tr("資源を安全に確定できません")); return; + } + finish(ResourceFailure::None); + }); + }; + } + s->handler = new ResourceHandler(s->token, s->root, s->entries, extract); + s->handler->setMimeTypes(s->mimeTypes); + connect(s->handler, &ResourceHandler::blocked, this, [this, s](const QString &, const QString &category, int count) { + recordResourceIssue(s, category == "network" ? "network.blocked" : category, count); + }); + s->profile = createDocumentProfile(s->token, s->handler, s); + QUrl url; + url.setScheme("doc"); + url.setHost(s->token); + url.setPath("/" + s->entry); + s->handler->authorizeTopLevel(url); + emit stageWeb(s->profile, url, s->token); +} +void Controller::webLoaded(const QString &t, bool ok) { + if (staging_ && staging_->token == t) { + if (ok) + commit(staging_); + else + fail(staging_, "E_OPEN_FAILED", tr("本文を表示できません")); + } else if (active_ && active_->token == t && !ok) + notify(tr("E_OPEN_FAILED: この章を表示できません。:infoで資源の警告を確認できます")); +} +void Controller::commit(Session *s) { + if (s != staging_) + return; + if (s->format != "pdf") { + const bool hasOutline = !s->meta.value("outline").toList().isEmpty() || + (s->format == "epub" && !s->meta.value("spine").toList().isEmpty()); + s->meta["capabilities"] = + QVariantMap{{"pageNavigation", "unsupported"}, + {"chapterNavigation", s->format == "epub" ? "supported" : "unsupported"}, + {"outline", hasOutline ? "supported" : "loading"}, + {"headings", "loading"}, + {"textSearch", "supported"}, + {"textSelection", "supported"}, + {"reflow", s->meta.value("fixed").toBool() ? "unsupported" : "supported"}, + {"password", "unavailable"}}; + QVariantMap reasons{{"pageNavigation", "E_PAGE_NAVIGATION_UNSUPPORTED"}}; + if (s->format != "epub") + reasons["chapterNavigation"] = "E_CHAPTER_NAVIGATION_UNSUPPORTED"; + if (s->meta.value("fixed").toBool()) + reasons["reflow"] = "E_REFLOW_UNSUPPORTED"; + s->meta["capabilityReasons"] = reasons; + } + if (!validateCapabilities(s->meta.value("capabilities"), + s->meta.value("capabilityReasons", QVariantMap{}))) { + fail(s, "E_WORKER_CRASH", tr("文書の能力情報が不正です")); + return; + } + savePosition(); + Session *old = active_; + active_ = s; + staging_ = nullptr; + state_ = "Ready"; + outline_ = s->meta.value("outline").toList(); + pages_.clear(); + headings_.clear(); + webLocation_.clear(); + back_.clear(); + forward_.clear(); + chapter_ = 0; + searchPage_ = searchStart_ = -1; + searchHasText_ = false; + ++searchRevision_; + pageRequests_.clear(); + indexPage_ = 0; + headingDirection_ = 0; + headingsComplete_ = false; + indexTimer_.stop(); + if (canvas_ && s->format != "pdf") canvas_->setDocument(nullptr, {}, 0); + if (s->format == "pdf") { + indexTimer_.start(); + const int count = s->meta.value("pageCount").toInt(); + for (int i = 0; i < count; ++i) + pages_.append(QVariantMap{{"title", QString::number(i + 1)}, {"page", i}}); + updatePageLabels(s->meta.value("pages").toList()); + if (canvas_) { + canvas_->setDocument(s->worker, s->meta.value("pages").toList(), count); + if (config_.value("pdf.zoom_mode").toString() == "percent") + canvas_->setZoom(config_.value("pdf.zoom_percent").toDouble()); + else if (config_.value("pdf.zoom_mode").toString() == "fit-page") + canvas_->fitPage(); + } + } else if (s->format == "epub") { + pages_ = s->meta.value("pageList").toList(); + if (pages_.isEmpty()) + pages_ = s->meta.value("spine").toList(); + if (outline_.isEmpty()) + outline_ = s->meta.value("spine").toList(); + } + const auto restoration = store_ ? store_->restoreOpened(s->source, s->sourceIdentity) : StateStore::Restoration{}; + restorePending_ = restoration.record.value("location").toMap(); + if (s->format != "pdf" && !restorePending_.isEmpty() && !restorePending_.contains("zoom") && restoration.record.contains("zoom")) + restorePending_["zoom"] = restoration.record.value("zoom"); + setContext("content"); + emit documentChanged(); + publishNavigation(); + emit positionChanged(); + emit stateChanged(); + if (s->profile) + emit activateWeb(s->token); + else + emit activateWeb({}); + if (old) + dispose(old); + notify(s->meta.value("warnings").toStringList().join("\n")); + if (restoration.identityMismatch) + notify(tr("E_LOCATOR_UNRESOLVED: 原本が変更または置換されているため、以前の位置を復元できません。先頭から表示します。")); + if (memoryPressureLevel_ == MemoryPressureLevel::Reduced) { + const auto message = s->format == "pdf" + ? tr("メモリの空きが少ないため、描画解像度を一時的に抑えています") + : tr("メモリの空きが少なくなっています。圧迫が続く場合は文書処理を停止します"); + if (s->format == "pdf") recordWarnings(s, {tr("メモリ圧迫時に描画解像度を一時的に抑制しました")}); + notify(message); + } + emit uiCommand("focus.content", {}); + if (s->format == "pdf" && !restorePending_.isEmpty()) { + restoreLocation(restorePending_); + restorePending_.clear(); + } + if (s->initialPage > 0) + execute("nav.page", {{"page", s->initialPage}}); + if (s->format == "htmlzip" && s->entrySelected && store_ && !store_->readOnly()) + store_->rememberArchiveEntry(s->source, s->entry, s->sourceIdentity); +} +void Controller::recordOpenFailure(const QString &path, const QString &code) { + QString safeCode = code; + QString reason, action; + if (code == "E_PDF_CORRUPT" || code == "E_ARCHIVE_CORRUPT") { + reason = tr("文書の内容を解析できません"); + action = tr("原本が正しく表示できるか確認し、別の正常なファイルを選択してください。"); + } else if (code == "E_ARCHIVE_ENCRYPTED" || code == "E_DRM_UNSUPPORTED") { + reason = code == "E_ARCHIVE_ENCRYPTED" ? tr("パスワード付きZIPには対応していません") : tr("この暗号化・保護方式には対応していません"); + action = tr("提供元の対応ビューアーを使用するか、対応する形式の文書を選択してください。"); + } else if (code == "E_EPUB_PACKAGE_INVALID" || code == "E_EPUB_NAV_INVALID") { + reason = tr("EPUBの文書構造または読書順を確認できません"); + action = tr("正常なEPUBファイルか確認し、別の原本を選択してください。"); + } else if (code == "E_PDF_FEATURE_UNSUPPORTED") { + reason = tr("このPDFの内容には対応していません"); + action = tr("提供元の対応ビューアーで内容を確認してください。"); + } else if (code == "E_FORMAT_UNSUPPORTED") { + reason = tr("対応する形式またはファイルの先頭情報ではありません"); + action = tr("PDF、HTML、HTML ZIP、EPUBの対応形式か確認してください。"); + } else if (code == "E_ARCHIVE_LIMIT" || code == "E_RESOURCE_LIMIT" || code == "E_FONT_LIMIT" || code == "E_LIMIT_EXCEEDED") { + reason = tr("処理に必要な資源または安全上限の制限に達しました"); + action = tr("メモリの空きや文書の大きさを確認し、小さい文書を選ぶか開き直してください。"); + } else if (code == "E_STORAGE_FULL" || code == "E_STORAGE_FAILED") { + reason = tr("作業領域へ保存できません"); + action = tr("保存先の空き容量と書き込み権限を確認してから開き直してください。"); + } else if (code == "E_SANDBOX_UNAVAILABLE") { + reason = tr("安全な文書処理を開始できません"); + action = tr("対応する実行環境とインストール状態を確認してください。"); + } else if (code == "E_WORKER_TIMEOUT" || code == "E_WORKER_CRASH" || code == "E_FONT_FAILED") { + reason = code == "E_WORKER_TIMEOUT" ? tr("文書処理が時間内に完了しませんでした") : tr("文書処理が停止しました"); + action = tr("文書を開き直してください。再び失敗する場合は別の文書を選択してください。"); + } else if (code == "E_ARCHIVE_UNSAFE_PATH" || code == "E_RESOURCE_BLOCKED" || code == "E_RESOURCE_ACCESS_DENIED") { + reason = tr("安全に読み取れない資源が含まれています"); + action = tr("文書の構成と読み取り権限を確認し、正常な原本を選択してください。"); + } else if (code == "E_HTML_ENTRY_MISSING" || code == "E_RESOURCE_MISSING" || code == "E_EPUB_SPINE_MISSING") { + reason = tr("表示に必要な本文が見つかりません"); + action = tr("本文と関連資源がそろっているか確認してから開き直してください。"); + } else { + safeCode = code == "E_SOURCE_UNREADABLE" ? code : QStringLiteral("E_OPEN_FAILED"); + reason = tr("ファイルを開けません"); + action = tr("ファイルの場所と読み取り権限を確認し、Ctrl+Oで選択してください。"); + } + QString name = QFileInfo(path).fileName().left(512); + for (auto &character : name) + if (character.category() == QChar::Other_Control || character.category() == QChar::Other_Format) character = QChar(0xfffd); + if (name.isEmpty()) name = tr("選択した文書"); + lastOpenFailure_ = tr("%1: %2\n対象: %3\n対処: %4").arg(safeCode, reason, name, action); + notify(lastOpenFailure_); +} +void Controller::fail(Session *s, const QString &c, const QString &m) { + const bool opening = s == staging_; + if (opening) { + recordOpenFailure(s->source, c); + staging_ = nullptr; + dispose(s); + state_ = active_ ? (active_->resourcesStopped ? "Recovering" : "Ready") : "Empty"; + } else if (s == active_) { + state_ = "Recovering"; + indexTimer_.stop(); + stopResources(s); + } else + return; + emit stateChanged(); + if (!opening) notify(c + ": " + m); +} +bool Controller::failPdfWorker(Session *s, const QCborMap &error) { + const auto code = error.value("code").toString(); + if (!s || s->format != "pdf" || (s != active_ && s != staging_) || + (code != "E_FONT_FAILED" && code != "E_FONT_LIMIT" && code != "E_WORKER_TIMEOUT" && code != "E_WORKER_CRASH")) return false; + if (!s->resourcesStopped) + fail(s, code, error.value("message").toString().left(4096) + tr(" 文書を開き直してください。")); + return true; +} +void Controller::loadPdfPages(int p) { + if (!active_ || format() != "pdf" || pageRequests_.contains(p) || state_ != "Ready") + return; + pageRequests_.insert(p); + QPointer w = active_; + w->worker->request("pages", {{"cursor", p}, {"limit", 16}}, [this, w, p](const QCborMap &m) { + if (!w || w != active_ || !canvas_) + return; + pageRequests_.remove(p); + if (m.contains(QStringLiteral("error")) && failPdfWorker(w, m.value("error").toMap())) return; + const auto pages = m.value("result").toMap().value("pages").toArray().toVariantList(); + if (!m.contains(QStringLiteral("error")) && + validatePageMetadata(pages, w->meta.value("pageCount").toInt(), p)) { + canvas_->updatePages(pages); + updatePageLabels(pages); + recordWarnings(w, m.value("result").toMap().value("warnings").toVariant().toStringList()); + publishNavigation(); + } else + fail(w, "E_PDF_CORRUPT", tr("ページ情報を読み込めません")); + }); +} +void Controller::updatePageLabels(const QVariantList &pages) { + for (const auto &value : pages) { + const auto page = value.toMap(); + const int index = page.value("pageIndex", -1).toInt(); + const QString label = page.value("label").toString(); + if (index < 0 || index >= pages_.size() || label.isEmpty()) + continue; + auto row = pages_[index].toMap(); + row["label"] = label; + row["title"] = + label == QString::number(index + 1) ? label : tr("%1(実ページ %2)").arg(label).arg(index + 1); + pages_[index] = row; + } +} +void Controller::execute(const QString &id, const QVariantMap &a) { + if (id == "file.open") { + if (a.contains("path")) + openPath(a.value("path").toString()); + else + emit uiCommand(id, a); + return; + } + if (id == "app.quit") { + qApp->quit(); + return; + } + if (id == "operation.cancel") { + cancel(); + emit uiCommand("focus.content", {}); + return; + } + if (id == "config.reload") { + if (config_.reload()) + notify(tr("設定を再読込しました")); + return; + } + if (id == "document.info") { + QString info = tr("形式: " + "%1\n文書スクリプト・外部資源・フォーム入力は無効です。\nPDFは原色・読み取り専用で" + "表示します。\n") + .arg(format()); + if (active_) { + info += active_->meta.value("warnings").toStringList().join('\n'); + if (!active_->resourceIssues.isEmpty()) { + info += tr("\n\n資源の表示制限(検出件数。同じ資源の再試行を含みます)\n"); + auto keys = active_->resourceIssues.keys(); + keys.sort(); + for (const auto &key : keys) { + const int count = active_->resourceIssues.value(key); + info += resourceIssueCode(key) + ": " + + resourceIssueDescription(key) + tr(" — %1件%2\n").arg(count).arg(count == 999999 ? tr("以上") : QString{}); + } + } + } + if (!lastOpenFailure_.isEmpty()) + info += tr("\n\n最後に開こうとした文書(表示中の文書とは別の試行情報)\n") + lastOpenFailure_; + emit uiCommand(id, {{"text", info}}); + return; + } + const QString capability = CommandRegistry::requiredCapability(id); + if (active_ && !capability.isEmpty()) { + QString error; + if (!validateCapabilities(active_->meta.value("capabilities"), + active_->meta.value("capabilityReasons", QVariantMap{}), &error)) { + notify(error); + return; + } + const auto availability = active_->meta.value("capabilities").toMap().value(capability).toString(); + if (availability != "supported" && availability != "loading") { + if (capability == "headings" && !active_->meta.value("headingLimitation").toString().isEmpty()) { + notify("E_PDF_STRUCTURE_LIMITED: " + active_->meta.value("headingLimitation").toString()); + return; + } + const auto reason = active_->meta.value("capabilityReasons").toMap().value(capability).toString(); + const QString prefix = reason.isEmpty() ? QString{} : reason + ": "; + notify(prefix + (availability == "unsupported" ? tr("この形式ではこの操作を利用できません") + : tr("この文書には対象の情報がありません"))); + return; + } + } + if (id == "history.clear" || id == "document.root.choose" || id.startsWith("panel.") || + id == "focus.next" || id == "focus.content" || id == "command.open" || id == "help.toggle" || + id == "search.open") { + if (id == "panel.toc.toggle" && outline_.isEmpty() && !outlineLoading()) { + notify(tr("この文書には目次がありません")); + return; + } + if (id == "panel.pages.toggle" && pages_.isEmpty()) { + notify(tr("この文書にはページ一覧がありません")); + return; + } + emit uiCommand(id, a); + return; + } + if (!active_) { + if (!staging_ && !recentDocuments().isEmpty() && + (id == "scroll.down" || id == "scroll.up" || id == "scroll.half_down" || + id == "scroll.half_up" || id == "scroll.page_down" || id == "scroll.page_up" || + id == "nav.document_start" || id == "nav.document_end" || id == "link.next" || + id == "link.previous" || id == "link.activate")) { + emit uiCommand("recent.navigate", {{"command", id}}); + return; + } + notify(tr("文書を開いてください")); + return; + } + if (active_->resourcesStopped) { + notify(tr("文書処理は停止しています。文書を開き直してください")); + return; + } + if (id == "nav.back" || id == "nav.forward") { + auto &from = id == "nav.back" ? back_ : forward_; + auto &to = id == "nav.back" ? forward_ : back_; + if (from.isEmpty()) { + notify(tr("移動履歴の端です")); + return; + } + to.push_back(currentLocation()); + auto loc = from.takeLast(); + restoreLocation(loc); + return; + } + if (id == "nav.page" || id == "nav.page_next" || id == "nav.page_previous") { + if (format() != "pdf" || !canvas_) { + notify(tr("実ページの移動はPDFで利用できます")); + return; + } + int p = id == "nav.page" ? a.value("page").toInt() - 1 + : canvas_->currentPage() + (id == "nav.page_next" ? 1 : -1); + if (p < 0 || p >= pages_.size()) { + notify(tr("ページ番号が範囲外です")); + return; + } + rememberJump(); + canvas_->goTo(p); + return; + } + if (id == "nav.chapter_next" || id == "nav.chapter_previous") { + if (format() != "epub") { + notify(tr("章移動はEPUBで利用できます")); + return; + } + const auto spine = active_->meta.value("spine").toList(); + const int direction = id.endsWith("next") ? 1 : -1; + int next = chapter_ + direction; + while (next >= 0 && next < spine.size() && !spine[next].toMap().value("linear", true).toBool()) + next += direction; + if (next < 0 || next >= spine.size()) { + notify(tr("章の端です")); + return; + } + const auto item = spine[next].toMap(); + const auto boundary = a.value("headingBoundary").toString(); + if (boundary == "heading.first" || boundary == "heading.last") { + if (item.value("missing").toBool()) { + notify(tr("E_EPUB_SPINE_MISSING: この章がありません")); + return; + } + rememberJump(); + navigateWeb(item.value("href").toString(), boundary); + setContext("content"); + emit uiCommand("focus.content", {}); + } else + activateItem(item); + return; + } + if (format() == "pdf" && canvas_) { + if (id.startsWith("scroll.")) { + double x = 0, y = 0; + if (id == "scroll.down") + y = 48; + if (id == "scroll.up") + y = -48; + if (id == "scroll.left") + x = -48; + if (id == "scroll.right") + x = 48; + if (id == "scroll.half_down") + y = canvas_->height() * .5; + if (id == "scroll.half_up") + y = -canvas_->height() * .5; + if (id == "scroll.page_down") + y = canvas_->height() * .9; + if (id == "scroll.page_up") + y = -canvas_->height() * .9; + canvas_->scroll(x, y); + return; + } + if (id == "zoom.in") + canvas_->setZoom(canvas_->zoom() * 1.1); + else if (id == "zoom.out") + canvas_->setZoom(canvas_->zoom() / 1.1); + else if (id == "zoom.fit_width") + canvas_->fitWidth(); + else if (id == "view.rotate") + canvas_->rotate(a.value("degrees").toInt()); + else if (id == "nav.document_start") { + rememberJump(); + canvas_->goTo(0); + } else if (id == "nav.document_end") { + rememberJump(); + canvas_->end(); + } else if (id == "nav.heading_next" || id == "nav.heading_previous") + pdfHeading(id.endsWith("next") ? 1 : -1); + else if (id.startsWith("search.")) + findPdf(id == "search.previous" ? -1 : 1); + else if (id == "link.next") + canvas_->focusLink(1); + else if (id == "link.previous") + canvas_->focusLink(-1); + else if (id == "link.activate") + canvas_->activateLink(); + return; + } + if (id == "view.rotate") { + notify(tr("回転はPDFで利用できます")); + return; + } + if (format() == "epub" && (id == "nav.document_start" || id == "nav.document_end")) { + const auto spine = active_->meta.value("spine").toList(); + const bool start = id == "nav.document_start"; + const int direction = start ? 1 : -1; + for (int i = start ? 0 : spine.size() - 1; i >= 0 && i < spine.size(); i += direction) { + const auto item = spine[i].toMap(); + if (!item.value("linear", true).toBool()) + continue; + if (item.value("missing").toBool()) { + notify(tr("E_EPUB_SPINE_MISSING: この章がありません")); + return; + } + rememberJump(); + navigateWeb(item.value("href").toString(), id); + setContext("content"); + emit uiCommand("focus.content", {}); + return; + } + notify(tr("通常の読書順に含まれる章がありません")); + return; + } + if (id.startsWith("nav.heading")) + rememberJump(); + emit webCommand(id, a); +} +bool Controller::command(const QString &t) { + const auto c = CommandRegistry::parse(t); + if (!c.valid()) { + notify(c.error); + return false; + } + execute(c.id, c.arguments); + return true; +} +void Controller::rememberJump() { + auto location = currentLocation(); + if (back_.isEmpty() || back_.last() != location) + back_.append(location); + if (back_.size() > 100) + back_.removeFirst(); + forward_.clear(); +} +QVariantMap Controller::currentLocation() const { + if (format() == "pdf" && canvas_) + return canvas_->location(); + return webLocation_; +} +// Renderer callbacks and saved state are data, not trusted navigation commands. +// Build a finite logical locator instead of retaining a renderer's transient URL +// (whose host is a session capability) or arbitrary additional properties. +static bool logicalWebLocation(const Session *session, const QVariantMap &raw, + bool fromRenderer, QVariantMap *out) { + if (!session || session->format == "pdf" || raw.size() > 24) + return false; + QVariantMap clean; + const QHash strings{{"cfi", 4096}, {"fragment", 1024}, {"textQuote", 160}, + {"href", 8192}, {"resourcePath", 1024}, + {"packagePath", 1024}, {"spineIdref", 1024}}; + for (auto it = strings.begin(); it != strings.end(); ++it) { + if (!raw.contains(it.key())) continue; + const auto value = raw.value(it.key()); + if (value.metaType().id() != QMetaType::QString || value.toString().size() > it.value() || + value.toString().contains(QChar(0))) return false; + if (it.key() == "cfi" || it.key() == "fragment" || it.key() == "textQuote") + clean[it.key()] = value; + } + for (const auto *name : {"progression", "offsetX", "offsetY", "zoom", "panX", "panY"}) { + if (!raw.contains(name)) { + if (qstrcmp(name, "progression") == 0) return false; + continue; + } + const auto value = raw.value(name); + const int type = value.metaType().id(); + if (type != QMetaType::Double && type != QMetaType::Float && type != QMetaType::Int && + type != QMetaType::UInt && type != QMetaType::LongLong && type != QMetaType::ULongLong) + return false; + const double number = value.toDouble(); + if (!std::isfinite(number)) return false; + if (qstrcmp(name, "zoom") == 0) { + if (number < 25 || number > 500) return false; + clean[name] = number; + } else if (qstrcmp(name, "progression") == 0 || qstrcmp(name, "panX") == 0 || qstrcmp(name, "panY") == 0) { + if (number < 0 || number > 1) return false; + clean[name] = number; + } else { + // A huge visible element may start outside the viewport; the reader + // uses zero for such offsets too. Never persist unbounded geometry. + clean[name] = std::abs(number) <= 2 ? number : 0; + } + } + if (raw.contains("fitWidth")) { + if (raw.value("fitWidth").metaType().id() != QMetaType::Bool) return false; + clean["fitWidth"] = raw.value("fitWidth"); + } + QUrl base; + base.setScheme("doc"); base.setHost(session->token); base.setPath("/"); + QUrl url; + if (fromRenderer) { + const auto value = raw.value("url"); + if (value.metaType().id() != QMetaType::QString || value.toString().size() > 8192 || + value.toString().contains(QChar(0))) return false; + url = QUrl(value.toString(), QUrl::StrictMode); + } else { + const auto href = raw.value("href").toString(); + const QUrl relative(href, QUrl::StrictMode); + if (!href.isEmpty() && (!relative.isValid() || !relative.isRelative() || + !relative.authority().isEmpty() || relative.path().startsWith('/'))) return false; + url = href.isEmpty() ? base : base.resolved(relative); + if (href.isEmpty() && raw.contains("resourcePath")) + url.setPath('/' + raw.value("resourcePath").toString()); + // Resolve the stable package/spine identity before interpreting the + // document-local CFI. Old href-only records still work after validation. + if (session->format == "epub" && raw.contains("packagePath")) { + if (raw.value("packagePath") != session->meta.value("packagePath") || + !raw.contains("spineIdref")) return false; + const auto idref = raw.value("spineIdref").toString(); + if (!idref.isEmpty()) { + bool found = false; + for (const auto &value : session->meta.value("spine").toList()) { + const auto item = value.toMap(); + if (item.value("idref").toString() != idref) continue; + if (item.value("missing").toBool()) return false; + const auto fragment = url.fragment(); + url = base.resolved(QUrl(item.value("href").toString(), QUrl::StrictMode)); + url.setFragment(fragment); + found = true; break; + } + if (!found) return false; + } + } else if (raw.contains("spineIdref")) return false; + } + const QString path = pathFromDocumentUrl(url, session->token); + if (path.isEmpty() || (!session->entries.isEmpty() && !session->entries.contains(path)) || + url.fragment().size() > 1024) return false; + if (!fromRenderer && raw.contains("resourcePath") && session->format != "epub" && + raw.value("resourcePath").toString() != path) return false; + QUrl relative; + relative.setPath(path); + relative.setQuery(url.query(QUrl::FullyEncoded), QUrl::StrictMode); + relative.setFragment(url.fragment()); + clean["href"] = relative.toString(QUrl::FullyEncoded); + clean["resourcePath"] = path; + if (session->format == "epub") { + const auto package = session->meta.value("packagePath"); + if (package.metaType().id() != QMetaType::QString || !safeResourcePath(package.toString()) || + !session->entries.contains(package.toString())) return false; + clean["packagePath"] = package; + clean["spineIdref"] = QString{}; // EPUB links may target non-spine content. + for (const auto &value : session->meta.value("spine").toList()) { + const auto item = value.toMap(); + if (pathFromDocumentUrl(base.resolved(QUrl(item.value("href").toString())), session->token) == path) { + const auto id = item.value("idref"); + if (id.metaType().id() != QMetaType::QString || id.toString().isEmpty() || + id.toString().size() > 1024 || id.toString().contains(QChar(0))) return false; + clean["spineIdref"] = id; + break; + } + } + } + *out = clean; + return true; +} +void Controller::restoreLocation(const QVariantMap &l) { + if (format() == "pdf" && canvas_) { + canvas_->goToLocation(l); + } else { + QVariantMap clean; + if (!logicalWebLocation(active_, l, false, &clean)) { + notify(tr("E_LOCATOR_UNRESOLVED: 保存された読書位置をこの文書で解決できません")); + return; + } + navigateWeb(clean.value("href").toString(), {}, true); + emit webCommand("location.restore", clean); + } +} +void Controller::savePosition() { + if (!config_.value("privacy.remember_position").toBool() && config_.value("privacy.recent_documents").toInt() == 0) + return; + if (active_ && store_ && !store_->readOnly() && !active_->stateCleared && !active_->stateIdentityRejected) { + const auto loc = active_->lastPresented; + if (!loc.isEmpty()) { + if (!StateStore::sameFileIdentity(active_->sourceIdentity, StateStore::fileIdentity(active_->source))) { + active_->stateIdentityRejected = true; + notify(tr("E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。")); + return; + } + store_->rememberOpened(active_->source, active_->sourceIdentity, loc, + qBound(25., loc.value("zoom", 100).toDouble(), format() == "pdf" ? 800. : 500.)); + } + } +} +QVariantList Controller::recentDocuments() const { + QVariantList result; + if (!store_) + return result; + for (const auto &value : store_->recentDocuments()) { + const auto entry = value.toMap(); + const auto path = entry.value("identity").toMap().value("canonicalPath").toString(); + if (!QDir::isAbsolutePath(path) || path.contains(QChar::Null)) + continue; + result.append(QVariantMap{{"documentId", entry.value("documentId")}, + {"title", QFileInfo(path).fileName()}, {"path", path}}); + } + return result; +} +void Controller::openRecent(const QString &documentId) { + if (!store_ || documentId.isEmpty()) + return; + for (const auto &value : store_->recentDocuments()) { + const auto entry = value.toMap(); + if (entry.value("documentId").toString() != documentId) + continue; + const auto saved = entry.value("identity").toMap(); + const auto path = saved.value("canonicalPath").toString(); + if (!QDir::isAbsolutePath(path) || path.contains(QChar::Null)) + break; + const auto current = StateStore::fileIdentity(path); + if (current.isEmpty()) { + notify(tr("E_OPEN_FAILED: 最近使った文書が見つからないか、読み取れません。Ctrl+O で場所を確認してください。\n%1").arg(path)); + return; + } + if (saved.value("fileIdentity").toString().isEmpty() || + saved.value("fileIdentity") != current.value("fileIdentity") || + saved.value("canonicalPath") != current.value("canonicalPath") || + saved.value("size").toLongLong() != current.value("size").toLongLong() || + saved.value("mtime").toLongLong() != current.value("mtime").toLongLong()) { + notify(tr("E_OPEN_FAILED: 文書が変更または置換されているため、以前の位置を復元できません。現在のファイルを開くには Ctrl+O で選択してください。\n%1").arg(path)); + return; + } + openPath(path); + return; + } + notify(tr("E_OPEN_FAILED: この文書は最近使った文書の一覧にありません。Ctrl+O で選択してください。")); +} +void Controller::clearHistory() { + if (store_ && store_->clearHistory()) { + if (active_) + active_->stateCleared = true; + notify(tr("履歴と保存位置を消去しました")); + } +} +int Controller::historyCount() const { + return store_ ? store_->count() : 0; +} +void Controller::activateItem(const QVariantMap &i) { + if (!active_ || active_->resourcesStopped) { + notify(tr("文書処理は停止しています。文書を開き直してください")); + return; + } + if (i.value("missing").toBool()) { + notify(tr("E_EPUB_SPINE_MISSING: この章がありません")); + return; + } + if (format() == "pdf" && canvas_) { + const int p = i.value("page", -1).toInt(); + if (p < 0 || p >= pages_.size()) { + notify(tr("目次の移動先が無効です")); + return; + } + rememberJump(); + canvas_->goToLocation(i); + if (i.value("precision").toString() == "page") + notify(tr("ページ先頭へ移動しました")); + } else { + const QString href = i.value("href").toString(); + if (href.isEmpty()) { + rememberJump(); + emit webCommand("heading.index", {{"index", i.value("index")}}); + } else { + rememberJump(); + navigateWeb(href); + } + } + setContext("content"); + emit uiCommand("focus.content", {}); +} +void Controller::navigateWeb(const QString &href, const QString &boundary, bool preserveLocation) { + if (!active_ || active_->resourcesStopped || !active_->handler) + return; + QUrl base; + base.setScheme("doc"); + base.setHost(active_->token); + base.setPath("/"); + const QUrl u = base.resolved(QUrl(href)); + const QString p = pathFromDocumentUrl(u, active_->token); + if (p.isEmpty() || (!active_->entries.isEmpty() && !active_->entries.contains(p))) { + notify(tr("E_RESOURCE_BLOCKED: 移動先は文書内ではありません")); + return; + } + active_->handler->authorizeTopLevel(u); + emit webCommand("navigate", {{"url", u}, {"boundary", boundary}, {"preserveLocation", preserveLocation}}); +} +bool Controller::navigationAllowed(const QUrl &u, int type, const QString &token) { + Session *s = active_ && active_->token == token ? active_ + : staging_ && staging_->token == token ? staging_ + : nullptr; + if (!s) + return false; + if (u.scheme() == "http" || u.scheme() == "https") { + if (type == 0) + emit externalLinkRequested(u); + return false; + } + const QString p = pathFromDocumentUrl(u, s->token); + if (p.isEmpty() || (!s->entries.isEmpty() && !s->entries.contains(p))) + return false; + if (type != 0 && type != 5 && type != 1 && type != 3) + return false; // reject form submissions and redirects + if (type == 5 && p != s->entry && s == staging_) + return false; + if (type == 0 && s == active_) + rememberJump(); + s->handler->authorizeTopLevel(u); + return true; +} +void Controller::webIndex(const QString &t, const QVariantMap &d) { + if (!active_ || active_->token != t || active_->resourcesStopped) + return; + qint64 budget = 0; + QVariantList cleanHeadings, cleanOutline; + if (!sanitizeNavigation(d.value("headings").toList(), &cleanHeadings, &budget) || + !sanitizeNavigation(d.value("outline").toList(), &cleanOutline, &budget)) { + notify(tr("E_RESOURCE_BLOCKED: 文書の索引が上限を超えています")); + return; + } + headings_ = cleanHeadings; + auto capabilities = active_->meta.value("capabilities").toMap(); + capabilities["headings"] = + headings_.isEmpty() ? (format() == "epub" ? "loading" : "unavailable") : "supported"; + capabilities["outline"] = + cleanOutline.isEmpty() && headings_.isEmpty() && outline_.isEmpty() ? "unavailable" : "supported"; + active_->meta["capabilities"] = capabilities; + if (!restorePending_.isEmpty()) { + const auto loc = restorePending_; + restorePending_.clear(); + restoreLocation(loc); + } + if (format() != "epub") { + outline_ = cleanOutline; + if (outline_.isEmpty()) + outline_ = headings_; + publishNavigation(); + } + if (d.value("scriptCount").toInt() > 0) + notify(tr("文書のスクリプトは無効です。静的な内容を表示しています")); +} +void Controller::webLocation(const QString &t, const QVariantMap &l) { + if (!active_ || active_->token != t || active_->resourcesStopped) + return; + QVariantMap clean; + if (!logicalWebLocation(active_, l, true, &clean)) { + notify(tr("E_RESOURCE_BLOCKED: 読書位置の応答が不正です")); + return; + } + webLocation_ = clean; + // Transient display metadata is never persisted as a logical locator. + webCurrentOutline_ = -1; + const auto current = l.value("currentOutline"); + bool currentOk = false; + const auto currentIndex = current.toLongLong(¤tOk); + if (l.value("navigationRevision").metaType().id() == QMetaType::QString && + l.value("navigationRevision").toString() == QString::number(outlineRevision_) && + current.metaType().id() != QMetaType::QString && current.metaType().id() != QMetaType::Bool && + currentOk && current.toDouble() == double(currentIndex) && currentIndex >= 0 && currentIndex < outline_.size()) { + QUrl url; url.setScheme("doc"); url.setHost(t); url.setPath('/' + clean.value("resourcePath").toString()); + webNavigationTargets(t, url); + if (cachedNavigationRows_.contains(int(currentIndex))) webCurrentOutline_ = int(currentIndex); + } + const QString p = clean.value("resourcePath").toString(); + const auto spine = active_->meta.value("spine").toList(); + for (int i = 0; i < spine.size(); ++i) + if (QUrl(spine[i].toMap().value("href").toString()).path() == p) { + chapter_ = i; + break; + } + emit positionChanged(); + if (window_) + window_->update(); +} +void Controller::indexPdfHeadings() { + if (!active_ || format() != "pdf" || !active_->worker || !active_->worker->idle()) + return; + const int outlineCursor = active_->meta.value("nextOutlineCursor", -1).toInt(); + if (outlineCursor >= 0) { + QPointer session = active_; + session->worker->request( + "outline", {{"cursor", outlineCursor}, {"limit", 128}}, + [this, session, outlineCursor](const QCborMap &reply) { + if (!session || session != active_) + return; + if (reply.contains(QStringLiteral("error")) && failPdfWorker(session, reply.value("error").toMap())) return; + const auto result = reply.value("result").toMap(); + const int count = session->meta.value("outlineCount").toInt(); + const auto rows = result.value("outline").toArray().toVariantList(); + const qint64 next = result.value("nextOutlineCursor").toInteger(-2); + QVariantList clean; + if (reply.contains(QStringLiteral("error")) || rows.isEmpty() || rows.size() > 128 || + outlineCursor + rows.size() > count || + result.value("outlineCount").toInteger(-1) != count || + next != (outlineCursor + rows.size() < count ? outlineCursor + rows.size() : -1)) { + fail(session, "E_WORKER_CRASH", tr("PDF目次の応答が不正です")); + return; + } + QString error; + if (!sanitizeNavigation(rows, &clean, &session->navigationBytes, &error)) { + if (error == "E_NAVIGATION_LIMIT") { + session->meta["nextOutlineCursor"] = -1; + indexPage_ = session->meta.value("pageCount").toInt(); + const QString warning = + tr("E_PDF_INDEX_PARTIAL: " + "目次・見出しの容量上限に達したため、索引を一部省略しました"); + recordWarnings(session, {warning}); + notify(warning); + } else + fail(session, "E_WORKER_CRASH", tr("PDF目次の応答が不正です")); + return; + } + outline_.append(clean); + session->meta["outline"] = outline_; + session->meta["nextOutlineCursor"] = next; + recordWarnings(session, result.value("warnings").toVariant().toStringList()); + publishNavigation(); + }); + return; + } + if (active_->meta.value("isTagged").isValid() && !active_->meta.value("isTagged").toBool()) + indexPage_ = active_->meta.value("pageCount").toInt(); + if (indexPage_ >= active_->meta.value("pageCount").toInt()) { + indexTimer_.stop(); + headingsComplete_ = true; + auto capabilities = active_->meta.value("capabilities").toMap(); + const bool absent = pdfHeadingCandidates().isEmpty(); + const bool limited = !active_->meta.value("headingLimitation").toString().isEmpty(); + capabilities["headings"] = absent ? (limited ? "unsupported" : "unavailable") : "supported"; + active_->meta["capabilities"] = capabilities; + if (limited) { + auto reasons = active_->meta.value("capabilityReasons").toMap(); + reasons["headings"] = "E_PDF_STRUCTURE_LIMITED"; + active_->meta["capabilityReasons"] = reasons; + } + if (headingDirection_) { + int direction = headingDirection_; + headingDirection_ = 0; + pdfHeading(direction); + } + return; + } + QPointer w = active_; + const int page = indexPage_++; + w->worker->request("headings", {{"page", page}}, [this, w](const QCborMap &m) { + if (!w || w != active_) + return; + if (m.contains(QStringLiteral("error"))) { + if (failPdfWorker(w, m.value("error").toMap())) return; + const QString warning = tr("E_PDF_INDEX_PARTIAL: 一部のページで見出しを読み込めませんでした"); + auto warnings = w->meta.value("warnings").toStringList(); + if (!warnings.contains(warning)) { + warnings << warning; + w->meta["warnings"] = warnings; + notify(warning); + } + return; + } + const auto result = m.value("result").toMap(); + const bool limited = result.value("structureLimited").toBool(); + const auto limitation = result.value("unavailableReason"); + if (!result.value("headings").isArray() || + (result.contains(QStringLiteral("structureLimited")) && !result.value("structureLimited").isBool()) || + (limited && (!limitation.isString() || limitation.toString().isEmpty() || limitation.toString().size() > 4096))) { + fail(w, "E_WORKER_CRASH", tr("見出しの応答が不正です")); + return; + } + if (limited) { + w->meta["headingLimitation"] = limitation.toString(); + const QString warning = "E_PDF_STRUCTURE_LIMITED: " + limitation.toString(); + recordWarnings(w, {warning}); + notify(warning); + } + QVariantList items; + const auto raw = result.value("headings").toArray().toVariantList(); + QString error; + if (!sanitizeNavigation(raw, &items, &w->navigationBytes, &error)) { + if (error == "E_NAVIGATION_LIMIT") { + indexPage_ = w->meta.value("pageCount").toInt(); + const QString warning = + tr("E_PDF_INDEX_PARTIAL: 見出しの容量上限に達したため、索引を一部省略しました"); + recordWarnings(w, {warning}); + notify(warning); + } else + fail(w, "E_WORKER_CRASH", tr("見出しの応答が不正です")); + return; + } + recordWarnings(w, m.value("result").toMap().value("warnings").toVariant().toStringList()); + for (const auto &v : items) { + auto row = v.toMap(); + if (row.value("page", -1).toInt() < 0 || row.value("page").toInt() >= pages_.size() || + row.value("title").toString().size() > 4096 || headings_.size() >= 20000) + continue; + const QString key = + row.value("id").toString().isEmpty() + ? QString::number(row.value("page").toInt()) + ":" + row.value("title").toString() + ":" + + row.value("source").toString() + ":" + QString::number(row.value("level").toInt()) + : row.value("id").toString(); + if (!w->headingKeys.contains(key)) { + w->headingKeys.insert(key); + headings_ << row; + } + } + }); +} +QVariantList Controller::pdfHeadingCandidates() const { + QVariantList result; + const bool fallback = headings_.isEmpty(); + for (const auto &item : fallback ? outline_ : headings_) { + const auto row = item.toMap(); + const int page = row.value("page", -1).toInt(); + if (page >= 0 && page < pages_.size() && (!fallback || row.value("kind").toString() == "internal")) + result.append(row); + } + return result; +} +void Controller::pdfHeading(int direction) { + if (!active_ || !canvas_) + return; + if (!headingsComplete_) { + headingDirection_ = direction; + notify(tr("見出し索引を読み込んでいます。Escで待機を取り消せます")); + return; + } + QVariantList candidates = pdfHeadingCandidates(); + if (candidates.isEmpty()) { + const QString limitation = active_->meta.value("headingLimitation").toString(); + notify(limitation.isEmpty() ? tr("この文書には見出し情報がありません") + : "E_PDF_STRUCTURE_LIMITED: " + limitation); + return; + } + std::stable_sort(candidates.begin(), candidates.end(), [this](const QVariant &a, const QVariant &b) { + auto x = a.toMap(), y = b.toMap(); + int px = x.value("page").toInt(), py = y.value("page").toInt(); + if (px != py) return px < py; + const double ax = canvas_->navigationY(px, x), by = canvas_->navigationY(py, y); + return std::isfinite(ax) && std::isfinite(by) && ax < by; + }); + if (direction < 0) + std::reverse(candidates.begin(), candidates.end()); + const auto loc = canvas_->location(); + const int page = loc.value("pageIndex").toInt(); + for (const auto &v : candidates) { + auto row = v.toMap(); + const int p = row.value("page", -1).toInt(); + // Compare the actual displayed position to the reading anchor. A + // page-only target is its top; raw PDF y cannot express reading order + // on a rotated page and a sentinel y would reselect a page start. + const double targetY = canvas_->navigationY(p, row); + const bool after = p > page || (p == page && std::isfinite(targetY) && targetY > 17); + const bool before = p < page || (p == page && std::isfinite(targetY) && targetY < 15); + if (p >= 0 && ((direction > 0 && after) || (direction < 0 && before))) { + activateItem(row); + if (headings_.isEmpty()) + notify(tr("目次項目へ移動しました")); + return; + } + } + notify(tr("見出しの端です")); +} +void Controller::openExternal(const QUrl &u) { + if (u.isValid() && (u.scheme() == "https" || u.scheme() == "http")) + QDesktopServices::openUrl(u); +} +void Controller::setHtmlRoot(const QUrl &u) { + if (memoryPressureLevel_ == MemoryPressureLevel::Stop) { + notify(tr("E_RESOURCE_LIMIT: メモリの空きが回復してから文書を開いてください")); + return; + } + if (!active_ || format() != "html" || !u.isLocalFile()) + return; + const QString root = QFileInfo(u.toLocalFile()).canonicalFilePath(); + const QString relative = QDir(root).relativeFilePath(active_->source); + if (!safeResourcePath(relative)) { + notify(tr("選択フォルダーに現在のHTMLが含まれていません")); + return; + } + cancel(); + auto *s = new Session(this); + s->token = QUuid::createUuid().toString(QUuid::Id128); + s->generation = ++generation_; + s->source = active_->source; + s->sourceIdentity = StateStore::fileIdentity(s->source); + s->format = "html"; + s->root = root; + s->entry = relative; + staging_ = s; + state_ = "Opening"; + emit stateChanged(); + prepareWeb(s); +} +void Controller::search(const QString &q) { + if (!active_ || active_->resourcesStopped) { + notify(tr("文書処理は停止しています。文書を開き直してください")); + return; + } + if (q.isEmpty() || q.size() > 1024) + return; + query_ = q; + if (format() != "pdf") { + emit webCommand("search", {{"query", q}}); + return; + } + findPdf(1, true); +} +void Controller::findPdf(int direction, bool reset) { + if (!active_ || !canvas_ || query_.isEmpty()) + return; + ++searchRevision_; + active_->worker->discardQueued("search"); + searchHasText_ = false; + if (reset) { + searchPage_ = -1; + searchStart_ = -1; + } + int page = searchPage_ >= 0 ? searchPage_ : canvas_->currentPage(); + int before = direction < 0 && searchStart_ >= 0 ? searchStart_ : INT_MAX; + int cursor = direction > 0 && searchStart_ >= 0 ? searchStart_ + 1 : 0; + searchPdfPage(page, cursor, before, direction, active_->meta.value("pageCount").toInt() + 1, + searchRevision_); +} +void Controller::searchPdfPage(int page, int cursor, int before, int direction, int remaining, qint64 rev, + QVariantMap candidate) { + if (!active_ || format() != "pdf" || rev != searchRevision_) + return; + if (remaining <= 0) { + if (!searchHasText_) { + auto capabilities = active_->meta.value("capabilities").toMap(); + capabilities["textSearch"] = "unavailable"; + active_->meta["capabilities"] = capabilities; + } + notify(searchHasText_ ? tr("検索結果がありません") : tr("検索できる文字情報がありません")); + return; + } + const int count = active_->meta.value("pageCount").toInt(); + page = (page + count) % count; + QPointer w = active_; + w->worker->request( + "search", {{"query", query_}, {"page", page}, {"start", cursor}, {"limit", 100}}, + [this, w, page, cursor, before, direction, remaining, rev, candidate](const QCborMap &m) mutable { + if (!w || w != active_) + return; + if (m.contains(QStringLiteral("error")) && failPdfWorker(w, m.value("error").toMap())) return; + if (rev != searchRevision_) return; + if (m.contains(QStringLiteral("error"))) { + notify(m.value("error").toMap().value("message").toString()); + return; + } + const auto r = m.value("result").toMap(); + recordWarnings(w, r.value("warnings").toVariant().toStringList()); + searchHasText_ = searchHasText_ || r.value("hasText").toBool(); + if (searchHasText_) { + auto capabilities = w->meta.value("capabilities").toMap(); + capabilities["textSearch"] = "supported"; + w->meta["capabilities"] = capabilities; + } + const auto matches = r.value("matches").toArray(); + for (const auto &v : matches) { + auto match = v.toMap().toVariantMap(); + if (match.value("page").toInt() != page) + continue; + const int first = match.value("start", -1).toInt(); + if (first < 0) + continue; + if (direction > 0) { + candidate = match; + break; + } + if (first < before) + candidate = match; + } + const int next = int(r.value("nextCursor").toInteger(-1)); + if (direction < 0 && next > cursor && next < before) { + searchPdfPage(page, next, before, direction, remaining, rev, candidate); + return; + } + if (!candidate.isEmpty()) { + searchPage_ = page; + searchStart_ = candidate.value("start").toInt(); + rememberJump(); + canvas_->showSearchResult(candidate); + notify(tr("一致する箇所へ移動しました")); + return; + } + searchPdfPage(page + direction, 0, INT_MAX, direction, remaining - 1, rev); + }); +} +void Controller::renderStopped() { + notify(tr("E_WORKER_CRASH: 本文の表示処理が停止しました。文書を開き直してください")); + state_ = "Recovering"; + emit stateChanged(); +} +QString Controller::script(const QString &name) const { + if (name != "reader") + return {}; + QFile f(":/scripts/reader.js"); + return f.open(QIODevice::ReadOnly) ? QString::fromUtf8(f.readAll()) : QString{}; +} +void Controller::archiveMetadata(Session *s) { + const auto counts = s->meta.value("metadataCounts").toMap(); + for (const QString &kind : + QStringList{"spine", "outline", "pageList", "landmarks", "candidates", "renditions"}) { + const int total = counts.value(kind).toInt(); + auto values = s->meta.value(kind).toList(); + if (total < 0 || total > 20000) { + fail(s, "E_WORKER_CRASH", tr("メタデータの件数が不正です")); + return; + } + if (values.size() < total) { + QPointer w = s; + const int offset = values.size(); + s->worker->request( + "metadata", {{"kind", kind}, {"offset", offset}, {"count", 128}}, + [this, w, kind, offset, total](const QCborMap &m) { + if (!w || w != staging_) + return; + const auto r = m.value("result").toMap(); + const auto items = r.value("items").toArray().toVariantList(); + if (m.contains(QStringLiteral("error")) || r.value("kind").toString() != kind || + r.value("total").toInteger() != total || items.isEmpty() || items.size() > 128 || + offset + items.size() > total) { + fail(w, "E_WORKER_CRASH", tr("メタデータの応答が不正です")); + return; + } + auto values = w->meta.value(kind).toList(); + QVariantList clean; + if (kind == "candidates" || kind == "renditions") { + for (const auto &item : items) { + if (item.metaType().id() != QMetaType::QString || item.toString().size() > 1024) { + fail(w, "E_WORKER_CRASH", tr("メタデータの文字列が不正です")); + return; + } + w->navigationBytes += item.toString().toUtf8().size(); + if (w->navigationBytes > MaxNavigationBytes) { + fail(w, "E_WORKER_CRASH", tr("メタデータの上限を超えました")); + return; + } + clean << item; + } + } else if (!sanitizeNavigation(items, &clean, &w->navigationBytes)) { + fail(w, "E_WORKER_CRASH", tr("ナビゲーションの応答が不正です")); + return; + } + values += clean; + w->meta[kind] = values; + archiveMetadata(w); + }); + return; + } + } + if (s->entry.isEmpty()) { + QStringList candidates; + for (const auto &v : s->meta.value("candidates").toList()) { + const auto candidate = v.toString(); + if (v.metaType().id() != QMetaType::QString || !safeResourcePath(candidate) || + !s->entries.contains(candidate) || + !QStringList{"html", "htm", "xhtml"}.contains(QFileInfo(candidate).suffix().toLower())) { + fail(s, "E_WORKER_CRASH", tr("入口候補の応答が不正です")); + return; + } + candidates << candidate; + } + if (s->format == "htmlzip" && store_ && + s->sourceIdentity == StateStore::fileIdentity(s->source)) { + const auto saved = store_->archiveEntry(s->source, candidates); + if (!saved.isEmpty()) { + s->entry = saved; + prepareWeb(s); + return; + } + } + emit entryNeeded(candidates); + } else + prepareWeb(s); +} +QVariantMap Controller::webOptions(const QString &token) const { + const Session *s = active_ && active_->token == token ? active_ + : staging_ && staging_->token == token ? staging_ + : nullptr; + if (!s) + return {}; + return {{"fixed", s->meta.value("fixed")}, + {"spread", s->meta.value("spread", "auto")}, + {"rtl", s->meta.value("rtl")}, + {"reading", config_.snapshot().value("reading")}, + {"spine", s->meta.value("spine")}, + {"format", s->format}}; +} + +void Controller::webDocumentLoaded(const QString &token, const QUrl &url) { + // A trusted WebPane load callback acknowledges the displayed chapter before + // the asynchronous DOM locator arrives. It never persists a guessed location. + if (!active_ || active_->token != token || active_->format != "epub" || active_->resourcesStopped) return; + const auto path = pathFromDocumentUrl(url, token); + if (path.isEmpty() || !active_->entries.contains(path)) return; + const auto spine = active_->meta.value("spine").toList(); + for (qsizetype i = 0; i < spine.size(); ++i) { + const auto row = spine[i].toMap(); + if (!row.value("missing").toBool() && row.value("href").toString().section('#', 0, 0) == path) { + chapter_ = int(i); + emit positionChanged(); + return; + } + } +} + +void Controller::webStarted(const QString &token, const QString &command, quint64 request) { + if (active_ && active_->token == token && request > 0 && request < (quint64(1) << 53) && command.size() <= 128) + emit webActionStarted(command, request); +} +void Controller::webApplied(const QString &token, const QString &command, quint64 request) { + if (active_ && active_->token == token) { + emit webActionApplied(); + if (request > 0 && request < (quint64(1) << 53) && command.size() <= 128) + emit webActionAcknowledged(command, request); + } +} +void Controller::registerRenderer(const QString &token, qint64 pid, int slot) { + if (pid < 0 || slot < 0 || slot > 1) + return; + Session *s = active_ && active_->token == token ? active_ + : staging_ && staging_->token == token ? staging_ + : nullptr; + if (!s) + return; + QString error; + if (!rendererWatchdog_.registerRenderer(token, pid, &error, slot)) { + if (s == active_) { + if (s->handler) + s->handler->revoke(); + emit disposeWeb(token); + } + fail(s, "E_SANDBOX_UNAVAILABLE", tr("本文プロセスの安全監視を開始できません")); + } +} + +quint32 Controller::beginRendererProbe(const QString &token, int slot) { + if ((!active_ || active_->token != token) && (!staging_ || staging_->token != token)) return 0; + return rendererWatchdog_.beginProbe(token, slot); +} +void Controller::finishRendererProbe(const QString &token, int slot, quint32 probe) { + rendererWatchdog_.acknowledgeProbe(token, slot, probe); +} + +} // namespace docview diff --git a/src/app/controller.h b/src/app/controller.h new file mode 100644 index 0000000..d37c47f --- /dev/null +++ b/src/app/controller.h @@ -0,0 +1,227 @@ +#pragma once +#include "common/worker_process.h" +#include "common/memory_pressure.h" +#include "core/config.h" +#include "core/input.h" +#include "core/state.h" +#include "pdf_canvas.h" +#include "web/renderer_watchdog.h" +#include "web/web_profile.h" +#include +#include +#include +#include +#include +#include +namespace docview { +class FontBroker; +class Session : public QObject { + public: + using QObject::QObject; + QString token, source, format, root, entry; + qint64 generation = 0, navigationBytes = 0; + int initialPage = 0; + QVariantMap meta, lastPresented, sourceIdentity; + QHash resourceIssues; + QSet entries, extracted, headingKeys; + QHash> extractWaiters; + qint64 expandedBytes = 0; + bool stateCleared = false; + bool stateIdentityRejected = false; + bool entrySelected = false; + bool resourcesStopped = false; + QHash mimeTypes; + WorkerProcess *worker = nullptr; + FontBroker *fontBroker = nullptr; + QQuickWebEngineProfile *profile = nullptr; + ResourceHandler *handler = nullptr; + std::unique_ptr temporary; + std::unique_ptr sessionLock; +}; +class Controller : public QObject { + Q_OBJECT + Q_PROPERTY(QString title READ title NOTIFY documentChanged) + Q_PROPERTY(QString format READ format NOTIFY documentChanged) + Q_PROPERTY(QString state READ state NOTIFY stateChanged) + Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged) + Q_PROPERTY(QString context READ context WRITE setContext NOTIFY contextChanged) + Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY modeChanged) + Q_PROPERTY(QString pending READ pending NOTIFY pendingChanged) + Q_PROPERTY(QVariantMap settings READ settings NOTIFY settingsChanged) + Q_PROPERTY(QVariantList outline READ outline NOTIFY navigationChanged) + Q_PROPERTY(QVariantList pages READ pages NOTIFY navigationChanged) + Q_PROPERTY(QString listTitle READ listTitle NOTIFY navigationChanged) + Q_PROPERTY(QString outlineTitle READ outlineTitle NOTIFY navigationChanged) + Q_PROPERTY(bool outlineLoading READ outlineLoading NOTIFY navigationChanged) + Q_PROPERTY(int currentOutline READ currentOutline NOTIFY currentOutlineChanged) + Q_PROPERTY(QString position READ position NOTIFY positionChanged) + Q_PROPERTY(QString webToken READ webToken NOTIFY documentChanged) + Q_PROPERTY(QVariantList recentDocuments READ recentDocuments NOTIFY recentDocumentsChanged) + public: + Controller(QObject *parent = nullptr); + ~Controller() override; + void initialize(const QString &config, bool readOnly = false, + const StoragePaths &paths = StoragePaths::forCurrentUser()); + void attachWindow(QQuickWindow *w); + Q_INVOKABLE void attachCanvas(PdfCanvas *c); + QString title() const; + QString format() const; + QString state() const { + return state_; + } + QString notice() const { + return notice_; + } + QString context() const { + return context_; + } + QString mode() const { + return mode_; + } + QString pending() const { + return input_.pending(); + } + QVariantMap settings() const { + return config_.snapshot(); + } + QVariantList outline() const { + return outline_; + } + QVariantList pages() const { + return pages_; + } + QString listTitle() const; + QString outlineTitle() const; + bool outlineLoading() const; + int currentOutline() const { return currentOutline_; } + QString position() const; + QString webToken() const { + return active_ ? active_->token : QString{}; + } + void setContext(const QString &); + void setMode(const QString &); + Q_INVOKABLE void open(const QUrl &url); + Q_INVOKABLE void openPath(const QString &path, const QString &password = {}, int initialPage = 0); + Q_INVOKABLE void cancel(); + // Internal lifecycle and measurement API; not a document command or QML slot. + void closeDocument(); + QVariantMap benchmarkSnapshot() const; + Q_INVOKABLE void execute(const QString &id, const QVariantMap &args = {}); + Q_INVOKABLE bool command(const QString &text); + Q_INVOKABLE void password(const QString &text); + Q_INVOKABLE void chooseEntry(const QString &path); + Q_INVOKABLE bool navigationAllowed(const QUrl &url, int navigationType, const QString &token); + Q_INVOKABLE void webLoaded(const QString &token, bool ok); + Q_INVOKABLE void webIndex(const QString &token, const QVariantMap &data); + Q_INVOKABLE void webResourceIssues(const QString &token, const QVariantMap &counts); + Q_INVOKABLE void webLocation(const QString &token, const QVariantMap &location); + Q_INVOKABLE void activateItem(const QVariantMap &item); + Q_INVOKABLE void openExternal(const QUrl &url); + Q_INVOKABLE void setHtmlRoot(const QUrl &url); + Q_INVOKABLE void notify(const QString &message); + Q_INVOKABLE void search(const QString &text); + Q_INVOKABLE void renderStopped(); + Q_INVOKABLE void webStarted(const QString &token, const QString &command, quint64 request); + Q_INVOKABLE void webApplied(const QString &token, const QString &command = {}, quint64 request = 0); + Q_INVOKABLE void webDocumentLoaded(const QString &token, const QUrl &url); + Q_INVOKABLE void registerRenderer(const QString &token, qint64 pid, int slot = 0); + Q_INVOKABLE quint32 beginRendererProbe(const QString &token, int slot); + Q_INVOKABLE void finishRendererProbe(const QString &token, int slot, quint32 probe); + Q_INVOKABLE void clearHistory(); + Q_INVOKABLE int historyCount() const; + QVariantList recentDocuments() const; + Q_INVOKABLE void openRecent(const QString &documentId); + Q_INVOKABLE QVariantMap webOptions(const QString &token) const; + Q_INVOKABLE QVariantMap webNavigationTargets(const QString &token, const QUrl &url) const; + Q_INVOKABLE QString script(const QString &name) const; + signals: + void documentChanged(); + void stateChanged(); + void noticeChanged(); + void contextChanged(); + void modeChanged(); + void pendingChanged(); + void settingsChanged(); + void navigationChanged(); + void currentOutlineChanged(); + void positionChanged(); + void recentDocumentsChanged(); + void uiCommand(QString id, QVariantMap args); + void webCommand(QString id, QVariantMap args); + void stageWeb(QQuickWebEngineProfile *profile, QUrl url, QString token); + void activateWeb(QString token); + void disposeWeb(QString token); + void externalLinkRequested(QUrl url); + void passwordNeeded(QString name); + void entryNeeded(QStringList candidates); + void framePresented(); + void webActionApplied(); + void webActionStarted(QString command, quint64 request); + void webActionAcknowledged(QString command, quint64 request); + + protected: + bool eventFilter(QObject *, QEvent *) override; + + private: + void fail(Session *s, const QString &code, const QString &message); + void recordOpenFailure(const QString &path, const QString &code); + bool failPdfWorker(Session *s, const QCborMap &error); + void commit(Session *s); + void dispose(Session *s); + void stopResources(Session *s); + void recordWarnings(Session *s, const QStringList &warnings); + void recordResourceIssue(Session *s, const QString &key, int count); + void prepareWeb(Session *s); + void archiveEntries(Session *s, int offset); + void archiveMetadata(Session *s); + void loadPdfPages(int page); + void updatePageLabels(const QVariantList &pages); + void publishNavigation(); + void updateCurrentOutline(); + void pdfHeading(int direction); + QVariantList pdfHeadingCandidates() const; + void indexPdfHeadings(); + void findPdf(int direction, bool reset = false); + void searchPdfPage(int page, int cursor, int before, int direction, int remaining, qint64 revision, + QVariantMap candidate = {}); + void navigateWeb(const QString &href, const QString &boundary = {}, bool preserveLocation = false); + void rememberJump(); + void restoreLocation(const QVariantMap &location); + QVariantMap currentLocation() const; + void savePosition(); + void applyMemoryPressure(MemoryPressureLevel level); + ConfigManager config_; + StoragePaths storagePaths_; + InputRouter input_; + RendererWatchdog rendererWatchdog_; + MemoryPressureMonitor memoryPressureMonitor_; + MemoryPressureLevel memoryPressureLevel_ = MemoryPressureLevel::Normal; + Session *active_ = nullptr, *staging_ = nullptr; + QPointer canvas_; + QPointer window_; + QString state_ = "Empty", notice_, context_ = "content", mode_ = "normal", passwordPath_, query_; + QVariantList outline_, pages_, headings_; + int currentOutline_ = -1, webCurrentOutline_ = -1; + qint64 outlineRevision_ = 0; + mutable qint64 cachedNavigationRevision_ = -1; + mutable QString cachedNavigationPath_; + mutable QVariantMap cachedNavigationTargets_; + mutable QSet cachedNavigationRows_; + int chapter_ = 0; + qint64 generation_ = 0; + QVariantMap webLocation_; + QVector back_, forward_; + QTimer noticeTimer_, indexTimer_; + int indexPage_ = 0, headingDirection_ = 0; + bool headingsComplete_ = false; + int searchPage_ = -1, searchStart_ = -1; + qint64 searchRevision_ = 0; + bool searchHasText_ = false; + bool composing_ = false; + int passwordInitialPage_ = 0; + StateStore *store_ = nullptr; + QVariantMap restorePending_; + QString lastOpenFailure_; // The last attempt belongs to the app, never the displayed document. + QSet pageRequests_; +}; +} // namespace docview diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..6cde612 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,149 @@ +#include "benchmark.h" +#include "common/sandbox.h" +#include "common/single_instance.h" +#include "controller.h" +#include "pdf_canvas.h" +#include "translations.h" +#include "web/web_profile.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace docview; +int main(int argc, char **argv) { + registerDocumentScheme(); + QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); + app.setApplicationName("DocView"); + app.setOrganizationName("DocView"); + app.setApplicationVersion("0.1.0"); + ApplicationTranslations translations; + QCommandLineParser parser; + parser.setApplicationDescription(QObject::tr("Vim風の読み取り専用文書リーダー")); + parser.addHelpOption(); + parser.addVersionOption(); + parser.addOption({"config", QObject::tr("設定ファイル"), "path"}); + parser.addOption({"page", QObject::tr("PDFの初期実ページ番号"), "number"}); + parser.addOption({"smoke-output", QObject::tr("試験用: 画面をPNG保存して終了"), "directory"}); + parser.addOption({"benchmark-output", QObject::tr("試験用: フレーム提示とRSSをJSONへ記録"), "path"}); + parser.addOption({"benchmark-runs", QObject::tr("試験用: 開く回数"), "count", "30"}); + parser.addOption({"benchmark-operations", QObject::tr("試験用: 各ページ・見出し・章系列の操作回数"), "count", "100"}); + parser.addOption({"benchmark-close-cycles", QObject::tr("試験用: 最小の明示的な開閉回数"), "count", "10"}); + parser.addOption({"benchmark-scroll-steps", QObject::tr("試験用: 16ms間隔の継続スクロール入力数"), "count", "240"}); + parser.addPositionalArgument("document", QObject::tr("ローカル文書のパス"), "[document]"); + parser.process(app); + if (parser.positionalArguments().size() > 1) + parser.showHelp(2); + int initialPage = 0; + if (parser.isSet("page")) { + bool ok = false; + initialPage = parser.value("page").toInt(&ok); + if (!ok || initialPage < 1 || initialPage > 100000 || parser.positionalArguments().isEmpty()) + return 2; + } + SingleInstance instance; + auto instanceResult = SingleInstance::StartResult::Primary; + QStringList openPaths; + for (const auto &path : parser.positionalArguments()) + openPaths << QFileInfo(path).absoluteFilePath(); + if (!parser.isSet("benchmark-output") && !parser.isSet("smoke-output")) { + instanceResult = instance.start(QFileInfo(StoragePaths::forCurrentUser().stateFile).absolutePath(), + openPaths, 750, initialPage); + if (instanceResult == SingleInstance::StartResult::Forwarded) + return 0; + } + qmlRegisterType("DocView", 1, 0, "PdfCanvas"); + Controller controller; + controller.initialize(parser.value("config"), instanceResult == SingleInstance::StartResult::ReadOnly); + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("reader", &controller); + engine.load(QUrl("qrc:/qml/Main.qml")); + if (engine.rootObjects().isEmpty()) + return 1; + auto *window = qobject_cast(engine.rootObjects().first()); + if (const auto *screen = window->screen()) { + const auto available = screen->availableGeometry(); + if (available.isValid()) { + window->resize(qMax(window->minimumWidth(), qMin(window->width(), available.width())), + qMax(window->minimumHeight(), qMin(window->height(), available.height()))); + window->setPosition(available.topLeft() + QPoint(qMax(0, (available.width() - window->width()) / 2), + qMax(0, (available.height() - window->height()) / 2))); + } + } + controller.attachWindow(window); + QObject::connect(&instance, &SingleInstance::openRequested, &controller, [&](const QStringList &paths) { + if (!paths.isEmpty()) + controller.openPath(paths.first()); + window->show(); + window->raise(); + window->requestActivate(); + }); + QObject::connect(&instance, &SingleInstance::openAtPageRequested, &controller, + [&](const QString &path, int page) { + controller.openPath(path, {}, page); + window->show(); + window->raise(); + window->requestActivate(); + }); + if (instanceResult == SingleInstance::StartResult::ReadOnly) + controller.notify(QObject::tr("状態保存を読み取り専用で開始しました")); + if (parser.isSet("benchmark-output")) { + if (parser.positionalArguments().isEmpty()) + return 2; + bool runsOk = false, opsOk = false, cyclesOk = false, stepsOk = false; + int runs = parser.value("benchmark-runs").toInt(&runsOk), ops = parser.value("benchmark-operations").toInt(&opsOk); + int cycles = parser.value("benchmark-close-cycles").toInt(&cyclesOk), steps = parser.value("benchmark-scroll-steps").toInt(&stepsOk); + if (!runsOk || !opsOk || !cyclesOk || !stepsOk || runs < 1 || runs > 100 || ops < 0 || ops > 1000 || cycles < 0 || cycles > 100 || steps < 0 || steps > 3000) + return 2; + auto canvas = window->findChild(); + auto monitor = new Benchmark(&controller, window, canvas, + QFileInfo(parser.positionalArguments().first()).absoluteFilePath(), + parser.value("benchmark-output"), runs, ops, cycles, steps, &app); + QTimer::singleShot(0, monitor, [monitor] { monitor->start(); }); + } else if (!parser.positionalArguments().isEmpty()) + QTimer::singleShot(0, &controller, [&] { + controller.openPath(QFileInfo(parser.positionalArguments().first()).absoluteFilePath(), {}, + initialPage); + }); + if (parser.isSet("smoke-output")) { + const QString out = parser.value("smoke-output"); + QDir().mkpath(out); + QTimer::singleShot(5000, &app, [&, out] { + const bool imageSaved = window->grabWindow().save(out + "/screen.png"); + const auto *screen = window->screen(); + const QJsonObject runtime{ + {"qtVersion", qVersion()}, {"qtPlatform", QGuiApplication::platformName()}, + {"requestedQtQuickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, + {"sceneGraphBackend", QQuickWindow::sceneGraphBackend()}, + {"windowWidth", window->width()}, {"windowHeight", window->height()}, + {"devicePixelRatio", window->devicePixelRatio()}, + {"screenWidth", screen ? screen->size().width() : 0}, + {"screenHeight", screen ? screen->size().height() : 0}}; + QFile runtimeFile(out + "/runtime.json"); + const auto runtimeBytes = QJsonDocument(runtime).toJson(); + const bool runtimeSaved = runtimeFile.open(QIODevice::WriteOnly) && + runtimeFile.write(runtimeBytes) == runtimeBytes.size(); + QFile f(out + "/state.txt"); + if (f.open(QIODevice::WriteOnly)) + f.write((controller.state() + "\n" + controller.format() + "\n" + controller.notice() + "\n") + .toUtf8()); + const auto *canvas = window->findChild(); + const bool pdfPresented = controller.format() != "pdf" || + (canvas && canvas->width() > 0 && canvas->height() > 0 && + canvas->viewportReady()); + const bool ready = (controller.state() == "Ready" && pdfPresented) || + (controller.state() == "Empty" && parser.positionalArguments().isEmpty()); + app.exit(imageSaved && runtimeSaved && ready ? 0 : 3); + }); + } + return app.exec(); +} diff --git a/src/app/pdf_canvas.cpp b/src/app/pdf_canvas.cpp new file mode 100644 index 0000000..0216a74 --- /dev/null +++ b/src/app/pdf_canvas.cpp @@ -0,0 +1,584 @@ +#include "pdf_canvas.h" +#include +#include +#include +#include +#include +#include +#include +#include +namespace docview { +namespace { +constexpr int TileSize = 512; +bool finitePoint(const QPointF &p) { return std::isfinite(p.x()) && std::isfinite(p.y()); } +QRectF pdfRect(const QVariant &value) { + const auto a = value.toList(); + if (a.size() != 4) return {}; + const QPointF lower(a[0].toDouble(), a[1].toDouble()), upper(a[2].toDouble(), a[3].toDouble()); + if (!finitePoint(lower) || !finitePoint(upper)) return {}; + return QRectF(lower, upper).normalized(); +} +} +PdfCanvas::PdfCanvas(QQuickItem *parent) : QQuickPaintedItem(parent) { + setAcceptedMouseButtons(Qt::LeftButton); setAntialiasing(false); + cache_.setMaxCost(256 * 1024); timer_.setSingleShot(true); + connect(&timer_, &QTimer::timeout, this, &PdfCanvas::requestVisible); + connect(this, &QQuickItem::windowChanged, this, [this](QQuickWindow *w) { + disconnect(windowDprConnection_); + if (w) windowDprConnection_ = connect(w, &QQuickWindow::devicePixelRatioChanged, this, [this] { + if (worker_) worker_->discardQueued(QStringLiteral("render")); + ++revision_; pending_.clear(); schedule(); + }); + }); +} +void PdfCanvas::setCacheBudget(int mib) { cache_.setMaxCost(qBound(64, mib, 512) * 1024); schedule(); } +void PdfCanvas::setBackgroundColor(const QColor &color) { + if (!color.isValid() || color == backgroundColor_) return; + backgroundColor_ = color; update(); emit backgroundColorChanged(); +} +void PdfCanvas::setPrefetchPages(int pages) { + const int next = qBound(0, pages, 3); + if (prefetchPages_ == next) return; + prefetchPages_ = next; + // Only one prefetch request can be active, and no prefetch is queued. + // An in-flight response is checked again before it can consume cache. + schedule(); +} +double PdfCanvas::rasterResolutionScale() const { + if (memoryPressureLevel_ < 3) return 1.; + const double dpr = window() ? window()->devicePixelRatio() : 1.; + // Keep the worker's public scale/DPR bounds. Lowering both together keeps + // NoZoom appearances the same logical size even in the reduced raster. + return qBound(.5, std::max(.05 / (scale_ * dpr), .5 / dpr), 1.); +} +void PdfCanvas::setMemoryPressureLevel(int level) { + const int next = qBound(0, level, 4); + if (memoryPressureLevel_ == next) return; + memoryPressureLevel_ = next; + // Queue cancellation is operation-scoped. Retire this render revision so + // active speculative/old-quality work cannot repopulate trimmed caches; + // the still-visible misses are resubmitted before speculative work. + if (worker_) worker_->discardQueued(QStringLiteral("render")); + ++revision_; pending_.clear(); prefetchMetadata_.clear(); timer_.stop(); + if (next >= 2) trimCache(); + schedule(); +} +void PdfCanvas::trimCache(bool currentResolutionOnly) { + QSet current; + if (currentResolutionOnly) for (const auto &tile : visibleTiles()) current.insert(tile.key); + for (auto it = cachedTiles_.begin(); it != cachedTiles_.end();) { + const auto &tile = it.value(); + bool keep = cache_.contains(it.key()) && tile.rotation == rotation_ && tile.page >= 0 && tile.page < rects_.size(); + if (keep && currentResolutionOnly) keep = current.contains(it.key()); + else if (keep) { + const auto &page = rects_[tile.page]; const double ratio = scale_ / tile.scale; + const QRectF target(page.x() - pan_ + tile.x * ratio, page.y() - offset_ + tile.y * ratio, + tile.w * ratio, tile.h * ratio); + keep = target.intersects(boundingRect()); + } + if (!keep) { cache_.remove(it.key()); it = cachedTiles_.erase(it); } + else ++it; + } +} +void PdfCanvas::setDocument(WorkerProcess *worker, const QVariantList &items, int count) { + workerFailed_ = false; + worker_ = worker; pages_.fill(Page{}, qBound(0, count, 100000)); rects_.clear(); + cache_.clear(); cachedTiles_.clear(); searchResult_.clear(); prefetchMetadata_.clear(); pending_.clear(); links_.clear(); annotations_.clear(); linksPending_.clear(); pendingLocation_.clear(); + ++document_; ++revision_; offset_ = pan_ = 0; currentPage_ = readingPage_ = rotation_ = 0; + selectedLink_ = selectedLinkPage_ = -1; + updatePages(items); relayout(false); +} +void PdfCanvas::updatePages(const QVariantList &items) { + bool changed = false; + for (const auto &item : items) { + const auto m = item.toMap(); const qint64 index = m.value("pageIndex", -1).toLongLong(); + const double width = m.value("width").toDouble(), height = m.value("height").toDouble(); + const int rotation = m.value("rotation").toInt(); const QRectF crop = pdfRect(m.value("cropBox")); + if (index < 0 || index >= pages_.size() || !std::isfinite(width) || !std::isfinite(height) || width <= 0 || height <= 0 || width > 100000 || height > 100000 || rotation < 0 || rotation > 270 || rotation % 90) continue; + Page page; page.width = width; page.height = height; page.label = m.value("label").toString().left(4096); page.known = true; page.rotation = rotation; + page.crop = crop.isEmpty() ? QRectF(0, 0, rotation % 180 ? height : width, rotation % 180 ? width : height) : crop; + pages_[int(index)] = page; prefetchMetadata_.remove(int(index)); changed = true; + } + if (!changed) return; + relayout(); + if (!pendingLocation_.isEmpty()) { + const int page = pendingLocation_.value("pageIndex", pendingLocation_.value("page", -1)).toInt(); + if (page >= 0 && page < pages_.size() && pages_[page].known) { const auto location = pendingLocation_; pendingLocation_.clear(); applyLocation(location); } + } +} +void PdfCanvas::geometryChange(const QRectF &now, const QRectF &before) { + QQuickPaintedItem::geometryChange(now, before); if (now.size() != before.size()) relayout(); +} +QPointF PdfCanvas::pageToCanvas(int index, const QPointF &point) const { + if (index < 0 || index >= pages_.size() || index >= rects_.size()) return {}; + const auto &page = pages_[index]; const auto &rect = rects_[index]; + double x = (point.x() - page.crop.left()) / page.crop.width(); + double y = (point.y() - page.crop.top()) / page.crop.height(); + QPointF normalized; + switch ((page.rotation + rotation_) % 360) { + case 90: normalized = {y, x}; break; + case 180: normalized = {1 - x, y}; break; + case 270: normalized = {1 - y, 1 - x}; break; + default: normalized = {x, 1 - y}; break; + } + return rect.topLeft() + QPointF(normalized.x() * rect.width() - pan_, normalized.y() * rect.height() - offset_); +} +QPointF PdfCanvas::canvasToPage(int index, const QPointF &point) const { + if (index < 0 || index >= pages_.size() || index >= rects_.size()) return {}; + const auto &page = pages_[index]; const auto &rect = rects_[index]; + double x = (point.x() + pan_ - rect.left()) / rect.width(); + double y = (point.y() + offset_ - rect.top()) / rect.height(); + QPointF normalized; + switch ((page.rotation + rotation_) % 360) { + case 90: normalized = {y, x}; break; + case 180: normalized = {1 - x, y}; break; + case 270: normalized = {1 - y, 1 - x}; break; + default: normalized = {x, 1 - y}; break; + } + return page.crop.topLeft() + QPointF(normalized.x() * page.crop.width(), normalized.y() * page.crop.height()); +} +QRectF PdfCanvas::annotationRectToCanvas(int page, const QVariantMap &annotation) const { + if (page < 0 || page >= pages_.size() || page >= rects_.size()) return {}; + const auto rect = pdfRect(annotation.value("rect")); if (rect.isEmpty()) return {}; + const int flags = annotation.value("flags").toInt(); + const QPointF anchor = pageToCanvas(page, {rect.left(), rect.bottom()}); + const double zoom = flags & 8 ? 1. : scale_; + if (flags & 16) return {anchor, QSizeF(rect.width() * zoom, rect.height() * zoom)}; + auto first = pageToCanvas(page, rect.topLeft()), last = pageToCanvas(page, rect.bottomRight()); + if (flags & 8) { first = anchor + (first - anchor) / scale_; last = anchor + (last - anchor) / scale_; } + return QRectF(first, last).normalized(); +} +double PdfCanvas::navigationY(int page, const QVariantMap &target) const { + if (page < 0 || page >= rects_.size() || !pages_[page].known) + return std::numeric_limits::quiet_NaN(); + if (!target.contains("yPt") && !target.contains("y")) return rects_[page].top() - offset_; + const auto &crop = pages_[page].crop; + const double x = target.value("xPt", target.value("x", crop.left())).toDouble(); + const double y = target.value("yPt", target.value("y")).toDouble(); + if (!std::isfinite(x) || !std::isfinite(y)) return std::numeric_limits::quiet_NaN(); + return pageToCanvas(page, {qBound(crop.left(), x, crop.right()), qBound(crop.top(), y, crop.bottom())}).y(); +} +void PdfCanvas::relayout(bool preserve) { + const int anchor = qBound(0, currentPage_, qMax(0, int(pages_.size()) - 1)); + const bool hadAnchor = preserve && anchor < rects_.size(); + const QPointF oldAnchor = hadAnchor ? canvasToPage(anchor, QPointF(width() / 2, height() / 2)) : QPointF(); + if (fitMode_ != "percent" && !pages_.isEmpty()) { + const double pageWidth = rotation_ % 180 ? pages_[anchor].height : pages_[anchor].width; + const double pageHeight = rotation_ % 180 ? pages_[anchor].width : pages_[anchor].height; + double fit = (width() - 32) / pageWidth; + if (fitMode_ == "fit-page") fit = std::min(fit, (height() - 32) / pageHeight); + scale_ = qBound(.05, fit, 8.0); + } + rects_.clear(); double y = 16; + for (const auto &page : pages_) { + double w = page.width, h = page.height; if (rotation_ % 180) std::swap(w, h); + w *= scale_; h *= scale_; rects_.append(QRectF(qMax(16., (width() - w) / 2), y, w, h)); y += h + 16; + } + total_ = y; + if (hadAnchor) { + const QPointF next = pageToCanvas(anchor, oldAnchor); offset_ += next.y() - height() / 2; pan_ += next.x() - width() / 2; + } + offset_ = qBound(0., offset_, qMax(0., total_ - height())); + double maxPan = 0; for (const auto &r : rects_) maxPan = qMax(maxPan, r.width() + 32 - width()); pan_ = qBound(0., pan_, maxPan); + if (worker_) worker_->discardQueued(QStringLiteral("render")); + ++revision_; pending_.clear(); updateCurrent(); emit layoutChanged(); emit zoomChanged(); schedule(); +} +void PdfCanvas::schedule() { update(); if (memoryPressureLevel_ < 4 && !timer_.isActive()) timer_.start(0); } +void PdfCanvas::scroll(double x, double y) { + if (!std::isfinite(x) || !std::isfinite(y)) return; + pendingLocation_.clear(); + offset_ = qBound(0., offset_ + y, qMax(0., total_ - height())); + double maxPan = 0; for (const auto &r : rects_) maxPan = qMax(maxPan, r.width() + 32 - width()); pan_ = qBound(0., pan_ + x, maxPan); + updateCurrent(); schedule(); +} +void PdfCanvas::goTo(int page, double fraction) { + if (page < 0 || page >= rects_.size() || !std::isfinite(fraction)) return; + pendingLocation_.clear(); + offset_ = qBound(0., rects_[page].y() + qBound(0., fraction, 1.) * rects_[page].height() - 16, qMax(0., total_ - height())); + currentPage_ = page; if (!pages_[page].known) emit pageNeeded(page); updateCurrent(); schedule(); +} +void PdfCanvas::goToLocation(const QVariantMap &location) { + const qint64 page = location.value("pageIndex", location.value("page", -1)).toLongLong(); + if (page < 0 || page >= pages_.size()) { emit renderFailed(tr("E_LOCATION_INVALID: ページ番号が範囲外です")); return; } + pendingLocation_.clear(); + if (!pages_[int(page)].known) { + goTo(int(page)); + // pageNeeded may synchronously provide the metadata. + if (pages_[int(page)].known) applyLocation(location); + else { pendingLocation_ = location; emit pageNeeded(int(page)); } + return; + } + applyLocation(location); +} +void PdfCanvas::applyLocation(const QVariantMap &location) { + const int page = location.value("pageIndex", location.value("page", -1)).toInt(); + if (page < 0 || page >= pages_.size()) return; + if (location.contains("viewRotation") || location.contains("rotation")) { + const int value = location.value("viewRotation", location.value("rotation")).toInt(); + if (value >= 0 && value <= 270 && value % 90 == 0) rotation_ = value; + } + if (location.contains("zoom")) { + const double zoom = location.value("zoom").toDouble(); if (std::isfinite(zoom)) scale_ = qBound(.05, zoom / 100., 8.); + } + if (location.contains("fitMode")) { + const auto fit = location.value("fitMode").toString(); if (fit == "fit-width" || fit == "fit-page" || fit == "percent") fitMode_ = fit; + } + currentPage_ = page; relayout(false); + const bool hasY = location.contains("yPt") || location.contains("y"); + if (!hasY) { goTo(page, location.value("progression").toDouble()); return; } + const auto &crop = pages_[page].crop; + const QPointF original(location.value("xPt", location.value("x", crop.left())).toDouble(), location.value("yPt", location.value("y", crop.bottom())).toDouble()); + if (!finitePoint(original)) { emit renderFailed(tr("E_LOCATION_INVALID: PDFの座標が無効です")); return; } + const QPointF clipped(qBound(crop.left(), original.x(), crop.right()), qBound(crop.top(), original.y(), crop.bottom())); + const QPointF device = pageToCanvas(page, clipped); offset_ += device.y() - 16; + if (location.contains("xPt")) pan_ += device.x() - 16; + else if (device.x() < 16) pan_ += device.x() - 16; else if (device.x() > width() - 16) pan_ += device.x() - width() + 16; + scroll(0, 0); + if (clipped != original) emit renderFailed(tr("保存位置をページの可視範囲内へ近似復元しました")); +} +void PdfCanvas::setZoom(double percent) { if (!std::isfinite(percent)) return; pendingLocation_.clear(); fitMode_ = "percent"; scale_ = qBound(.25, percent / 100, 8.); relayout(); } +void PdfCanvas::fitWidth() { pendingLocation_.clear(); fitMode_ = "fit-width"; relayout(); } +void PdfCanvas::fitPage() { pendingLocation_.clear(); fitMode_ = "fit-page"; relayout(); } +void PdfCanvas::rotate(int degrees) { + if (degrees % 90) return; + pendingLocation_.clear(); + auto saved = location(); + rotation_ = int((qint64(rotation_) + degrees) % 360 + 360) % 360; + if (saved.isEmpty()) { relayout(false); return; } + saved.insert("rotation", rotation_); saved.insert("viewRotation", rotation_); applyLocation(saved); +} +void PdfCanvas::end() { scroll(0, total_); } +void PdfCanvas::updateCurrent() { + if (rects_.isEmpty()) return; + // Status/page commands use the viewport centre; saved reading positions + // and heading traversal use its leading edge, so short pages are not skipped. + const double anchor = offset_ + 17; int index = int(rects_.size()) - 1; + for (int i = 0; i < rects_.size(); ++i) if (rects_[i].bottom() > anchor) { index = i; break; } + readingPage_ = index; + const double centre = offset_ + height() / 2; + double nearest = std::numeric_limits::infinity(); + int centralPage = 0; + for (int i = 0; i < rects_.size(); ++i) { + const double distance = std::max({rects_[i].top() - centre, centre - rects_[i].bottom(), 0.}); + if (distance < nearest) { nearest = distance; centralPage = i; } + if (rects_[i].top() > centre) break; + } + const bool changed = centralPage != currentPage_; currentPage_ = centralPage; emit positionChanged(); + if (!pages_[readingPage_].known) emit pageNeeded(readingPage_); + if (currentPage_ != readingPage_ && !pages_[currentPage_].known) emit pageNeeded(currentPage_); + if (changed) { selectedLink_ = selectedLinkPage_ = -1; } +} +QVariantMap PdfCanvas::location() const { + if (readingPage_ < 0 || readingPage_ >= rects_.size()) return {}; + const auto &page = pages_[readingPage_]; const auto &rect = rects_[readingPage_]; + QPointF point = canvasToPage(readingPage_, QPointF(qBound(16., rect.left() - pan_, qMax(16., width() - 16)), 16)); + point.setX(qBound(page.crop.left(), point.x(), page.crop.right())); point.setY(qBound(page.crop.top(), point.y(), page.crop.bottom())); + const double fraction = (offset_ + 16 - rect.y()) / rect.height(); + return {{"kind", "pdf"}, {"pageIndex", readingPage_}, {"pageLabel", page.label}, {"xPt", point.x()}, {"yPt", point.y()}, + {"progression", qBound(0., fraction, 1.)}, {"rotation", rotation_}, {"viewRotation", rotation_}, {"zoom", zoom()}, {"fitMode", fitMode_}}; +} +QList PdfCanvas::visibleTiles() const { + QList result; if (!worker_ || width() <= 0 || height() <= 0) return result; + const double dpr = (window() ? window()->devicePixelRatio() : 1.) * rasterResolutionScale(); + QRectF viewport(pan_, offset_, width(), height()); + for (int page = 0; page < rects_.size(); ++page) { + const auto rect = rects_[page]; if (!rect.intersects(viewport) || !pages_[page].known) continue; + const auto clip = rect.intersected(viewport); + const int firstX = qMax(0, int(std::floor((clip.left() - rect.left()) * dpr / TileSize)) * TileSize); + const int firstY = qMax(0, int(std::floor((clip.top() - rect.top()) * dpr / TileSize)) * TileSize); + const int pixelWidth = int(std::ceil(rect.width() * dpr)), pixelHeight = int(std::ceil(rect.height() * dpr)); + for (int y = firstY; y < qMin(pixelHeight, int(std::ceil((clip.bottom() - rect.top()) * dpr))); y += TileSize) + for (int x = firstX; x < qMin(pixelWidth, int(std::ceil((clip.right() - rect.left()) * dpr))); x += TileSize) { + const int w = qMin(TileSize, pixelWidth - x), h = qMin(TileSize, pixelHeight - y); + const QString key = QString("%1:%2:%3:%4:%5:%6:%7:%8:%9").arg(document_).arg(page).arg(scale_ * dpr, 0, 'g', 17).arg(rotation_).arg(x).arg(y).arg(w).arg(h).arg(dpr, 0, 'g', 17); + result.append({key, page, x, y, w, h, rotation_, scale_ * dpr, dpr, QRectF(rect.x() - pan_ + x / dpr, rect.y() - offset_ + y / dpr, w / dpr, h / dpr)}); + } + } + return result; +} +void PdfCanvas::paint(QPainter *painter) { + painter->fillRect(boundingRect(), backgroundColor_); + for (int i = 0; i < rects_.size(); ++i) { + const auto rect = rects_[i].translated(-pan_, -offset_); if (!rect.intersects(boundingRect())) continue; + painter->fillRect(rect, Qt::white); painter->setPen(QColor("#69737e")); + painter->drawText(rect.adjusted(16, 16, -16, -16), Qt::AlignTop | Qt::AlignLeft, tr("ページ %1 · 読込中").arg(i + 1)); + } + // Cached earlier resolutions remain as provisional pixels during zoom. + // New tiles are composited over them; only current-resolution tiles satisfy + // viewportReady(), so a stretched preview is never counted as completion. + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + for (auto it = cachedTiles_.cbegin(); it != cachedTiles_.cend(); ++it) { + const auto &tile = it.value(); + if (tile.rotation != rotation_ || tile.page < 0 || tile.page >= rects_.size()) continue; + const auto &page = rects_[tile.page]; const double ratio = scale_ / tile.scale; + const QRectF target(page.x() - pan_ + tile.x * ratio, page.y() - offset_ + tile.y * ratio, tile.w * ratio, tile.h * ratio); + if (target.intersects(boundingRect())) if (auto image = cache_.object(tile.key)) painter->drawImage(target, *image); + } + painter->setRenderHint(QPainter::SmoothPixmapTransform, false); + for (const auto &tile : visibleTiles()) if (auto image = cache_.object(tile.key)) painter->drawImage(tile.target, *image, QRectF(0, 0, tile.w, tile.h)); + const int searchPage = searchResult_.value("page", -1).toInt(); + if (searchPage >= 0 && searchPage < rects_.size()) { + painter->setPen(QPen(QColor("#bb8b00"), 1)); painter->setBrush(QColor(255, 212, 71, 95)); + for (const auto &value : searchResult_.value("rects").toList()) { + const auto rect = pdfRect(value); if (rect.isEmpty()) continue; + const QRectF device(pageToCanvas(searchPage, rect.topLeft()), pageToCanvas(searchPage, rect.bottomRight())); painter->drawRect(device.normalized()); + } + } + if (const auto selected = selectedTarget(); !selected.isEmpty()) { + const auto device = annotationRectToCanvas(selectedLinkPage_, selected) + .intersected(rects_[selectedLinkPage_].translated(-pan_, -offset_)); + painter->setPen(QPen(QColor("#63dec4"), 2)); painter->setBrush(QColor(99, 222, 196, 35)); + if (!device.isEmpty()) painter->drawRect(device); + } +} +QList PdfCanvas::prefetchTiles() const { + QList result; + if (!worker_ || memoryPressureLevel_ >= 1 || prefetchPages_ == 0 || width() <= 0 || height() <= 0) return result; + const QRectF viewport(pan_, offset_, width(), height()); + int first = -1, last = -1; + for (int page = 0; page < rects_.size(); ++page) if (rects_[page].intersects(viewport)) { + if (first < 0) first = page; last = page; + } + if (first < 0) return result; + const double dpr = window() ? window()->devicePixelRatio() : 1.; + // Each adjacent page contributes at most eight tiles from a single + // viewport-sized strip. A very large page never expands into a full grid. + for (int distance = 1; distance <= prefetchPages_; ++distance) { + for (const int page : {last + distance, first - distance}) { + if (page < 0 || page >= pages_.size() || !pages_[page].known) continue; + const auto &rect = rects_[page]; + const double left = qBound(0., pan_ - rect.x(), qMax(0., rect.width() - width())); + const double top = page < first ? qMax(0., rect.height() - height()) : 0.; + const int startX = qMax(0, int(std::floor(left * dpr / TileSize)) * TileSize); + const int startY = qMax(0, int(std::floor(top * dpr / TileSize)) * TileSize); + const int pw = int(std::ceil(rect.width() * dpr)), ph = int(std::ceil(rect.height() * dpr)); + const int endX = qMin(pw, int(std::ceil((left + width()) * dpr))); + const int endY = qMin(ph, int(std::ceil((top + height()) * dpr))); + int count = 0; + for (int y = startY; y < endY && count < 8; y += TileSize) + for (int x = startX; x < endX && count < 8; x += TileSize, ++count) { + const int w = qMin(TileSize, pw - x), h = qMin(TileSize, ph - y); + const QString key = QString("%1:%2:%3:%4:%5:%6:%7:%8:%9").arg(document_).arg(page).arg(scale_ * dpr, 0, 'g', 17).arg(rotation_).arg(x).arg(y).arg(w).arg(h).arg(dpr, 0, 'g', 17); + result.append({key, page, x, y, w, h, rotation_, scale_ * dpr, dpr, QRectF(rect.x() - pan_ + x / dpr, rect.y() - offset_ + y / dpr, w / dpr, h / dpr)}); + } + } + } + return result; +} +qint64 PdfCanvas::visibleMissingCost() const { + qint64 cost = 0; + for (const auto &tile : visibleTiles()) if (!cache_.contains(tile.key)) cost += (qint64(tile.w) * tile.h * 4 + 1023) / 1024; + return cost; +} +void PdfCanvas::requestVisible() { + if (!worker_ || workerFailed_ || memoryPressureLevel_ >= 4) return; + if (memoryPressureLevel_ >= 2) trimCache(memoryPressureLevel_ >= 3 && viewportReady()); + for (auto it = cachedTiles_.begin(); it != cachedTiles_.end();) { + if (!cache_.contains(it.key())) it = cachedTiles_.erase(it); else ++it; + } + const auto visible = visibleTiles(); + for (const auto &tile : visible) { + requestLinks(tile.page); + if (cache_.contains(tile.key) || pending_.contains(tile.key)) continue; + if (pending_.size() >= 4) break; + requestTile(tile, false); + } + // Rendering for the viewport and all already queued interactive work win. + // There is at most one low-priority tile in flight, never a prefetch queue. + if (memoryPressureLevel_ >= 1 || prefetchPages_ == 0 || !pending_.isEmpty() || !viewportReady()) return; + if (!worker_->idle()) { if (!timer_.isActive()) timer_.start(60); return; } + for (const auto &tile : prefetchTiles()) { + if (cache_.contains(tile.key)) continue; + const qint64 cost = (qint64(tile.w) * tile.h * 4 + 1023) / 1024; + if (cache_.totalCost() + cost + visibleMissingCost() > cache_.maxCost()) return; + requestTile(tile, true); return; + } + // Initial metadata is paged. Fetch only the adjacent pages for which the + // configured prefetch radius actually asks; the controller coalesces them. + if (!visible.isEmpty()) { + int first = visible.first().page, last = visible.last().page; + for (int distance = 1; distance <= prefetchPages_; ++distance) + for (int page : {last + distance, first - distance}) { + if (page < 0 || page >= pages_.size() || pages_[page].known || prefetchMetadata_.contains(page)) continue; + prefetchMetadata_.insert(page); emit pageNeeded(page); return; + } + } +} +void PdfCanvas::requestTile(const Tile &tile, bool prefetch) { + pending_.insert(tile.key); const auto rev = revision_, doc = document_; QPointer self(this); + QCborMap request{{"page", tile.page}, {"scale", tile.scale}, {"devicePixelRatio", tile.dpr}, {"rotation", rotation_}, {"x", tile.x}, {"y", tile.y}, {"width", tile.w}, {"height", tile.h}, {"renderRevision", rev}}; + emit tileRequested(tile.page, prefetch, rev); + if (!self || !worker_ || doc != document_ || rev != revision_) return; + if (prefetch) ++prefetchRenderRequests_; else ++visibleRenderRequests_; + worker_->request("render", request, [self, tile, prefetch, rev, doc](const QCborMap &message) { + if (!self) return; + if (doc != self->document_) { ++self->staleRenderResponses_; return; } + // A font failure poisons this worker's PDFium cache. It remains fatal + // across zoom revisions and speculative tiles, but never documents. + if (message.contains(QStringLiteral("error")) && self->handleWorkerError(message.value("error").toMap())) return; + // A newer revision can have the same tile key; do not remove its pending entry. + if (rev != self->revision_) { ++self->staleRenderResponses_; self->schedule(); return; } + self->pending_.remove(tile.key); + if (message.contains(QStringLiteral("error"))) { + if (!prefetch) emit self->renderFailed(message.value("error").toMap().value("message").toString()); + return; + } + const auto result = message.value("result").toMap(); const auto bytes = result.value("data").toByteArray(); + self->reportWarnings(result.value("warnings")); + if (!self) return; + const qint64 w = result.value("width").toInteger(), h = result.value("height").toInteger(), stride = result.value("stride").toInteger(); + if (w != tile.w || h != tile.h || stride != w * 4 || bytes.size() != stride * h || result.value("page").toInteger(-1) != tile.page || + result.value("pixelFormat").toString() != "BGRA8888" || result.value("renderRevision").toInteger(-1) != rev || + result.value("x").toInteger(-1) != tile.x || result.value("y").toInteger(-1) != tile.y) { self->handleWorkerError({{"code", "E_WORKER_CRASH"}, {"message", tr("不正な描画画像")}}); return; } + const qint64 cost = (w * h * 4 + 1023) / 1024; + const auto visibleTiles = self->visibleTiles(); bool visibleNow = false; + for (const auto &visible : visibleTiles) if (visible.key == tile.key) { visibleNow = true; break; } + if (self->memoryPressureLevel_ >= 2 && !visibleNow) { self->schedule(); return; } + if (prefetch && !visibleNow) { + // Never evict anything for speculative pixels. Visible cache and + // any new viewport misses reserve capacity before prefetch does. + const auto candidates = self->prefetchTiles(); bool stillWanted = false; + for (const auto &candidate : candidates) if (candidate.key == tile.key) { stillWanted = true; break; } + if (!stillWanted || self->cache_.totalCost() + cost + self->visibleMissingCost() > self->cache_.maxCost()) { + ++self->discardedPrefetchResponses_; self->schedule(); return; + } + } + for (const auto &visible : visibleTiles) self->cache_.object(visible.key); // protect displayed LRU entries + QImage image = QImage(reinterpret_cast(bytes.constData()), int(w), int(h), int(stride), QImage::Format_ARGB32).copy(); + self->cache_.insert(tile.key, new QImage(image), qMax(1, int(cost))); + self->cachedTiles_.insert(tile.key, tile); + if (self->memoryPressureLevel_ >= 2) self->trimCache(self->memoryPressureLevel_ >= 3 && self->viewportReady()); + self->schedule(); if (!prefetch || visibleNow) emit self->frameReady(); + }); +} +bool PdfCanvas::handleWorkerError(const QCborMap &error) { + const auto code = error.value("code").toString(); + if (code != "E_FONT_FAILED" && code != "E_FONT_LIMIT" && code != "E_WORKER_TIMEOUT" && code != "E_WORKER_CRASH") return false; + if (!workerFailed_) { + workerFailed_ = true; timer_.stop(); pending_.clear(); + if (worker_) worker_->discardQueued(); + emit fatalWorkerError(code, error.value("message").toString().left(4096)); + } + return true; +} +void PdfCanvas::reportWarnings(const QCborValue &value) { + if (!value.isArray()) return; + QPointer self(this); + const auto warnings = value.toArray(); + for (qsizetype i = 0; i < std::min(32, warnings.size()); ++i) { + if (!warnings[i].isString()) continue; + const auto warning = warnings[i].toString().left(1024); + if (warning.isEmpty() || warning.contains(QChar::Null)) continue; + emit documentWarning(warning); + if (!self) return; + } +} +bool PdfCanvas::viewportReady() const { + if (memoryPressureLevel_ >= 4) return false; + const auto tiles = visibleTiles(); + if (tiles.isEmpty()) return false; + for (const auto &tile : tiles) if (!cache_.contains(tile.key)) return false; + // Unknown visible pages still need metadata and are not a completed frame. + const QRectF viewport(pan_, offset_, width(), height()); + for (int page = 0; page < rects_.size(); ++page) if (rects_[page].intersects(viewport) && !pages_[page].known) return false; + return true; +} +void PdfCanvas::showSearchResult(const QVariantMap &match) { + searchResult_.clear(); + if (match.isEmpty()) { update(); return; } + const qint64 page = match.value("page", -1).toLongLong(); const auto rects = match.value("rects").toList(); + if (page < 0 || page >= pages_.size() || rects.isEmpty() || rects.size() > 100) return; + for (const auto &value : rects) if (pdfRect(value).isEmpty()) return; + searchResult_ = match; const auto first = pdfRect(rects.first()); + goToLocation({{"page", page}, {"x", first.left()}, {"y", first.bottom()}}); update(); +} +void PdfCanvas::requestLinks(int page) { + if (!worker_ || memoryPressureLevel_ >= 4 || links_.contains(page) || linksPending_.contains(page)) return; + linksPending_.insert(page); const auto doc = document_; QPointer self(this); + worker_->request("links", {{"page", page}}, [self, page, doc](const QCborMap &message) { + if (!self || doc != self->document_) return; self->linksPending_.remove(page); + if (message.contains(QStringLiteral("error"))) { self->handleWorkerError(message.value("error").toMap()); return; } + const auto result = message.value("result").toMap(); + if (result.value("page").toInteger(-1) != page) return; + QVariantList links, notes; + const auto entries = result.value("links").toArray(); if (entries.size() > 1000) return; + for (const auto &entry : entries) { const auto link = entry.toMap().toVariantMap(); if (!pdfRect(link.value("rect")).isEmpty()) links.append(link); } + const auto annotations = result.value("annotations").toArray(); if (annotations.size() > 1000) return; + for (const auto &entry : annotations) { + const auto map = entry.toMap(); const auto text = map.value("text"); + if (!text.isString() || text.toString().isEmpty() || text.toString().size() > 4096 || text.toString().contains(QChar::Null)) continue; + const auto note = map.toVariantMap(); if (!pdfRect(note.value("rect")).isEmpty()) notes.append(note); + } + if (self->links_.size() >= 32) { const int victim = self->links_.constBegin().key(); self->links_.remove(victim); self->annotations_.remove(victim); } + self->links_.insert(page, links); self->annotations_.insert(page, notes); + self->reportWarnings(result.value("warnings")); + }); +} +void PdfCanvas::activateTarget(const QVariantMap &target) { + const auto kind = target.value("kind").toString(); + if (kind == "internal") { + const qint64 page = target.value("page", -1).toLongLong(); if (page >= 0 && page < pages_.size()) emit internalLinkRequested(target); + } else if (kind == "external") { + const QUrl url(target.value("url").toString(), QUrl::StrictMode); + if (url.isValid() && !url.host().isEmpty() && (url.scheme() == "http" || url.scheme() == "https")) emit externalLinkRequested(url); + } else emit renderFailed(tr("E_LINK_BLOCKED: このリンクの操作は許可されていません")); +} +QVariantMap PdfCanvas::selectedTarget() const { + if (selectedLinkPage_ < 0 || selectedLinkPage_ >= pages_.size() || selectedLink_ < 0) return {}; + const auto links = links_.value(selectedLinkPage_); + if (selectedLink_ < links.size()) return links[selectedLink_].toMap(); + const auto notes = annotations_.value(selectedLinkPage_); + const int index = selectedLink_ - int(links.size()); + return index < notes.size() ? notes[index].toMap() : QVariantMap{}; +} +void PdfCanvas::focusLink(int direction) { + if (currentPage_ < 0 || currentPage_ >= pages_.size()) return; + if (!links_.contains(currentPage_)) { requestLinks(currentPage_); emit renderFailed(tr("リンクと注釈を読込中です。もう一度操作してください")); return; } + const int page = currentPage_, linkCount = int(links_.value(page).size()); + const int noteCount = int(annotations_.value(page).size()), count = linkCount + noteCount; + if (!count) { emit renderFailed(tr("このページにはリンクや注釈がありません")); return; } + if (selectedLinkPage_ != page) { selectedLink_ = direction < 0 ? 0 : -1; selectedLinkPage_ = page; } + const int index = (selectedLink_ + (direction < 0 ? -1 : 1) + count) % count; + selectedLink_ = index; + const QRectF rect = pdfRect(selectedTarget().value("rect")); + const auto &crop = pages_[page].crop; + // A comment outside CropBox must still be readable from the keyboard. + // Keep navigation on its owning page instead of following its off-page rect. + const auto center = rect.center(); + const auto visibleRect = annotationRectToCanvas(page, selectedTarget()).intersected(rects_[page].translated(-pan_, -offset_)); + const auto point = visibleRect.isEmpty() + ? pageToCanvas(page, {qBound(crop.left(), center.x(), crop.right()), qBound(crop.top(), center.y(), crop.bottom())}) + : visibleRect.center(); + double dx = 0, dy = 0; + if (point.x() < 16 || point.x() > width() - 16) dx = point.x() - width() / 2; + if (point.y() < 16 || point.y() > height() - 16) dy = point.y() - height() / 2; + const double lower = rects_[page].top() - 16; + const double nextOffset = qBound(lower, offset_ + dy, qMax(lower, rects_[page].bottom() - 17)); + scroll(dx, nextOffset - offset_); + selectedLinkPage_ = page; selectedLink_ = index; + if (index >= linkCount) emit renderFailed((rect.intersects(crop) + ? tr("注釈 %1 / %2 · Enterで本文を開きます") : tr("ページ外の注釈 %1 / %2 · Enterで本文を開きます")) + .arg(index - linkCount + 1).arg(noteCount)); + schedule(); +} +void PdfCanvas::activateLink() { + const auto target = selectedTarget(); + if (target.isEmpty()) { emit renderFailed(tr("リンクまたは注釈を選択してください")); return; } + if (selectedLink_ < links_.value(selectedLinkPage_).size()) activateTarget(target); + else emit annotationRequested(target.value("text").toString()); +} +void PdfCanvas::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() != Qt::LeftButton) return; + for (int page = 0; page < rects_.size(); ++page) { + if (!rects_[page].translated(-pan_, -offset_).contains(event->position())) continue; + for (const auto &value : links_.value(page)) { const auto link = value.toMap(); if (annotationRectToCanvas(page, link).contains(event->position())) { activateTarget(link); event->accept(); return; } } + for (const auto &value : annotations_.value(page)) { const auto note = value.toMap(); if (annotationRectToCanvas(page, note).contains(event->position())) { emit annotationRequested(note.value("text").toString()); event->accept(); return; } } + break; + } + event->accept(); +} +void PdfCanvas::wheelEvent(QWheelEvent *event) { + QPoint delta = event->pixelDelta(); if (delta.isNull()) delta = event->angleDelta() / 3; + scroll(-delta.x(), -delta.y()); event->accept(); +} +} diff --git a/src/app/pdf_canvas.h b/src/app/pdf_canvas.h new file mode 100644 index 0000000..fd2e7c2 --- /dev/null +++ b/src/app/pdf_canvas.h @@ -0,0 +1,120 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "common/worker_process.h" +namespace docview { +class PdfCanvas : public QQuickPaintedItem { + Q_OBJECT + Q_PROPERTY(int currentPage READ currentPage NOTIFY positionChanged) + Q_PROPERTY(double zoom READ zoom NOTIFY zoomChanged) + Q_PROPERTY(double offset READ offset NOTIFY positionChanged) + Q_PROPERTY(double totalHeight READ totalHeight NOTIFY layoutChanged) + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor NOTIFY backgroundColorChanged) +public: + explicit PdfCanvas(QQuickItem *parent = nullptr); + void paint(QPainter *) override; + void setDocument(WorkerProcess *worker, const QVariantList &pages, int count); + void updatePages(const QVariantList &pages); + void setCacheBudget(int mib); + void setPrefetchPages(int pages); + void setMemoryPressureLevel(int level); + int memoryPressureLevel() const { return memoryPressureLevel_; } + double rasterResolutionScale() const; + void setBackgroundColor(const QColor &color); + QColor backgroundColor() const { return backgroundColor_; } + void cancelPendingNavigation() { pendingLocation_.clear(); } + void clearSelection() { selectedLink_ = selectedLinkPage_ = -1; update(); } + int currentPage() const { return currentPage_; } + double zoom() const { return scale_ * 100; } + double offset() const { return offset_; } + double totalHeight() const { return total_; } + // Cache cost is QCache's KiB-rounded image charge, not process RSS. + qint64 cacheCostBytes() const { return qint64(cache_.totalCost()) * 1024; } + qint64 cacheBudgetBytes() const { return qint64(cache_.maxCost()) * 1024; } + qsizetype cacheEntryCount() const { return cache_.size(); } + // Lifetime counters: sample before/after an operation. Repaint and + // readiness checks do not count as hits or additional render requests. + quint64 visibleRenderRequestCount() const { return visibleRenderRequests_; } + quint64 prefetchRenderRequestCount() const { return prefetchRenderRequests_; } + quint64 staleRenderResponseCount() const { return staleRenderResponses_; } + quint64 discardedPrefetchResponseCount() const { return discardedPrefetchResponses_; } + qsizetype pendingRenderCount() const { return pending_.size(); } + Q_INVOKABLE void scroll(double x, double y); + Q_INVOKABLE void goTo(int page, double fraction = 0); + Q_INVOKABLE void goToLocation(const QVariantMap &location); + Q_INVOKABLE void setZoom(double percent); + Q_INVOKABLE void fitWidth(); + Q_INVOKABLE void fitPage(); + Q_INVOKABLE void rotate(int degrees); + Q_INVOKABLE void end(); + Q_INVOKABLE void focusLink(int direction); + Q_INVOKABLE void activateLink(); + QVariantMap location() const; + bool viewportReady() const; + Q_INVOKABLE void showSearchResult(const QVariantMap &match); + // Shared forward/inverse PDF-space transform, also used by link hit tests. + QPointF pageToCanvas(int page, const QPointF &point) const; + QPointF canvasToPage(int page, const QPointF &point) const; + double navigationY(int page, const QVariantMap &target) const; + QRectF annotationRectToCanvas(int page, const QVariantMap &annotation) const; +signals: + void positionChanged(); void zoomChanged(); void layoutChanged(); + void renderFailed(QString message); void frameReady(); void pageNeeded(int page); + void fatalWorkerError(QString code, QString message); + void internalLinkRequested(QVariantMap target); + void externalLinkRequested(QUrl url); + void annotationRequested(QString text); + void documentWarning(QString message); + void tileRequested(int page, bool prefetch, qint64 revision); + void backgroundColorChanged(); +protected: + void geometryChange(const QRectF &, const QRectF &) override; + void wheelEvent(QWheelEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; +private: + void relayout(bool preserve = true); + void schedule(); void requestVisible(); void updateCurrent(); + void requestLinks(int page); void activateTarget(const QVariantMap &target); + QVariantMap selectedTarget() const; + bool handleWorkerError(const QCborMap &error); + void reportWarnings(const QCborValue &warnings); + void applyLocation(const QVariantMap &location); + struct Page { + double width = 595, height = 842; + QString label; + bool known = false; + QRectF crop{0, 0, 595, 842}; // PDF coordinates: left,bottom,width,height + int rotation = 0; + }; + struct Tile { QString key; int page, x, y, w, h, rotation; double scale, dpr; QRectF target; }; + QList visibleTiles() const; + QList prefetchTiles() const; + void requestTile(const Tile &tile, bool prefetch); + qint64 visibleMissingCost() const; + void trimCache(bool currentResolutionOnly = false); + QPointer worker_; + QMetaObject::Connection windowDprConnection_; + QVector pages_; QVector rects_; + QCache cache_; QSet pending_; + QHash cachedTiles_; + QVariantMap searchResult_; + QHash links_, annotations_; QSet linksPending_; + QTimer timer_; double offset_ = 0, pan_ = 0, scale_ = 1, total_ = 0; + int currentPage_ = 0, readingPage_ = 0, rotation_ = 0, selectedLink_ = -1, selectedLinkPage_ = -1; + int prefetchPages_ = 0; + int memoryPressureLevel_ = 0; + bool workerFailed_ = false; + QSet prefetchMetadata_; + QString fitMode_ = QStringLiteral("fit-width"); + qint64 revision_ = 0, document_ = 0; + quint64 visibleRenderRequests_ = 0, prefetchRenderRequests_ = 0; + quint64 staleRenderResponses_ = 0, discardedPrefetchResponses_ = 0; + QVariantMap pendingLocation_; + QColor backgroundColor_{QStringLiteral("#181c20")}; +}; +} diff --git a/src/app/translations.cpp b/src/app/translations.cpp new file mode 100644 index 0000000..7ebfe11 --- /dev/null +++ b/src/app/translations.cpp @@ -0,0 +1,21 @@ +#include "translations.h" + +#include +#include + +namespace docview { + +ApplicationTranslations::ApplicationTranslations() +{ + const auto directory = QLibraryInfo::path(QLibraryInfo::TranslationsPath); + // Qt deployment tools may merge module catalogs into qt_ja.qm. + if (qtBase_.load(QStringLiteral("qtbase_ja"), directory) + || qtBase_.load(QStringLiteral("qt_ja"), directory)) + QCoreApplication::installTranslator(&qtBase_); + if (qtDeclarative_.load(QStringLiteral("qtdeclarative_ja"), directory)) + QCoreApplication::installTranslator(&qtDeclarative_); + if (application_.load(QStringLiteral(":/i18n/docview_ja.qm"))) + QCoreApplication::installTranslator(&application_); +} + +} // namespace docview diff --git a/src/app/translations.h b/src/app/translations.h new file mode 100644 index 0000000..7ea01f4 --- /dev/null +++ b/src/app/translations.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace docview { + +// Japanese is the product's initial UI language, independent of the document's +// language or the host's numeric/date locale. Keep translators alive for the app. +class ApplicationTranslations { + public: + ApplicationTranslations(); + + private: + QTranslator qtBase_; + QTranslator qtDeclarative_; + QTranslator application_; +}; + +} // namespace docview diff --git a/src/archive/archive.cpp b/src/archive/archive.cpp new file mode 100644 index 0000000..2f60b34 --- /dev/null +++ b/src/archive/archive.cpp @@ -0,0 +1,684 @@ +#include "archive.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +namespace docview { +struct ArchiveInputMeter { + static constexpr quint64 MaximumRead = 64 * 1024; + ArchiveStreamStatistics statistics; + QElapsedTimer timer; + bool active = false; + bool expired = false; +}; +namespace { +bool fail(ArchiveError *error, const char *code, const QString &message) { + if (error) *error = {QString::fromLatin1(code), message}; + return false; +} +QString mimeFor(const QString &path) { + const QString suffix = QFileInfo(path).suffix().toLower(); + static const QHash types = { + {"html", "text/html"}, {"htm", "text/html"}, {"xhtml", "application/xhtml+xml"}, + {"css", "text/css"}, {"svg", "image/svg+xml"}, {"png", "image/png"}, + {"jpg", "image/jpeg"}, {"jpeg", "image/jpeg"}, {"gif", "image/gif"}, + {"webp", "image/webp"}, {"avif", "image/avif"}, {"woff", "font/woff"}, + {"woff2", "font/woff2"}, {"ttf", "font/ttf"}, {"otf", "font/otf"}, + {"xml", "application/xml"}, {"opf", "application/oebps-package+xml"}, + {"ncx", "application/x-dtbncx+xml"} + }; + return types.value(suffix, "application/octet-stream"); +} +QString collisionKey(const QString &path) { + return path.normalized(QString::NormalizationForm_C).toCaseFolded().normalized(QString::NormalizationForm_C); +} +quint16 u16(const QByteArray &data, int offset) { + return qFromLittleEndian(reinterpret_cast(data.constData() + offset)); +} +quint32 u32(const QByteArray &data, int offset) { + return qFromLittleEndian(reinterpret_cast(data.constData() + offset)); +} +quint64 u64(const QByteArray &data, int offset) { + return qFromLittleEndian(reinterpret_cast(data.constData() + offset)); +} +// libzip's string API cannot report embedded NUL in the original filename. +// Inspect the bounded central directory before asking libzip to interpret it. +bool checkRawDirectory(QFile &file, const ArchiveLimits &limits, ArchiveError *error) { + const qint64 length = file.size(); + if (length < 22) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引が見つかりません。")); + const qint64 tailStart = std::max(0, length - 65557); + file.seek(tailStart); + const QByteArray tail = file.read(65557); + int eocd = -1; + for (int i = int(tail.size()) - 22; i >= 0; --i) { + if (u32(tail, i) == 0x06054b50 && i + 22 + u16(tail, i + 20) == tail.size()) { + eocd = i; + break; + } + } + if (eocd < 0 || u16(tail, eocd + 4) || u16(tail, eocd + 6)) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引が不正、または分割アーカイブです。")); + quint64 count = u16(tail, eocd + 10); + quint64 directorySize = u32(tail, eocd + 12); + quint64 offset = u32(tail, eocd + 16); + if (count == 65535 || directorySize == 0xffffffff || offset == 0xffffffff) { + const qint64 locator = tailStart + eocd - 20; + if (locator < 0 || !file.seek(locator)) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIP64の索引位置が不正です。")); + const auto loc = file.read(20); + if (loc.size() != 20 || u32(loc, 0) != 0x07064b50 || u32(loc, 4) || u32(loc, 16) != 1) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIP64の索引位置が不正です。")); + const quint64 pos = u64(loc, 8); + if (pos > quint64(length - 56) || !file.seek(qint64(pos))) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIP64の索引が不正です。")); + const auto end = file.read(56); + if (end.size() != 56 || u32(end, 0) != 0x06064b50 || u32(end, 16) || u32(end, 20)) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIP64の索引が不正です。")); + count = u64(end, 32); + directorySize = u64(end, 40); + offset = u64(end, 48); + } + if (count > limits.maxEntries) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "アーカイブ内の項目数が上限を超えています。")); + if (offset > quint64(length) || directorySize > quint64(length) - offset || !file.seek(qint64(offset))) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引がファイルの範囲を超えています。")); + quint64 consumed = 0; + for (quint64 i = 0; i < count; ++i) { + const auto header = file.read(46); + if (header.size() != 46 || u32(header, 0) != 0x02014b50) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引が途中で切れています。")); + const quint16 nameSize = u16(header, 28), extraSize = u16(header, 30), commentSize = u16(header, 32); + if (!nameSize || nameSize > limits.maxPathBytes + 1) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内のパスが長さの上限を超えています。")); + const auto name = file.read(nameSize); + if (name.size() != nameSize || name.contains('\0')) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内のパスにNUL文字が含まれています。")); + const auto extra = file.read(extraSize); + if (extra.size() != extraSize) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの拡張フィールドが途中で切れています。")); + for (qsizetype p = 0; p < extra.size();) { + if (extra.size() - p < 4) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの拡張フィールドが不正です。")); + const quint16 tag = u16(extra, int(p)), size = u16(extra, int(p + 2)); + p += 4; + if (size > extra.size() - p) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの拡張フィールドが不正です。")); + // Unix and ASi Unix extensions can carry links beyond external attrs. + if ((tag == 0x000d && size > 12) || (tag == 0x756e && size > 14)) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内のリンクは許可されていません。")); + p += size; + } + consumed += 46ull + nameSize + extraSize + commentSize; + if (consumed > directorySize || !file.seek(file.pos() + commentSize)) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引の長さが不正です。")); + } + return true; +} +struct FileSource { + QPointer file; + zip_error_t error; + zip_uint64_t length = 0; + zip_uint64_t offset = 0; + std::shared_ptr meter; + FileSource(QFile *input, std::shared_ptr accounting) + : file(input), length(zip_uint64_t(input->size())), meter(std::move(accounting)) { zip_error_init(&error); } + ~FileSource() { zip_error_fini(&error); } +}; +zip_int64_t fileSourceCallback(void *context, void *data, zip_uint64_t length, zip_source_cmd_t command) { + auto &source = *static_cast(context); + const auto invalid = [&source](int code) -> zip_int64_t { zip_error_set(&source.error, code, 0); return -1; }; + switch (command) { + case ZIP_SOURCE_OPEN: + if (!source.file || !source.file->isOpen() || !source.file->isReadable() + || source.file->isWritable() || source.file->isSequential()) return invalid(ZIP_ER_READ); + source.offset = 0; + return 0; + case ZIP_SOURCE_READ: { + if (source.meter->active && source.meter->timer.elapsed() > 30000) { + source.meter->expired = true; + return invalid(ZIP_ER_READ); + } + if (!source.file || !source.file->isOpen() || source.offset > source.length + || !source.file->seek(qint64(source.offset))) return invalid(ZIP_ER_READ); + const auto amount = std::min({length, source.length - source.offset, + source.meter->active ? ArchiveInputMeter::MaximumRead : zip_uint64_t(std::numeric_limits::max())}); + const auto count = source.file->read(static_cast(data), qint64(amount)); + if (count < 0) return invalid(ZIP_ER_READ); + source.offset += zip_uint64_t(count); + if (source.meter->active) { + if (quint64(count) > std::numeric_limits::max() - source.meter->statistics.inputBytesRead) return invalid(ZIP_ER_INVAL); + source.meter->statistics.inputBytesRead += quint64(count); + source.meter->statistics.largestInputRead = std::max(source.meter->statistics.largestInputRead, quint64(count)); + } + return count; + } + case ZIP_SOURCE_STAT: { + if (length < sizeof(zip_stat_t)) return invalid(ZIP_ER_INVAL); + auto *stat = static_cast(data); + zip_stat_init(stat); stat->size = source.length; stat->valid = ZIP_STAT_SIZE; + return sizeof(zip_stat_t); + } + case ZIP_SOURCE_SEEK: { + const auto position = zip_source_seek_compute_offset(source.offset, source.length, data, length, &source.error); + if (position < 0) return -1; + source.offset = zip_uint64_t(position); + return 0; + } + case ZIP_SOURCE_TELL: return zip_int64_t(source.offset); + case ZIP_SOURCE_CLOSE: return 0; + case ZIP_SOURCE_ERROR: return zip_error_to_data(&source.error, data, length); + case ZIP_SOURCE_FREE: delete &source; return 0; + case ZIP_SOURCE_SUPPORTS: + return ZIP_SOURCE_SUPPORTS_SEEKABLE | ZIP_SOURCE_MAKE_COMMAND_BITMASK(ZIP_SOURCE_SUPPORTS_REOPEN) + | ZIP_SOURCE_MAKE_COMMAND_BITMASK(ZIP_SOURCE_ACCEPT_EMPTY); + case ZIP_SOURCE_ACCEPT_EMPTY: return 0; + default: return invalid(ZIP_ER_OPNOTSUPP); + } +} +QString tag(const QDomElement &e) { return e.localName().isEmpty() ? e.tagName().section(':', -1) : e.localName(); } +QList children(const QDomElement &e, const QString &name = {}) { + QList result; + for (auto n = e.firstChildElement(); !n.isNull(); n = n.nextSiblingElement()) + if (name.isEmpty() || tag(n) == name) result.push_back(n); + return result; +} +QList descendants(const QDomElement &e, const QString &name) { + QList result; + if (tag(e) == name) result.push_back(e); + for (const auto &c : children(e)) result.append(descendants(c, name)); + return result; +} +bool parseXml(const QByteArray &bytes, const ArchiveLimits &limits, QDomDocument *document, ArchiveError *error) { + if (quint64(bytes.size()) > limits.maxXmlBytes) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "文書構造のXMLがサイズの上限を超えています。")); + QXmlStreamReader reader(bytes); + QByteArray safeXml; + QXmlStreamWriter writer(&safeXml); + int depth = 0, elements = 0; + reader.setEntityExpansionLimit(1); + while (!reader.atEnd()) { + const auto token = reader.readNext(); + if (token == QXmlStreamReader::EntityReference) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "XMLの実体参照は許可されていません。")); + if (token == QXmlStreamReader::DTD) { + if (!reader.entityDeclarations().isEmpty() || !reader.notationDeclarations().isEmpty()) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "XMLの実体宣言は許可されていません。")); + // EPUB 2 NCX commonly declares an external DOCTYPE. The stream + // parser has no resolver and never retrieves it; omit the DTD + // before the DOM pass so no external subsets can be consulted. + continue; + } + if (token == QXmlStreamReader::StartElement && (++depth > limits.maxXmlDepth || ++elements > limits.maxXmlElements)) + return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "文書構造のXMLが階層数または要素数の上限を超えています。")); + if (token == QXmlStreamReader::EndElement) --depth; + if (token != QXmlStreamReader::Invalid) writer.writeCurrentToken(reader); + } + if (reader.hasError() || writer.hasError() || !document->setContent(safeXml, QDomDocument::ParseOption::UseNamespaceProcessing)) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "文書構造のXMLが不正です。")); + return true; +} +// Manifest/nav references are URLs, whereas ZIP entry names are never decoded. +QString resolveHref(const QString &base, const QString &reference) { + if (reference.isEmpty() || reference.size() > 4096 || reference.contains('\\')) return {}; + static const QRegularExpression badEscapes("%(?:00|2f|5c|25)", QRegularExpression::CaseInsensitiveOption); + if (badEscapes.match(reference).hasMatch()) return {}; + const QUrl ref(reference, QUrl::StrictMode); + if (!ref.isValid() || !ref.scheme().isEmpty() || !ref.host().isEmpty() || reference.startsWith('/')) return {}; + // Resolve dot segments explicitly: QUrl normalizes traversal above the root. + const auto relativePath = ref.path(QUrl::FullyDecoded); + QStringList parts = base.section('/', 0, -2).split('/', Qt::SkipEmptyParts); + if (!base.contains('/')) parts.clear(); + if (relativePath.isEmpty()) parts = base.split('/'); + else for (const QString &part : relativePath.split('/')) { + if (part.isEmpty() || part == ".") continue; + if (part == "..") { if (parts.isEmpty()) return {}; parts.removeLast(); } + else parts.append(part); + } + const auto path = parts.join('/'); + if (!ArchiveReader::validPath(path, false)) return {}; + return path + (ref.hasFragment() ? "#" + ref.fragment(QUrl::FullyDecoded) : QString()); +} +QString resourcePath(const QString &href) { return href.section('#', 0, 0); } +QString label(const QDomElement &e) { return e.text().simplified().left(4096); } +bool tokensContain(const QString &tokens, const QString &token) { + return tokens.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts).contains(token); +} +} + +QVariantMap ArchiveEntry::toVariant() const { + return {{"path", path}, {"size", QVariant::fromValue(size)}, {"mime", mime}, {"directory", directory}}; +} +QVariantMap ArchiveDocument::toVariant() const { + return {{"format", format}, {"title", title}, {"entry", entry}, {"packagePath", packagePath}, + {"candidates", candidates}, {"warnings", warnings}, {"renditions", renditions}, + {"spine", spine}, {"outline", outline}, {"pageList", pageList}, {"landmarks", landmarks}, + {"rtl", rtl}, {"fixed", fixed}, {"spread", spread}}; +} +ArchiveReader::ArchiveReader(ArchiveLimits limits) : limits_(limits) {} +ArchiveReader::~ArchiveReader() { if (archive_) zip_discard(archive_); } +ArchiveStreamStatistics ArchiveReader::lastStreamStatistics() const { + return inputMeter_ ? inputMeter_->statistics : ArchiveStreamStatistics{}; +} + +bool ArchiveReader::validPath(const QString &path, bool directory, const ArchiveLimits &limits) { + QString name = path; + if (directory && name.endsWith('/')) name.chop(1); + if (name.isEmpty() || name.startsWith('/') || name.contains('\\') || name.contains(':') || + name.contains(QChar::Null) || name.toUtf8().size() > limits.maxPathBytes) return false; + const auto parts = name.split('/'); + if (parts.size() > limits.maxDepth) return false; + static const QRegularExpression devices("^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\\.|$)", QRegularExpression::CaseInsensitiveOption); + for (const auto &p : parts) { + if (p.isEmpty() || p == "." || p == ".." || p.endsWith('.') || p.endsWith(' ') || devices.match(p).hasMatch()) return false; + for (const QChar c : p) if (c.unicode() < 32 || c.unicode() == 127 || c == '<' || c == '>' || c == '"' || c == '|' || c == '?' || c == '*') return false; + } + return true; +} + +bool ArchiveReader::open(const QString &source, ArchiveError *error) { + auto file = std::make_unique(source); + if (!file->open(QIODevice::ReadOnly)) return fail(error, "E_OPEN_FAILED", QCoreApplication::translate("Archive", "アーカイブを読み取れません。")); + if (!openFile(file.get(), error)) return false; + ownedSource_ = std::move(file); + return true; +} + +bool ArchiveReader::openFile(QFile *source, ArchiveError *error) { + if (archive_) zip_discard(archive_); + archive_ = nullptr; + ownedSource_.reset(); + inputMeter_ = std::make_shared(); + entries_.clear(); paths_.clear(); fontKeys_.clear(); extracted_.clear(); totalExtracted_ = 0; + if (!source || !source->isOpen() || !source->isReadable() || source->isWritable() || source->isSequential() || source->size() < 0) + return fail(error, "E_OPEN_FAILED", QCoreApplication::translate("Archive", "アーカイブには位置を指定して読み取れる読取り専用ファイルが必要です。")); + if (!checkRawDirectory(*source, limits_, error)) return false; + zip_error_t zipError; zip_error_init(&zipError); + auto *context = new FileSource(source, inputMeter_); + auto *zipSource = zip_source_function_create(fileSourceCallback, context, &zipError); + if (!zipSource) delete context; + else { + archive_ = zip_open_from_source(zipSource, ZIP_RDONLY | ZIP_CHECKCONS, &zipError); + if (!archive_) zip_source_free(zipSource); + } + zip_error_fini(&zipError); + if (!archive_) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "ZIPの索引に不整合があります。")); + const auto count = zip_get_num_entries(archive_, 0); + if (count < 0 || quint64(count) > limits_.maxEntries) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "アーカイブ内の項目数が上限を超えています。")); + QHash allNames; + QSet files, explicitNames; + quint64 total = 0; + for (zip_int64_t i = 0; i < count; ++i) { + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive_, zip_uint64_t(i), ZIP_FL_ENC_GUESS, &stat) < 0 || !stat.name || + !(stat.valid & ZIP_STAT_SIZE) || !(stat.valid & ZIP_STAT_COMP_SIZE) || !(stat.valid & ZIP_STAT_ENCRYPTION_METHOD)) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の項目情報が不正です。")); + const QByteArray utf8(stat.name); + const QString path = QString::fromUtf8(utf8); + const bool directory = path.endsWith('/'); + if (path.toUtf8() != utf8 || !validPath(path, directory, limits_)) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内の項目のパスが安全ではありません。")); + zip_uint8_t system = 0; zip_uint32_t attrs = 0; + if (zip_file_get_external_attributes(archive_, zip_uint64_t(i), 0, &system, &attrs) < 0) + return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の属性を読み取れません。")); + const auto kind = (attrs >> 16) & 0170000; + if ((kind && kind != 0100000 && kind != 0040000) || (kind == 0040000 && !directory) || + (kind == 0100000 && directory) || ((attrs & 0x10) && !directory) || (attrs & 0x400)) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内のリンクや特殊ファイルは許可されていません。")); + if (stat.encryption_method != ZIP_EM_NONE) + return fail(error, "E_ARCHIVE_ENCRYPTED", QCoreApplication::translate("Archive", "パスワードで保護されたアーカイブには対応していません。")); + if (stat.size > limits_.maxEntryBytes || total > limits_.maxTotalBytes || stat.size > limits_.maxTotalBytes - total || + (stat.size > limits_.ratioThreshold && (stat.comp_size == 0 || stat.size / stat.comp_size > limits_.maxRatio || + (stat.size / stat.comp_size == limits_.maxRatio && stat.size % stat.comp_size != 0)))) + return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "アーカイブの展開量が上限を超えています。")); + if (directory && stat.size) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内のディレクトリーにファイルデータがあります。")); + total += stat.size; + const QString clean = directory ? path.left(path.size() - 1) : path; + const auto key = collisionKey(clean); + if (explicitNames.contains(key) || (!directory && allNames.contains(key))) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内の項目名が重複しています。")); + explicitNames.insert(key); + const auto parts = clean.split('/'); + QString prefix; + for (qsizetype n = 0; n < parts.size(); ++n) { + if (!prefix.isEmpty()) prefix += '/'; + prefix += parts[n]; + const auto prefixKey = collisionKey(prefix); + if ((allNames.contains(prefixKey) && allNames[prefixKey] != prefix) || + (n + 1 < parts.size() && files.contains(prefixKey)) || (directory && files.contains(prefixKey))) + return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "アーカイブ内のファイル名とディレクトリー名が重複しています。")); + allNames[prefixKey] = prefix; + } + if (!directory) files.insert(key); + paths_[path] = entries_.size(); + entries_.append({path, stat.size, stat.comp_size, quint64(i), mimeFor(path), directory}); + } + return true; +} + +bool ArchiveReader::stream(const ArchiveEntry &entry, quint64 limit, + const std::function &write, ArchiveError *error) { + if (!archive_ || entry.directory) return fail(error, "E_RESOURCE_MISSING", QCoreApplication::translate("Archive", "アーカイブ内の資源を利用できません。")); + if (entry.size > limit) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "アーカイブ内の資源がサイズの上限を超えています。")); + if (!inputMeter_ || inputMeter_->active) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の資源は同時に複数展開できません。")); + struct Measurement { + ArchiveInputMeter &meter; + explicit Measurement(ArchiveInputMeter &m) : meter(m) { + meter.statistics = {}; meter.expired = false; meter.timer.start(); meter.active = true; + } + ~Measurement() { meter.active = false; } + } measurement(*inputMeter_); + // Start before open: some codecs can read their headers/payload on open. + // Keep all earlier reads when output later crosses the 32 MiB threshold. + zip_file_t *file = zip_fopen_index(archive_, entry.index, 0); + inputMeter_->statistics.inputBytesDuringOpen = inputMeter_->statistics.inputBytesRead; + if (!file) return inputMeter_->expired + ? fail(error, "E_WORKER_TIMEOUT", QCoreApplication::translate("Archive", "資源の展開が時間制限を超えました。")) + : fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の資源を展開できません。")); + QByteArray buffer(64 * 1024, Qt::Uninitialized); + quint64 actual = 0; + bool ok = true; + for (;;) { + const auto n = zip_fread(file, buffer.data(), zip_uint64_t(buffer.size())); + if (inputMeter_->expired || inputMeter_->timer.elapsed() > 30000) { + ok = fail(error, "E_WORKER_TIMEOUT", QCoreApplication::translate("Archive", "資源の展開が時間制限を超えました。")); break; + } + if (n < 0) { ok = fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の資源のCRCまたは展開結果の検証に失敗しました。")); break; } + if (!n) break; + if (quint64(n) > limit - actual || quint64(n) > entry.size - std::min(entry.size, actual)) { + ok = fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "展開した資源が宣言されたサイズまたは上限を超えています。")); break; + } + const auto output = actual + quint64(n); + const auto input = inputMeter_->statistics.inputBytesRead; + inputMeter_->statistics.outputBytes = output; + if (output > limits_.ratioThreshold && (!input || output / input > limits_.maxRatio || + (output / input == limits_.maxRatio && output % input))) { + ok = fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "資源の実読取量に対する展開量が倍率の上限を超えています。")); break; + } + const auto key = fontKeys_.value(entry.path); + if (!key.isEmpty() && actual < 1040) { + for (quint64 k = 0; k < quint64(n) && actual + k < 1040; ++k) + buffer[qsizetype(k)] = char(uchar(buffer[qsizetype(k)]) ^ uchar(key[qsizetype((actual + k) % quint64(key.size()))])); + } + if (!write(buffer.constData(), qsizetype(n))) { ok = fail(error, "E_STORAGE_FULL", QCoreApplication::translate("Archive", "資源を一時保存先へ書き込めません。")); break; } + actual += quint64(n); + } + if (zip_fclose(file) < 0 && ok) ok = fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "アーカイブ内の資源の検証に失敗しました。")); + if (ok && actual != entry.size) return fail(error, "E_ARCHIVE_CORRUPT", QCoreApplication::translate("Archive", "展開した資源の長さが一致しません。")); + return ok; +} + +bool ArchiveReader::read(const QString &path, quint64 limit, QByteArray *bytes, ArchiveError *error) { + bytes->clear(); + const auto found = paths_.constFind(path); + if (found == paths_.cend()) return fail(error, "E_RESOURCE_MISSING", QCoreApplication::translate("Archive", "アーカイブ内の資源が見つかりません。")); + const auto &entry = entries_[*found]; + const bool ok = stream(entry, std::min(limit, limits_.maxEntryBytes), [bytes](const char *p, qsizetype n) { bytes->append(p, n); return true; }, error); + if (!ok) bytes->clear(); + return ok; +} + +bool ArchiveReader::extract(const QString &path, const QString &outputRoot, ArchiveError *error) { + const auto found = paths_.constFind(path); + if (found == paths_.cend() || entries_[*found].directory) return fail(error, "E_RESOURCE_MISSING", QCoreApplication::translate("Archive", "アーカイブ内の資源が見つかりません。")); + if (extracted_.contains(path)) return true; + const auto &entry = entries_[*found]; + if (entry.size > limits_.maxTotalBytes - totalExtracted_) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "文書全体の展開量が上限を超えています。")); +#ifdef Q_OS_UNIX + int directory = ::open(QFile::encodeName(outputRoot).constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (directory < 0) return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "一時資源の保存ルートを利用できません。")); + const auto components = path.split('/'); + for (qsizetype i = 0; i + 1 < components.size(); ++i) { + const auto name = components[i].toUtf8(); + if (::mkdirat(directory, name.constData(), 0700) < 0 && errno != EEXIST) { + ::close(directory); return fail(error, "E_STORAGE_FULL", QCoreApplication::translate("Archive", "一時資源のディレクトリーを作成できません。")); + } + const int next = ::openat(directory, name.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + ::close(directory); directory = next; + if (directory < 0) return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "一時資源のディレクトリーが安全ではありません。")); + } + const auto target = components.last().toUtf8(); + // The random private session root is never exposed to document code. O_EXCL + // and NOFOLLOW stop pre-existing names, links, and cross-session overwrites. + const int fd = ::openat(directory, target.constData(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd < 0) { ::close(directory); return fail(error, "E_ARCHIVE_UNSAFE_PATH", QCoreApplication::translate("Archive", "一時資源の名前が使用済み、またはファイルを作成できません。")); } + const bool ok = stream(entry, limits_.maxEntryBytes, [fd](const char *data, qsizetype size) { + qsizetype offset = 0; + while (offset < size) { const auto n = ::write(fd, data + offset, size_t(size - offset)); if (n < 0 && errno == EINTR) continue; if (n <= 0) return false; offset += n; } + return true; + }, error); + ::close(fd); + if (!ok) ::unlinkat(directory, target.constData(), 0); + ::close(directory); + if (!ok) return false; +#else + Q_UNUSED(outputRoot); + return fail(error, "E_SANDBOX_UNAVAILABLE", QCoreApplication::translate("Archive", "この環境では安全なアーカイブ展開を利用できません。")); +#endif + totalExtracted_ += entry.size; + extracted_.insert(path, entry.size); + return true; +} + +bool ArchiveReader::extractTo(const QString &path, const std::function &sink, ArchiveError *error) { + const auto found = paths_.constFind(path); + if (found == paths_.cend() || entries_[*found].directory) return fail(error, "E_RESOURCE_MISSING", QCoreApplication::translate("Archive", "アーカイブ内の資源が見つかりません。")); + const auto &entry = entries_[*found]; + if (entry.size > limits_.maxTotalBytes - totalExtracted_) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "文書全体の展開量が上限を超えています。")); + if (!stream(entry, limits_.maxEntryBytes, sink, error)) return false; + totalExtracted_ += entry.size; + return true; +} + +bool ArchiveReader::describe(bool epubExpected, ArchiveDocument *document, ArchiveError *error) { + *document = {}; + if (!archive_) return fail(error, "E_OPEN_FAILED", QCoreApplication::translate("Archive", "アーカイブが開かれていません。")); + QByteArray mimetype; + if (paths_.contains("mimetype") && !read("mimetype", 256, &mimetype, error)) return false; + if (epubExpected || mimetype == "application/epub+zip" || paths_.contains("META-INF/container.xml")) + return describeEpub(document, error); + document->format = "htmlzip"; + QStringList filePaths; + for (const auto &entry : entries_) if (!entry.directory) { + filePaths.append(entry.path); + if (entry.mime == "text/html" || entry.mime == "application/xhtml+xml") document->candidates.append(entry.path); + } + if (document->candidates.isEmpty()) return fail(error, "E_HTML_ENTRY_MISSING", QCoreApplication::translate("Archive", "アーカイブ内に入口となるHTMLがありません。")); + if (document->candidates.contains("index.html")) document->entry = "index.html"; + else { + const auto top = filePaths.first().section('/', 0, 0); + const bool common = std::all_of(filePaths.cbegin(), filePaths.cend(), [&top](const auto &path) { return path.startsWith(top + '/'); }); + if (common && document->candidates.contains(top + "/index.html")) document->entry = top + "/index.html"; + } + document->title = document->entry.isEmpty() ? "HTML archive" : QFileInfo(document->entry).fileName(); + return true; +} + +bool ArchiveReader::describeEpub(ArchiveDocument *document, ArchiveError *error) { + QByteArray bytes; + if (!read("mimetype", 256, &bytes, error) || bytes != "application/epub+zip") + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのメディア型が不明、または不正です。")); + auto xml = [&](const QString &path, QDomDocument *dom) { + return read(path, limits_.maxXmlBytes, &bytes, error) && parseXml(bytes, limits_, dom, error); + }; + QDomDocument container; + if (!xml("META-INF/container.xml", &container)) return false; + if (tag(container.documentElement()) != "container") return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのコンテナーのルートが不正です。")); + const auto rootfiles = descendants(container.documentElement(), "rootfile"); + if (rootfiles.isEmpty()) return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのコンテナーにパッケージがありません。")); + if (quint64(rootfiles.size()) > limits_.maxEntries) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "EPUBの表示版の数が上限を超えています。")); + for (const auto &r : rootfiles) { + const QString p = r.attribute("full-path"); + if (!validPath(p, false, limits_)) return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのパッケージのパスが不正です。")); + document->renditions.append(p); + } + document->packagePath = document->renditions.first(); + QDomDocument package; + if (!xml(document->packagePath, &package)) return false; + const auto root = package.documentElement(); + const QString version = root.attribute("version"); + if (tag(root) != "package" || (!version.startsWith("2.") && !version.startsWith("3."))) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "この版のEPUBパッケージには対応していません。")); + const auto manifests = children(root, "manifest"), spines = children(root, "spine"), metadata = children(root, "metadata"); + if (manifests.size() != 1 || spines.size() != 1 || metadata.size() != 1) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのパッケージ構造に不足があります。")); + struct Item { QString id, href, type, properties, fallback; }; + QHash items; + QString navPath, uniqueIdentifier; + for (const auto &m : children(metadata.first())) { + if (tag(m) == "title" && document->title.isEmpty()) document->title = label(m); + if (tag(m) == "identifier" && m.attribute("id") == root.attribute("unique-identifier")) uniqueIdentifier = m.text(); + if (tag(m) == "meta" && m.attribute("property") == "rendition:layout") document->fixed = label(m) == "pre-paginated"; + if (tag(m) == "meta" && m.attribute("property") == "rendition:spread") { + const auto value = label(m); + if (QStringList{"auto", "both", "none", "landscape"}.contains(value)) document->spread = value; + else if (value == "portrait") document->spread = "both"; // EPUB 3.0 deprecated value. + } + } + if (document->title.isEmpty()) document->title = "EPUB"; + for (const auto &m : children(manifests.first(), "item")) { + Item item{m.attribute("id"), resolveHref(document->packagePath, m.attribute("href")), m.attribute("media-type"), m.attribute("properties"), m.attribute("fallback")}; + if (item.id.isEmpty() || item.id.size() > 1024 || items.contains(item.id)) + return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBのmanifestに重複または不正な識別子があります。")); + items.insert(item.id, item); + if (tokensContain(item.properties, "nav") && navPath.isEmpty()) navPath = resourcePath(item.href); + if (!item.href.isEmpty() && paths_.contains(resourcePath(item.href))) { + // EPUB media types, rather than filename extensions, identify + // extensionless resources. Only the static rendering whitelist is + // exported; no arbitrary type is trusted by the resource broker. + static const QSet supportedTypes{"application/xhtml+xml", "text/html", "text/css", "image/svg+xml", "image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "font/ttf", "font/otf", "font/woff", "font/woff2"}; + QString mime = item.type; + if (mime == "application/vnd.ms-opentype" || mime == "application/font-sfnt") mime = "font/otf"; + if (mime == "application/font-woff") mime = "font/woff"; + if (supportedTypes.contains(mime)) entries_[paths_[resourcePath(item.href)]].mime = mime; + } + if (m.hasAttribute("media-overlay") || tokensContain(item.properties, "scripted") || item.type.startsWith("audio/") || item.type.startsWith("video/")) + if (!document->warnings.contains("E_EPUB_STATIC_ONLY")) document->warnings.append("E_EPUB_STATIC_ONLY"); + } + document->rtl = spines.first().attribute("page-progression-direction") == "rtl"; + bool readable = false; + const auto itemrefs = children(spines.first(), "itemref"); + if (quint64(itemrefs.size()) > limits_.maxEntries) return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "EPUBの読書順序の項目数が上限を超えています。")); + for (const auto &reference : itemrefs) { + const QString idref = reference.attribute("idref"); + if (idref.isEmpty() || idref.size() > 1024) return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBの読書順序の識別子が不正です。")); + QString id = idref; + QSet visited; + Item item; + bool supported = false; + while (!id.isEmpty() && !visited.contains(id) && visited.size() < 64 && items.contains(id)) { + visited.insert(id); item = items[id]; + if ((item.type == "application/xhtml+xml" || item.type == "image/svg+xml" || item.type == "text/html") && + !item.href.isEmpty() && paths_.contains(resourcePath(item.href))) { supported = true; break; } + id = item.fallback; + } + bool fixed = document->fixed; + const auto properties = reference.attribute("properties"); + if (tokensContain(properties, "rendition:layout-pre-paginated")) fixed = true; + if (tokensContain(properties, "rendition:layout-reflowable")) fixed = false; + const bool linear = reference.attribute("linear") != "no"; + QString spread; + if ((tokensContain(properties, "page-spread-left") || tokensContain(properties, "rendition:page-spread-left"))) spread = "left"; + if ((tokensContain(properties, "page-spread-right") || tokensContain(properties, "rendition:page-spread-right"))) spread = "right"; + if (tokensContain(properties, "rendition:page-spread-center")) spread = "center"; + QString renditionSpread = document->spread; + for (const auto &value : QStringList{"auto", "both", "landscape", "none"}) + if (tokensContain(properties, "rendition:spread-" + value)) renditionSpread = value; + if (tokensContain(properties, "rendition:spread-portrait")) renditionSpread = "both"; + document->spine.append(QVariantMap{{"idref", idref}, {"href", supported ? item.href : QString()}, {"title", idref.left(4096)}, + {"linear", linear}, {"layout", fixed ? "fixed" : "reflowable"}, {"spread", spread}, + {"renditionSpread", renditionSpread}, {"missing", !supported}}); + if (supported) { + readable = true; + if (document->entry.isEmpty() && linear) document->entry = resourcePath(item.href); + } else document->warnings.append("E_EPUB_SPINE_MISSING"); + } + if (document->spine.isEmpty() || !readable) return fail(error, "E_EPUB_PACKAGE_INVALID", QCoreApplication::translate("Archive", "EPUBの読書順序に表示できる本文がありません。")); + if (document->entry.isEmpty()) for (const auto &s : document->spine) if (!s.toMap()["missing"].toBool()) { document->entry = resourcePath(s.toMap()["href"].toString()); break; } + if (paths_.contains("META-INF/encryption.xml")) { + QDomDocument encryption; + if (!xml("META-INF/encryption.xml", &encryption)) return false; + for (const auto &encrypted : descendants(encryption.documentElement(), "EncryptedData")) { + const auto methods = descendants(encrypted, "EncryptionMethod"), references = descendants(encrypted, "CipherReference"); + if (methods.size() != 1 || methods.first().attribute("Algorithm") != "http://www.idpf.org/2008/embedding" || references.size() != 1) + return fail(error, "E_DRM_UNSUPPORTED", QCoreApplication::translate("Archive", "このEPUBの暗号化方式には対応していません。")); + const QString path = resourcePath(resolveHref("encryption.xml", references.first().attribute("URI"))); + bool font = false; + for (const auto &item : items) if (resourcePath(item.href) == path && (item.type.startsWith("font/") || item.type == "application/vnd.ms-opentype" || item.type == "application/font-sfnt" || item.type == "application/font-woff")) font = true; + if (!font || !paths_.contains(path) || uniqueIdentifier.isEmpty()) return fail(error, "E_DRM_UNSUPPORTED", QCoreApplication::translate("Archive", "EPUBの標準的なフォント難読化のみ対応しています。")); + uniqueIdentifier.remove(QRegularExpression("[\\x20\\x09\\x0d\\x0a]")); + fontKeys_[path] = QCryptographicHash::hash(uniqueIdentifier.toUtf8(), QCryptographicHash::Sha1); + } + } + auto outlineNode = [&](const QString &title, const QString &href, int depth, const QString &source) -> QVariant { + const QString resolved = resolveHref(source, href); + const bool valid = !resolved.isEmpty() && paths_.contains(resourcePath(resolved)); + return QVariantMap{{"title", title.left(4096)}, {"href", valid ? resolved : QString()}, {"depth", depth}, {"missing", !valid}}; + }; + if (!navPath.isEmpty()) { + QDomDocument nav; + ArchiveError saved; + if (error) saved = *error; + if (xml(navPath, &nav)) { + for (const auto &navigation : descendants(nav.documentElement(), "nav")) { + QString type = navigation.attributeNS("http://www.idpf.org/2007/ops", "type"); + if (type.isEmpty()) type = navigation.attribute("epub:type"); + QVariantList *list = tokensContain(type, "toc") ? &document->outline : tokensContain(type, "page-list") ? &document->pageList : tokensContain(type, "landmarks") ? &document->landmarks : nullptr; + if (!list) continue; + std::function traverse = [&](QDomElement element, int depth) { + for (const auto &c : children(element)) { + const auto name = tag(c); + if (name == "a" || (name == "span" && tag(element) == "li")) list->append(outlineNode(label(c), c.attribute("href"), std::max(0, depth - 1), navPath)); + else traverse(c, depth + (name == "ol" ? 1 : 0)); + } + }; + traverse(navigation, 0); + } + } else if (error && error->code == "E_ARCHIVE_LIMIT") return false; + if (document->outline.isEmpty()) document->warnings.append("E_EPUB_NAV_INVALID"); + if (error) *error = saved; + } + if (document->outline.isEmpty()) { + QString ncxPath = resourcePath(items.value(spines.first().attribute("toc")).href); + if (ncxPath.isEmpty()) for (const auto &item : items) if (item.type == "application/x-dtbncx+xml") { ncxPath = resourcePath(item.href); break; } + if (!ncxPath.isEmpty()) { + QDomDocument ncx; + ArchiveError saved; if (error) saved = *error; + if (xml(ncxPath, &ncx)) { + std::function traverse = [&](QDomElement e, int depth) { + for (const auto &point : children(e, "navPoint")) { + const auto labels = children(point, "navLabel"), content = children(point, "content"); + document->outline.append(outlineNode(labels.isEmpty() ? QString() : label(labels.first()), content.isEmpty() ? QString() : content.first().attribute("src"), depth, ncxPath)); + traverse(point, depth + 1); + } + }; + for (const auto &map : descendants(ncx.documentElement(), "navMap")) traverse(map, 0); + } else if (error && error->code == "E_ARCHIVE_LIMIT") return false; + if (error) *error = saved; + } + } + if (document->outline.isEmpty()) { + document->warnings.append("E_EPUB_TOC_GENERATED"); + for (const auto &s : document->spine) { auto m = s.toMap(); m["depth"] = 0; m["source"] = "spine"; document->outline.append(m); } + } + document->format = "epub"; + if (quint64(document->outline.size()) > limits_.maxEntries || quint64(document->pageList.size()) > limits_.maxEntries || quint64(document->landmarks.size()) > limits_.maxEntries) + return fail(error, "E_ARCHIVE_LIMIT", QCoreApplication::translate("Archive", "EPUBのナビゲーション索引が項目数の上限を超えています。")); + document->warnings.removeDuplicates(); + return true; +} +} diff --git a/src/archive/archive.h b/src/archive/archive.h new file mode 100644 index 0000000..a0e062f --- /dev/null +++ b/src/archive/archive.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct zip; +class QFile; + +namespace docview { + +struct ArchiveLimits { + quint64 maxEntries = 20000; + quint64 maxEntryBytes = 256ull * 1024 * 1024; + quint64 maxTotalBytes = 2ull * 1024 * 1024 * 1024; + quint64 maxXmlBytes = 16ull * 1024 * 1024; + int maxPathBytes = 1024; + int maxDepth = 64; + int maxXmlDepth = 128; + int maxXmlElements = 200000; + quint64 ratioThreshold = 32ull * 1024 * 1024; + quint64 maxRatio = 200; +}; + +struct ArchiveError { QString code; QString message; }; +// Counts bytes returned by the archive's bounded source, including any reads +// during zip_fopen. This is not a codec-internal consumed-bit counter. +struct ArchiveStreamStatistics { + quint64 inputBytesRead = 0; + quint64 inputBytesDuringOpen = 0; + quint64 largestInputRead = 0; + quint64 outputBytes = 0; +}; +struct ArchiveInputMeter; +struct ArchiveEntry { + QString path; + quint64 size = 0; + quint64 compressedSize = 0; + quint64 index = 0; + QString mime; + bool directory = false; + QVariantMap toVariant() const; +}; + +struct ArchiveDocument { + QString format; + QString title; + QString entry; + QString packagePath; + QString spread = "auto"; + QStringList candidates; + QStringList warnings; + QStringList renditions; + QVariantList spine; + QVariantList outline; + QVariantList pageList; + QVariantList landmarks; + bool rtl = false; + bool fixed = false; + QVariantMap toVariant() const; +}; + +// This parser is linked only into ArchiveWorker and isolated unit tests. +class ArchiveReader { +public: + explicit ArchiveReader(ArchiveLimits limits = {}); + ~ArchiveReader(); + ArchiveReader(const ArchiveReader &) = delete; + ArchiveReader &operator=(const ArchiveReader &) = delete; + bool open(const QString &source, ArchiveError *error); + // Borrows a seekable, read-only QFile. Keep it open for this reader's + // lifetime; neither index validation nor libzip reopens its file name. + bool openFile(QFile *source, ArchiveError *error); + bool describe(bool epubExpected, ArchiveDocument *document, ArchiveError *error); + bool extract(const QString &path, const QString &outputRoot, ArchiveError *error); + // The sink receives provisional bytes. Publish only after this returns + // true: libzip validates the unmodified archive CRC at end of stream. + bool extractTo(const QString &path, const std::function &sink, ArchiveError *error); + bool read(const QString &path, quint64 limit, QByteArray *bytes, ArchiveError *error); + const QList &entries() const { return entries_; } + ArchiveStreamStatistics lastStreamStatistics() const; + static bool validPath(const QString &path, bool directory, const ArchiveLimits &limits = {}); +private: + bool stream(const ArchiveEntry &entry, quint64 limit, + const std::function &write, ArchiveError *error); + bool describeEpub(ArchiveDocument *document, ArchiveError *error); + ArchiveLimits limits_; + zip *archive_ = nullptr; + std::unique_ptr ownedSource_; + std::shared_ptr inputMeter_; + QList entries_; + QHash paths_; + QHash fontKeys_; + QHash extracted_; + quint64 totalExtracted_ = 0; +}; +} diff --git a/src/archive/main.cpp b/src/archive/main.cpp new file mode 100644 index 0000000..da6ce98 --- /dev/null +++ b/src/archive/main.cpp @@ -0,0 +1,151 @@ +#include "archive.h" +#include "common/ipc.h" +#include "common/worker_runtime.h" +#include +#include +#include +#include +#include + +using namespace docview; + +namespace { +QCborMap replyFor(const QCborMap &request) { + QCborMap reply; + for (const auto *field : {"protocolVersion", "requestId", "sessionId", "generation", "operation"}) + reply.insert(QString::fromLatin1(field), request.value(QString::fromLatin1(field))); + return reply; +} +QCborArray entryPage(const ArchiveReader &reader, qsizetype offset, qsizetype count) { + QCborArray page; + for (qsizetype i = offset; i < std::min(reader.entries().size(), offset + count); ++i) + page.append(QCborValue::fromVariant(reader.entries()[i].toVariant())); + return page; +} +QCborArray metadataPage(const QVariantList &items, qsizetype offset, qsizetype count, qsizetype byteBudget) { + QCborArray page; + qsizetype bytes = 8; + for (qsizetype i = offset; i < std::min(items.size(), offset + count); ++i) { + const auto value = QCborValue::fromVariant(items[i]); + const auto size = value.toCbor().size(); + if (size > byteBudget - bytes) break; + page.append(value); bytes += size; + } + return page; +} +} + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + QCommandLineParser parser; + addWorkerOptions(parser); + parser.addOption(QCommandLineOption("output", "Private resource root", "path")); + parser.process(app); + const auto source = parser.value("source"); + QString sandboxError; + // The parser has no filesystem write authority. The Main broker receives + // bounded provisional chunks and alone commits verified resources. + auto runtime = startWorkerRuntime(parser, {}, &sandboxError); + if (!runtime) return 5; + IpcChannel channel(runtime->transport.get()); + QObject::connect(&channel, &IpcChannel::protocolError, &app, [&app](const QString &) { app.exit(6); }); + ArchiveReader reader; + ArchiveDocument document; + bool opened = false; + bool handling = false; + QString activeSession; + qint64 activeGeneration = -1; + QObject::connect(&channel, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { + if (handling) { app.exit(6); return; } + QScopedValueRollback guard(handling, true); + auto reply = replyFor(request); + const auto operation = request.value(QStringLiteral("operation")).toString(); + const auto payload = request.value(QStringLiteral("payload")).toMap(); + ArchiveError error; + QCborMap result; + bool ok = true; + if (opened && (request.value(QStringLiteral("sessionId")).toString() != activeSession || request.value(QStringLiteral("generation")).toInteger() != activeGeneration)) { + ok = false; error = {"E_PROTOCOL_INVALID", QCoreApplication::translate("ArchiveWorker", "アーカイブの要求が別の文書に属しています。")}; + } else if (operation == "open" && !opened) { + const bool epub = source.endsWith(".epub", Qt::CaseInsensitive) || payload.value(QStringLiteral("format")).toString() == "epub" + || payload.value(QStringLiteral("epub")).toBool(); + ok = reader.openFile(&runtime->source, &error) && reader.describe(epub, &document, &error); + if (ok) { + opened = true; + activeSession = request.value(QStringLiteral("sessionId")).toString(); + activeGeneration = request.value(QStringLiteral("generation")).toInteger(); + auto metadata = document.toVariant(); + QVariantMap counts; + for (const auto *name : {"spine", "outline", "pageList", "landmarks", "candidates", "renditions"}) { + const QString key = QString::fromLatin1(name); + const auto list = metadata[key].toList(); + counts[key] = list.size(); + metadata[key] = metadataPage(list, 0, 32, 48 * 1024).toVariantList(); + } + metadata["metadataCounts"] = counts; + result = QCborValue::fromVariant(metadata).toMap(); + result.insert(QStringLiteral("entries"), entryPage(reader, 0, 64)); + result.insert(QStringLiteral("entryCount"), reader.entries().size()); + result.insert(QStringLiteral("nextOffset"), std::min(64, reader.entries().size())); + } + } else if (operation == "entries" && opened) { + const auto offset = payload.value(QStringLiteral("offset")).toInteger(-1); + const auto count = payload.value(QStringLiteral("count")).toInteger(128); + if (offset < 0 || offset > reader.entries().size() || count < 1 || count > 128) { + ok = false; error = {"E_PROTOCOL_INVALID", QCoreApplication::translate("ArchiveWorker", "アーカイブの索引範囲が不正です。")}; + } else { + result.insert(QStringLiteral("entries"), entryPage(reader, offset, count)); + result.insert(QStringLiteral("nextOffset"), std::min(offset + count, reader.entries().size())); + result.insert(QStringLiteral("total"), reader.entries().size()); + } + } else if (operation == "metadata" && opened) { + const auto kind = payload.value(QStringLiteral("kind")).toString(); + const auto offset = payload.value(QStringLiteral("offset")).toInteger(-1); + const auto count = payload.value(QStringLiteral("count")).toInteger(128); + const QStringList allowed{"spine", "outline", "pageList", "landmarks", "candidates", "renditions"}; + const auto values = document.toVariant()[kind].toList(); + if (!allowed.contains(kind) || offset < 0 || offset > values.size() || count < 1 || count > 128) { + ok = false; error = {"E_PROTOCOL_INVALID", QCoreApplication::translate("ArchiveWorker", "アーカイブの項目情報の範囲が不正です。")}; + } else { + result.insert(QStringLiteral("kind"), kind); + const auto page = metadataPage(values, offset, count, 512 * 1024); + result.insert(QStringLiteral("items"), page); + result.insert(QStringLiteral("nextOffset"), offset + page.size()); + result.insert(QStringLiteral("total"), values.size()); + } + } else if (operation == "extract" && opened) { + const auto path = payload.value(QStringLiteral("path")).toString(); + quint64 actual = 0; + bool transportFailed = false; + ok = reader.extractTo(path, [&](const char *data, qsizetype length) { + auto chunk = replyFor(request); + chunk.insert(QStringLiteral("result"), QCborMap{{QStringLiteral("path"), path}, + {QStringLiteral("data"), QByteArray(data, length)}, {QStringLiteral("more"), true}}); + if (!channel.send(chunk)) { transportFailed = true; return false; } + if (!runtime->flushWrites(128 * 1024)) { transportFailed = true; return false; } + actual += quint64(length); + return true; + }, &error); + if (transportFailed) { app.exit(7); return; } + if (ok) { + result.insert(QStringLiteral("path"), path); + result.insert(QStringLiteral("size"), qint64(actual)); + result.insert(QStringLiteral("more"), false); + } + } else if (operation == "ping") { + result.insert(QStringLiteral("ready"), true); + } else if (operation == "close") { + result.insert(QStringLiteral("closed"), true); + } else { + ok = false; error = {"E_PROTOCOL_INVALID", QCoreApplication::translate("ArchiveWorker", "このアーカイブ操作は利用できません。")}; + } + if (ok) reply.insert(QStringLiteral("result"), result); + else reply.insert(QStringLiteral("error"), QCborMap{{QStringLiteral("code"), error.code}, {QStringLiteral("message"), error.message}}); + if (!channel.send(reply)) app.exit(7); + if (operation == "close") { + if (!runtime->flushWrites()) app.exit(7); + else QTimer::singleShot(0, &app, &QCoreApplication::quit); + } + }); + return app.exec(); +} diff --git a/src/broker/font_backend.cpp b/src/broker/font_backend.cpp new file mode 100644 index 0000000..635bca7 --- /dev/null +++ b/src/broker/font_backend.cpp @@ -0,0 +1,55 @@ +#include "font_backend.h" + +namespace docview::fontbackend { +QString normalizedFamily(QString value) { + value = value.toCaseFolded(); + value.remove(' '); value.remove('-'); value.remove('_'); + if (value.startsWith('@')) value.remove(0, 1); + return value; +} +QString charsetLanguage(int charset) { + switch (charset) { + case 0: return "en"; + case 128: return "ja"; + case 129: return "ko"; + case 134: return "zh-cn"; + case 136: return "zh-tw"; + case 161: return "el"; + case 163: return "vi"; + case 177: return "he"; + case 178: return "ar"; + case 204: return "ru"; + case 222: return "th"; + case 238: return "pl"; + default: return {}; + } +} +QStringList familyCandidates(const QString &requested, int charset, int pitchFamily) { + const auto name = normalizedFamily(requested); + QStringList result; + if (!requested.isEmpty()) result << requested; + // Symbol fonts require their own cmap. Never turn them into generic sans. + if (charset == 2 || name == "symbol" || name == "zapfdingbats") return result; + const bool fixed = (pitchFamily & 3) == 1 || name.startsWith("courier"); + const bool serif = (pitchFamily & 0xf0) == 0x10 || name.startsWith("times") || + name.contains("mincho") || name.contains(QString::fromUtf8("明朝")); + if (charset == 128) { + if (serif) result << "Yu Mincho" << "MS PMincho" << "Noto Serif CJK JP" << "IPAexMincho" << "IPAMincho"; + else result << "Yu Gothic" << "Meiryo" << "MS PGothic" << "Noto Sans CJK JP" << "BIZ UDPGothic" << "IPAexGothic"; + } else if (charset == 134) { + result << (serif ? "SimSun" : "Microsoft YaHei") << (serif ? "Noto Serif CJK SC" : "Noto Sans CJK SC"); + } else if (charset == 136) { + result << (serif ? "MingLiU" : "Microsoft JhengHei") << (serif ? "Noto Serif CJK TC" : "Noto Sans CJK TC"); + } else if (charset == 129) { + result << (serif ? "Batang" : "Malgun Gothic") << (serif ? "Noto Serif CJK KR" : "Noto Sans CJK KR"); + } else if (fixed) { + result << "Courier New" << "Liberation Mono" << "Nimbus Mono PS" << "DejaVu Sans Mono"; + } else if (serif) { + result << "Times New Roman" << "Liberation Serif" << "Nimbus Roman" << "DejaVu Serif"; + } else { + result << "Arial" << "Liberation Sans" << "Nimbus Sans" << "DejaVu Sans"; + } + result.removeDuplicates(); + return result; +} +} diff --git a/src/broker/font_backend.h b/src/broker/font_backend.h new file mode 100644 index 0000000..6247dc1 --- /dev/null +++ b/src/broker/font_backend.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace docview::fontbackend { +inline constexpr qint64 MaxFontBytes = 64ll * 1024 * 1024; +struct Request { + QByteArray faceLocal; + int weight = 400; + bool italic = false; + int charset = 1; + int pitchFamily = 0; +}; +struct Selection { + QString cacheKey; + QByteArray actualFaceLocal; + int charset = 1; + qint64 size = 0; + int faceIndex = 0; + qint64 faceOffset = -1; + bool substituted = false; + // Called on this backend's owning thread, before another select(). + // Must deliver only the already selected font; never use worker input as a path. + std::function read; +}; +class Backend { +public: + virtual ~Backend() = default; + virtual bool select(const Request &, Selection *, QString *error) = 0; +}; +std::unique_ptr createBackend(const std::atomic_bool &cancelled); +QString normalizedFamily(QString value); +QStringList familyCandidates(const QString &requested, int charset, int pitchFamily); +QString charsetLanguage(int charset); +} diff --git a/src/broker/font_backend_linux.cpp b/src/broker/font_backend_linux.cpp new file mode 100644 index 0000000..095c7c3 --- /dev/null +++ b/src/broker/font_backend_linux.cpp @@ -0,0 +1,149 @@ +#include "font_backend.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview::fontbackend { +namespace { +struct ConfigDelete { void operator()(FcConfig *p) const { if (p) FcConfigDestroy(p); } }; +struct SetDelete { void operator()(FcFontSet *p) const { if (p) FcFontSetDestroy(p); } }; +struct PatternDelete { void operator()(FcPattern *p) const { if (p) FcPatternDestroy(p); } }; +struct Fd { + explicit Fd(int descriptor) : value(descriptor) {} + Fd(const Fd &) = delete; + int value; + ~Fd() { if (value >= 0) ::close(value); } +}; +using Pattern = std::unique_ptr; +QString string(FcPattern *pattern, const char *key, int index = 0) { + FcChar8 *text = nullptr; + return FcPatternGetString(pattern, key, index, &text) == FcResultMatch ? QString::fromUtf8(reinterpret_cast(text)) : QString(); +} +int number(FcPattern *pattern, const char *key, int fallback = 0) { + int value = fallback; FcPatternGetInteger(pattern, key, 0, &value); return value; +} +bool flag(FcPattern *pattern, const char *key, bool fallback = false) { + FcBool value = fallback; FcPatternGetBool(pattern, key, 0, &value); return value; +} +QString identity(const struct stat &s) { + return QString("%1:%2:%3:%4:%5").arg(quint64(s.st_dev)).arg(quint64(s.st_ino)) + .arg(qint64(s.st_size)).arg(qint64(s.st_mtim.tv_sec)).arg(qint64(s.st_mtim.tv_nsec)); +} +QString key(FcPattern *pattern) { return string(pattern, FC_FILE) + QChar::Null + QString::number(number(pattern, FC_INDEX)); } +class LinuxBackend final : public Backend { + struct Entry { QString path, identity; FcPattern *pattern; }; + const std::atomic_bool &cancelled_; + std::unique_ptr config_; + std::unique_ptr fonts_; + QHash entries_; + QSet families_; + bool initialized_ = false; + bool initialize(QString *error) { + if (initialized_) return bool(config_ && fonts_); + initialized_ = true; + config_.reset(FcInitLoadConfigAndFonts()); + fonts_.reset(FcFontSetCreate()); + if (!config_ || !fonts_) { *error = "index"; return false; } + for (auto setName : {FcSetSystem, FcSetApplication}) { + const auto *set = FcConfigGetFonts(config_.get(), setName); + if (!set) continue; + for (int i = 0; i < set->nfont && entries_.size() < 32768 && !cancelled_; ++i) { + auto *pattern = set->fonts[i]; + const auto source = string(pattern, FC_FILE); + const int index = number(pattern, FC_INDEX, -1); + const auto suffix = QFileInfo(source).suffix().toLower(); + if (index < 0 || index > 65535 || !flag(pattern, FC_OUTLINE) || !flag(pattern, FC_SCALABLE) || + flag(pattern, FC_VARIABLE) || flag(pattern, "namedinstance") || flag(pattern, FC_COLOR) || + !QStringList{"ttf", "otf", "ttc", "otc"}.contains(suffix)) continue; + const auto canonical = QFileInfo(source).canonicalFilePath(); + if (canonical.isEmpty()) continue; + struct stat info{}; + if (::lstat(QFile::encodeName(canonical).constData(), &info) || !S_ISREG(info.st_mode) || info.st_size <= 0) continue; + const auto id = key(pattern); + if (entries_.contains(id)) continue; + FcPatternReference(pattern); + if (!FcFontSetAdd(fonts_.get(), pattern)) { FcPatternDestroy(pattern); *error = "index"; return false; } + entries_.insert(id, {canonical, identity(info), pattern}); + for (int n = 0;; ++n) { + auto family = string(pattern, FC_FAMILY, n); + if (family.isEmpty()) break; + families_.insert(normalizedFamily(family)); + } + } + } + return !cancelled_; + } +public: + explicit LinuxBackend(const std::atomic_bool &cancelled) : cancelled_(cancelled) {} + bool select(const Request &request, Selection *selected, QString *error) override { + error->clear(); + if (!initialize(error) || cancelled_) return false; + const auto requested = QString::fromLocal8Bit(request.faceLocal); + auto candidates = familyCandidates(requested, request.charset, request.pitchFamily); + Pattern pattern(FcPatternCreate()); + if (!pattern) { *error = "allocation"; return false; } + bool named = false; + for (const auto &family : candidates) { + if (!families_.contains(normalizedFamily(family))) continue; + const auto utf8 = family.toUtf8(); + if (!FcPatternAddString(pattern.get(), FC_FAMILY, reinterpret_cast(utf8.constData()))) { *error = "pattern"; return false; } + named = true; + } + const auto normalized = normalizedFamily(requested); + if (!named && (request.charset == 2 || normalized == "symbol" || normalized == "zapfdingbats")) return false; + if (!named) { + const char *generic = (request.pitchFamily & 3) == 1 ? "monospace" : (request.pitchFamily & 0xf0) == 0x10 ? "serif" : "sans-serif"; + FcPatternAddString(pattern.get(), FC_FAMILY, reinterpret_cast(generic)); + } + FcPatternAddInteger(pattern.get(), FC_WEIGHT, FcWeightFromOpenType(request.weight ? request.weight : 400)); + FcPatternAddInteger(pattern.get(), FC_SLANT, request.italic ? FC_SLANT_ITALIC : FC_SLANT_ROMAN); + if ((request.pitchFamily & 3) == 1) FcPatternAddInteger(pattern.get(), FC_SPACING, FC_MONO); + const auto language = charsetLanguage(request.charset).toUtf8(); + if (!language.isEmpty()) FcPatternAddString(pattern.get(), FC_LANG, reinterpret_cast(language.constData())); + if (!FcConfigSubstitute(config_.get(), pattern.get(), FcMatchPattern)) { *error = "pattern"; return false; } + FcDefaultSubstitute(pattern.get()); + FcResult status{}; FcFontSet *set = fonts_.get(); + Pattern match(FcFontSetMatch(config_.get(), &set, 1, pattern.get(), &status)); + if (!match || cancelled_) return false; + const auto entry = entries_.constFind(key(match.get())); + if (entry == entries_.cend()) { *error = "index"; return false; } + if (!language.isEmpty()) { + FcLangSet *languages = nullptr; + if (FcPatternGetLangSet(entry->pattern, FC_LANG, 0, &languages) != FcResultMatch || + FcLangSetHasLang(languages, reinterpret_cast(language.constData())) == FcLangDifferentLang) return false; + } + if (request.charset == 2 && !flag(entry->pattern, FC_SYMBOL)) return false; + auto fd = std::make_shared(::open(QFile::encodeName(entry->path).constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW)); + struct stat info{}; + if (fd->value < 0 || ::fstat(fd->value, &info) || !S_ISREG(info.st_mode) || identity(info) != entry->identity) { *error = "changed"; return false; } + if (info.st_size <= 0 || info.st_size > MaxFontBytes) { *error = "limit"; return false; } + const auto family = string(entry->pattern, FC_FAMILY); + selected->actualFaceLocal = family.toLocal8Bit(); + if (selected->actualFaceLocal.isEmpty() || selected->actualFaceLocal.size() > 256 || selected->actualFaceLocal.contains('\0')) return false; + selected->cacheKey = entry->identity; + selected->charset = request.charset == 1 ? 0 : request.charset; + selected->size = info.st_size; selected->faceIndex = number(match.get(), FC_INDEX); + selected->substituted = normalizedFamily(family) != normalized; + selected->read = [this, fd, size = selected->size, expected = entry->identity](QString *reason) { + QByteArray result(size, Qt::Uninitialized); + qint64 position = 0; + while (position < size && !cancelled_) { + const auto count = ::pread(fd->value, result.data() + position, size_t(qMin(65536, size - position)), position); + if (count <= 0) { *reason = "read"; return QByteArray(); } + position += count; + } + struct stat after{}; + if (cancelled_ || ::fstat(fd->value, &after) || identity(after) != expected) { *reason = "changed"; return QByteArray(); } + return result; + }; + return true; + } +}; +} +std::unique_ptr createBackend(const std::atomic_bool &cancelled) { return std::make_unique(cancelled); } +} diff --git a/src/broker/font_backend_win.cpp b/src/broker/font_backend_win.cpp new file mode 100644 index 0000000..6fd7829 --- /dev/null +++ b/src/broker/font_backend_win.cpp @@ -0,0 +1,161 @@ +#include "font_backend.h" +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include + +namespace docview::fontbackend { +namespace { +QString decode(const QByteArray &bytes) { + if (bytes.isEmpty()) return {}; + const int size = MultiByteToWideChar(CP_ACP, MB_ERR_INVALID_CHARS, bytes.constData(), int(bytes.size()), nullptr, 0); + if (size <= 0 || size > 256) return {}; + QString result(size, Qt::Uninitialized); + if (MultiByteToWideChar(CP_ACP, MB_ERR_INVALID_CHARS, bytes.constData(), int(bytes.size()), + reinterpret_cast(result.data()), size) != size) return {}; + return result; +} +QByteArray encode(const QString &text) { + const auto wide = text.toStdWString(); + // CP_ACP can be UTF-8 on modern Windows; WC_NO_BEST_FIT_CHARS is invalid + // for that code page. Round-trip validation below rejects lossy conversion. + const int size = WideCharToMultiByte(CP_ACP, 0, wide.data(), int(wide.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0 || size > 256) return {}; + QByteArray result(size, Qt::Uninitialized); + if (WideCharToMultiByte(CP_ACP, 0, wide.data(), int(wide.size()), result.data(), size, nullptr, nullptr) != size || decode(result) != text) return {}; + return result; +} +struct Font { + HDC dc = nullptr; + HFONT font = nullptr; + HGDIOBJ previous = nullptr; + ~Font() { + if (dc && previous && previous != HGDI_ERROR) SelectObject(dc, previous); + if (font) DeleteObject(font); + if (dc) DeleteDC(dc); + } +}; +QString indexedFamily(const QString &family) { + auto result = normalizedFamily(family); + // GDI enumerates vertical '@' faces separately. Preserve that distinction + // in the index so enumeration order cannot select one for horizontal text. + if (family.startsWith('@')) result.prepend('@'); + return result; +} +class WindowsBackend final : public Backend { + const std::atomic_bool &cancelled_; + QHash families_; + bool initialized_ = false; + static int CALLBACK enumerate(const LOGFONTW *logical, const TEXTMETRICW *, DWORD type, LPARAM parameter) { + auto *self = reinterpret_cast(parameter); + if (self->cancelled_ || self->families_.size() >= 32768) return 0; + if (!(type & TRUETYPE_FONTTYPE)) return 1; + int length = 0; + while (length < LF_FACESIZE && logical->lfFaceName[length]) ++length; + const auto family = QString::fromWCharArray(logical->lfFaceName, length); + if (!family.isEmpty()) self->families_.insert(indexedFamily(family), family); + return 1; + } + bool initialize(QString *error) { + if (initialized_) return !families_.isEmpty(); + initialized_ = true; + HDC dc = CreateCompatibleDC(nullptr); + if (!dc) { *error = "index"; return false; } + LOGFONTW logical{}; logical.lfCharSet = DEFAULT_CHARSET; + EnumFontFamiliesExW(dc, &logical, &enumerate, reinterpret_cast(this), 0); + DeleteDC(dc); + if (families_.isEmpty() && !cancelled_) *error = "index"; + return !families_.isEmpty() && !cancelled_; + } + bool selectFamily(const Request &request, const QString &requested, const QString &family, + Selection *selected, QString *error) { + auto font = std::make_shared(); + font->dc = CreateCompatibleDC(nullptr); + if (!font->dc) { *error = "dc"; return false; } + LOGFONTW logical{}; + logical.lfHeight = -16; + logical.lfWeight = request.weight ? request.weight : 400; + logical.lfItalic = request.italic; + logical.lfCharSet = BYTE(request.charset); + logical.lfPitchAndFamily = BYTE(request.pitchFamily); + logical.lfOutPrecision = OUT_TT_ONLY_PRECIS; + const auto wide = family.toStdWString(); + if (wide.size() >= LF_FACESIZE) return false; + std::memcpy(logical.lfFaceName, wide.c_str(), (wide.size() + 1) * sizeof(wchar_t)); + font->font = CreateFontIndirectW(&logical); + if (!font->font) { *error = "select"; return false; } + font->previous = SelectObject(font->dc, font->font); + if (!font->previous || font->previous == HGDI_ERROR) { *error = "select"; return false; } + wchar_t actual[256]{}; TEXTMETRICW metrics{}; + if (!GetTextFaceW(font->dc, 256, actual) || !GetTextMetricsW(font->dc, &metrics)) { *error = "metrics"; return false; } + const auto actualFamily = QString::fromWCharArray(actual); + if (!families_.contains(indexedFamily(actualFamily))) { *error = "index"; return false; } + // A charset mismatch must not quietly return Latin for an unembedded CJK font. + if (request.charset != DEFAULT_CHARSET && metrics.tmCharSet != request.charset) return false; + // GDI may synthesize a variable-font instance without returning those + // instance coordinates in the SFNT blob. Do not advertise that instance + // as faithfully represented by default bytes. + constexpr DWORD VariationTag = 0x72617666; // little-endian 'fvar' + if (GetFontData(font->dc, VariationTag, 0, nullptr, 0) != GDI_ERROR) return false; + const DWORD plainSize = GetFontData(font->dc, 0, 0, nullptr, 0); + constexpr DWORD CollectionTag = 0x66637474; // GDI's little-endian 'ttcf'. + const DWORD collectionSize = GetFontData(font->dc, CollectionTag, 0, nullptr, 0); + const bool collection = collectionSize != GDI_ERROR && collectionSize != 0; + const DWORD size = collection ? collectionSize : plainSize; + if (plainSize == GDI_ERROR || !plainSize || size == GDI_ERROR || !size || (collection && plainSize > size)) { *error = "data"; return false; } + if (size > MaxFontBytes) { *error = "limit"; return false; } + selected->actualFaceLocal = encode(actualFamily); + if (selected->actualFaceLocal.isEmpty()) return false; + selected->charset = metrics.tmCharSet; + selected->size = size; selected->faceOffset = collection ? size - plainSize : 0; + selected->substituted = indexedFamily(actualFamily) != indexedFamily(requested); + // The selected physical font is pinned by the HFONT through snapshot + // acquisition. No filename, registry path or PDF bytes are parsed here. + selected->cacheKey = QString("win:%1:%2:%3:%4:%5:%6").arg(actualFamily) + .arg(logical.lfWeight).arg(logical.lfItalic).arg(metrics.tmCharSet).arg(size).arg(selected->faceOffset); + selected->read = [this, font, size, table = collection ? CollectionTag : 0u](QString *reason) { + QByteArray bytes(size, Qt::Uninitialized); + DWORD position = 0; + while (position < size && !cancelled_) { + const DWORD count = qMin(65536, size - position); + if (GetFontData(font->dc, table, position, bytes.data() + position, count) != count) { *reason = "read"; return QByteArray(); } + position += count; + } + if (cancelled_) { *reason = "cancelled"; return QByteArray(); } + return bytes; + }; + return true; + } +public: + explicit WindowsBackend(const std::atomic_bool &cancelled) : cancelled_(cancelled) {} + bool select(const Request &request, Selection *selected, QString *error) override { + error->clear(); + if (!initialize(error) || cancelled_) return false; + const auto requested = decode(request.faceLocal); + if (!request.faceLocal.isEmpty() && requested.isEmpty()) return false; + QSet tried; + QString failure; + for (const auto &candidate : familyCandidates(requested, request.charset, request.pitchFamily)) { + if (cancelled_) return false; + const auto family = families_.value(indexedFamily(candidate)); + if (family.isEmpty() || tried.contains(family)) continue; + tried.insert(family); + Selection next; QString reason; + if (selectFamily(request, requested, family, &next, &reason)) { + *selected = std::move(next); return true; + } + // A present but unsupported variable face must not prevent a later + // static alias from supplying the requested charset. Preserve a + // real OS/quota failure only when no usable candidate remains. + if (!reason.isEmpty() && (failure.isEmpty() || reason == "limit")) failure = reason; + } + *error = failure; + return false; + } +}; +} +std::unique_ptr createBackend(const std::atomic_bool &cancelled) { return std::make_unique(cancelled); } +} diff --git a/src/broker/font_broker.cpp b/src/broker/font_broker.cpp new file mode 100644 index 0000000..65dca55 --- /dev/null +++ b/src/broker/font_broker.cpp @@ -0,0 +1,227 @@ +#include "font_broker.h" +#include +#include "font_backend.h" +#include "common/font_contract.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +QCborMap error(const char *code, const QString &message) { + return {{"error", QCborMap{{"code", QString::fromLatin1(code)}, {"message", message}}}}; +} +constexpr qint64 CacheLimit = MaxBrokerFontCacheBytes, TransferLimit = MaxFontTransferBytes; +struct Blob { + QByteArray bytes, hash; + qint64 charge = 0; + ~Blob(); +}; +struct SharedBudget { + struct Entry { QString owner; std::shared_ptr blob; quint64 lastUse; }; + std::atomic_int threads{0}; + std::atomic snapshots{0}; + std::mutex mutex; + QHash cache; + quint64 clock = 0; + std::shared_ptr find(const QString &key) { + std::lock_guard lock(mutex); + const auto it = cache.find(key); + if (it == cache.end()) return {}; + it->lastUse = ++clock; + return it->blob; + } + void touch(const QString &key) { + std::lock_guard lock(mutex); + const auto it = cache.find(key); + if (it != cache.end()) it->lastUse = ++clock; + } + bool reserve(qint64 bytes, qint64 loweredLimit) { + std::lock_guard lock(mutex); + const qint64 limit = std::min(loweredLimit, CacheLimit); + // Snapshots being read are charged but not published in this cache. + // Allocation can proceed only after enough globally unused entries are + // evicted, including entries created by another active/staging service. + while (bytes > limit - snapshots.load()) { + auto victim = cache.end(); + for (auto it = cache.begin(); it != cache.end(); ++it) + if (it->blob.use_count() == 1 && (victim == cache.end() || it->lastUse < victim->lastUse)) victim = it; + if (victim == cache.end()) return false; + cache.erase(victim); + } + snapshots.fetch_add(bytes); + return true; + } + void publish(const QString &owner, const QString &key, const std::shared_ptr &blob) { + std::lock_guard lock(mutex); + cache.insert(key, {owner, blob, ++clock}); + } + void removeOwner(const QString &owner) { + std::lock_guard lock(mutex); + for (auto it = cache.begin(); it != cache.end();) + if (it->owner == owner) it = cache.erase(it); else ++it; + } +}; +// Shared by active, staging, and revoked-but-still-returning services. The +// registry outlives detached OS calls. Each service removes its snapshots on its +// background thread; cached entries own bytes only, never OS objects or paths. +SharedBudget &budget() { static auto *value = new SharedBudget; return *value; } +Blob::~Blob() { + // Release bytes before making their charge available to another reader. + // IPC chunks have separately bounded transport queues. + bytes = {}; hash = {}; + if (charge) budget().snapshots.fetch_sub(charge); +} +} +struct FontBroker::State { + struct Task { QString operation; QCborMap payload; std::function completion; }; + struct Issued { std::shared_ptr blob; QString cacheKey; }; + std::atomic_bool revoked{false}; + std::mutex mutex; + std::condition_variable available; + std::deque tasks; + QObject *receiver = nullptr; // Guarded by mutex through queued event posting. + bool running = true; + std::unique_ptr backend; + QHash issued; + QSet selections; + const QString owner = QUuid::createUuid().toString(QUuid::Id128); + qint64 cacheLimit = CacheLimit, transferred = 0; + + QCborMap execute(const QString &operation, const QCborMap &payload) { + auto wire = payload; wire.insert(QStringLiteral("fontRequestId"), 1); + QString reason; + if (!validateFontPayload(operation, wire, &reason)) + return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォント要求が無効です。")); + if (operation == "font.close") { + if (!issued.remove(payload.value("fontId").toString())) + return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォントの参照が無効です。")); + return {{"released", true}}; + } + if (operation == "font.read") { + const auto id = payload.value("fontId").toString(); + const auto it = issued.constFind(id); + if (it == issued.cend()) return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォントの参照が無効です。")); + const auto offset = payload.value("offset").toInteger(), length = payload.value("length").toInteger(); + const auto &bytes = it->blob->bytes; + if (offset > bytes.size() || length > bytes.size() - offset) + return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォントの読取り範囲が無効です。")); + if (length > TransferLimit - transferred) + return error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォント転送量が上限を超えました。")); + transferred += length; budget().touch(it->cacheKey); + return {{"fontId", id}, {"offset", offset}, {"data", bytes.mid(offset, length)}}; + } + if (issued.size() >= MaxFontHandles) + return error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォント参照数が上限を超えました。")); + const auto fingerprint = QCborValue(payload).toCbor(); + if (!selections.contains(fingerprint)) { + if (selections.size() >= MaxFontSelections) + return error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォント選択数が上限を超えました。")); + selections.insert(fingerprint); // Negative matches also consume a distinct selection. + } + if (!backend) backend = fontbackend::createBackend(revoked); + if (!backend) return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "OSフォント索引を利用できません。")); + fontbackend::Request request{payload.value("faceLocal").toByteArray(), int(payload.value("weight").toInteger()), + payload.value("italic").toBool(), int(payload.value("charset").toInteger()), int(payload.value("pitchFamily").toInteger())}; + fontbackend::Selection selected; + if (!backend->select(request, &selected, &reason)) { + if (!reason.isEmpty()) return error(reason == "limit" ? "E_FONT_LIMIT" : "E_FONT_FAILED", QCoreApplication::translate("FontBroker", "OSフォントを取得できません。")); + return {{"found", false}}; + } + if (revoked) return {}; + if (selected.size <= 0 || selected.size > fontbackend::MaxFontBytes) + return error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォントのサイズが上限を超えました。")); + // Cache ownership is session-specific, while eviction is global. This + // also keeps independent OS-index snapshots from aliasing each other. + const QString cacheKey = owner + ':' + selected.cacheKey; + auto blob = budget().find(cacheKey); + if (!blob) { + if (!selected.read) return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォントを読み取れません。")); + blob = std::make_shared(); + if (!budget().reserve(selected.size, cacheLimit)) + return error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォントキャッシュが上限に達しました。")); + // Charge before reading so two OS threads cannot both allocate past + // the process-wide snapshot limit. RAII releases on every failure. + blob->charge = selected.size; + auto bytes = selected.read(&reason); + if (revoked) return {}; + if (bytes.size() != selected.size) return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "フォントの読取りが完了しませんでした。")); + blob->bytes = std::move(bytes); + blob->hash = QCryptographicHash::hash(blob->bytes, QCryptographicHash::Sha256); + budget().publish(owner, cacheKey, blob); + } + const auto id = QUuid::createUuid().toString(QUuid::Id128); + QCborMap result{{"found", true}, {"fontId", id}, {"actualFaceLocal", selected.actualFaceLocal}, + {"charset", selected.charset}, {"size", blob->bytes.size()}, {"sha256", blob->hash}, {"substituted", selected.substituted}}; + if (selected.faceOffset >= 0) result.insert(QStringLiteral("faceOffset"), selected.faceOffset); + else result.insert(QStringLiteral("faceIndex"), selected.faceIndex); + auto wireResult = result; wireResult.insert(QStringLiteral("fontRequestId"), 1); + if (!validateFontResult(operation, wire, wireResult, &reason)) + return error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "OSフォント情報が無効です。")); + issued.insert(id, {std::move(blob), cacheKey}); + return result; + } + static void run(std::shared_ptr state) { + for (;;) { + Task task; + { + std::unique_lock lock(state->mutex); + state->available.wait(lock, [&] { return state->revoked || !state->tasks.empty(); }); + if (state->revoked) break; + task = std::move(state->tasks.front()); state->tasks.pop_front(); + } + QCborMap result; + try { result = state->execute(task.operation, task.payload); } + catch (...) { result = error("E_FONT_FAILED", QCoreApplication::translate("FontBroker", "OSフォント処理に失敗しました。")); } + std::lock_guard lock(state->mutex); + if (!state->revoked && state->receiver && task.completion) { + QMetaObject::invokeMethod(state->receiver, [state, completion = std::move(task.completion), result = std::move(result)]() mutable { + if (!state->revoked) completion(std::move(result)); + }, Qt::QueuedConnection); + } + } + // These objects are destroyed on their owning background thread even + // if the GUI facade was destroyed while an OS call was still running. + state->issued.clear(); budget().removeOwner(state->owner); state->backend.reset(); + budget().threads.fetch_sub(1); + } +}; +FontBroker::FontBroker(QObject *parent) : FontBroker(parent, CacheLimit) {} +FontBroker::FontBroker(QObject *parent, qint64 snapshotCacheLimit) : QObject(parent), state_(std::make_shared()) { + state_->cacheLimit = std::clamp(snapshotCacheLimit, 1, CacheLimit); + state_->receiver = this; + int count = budget().threads.load(); + while (count < 2 && !budget().threads.compare_exchange_weak(count, count + 1)) {} + if (count >= 2) { state_->running = false; return; } + try { std::thread(&State::run, state_).detach(); } + catch (...) { state_->running = false; budget().threads.fetch_sub(1); } +} +FontBroker::~FontBroker() { revoke(); } +void FontBroker::revoke() { + std::lock_guard lock(state_->mutex); + state_->revoked = true; state_->receiver = nullptr; state_->tasks.clear(); + state_->available.notify_one(); +} +void FontBroker::request(const QString &operation, const QCborMap &payload, std::function completion) { + Q_ASSERT(QThread::currentThread() == thread()); + std::lock_guard lock(state_->mutex); + if (state_->revoked) return; + if (!state_->running || state_->tasks.size() >= 8) { + QMetaObject::invokeMethod(this, [state = state_, completion = std::move(completion)] { + if (!state->revoked && completion) completion(error("E_FONT_LIMIT", QCoreApplication::translate("FontBroker", "フォント処理の待機数が上限を超えました。"))); + }, Qt::QueuedConnection); + return; + } + state_->tasks.push_back({operation, payload, std::move(completion)}); + state_->available.notify_one(); +} +} diff --git a/src/broker/font_broker.h b/src/broker/font_broker.h new file mode 100644 index 0000000..bdf258e --- /dev/null +++ b/src/broker/font_broker.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +namespace docview { +// One facade per PDF worker/session. This class has no PDFium dependency. +// Calls and completion callbacks belong to the facade's QObject thread. +class FontBroker final : public QObject { +public: + explicit FontBroker(QObject *parent = nullptr); + // Lowers the admission limit against the shared cache; cannot exceed the + // production 256 MiB cap. Useful for constrained deployments and tests. + FontBroker(QObject *parent, qint64 snapshotCacheLimit); + ~FontBroker() override; + void request(const QString &operation, const QCborMap &payload, + std::function completion); + // Permanently invalidates this service and drops queued/late completions. + // Does not wait for an in-progress OS font API or file read. + void revoke(); +private: + struct State; + std::shared_ptr state_; +}; +} diff --git a/src/common/contracts.cpp b/src/common/contracts.cpp new file mode 100644 index 0000000..1603128 --- /dev/null +++ b/src/common/contracts.cpp @@ -0,0 +1,151 @@ +#include "contracts.h" +#include +#include +#include +#include +#include +#include +namespace docview { +bool validateCapabilities(const QVariant &capabilities, const QVariant &reasons, QString *error) { + auto bad = [&] { + if (error) + *error = QCoreApplication::translate("Contracts", "E_WORKER_CRASH: 文書の能力情報が不正です"); + return false; + }; + if (capabilities.metaType().id() != QMetaType::QVariantMap || + reasons.metaType().id() != QMetaType::QVariantMap) + return bad(); + static const QSet names = {"pageNavigation", "chapterNavigation", "outline", "headings", + "textSearch", "textSelection", "reflow", "password"}; + static const QSet states = {"supported", "unavailable", "loading", "unsupported"}; + static const QRegularExpression code(QStringLiteral("\\AE_[A-Z0-9_]{1,126}\\z")); + const auto declared = capabilities.toMap(); + const auto declaredReasons = reasons.toMap(); + if (declared.size() != names.size() || declaredReasons.size() > names.size()) + return bad(); + for (auto it = declaredReasons.begin(); it != declaredReasons.end(); ++it) { + if (!names.contains(it.key()) || it.value().metaType().id() != QMetaType::QString || + !code.match(it.value().toString()).hasMatch()) + return bad(); + } + for (auto it = declared.begin(); it != declared.end(); ++it) { + if (!names.contains(it.key()) || it.value().metaType().id() != QMetaType::QString || + !states.contains(it.value().toString()) || + (it.value().toString() == "unsupported" && !declaredReasons.contains(it.key()))) + return bad(); + } + if (error) + error->clear(); + return true; +} +bool sanitizeNavigation(const QVariantList &input, QVariantList *out, qint64 *used, QString *error) { + auto bad = [&] { + if (error) + *error = QCoreApplication::translate("Contracts", "E_WORKER_CRASH: ナビゲーションの応答が不正です"); + return false; + }; + if (input.size() > 20000 || !out || !used) + return bad(); + QVariantList clean; + qint64 bytes = *used; + const QHash strings = {{"parentId", 128}, {"title", 4096}, {"href", 2048}, {"id", 128}, + {"idref", 1024}, {"source", 32}, {"precision", 32}, {"kind", 32}, + {"layout", 32}, {"spread", 64}, {"reason", 128}, {"renditionSpread", 32}}; + const QSet numbers = {"page", "pageIndex", "index", "level", "depth"}; + const QSet real = {"x", "y", "xPt", "yPt"}; + const QSet booleans = {"missing", "linear"}; + for (const auto &v : input) { + if (v.metaType().id() != QMetaType::QVariantMap) + return bad(); + const auto row = v.toMap(); + QVariantMap next; + for (auto it = row.begin(); it != row.end(); ++it) { + const auto key = it.key(); + if (strings.contains(key)) { + if (it.value().metaType().id() != QMetaType::QString || + it.value().toString().size() > strings[key]) + return bad(); + next[key] = it.value(); + } else if (numbers.contains(key)) { + bool ok = false; + const auto n = it.value().toLongLong(&ok); + if (!ok || it.value().toDouble() != double(n) || n < 0 || + n > (key == "depth" ? 128 + : key == "level" ? 6 + : 100000)) + return bad(); + next[key] = n; + } else if (real.contains(key)) { + bool ok = false; + double n = it.value().toDouble(&ok); + if (!ok || !std::isfinite(n) || std::abs(n) > 1e7) + return bad(); + next[key] = n; + } else if (booleans.contains(key)) { + if (it.value().metaType().id() != QMetaType::Bool) + return bad(); + next[key] = it.value(); + } else if (key == "rect") { + const auto rect = it.value().toList(); + if (rect.size() != 4) + return bad(); + for (const auto &p : rect) { + bool ok = false; + double n = p.toDouble(&ok); + if (!ok || !std::isfinite(n) || std::abs(n) > 1e7) + return bad(); + } + next[key] = rect; + } + } + if (next.isEmpty()) + return bad(); + bytes += QJsonDocument(QJsonObject::fromVariantMap(next)).toJson(QJsonDocument::Compact).size(); + if (bytes > MaxNavigationBytes) { + if (error) + *error = "E_NAVIGATION_LIMIT"; + return false; + } + clean << next; + } + *out = clean; + *used = bytes; + return true; +} +bool validatePageMetadata(const QVariantList &pages, int count, int first) { + if (count < 1 || count > 100000 || pages.isEmpty() || pages.size() > 256) + return false; + QSet seen; + int expected = first; + for (const auto &v : pages) { + const auto p = v.toMap(); + bool ok = false; + const auto i = p.value("pageIndex").toLongLong(&ok); + if (!ok || i < 0 || i >= count || seen.contains(int(i))) + return false; + if (first >= 0 && i != expected++) + return false; + seen.insert(int(i)); + for (const auto &key : {"width", "height"}) { + double n = p.value(key).toDouble(&ok); + if (!ok || !std::isfinite(n) || n <= 0 || n > 100000) + return false; + } + if (p.value("label").toString().size() > 4096) + return false; + const auto rotation = p.value("rotation").toInt(&ok); + if (!ok || (rotation != 0 && rotation != 90 && rotation != 180 && rotation != 270)) + return false; + const auto box = p.value("cropBox").toList(); + if (box.size() != 4 || box[2].toDouble() <= box[0].toDouble() || + box[3].toDouble() <= box[1].toDouble()) + return false; + for (const auto &v : box) { + double n = v.toDouble(&ok); + if (!ok || !std::isfinite(n) || std::abs(n) > 1e7) + return false; + } + } + return true; +} +} // namespace docview diff --git a/src/common/contracts.h b/src/common/contracts.h new file mode 100644 index 0000000..7a6674d --- /dev/null +++ b/src/common/contracts.h @@ -0,0 +1,10 @@ +#pragma once +#include +namespace docview { +inline constexpr qint64 MaxNavigationBytes = 32ll * 1024 * 1024; +// Version 1 requires all eight capabilities. Unknown declarations never grant an operation. +bool validateCapabilities(const QVariant &capabilities, const QVariant &reasons, QString *error = nullptr); +bool sanitizeNavigation(const QVariantList &input, QVariantList *output, qint64 *usedBytes, + QString *error = nullptr); +bool validatePageMetadata(const QVariantList &pages, int pageCount, int firstPage = -1); +} // namespace docview diff --git a/src/common/font_contract.cpp b/src/common/font_contract.cpp new file mode 100644 index 0000000..d8051b6 --- /dev/null +++ b/src/common/font_contract.cpp @@ -0,0 +1,148 @@ +#include "font_contract.h" + +#include + +namespace docview { +namespace { +bool keys(const QCborMap &map, std::initializer_list names) { + if (map.size() != qsizetype(names.size())) + return false; + for (const auto *name : names) + if (!map.contains(QString::fromLatin1(name))) + return false; + return true; +} +bool integer(const QCborValue &value, qint64 minimum, qint64 maximum) { + return value.isInteger() && value.toInteger() >= minimum && value.toInteger() <= maximum; +} +bool identity(const QCborMap &map) { + return map.value("fontRequestId").isInteger() && map.value("fontRequestId").toInteger() > 0; +} +bool name(const QCborValue &value, bool emptyAllowed) { + if (!value.isByteArray()) + return false; + const auto bytes = value.toByteArray(); + return (emptyAllowed || !bytes.isEmpty()) && bytes.size() <= MaxFontNameBytes && !bytes.contains('\0'); +} +bool reject(QString *error, const char *message) { + if (error) + *error = QString::fromLatin1(message); + return false; +} +} // namespace + +bool isFontOperation(const QString &operation) { + return operation == "font.map" || operation == "font.read" || operation == "font.close"; +} +bool validFontCharset(qint64 charset) { + switch (charset) { + case 0: + case 1: + case 2: + case 128: + case 129: + case 134: + case 136: + case 161: + case 163: + case 177: + case 178: + case 204: + case 222: + case 238: + return true; + default: + return false; + } +} +bool validFontId(const QCborValue &value) { + if (!value.isString() || value.toString().size() != 32) + return false; + for (const auto c : value.toString()) + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return false; + return true; +} +bool validateFontPayload(const QString &operation, const QCborMap &payload, QString *error) { + if (!identity(payload)) + return reject(error, "invalid font request identity"); + if (operation == "font.map") { + const auto pitch = payload.value("pitchFamily"); + if (!keys(payload, {"fontRequestId", "faceLocal", "weight", "italic", "charset", "pitchFamily"}) || + !name(payload.value("faceLocal"), true) || !integer(payload.value("weight"), 0, 1000) || + !payload.value("italic").isBool() || !payload.value("charset").isInteger() || + !validFontCharset(payload.value("charset").toInteger()) || !integer(pitch, 0, 0x52) || + (pitch.toInteger() & 0x0f) > 2) + return reject(error, "invalid font map payload"); + } else if (operation == "font.read") { + if (!keys(payload, {"fontRequestId", "fontId", "offset", "length"}) || + !validFontId(payload.value("fontId")) || + !integer(payload.value("offset"), 0, MaxFontSnapshotBytes - 1) || + !integer(payload.value("length"), 1, MaxFontReadBytes) || + payload.value("length").toInteger() > MaxFontSnapshotBytes - payload.value("offset").toInteger()) + return reject(error, "invalid font read payload"); + } else if (operation == "font.close") { + if (!keys(payload, {"fontRequestId", "fontId"}) || !validFontId(payload.value("fontId"))) + return reject(error, "invalid font close payload"); + } else { + return reject(error, "unknown font operation"); + } + if (error) + error->clear(); + return true; +} +bool validateFontResult(const QString &operation, const QCborMap &request, const QCborMap &result, + QString *error) { + if (!validateFontPayload(operation, request, error) || !identity(result) || + result.value("fontRequestId") != request.value("fontRequestId")) + return reject(error, "font result identity mismatch"); + if (operation == "font.map") { + if (!result.value("found").isBool()) + return reject(error, "invalid font map result"); + if (!result.value("found").toBool()) { + if (!keys(result, {"fontRequestId", "found"})) + return reject(error, "invalid absent font result"); + } else { + const bool index = result.contains(QStringLiteral("faceIndex")); + const bool correctKeys = + index ? keys(result, {"fontRequestId", "found", "fontId", "actualFaceLocal", "charset", + "size", "sha256", "faceIndex", "substituted"}) + : keys(result, {"fontRequestId", "found", "fontId", "actualFaceLocal", "charset", + "size", "sha256", "faceOffset", "substituted"}); + if (!correctKeys || !validFontId(result.value("fontId")) || + !name(result.value("actualFaceLocal"), false) || !result.value("charset").isInteger() || + !validFontCharset(result.value("charset").toInteger()) || + !integer(result.value("size"), 1, MaxFontSnapshotBytes) || + !result.value("sha256").isByteArray() || result.value("sha256").toByteArray().size() != 32 || + !result.value("substituted").isBool() || + (index ? !integer(result.value("faceIndex"), 0, 65535) + : !integer(result.value("faceOffset"), 0, result.value("size").toInteger() - 1))) + return reject(error, "invalid selected font result"); + } + } else if (operation == "font.read") { + if (!keys(result, {"fontRequestId", "fontId", "offset", "data"}) || + !validFontId(result.value("fontId")) || !result.value("offset").isInteger() || + result.value("fontId") != request.value("fontId") || + result.value("offset") != request.value("offset") || !result.value("data").isByteArray() || + result.value("data").toByteArray().size() != request.value("length").toInteger()) + return reject(error, "invalid font read result"); + } else if (!keys(result, {"fontRequestId", "released"}) || !result.value("released").isBool() || + !result.value("released").toBool()) { + return reject(error, "invalid font close result"); + } + if (error) + error->clear(); + return true; +} +bool validateFontError(const QCborMap &map, QString *error) { + static const QSet codes{"E_FONT_LIMIT", "E_FONT_FAILED", "E_WORKER_TIMEOUT", "E_WORKER_CRASH"}; + if (!keys(map, {"fontRequestId", "code", "message"}) || !identity(map) || !map.value("code").isString() || + !codes.contains(map.value("code").toString()) || !map.value("message").isString() || + map.value("message").toString().isEmpty() || map.value("message").toString().size() > 4096 || + map.value("message").toString().contains(QChar::Null)) + return reject(error, "invalid font error"); + if (error) + error->clear(); + return true; +} +} // namespace docview diff --git a/src/common/font_contract.h b/src/common/font_contract.h new file mode 100644 index 0000000..aff7e06 --- /dev/null +++ b/src/common/font_contract.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace docview { + +inline constexpr int MaxFontNameBytes = 256; +inline constexpr qint64 MaxFontSnapshotBytes = 64ll * 1024 * 1024; +inline constexpr int MaxFontReadBytes = 65536; +inline constexpr int MaxFontHandles = 16; +inline constexpr int MaxFontSelections = 256; +inline constexpr qint64 MaxFontTransferBytes = 512ll * 1024 * 1024; +inline constexpr qint64 MaxWorkerFontCacheBytes = 128ll * 1024 * 1024; +inline constexpr qint64 MaxBrokerFontCacheBytes = 256ll * 1024 * 1024; +inline constexpr int FontReplyTimeoutMs = 5000; +inline constexpr int FontFetchTimeoutMs = 15000; + +bool isFontOperation(const QString &operation); +bool validFontCharset(qint64 charset); +bool validFontId(const QCborValue &value); +// Wire maps include fontRequestId. No unknown fields or coercions are accepted. +bool validateFontPayload(const QString &operation, const QCborMap &payload, QString *error = nullptr); +bool validateFontResult(const QString &operation, const QCborMap &requestPayload, const QCborMap &result, + QString *error = nullptr); +bool validateFontError(const QCborMap &errorMap, QString *error = nullptr); + +} // namespace docview diff --git a/src/common/ipc.cpp b/src/common/ipc.cpp new file mode 100644 index 0000000..cd9cb22 --- /dev/null +++ b/src/common/ipc.cpp @@ -0,0 +1,133 @@ +#include "ipc.h" +#include "font_contract.h" +#include +#include +#include +#include +namespace docview { +static bool finiteValue(const QCborValue &v, int depth, qsizetype &nodes) { + if (++nodes > 100000 || depth > 32 || v.isTag() || v.isUndefined()) + return false; + if (v.isDouble() && !std::isfinite(v.toDouble())) + return false; + if (v.isString() && v.toString().size() > 65536) + return false; + if (v.isArray()) + for (const auto &x : v.toArray()) + if (!finiteValue(x, depth + 1, nodes)) + return false; + if (v.isMap()) { + const auto m = v.toMap(); + for (auto it = m.begin(); it != m.end(); ++it) + if (!it.key().isString() || it.key().toString().size() > 128 || + !finiteValue(it.value(), depth + 1, nodes)) + return false; + } + return true; +} +bool validateEnvelope(const QCborMap &m, QString *error) { + const auto bad = [&](const QString &s) { + if (error) + *error = s; + return false; + }; + qsizetype n = 0; + if (!finiteValue(m, 0, n)) + return bad("invalid CBOR value"); + if (m.value("protocolVersion").toInteger(-1) != 1) + return bad("unsupported protocol version"); + if (!m.value("requestId").isInteger() || m.value("requestId").toInteger() < 0 || + !m.value("generation").isInteger() || m.value("generation").toInteger() < 0) + return bad("invalid request identity"); + const QString session = m.value("sessionId").toString(); + if (session.isEmpty() || session.size() > 128) + return bad("invalid session"); + static const QSet operations = {"open", "render", "outline", "headings", "links", + "search", "pageInfo", "pages", "extract", "entries", + "close", "cancel", "probe", "ping", "metadata", + "font.map", "font.read", "font.close"}; + if (!operations.contains(m.value("operation").toString())) + return bad("unknown operation"); + const bool result = m.contains(QStringLiteral("result")), err = m.contains(QStringLiteral("error")), + payload = m.contains(QStringLiteral("payload")); + if ((result && err) || ((result || err) && payload) || (!result && !err && !payload)) + return bad("invalid envelope union"); + if (payload && !m.value("payload").isMap()) + return bad("invalid payload"); + if (err && !m.value("error").isMap()) + return bad("invalid error"); + if (isFontOperation(m.value("operation").toString())) { + if (m.size() != 6 || !m.value("protocolVersion").isInteger() || + (result && !m.value("result").isMap())) + return bad("invalid font envelope"); + if (payload && + !validateFontPayload(m.value("operation").toString(), m.value("payload").toMap(), error)) + return false; + if (err && !validateFontError(m.value("error").toMap(), error)) + return false; + } + return true; +} +IpcChannel::IpcChannel(QIODevice *d, QObject *p) : QObject(p), device_(d) { + connect(d, &QIODevice::readyRead, this, &IpcChannel::read); +} +void IpcChannel::fail(const QString &s) { + if (failed_) + return; + failed_ = true; + buffer_.clear(); + emit protocolError(s); + device_->close(); +} +bool IpcChannel::send(const QCborMap &m) { + QString error; + if (failed_ || !validateEnvelope(m, &error)) + return false; + auto bytes = QCborValue(m).toCbor(); + const bool data = m.value("operation").toString() == "render" && m.contains(QStringLiteral("result")); + if (bytes.size() > (data ? MaxDataFrame : MaxControlFrame)) + return false; + quint32 header = quint32(bytes.size()) | (data ? 0x80000000u : 0); + char raw[4]; + qToBigEndian(header, raw); + return device_->write(raw, 4) == 4 && device_->write(bytes) == bytes.size(); +} +void IpcChannel::read() { + while (device_->bytesAvailable() > 0) { + const auto room = qint64(MaxDataFrame) + 4 - buffer_.size(); + if (room <= 0) { + fail("IPC buffer limit"); + return; + } + buffer_ += device_->read(qMin(room, device_->bytesAvailable())); + while (buffer_.size() >= 4) { + const quint32 h = qFromBigEndian(buffer_.constData()); + const bool data = h & 0x80000000u; + const quint32 size = h & 0x7fffffffu; + if (!size || size > (data ? MaxDataFrame : MaxControlFrame)) { + fail("IPC frame limit"); + return; + } + if (buffer_.size() < size + 4) + break; + QCborParserError e; + const auto v = QCborValue::fromCbor(buffer_.mid(4, size), &e); + buffer_.remove(0, size + 4); + QString reason; + if (e.error != QCborError::NoError || !v.isMap() || !validateEnvelope(v.toMap(), &reason)) { + fail("invalid IPC envelope: " + reason); + return; + } + const auto m = v.toMap(); + if (data && + (m.value("operation").toString() != "render" || !m.contains(QStringLiteral("result")))) { + fail("invalid data channel operation"); + return; + } + emit messageReceived(m); + if (failed_) + return; + } + } +} +} // namespace docview diff --git a/src/common/ipc.h b/src/common/ipc.h new file mode 100644 index 0000000..747ffed --- /dev/null +++ b/src/common/ipc.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include +namespace docview { +inline constexpr quint32 MaxControlFrame = 1024 * 1024; +inline constexpr quint32 MaxDataFrame = 8 * 1024 * 1024; +bool validateEnvelope(const QCborMap &map, QString *error = nullptr); +class IpcChannel : public QObject { + Q_OBJECT + public: + explicit IpcChannel(QIODevice *device, QObject *parent = nullptr); + bool send(const QCborMap &message); + signals: + void messageReceived(QCborMap message); + void protocolError(QString message); + + private: + void read(); + void fail(const QString &message); + QIODevice *device_; + QByteArray buffer_; + bool failed_ = false; +}; +} // namespace docview diff --git a/src/common/memory_pressure.cpp b/src/common/memory_pressure.cpp new file mode 100644 index 0000000..4f7a3c1 --- /dev/null +++ b/src/common/memory_pressure.cpp @@ -0,0 +1,148 @@ +#include "memory_pressure.h" + +#include +#include +#include +#ifdef Q_OS_WIN +#include +#endif + +namespace docview { +namespace { +constexpr quint64 MiB = 1024ull * 1024; +constexpr qsizetype MaximumMeminfoBytes = 64 * 1024; + +bool validSample(const MemorySample &sample) { + return sample.valid && sample.totalBytes > 0 && sample.availableBytes <= sample.totalBytes; +} + +// ceil(total * percent / 100), without overflowing uint64 multiplication. +quint64 percentageCeiling(quint64 total, quint64 percent) { + return (total / 100) * percent + ((total % 100) * percent + 99) / 100; +} + +bool parseKiB(const QByteArray &text, quint64 *bytes) { + const auto fields = text.simplified().split(' '); + if (fields.size() != 2 || fields[0].isEmpty() || fields[1] != "kB") + return false; + for (const char c : fields[0]) + if (c < '0' || c > '9') + return false; + bool ok = false; + const auto kib = fields[0].toULongLong(&ok); + if (!ok || kib > std::numeric_limits::max() / 1024) + return false; + *bytes = kib * 1024; + return true; +} +} // namespace + +MemorySample parseLinuxMemoryInfo(const QByteArray &data) { + if (data.isEmpty() || data.size() > MaximumMeminfoBytes || data.contains('\0')) + return {}; + MemorySample sample; + bool total = false, available = false; + for (const auto &line : data.split('\n')) { + const auto colon = line.indexOf(':'); + if (colon < 0) + continue; + const auto name = line.first(colon); + if (name == "MemTotal") { + if (total || !parseKiB(line.mid(colon + 1), &sample.totalBytes)) + return {}; + total = true; + } else if (name == "MemAvailable") { + if (available || !parseKiB(line.mid(colon + 1), &sample.availableBytes)) + return {}; + available = true; + } + } + sample.valid = total && available; + return validSample(sample) ? sample : MemorySample{}; +} + +MemorySample readSystemMemory() { +#ifdef Q_OS_LINUX + QFile file(QStringLiteral("/proc/meminfo")); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const auto bytes = file.read(MaximumMeminfoBytes + 1); + if (file.error() != QFileDevice::NoError) + return {}; + return parseLinuxMemoryInfo(bytes); +#elif defined(Q_OS_WIN) + // Microsoft: dwLength must be initialized; ullAvailPhys includes reusable + // standby/free/zero pages, rather than only completely unused pages. + MEMORYSTATUSEX memory{}; + memory.dwLength = sizeof(memory); + if (!GlobalMemoryStatusEx(&memory)) + return {}; + const MemorySample sample{memory.ullTotalPhys, memory.ullAvailPhys, true}; + return validSample(sample) ? sample : MemorySample{}; +#else + return {}; +#endif +} + +MemoryPressureLevel MemoryPressurePolicy::observe(const MemorySample &sample) { + if (!validSample(sample)) { + pressureSamples_ = recoverySamples_ = 0; + return level_; + } + const bool pressure = sample.availableBytes < 1024 * MiB && + sample.availableBytes < percentageCeiling(sample.totalBytes, 10); + const bool critical = + sample.availableBytes < 256 * MiB && sample.availableBytes < percentageCeiling(sample.totalBytes, 3); + const bool recovered = sample.availableBytes >= 1536 * MiB || + sample.availableBytes >= percentageCeiling(sample.totalBytes, 15); + if (pressure) { + recoverySamples_ = 0; + if (level_ == MemoryPressureLevel::Normal) { + level_ = MemoryPressureLevel::NoPrefetch; + pressureSamples_ = 0; + } else if (level_ != MemoryPressureLevel::Stop && + (critical || ++pressureSamples_ >= SustainedSamplesPerStage)) { + level_ = static_cast(static_cast(level_) + 1); + pressureSamples_ = 0; + } + } else { + pressureSamples_ = 0; + if (recovered && level_ != MemoryPressureLevel::Normal) { + if (++recoverySamples_ >= RecoverySamples) { + level_ = MemoryPressureLevel::Normal; + recoverySamples_ = 0; + } + } else { + recoverySamples_ = 0; + } + } + return level_; +} + +MemoryPressureMonitor::MemoryPressureMonitor(QObject *parent, Sampler sampler) + : QObject(parent), sampler_(sampler ? std::move(sampler) : Sampler(readSystemMemory)) { + qRegisterMetaType(); + timer_.setInterval(SampleIntervalMs); + timer_.setTimerType(Qt::PreciseTimer); + connect(&timer_, &QTimer::timeout, this, &MemoryPressureMonitor::pollNow); +} +void MemoryPressureMonitor::start() { + if (timer_.isActive()) + return; + timer_.start(); + pollNow(); +} +void MemoryPressureMonitor::stop() { + timer_.stop(); +} +void MemoryPressureMonitor::pollNow() { + observeSample(sampler_()); +} +void MemoryPressureMonitor::observeSample(const MemorySample &sample) { + lastSample_ = sample; + const auto old = policy_.level(); + const auto next = policy_.observe(sample); + if (next != old) + emit levelChanged(next); +} +} // namespace docview diff --git a/src/common/memory_pressure.h b/src/common/memory_pressure.h new file mode 100644 index 0000000..66dbc1f --- /dev/null +++ b/src/common/memory_pressure.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include + +namespace docview { + +enum class MemoryPressureLevel { Normal, NoPrefetch, Trim, Reduced, Stop }; + +struct MemorySample { + quint64 totalBytes = 0; + quint64 availableBytes = 0; + bool valid = false; +}; + +// MemAvailable includes reclaimable memory; MemFree is deliberately not a +// substitute. Malformed, incomplete and oversized samples are unavailable. +MemorySample parseLinuxMemoryInfo(const QByteArray &data); +MemorySample readSystemMemory(); + +class MemoryPressurePolicy { + public: + static constexpr int SustainedSamplesPerStage = 5; + static constexpr int RecoverySamples = 5; + MemoryPressureLevel level() const { + return level_; + } + MemoryPressureLevel observe(const MemorySample &sample); + + private: + MemoryPressureLevel level_ = MemoryPressureLevel::Normal; + int pressureSamples_ = 0; + int recoverySamples_ = 0; +}; + +// The source can be replaced by a deterministic sampler without allocating +// memory. start() polls immediately, then once per second; stop() retains state. +// Each pressure transition advances at most one stage, including critical input. +class MemoryPressureMonitor : public QObject { + Q_OBJECT + public: + using Sampler = std::function; + static constexpr int SampleIntervalMs = 1000; + explicit MemoryPressureMonitor(QObject *parent = nullptr, Sampler sampler = {}); + MemoryPressureLevel level() const { + return policy_.level(); + } + MemorySample lastSample() const { + return lastSample_; + } + bool running() const { + return timer_.isActive(); + } + void start(); + void stop(); + void pollNow(); + void observeSample(const MemorySample &sample); + signals: + void levelChanged(docview::MemoryPressureLevel level); + + private: + MemoryPressurePolicy policy_; + Sampler sampler_; + MemorySample lastSample_; + QTimer timer_; +}; + +} // namespace docview + +Q_DECLARE_METATYPE(docview::MemoryPressureLevel) diff --git a/src/common/sandbox.cpp b/src/common/sandbox.cpp new file mode 100644 index 0000000..ad89b72 --- /dev/null +++ b/src/common/sandbox.cpp @@ -0,0 +1,162 @@ +#include "sandbox.h" +#ifdef Q_OS_WIN +#include "windows_sandbox.h" +#endif +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#include +#include +#include +#include +#include +#include +#endif +namespace docview { +bool webSandboxEnvironmentSafe(QString *error) { + const QString flags = + qEnvironmentVariable("QTWEBENGINE_CHROMIUM_FLAGS") + " " + QCoreApplication::arguments().join(' '); + if (qEnvironmentVariableIsSet("QTWEBENGINE_DISABLE_SANDBOX") || flags.contains("--no-sandbox") || + flags.contains("--disable-setuid-sandbox") || flags.contains("--disable-seccomp-filter-sandbox") || + flags.contains("--single-process") || flags.contains("--in-process-gpu") || + qEnvironmentVariableIsSet("QTWEBENGINE_REMOTE_DEBUGGING")) { + if (error) + *error = QCoreApplication::translate("Sandbox", "E_SANDBOX_UNAVAILABLE: 保護を無効にする起動設定が指定されています"); + return false; + } + return true; +} +bool enterSandbox(const QStringList &readPaths, const QStringList &writePaths, QString *error, + const QString &expectedWindowsSid) { + const auto fail = [&](const char *s) { + if (error) + *error = QStringLiteral("E_SANDBOX_UNAVAILABLE: ") + QString::fromLatin1(s); + return false; + }; +#ifdef Q_OS_LINUX + Q_UNUSED(expectedWindowsSid); + const int abi = int(syscall(SYS_landlock_create_ruleset, nullptr, 0, LANDLOCK_CREATE_RULESET_VERSION)); + if (abi < 3) + return fail("Landlock ABI 3 or newer required"); + const quint64 read = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + quint64 all = read | LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM | LANDLOCK_ACCESS_FS_REFER | + LANDLOCK_ACCESS_FS_TRUNCATE; + landlock_ruleset_attr attr{}; + attr.handled_access_fs = all; + const int rules = int(syscall(SYS_landlock_create_ruleset, &attr, sizeof(attr), 0)); + if (rules < 0) + return fail("cannot create Landlock rules"); + auto allow = [&](const QString &path, bool writable) { + const QByteArray native = QFile::encodeName(path); + const int fd = ::open(native.constData(), O_PATH | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return false; + landlock_path_beneath_attr rule{}; + rule.parent_fd = fd; + const bool dir = QFileInfo(path).isDir(); + rule.allowed_access = dir ? read : quint64(LANDLOCK_ACCESS_FS_READ_FILE); + if (writable) + rule.allowed_access |= LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_TRUNCATE | + (dir ? (LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REFER) + : 0); + const int rc = int(syscall(SYS_landlock_add_rule, rules, LANDLOCK_RULE_PATH_BENEATH, &rule, 0)); + ::close(fd); + return rc == 0; + }; + for (const auto &path : readPaths) + if (QFileInfo::exists(path) && !allow(path, false)) { + ::close(rules); + return fail("cannot allow read resource"); + } + for (const auto &path : writePaths) + if (!allow(path, true)) { + ::close(rules); + return fail("cannot allow output resource"); + } + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || syscall(SYS_landlock_restrict_self, rules, 0)) { + ::close(rules); + return fail("cannot enforce Landlock"); + } + ::close(rules); + rlimit memory{1536ull * 1024 * 1024, 1536ull * 1024 * 1024}; + rlimit files{256, 256}; + rlimit core{0, 0}; + if (setrlimit(RLIMIT_AS, &memory) || setrlimit(RLIMIT_NOFILE, &files) || setrlimit(RLIMIT_CORE, &core)) + return fail("cannot set resource limits"); + scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW); + if (!ctx) + return fail("cannot create seccomp"); + const char *denied[] = {"kill", + "tkill", + "tgkill", + "pidfd_open", + "pidfd_send_signal", + "process_madvise", + "socket", + "socketpair", + "connect", + "bind", + "listen", + "accept", + "accept4", + "fork", + "vfork", + "clone", + "clone3", + "execve", + "execveat", + "ptrace", + "process_vm_readv", + "process_vm_writev", + "mount", + "umount2", + "pivot_root", + "unshare", + "setns", + "bpf", + "userfaultfd", + "keyctl", + "add_key", + "request_key", + "io_uring_setup", + "open_by_handle_at", + "name_to_handle_at", + "reboot", + "kexec_load", + "init_module", + "finit_module", + "delete_module"}; + for (const char *name : denied) { + int nr = seccomp_syscall_resolve_name(name); + if (nr != __NR_SCMP_ERROR && seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), nr, 0) < 0) { + seccomp_release(ctx); + return fail("cannot add seccomp rule"); + } + } + if (seccomp_attr_set(ctx, SCMP_FLTATR_CTL_TSYNC, 1) < 0 || seccomp_load(ctx) < 0) { + seccomp_release(ctx); + return fail("cannot enforce seccomp"); + } + seccomp_release(ctx); + return true; +#elif defined(Q_OS_WIN) + Q_UNUSED(readPaths); + Q_UNUSED(writePaths); + return verifyWindowsWorkerSandbox(expectedWindowsSid, error); +#else + Q_UNUSED(readPaths); + Q_UNUSED(writePaths); + Q_UNUSED(expectedWindowsSid); + return fail("this build has no verified OS sandbox launcher"); +#endif +} +} // namespace docview diff --git a/src/common/sandbox.h b/src/common/sandbox.h new file mode 100644 index 0000000..0cccc08 --- /dev/null +++ b/src/common/sandbox.h @@ -0,0 +1,7 @@ +#pragma once +#include +namespace docview { +bool enterSandbox(const QStringList &readPaths, const QStringList &writePaths, QString *error, + const QString &expectedWindowsSid = {}); +bool webSandboxEnvironmentSafe(QString *error); +} // namespace docview diff --git a/src/common/session_storage.cpp b/src/common/session_storage.cpp new file mode 100644 index 0000000..cbcdeca --- /dev/null +++ b/src/common/session_storage.cpp @@ -0,0 +1,56 @@ +#include "session_storage.h" +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#endif +namespace docview { +void cleanExpiredSessions(const QString &base) { + QFileInfo baseInfo(base); + if (baseInfo.isSymLink() || !baseInfo.isDir()) + return; + QDir dir(baseInfo.canonicalFilePath()); + for (const auto &info : dir.entryInfoList({"docview-*"}, QDir::Dirs | QDir::NoDotAndDotDot)) { + if (info.isSymLink() || info.canonicalPath() != dir.absolutePath()) + continue; +#ifdef Q_OS_UNIX + if (info.ownerId() != getuid()) + continue; +#endif + QFileInfo markerInfo(info.filePath() + "/.docview-session"); + if (markerInfo.isSymLink() || !markerInfo.isFile() || markerInfo.size() > 128) + continue; + QFile marker(markerInfo.filePath()); + if (!marker.open(QIODevice::ReadOnly) || marker.readAll() != "docview-session-v1\n") + continue; + marker.close(); + QLockFile lock(info.filePath() + "/.lock"); + if (!lock.tryLock(0)) + continue; + // Windows QLockFile opens .lock without FILE_SHARE_DELETE. Keep both + // lease files until the payload is gone; a partial failure must retain + // the marker so a later launch can retry reclaiming the directory. + QDir session(info.filePath()); + if (!session.isReadable()) + continue; + bool removed = true; + for (const auto &entry : + session.entryInfoList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot)) { + if (entry.fileName() == ".lock" || entry.fileName() == ".docview-session") + continue; + const bool success = entry.isDir() && !entry.isSymLink() + ? QDir(entry.filePath()).removeRecursively() + : QFile::remove(entry.filePath()); + removed = success && removed; + } + if (!removed || !QFile::remove(markerInfo.filePath())) + continue; + lock.unlock(); + // Only remove an empty directory after dropping the lease. A newly + // acquired lease or any unexpected contents make this fail safely. + QDir().rmdir(info.filePath()); + } +} +} // namespace docview diff --git a/src/common/session_storage.h b/src/common/session_storage.h new file mode 100644 index 0000000..54c0e36 --- /dev/null +++ b/src/common/session_storage.h @@ -0,0 +1,6 @@ +#pragma once +#include +namespace docview { +// Removes only expired directories created by this application and owned by this user. +void cleanExpiredSessions(const QString &base); +} // namespace docview diff --git a/src/common/single_instance.cpp b/src/common/single_instance.cpp new file mode 100644 index 0000000..c469dd2 --- /dev/null +++ b/src/common/single_instance.cpp @@ -0,0 +1,426 @@ +#include "single_instance.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_LINUX +#include +#include +#include +#include +#include +#elif defined(Q_OS_WIN) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#endif + +namespace docview { +namespace { +constexpr int maximumFrame = 8192; + +bool validPaths(const QStringList &paths) +{ + if (paths.size() > 16) + return false; + for (const auto &path : paths) { + if (path.isEmpty() || path.size() > 4096 || path.contains(QChar::Null) + || !QDir::isAbsolutePath(path) || path.startsWith(':') || path.startsWith("//") || path.startsWith("\\\\")) + return false; + } + return true; +} + +QCborMap openRequest(const QStringList &paths, int initialPage) +{ + QCborMap request{{"version", 1}, {"operation", "open"}, {"paths", QCborArray::fromStringList(paths)}}; + if (initialPage) + request.insert(QStringLiteral("initialPage"), initialPage); + return request; +} + +QByteArray encode(const QCborMap &map) +{ + const auto payload = QCborValue(map).toCbor(); + if (payload.size() > maximumFrame) + return {}; + QByteArray frame(4, '\0'); + qToBigEndian(static_cast(payload.size()), frame.data()); + return frame + payload; +} + +bool decode(const QByteArray &bytes, QCborMap &map) +{ + QCborStreamReader reader(bytes); + const auto value = QCborValue::fromCbor(reader); + if (reader.lastError() != QCborError::NoError || reader.currentOffset() != bytes.size() || !value.isMap()) + return false; + map = value.toMap(); + return true; +} + +#ifdef Q_OS_WIN +std::vector userSid(HANDLE process) +{ + HANDLE token = nullptr; + if (!OpenProcessToken(process, TOKEN_QUERY, &token)) + return {}; + DWORD bytes = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector details(bytes), sid; + if (bytes && GetTokenInformation(token, TokenUser, details.data(), bytes, &bytes)) { + const auto source = reinterpret_cast(details.data())->User.Sid; + sid.resize(GetLengthSid(source)); + if (!CopySid(static_cast(sid.size()), sid.data(), source)) + sid.clear(); + } + CloseHandle(token); + return sid; +} + +bool privateWindowsDirectory(const QString &path) +{ + const auto user = userSid(GetCurrentProcess()); + if (user.empty()) + return false; + PSID owner = nullptr; + PACL dacl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + auto native = QDir::toNativeSeparators(path).toStdWString(); + if (GetNamedSecurityInfoW(native.data(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor) != ERROR_SUCCESS) + return false; + bool good = owner && EqualSid(owner, const_cast(user.data())) && dacl; + for (DWORD index = 0; good && index < dacl->AceCount; ++index) { + void *raw = nullptr; + if (!GetAce(dacl, index, &raw)) { good = false; break; } + const auto *header = static_cast(raw); + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + const auto *ace = static_cast(raw); + const auto sid = const_cast(&ace->SidStart); + good = EqualSid(sid, const_cast(user.data())) + || IsWellKnownSid(sid, WinLocalSystemSid) || IsWellKnownSid(sid, WinBuiltinAdministratorsSid); + } else if (header->AceType != ACCESS_DENIED_ACE_TYPE) { + good = false; // Reject compound/callback/object grants we have not validated. + } + } + LocalFree(descriptor); + return good; +} +#endif + +bool peerIsCurrentUser(QLocalSocket *socket, bool serverSide) +{ +#ifdef Q_OS_LINUX + Q_UNUSED(serverSide); + struct ucred credentials {}; + socklen_t size = sizeof(credentials); + return ::getsockopt(static_cast(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &credentials, &size) == 0 + && size == sizeof(credentials) && credentials.uid == ::geteuid(); +#elif defined(Q_OS_WIN) + ULONG processId = 0; + const auto pipe = reinterpret_cast(socket->socketDescriptor()); + if (!(serverSide ? GetNamedPipeClientProcessId(pipe, &processId) : GetNamedPipeServerProcessId(pipe, &processId))) + return false; + const auto current = userSid(GetCurrentProcess()); + const HANDLE peer = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (!peer) + return false; + const auto remote = userSid(peer); + CloseHandle(peer); + return !current.empty() && !remote.empty() + && EqualSid(const_cast(current.data()), const_cast(remote.data())); +#else + Q_UNUSED(socket); Q_UNUSED(serverSide); + return false; +#endif +} + +} // namespace + +SingleInstance::SingleInstance(QObject *parent) : QObject(parent) +{ + m_server.setSocketOptions(QLocalServer::UserAccessOption); + m_server.setMaxPendingConnections(8); + connect(&m_server, &QLocalServer::newConnection, this, &SingleInstance::acceptConnections); +} + +SingleInstance::~SingleInstance() { stop(); } + +bool SingleInstance::prepareDirectory(const QString &directory) +{ + if (directory.isEmpty() || !QDir::isAbsolutePath(directory) || QFileInfo(directory).isSymLink()) + return false; + const bool existed = QFileInfo::exists(directory); + if (!existed && !QDir().mkpath(directory)) + return false; +#ifdef Q_OS_LINUX + if (!existed && !QFile::setPermissions(directory, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner)) + return false; + m_directoryFd = ::open(QFile::encodeName(directory).constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + struct stat details {}; + if (m_directoryFd < 0 || ::fstat(m_directoryFd, &details) != 0 || !S_ISDIR(details.st_mode) + || details.st_uid != ::geteuid() || (details.st_mode & 0077) != 0) + return false; + // Pin the verified inode and avoid sockaddr_un's short pathname limit even + // for long/non-ASCII XDG paths. Every process resolves its own proc fd alias. + const auto pinned = QStringLiteral("/proc/self/fd/%1").arg(m_directoryFd); + m_endpoint = pinned + "/i"; + m_lockPath = pinned + "/instance.lock"; + if (::fstatat(m_directoryFd, "instance.lock", &details, AT_SYMLINK_NOFOLLOW) == 0) { + if (!S_ISREG(details.st_mode) || details.st_uid != ::geteuid() || details.st_nlink != 1) + return false; + } else if (errno != ENOENT) { + return false; + } + return true; +#elif defined(Q_OS_WIN) + if (!privateWindowsDirectory(directory)) + return false; + const auto sid = userSid(GetCurrentProcess()); + const auto key = QFileInfo(directory).canonicalFilePath().toUtf8() + + QByteArray(reinterpret_cast(sid.data()), static_cast(sid.size())); + m_endpoint = "docview-" + QString::fromLatin1(QCryptographicHash::hash(key, QCryptographicHash::Sha256).toHex().left(32)); + m_lockPath = QDir(directory).filePath("instance.lock"); + return !QFileInfo(m_lockPath).isSymLink(); +#else + Q_UNUSED(existed); + return false; +#endif +} + +SingleInstance::ForwardResult SingleInstance::forward(const QStringList &paths, int timeoutMs, int initialPage) +{ + QLocalSocket socket; + socket.setReadBufferSize(maximumFrame + 4); + socket.connectToServer(m_endpoint, QIODevice::ReadWrite); + if (!socket.waitForConnected(timeoutMs)) + return ForwardResult::Unavailable; + if (!peerIsCurrentUser(&socket, false)) + return ForwardResult::Rejected; + const auto frame = encode(openRequest(paths, initialPage)); + if (frame.isEmpty() || socket.write(frame) != frame.size()) + return ForwardResult::Rejected; + QElapsedTimer elapsed; + elapsed.start(); + QByteArray response; + while (elapsed.elapsed() < timeoutMs) { + if (!socket.bytesAvailable() && !socket.waitForReadyRead(qMax(1, timeoutMs - static_cast(elapsed.elapsed())))) + break; + response += socket.read(maximumFrame + 5 - response.size()); + if (response.size() < 4) + continue; + const auto size = qFromBigEndian(response.constData()); + if (size == 0 || size > maximumFrame || response.size() > size + 4) + return ForwardResult::Rejected; + if (response.size() == size + 4) { + QCborMap reply; + return decode(response.mid(4), reply) && reply.size() == 2 && reply.value("version").isInteger() + && reply.value("version").toInteger() == 1 && reply.value("accepted").isBool() + && reply.value("accepted").toBool() ? ForwardResult::Accepted : ForwardResult::Rejected; + } + } + return ForwardResult::Rejected; +} + +bool SingleInstance::staleEndpointSafeToRemove() +{ +#ifdef Q_OS_LINUX + struct stat details {}; + if (::fstatat(m_directoryFd, "i", &details, AT_SYMLINK_NOFOLLOW) < 0) + return errno == ENOENT; + if (!S_ISSOCK(details.st_mode) || details.st_uid != ::geteuid() || details.st_nlink != 1) + return false; + return ::unlinkat(m_directoryFd, "i", 0) == 0; +#elif defined(Q_OS_WIN) + return true; // A named pipe vanishes when its owner closes it; never remove a global endpoint. +#else + return false; +#endif +} + +SingleInstance::StartResult SingleInstance::start(const QString &directory, const QStringList &paths, + int timeoutMs, int initialPage) +{ + if (m_primary) + return StartResult::Primary; + stop(); + m_error.clear(); + timeoutMs = qBound(100, timeoutMs, 3000); + if (initialPage < 0 || initialPage > 100000 || (initialPage && paths.size() != 1)) { + m_error = "An initial page must be between 1 and 100000 and requires exactly one local path"; + return StartResult::ReadOnly; + } + if (!validPaths(paths) || encode(openRequest(paths, initialPage)).isEmpty()) { + m_error = "Open request must contain at most 16 absolute local paths and fit within 8 KiB"; + return StartResult::ReadOnly; + } + if (!prepareDirectory(directory)) { + m_error = "Single-instance state directory is not private, owned, and free of links"; + stop(); + return StartResult::ReadOnly; + } + const auto initial = forward(paths, timeoutMs, initialPage); + if (initial != ForwardResult::Unavailable) { + if (initial == ForwardResult::Rejected) + m_error = "Existing instance could not safely acknowledge the open request; starting read-only"; + stop(); + return initial == ForwardResult::Accepted ? StartResult::Forwarded : StartResult::ReadOnly; + } + m_lock = std::make_unique(m_lockPath); + m_lock->setStaleLockTime(0); + if (!m_lock->tryLock(0)) { + // The primary may still be between its lock and listen calls. + const auto retry = forward(paths, timeoutMs, initialPage); + if (retry != ForwardResult::Accepted) + m_error = "Another instance owns the writer lock but cannot be reached; starting read-only"; + stop(); + return retry == ForwardResult::Accepted ? StartResult::Forwarded : StartResult::ReadOnly; + } + // Never remove a socket that actually accepted a connection, even when the + // lock is free: it may belong to a process with an incompatible protocol. + const auto probe = forward(paths, timeoutMs, initialPage); + if (probe != ForwardResult::Unavailable) { + if (probe == ForwardResult::Rejected) + m_error = "The existing endpoint uses an incompatible protocol; starting read-only"; + stop(); + return probe == ForwardResult::Accepted ? StartResult::Forwarded : StartResult::ReadOnly; + } + if (!staleEndpointSafeToRemove() || !m_server.listen(m_endpoint)) { + m_error = "Cannot create a private instance endpoint; starting read-only"; + stop(); + return StartResult::ReadOnly; + } +#ifdef Q_OS_LINUX + struct stat details {}; + if (::fstatat(m_directoryFd, "i", &details, AT_SYMLINK_NOFOLLOW) < 0 || !S_ISSOCK(details.st_mode) + || details.st_uid != ::geteuid() || (details.st_mode & 0077) != 0) { + m_error = "Instance socket ownership or permissions did not match the policy"; + stop(); + return StartResult::ReadOnly; + } + m_socketDevice = details.st_dev; + m_socketInode = details.st_ino; +#endif + m_primary = true; + return StartResult::Primary; +} + +void SingleInstance::acceptConnections() +{ + while (m_server.hasPendingConnections()) { + auto *socket = m_server.nextPendingConnection(); + if (!socket) + continue; + if (m_clients >= 8 || !peerIsCurrentUser(socket, true)) { + socket->abort(); socket->deleteLater(); continue; + } + ++m_clients; + socket->setReadBufferSize(maximumFrame + 4); + auto buffer = std::make_shared(); + auto done = std::make_shared(false); + auto *deadline = new QTimer(socket); + deadline->setSingleShot(true); + connect(deadline, &QTimer::timeout, socket, &QLocalSocket::abort); + deadline->start(1500); + connect(socket, &QLocalSocket::disconnected, this, [this, socket] { + --m_clients; socket->deleteLater(); + }); + auto consume = [this, socket, buffer, done, deadline] { + if (*done) + return; + *buffer += socket->read(maximumFrame + 5 - buffer->size()); + if (buffer->size() < 4) + return; + const auto size = qFromBigEndian(buffer->constData()); + if (!size || size > maximumFrame || buffer->size() > size + 4) { + *done = true; socket->abort(); return; + } + if (buffer->size() < size + 4) + return; + *done = true; + QCborMap request; + QStringList paths; + const bool decoded = decode(buffer->mid(4), request); + const bool hasPage = request.contains(QStringLiteral("initialPage")); + bool valid = decoded && request.size() == (hasPage ? 4 : 3) + && request.value("version").isInteger() && request.value("version").toInteger() == 1 + && request.value("operation").isString() && request.value("operation").toString() == "open" + && request.value("paths").isArray() && request.value("paths").toArray().size() <= 16; + if (valid) { + for (const auto &value : request.value("paths").toArray()) { + if (!value.isString()) { valid = false; break; } + paths.append(value.toString()); + } + valid = valid && validPaths(paths); + } + int initialPage = 0; + if (valid && hasPage) { + const auto page = request.value("initialPage"); + valid = page.isInteger() && page.toInteger() >= 1 && page.toInteger() <= 100000 + && paths.size() == 1; + if (valid) + initialPage = int(page.toInteger()); + } + if (!valid) { socket->abort(); return; } + deadline->stop(); + const auto response = encode({{"version", 1}, {"accepted", true}}); + if (socket->write(response) != response.size()) { socket->abort(); return; } + socket->flush(); + if (initialPage) + emit openAtPageRequested(paths.front(), initialPage); + else + emit openRequested(paths); + socket->disconnectFromServer(); + }; + connect(socket, &QLocalSocket::readyRead, this, consume); + consume(); + } +} + +void SingleInstance::stop() +{ +#ifdef Q_OS_LINUX + // Qt's Unix close removes its pathname without checking its inode. Suppress + // that generic cleanup and remove only the socket we recorded via the pinned + // directory handle. This flag is changed only after listening, never used to + // create an abstract socket or relax permissions on the actual endpoint. + m_server.setSocketOptions(QLocalServer::UserAccessOption | QLocalServer::AbstractNamespaceOption); + m_server.close(); + if (m_directoryFd >= 0 && m_socketInode) { + struct stat current {}; + if (::fstatat(m_directoryFd, "i", ¤t, AT_SYMLINK_NOFOLLOW) == 0 + && S_ISSOCK(current.st_mode) && current.st_uid == ::geteuid() + && current.st_dev == m_socketDevice && current.st_ino == m_socketInode) + ::unlinkat(m_directoryFd, "i", 0); + } + m_server.setSocketOptions(QLocalServer::UserAccessOption); +#else + m_server.close(); +#endif + m_lock.reset(); +#ifdef Q_OS_LINUX + if (m_directoryFd >= 0) + ::close(m_directoryFd); +#endif + m_directoryFd = -1; + m_socketDevice = m_socketInode = 0; + m_primary = false; + for (auto *socket : m_server.findChildren()) + socket->abort(); +} + +} // namespace docview diff --git a/src/common/single_instance.h b/src/common/single_instance.h new file mode 100644 index 0000000..9088ef3 --- /dev/null +++ b/src/common/single_instance.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace docview { + +class SingleInstance : public QObject { + Q_OBJECT +public: + enum class StartResult { Primary, Forwarded, ReadOnly }; + Q_ENUM(StartResult) + explicit SingleInstance(QObject *parent = nullptr); + ~SingleInstance() override; + StartResult start(const QString &stateDirectory, const QStringList &absolutePaths = {}, + int timeoutMs = 750, int initialPage = 0); + bool isPrimary() const { return m_primary; } + QString lastError() const { return m_error; } + QString endpoint() const { return m_endpoint; } + void stop(); +signals: + // Empty paths means activate the existing window without opening a document. + void openRequested(const QStringList &absolutePaths); + // Physical page numbers are one-based and apply to exactly one path. + void openAtPageRequested(const QString &absolutePath, int initialPage); +private: + enum class ForwardResult { Unavailable, Accepted, Rejected }; + bool prepareDirectory(const QString &directory); + ForwardResult forward(const QStringList &paths, int timeoutMs, int initialPage); + bool staleEndpointSafeToRemove(); + void acceptConnections(); + QLocalServer m_server; + std::unique_ptr m_lock; + QString m_endpoint, m_lockPath, m_error; + bool m_primary = false; + int m_directoryFd = -1; + quint64 m_socketDevice = 0, m_socketInode = 0; + int m_clients = 0; +}; + +} // namespace docview diff --git a/src/common/windows_pipe.cpp b/src/common/windows_pipe.cpp new file mode 100644 index 0000000..6e06439 --- /dev/null +++ b/src/common/windows_pipe.cpp @@ -0,0 +1,413 @@ +#include "windows_pipe.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#endif + +namespace docview { +namespace { +void closeHandle(quintptr handle) { +#ifdef Q_OS_WIN + if (handle && reinterpret_cast(handle) != INVALID_HANDLE_VALUE) + CloseHandle(reinterpret_cast(handle)); +#else + Q_UNUSED(handle); +#endif +} +} // namespace + +WindowsPipeHandles::WindowsPipeHandles(quintptr readHandle, quintptr writeHandle) + : read_(readHandle), write_(writeHandle) {} +WindowsPipeHandles::~WindowsPipeHandles() { + close(); +} +WindowsPipeHandles::WindowsPipeHandles(WindowsPipeHandles &&other) noexcept + : read_(std::exchange(other.read_, 0)), write_(std::exchange(other.write_, 0)) {} +WindowsPipeHandles &WindowsPipeHandles::operator=(WindowsPipeHandles &&other) noexcept { + if (this != &other) { + close(); + read_ = std::exchange(other.read_, 0); + write_ = std::exchange(other.write_, 0); + } + return *this; +} +quintptr WindowsPipeHandles::takeReadHandle() { + return std::exchange(read_, 0); +} +quintptr WindowsPipeHandles::takeWriteHandle() { + return std::exchange(write_, 0); +} +void WindowsPipeHandles::close() { + closeHandle(read_); + if (write_ != read_) + closeHandle(write_); + read_ = write_ = 0; +} + +#ifdef Q_OS_WIN +namespace { +struct Handle { + HANDLE value = nullptr; + ~Handle() { + closeHandle(reinterpret_cast(value)); + } + quintptr take() { + return reinterpret_cast(std::exchange(value, nullptr)); + } + bool valid() const { + return value && value != INVALID_HANDLE_VALUE; + } +}; +struct LocalMemory { + HLOCAL value = nullptr; + ~LocalMemory() { + if (value) + LocalFree(value); + } +}; +bool pipeDirection(PSECURITY_DESCRIPTOR descriptor, Handle &reader, Handle &writer) { + const auto name = + (QStringLiteral("\\\\.\\pipe\\docview-private-") + QUuid::createUuid().toString(QUuid::WithoutBraces)) + .toStdWString(); + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE}; + reader.value = CreateNamedPipeW( + name.c_str(), PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, 65536, 65536, 0, + &attributes); + if (!reader.valid()) + return false; + writer.value = CreateFileW(name.c_str(), GENERIC_WRITE, 0, &attributes, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_ANONYMOUS, nullptr); + if (!writer.valid()) + return false; + Handle event{CreateEventW(nullptr, TRUE, FALSE, nullptr)}; + if (!event.valid()) + return false; + OVERLAPPED connection{}; + connection.hEvent = event.value; + // Our client is already connected. Never accept another client's connection. + if (!ConnectNamedPipe(reader.value, &connection)) { + const DWORD code = GetLastError(); + if (code == ERROR_IO_PENDING) { + CancelIoEx(reader.value, &connection); + DWORD transferred = 0; + GetOverlappedResult(reader.value, &connection, &transferred, TRUE); + return false; + } + if (code != ERROR_PIPE_CONNECTED) + return false; + } + ULONG peer = 0; + return GetNamedPipeClientProcessId(reader.value, &peer) && peer == GetCurrentProcessId(); +} +struct Operation { + OVERLAPPED overlapped{}; + std::array bytes{}; + DWORD size = 0; + bool pending = false; + Operation() { + overlapped.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + } + ~Operation() { + closeHandle(reinterpret_cast(overlapped.hEvent)); + } +}; +struct NativeIo { + WindowsPipeHandles handles; + Operation read, write; + ~NativeIo() = default; + void cancel() { + if (read.pending) + CancelIoEx(reinterpret_cast(handles.readHandle()), &read.overlapped); + if (write.pending) + CancelIoEx(reinterpret_cast(handles.writeHandle()), &write.overlapped); + } + void drainCancellation() { + DWORD ignored = 0; + if (read.pending) + GetOverlappedResult(reinterpret_cast(handles.readHandle()), &read.overlapped, &ignored, + TRUE); + if (write.pending) + GetOverlappedResult(reinterpret_cast(handles.writeHandle()), &write.overlapped, &ignored, + TRUE); + } +}; +} // namespace + +std::optional createWindowsPipePair(QString *error) { + auto bad = [&]() -> std::optional { + if (error) + *error = QCoreApplication::translate("WindowsPipe", "E_SANDBOX_UNAVAILABLE: 専用通信ハンドルを作成できません"); + return std::nullopt; + }; + Handle token; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token.value)) + return bad(); + DWORD size = 0; + GetTokenInformation(token.value, TokenUser, nullptr, 0, &size); + std::vector bytes(size); + if (!size || !GetTokenInformation(token.value, TokenUser, bytes.data(), size, &size)) + return bad(); + LPWSTR sid = nullptr; + if (!ConvertSidToStringSidW(reinterpret_cast(bytes.data())->User.Sid, &sid)) + return bad(); + LocalMemory sidMemory{sid}; + const std::wstring acl = L"D:P(A;;GA;;;SY)(A;;GA;;;" + std::wstring(sid) + L")"; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(acl.c_str(), SDDL_REVISION_1, &descriptor, + nullptr)) + return bad(); + LocalMemory descriptorMemory{descriptor}; + Handle brokerRead, workerWrite, workerRead, brokerWrite; + if (!pipeDirection(descriptor, brokerRead, workerWrite) || + !pipeDirection(descriptor, workerRead, brokerWrite)) + return bad(); + if (error) + error->clear(); + return WindowsPipePair{WindowsPipeHandles(brokerRead.take(), brokerWrite.take()), + WindowsPipeHandles(workerRead.take(), workerWrite.take())}; +} + +struct WindowsPipeDevice::Impl { + std::unique_ptr io; + QWinEventNotifier *readNotifier = nullptr, *writeNotifier = nullptr; + QByteArray incoming; + QQueue outgoing; + qsizetype outgoingOffset = 0; + qint64 queuedBytes = 0; +}; +#else +std::optional createWindowsPipePair(QString *error) { + if (error) + *error = "E_SANDBOX_UNAVAILABLE: Windows pipe transport requires Windows"; + return std::nullopt; +} +struct WindowsPipeDevice::Impl {}; +#endif + +WindowsPipeDevice::WindowsPipeDevice(QObject *parent) : QIODevice(parent), d(std::make_unique()) {} +WindowsPipeDevice::~WindowsPipeDevice() { + close(); +} +bool WindowsPipeDevice::adopt(quintptr readHandle, quintptr writeHandle, QString *error) { + close(); + WindowsPipeHandles ownership(readHandle, writeHandle); +#ifdef Q_OS_WIN + if (readHandle && writeHandle && readHandle != writeHandle && + GetFileType(reinterpret_cast(readHandle)) == FILE_TYPE_PIPE && + GetFileType(reinterpret_cast(writeHandle)) == FILE_TYPE_PIPE) { + auto io = std::make_unique(); + io->handles = std::move(ownership); + if (io->read.overlapped.hEvent && io->write.overlapped.hEvent) { + d->io = std::move(io); + d->readNotifier = new QWinEventNotifier(d->io->read.overlapped.hEvent, this); + d->writeNotifier = new QWinEventNotifier(d->io->write.overlapped.hEvent, this); + d->readNotifier->setEnabled(false); + d->writeNotifier->setEnabled(false); + connect(d->readNotifier, &QWinEventNotifier::activated, this, [this] { completeRead(); }); + connect(d->writeNotifier, &QWinEventNotifier::activated, this, [this] { completeWrite(); }); + QIODevice::open(QIODevice::ReadWrite | QIODevice::Unbuffered); + if (error) + error->clear(); + QTimer::singleShot(0, this, [this] { beginRead(); }); + return true; + } + } +#endif + setErrorString(QCoreApplication::translate("WindowsPipe", "E_SANDBOX_UNAVAILABLE: 継承通信ハンドルが不正です")); + if (error) + *error = errorString(); + return false; +} +qint64 WindowsPipeDevice::bytesAvailable() const { +#ifdef Q_OS_WIN + return QIODevice::bytesAvailable() + d->incoming.size(); +#else + return 0; +#endif +} +qint64 WindowsPipeDevice::bytesToWrite() const { +#ifdef Q_OS_WIN + return d->queuedBytes; +#else + return 0; +#endif +} +void WindowsPipeDevice::close() { +#ifdef Q_OS_WIN + if (d->readNotifier) { + d->readNotifier->setEnabled(false); + d->readNotifier->disconnect(this); + d->readNotifier->deleteLater(); + d->readNotifier = nullptr; + } + if (d->writeNotifier) { + d->writeNotifier->setEnabled(false); + d->writeNotifier->disconnect(this); + d->writeNotifier->deleteLater(); + d->writeNotifier = nullptr; + } + if (d->io) { + d->io->cancel(); + if (d->io->read.pending || d->io->write.pending) { + // CancelIoEx is asynchronous. Keep OVERLAPPED, event, buffer and file + // handles alive until completion without waiting on the GUI thread. + std::thread([io = std::move(d->io)] { io->drainCancellation(); }).detach(); + } else { + d->io.reset(); + } + } + d->incoming.clear(); + d->outgoing.clear(); + d->outgoingOffset = 0; + d->queuedBytes = 0; +#endif + QIODevice::close(); +} +void WindowsPipeDevice::fail(const QString &message) { + if (!isOpen()) + return; + setErrorString(message); + close(); + emit transportError(message); + emit disconnected(); +} +qint64 WindowsPipeDevice::readData(char *data, qint64 maximum) { +#ifdef Q_OS_WIN + const auto size = std::min(maximum, qint64(d->incoming.size())); + if (size > 0) { + std::memcpy(data, d->incoming.constData(), size_t(size)); + d->incoming.remove(0, qsizetype(size)); + QTimer::singleShot(0, this, [this] { beginRead(); }); + } + return size; +#else + Q_UNUSED(data); + Q_UNUSED(maximum); + return -1; +#endif +} +qint64 WindowsPipeDevice::writeData(const char *data, qint64 size) { +#ifdef Q_OS_WIN + if (!isOpen() || size < 0 || size > MaxWriteBuffer - d->queuedBytes) { + setErrorString(QStringLiteral("IPC write buffer limit")); + return -1; + } + if (size) { + d->outgoing.enqueue(QByteArray(data, qsizetype(size))); + d->queuedBytes += size; + beginWrite(); + } + return size; +#else + Q_UNUSED(data); + Q_UNUSED(size); + return -1; +#endif +} +void WindowsPipeDevice::beginRead() { +#ifdef Q_OS_WIN + if (!isOpen() || !d->io || d->io->read.pending || d->incoming.size() >= MaxReadBuffer) + return; + auto &operation = d->io->read; + ResetEvent(operation.overlapped.hEvent); + operation.size = DWORD(std::min(qint64(operation.bytes.size()), MaxReadBuffer - d->incoming.size())); + operation.pending = true; + const bool immediate = ReadFile(reinterpret_cast(d->io->handles.readHandle()), + operation.bytes.data(), operation.size, nullptr, &operation.overlapped); + if (!immediate && GetLastError() != ERROR_IO_PENDING) { + operation.pending = false; + fail(QStringLiteral("IPC read disconnected")); + return; + } + d->readNotifier->setEnabled(true); + if (immediate) + QTimer::singleShot(0, this, [this] { completeRead(); }); +#endif +} +void WindowsPipeDevice::completeRead() { +#ifdef Q_OS_WIN + if (!isOpen() || !d->io || !d->io->read.pending) + return; + auto &operation = d->io->read; + DWORD transferred = 0; + const bool success = GetOverlappedResult(reinterpret_cast(d->io->handles.readHandle()), + &operation.overlapped, &transferred, FALSE); + if (!success && GetLastError() == ERROR_IO_INCOMPLETE) + return; + operation.pending = false; + d->readNotifier->setEnabled(false); + if (!success || transferred == 0 || transferred > operation.size) { + fail(QStringLiteral("IPC read disconnected")); + return; + } + d->incoming.append(operation.bytes.data(), transferred); + emit readyRead(); + beginRead(); +#endif +} +void WindowsPipeDevice::beginWrite() { +#ifdef Q_OS_WIN + if (!isOpen() || !d->io || d->io->write.pending || d->outgoing.isEmpty()) + return; + auto &operation = d->io->write; + ResetEvent(operation.overlapped.hEvent); + const auto &front = d->outgoing.head(); + operation.size = DWORD(std::min(qsizetype(operation.bytes.size()), front.size() - d->outgoingOffset)); + std::memcpy(operation.bytes.data(), front.constData() + d->outgoingOffset, operation.size); + operation.pending = true; + const bool immediate = WriteFile(reinterpret_cast(d->io->handles.writeHandle()), + operation.bytes.data(), operation.size, nullptr, &operation.overlapped); + if (!immediate && GetLastError() != ERROR_IO_PENDING) { + operation.pending = false; + fail(QStringLiteral("IPC write disconnected")); + return; + } + d->writeNotifier->setEnabled(true); + if (immediate) + QTimer::singleShot(0, this, [this] { completeWrite(); }); +#endif +} +void WindowsPipeDevice::completeWrite() { +#ifdef Q_OS_WIN + if (!isOpen() || !d->io || !d->io->write.pending) + return; + auto &operation = d->io->write; + DWORD transferred = 0; + const bool success = GetOverlappedResult(reinterpret_cast(d->io->handles.writeHandle()), + &operation.overlapped, &transferred, FALSE); + if (!success && GetLastError() == ERROR_IO_INCOMPLETE) + return; + operation.pending = false; + d->writeNotifier->setEnabled(false); + if (!success || transferred == 0 || transferred > operation.size) { + fail(QStringLiteral("IPC write disconnected")); + return; + } + d->outgoingOffset += transferred; + d->queuedBytes -= transferred; + if (d->outgoingOffset == d->outgoing.head().size()) { + d->outgoing.dequeue(); + d->outgoingOffset = 0; + } + emit bytesWritten(transferred); + beginWrite(); +#endif +} +} // namespace docview diff --git a/src/common/windows_pipe.h b/src/common/windows_pipe.h new file mode 100644 index 0000000..a580913 --- /dev/null +++ b/src/common/windows_pipe.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace docview { + +// Owns two distinct directional HANDLEs. The broker creates both endpoints before +// launch; the child receives duplicated handles and never connects by pipe name. +class WindowsPipeHandles { +public: + WindowsPipeHandles() = default; + WindowsPipeHandles(quintptr readHandle, quintptr writeHandle); + ~WindowsPipeHandles(); + WindowsPipeHandles(WindowsPipeHandles &&other) noexcept; + WindowsPipeHandles &operator=(WindowsPipeHandles &&other) noexcept; + WindowsPipeHandles(const WindowsPipeHandles &) = delete; + WindowsPipeHandles &operator=(const WindowsPipeHandles &) = delete; + quintptr readHandle() const { return read_; } + quintptr writeHandle() const { return write_; } + quintptr takeReadHandle(); + quintptr takeWriteHandle(); + void close(); +private: + quintptr read_ = 0, write_ = 0; +}; + +struct WindowsPipePair { + WindowsPipeHandles broker; + WindowsPipeHandles worker; +}; +std::optional createWindowsPipePair(QString *error = nullptr); + +class WindowsPipeDevice : public QIODevice { + Q_OBJECT +public: + static constexpr qint64 MaxReadBuffer = 256 * 1024; + static constexpr qint64 MaxWriteBuffer = 16 * 1024 * 1024; + explicit WindowsPipeDevice(QObject *parent = nullptr); + ~WindowsPipeDevice() override; + // Takes ownership on success AND failure. Handles must have been opened with + // FILE_FLAG_OVERLAPPED and must be distinct, read-only/write-only pipe handles. + bool adopt(quintptr readHandle, quintptr writeHandle, QString *error = nullptr); + bool isSequential() const override { return true; } + qint64 bytesAvailable() const override; + qint64 bytesToWrite() const override; + void close() override; +signals: + void transportError(QString message); + void disconnected(); +protected: + qint64 readData(char *data, qint64 maximum) override; + qint64 writeData(const char *data, qint64 size) override; +private: + struct Impl; + std::unique_ptr d; + void beginRead(); + void completeRead(); + void beginWrite(); + void completeWrite(); + void fail(const QString &message); +}; + +} // namespace docview diff --git a/src/common/windows_runtime_staging.cpp b/src/common/windows_runtime_staging.cpp new file mode 100644 index 0000000..c830398 --- /dev/null +++ b/src/common/windows_runtime_staging.cpp @@ -0,0 +1,259 @@ +#include "windows_runtime_staging.h" +#include "session_storage.h" +#include "web/resource_policy.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#endif + +namespace docview { +namespace { +constexpr qint64 maximumManifestBytes = 128 * 1024; +constexpr qint64 maximumFileBytes = 256ll * 1024 * 1024; +constexpr qint64 maximumRuntimeBytes = 512ll * 1024 * 1024; + +bool fail(QString *error, const char *message) { + if (error) + *error = QStringLiteral("E_SANDBOX_UNAVAILABLE: ") + QString::fromLatin1(message); + return false; +} + +bool runtimeName(const QString &name) { + static const QRegularExpression pattern(QStringLiteral("\\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\z")); + return pattern.match(name).hasMatch() && safeResourcePath(name); +} + +QString defaultBaseDirectory() { +#ifdef Q_OS_WIN + PWSTR native = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, nullptr, &native))) + return {}; + const auto result = QDir(QString::fromWCharArray(native)).filePath("DocView/cache/worker-runtimes"); + CoTaskMemFree(native); + return result; +#else + // Native launches use this class only on Windows. Tests supply an explicit + // temporary base instead of touching the user's cache on other platforms. + return {}; +#endif +} + +bool protectOwnedDirectory(const QString &path) { +#ifdef Q_OS_WIN + const auto native = QDir::toNativeSeparators(path).toStdWString(); + HANDLE directory = CreateFileW(native.c_str(), FILE_READ_ATTRIBUTES | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (directory == INVALID_HANDLE_VALUE) + return false; + BY_HANDLE_FILE_INFORMATION information{}; + HANDLE token = nullptr; + bool good = GetFileInformationByHandle(directory, &information) && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && + !(information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) && + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token); + DWORD size = 0; + if (good) + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + std::vector user(size); + good = good && size && GetTokenInformation(token, TokenUser, user.data(), size, &size); + PSID owner = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (good) + good = GetSecurityInfo(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, + nullptr, nullptr, nullptr, &descriptor) == ERROR_SUCCESS; + if (good) + good = owner && EqualSid(owner, reinterpret_cast(user.data())->User.Sid); + PACL acl = nullptr; + if (good) { + EXPLICIT_ACCESSW access{}; + access.grfAccessPermissions = FILE_ALL_ACCESS; + access.grfAccessMode = SET_ACCESS; + access.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_USER; + access.Trustee.ptstrName = reinterpret_cast(reinterpret_cast(user.data())->User.Sid); + good = SetEntriesInAclW(1, &access, nullptr, &acl) == ERROR_SUCCESS && + SetSecurityInfo(directory, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, acl, nullptr) == ERROR_SUCCESS; + } + if (acl) LocalFree(acl); + if (descriptor) LocalFree(descriptor); + if (token) CloseHandle(token); + CloseHandle(directory); + return good; +#else + const QFileInfo info(path); + return !info.isSymLink() && info.isDir() && + QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); +#endif +} +} // namespace + +bool parseWindowsRuntimeManifest(const QByteArray &data, const QString &executableName, + QVector *files, QString *error) { + const auto bad = [&] { return fail(error, "invalid worker runtime manifest"); }; + if (!files || data.isEmpty() || data.size() > maximumManifestBytes || !runtimeName(executableName) || + !executableName.endsWith(".exe", Qt::CaseInsensitive)) + return bad(); + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson(data, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return bad(); + const auto object = document.object(); + if (object.size() != 3 || !object.value("schemaVersion").isDouble() || + object.value("schemaVersion").toDouble() != 1 || object.value("executable").toString() != executableName || + !object.value("files").isArray()) + return bad(); + const auto entries = object.value("files").toArray(); + if (entries.isEmpty() || entries.size() > 128) + return bad(); + static const QRegularExpression digestPattern(QStringLiteral("\\A[0-9a-f]{64}\\z")); + QSet names; + QVector checked; + qint64 total = 0; + bool containsExecutable = false; + for (const auto &value : entries) { + if (!value.isObject()) + return bad(); + const auto entry = value.toObject(); + const auto name = entry.value("path").toString(); + const auto digest = entry.value("sha256").toString(); + const double size = entry.value("size").toDouble(-1); + if (entry.size() != 3 || !runtimeName(name) || + (name != executableName && !name.endsWith(".dll", Qt::CaseInsensitive)) || + names.contains(name.toCaseFolded()) || !digestPattern.match(digest).hasMatch() || + !entry.value("size").isDouble() || !std::isfinite(size) || size < 1 || + size > maximumFileBytes || std::floor(size) != size) + return bad(); + total += static_cast(size); + if (total > maximumRuntimeBytes) + return bad(); + names.insert(name.toCaseFolded()); + containsExecutable |= name == executableName; + checked.push_back({name, digest.toLatin1(), static_cast(size)}); + } + if (!containsExecutable) + return bad(); + *files = std::move(checked); + if (error) error->clear(); + return true; +} + +WindowsRuntimeStaging::WindowsRuntimeStaging(QString baseDirectory) + : baseDirectory_(baseDirectory.isEmpty() ? defaultBaseDirectory() : std::move(baseDirectory)) {} +WindowsRuntimeStaging::~WindowsRuntimeStaging() = default; +QString WindowsRuntimeStaging::directoryPath() const { + return directory_ ? QDir(directory_->path()).filePath("runtime") : QString{}; +} +QString WindowsRuntimeStaging::executablePath() const { + return directory_ ? QDir(directoryPath()).filePath(executableName_) : QString{}; +} + +bool WindowsRuntimeStaging::prepare(const QString &executable, QString *error) { + const auto reject = [&](const char *message) { + lease_.reset(); + directory_.reset(); + executableName_.clear(); + return fail(error, message); + }; + if (directory_) + return fail(error, "a live worker runtime cannot be replaced"); + const QFileInfo sourceInfo(executable); + const QString name = sourceInfo.fileName(); + if (baseDirectory_.isEmpty() || !sourceInfo.isAbsolute() || !runtimeName(name) || + sourceInfo.isSymLink() || !sourceInfo.isFile()) + return reject("invalid worker executable or staging directory"); + const QString sourceDirectory = sourceInfo.absolutePath(); + auto manifest = openResource(sourceDirectory, name + ".runtime.json"); + QVector files; + if (!manifest || manifest->size() > maximumManifestBytes || + !parseWindowsRuntimeManifest(manifest->read(maximumManifestBytes + 1), name, &files, error)) + return reject("cannot verify the worker runtime manifest"); + if (QFileInfo(baseDirectory_).isSymLink() || !QDir().mkpath(baseDirectory_) || + !protectOwnedDirectory(baseDirectory_)) + return reject("cannot create a private worker staging root"); + cleanExpiredSessions(baseDirectory_); + directory_ = std::make_unique(QDir(baseDirectory_).filePath("docview-XXXXXX")); + if (!directory_->isValid() || !protectOwnedDirectory(directory_->path())) + return reject("cannot create a private worker staging directory"); + lease_ = std::make_unique(QDir(directory_->path()).filePath(".lock")); + lease_->setStaleLockTime(0); + if (!lease_->tryLock(0)) + return reject("cannot lease the worker staging directory"); + QFile marker(QDir(directory_->path()).filePath(".docview-session")); + const bool markerWritten = marker.open(QIODevice::WriteOnly | QIODevice::NewOnly) && + marker.write("docview-session-v1\n") == 19; + marker.close(); + if (!markerWritten) + return reject("cannot identify the worker staging directory"); + // Keep the broker's active lease outside the read-only AppContainer tree. + // The launcher's ACL/regular-file inspection must not reopen a locked lease. + if (!QDir().mkpath(directoryPath()) || !protectOwnedDirectory(directoryPath())) + return reject("cannot create the worker runtime subdirectory"); + for (const auto &entry : files) { + // End all open-file lifetimes before removing the directory on failure; + // Windows otherwise retains a staging file held by QSaveFile. + const char *copyError = nullptr; + const auto copy = [&]() { + auto source = openResource(sourceDirectory, entry.path); + if (!source || source->size() != entry.size) { + copyError = "worker runtime file is missing, linked, or has changed size"; + return false; + } + const auto destination = QDir(directoryPath()).filePath(entry.path); + QSaveFile target(destination); + target.setDirectWriteFallback(false); + if (!target.open(QIODevice::WriteOnly)) { + copyError = "cannot create the worker runtime copy"; + return false; + } + QCryptographicHash hash(QCryptographicHash::Sha256); + qint64 copied = 0; + while (!source->atEnd()) { + const auto chunk = source->read(65536); + if (chunk.isEmpty() || copied + chunk.size() > entry.size || target.write(chunk) != chunk.size()) { + copyError = "cannot copy the bounded worker runtime file"; + return false; + } + hash.addData(chunk); + copied += chunk.size(); + } + if (copied != entry.size || hash.result().toHex() != entry.sha256 || !target.commit()) { + copyError = "worker runtime hash verification failed"; + return false; + } + if (!QFile::setPermissions(destination, QFileDevice::ReadOwner | QFileDevice::WriteOwner | + (entry.path == name ? QFileDevice::ExeOwner : QFileDevice::Permissions{}))) { + copyError = "cannot protect the worker runtime copy"; + return false; + } + return true; + }; + if (!copy()) + return reject(copyError); + } + executableName_ = name; + if (error) error->clear(); + return true; +} +} // namespace docview diff --git a/src/common/windows_runtime_staging.h b/src/common/windows_runtime_staging.h new file mode 100644 index 0000000..96d4cd3 --- /dev/null +++ b/src/common/windows_runtime_staging.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +class QLockFile; +class QTemporaryDir; + +namespace docview { +struct WindowsRuntimeFile { + QString path; + QByteArray sha256; + qint64 size = 0; +}; + +// This parser is platform independent so deployment metadata is tested on every +// build. A manifest authorizes only immediate executable/DLL filenames. +bool parseWindowsRuntimeManifest(const QByteArray &data, const QString &executableName, + QVector *files, QString *error = nullptr); + +class WindowsRuntimeStaging { + public: + explicit WindowsRuntimeStaging(QString baseDirectory = {}); + ~WindowsRuntimeStaging(); + WindowsRuntimeStaging(const WindowsRuntimeStaging &) = delete; + WindowsRuntimeStaging &operator=(const WindowsRuntimeStaging &) = delete; + bool prepare(const QString &executable, QString *error = nullptr); + QString executablePath() const; + QString directoryPath() const; + + private: + QString baseDirectory_, executableName_; + std::unique_ptr directory_; + std::unique_ptr lease_; +}; +} // namespace docview diff --git a/src/common/windows_sandbox.cpp b/src/common/windows_sandbox.cpp new file mode 100644 index 0000000..80abf4c --- /dev/null +++ b/src/common/windows_sandbox.cpp @@ -0,0 +1,563 @@ +#include "windows_sandbox.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace docview { + +QString quoteWindowsArgument(const QString &argument) +{ + // Microsoft C/C++ argv quoting. There is no shell, expansion, or cmd.exe. + QString result = "\""; + int backslashes = 0; + for (const QChar character : argument) { + if (character == '\\') { + ++backslashes; + } else { + result += QString(backslashes * (character == '"' ? 2 : 1), '\\'); + if (character == '"') + result += '\\'; + result += character; + backslashes = 0; + } + } + result += QString(backslashes * 2, '\\'); + return result + '"'; +} + +#ifdef Q_OS_WIN +namespace { +constexpr SIZE_T WorkerMemoryLimit = static_cast(1536ULL * 1024 * 1024); +constexpr DWORD WorkerJobFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_PROCESS_MEMORY | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION; +constexpr DWORD WorkerUiRestrictions = JOB_OBJECT_UILIMIT_DESKTOP | JOB_OBJECT_UILIMIT_DISPLAYSETTINGS + | JOB_OBJECT_UILIMIT_EXITWINDOWS | JOB_OBJECT_UILIMIT_GLOBALATOMS | JOB_OBJECT_UILIMIT_HANDLES + | JOB_OBJECT_UILIMIT_READCLIPBOARD | JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS | JOB_OBJECT_UILIMIT_WRITECLIPBOARD; + +struct Handle { + HANDLE value = nullptr; + Handle() = default; + explicit Handle(HANDLE handle) : value(handle) {} + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + Handle(Handle &&other) noexcept : value(std::exchange(other.value, nullptr)) {} + Handle &operator=(Handle &&other) noexcept { + if (this != &other) { + close(); + value = std::exchange(other.value, nullptr); + } + return *this; + } + ~Handle() { close(); } + void close() { + if (value && value != INVALID_HANDLE_VALUE) + CloseHandle(value); + value = nullptr; + } + explicit operator bool() const { return value && value != INVALID_HANDLE_VALUE; } +}; + +struct LocalMemory { + HLOCAL value = nullptr; + ~LocalMemory() { if (value) LocalFree(value); } +}; +struct RegistryKey { + HKEY value = nullptr; + ~RegistryKey() { if (value) RegCloseKey(value); } +}; + +struct Profile { + std::wstring name; + PSID sid = nullptr; + ~Profile() { + if (sid) + FreeSid(sid); + if (!name.empty()) + DeleteAppContainerProfile(name.c_str()); + } +}; + +struct Attributes { + std::vector bytes; + LPPROC_THREAD_ATTRIBUTE_LIST value = nullptr; + ~Attributes() { if (value) DeleteProcThreadAttributeList(value); } + bool initialize(DWORD count) { + SIZE_T size = 0; + InitializeProcThreadAttributeList(nullptr, count, 0, &size); + if (!size) + return false; + bytes.resize(size); + const auto list = reinterpret_cast(bytes.data()); + if (!InitializeProcThreadAttributeList(list, count, 0, &size)) + return false; + value = list; + return true; + } + bool set(DWORD_PTR key, void *data, SIZE_T size) { + return UpdateProcThreadAttribute(value, 0, key, data, size, nullptr, nullptr) != FALSE; + } +}; + +bool strictToken(HANDLE process, PSID expectedSid) +{ + Handle token; + if (!OpenProcessToken(process, TOKEN_QUERY, &token.value)) + return false; + DWORD appContainer = 0; + DWORD length = 0; + if (!GetTokenInformation(token.value, TokenIsAppContainer, &appContainer, sizeof(appContainer), &length) + || appContainer != 1) + return false; + DWORD lessPrivileged = 0; + if (!GetTokenInformation(token.value, TokenIsLessPrivilegedAppContainer, &lessPrivileged, sizeof(lessPrivileged), &length) + || lessPrivileged != 1) + return false; + GetTokenInformation(token.value, TokenAppContainerSid, nullptr, 0, &length); + std::vector container(length); + if (!length || !GetTokenInformation(token.value, TokenAppContainerSid, container.data(), length, &length)) + return false; + const auto *identity = reinterpret_cast(container.data()); + if (!identity->TokenAppContainer || !IsValidSid(identity->TokenAppContainer) + || !EqualSid(identity->TokenAppContainer, expectedSid)) + return false; + GetTokenInformation(token.value, TokenCapabilities, nullptr, 0, &length); + std::vector capabilities(length); + if (!length || !GetTokenInformation(token.value, TokenCapabilities, capabilities.data(), length, &length) + || reinterpret_cast(capabilities.data())->GroupCount != 0) + return false; + GetTokenInformation(token.value, TokenIntegrityLevel, nullptr, 0, &length); + std::vector integrity(length); + if (!length || !GetTokenInformation(token.value, TokenIntegrityLevel, integrity.data(), length, &length)) + return false; + const auto sid = reinterpret_cast(integrity.data())->Label.Sid; + if (!sid || !IsValidSid(sid)) return false; + const auto count = *GetSidSubAuthorityCount(sid); + return count > 0 && *GetSidSubAuthority(sid, count - 1) <= SECURITY_MANDATORY_LOW_RID; +} + +bool strictJob(HANDLE job) +{ + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + JOBOBJECT_BASIC_UI_RESTRICTIONS restrictions{}; + if (!QueryInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits), nullptr) + || !QueryInformationJobObject(job, JobObjectBasicUIRestrictions, &restrictions, sizeof(restrictions), nullptr)) + return false; + const auto flags = limits.BasicLimitInformation.LimitFlags; + return (flags & WorkerJobFlags) == WorkerJobFlags + && !(flags & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)) + && limits.BasicLimitInformation.ActiveProcessLimit == 1 + && limits.ProcessMemoryLimit == WorkerMemoryLimit + && (restrictions.UIRestrictionsClass & WorkerUiRestrictions) == WorkerUiRestrictions; +} + +bool privateAcl(const std::wstring &path, PSID containerSid, PSID ownerSid, bool directory) +{ + // Replace ACLs only on broker staging copies and this newly-created profile. + // The original source is accessed exclusively through a read-only handle. + std::array entries{}; + entries[0].grfAccessPermissions = FILE_ALL_ACCESS; + entries[1].grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + for (auto &entry : entries) { + entry.grfAccessMode = SET_ACCESS; + entry.grfInheritance = directory ? SUB_CONTAINERS_AND_OBJECTS_INHERIT : NO_INHERITANCE; + entry.Trustee.TrusteeForm = TRUSTEE_IS_SID; + entry.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN; + } + entries[0].Trustee.ptstrName = reinterpret_cast(ownerSid); + entries[1].Trustee.ptstrName = reinterpret_cast(containerSid); + entries[1].grfAccessMode = GRANT_ACCESS; + entries[2].Trustee.ptstrName = reinterpret_cast(containerSid); + entries[2].grfAccessMode = DENY_ACCESS; + entries[2].grfAccessPermissions = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | WRITE_OWNER | (directory ? FILE_DELETE_CHILD : 0); + PACL acl = nullptr; + if (SetEntriesInAclW(3, entries.data(), nullptr, &acl) != ERROR_SUCCESS) + return false; + LocalMemory aclOwner{acl}; + if (SetNamedSecurityInfoW(const_cast(path.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr, nullptr, acl, nullptr) != ERROR_SUCCESS) + return false; + return true; +} + +QString profileFolder(PSID sid) +{ + LPWSTR text = nullptr; + if (!ConvertSidToStringSidW(sid, &text)) return {}; + LocalMemory textOwner{text}; + PWSTR folder = nullptr; + if (FAILED(GetAppContainerFolderPath(text, &folder))) return {}; + const auto result = QString::fromWCharArray(folder); + CoTaskMemFree(folder); + return result; +} + +bool restrictRegistryTree(HKEY key, PSID containerSid, PSID ownerSid, size_t &visited, int depth = 0) +{ + if (++visited > 20000 || depth > 64) return false; + std::array entries{}; + for (auto &entry : entries) { + entry.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT; + entry.Trustee.TrusteeForm = TRUSTEE_IS_SID; + entry.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN; + } + entries[0].Trustee.ptstrName = reinterpret_cast(ownerSid); + entries[0].grfAccessMode = SET_ACCESS; + entries[0].grfAccessPermissions = KEY_ALL_ACCESS; + entries[1].Trustee.ptstrName = reinterpret_cast(containerSid); + entries[1].grfAccessMode = DENY_ACCESS; + entries[1].grfAccessPermissions = KEY_SET_VALUE | KEY_CREATE_SUB_KEY | KEY_CREATE_LINK | DELETE | WRITE_DAC | WRITE_OWNER; + entries[2].Trustee.ptstrName = reinterpret_cast(containerSid); + entries[2].grfAccessMode = GRANT_ACCESS; + entries[2].grfAccessPermissions = KEY_READ; + PACL acl = nullptr; + if (SetEntriesInAclW(ULONG(entries.size()), entries.data(), nullptr, &acl) != ERROR_SUCCESS) return false; + LocalMemory aclOwner{acl}; + if (SetSecurityInfo(key, SE_REGISTRY_KEY, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + nullptr, nullptr, acl, nullptr) != ERROR_SUCCESS) return false; + for (DWORD index = 0; ; ++index) { + wchar_t name[256]{}; DWORD length = 256; + const auto status = RegEnumKeyExW(key, index, name, &length, nullptr, nullptr, nullptr, nullptr); + if (status == ERROR_NO_MORE_ITEMS) return true; + if (status != ERROR_SUCCESS) return false; + RegistryKey child; + if (RegOpenKeyExW(key, name, REG_OPTION_OPEN_LINK, KEY_READ | WRITE_DAC, &child.value) != ERROR_SUCCESS) return false; + DWORD type = 0; + const auto link = RegQueryValueExW(child.value, L"SymbolicLinkValue", nullptr, &type, nullptr, nullptr); + if (link == ERROR_SUCCESS && type == REG_LINK) return false; + if (link != ERROR_SUCCESS && link != ERROR_FILE_NOT_FOUND) return false; + if (!restrictRegistryTree(child.value, containerSid, ownerSid, visited, depth + 1)) return false; + } +} + +bool restrictProfileRegistry(HANDLE process, PSID containerSid, PSID ownerSid) +{ + Handle token; + if (!OpenProcessToken(process, TOKEN_QUERY | TOKEN_DUPLICATE, &token.value) + || !ImpersonateLoggedOnUser(token.value)) return false; + RegistryKey key; + const auto result = GetAppContainerRegistryLocation(KEY_READ | WRITE_DAC, &key.value); + // Continuing under an unexpected thread identity is never acceptable. + if (!RevertToSelf()) TerminateProcess(GetCurrentProcess(), ERROR_ACCESS_DENIED); + if (FAILED(result) || !key.value) return false; + size_t visited = 0; + return restrictRegistryTree(key.value, containerSid, ownerSid, visited); +} + +bool profileStorageIsReadOnly(PSID sid) +{ + const auto folder = profileFolder(sid); + if (folder.isEmpty()) return false; + // Probe access rights without creating files or modifying the registry. + // Test each right separately: a denied combined request is not sufficient. + for (const DWORD access : {DWORD(FILE_ADD_FILE), DWORD(FILE_ADD_SUBDIRECTORY), DWORD(FILE_WRITE_ATTRIBUTES), + DWORD(DELETE), DWORD(WRITE_DAC), DWORD(WRITE_OWNER)}) { + Handle probe(CreateFileW(QDir::toNativeSeparators(folder).toStdWString().c_str(), access, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (probe || GetLastError() != ERROR_ACCESS_DENIED) return false; + } + RegistryKey readable; + if (FAILED(GetAppContainerRegistryLocation(KEY_READ, &readable.value))) return false; + for (const REGSAM access : {REGSAM(KEY_SET_VALUE), REGSAM(KEY_CREATE_SUB_KEY), REGSAM(KEY_CREATE_LINK), + REGSAM(DELETE), REGSAM(WRITE_DAC), REGSAM(WRITE_OWNER)}) { + RegistryKey probe; + if (RegOpenKeyExW(readable.value, L"", 0, access, &probe.value) != ERROR_ACCESS_DENIED) return false; + } + return true; +} + +bool configureStagingTree(const QString &root, PSID containerSid, PSID ownerSid) +{ + std::vector directories{QDir::toNativeSeparators(root).toStdWString()}; + size_t count = 0; + while (!directories.empty()) { + const auto directory = directories.back(); + directories.pop_back(); + const DWORD attributes = GetFileAttributesW(directory.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || !(attributes & FILE_ATTRIBUTE_DIRECTORY) + || (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) + return false; + if (!privateAcl(directory, containerSid, ownerSid, true)) + return false; + WIN32_FIND_DATAW entry{}; + HANDLE search = FindFirstFileW((directory + L"\\*").c_str(), &entry); + if (search == INVALID_HANDLE_VALUE) { + if (GetLastError() == ERROR_FILE_NOT_FOUND) continue; + return false; + } + bool valid = true; + do { + if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) + continue; + if (++count > 20000 || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) { + valid = false; + break; + } + const auto path = directory + L"\\" + entry.cFileName; + if (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + directories.push_back(path); + } else { + Handle file(CreateFileW(path.c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + BY_HANDLE_FILE_INFORMATION details{}; + if (!file || !GetFileInformationByHandle(file.value, &details) || details.nNumberOfLinks != 1 + || !privateAcl(path, containerSid, ownerSid, false)) { + valid = false; + break; + } + } + } while (FindNextFileW(search, &entry)); + const auto lastError = GetLastError(); + FindClose(search); + if (!valid || lastError != ERROR_NO_MORE_FILES) + return false; + } + return true; +} + +bool underRoot(const QString &file, const QString &root) +{ + const auto relative = QDir(root).relativeFilePath(file); + return !QDir::isAbsolutePath(relative) && relative != ".." && !relative.startsWith("../"); +} + +bool duplicateRestricted(quintptr source, DWORD access, DWORD type, Handle &target) +{ + const auto handle = reinterpret_cast(source); + if (!handle || handle == INVALID_HANDLE_VALUE || GetFileType(handle) != type) + return false; + return DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), &target.value, access, TRUE, 0) != FALSE; +} + +} // namespace + +struct NativeProcess::Impl { + Profile profile; + Handle process; + Handle job; + DWORD id = 0; + ~Impl() { + if (job) + TerminateJobObject(job.value, ERROR_PROCESS_ABORTED); + if (process) + WaitForSingleObject(process.value, 1000); + // Profile deletion follows handle closure and process shutdown. + process.close(); + job.close(); + } +}; + +NativeProcess::NativeProcess(std::unique_ptr implementation) : d(std::move(implementation)) {} +NativeProcess::~NativeProcess() = default; +bool NativeProcess::running() const { return WaitForSingleObject(d->process.value, 0) == WAIT_TIMEOUT; } +bool NativeProcess::wait(unsigned long milliseconds) const { return WaitForSingleObject(d->process.value, milliseconds) == WAIT_OBJECT_0; } +void NativeProcess::terminate() { TerminateJobObject(d->job.value, ERROR_PROCESS_ABORTED); } +quint32 NativeProcess::processId() const { return d->id; } +quintptr NativeProcess::processHandle() const { return reinterpret_cast(d->process.value); } +quint32 NativeProcess::exitCode() const { + DWORD code = ERROR_PROCESS_ABORTED; + if (!GetExitCodeProcess(d->process.value, &code)) return ERROR_PROCESS_ABORTED; + return code; +} + +bool verifyWindowsWorkerSandbox(const QString &expectedSid, QString *error) +{ + const auto fail = [error](const char *reason) { + if (error) *error = QStringLiteral("E_SANDBOX_UNAVAILABLE: ") + QString::fromLatin1(reason); + return false; + }; + if (expectedSid.isEmpty() || expectedSid.size() > 256 || expectedSid.contains(QChar::Null)) + return fail("missing AppContainer identity"); + PSID sid = nullptr; + if (!ConvertStringSidToSidW(expectedSid.toStdWString().c_str(), &sid)) + return fail("invalid AppContainer identity"); + LocalMemory owner{sid}; + BOOL member = FALSE; + if (!IsValidSid(sid) || !strictToken(GetCurrentProcess(), sid)) + return fail("worker token does not have the required LPAC, identity, capabilities, or integrity level"); + // A null job queries the immediate Job of the calling process, including + // under nested Jobs. Do not trust a caller-supplied handle or boolean. + if (!IsProcessInJob(GetCurrentProcess(), nullptr, &member) || !member || !strictJob(nullptr)) + return fail("worker Job does not enforce the required process, memory, lifetime, or UI limits"); + if (!profileStorageIsReadOnly(sid)) + return fail("worker profile filesystem or registry is not read-only"); + if (error) error->clear(); + return true; +} + +std::unique_ptr launchWindowsWorker(const WindowsWorkerSpec &spec, QString *error) +{ + const auto fail = [error](const char *reason) -> std::unique_ptr { + if (error) + *error = QStringLiteral("E_SANDBOX_UNAVAILABLE: ") + QString::fromLatin1(reason); + return {}; + }; + const auto runtime = QFileInfo(spec.privateRuntimeDirectory).canonicalFilePath(); + const auto executable = QFileInfo(spec.executable).canonicalFilePath(); + if (runtime.isEmpty() || executable.isEmpty() || !underRoot(executable, runtime) + || QFileInfo(spec.privateRuntimeDirectory).isSymLink() || QFileInfo(spec.executable).isSymLink()) + return fail("worker must be a regular file inside its private runtime staging directory"); + auto implementation = std::make_unique(); + implementation->profile.name = ("DocView.Worker." + QUuid::createUuid().toString(QUuid::WithoutBraces)).toStdWString(); + if (FAILED(CreateAppContainerProfile(implementation->profile.name.c_str(), L"DocView document worker", + L"Temporary isolated read-only document processing", nullptr, 0, &implementation->profile.sid))) { + implementation->profile.name.clear(); + return fail("cannot create a unique AppContainer profile"); + } + Handle ownerToken; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &ownerToken.value)) + return fail("cannot determine the staging directory owner"); + DWORD tokenBytes = 0; + GetTokenInformation(ownerToken.value, TokenUser, nullptr, 0, &tokenBytes); + std::vector token(tokenBytes); + if (!tokenBytes || !GetTokenInformation(ownerToken.value, TokenUser, token.data(), tokenBytes, &tokenBytes)) + return fail("cannot determine the current user SID"); + const auto userSid = reinterpret_cast(token.data())->User.Sid; + const auto profile = profileFolder(implementation->profile.sid); + if (profile.isEmpty() || !configureStagingTree(profile, implementation->profile.sid, userSid) + || !configureStagingTree(runtime, implementation->profile.sid, userSid)) + return fail("cannot apply private staging DACLs or found a link in the staging tree"); + + Handle document, input, reply; + if (!duplicateRestricted(spec.documentHandle, FILE_GENERIC_READ, FILE_TYPE_DISK, document) + || !duplicateRestricted(spec.controlReadHandle, FILE_GENERIC_READ, FILE_TYPE_PIPE, input) + || !duplicateRestricted(spec.controlWriteHandle, FILE_GENERIC_WRITE, FILE_TYPE_PIPE, reply)) + return fail("cannot duplicate the required read-only document and directional pipe handles"); + SECURITY_ATTRIBUTES inheritable{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + Handle nullDevice(CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + if (!nullDevice) + return fail("cannot create inert standard streams"); + implementation->job = Handle(CreateJobObjectW(nullptr, nullptr)); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = WorkerJobFlags; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + limits.ProcessMemoryLimit = WorkerMemoryLimit; + if (!implementation->job || !SetInformationJobObject(implementation->job.value, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) + return fail("cannot enforce worker process count, memory, and lifetime limits"); + JOBOBJECT_BASIC_UI_RESTRICTIONS restrictions{}; + restrictions.UIRestrictionsClass = WorkerUiRestrictions; + if (!SetInformationJobObject(implementation->job.value, JobObjectBasicUIRestrictions, &restrictions, sizeof(restrictions))) + return fail("cannot enforce worker desktop restrictions"); + + Attributes attributes; + SECURITY_CAPABILITIES capabilities{}; + capabilities.AppContainerSid = implementation->profile.sid; + std::array inherited{document.value, input.value, reply.value, nullDevice.value}; + DWORD childPolicy = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED; + DWORD packagesPolicy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + if (!attributes.initialize(4) + || !attributes.set(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &capabilities, sizeof(capabilities)) + || !attributes.set(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited.data(), sizeof(inherited)) + || !attributes.set(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(childPolicy)) + || !attributes.set(PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, &packagesPolicy, sizeof(packagesPolicy))) + return fail("cannot install LPAC, handle-list, and child-process attributes"); + + QStringList arguments = spec.arguments; + for (const auto &argument : arguments) { + if (argument.contains(QChar::Null) || argument.startsWith("--document-handle") + || argument.startsWith("--ipc-read-handle") || argument.startsWith("--ipc-write-handle") + || argument.startsWith("--sandbox-sid") || argument.startsWith("--source") || argument.startsWith("--socket")) + return fail("invalid or reserved worker argument"); + } + LPWSTR sidText = nullptr; + if (!ConvertSidToStringSidW(implementation->profile.sid, &sidText)) return fail("cannot encode AppContainer identity"); + LocalMemory sidOwner{sidText}; + arguments << "--sandbox-sid" << QString::fromWCharArray(sidText) + << "--document-handle" << QString::number(reinterpret_cast(document.value)) + << "--ipc-read-handle" << QString::number(reinterpret_cast(input.value)) + << "--ipc-write-handle" << QString::number(reinterpret_cast(reply.value)); + QString command = quoteWindowsArgument(executable); + for (const auto &argument : arguments) + command += ' ' + quoteWindowsArgument(argument); + if (command.size() >= 32767) + return fail("worker command line exceeds the Windows limit"); + auto commandLine = command.toStdWString(); + const auto application = QDir::toNativeSeparators(executable).toStdWString(); + const auto workingDirectory = QDir::toNativeSeparators(runtime).toStdWString(); + // Forward a finite environment so user Qt/plugin search paths cannot load code. + wchar_t windowsDirectory[MAX_PATH]{}; + if (!GetWindowsDirectoryW(windowsDirectory, MAX_PATH)) + return fail("cannot resolve the Windows runtime directory"); + const std::wstring windowsRoot(windowsDirectory); + std::wstring environment = L"PATH=" + workingDirectory + L";" + windowsRoot + L"\\System32"; + environment.push_back(L'\0'); + environment += L"SystemRoot=" + windowsRoot; + environment.push_back(L'\0'); + environment.push_back(L'\0'); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = nullDevice.value; + startup.StartupInfo.hStdOutput = nullDevice.value; + startup.StartupInfo.hStdError = nullDevice.value; + startup.lpAttributeList = attributes.value; + PROCESS_INFORMATION process{}; + if (!CreateProcessW(application.c_str(), commandLine.data(), nullptr, nullptr, TRUE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED | CREATE_NO_WINDOW, + environment.data(), workingDirectory.c_str(), &startup.StartupInfo, &process)) + return fail("cannot create the protected worker process"); + implementation->process = Handle(process.hProcess); + implementation->id = process.dwProcessId; + Handle thread(process.hThread); + if (!AssignProcessToJobObject(implementation->job.value, implementation->process.value) + || !strictToken(implementation->process.value, implementation->profile.sid) + || !strictJob(implementation->job.value) + || !restrictProfileRegistry(implementation->process.value, implementation->profile.sid, userSid) + || !configureStagingTree(profile, implementation->profile.sid, userSid)) { + TerminateProcess(implementation->process.value, ERROR_ACCESS_DENIED); + return fail("worker token or Job assignment did not meet the isolation policy"); + } + BOOL member = FALSE; + if (!IsProcessInJob(implementation->process.value, implementation->job.value, &member) || !member + || ResumeThread(thread.value) == static_cast(-1)) { + TerminateProcess(implementation->process.value, ERROR_ACCESS_DENIED); + return fail("cannot verify and resume the protected worker"); + } + if (error) + error->clear(); + return std::unique_ptr(new NativeProcess(std::move(implementation))); +} + +#else +struct NativeProcess::Impl {}; +NativeProcess::NativeProcess(std::unique_ptr implementation) : d(std::move(implementation)) {} +NativeProcess::~NativeProcess() = default; +bool NativeProcess::running() const { return false; } +bool NativeProcess::wait(unsigned long) const { return true; } +void NativeProcess::terminate() {} +quint32 NativeProcess::processId() const { return 0; } +quintptr NativeProcess::processHandle() const { return 0; } +quint32 NativeProcess::exitCode() const { return 1; } +bool verifyWindowsWorkerSandbox(const QString &, QString *error) +{ + if (error) *error = "E_SANDBOX_UNAVAILABLE: Windows token verification requires Windows"; + return false; +} +std::unique_ptr launchWindowsWorker(const WindowsWorkerSpec &, QString *error) +{ + if (error) + *error = "E_SANDBOX_UNAVAILABLE: Windows AppContainer launcher requires Windows"; + return {}; +} +#endif + +} // namespace docview diff --git a/src/common/windows_sandbox.h b/src/common/windows_sandbox.h new file mode 100644 index 0000000..bca9e0a --- /dev/null +++ b/src/common/windows_sandbox.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +namespace docview { + +// All paths are broker-created session staging paths, never original documents or +// shared installation directories. Borrowed handles remain owned by the caller. +struct WindowsWorkerSpec { + QString executable; + QStringList arguments; + QString privateRuntimeDirectory; + quintptr documentHandle = 0; + quintptr controlReadHandle = 0; + quintptr controlWriteHandle = 0; +}; + +class NativeProcess { +public: + ~NativeProcess(); + NativeProcess(const NativeProcess &) = delete; + NativeProcess &operator=(const NativeProcess &) = delete; + bool running() const; + bool wait(unsigned long milliseconds) const; + void terminate(); + quint32 processId() const; + quintptr processHandle() const; + quint32 exitCode() const; +private: + struct Impl; + explicit NativeProcess(std::unique_ptr implementation); + std::unique_ptr d; + friend std::unique_ptr launchWindowsWorker(const WindowsWorkerSpec &, QString *); +}; + +// A failed protection setup returns null; there is no unrestricted fallback. +std::unique_ptr launchWindowsWorker(const WindowsWorkerSpec &spec, QString *error); +// Verifies kernel state in the child; the expected SID alone is never evidence +// of confinement. Requires LPAC, no capabilities, low IL and strict Job limits. +bool verifyWindowsWorkerSandbox(const QString &expectedSid, QString *error); +QString quoteWindowsArgument(const QString &argument); + +} // namespace docview diff --git a/src/common/worker_font_client.cpp b/src/common/worker_font_client.cpp new file mode 100644 index 0000000..42341fa --- /dev/null +++ b/src/common/worker_font_client.cpp @@ -0,0 +1,138 @@ +#include "worker_font_client.h" +#include "ipc.h" +#include "worker_runtime.h" + +#include +#include +#include +#include + +namespace docview { +WorkerFontClient::WorkerFontClient(IpcChannel *channel, WorkerRuntime *runtime, QObject *parent) + : QObject(parent), channel_(channel), runtime_(runtime) { + connect(channel_, &IpcChannel::protocolError, this, + [this](const QString &message) { fail("E_WORKER_CRASH", message, true); }); + connect(runtime_->transport.get(), &QIODevice::aboutToClose, this, + [this] { fail("E_WORKER_CRASH", tr("フォント取得の通信が停止しました")); }); + if (auto *socket = qobject_cast(runtime_->transport.get())) + connect(socket, &QLocalSocket::disconnected, this, + [this] { fail("E_WORKER_CRASH", tr("フォント取得の通信が停止しました")); }); +} +void WorkerFontClient::fail(const QString &code, const QString &message, bool protocol) { + const bool first = !failed(); + if (first) { + errorCode_ = code; + errorMessage_ = message; + } + if (waiting_) + waiting_->quit(); + if (protocol && first) + emit protocolError(message); +} +QCborMap WorkerFontClient::failureResult() const { + return {{"error", QCborMap{{"code", errorCode_}, {"message", errorMessage_}}}}; +} +bool WorkerFontClient::setParentRequest(const QCborMap &request, QString *error) { + if (parent_ || pending_ || !validateEnvelope(request, error) || + !request.contains(QStringLiteral("payload")) || + isFontOperation(request.value("operation").toString())) { + if (error) + *error = "invalid or overlapping font parent request"; + fail("E_WORKER_CRASH", tr("フォント取得の親要求が不正です"), true); + return false; + } + parent_ = request; + errorCode_.clear(); + errorMessage_.clear(); + return true; +} +void WorkerFontClient::clearParentRequest() { + if (pending_) { + fail("E_WORKER_CRASH", tr("フォント取得中に親要求が終了しました"), true); + return; + } + parent_.reset(); +} +bool WorkerFontClient::routeReply(const QCborMap &reply) { + const auto operation = reply.value("operation").toString(); + if (!isFontOperation(operation)) + return false; + QString error; + if (!parent_ || !pending_ || pending_->done || !validateEnvelope(reply, &error) || + reply.contains(QStringLiteral("payload")) || operation != pending_->operation || + reply.value("sessionId") != parent_->value("sessionId") || + reply.value("generation") != parent_->value("generation") || + reply.value("requestId") != parent_->value("requestId")) { + fail("E_WORKER_CRASH", tr("フォント取得の応答が一致しません"), true); + return true; + } + if (reply.contains(QStringLiteral("error"))) { + const auto details = reply.value("error").toMap(); + if (!validateFontError(details, &error) || + details.value("fontRequestId") != pending_->payload.value("fontRequestId")) { + fail("E_WORKER_CRASH", tr("フォント取得のエラー応答が不正です"), true); + return true; + } + fail(details.value("code").toString(), details.value("message").toString()); + } else { + auto result = reply.value("result").toMap(); + if (!validateFontResult(operation, pending_->payload, result, &error)) { + fail("E_WORKER_CRASH", tr("フォント取得の応答内容が不正です"), true); + return true; + } + result.remove(QStringLiteral("fontRequestId")); + pending_->result = result; + pending_->done = true; + if (waiting_) + waiting_->quit(); + } + return true; +} +QCborMap WorkerFontClient::call(const QString &operation, QCborMap payload, int timeoutMs) { + if (failed()) + return failureResult(); + if (!parent_ || pending_ || next_ == std::numeric_limits::max()) { + fail("E_WORKER_CRASH", tr("フォント取得要求の状態が不正です"), true); + return failureResult(); + } + if (timeoutMs <= 0) { + fail("E_WORKER_TIMEOUT", tr("フォント取得が時間制限を超えました")); + return failureResult(); + } + // The caller cannot supply or reuse its own reverse-RPC identity. + if (payload.contains(QStringLiteral("fontRequestId"))) { + fail("E_WORKER_CRASH", tr("フォント取得要求の識別子が不正です"), true); + return failureResult(); + } + payload.insert(QStringLiteral("fontRequestId"), next_++); + if (!validateFontPayload(operation, payload)) { + fail("E_WORKER_CRASH", tr("フォント取得要求の内容が不正です"), true); + return failureResult(); + } + pending_ = Pending{operation, payload, {}, false}; + auto envelope = *parent_; + envelope.insert(QStringLiteral("operation"), operation); + envelope.insert(QStringLiteral("payload"), payload); + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + connect(&timer, &QTimer::timeout, &loop, + [this] { fail("E_WORKER_TIMEOUT", tr("フォント取得が時間制限を超えました")); }); + waiting_ = &loop; + timer.start(qMin(timeoutMs, FontReplyTimeoutMs)); + // Flush a small request without waiting for its reply or pre-queuing a + // font blob. The same timer spans transport flushing and response wait. + if (!channel_->send(envelope) || + !runtime_->flushWrites(MaxControlFrame, qMin(timeoutMs, FontReplyTimeoutMs))) + fail("E_WORKER_CRASH", tr("フォント取得要求を送信できません")); + if (!failed() && !pending_->done) + loop.exec(QEventLoop::ExcludeUserInputEvents); + timer.stop(); + waiting_ = nullptr; + if (!failed() && !pending_->done) + fail("E_WORKER_CRASH", tr("フォント取得が完了前に中断されました")); + const auto result = failed() ? failureResult() : pending_->result; + pending_.reset(); + return result; +} +} // namespace docview diff --git a/src/common/worker_font_client.h b/src/common/worker_font_client.h new file mode 100644 index 0000000..e62d7b9 --- /dev/null +++ b/src/common/worker_font_client.h @@ -0,0 +1,52 @@ +#pragma once + +#include "font_contract.h" +#include +#include + +class QEventLoop; +namespace docview { +class IpcChannel; +struct WorkerRuntime; + +// Used only from the worker's event-loop thread, after the ordinary request's +// receive stack has unwound. The main router must routeReply() before its +// ordinary-request guard and reserve setParentRequest() before queuing work. +class WorkerFontClient : public QObject { + Q_OBJECT + public: + WorkerFontClient(IpcChannel *channel, WorkerRuntime *runtime, QObject *parent = nullptr); + bool setParentRequest(const QCborMap &request, QString *error = nullptr); + void clearParentRequest(); + bool routeReply(const QCborMap &reply); + QCborMap call(const QString &operation, QCborMap payload, int timeoutMs = FontReplyTimeoutMs); + bool failed() const { + return !errorCode_.isEmpty(); + } + QString errorCode() const { + return errorCode_; + } + QString errorMessage() const { + return errorMessage_; + } + signals: + void protocolError(QString message); + + private: + struct Pending { + QString operation; + QCborMap payload; + QCborMap result; + bool done = false; + }; + void fail(const QString &code, const QString &message, bool protocol = false); + QCborMap failureResult() const; + IpcChannel *channel_; + WorkerRuntime *runtime_; + std::optional parent_; + std::optional pending_; + QEventLoop *waiting_ = nullptr; + qint64 next_ = 1; + QString errorCode_, errorMessage_; +}; +} // namespace docview diff --git a/src/common/worker_process.cpp b/src/common/worker_process.cpp new file mode 100644 index 0000000..0b218f9 --- /dev/null +++ b/src/common/worker_process.cpp @@ -0,0 +1,460 @@ +#include "worker_process.h" +#include "font_contract.h" +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include "windows_pipe.h" +#include "windows_runtime_staging.h" +#include "windows_sandbox.h" +#include +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif +namespace docview { +struct WorkerProcess::FontCallbackState { + QMutex mutex; + WorkerProcess *owner = nullptr; +}; +WorkerProcess::WorkerProcess(QString s, qint64 g, QObject *p) + : QObject(p), session_(std::move(s)), generation_(g) { + fontCallbackState_ = std::make_shared(); + fontCallbackState_->owner = this; + server_.setSocketOptions(QLocalServer::UserAccessOption); + deadline_.setSingleShot(true); + slow_.setSingleShot(true); + // Resource limits and long-running notifications must not fire early when + // Qt coalesces timers. Keep the specified wall-clock budgets intact. + deadline_.setTimerType(Qt::PreciseTimer); + slow_.setTimerType(Qt::PreciseTimer); + connect(&deadline_, &QTimer::timeout, this, + [this] { abort("E_WORKER_TIMEOUT", tr("文書処理が時間制限を超えました")); }); + connect(&slow_, &QTimer::timeout, this, &WorkerProcess::slow); + connect(&server_, &QLocalServer::newConnection, this, [this] { + if (socket_) { + auto s = server_.nextPendingConnection(); + s->disconnectFromServer(); + s->deleteLater(); + return; + } + socket_ = server_.nextPendingConnection(); + socket_->setParent(this); + server_.close(); + deadline_.stop(); + attachTransport(socket_); + emit ready(); + pump(); + }); + connect(&process_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + if (!stopping_) + abort("E_WORKER_CRASH", tr("文書処理プロセスを起動できません")); + }); + connect(&process_, qOverload(&QProcess::finished), this, + [this](int exitCode, QProcess::ExitStatus status) { + processFinished(exitCode, status == QProcess::NormalExit); + }); +} +void WorkerProcess::attachTransport(QIODevice *device) { + channel_ = new IpcChannel(device, this); + connect(channel_, &IpcChannel::protocolError, this, + [this](const QString &) { abort("E_WORKER_CRASH", tr("文書処理から不正な応答を受信しました")); }); + connect(channel_, &IpcChannel::messageReceived, this, [this](const QCborMap &m) { + if (isFontOperation(m.value("operation").toString())) { + receiveFontRequest(m); + return; + } + // A worker's bounded synchronous font wait may expire before an OS font + // lookup completes. Its terminal parent timeout revokes that subrequest; + // the queued service completion will then fail its identity check. + if (fontPending_ && active_ && m.value("sessionId").toString() == session_ && + m.value("generation").toInteger() == generation_ && + m.value("requestId").toInteger() == active_->id && + m.value("operation").toString() == active_->op && + m.value("error").toMap().value("code").toString() == "E_WORKER_TIMEOUT") + fontPending_.reset(); + if (!active_ || fontPending_ || m.value("sessionId").toString() != session_ || + m.value("generation").toInteger() != generation_ || + m.value("requestId").toInteger() != active_->id || + m.value("operation").toString() != active_->op || + (!m.contains(QStringLiteral("result")) && !m.contains(QStringLiteral("error")))) { + abort("E_WORKER_CRASH", tr("文書処理の応答が一致しません")); + return; + } + const bool more = m.value("result").toMap().value("more").toBool(); + if (more) { + if (active_->op != "extract") { + abort("E_WORKER_CRASH", tr("応答の分割形式が不正です")); + return; + } + auto cb = active_->reply; + if (cb) + cb(m); + return; + } + deadline_.stop(); + slow_.stop(); + const bool closed = active_->op == "close" && !m.contains(QStringLiteral("error")); + auto cb = std::move(active_->reply); + active_.reset(); + // A successful close is terminal. Release the process and discard any + // queued work before the callback can enqueue another request. + if (closed) + stop(); + if (cb) + cb(m); + pump(); + }); +} +void WorkerProcess::setFontRequestHandler(FontRequestHandler handler) { + if (stopping_) + return; + if (fontPending_) { + abort("E_WORKER_CRASH", tr("フォント処理中にサービスが変更されました")); + return; + } + fontHandler_ = std::move(handler); +} +void WorkerProcess::receiveFontRequest(const QCborMap &request) { + const auto operation = request.value("operation").toString(); + const auto payload = request.value("payload").toMap(); + if (stopping_) + return; + if (!fontHandler_ || archive_ || !active_ || fontPending_ || + !request.contains(QStringLiteral("payload")) || request.value("sessionId").toString() != session_ || + request.value("generation").toInteger() != generation_ || + request.value("requestId").toInteger() != active_->id || !validateFontPayload(operation, payload) || + payload.value("fontRequestId").toInteger() <= lastFontRequestId_) { + abort("E_WORKER_CRASH", tr("フォント取得要求が不正です")); + return; + } + if (operation != "font.map") { + const auto grant = fontGrants_.constFind(payload.value("fontId").toString()); + if (grant == fontGrants_.cend() || + (operation == "font.read" && + (payload.value("offset").toInteger() > *grant || + payload.value("length").toInteger() > *grant - payload.value("offset").toInteger()))) { + abort("E_WORKER_CRASH", tr("フォント取得の識別子または範囲が不正です")); + return; + } + } + lastFontRequestId_ = payload.value("fontRequestId").toInteger(); + fontPending_ = FontRequest{active_->id, operation, payload}; + const auto state = fontCallbackState_; + const auto parentId = active_->id; + const auto fontId = lastFontRequestId_; + auto servicePayload = payload; + servicePayload.remove(QStringLiteral("fontRequestId")); + const auto handler = fontHandler_; + handler(operation, servicePayload, [state, parentId, fontId](QCborMap result) { + // stop/destruction revokes the pointer under this same lock. Posting is + // thread-safe; the queued invocation is also removed with its receiver. + QMutexLocker lock(&state->mutex); + if (!state->owner) + return; + QPointer owner(state->owner); + QMetaObject::invokeMethod( + state->owner, + [owner, parentId, fontId, result = std::move(result)]() mutable { + if (owner) + owner->completeFontRequest(parentId, fontId, std::move(result)); + }, + Qt::QueuedConnection); + }); +} +void WorkerProcess::completeFontRequest(qint64 parentId, qint64 fontId, QCborMap result) { + if (stopping_ || !active_ || active_->id != parentId || !fontPending_ || + fontPending_->parentId != parentId || + fontPending_->payload.value("fontRequestId").toInteger() != fontId) + return; + const auto operation = fontPending_->operation; + const auto payload = fontPending_->payload; + QCborMap envelope{{"protocolVersion", 1}, + {"sessionId", session_}, + {"generation", generation_}, + {"requestId", parentId}, + {"operation", operation}}; + if (result.contains(QStringLiteral("error"))) { + auto error = result.value("error").toMap(); + if (result.size() != 1 || !result.value("error").isMap() || + error.contains(QStringLiteral("fontRequestId"))) { + abort("E_WORKER_CRASH", tr("フォントサービスのエラー応答が不正です")); + return; + } + error.insert(QStringLiteral("fontRequestId"), fontId); + if (!validateFontError(error)) { + abort("E_WORKER_CRASH", tr("フォントサービスのエラー内容が不正です")); + return; + } + envelope.insert(QStringLiteral("error"), error); + } else { + if (result.contains(QStringLiteral("fontRequestId"))) { + abort("E_WORKER_CRASH", tr("フォントサービスの識別子が不正です")); + return; + } + result.insert(QStringLiteral("fontRequestId"), fontId); + if (!validateFontResult(operation, payload, result)) { + abort("E_WORKER_CRASH", tr("フォントサービスの応答内容が不正です")); + return; + } + if (operation == "font.map" && result.value("found").toBool()) { + const auto id = result.value("fontId").toString(); + if (fontGrants_.contains(id) || fontGrants_.size() >= MaxFontHandles) { + abort("E_WORKER_CRASH", tr("フォントサービスの選択数または識別子が不正です")); + return; + } + fontGrants_.insert(id, result.value("size").toInteger()); + } else if (operation == "font.close") { + fontGrants_.remove(payload.value("fontId").toString()); + } + envelope.insert(QStringLiteral("result"), result); + } + fontPending_.reset(); + if (!channel_->send(envelope)) + abort("E_WORKER_CRASH", tr("フォントサービスの応答を送信できません")); +} +void WorkerProcess::processFinished(int exitCode, bool normalExit) { + if (stopping_) + return; + if (normalExit && exitCode == 0 && active_ && active_->op == "close" && !closeDrainScheduled_) { + // Process exit and the pipe's last completed read are separate events + // on Windows. Give an already-written close acknowledgement one short + // event-loop turn to arrive; an exit without that reply is still an error. + closeDrainScheduled_ = true; + QTimer::singleShot(50, this, [this] { processFinished(0, true); }); + return; + } + if (normalExit && exitCode == 5) + abort("E_SANDBOX_UNAVAILABLE", + tr("この環境では文書処理の保護を有効にできません。安全のため文書を開けません")); + else + abort("E_WORKER_CRASH", tr("文書処理が停止しました。再読込できます")); +} +WorkerProcess::~WorkerProcess() { + stop(); +} +bool WorkerProcess::start(const QString &e, const QStringList &a) { + if (stopping_ || channel_ || process_.state() != QProcess::NotRunning) + return false; + const auto workerName = QFileInfo(e).fileName(); + archive_ = workerName.compare(QStringLiteral("docview-archive-worker"), Qt::CaseInsensitive) == 0 || + workerName.compare(QStringLiteral("docview-archive-worker.exe"), Qt::CaseInsensitive) == 0; +#ifdef Q_OS_WIN + // Only these broker-owned launch inputs are accepted. Neither original path + // is part of the child command line; archive extraction is broker-written. + QString source; + bool outputSeen = false; + for (qsizetype i = 0; i < a.size(); ++i) { + if (a[i] == "--source" && source.isEmpty() && i + 1 < a.size()) { + source = a[++i]; + } else if (a[i] == "--output" && !outputSeen && i + 1 < a.size()) { + outputSeen = true; + ++i; + } else { + abort("E_SANDBOX_UNAVAILABLE", tr("文書処理の起動引数が不正です")); + return false; + } + } + if (!QDir::isAbsolutePath(source) || source.contains(QChar::Null)) { + abort("E_OPEN_FAILED", tr("文書の場所が不正です")); + return false; + } + const auto nativeSource = QDir::toNativeSeparators(source).toStdWString(); + const HANDLE document = CreateFileW(nativeSource.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + struct SourceHandle { + HANDLE value; + ~SourceHandle() { + if (value != INVALID_HANDLE_VALUE) + CloseHandle(value); + } + } ownedDocument{document}; + BY_HANDLE_FILE_INFORMATION info{}; + if (document == INVALID_HANDLE_VALUE || GetFileType(document) != FILE_TYPE_DISK || + !GetFileInformationByHandle(document, &info) || + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) { + abort("E_OPEN_FAILED", tr("文書を読取り専用で開けません")); + return false; + } + QString error; + runtime_ = std::make_unique(); + // Callers use the shared worker basename; QProcess adds this suffix on + // Windows, but the explicit runtime inventory requires the actual file. + const auto executable = e.endsWith(".exe", Qt::CaseInsensitive) ? e : e + ".exe"; + if (!runtime_->prepare(executable, &error)) { + abort("E_SANDBOX_UNAVAILABLE", error); + return false; + } + auto endpoints = createWindowsPipePair(&error); + if (!endpoints) { + abort("E_SANDBOX_UNAVAILABLE", error); + return false; + } + pipe_ = std::make_unique(); + if (!pipe_->adopt(endpoints->broker.takeReadHandle(), endpoints->broker.takeWriteHandle(), &error)) { + abort("E_SANDBOX_UNAVAILABLE", error); + return false; + } + WindowsWorkerSpec spec; + spec.executable = runtime_->executablePath(); + spec.privateRuntimeDirectory = runtime_->directoryPath(); + spec.documentHandle = reinterpret_cast(document); + spec.controlReadHandle = endpoints->worker.readHandle(); + spec.controlWriteHandle = endpoints->worker.writeHandle(); + nativeProcess_ = launchWindowsWorker(spec, &error); + endpoints->worker.close(); + if (!nativeProcess_) { + abort("E_SANDBOX_UNAVAILABLE", error); + return false; + } + connect(pipe_.get(), &WindowsPipeDevice::transportError, this, [this](const QString &) { + // A child can close inherited handles just before ExitProcess(5). Allow + // its process notifier to preserve the specific sandbox failure code. + QTimer::singleShot(50, this, [this] { + if (stopping_) + return; + if (nativeProcess_ && !nativeProcess_->running()) + processFinished(int(nativeProcess_->exitCode()), true); + else + abort("E_WORKER_CRASH", tr("文書処理の通信が停止しました")); + }); + }); + auto *notifier = new QWinEventNotifier(reinterpret_cast(nativeProcess_->processHandle()), this); + nativeExitNotifier_ = notifier; + connect(notifier, &QWinEventNotifier::activated, this, [this, notifier] { + notifier->setEnabled(false); + if (nativeProcess_) + processFinished(int(nativeProcess_->exitCode()), true); + }); + attachTransport(pipe_.get()); + // Unlike the Unix socket, inherited handles exist before the worker enters + // its event loop. Do not report ready until the protected runtime replies. + request("ping", {}, [this](const QCborMap &reply) { + if (reply.contains(QStringLiteral("error"))) { + const auto details = reply.value("error").toMap(); + abort(details.value("code").toString(), details.value("message").toString()); + return; + } + if (!reply.value("result").toMap().value("ready").toBool()) { + abort("E_WORKER_CRASH", tr("文書処理の起動応答が不正です")); + return; + } + emit ready(); + }); + if (stopping_) + return false; + deadline_.start(10000); + return true; +#else + const QString name = "docview-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + if (!server_.listen(name)) { + abort("E_OPEN_FAILED", tr("文書処理の通信を準備できません")); + return false; + } + QStringList args = {"--socket", server_.fullServerName()}; + args += a; + process_.setProcessChannelMode(QProcess::SeparateChannels); + process_.setStandardOutputFile(QProcess::nullDevice()); + process_.setStandardErrorFile(QProcess::nullDevice()); + process_.start(e, args); + deadline_.start(10000); + return true; +#endif +} +void WorkerProcess::request(const QString &o, const QCborMap &p, Reply cb) { + if (stopping_) + return; + queue_.enqueue({next_++, o, p, std::move(cb)}); + pump(); +} +void WorkerProcess::pump() { + if (stopping_ || !channel_ || active_ || queue_.isEmpty()) + return; + active_ = queue_.dequeue(); + QCborMap m{{"protocolVersion", 1}, {"requestId", active_->id}, {"sessionId", session_}, + {"generation", generation_}, {"operation", active_->op}, {"payload", active_->payload}}; + if (!channel_->send(m)) { + abort("E_WORKER_CRASH", tr("文書処理の要求を送信できません")); + return; + } + deadline_.start(archive_ ? 30000 : 120000); + slow_.start(archive_ ? 10000 : 30000); +} +qsizetype WorkerProcess::queuedRequestCount(const QString &operation) const { + if (operation.isEmpty()) + return queue_.size(); + qsizetype count = 0; + for (const auto &request : queue_) + if (request.op == operation) + ++count; + return count; +} +int WorkerProcess::activeRequestCount(const QString &operation) const { + return active_ && (operation.isEmpty() || active_->op == operation) ? 1 : 0; +} +void WorkerProcess::discardQueued(const QString &operation) { + if (operation.isEmpty()) { + discardedQueuedRequests_ += quint64(queue_.size()); + queue_.clear(); + return; + } + QQueue keep; + while (!queue_.isEmpty()) { + auto r = queue_.dequeue(); + if (r.op != operation) + keep.enqueue(std::move(r)); + else + ++discardedQueuedRequests_; + } + queue_ = std::move(keep); +} +void WorkerProcess::abort(const QString &c, const QString &m) { + if (stopping_) + return; + stop(); + emit failed(c, m); +} +void WorkerProcess::stop() { + if (stopping_) + return; + stopping_ = true; + { + QMutexLocker lock(&fontCallbackState_->mutex); + fontCallbackState_->owner = nullptr; + } + fontHandler_ = {}; + fontPending_.reset(); + fontGrants_.clear(); + deadline_.stop(); + slow_.stop(); + discardQueued(); + active_.reset(); + server_.close(); + if (socket_) + socket_->close(); +#ifdef Q_OS_WIN + if (auto *notifier = qobject_cast(nativeExitNotifier_)) { + notifier->setEnabled(false); + notifier->disconnect(this); + notifier->deleteLater(); + nativeExitNotifier_ = nullptr; + } + if (pipe_) + pipe_->close(); + if (nativeProcess_) { + nativeProcess_->terminate(); + nativeProcess_.reset(); + } + runtime_.reset(); +#endif + if (process_.state() != QProcess::NotRunning) { + process_.kill(); + process_.waitForFinished(1000); + } +} +} // namespace docview diff --git a/src/common/worker_process.h b/src/common/worker_process.h new file mode 100644 index 0000000..8197821 --- /dev/null +++ b/src/common/worker_process.h @@ -0,0 +1,93 @@ +#pragma once +#include "ipc.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace docview { +#ifdef Q_OS_WIN +class NativeProcess; +class WindowsPipeDevice; +class WindowsRuntimeStaging; +#endif +class WorkerProcess : public QObject { + Q_OBJECT + public: + using Reply = std::function; + using FontRequestHandler = + std::function)>; + WorkerProcess(QString session, qint64 generation, QObject *parent = nullptr); + ~WorkerProcess() override; + bool start(const QString &executable, const QStringList &arguments); + void request(const QString &operation, const QCborMap &payload, Reply reply = {}); + void discardQueued(const QString &operation = {}); + // Read on the owning thread. Active requests include streaming responses; + // discarded counts only unsent queue entries, never the active request. + qsizetype queuedRequestCount(const QString &operation = {}) const; + int activeRequestCount(const QString &operation = {}) const; + quint64 discardedQueuedRequestCount() const { return discardedQueuedRequests_; } + // Register only for PDF workers. The service receives maps without the + // wire-only fontRequestId and may complete asynchronously from any thread. + void setFontRequestHandler(FontRequestHandler handler); + void stop(); + bool isConnected() const { + return channel_ != nullptr && !stopping_; + } + bool idle() const { + return !active_ && queue_.isEmpty(); + } + signals: + void ready(); + void failed(QString code, QString message); + void slow(); + + private: + struct Request { + qint64 id; + QString op; + QCborMap payload; + Reply reply; + }; + void pump(); + void attachTransport(QIODevice *device); + void processFinished(int exitCode, bool normalExit); + void abort(const QString &code, const QString &message); + void receiveFontRequest(const QCborMap &request); + void completeFontRequest(qint64 parentId, qint64 fontId, QCborMap result); + struct FontCallbackState; + struct FontRequest { + qint64 parentId; + QString operation; + QCborMap payload; + }; + QString session_; + qint64 generation_, next_ = 1; + QLocalServer server_; + QProcess process_; + QLocalSocket *socket_ = nullptr; + IpcChannel *channel_ = nullptr; + QQueue queue_; + quint64 discardedQueuedRequests_ = 0; + std::optional active_; + FontRequestHandler fontHandler_; + std::shared_ptr fontCallbackState_; + std::optional fontPending_; + QHash fontGrants_; + qint64 lastFontRequestId_ = 0; + QTimer deadline_, slow_; + bool stopping_ = false; + bool archive_ = false; + bool closeDrainScheduled_ = false; +#ifdef Q_OS_WIN + std::unique_ptr nativeProcess_; + std::unique_ptr runtime_; + std::unique_ptr pipe_; + QObject *nativeExitNotifier_ = nullptr; +#endif +}; +} // namespace docview diff --git a/src/common/worker_runtime.cpp b/src/common/worker_runtime.cpp new file mode 100644 index 0000000..1de373d --- /dev/null +++ b/src/common/worker_runtime.cpp @@ -0,0 +1,138 @@ +#include "worker_runtime.h" +#include "sandbox.h" +#include "windows_pipe.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#endif + +namespace docview { + +void addWorkerOptions(QCommandLineParser &parser) +{ + parser.addOptions({QCommandLineOption("socket", "Private broker socket", "name"), + QCommandLineOption("source", "Read-only source document", "path"), + QCommandLineOption("document-handle", "Inherited read-only source handle", "handle"), + QCommandLineOption("ipc-read-handle", "Inherited request pipe handle", "handle"), + QCommandLineOption("ipc-write-handle", "Inherited response pipe handle", "handle"), + QCommandLineOption("sandbox-sid", "Expected isolated package identity", "sid")}); +} + +std::unique_ptr startWorkerRuntime(const QCommandLineParser &parser, + const QStringList &extraLinuxReadPaths, QString *error) +{ + const auto fail = [error](const QString &reason) -> std::unique_ptr { + if (error) *error = reason; + return {}; + }; + auto runtime = std::make_unique(); +#ifdef Q_OS_WIN + Q_UNUSED(extraLinuxReadPaths); + // Never accept a Windows path/socket fallback, including empty options. + if (parser.isSet("source") || parser.isSet("socket")) + return fail("E_SANDBOX_UNAVAILABLE: Windows workers require inherited handles"); + const auto parseHandle = [&parser](const char *name) -> quintptr { + const QString value = parser.value(QString::fromLatin1(name)); + if (value.isEmpty() || value.size() > 20) return 0; + for (const auto c : value) if (c < '0' || c > '9') return 0; + bool ok = false; + const auto number = value.toULongLong(&ok); + if (!ok || !number || number >= std::numeric_limits::max()) return 0; + return static_cast(number); + }; + const auto document = parseHandle("document-handle"); + const auto input = parseHandle("ipc-read-handle"); + const auto output = parseHandle("ipc-write-handle"); + if (!document || !input || !output || document == input || document == output || input == output) + return fail("E_SANDBOX_UNAVAILABLE: invalid inherited handles"); + if (!enterSandbox({}, {}, error, parser.value("sandbox-sid"))) return {}; + auto documentHandle = reinterpret_cast(document); + if (GetFileType(documentHandle) != FILE_TYPE_DISK) + return fail("E_SANDBOX_UNAVAILABLE: source handle is not a disk file"); + HANDLE readOnly = nullptr; + const bool duplicated = DuplicateHandle(GetCurrentProcess(), documentHandle, GetCurrentProcess(), &readOnly, + FILE_GENERIC_READ, FALSE, 0); + CloseHandle(documentHandle); + if (!duplicated) return fail("E_SANDBOX_UNAVAILABLE: cannot restrict source handle rights"); + documentHandle = readOnly; + // _open_osfhandle transfers ownership to the CRT descriptor. QFile then + // owns that descriptor; neither layer reopens the original document path. + const int descriptor = _open_osfhandle(reinterpret_cast(documentHandle), _O_RDONLY | _O_BINARY); + if (descriptor < 0) { + CloseHandle(documentHandle); + return fail("E_OPEN_FAILED: cannot adopt source handle"); + } + if (!runtime->source.open(descriptor, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + _close(descriptor); + return fail("E_OPEN_FAILED: cannot adopt source descriptor"); + } + auto pipe = std::make_unique(); + if (!pipe->adopt(input, output, error)) return {}; + QObject::connect(pipe.get(), &WindowsPipeDevice::disconnected, qApp, &QCoreApplication::quit); + QObject::connect(pipe.get(), &WindowsPipeDevice::transportError, qApp, [](const QString &) { QCoreApplication::exit(6); }); + runtime->transport = std::move(pipe); +#else + if (parser.isSet("document-handle") || parser.isSet("ipc-read-handle") + || parser.isSet("ipc-write-handle") || parser.isSet("sandbox-sid")) + return fail("E_SANDBOX_UNAVAILABLE: inherited Windows handles are unsupported here"); + const auto source = QFileInfo(parser.value("source")).canonicalFilePath(); + if (source.isEmpty() || parser.value("socket").isEmpty()) + return fail("E_OPEN_FAILED: source and private socket are required"); + runtime->source.setFileName(source); + if (!runtime->source.open(QIODevice::ReadOnly) || runtime->source.isSequential()) + return fail("E_OPEN_FAILED: cannot open read-only source"); + auto socket = std::make_unique(); + socket->connectToServer(parser.value("socket")); + if (!socket->waitForConnected(5000)) return fail("E_OPEN_FAILED: private broker socket unavailable"); + QObject::connect(socket.get(), &QLocalSocket::disconnected, qApp, &QCoreApplication::quit); + runtime->transport = std::move(socket); + auto paths = extraLinuxReadPaths; + paths.prepend(source); + if (!enterSandbox(paths, {}, error)) return {}; +#endif + if (error) error->clear(); + return runtime; +} + +bool WorkerRuntime::flushWrites(qint64 maximumPending, int timeoutMs) +{ + if (!transport || !transport->isOpen() || maximumPending < 0 || timeoutMs <= 0) return false; + QElapsedTimer deadline; + deadline.start(); + if (auto *socket = qobject_cast(transport.get())) { + socket->flush(); + while (socket->bytesToWrite() > maximumPending) { + const int remaining = timeoutMs - int(deadline.elapsed()); + if (remaining <= 0 || !socket->waitForBytesWritten(remaining)) return false; + } + return socket->state() == QLocalSocket::ConnectedState; + } + while (transport->bytesToWrite() > maximumPending) { + const int remaining = timeoutMs - int(deadline.elapsed()); + if (remaining <= 0) return false; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(transport.get(), &QIODevice::bytesWritten, &loop, &QEventLoop::quit); + QObject::connect(transport.get(), &QIODevice::aboutToClose, &loop, &QEventLoop::quit); + timer.start(remaining); + loop.exec(QEventLoop::ExcludeUserInputEvents); + if (!transport->isOpen()) return false; + } + return true; +} + +} // namespace docview diff --git a/src/common/worker_runtime.h b/src/common/worker_runtime.h new file mode 100644 index 0000000..d46b84b --- /dev/null +++ b/src/common/worker_runtime.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +class QCommandLineParser; + +namespace docview { + +// Owns the already-open source for the entire parser lifetime. Windows receives +// only inherited handles; Linux acquires the same read-only resource before +// entering Landlock/seccomp. No parser is given a path to reopen on Windows. +struct WorkerRuntime { + QFile source; + std::unique_ptr transport; + bool flushWrites(qint64 maximumPending = 0, int timeoutMs = 5000); +}; + +void addWorkerOptions(QCommandLineParser &parser); +std::unique_ptr startWorkerRuntime(const QCommandLineParser &parser, + const QStringList &extraLinuxReadPaths, QString *error); + +} // namespace docview diff --git a/src/core/config.cpp b/src/core/config.cpp new file mode 100644 index 0000000..b990105 --- /dev/null +++ b/src/core/config.cpp @@ -0,0 +1,360 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#include +#endif + +namespace docview { +namespace { + +constexpr qint64 maximumConfigBytes = 1024 * 1024; + +QString xdgDirectory(const char *variable, const QString &fallback) +{ + const auto value = qEnvironmentVariable(variable); + return !value.isEmpty() && QDir::isAbsolutePath(value) ? QDir::cleanPath(value) : fallback; +} + +#ifdef Q_OS_WIN +QString knownDirectory(REFKNOWNFOLDERID folder) +{ + PWSTR value = nullptr; + if (FAILED(SHGetKnownFolderPath(folder, KF_FLAG_DEFAULT, nullptr, &value))) + return {}; + const QString result = QString::fromWCharArray(value); + CoTaskMemFree(value); + return result; +} +#endif + +QString nodeError(const toml::node &node, const QString &key, const QString &reason) +{ + return QCoreApplication::translate("Configuration", "%1行、%2: %3").arg(node.source().begin.line).arg(key, reason); +} + +QVariant scalar(const toml::node &node) +{ + if (node.is_boolean()) + return node.as_boolean()->get(); + if (node.is_integer()) + return QVariant::fromValue(node.as_integer()->get()); + if (node.is_floating_point()) + return node.as_floating_point()->get(); + if (node.is_string()) + return QString::fromStdString(node.as_string()->get()); + return {}; +} + +bool finiteStructure(const toml::node &node, int depth, int &count) +{ + if (++count > 4096 || depth > 8) + return false; + if (const auto *table = node.as_table()) { + for (const auto &[key, item] : *table) { + Q_UNUSED(key); + if (!finiteStructure(item, depth + 1, count)) + return false; + } + } else if (const auto *array = node.as_array()) { + for (const auto &item : *array) { + if (!finiteStructure(item, depth + 1, count)) + return false; + } + } + return true; +} + +QString validateValue(const QString &key, const toml::node &node) +{ + const QMap> numeric = { + {"ui.font_size", {10, 28}}, {"ui.panel_width", {160, 600}}, {"pdf.zoom_percent", {25, 800}}, + {"reading.font_size", {12, 40}}, {"reading.line_height", {1, 2.5}}, + {"keys.sequence_timeout_ms", {0, 3000}}, {"performance.tile_cache_mib", {64, 512}}, + {"performance.prefetch_pages", {0, 3}}, {"privacy.recent_documents", {0, 100}} + }; + const QSet fractional = {"ui.font_size", "reading.font_size", "reading.line_height"}; + const QSet booleans = {"ui.status_bar", "ui.toc_visible", "ui.pages_visible", "pdf.preserve_colors", + "reading.use_publisher_style", "privacy.remember_position", "privacy.store_text_anchor"}; + if (numeric.contains(key)) { + if (!node.is_integer() && !(fractional.contains(key) && node.is_floating_point())) + return QCoreApplication::translate("Configuration", "仕様に合う数値を指定してください"); + const double number = scalar(node).toDouble(); + const auto range = numeric.value(key); + if (!(number >= range.first && number <= range.second) + || (key == "keys.sequence_timeout_ms" && number != 0 && number < 200)) + return QCoreApplication::translate("Configuration", "値が設定範囲(%1〜%2)を超えています").arg(range.first).arg(range.second); + return {}; + } + if (booleans.contains(key)) { + if (!node.is_boolean()) + return QCoreApplication::translate("Configuration", "true または false を指定してください"); + if (key == "pdf.preserve_colors" && !node.as_boolean()->get()) + return QCoreApplication::translate("Configuration", "原文の色を保持するため、preserve_colors は true にしてください"); + return {}; + } + if (key == "ui.theme" || key == "pdf.zoom_mode") { + const QStringList allowed = key == "ui.theme" ? QStringList{"dark", "light", "system"} + : QStringList{"fit-width", "fit-page", "percent"}; + if (!node.is_string() || !allowed.contains(scalar(node).toString())) + return QCoreApplication::translate("Configuration", "次のいずれかを指定してください: %1").arg(allowed.join(", ")); + return {}; + } + return QCoreApplication::translate("Configuration", "この設定項目はありません"); +} + +QString bindingIdentity(const KeyBinding &binding) +{ + return binding.mode + QChar(0x1f) + binding.context + QChar(0x1f) + binding.keys.join(QChar(0x1f)); +} + +bool prefixOf(const QStringList &a, const QStringList &b) +{ + if (a.size() >= b.size()) + return false; + for (qsizetype i = 0; i < a.size(); ++i) { + if (a[i] != b[i]) + return false; + } + return true; +} + +QVariantList bindingVariants(const QVector &bindings) +{ + QVariantList result; + for (const auto &binding : bindings) { + if (binding.command != "unbound") + result.append(QVariantMap{{"mode", binding.mode}, {"context", binding.context}, + {"keys", binding.keys}, {"command", binding.command}, {"args", binding.arguments}}); + } + return result; +} + +} // namespace + +StoragePaths StoragePaths::forCurrentUser() +{ +#ifdef Q_OS_WIN + QString roaming = knownDirectory(FOLDERID_RoamingAppData); + QString local = knownDirectory(FOLDERID_LocalAppData); + if (roaming.isEmpty()) + roaming = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation); + if (local.isEmpty()) + local = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation); + return {QDir(roaming).filePath("DocView/config.toml"), QDir(local).filePath("DocView/state/state.json"), + QDir(local).filePath("DocView/cache"), QDir(local).filePath("DocView/logs")}; +#else + const auto home = QDir::homePath(); + const auto config = xdgDirectory("XDG_CONFIG_HOME", home + "/.config"); + const auto state = xdgDirectory("XDG_STATE_HOME", home + "/.local/state"); + const auto cache = xdgDirectory("XDG_CACHE_HOME", home + "/.cache"); + return {config + "/docview/config.toml", state + "/docview/state.json", cache + "/docview", state + "/docview/logs"}; +#endif +} + +QVariantMap ConfigManager::defaults() +{ + return { + {"schema_version", 1}, + {"ui", QVariantMap{{"theme", "dark"}, {"status_bar", true}, {"toc_visible", false}, {"pages_visible", false}, + {"font_size", 14.0}, {"panel_width", 280}}}, + {"pdf", QVariantMap{{"zoom_mode", "fit-width"}, {"zoom_percent", 100}, {"preserve_colors", true}}}, + {"reading", QVariantMap{{"font_size", 18.0}, {"line_height", 1.6}, {"use_publisher_style", true}}}, + {"keys", QVariantMap{{"sequence_timeout_ms", 800}}}, + {"performance", QVariantMap{{"tile_cache_mib", 256}, {"prefetch_pages", 1}}}, + {"privacy", QVariantMap{{"remember_position", true}, {"recent_documents", 20}, {"store_text_anchor", false}}} + }; +} + +ConfigManager::ConfigManager(QObject *parent) + : QObject(parent), m_snapshot(defaults()), m_bindings(CommandRegistry::defaultBindings()), + m_path(StoragePaths::forCurrentUser().configFile) +{ + m_snapshot.insert("keybindings", bindingVariants(m_bindings)); +} + +QVariant ConfigManager::value(const QString &dottedKey) const +{ + QVariant result = m_snapshot; + for (const auto &part : dottedKey.split('.')) + result = result.toMap().value(part); + return result; +} + +bool ConfigManager::fail(const QString &message) +{ + m_lastError = message; + emit errorChanged(); + emit errorOccurred(message); + return false; +} + +bool ConfigManager::reload(const QString &path) +{ + // The selected source is distinct from the active snapshot. A broken explicit + // file remains the reload target so editing it followed by :reload can recover. + if (!path.isEmpty()) + m_path = path; + const auto candidatePath = m_path; + QFile file(candidatePath); + if (!file.exists()) { + if (!path.isEmpty() || m_hasLoadedFile || m_path != StoragePaths::forCurrentUser().configFile) + return fail(QCoreApplication::translate("Configuration", "設定ファイルが見つかりません: %1").arg(candidatePath)); + if (!loadData({}, candidatePath)) + return false; + return true; + } + if (!file.open(QIODevice::ReadOnly)) + return fail(QCoreApplication::translate("Configuration", "設定ファイルを読み取れません: %1").arg(file.errorString())); + if (file.size() > maximumConfigBytes) + return fail(QCoreApplication::translate("Configuration", "設定ファイルが1 MiBの上限を超えています")); + const auto bytes = file.read(maximumConfigBytes + 1); + if (!loadData(bytes, candidatePath)) + return false; + m_hasLoadedFile = true; + return true; +} + +bool ConfigManager::loadData(const QByteArray &bytes, const QString &source) +{ + if (bytes.size() > maximumConfigBytes) + return fail(QCoreApplication::translate("Configuration", "設定ファイルが1 MiBの上限を超えています")); + toml::table document; + try { + document = toml::parse(std::string_view(bytes.constData(), static_cast(bytes.size())), source.toStdString()); + } catch (const toml::parse_error &error) { + return fail(QCoreApplication::translate("Configuration", "%1行: 設定の構文に誤りがあります。詳細(解析器): %2").arg(error.source().begin.line) + .arg(QString::fromUtf8(error.description().data(), static_cast(error.description().size())))); + } + int count = 0; + if (!finiteStructure(document, 0, count)) + return fail(QCoreApplication::translate("Configuration", "設定項目が多すぎるか、入れ子が8階層を超えています")); + QVariantMap candidate = defaults(); + QVector bindings = CommandRegistry::defaultBindings(); + for (const auto &[tomlKey, node] : document) { + const QString key = QString::fromStdString(std::string(tomlKey.str())); + if (key == "schema_version") { + if (!node.is_integer() || node.as_integer()->get() != 1) + return fail(nodeError(node, key, QCoreApplication::translate("Configuration", "schema_version は 1 のみ対応しています。ファイルは変更していません"))); + continue; + } + if (key == "keybindings") + continue; + if (!candidate.contains(key) || !node.is_table()) + return fail(nodeError(node, key, QCoreApplication::translate("Configuration", "不明なセクション、またはテーブル以外の値です"))); + QVariantMap section = candidate.value(key).toMap(); + for (const auto &[childKey, child] : *node.as_table()) { + const auto item = QString::fromStdString(std::string(childKey.str())); + const QString dottedKey = key + "." + item; + const auto error = validateValue(dottedKey, child); + if (!error.isEmpty()) + return fail(nodeError(child, dottedKey, error)); + section.insert(item, scalar(child)); + } + candidate.insert(key, section); + } + if (const auto *node = document.get("keybindings")) { + const auto *array = node->as_array(); + if (!array || array->size() > 512) + return fail(nodeError(*node, "keybindings", QCoreApplication::translate("Configuration", "キーバインドは512件以内の配列で指定してください"))); + QSet seen; + for (const auto &item : *array) { + const auto *table = item.as_table(); + if (!table) + return fail(nodeError(item, "keybindings", QCoreApplication::translate("Configuration", "各キーバインドをテーブルで指定してください"))); + const QSet allowed = {"mode", "context", "keys", "command", "args"}; + for (const auto &[field, value] : *table) { + const auto name = QString::fromStdString(std::string(field.str())); + if (!allowed.contains(name)) + return fail(nodeError(value, "keybindings." + name, QCoreApplication::translate("Configuration", "このキーバインド項目はありません"))); + } + KeyBinding binding; + binding.line = item.source().begin.line; + for (const auto &field : {"mode", "context", "command"}) { + const auto *value = table->get(field); + if (!value || !value->is_string()) + return fail(nodeError(item, "keybindings", QCoreApplication::translate("Configuration", "%1 は文字列で指定してください").arg(field))); + } + binding.mode = scalar(*table->get("mode")).toString(); + binding.context = scalar(*table->get("context")).toString(); + binding.command = scalar(*table->get("command")).toString(); + if (!QStringList{"normal", "command", "search", "dialog"}.contains(binding.mode) + || !QStringList{"global", "content", "toc", "pages"}.contains(binding.context)) + return fail(nodeError(item, "keybindings", QCoreApplication::translate("Configuration", "指定されたモードまたは操作領域はありません"))); + // Text-entry modes intentionally have no application key bindings. + if (binding.mode != "normal" && binding.command != "unbound") + return fail(nodeError(item, "keybindings.mode", QCoreApplication::translate("Configuration", "文字入力モードには閲覧コマンドを割り当てられません"))); + const auto *keysNode = table->get("keys"); + const auto *keys = keysNode ? keysNode->as_array() : nullptr; + if (!keys || keys->empty() || keys->size() > 4) + return fail(nodeError(item, "keybindings.keys", QCoreApplication::translate("Configuration", "1〜4個の論理キーを指定してください"))); + for (const auto &key : *keys) { + const auto value = scalar(key).toString(); + const auto canonical = CommandRegistry::canonicalKey(value); + if (!key.is_string() || canonical.isEmpty() || canonical == "Esc") + return fail(nodeError(key, "keybindings.keys", QCoreApplication::translate("Configuration", "キー名が無効、または予約済みのEscです。シフト記号には入力される文字を指定してください(例: Shift+1ではなく !)"))); + binding.keys.append(canonical); + } + if ((binding.context == "content" || binding.context == "global") + && binding.keys.front().size() == 1 && binding.keys.front().front().isDigit()) + return fail(nodeError(item, "keybindings.keys", QCoreApplication::translate("Configuration", "先頭の数字はPDFの実ページ番号入力に使うため予約されています"))); + if (const auto *arguments = table->get("args")) { + if (!arguments->is_table()) + return fail(nodeError(*arguments, "keybindings.args", QCoreApplication::translate("Configuration", "引数をテーブルで指定してください"))); + for (const auto &[argumentName, argument] : *arguments->as_table()) { + if (!scalar(argument).isValid()) + return fail(nodeError(argument, "keybindings.args", QCoreApplication::translate("Configuration", "引数には文字列・数値・真偽値のみ指定できます"))); + binding.arguments.insert(QString::fromStdString(std::string(argumentName.str())), scalar(argument)); + } + } + const auto error = CommandRegistry::validate(binding.command, binding.arguments); + if (!error.isEmpty()) + return fail(nodeError(item, "keybindings.command", error)); + const auto identity = bindingIdentity(binding); + if (seen.contains(identity)) + return fail(nodeError(item, "keybindings.keys", QCoreApplication::translate("Configuration", "ファイル内でキーの割り当てが重複しています"))); + seen.insert(identity); + bool replaced = false; + for (auto &existing : bindings) { + if (bindingIdentity(existing) == identity) { + existing = binding; + replaced = true; + break; + } + } + if (!replaced) + bindings.append(binding); + } + } + // Exact local/global matches are permitted; the local binding takes priority. + for (qsizetype i = 0; i < bindings.size(); ++i) { + for (qsizetype j = i + 1; j < bindings.size(); ++j) { + const auto &left = bindings[i]; + const auto &right = bindings[j]; + if (left.command == "unbound" || right.command == "unbound" || left.mode != right.mode) + continue; + if (left.context != right.context && left.context != "global" && right.context != "global") + continue; + if (prefixOf(left.keys, right.keys) || prefixOf(right.keys, left.keys)) + return fail(QCoreApplication::translate("Configuration", "%1行、keybindings.keys: %2 と %3 の接頭辞が衝突しています") + .arg(qMax(left.line, right.line)).arg(left.keys.join(' '), right.keys.join(' '))); + } + } + candidate.insert("keybindings", bindingVariants(bindings)); + m_snapshot = std::move(candidate); + m_bindings = std::move(bindings); + m_lastError.clear(); + emit errorChanged(); + emit changed(); + return true; +} + +} // namespace docview diff --git a/src/core/config.h b/src/core/config.h new file mode 100644 index 0000000..e276377 --- /dev/null +++ b/src/core/config.h @@ -0,0 +1,45 @@ +#pragma once + +#include "input.h" + +#include +#include + +namespace docview { + +struct StoragePaths { + QString configFile; + QString stateFile; + QString cacheDirectory; + QString logsDirectory; + static StoragePaths forCurrentUser(); +}; + +class ConfigManager : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap snapshot READ snapshot NOTIFY changed) + Q_PROPERTY(QString lastError READ lastError NOTIFY errorChanged) +public: + explicit ConfigManager(QObject *parent = nullptr); + QVariantMap snapshot() const { return m_snapshot; } + QVariant value(const QString &dottedKey) const; + const QVector &bindings() const { return m_bindings; } + QString lastError() const { return m_lastError; } + QString path() const { return m_path; } + Q_INVOKABLE bool reload(const QString &path = {}); + bool loadData(const QByteArray &toml, const QString &source = QStringLiteral("config.toml")); + static QVariantMap defaults(); +signals: + void changed(); + void errorChanged(); + void errorOccurred(const QString &message); +private: + bool fail(const QString &message); + QVariantMap m_snapshot; + QVector m_bindings; + QString m_path; + QString m_lastError; + bool m_hasLoadedFile = false; +}; + +} // namespace docview diff --git a/src/core/input.cpp b/src/core/input.cpp new file mode 100644 index 0000000..a37a9e5 --- /dev/null +++ b/src/core/input.cpp @@ -0,0 +1,412 @@ +#include "input.h" +#include "config.h" + +#include +#include +#include +#include + +namespace docview { +namespace { + +const QSet &commands() +{ + static const QSet values = { + "file.open", "app.quit", "operation.cancel", "document.root.choose", "document.info", + "history.clear", "scroll.down", "scroll.up", "scroll.left", "scroll.right", + "scroll.half_down", "scroll.half_up", "scroll.page_down", "scroll.page_up", + "nav.document_start", "nav.document_end", "nav.page", "nav.page_next", "nav.page_previous", + "nav.heading_next", "nav.heading_previous", "nav.chapter_next", "nav.chapter_previous", + "panel.toc.toggle", "panel.pages.toggle", "panel.status.toggle", "focus.next", "focus.content", + "zoom.in", "zoom.out", "zoom.fit_width", "view.rotate", "command.open", "config.reload", + "help.toggle", "nav.back", "nav.forward", "search.open", "search.next", "search.previous", + "panel.next", "panel.previous", "panel.collapse", "panel.expand", "panel.first", "panel.last", + "panel.half_down", "panel.half_up", "panel.activate", "link.next", "link.previous", "link.activate", + "unbound" + }; + return values; +} + +bool integerArgument(const QVariant &value, qlonglong minimum, qlonglong maximum) +{ + const auto type = value.metaType().id(); + if (type != QMetaType::Int && type != QMetaType::LongLong && type != QMetaType::UInt + && type != QMetaType::ULongLong) + return false; + bool ok = false; + const auto integer = value.toLongLong(&ok); + return ok && integer >= minimum && integer <= maximum; +} + +} // namespace + +QVariantMap CommandResult::toVariant() const +{ + return valid() ? QVariantMap{{"command", id}, {"args", arguments}} : QVariantMap{}; +} + +bool CommandRegistry::known(const QString &id) { return commands().contains(id); } + +QString CommandRegistry::validate(const QString &id, const QVariantMap &arguments) +{ + if (!known(id)) + return QCoreApplication::translate("Commands", "不明なコマンドです: %1").arg(id); + if (id == "file.open") { + if (arguments.isEmpty()) + return {}; + if (arguments.size() != 1 || arguments.value("path").metaType().id() != QMetaType::QString + || arguments.value("path").toString().isEmpty() || arguments.value("path").toString().size() > 32768 + || arguments.value("path").toString().contains(QChar::Null)) + return QCoreApplication::translate("Commands", "file.open には空でないパスを1つ指定してください"); + return {}; + } + if (id == "nav.page") { + if (arguments.size() != 1 || !integerArgument(arguments.value("page"), 1, std::numeric_limits::max())) + return QCoreApplication::translate("Commands", "nav.page には1以上の整数でページ番号を指定してください"); + return {}; + } + if (id == "view.rotate") { + if (arguments.size() != 1 || !integerArgument(arguments.value("degrees"), -270, 270) + || arguments.value("degrees").toInt() == 0 || arguments.value("degrees").toInt() % 90) + return QCoreApplication::translate("Commands", "view.rotate の角度は -270、-180、-90、90、180、270 のいずれかを指定してください"); + return {}; + } + return arguments.isEmpty() ? QString{} : QCoreApplication::translate("Commands", "%1 に引数は指定できません").arg(id); +} + +QString CommandRegistry::requiredCapability(const QString &id) +{ + if (id == "nav.page" || id == "nav.page_next" || id == "nav.page_previous" || id == "view.rotate") + return "pageNavigation"; + if (id.startsWith("nav.chapter_")) + return "chapterNavigation"; + if (id.startsWith("nav.heading_")) + return "headings"; + if (id == "panel.toc.toggle") + return "outline"; + if (id.startsWith("search.")) + return "textSearch"; + return {}; +} + +bool CommandRegistry::repeatable(const QString &id) +{ + return id.startsWith("scroll.") || id == "panel.next" || id == "panel.previous" + || id == "panel.half_down" || id == "panel.half_up" || id == "zoom.in" || id == "zoom.out"; +} + +CommandResult CommandRegistry::parse(const QString &text) +{ + QString source = text.trimmed(); + if (source.startsWith(':')) + source.remove(0, 1); + if (source.size() > 32768 || source.contains(QChar::Null)) + return {{}, {}, QCoreApplication::translate("Commands", "コマンドが長すぎるか、NUL文字が含まれています")}; + QStringList tokens; + QString token; + bool quoted = false; + bool started = false; + // Backslashes are deliberately literal, including those in Windows paths. + for (const auto character : source) { + if (character == '"') { + quoted = !quoted; + started = true; + } else if (character.isSpace() && !quoted) { + if (started) { + tokens.append(token); + token.clear(); + started = false; + } + } else { + token.append(character); + started = true; + } + } + if (quoted) + return {{}, {}, QCoreApplication::translate("Commands", "ダブルクォーテーションが閉じられていません")}; + if (started) + tokens.append(token); + if (tokens.isEmpty()) + return {{}, {}, QCoreApplication::translate("Commands", "コマンドを入力してください")}; + const QString name = tokens.takeFirst(); + const QMap simple = { + {"q", "app.quit"}, {"cancel", "operation.cancel"}, {"root", "document.root.choose"}, + {"info", "document.info"}, {"toctoggle", "panel.toc.toggle"}, {"pagestoggle", "panel.pages.toggle"}, + {"statusbar", "panel.status.toggle"}, {"reload", "config.reload"}, {"help", "help.toggle"} + }; + CommandResult result; + if (simple.contains(name)) { + result.id = simple.value(name); + if (!tokens.isEmpty()) + result.error = QCoreApplication::translate("Commands", "%1 に引数は指定できません").arg(name); + } else if (name == "open") { + result.id = "file.open"; + if (tokens.size() > 1) + result.error = QCoreApplication::translate("Commands", "空白を含むパスはダブルクォーテーションで囲んでください"); + else if (tokens.size() == 1) + result.arguments.insert("path", tokens.front()); + } else if (name == "page" || name == "rotate") { + result.id = name == "page" ? "nav.page" : "view.rotate"; + bool ok = false; + const qlonglong number = tokens.size() == 1 ? tokens.front().toLongLong(&ok) : 0; + static const QRegularExpression integer(QStringLiteral("^-?[0-9]+$")); + if (!ok || !integer.match(tokens.value(0)).hasMatch()) + result.error = QCoreApplication::translate("Commands", "%1 には整数の引数を1つ指定してください").arg(name); + else + result.arguments.insert(name == "page" ? "page" : "degrees", number); + } else if (name == "history" && tokens == QStringList{"clear"}) { + result.id = "history.clear"; + } else { + result.error = QCoreApplication::translate("Commands", "コマンドまたは引数が無効です: %1").arg(name); + } + if (result.error.isEmpty()) + result.error = validate(result.id, result.arguments); + return result; +} + +bool CommandRegistry::validKey(const QString &key) +{ + return !canonicalKey(key).isEmpty(); +} + +QString CommandRegistry::canonicalKey(const QString &key) +{ + if (key.isEmpty() || key.size() > 64) + return {}; + if (key.size() == 1) + return key.front().isPrint() && !key.front().isSpace() ? key : QString{}; + static const QSet named = {"Esc", "Enter", "Tab", "Space", "Backspace", "Delete", "Insert", + "Home", "End", "PageUp", "PageDown", "Left", "Right", "Up", "Down"}; + static const QRegularExpression functionKey("^F([1-9]|1[0-9]|2[0-4])$"); + const QStringList order{"Ctrl", "Alt", "Shift", "Meta"}; + QString base = key; + QSet modifiers; + for (;;) { + bool found = false; + for (const auto &modifier : order) { + if (!base.startsWith(modifier + '+')) + continue; + if (modifiers.contains(modifier)) + return {}; + modifiers.insert(modifier); + base.remove(0, modifier.size() + 1); + found = true; + break; + } + if (!found) + break; + } + const bool printable = base.size() == 1 && base.front().isPrint() && !base.front().isSpace(); + if (!printable && !named.contains(base) && !functionKey.match(base).hasMatch()) + return {}; + const bool latinLetter = base.size() == 1 && ((base.front() >= 'a' && base.front() <= 'z') + || (base.front() >= 'A' && base.front() <= 'Z')); + if (printable && modifiers == QSet{"Shift"}) { + // Shift+1 can produce different characters on different layouts. + // Such bindings must use the actual character (for example !). + return latinLetter ? base.toUpper() : QString{}; + } + if (!modifiers.isEmpty() && latinLetter) + base = base.toUpper(); + QString result; + for (const auto &modifier : order) { + if (modifiers.contains(modifier)) + result += modifier + '+'; + } + return result + base; +} + +QVector CommandRegistry::defaultBindings() +{ + QVector values; + auto add = [&values](const QString &context, const QStringList &keys, const QString &command) { + values.append({"normal", context, keys, command, {}, 0}); + }; + const QMap content = { + {"j", "scroll.down"}, {"k", "scroll.up"}, {"h", "scroll.left"}, {"l", "scroll.right"}, + {"Ctrl+D", "scroll.half_down"}, {"Ctrl+U", "scroll.half_up"}, {"Ctrl+F", "scroll.page_down"}, + {"Ctrl+B", "scroll.page_up"}, {"G", "nav.document_end"}, {"J", "nav.page_next"}, + {"K", "nav.page_previous"}, {"+", "zoom.in"}, {"-", "zoom.out"}, {"=", "zoom.fit_width"}, + {"Tab", "link.next"}, {"Shift+Tab", "link.previous"}, {"Enter", "link.activate"}, + {"Alt+Left", "nav.back"}, {"Alt+Right", "nav.forward"}, {"/", "search.open"}, + {"n", "search.next"}, {"N", "search.previous"} + }; + for (auto it = content.cbegin(); it != content.cend(); ++it) + add("content", {it.key()}, it.value()); + add("content", {"g", "g"}, "nav.document_start"); + add("content", {"]", "]"}, "nav.heading_next"); + add("content", {"[", "["}, "nav.heading_previous"); + add("content", {"]", "c"}, "nav.chapter_next"); + add("content", {"[", "c"}, "nav.chapter_previous"); + const QMap global = {{"Ctrl+O", "file.open"}, {"t", "panel.toc.toggle"}, + {"p", "panel.pages.toggle"}, {":", "command.open"}, {"?", "help.toggle"}}; + for (auto it = global.cbegin(); it != global.cend(); ++it) + add("global", {it.key()}, it.value()); + add("global", {"z", "s"}, "panel.status.toggle"); + add("global", {"Ctrl+W", "w"}, "focus.next"); + for (const auto &context : {QStringLiteral("toc"), QStringLiteral("pages")}) { + add(context, {"j"}, "panel.next"); + add(context, {"k"}, "panel.previous"); + add(context, {"g", "g"}, "panel.first"); + add(context, {"G"}, "panel.last"); + add(context, {"Ctrl+D"}, "panel.half_down"); + add(context, {"Ctrl+U"}, "panel.half_up"); + add(context, {"Enter"}, "panel.activate"); + } + add("toc", {"h"}, "panel.collapse"); + add("toc", {"l"}, "panel.expand"); + return values; +} + +InputRouter::InputRouter(QObject *parent) : QObject(parent), m_bindings(CommandRegistry::defaultBindings()) +{ + m_timeout.setSingleShot(true); + connect(&m_timeout, &QTimer::timeout, this, &InputRouter::reset); +} + +void InputRouter::setConfig(const ConfigManager &config) +{ + setBindings(config.bindings(), config.value("keys.sequence_timeout_ms").toInt()); +} + +void InputRouter::setBindings(QVector bindings, int timeoutMs) +{ + reset(); + m_bindings = std::move(bindings); + m_timeoutMs = timeoutMs; +} + +void InputRouter::setMode(const QString &mode) +{ + if (m_mode == mode) + return; + reset(); + m_mode = mode; + emit modeChanged(); +} + +QString InputRouter::pending() const { return m_digits.isEmpty() ? m_pending.join(' ') : m_digits; } + +void InputRouter::reset() +{ + const bool changed = !m_pending.isEmpty() || !m_digits.isEmpty(); + m_pending.clear(); + m_digits.clear(); + m_timeout.stop(); + if (changed) + emit pendingChanged(); +} + +void InputRouter::waitForNextKey() +{ + if (m_timeoutMs > 0) + m_timeout.start(m_timeoutMs); + emit pendingChanged(); +} + +QVariantMap InputRouter::execute(const QString &command, const QVariantMap &arguments) +{ + if (command == "unbound") + return {}; + if (!CommandRegistry::validate(command, arguments).isEmpty()) + return {}; + emit commandReady(command, arguments); + return {{"command", command}, {"args", arguments}}; +} + +QVariantMap InputRouter::feed(const QString &key, const QString &context, bool composing, bool autoRepeat) +{ + if (context != m_context) { + reset(); + m_context = context; + } + if (composing || m_mode != "normal" || context == "dialog" || context == "command" || context == "search") { + reset(); + return {}; + } + if (key == "Esc") { + if (!pending().isEmpty()) { + reset(); + return {}; + } + if (autoRepeat) + return {}; + return execute(context == "toc" || context == "pages" ? "focus.content" : "operation.cancel"); + } + if (!m_digits.isEmpty()) { + if (autoRepeat) + return {}; + if (key.size() == 1 && key.front() >= '0' && key.front() <= '9') { + if (m_digits.size() < 12) + m_digits.append(key); + else + m_digits = "0"; // Overflow stays invalid until the entire numeric sequence is consumed. + waitForNextKey(); + return {}; + } + const auto digits = m_digits; + reset(); + bool ok = false; + const qlonglong page = digits.toLongLong(&ok); + if (key == "G" && !digits.startsWith('0') && ok && page <= std::numeric_limits::max()) + return execute("nav.page", {{"page", page}}); + return {}; // Numeric mismatches must not become unrelated commands. + } + if (context == "content" && m_pending.isEmpty() && key.size() == 1 && key.front() >= '0' && key.front() <= '9') { + if (!autoRepeat) { + m_digits = key; + waitForNextKey(); + } + return {}; + } + return match(key, autoRepeat); +} + +QVariantMap InputRouter::match(const QString &key, bool autoRepeat, bool reprocessed) +{ + if (autoRepeat && !m_pending.isEmpty()) + return {}; + auto candidate = m_pending; + candidate.append(key); + const KeyBinding *exact = nullptr; + bool prefix = false; + for (const auto &binding : m_bindings) { + if (binding.mode != m_mode || (binding.context != m_context && binding.context != "global")) + continue; + if (binding.keys.size() < candidate.size()) + continue; + bool matches = true; + for (qsizetype i = 0; i < candidate.size(); ++i) + matches = matches && candidate[i] == binding.keys[i]; + if (!matches) + continue; + if (binding.keys.size() == candidate.size()) { + if (!exact || binding.context == m_context) + exact = &binding; + } else if (binding.command != "unbound") { + prefix = true; + } + } + if (exact) { + const auto command = exact->command; + const auto arguments = exact->arguments; + reset(); + if (autoRepeat && !CommandRegistry::repeatable(command)) + return {}; + return execute(command, arguments); + } + if (prefix) { + if (!autoRepeat) { + m_pending = candidate; + waitForNextKey(); + } + return {}; + } + const bool hadPrefix = !m_pending.isEmpty(); + reset(); + if (hadPrefix && !reprocessed) + return feed(key, m_context, false, autoRepeat); + return {}; +} + +} // namespace docview diff --git a/src/core/input.h b/src/core/input.h new file mode 100644 index 0000000..1944e41 --- /dev/null +++ b/src/core/input.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace docview { + +struct KeyBinding { + QString mode = QStringLiteral("normal"); + QString context = QStringLiteral("content"); + QStringList keys; + QString command; + QVariantMap arguments; + int line = 0; +}; + +struct CommandResult { + QString id; + QVariantMap arguments; + QString error; + bool valid() const { return !id.isEmpty() && error.isEmpty(); } + QVariantMap toVariant() const; +}; + +class CommandRegistry { +public: + static bool known(const QString &id); + static QString validate(const QString &id, const QVariantMap &arguments = {}); + static QString requiredCapability(const QString &id); + static bool repeatable(const QString &id); + static CommandResult parse(const QString &text); + static QVector defaultBindings(); + // Empty means invalid. Normalize modifier order and letter spelling to + // match the logical key names emitted by the window's event filter. + static QString canonicalKey(const QString &key); + static bool validKey(const QString &key); +}; + +class ConfigManager; + +class InputRouter : public QObject { + Q_OBJECT + Q_PROPERTY(QString pending READ pending NOTIFY pendingChanged) + Q_PROPERTY(QString mode READ mode WRITE setMode NOTIFY modeChanged) +public: + explicit InputRouter(QObject *parent = nullptr); + void setConfig(const ConfigManager &config); + void setBindings(QVector bindings, int timeoutMs = 800); + Q_INVOKABLE QVariantMap feed(const QString &key, const QString &context = QStringLiteral("content"), + bool composing = false, bool autoRepeat = false); + Q_INVOKABLE void reset(); + void setMode(const QString &mode); + QString mode() const { return m_mode; } + QString pending() const; +signals: + void commandReady(const QString &command, const QVariantMap &arguments); + void pendingChanged(); + void modeChanged(); +private: + QVariantMap execute(const QString &command, const QVariantMap &arguments = {}); + QVariantMap match(const QString &key, bool autoRepeat, bool reprocessed = false); + void waitForNextKey(); + QVector m_bindings; + QStringList m_pending; + QString m_digits; + QString m_context = QStringLiteral("content"); + QString m_mode = QStringLiteral("normal"); + QTimer m_timeout; + int m_timeoutMs = 800; +}; + +} // namespace docview diff --git a/src/core/state.cpp b/src/core/state.cpp new file mode 100644 index 0000000..fb55b2a --- /dev/null +++ b/src/core/state.cpp @@ -0,0 +1,491 @@ +#include "state.h" +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#elif defined(Q_OS_WIN) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace docview { +namespace { + +constexpr qint64 maximumStateBytes = 8 * 1024 * 1024; + +bool decodeState(const QByteArray &data, QJsonArray &documents) +{ + QJsonParseError error; + const auto json = QJsonDocument::fromJson(data, &error); + if (error.error != QJsonParseError::NoError || !json.isObject()) + return false; + const auto object = json.object(); + if (object.value("state_version").toInt(-1) != 1 || !object.value("documents").isArray()) + return false; + const auto values = object.value("documents").toArray(); + if (values.size() > 1000) + return false; + for (const auto &value : values) { + if (!value.isObject()) + return false; + const auto entry = value.toObject(); + const auto identity = entry.value("identity").toObject(); + if (entry.value("documentId").toString().isEmpty() || !identity.value("canonicalPath").isString() + || identity.value("canonicalPath").toString().size() > 32768 || !identity.value("size").isDouble() + || !identity.value("mtime").isDouble() || !entry.value("location").isObject()) + return false; + } + documents = values; + return true; +} + +QByteArray readState(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly) || file.size() > maximumStateBytes) + return {}; + return file.read(maximumStateBytes + 1); +} + +bool removeTransientLocationUrls(QJsonArray &documents) +{ + bool changed = false; + for (qsizetype i = 0; i < documents.size(); ++i) { + auto entry = documents[i].toObject(); + auto location = entry.value("location").toObject(); + if (!location.contains("url")) + continue; + // This field was the renderer's doc://session-token URL. Leave href, + // CFI and authored text (including text that looks like a URL) intact. + location.remove("url"); + entry.insert("location", location); + documents[i] = entry; + changed = true; + } + return changed; +} + +QByteArray encodeState(const QJsonArray &documents) +{ + return QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", documents}}) + .toJson(QJsonDocument::Compact); +} + +bool atomicWrite(const QString &path, const QByteArray &data) +{ + QSaveFile file(path); + file.setDirectWriteFallback(false); + if (!file.open(QIODevice::WriteOnly)) + return false; + if (!file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)) + return false; + if (file.write(data) != data.size()) + return false; + return file.commit(); +} + +QVariant withoutTextQuotes(const QVariant &value) +{ + if (value.metaType().id() == QMetaType::QVariantMap) { + auto map = value.toMap(); + map.remove("textQuote"); + map.remove("text_quote"); + for (auto it = map.begin(); it != map.end(); ++it) + it.value() = withoutTextQuotes(it.value()); + return map; + } + if (value.metaType().id() == QMetaType::QVariantList) { + auto list = value.toList(); + for (auto &item : list) + item = withoutTextQuotes(item); + return list; + } + return value; +} + +bool sameIdentity(const QVariantMap &left, const QVariantMap &right) +{ + // Historical Windows records used an empty physical identity. Preserve + // their history entry, but do not restore a position using path/time alone. + return !left.value("fileIdentity").toString().isEmpty() && !right.value("fileIdentity").toString().isEmpty() + && left.value("canonicalPath") == right.value("canonicalPath") + && left.value("size").toLongLong() == right.value("size").toLongLong() + && left.value("mtime").toLongLong() == right.value("mtime").toLongLong() + && left.value("fileIdentity") == right.value("fileIdentity"); +} + +bool safeArchiveEntry(const QString &path) +{ + // Saved state is untrusted. Recheck relative names even though the worker + // and resource broker also validate their independently supplied paths. + if (path.isEmpty() || path.toUtf8().size() > 1024 || path.startsWith('/') + || path.contains('\\') || path.contains(':') || path.contains(QChar::ReplacementCharacter) + || !QStringList{"html", "htm", "xhtml"}.contains(QFileInfo(path).suffix().toLower())) + return false; + const auto parts = path.split('/'); + if (parts.size() > 64) + return false; + static const QRegularExpression reserved("^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\\.|$)", + QRegularExpression::CaseInsensitiveOption); + for (const auto &part : parts) { + if (part.isEmpty() || part == "." || part == ".." || part.endsWith('.') || part.endsWith(' ') + || reserved.match(part).hasMatch()) + return false; + for (const auto c : part) + if (c.unicode() < 32 || c.unicode() == 127 || QStringLiteral("<>?*|\"").contains(c)) + return false; + } + return true; +} + +} // namespace + +StateStore::StateStore(const QString &stateFile, QObject *parent) + : QObject(parent), m_path(stateFile.isEmpty() ? StoragePaths::forCurrentUser().stateFile : stateFile) +{ + const QString directory = QFileInfo(m_path).absolutePath(); + if (QFileInfo(directory).isSymLink()) { + m_lastError = tr("読書状態の保存先にはシンボリックリンクを使用できません。"); + } else if (!QDir().mkpath(directory)) { + m_lastError = tr("専用の読書状態フォルダーを作成できないため、状態は読み取り専用です。"); + } else { + QFile::setPermissions(directory, QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); + m_lock = std::make_unique(m_path + ".lock"); + m_lock->setStaleLockTime(0); + m_hasLock = m_lock->tryLock(0); + if (!m_hasLock) + m_lastError = tr("別のアプリが保存先を使用しているため、読書状態は読み取り専用です。"); + } + load(); + m_debounce.setSingleShot(true); + m_debounce.setInterval(2000); + m_maximumInterval.setSingleShot(true); + m_maximumInterval.setInterval(10000); + connect(&m_debounce, &QTimer::timeout, this, &StateStore::flush); + connect(&m_maximumInterval, &QTimer::timeout, this, &StateStore::flush); + if (m_migrationPending) { + m_debounce.start(); + m_maximumInterval.start(); + } +} + +StateStore::~StateStore() { flush(); } + +void StateStore::forceReadOnly(){m_dirty=false;m_migrationPending=false;m_debounce.stop();m_maximumInterval.stop();if(m_lock&&m_hasLock)m_lock->unlock();m_hasLock=false;} + +void StateStore::setConfig(const ConfigManager &config) +{ + m_rememberPosition = config.value("privacy.remember_position").toBool(); + m_storeTextAnchor = config.value("privacy.store_text_anchor").toBool(); + m_recentLimit = config.value("privacy.recent_documents").toInt(); +} + +bool StateStore::fail(const QString &message) +{ + m_lastError = message; + emit errorOccurred(message); + return false; +} + +bool StateStore::load() +{ + if (!QFileInfo::exists(m_path) && !QFileInfo::exists(m_path + ".bak")) + return true; + QJsonArray current, backup; + const bool currentValid = decodeState(readState(m_path), current); + const bool backupValid = decodeState(readState(m_path + ".bak"), backup); + if (!currentValid && !backupValid) + return fail(tr("読書状態を読み取れません。文書は通常どおり開けます。")); + const bool currentChanged = currentValid && removeTransientLocationUrls(current); + const bool backupChanged = backupValid && removeTransientLocationUrls(backup); + m_documents = currentValid ? current : backup; + if (!currentValid) + m_lastError = tr("読書状態が破損していたため、前回の保存内容を復元しました。"); + + // Defer disk migration until flush, allowing callers to force read-only + // immediately after construction. A lock-less instance only changes memory. + m_migrationPending = m_hasLock && (currentChanged || backupChanged); + return true; +} + +QVariantMap StateStore::fileIdentity(const QString &path) +{ + const QFileInfo info(path); + if (!info.exists() || !info.isFile() || info.canonicalFilePath().isEmpty()) + return {}; + QString physicalIdentity; + qint64 size = info.size(); + qint64 mtime = info.lastModified().toMSecsSinceEpoch(); +#ifdef Q_OS_UNIX + struct stat details {}; + if (::stat(QFile::encodeName(info.canonicalFilePath()).constData(), &details) == 0) + physicalIdentity = QString::number(static_cast(details.st_dev)) + ":" + + QString::number(static_cast(details.st_ino)); +#elif defined(Q_OS_WIN) + const auto nativePath = QDir::toNativeSeparators(info.canonicalFilePath()).toStdWString(); + const HANDLE handle = CreateFileW(nativePath.c_str(), FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (handle == INVALID_HANDLE_VALUE) return {}; + struct CloseFile { HANDLE handle; ~CloseFile() { CloseHandle(handle); } } owner{handle}; + BY_HANDLE_FILE_INFORMATION details{}; + if (GetFileType(handle) != FILE_TYPE_DISK || !GetFileInformationByHandle(handle, &details) + || (details.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT))) return {}; + const auto nativeSize = (quint64(details.nFileSizeHigh) << 32) | details.nFileSizeLow; + if (nativeSize > quint64(std::numeric_limits::max())) return {}; + size = qint64(nativeSize); + const auto modified = (quint64(details.ftLastWriteTime.dwHighDateTime) << 32) | details.ftLastWriteTime.dwLowDateTime; + mtime = qint64(modified / 10000) - 11644473600000LL; + FILE_ID_INFO extended{}; + if (GetFileInformationByHandleEx(handle, FileIdInfo, &extended, sizeof(extended))) { + const QByteArray identifier(reinterpret_cast(extended.FileId.Identifier), sizeof(extended.FileId.Identifier)); + if (identifier != QByteArray(identifier.size(), '\0')) + physicalIdentity = "win-id128:" + QString::number(qulonglong(extended.VolumeSerialNumber), 16) + + ':' + QString::fromLatin1(identifier.toHex()); + } + // ReFS does not promise that the older 64-bit file index is unique. If + // FileIdInfo is unsupported or incomplete, do not weaken the identity. +#endif + // A failed identity query must never match a previous failed query. Unix's + // existing dev:ino string remains unchanged; Windows IDs are namespaced. + if (physicalIdentity.isEmpty()) return {}; + return {{"canonicalPath", info.canonicalFilePath()}, {"size", size}, + {"mtime", mtime}, {"fileIdentity", physicalIdentity}}; +} + +bool StateStore::remember(const QString &path, const QVariantMap &location, double zoom) +{ + return rememberOpened(path, fileIdentity(path), location, zoom); +} + +bool StateStore::sameFileIdentity(const QVariantMap &left, const QVariantMap &right) +{ + return sameIdentity(left, right); +} + +bool StateStore::rememberOpened(const QString &path, const QVariantMap &openedIdentity, + const QVariantMap &location, double zoom) +{ + if (!m_hasLock) + return fail(tr("保存先のロックを取得できないため、読書状態は読み取り専用です。")); + if (!m_rememberPosition && m_recentLimit == 0) + return true; + const auto identity = fileIdentity(path); + if (identity.isEmpty()) + return fail(tr("文書の同一性を確認できないため、読書状態を保存できません。")); + if (!sameIdentity(identity, openedIdentity)) + return fail(tr("E_STATE_SAVE: 原本が変更または置換されたため、開いていた文書の位置は保存しません。文書を開き直してください。")); + if (!std::isfinite(zoom) || zoom < 25 || zoom > 800) + return fail(tr("倍率が範囲外のため、読書状態を保存できません。")); + auto logicalLocation = location; + logicalLocation.remove("url"); + const auto encodedLocation = QJsonObject::fromVariantMap(logicalLocation); + if (QJsonDocument(encodedLocation).toJson(QJsonDocument::Compact).size() > 32768) + return fail(tr("読書位置のデータが保存容量の上限を超えています。")); + auto entry = takeDocument(identity); + if (m_rememberPosition) { + const auto saved = m_storeTextAnchor ? logicalLocation : withoutTextQuotes(logicalLocation).toMap(); + entry.insert("location", QJsonObject::fromVariantMap(saved)); + entry.insert("zoom", zoom); + } + storeDocument(entry, identity); + return true; +} + +QJsonObject StateStore::takeDocument(const QVariantMap &identity) +{ + QJsonObject entry; + for (qsizetype i = 0; i < m_documents.size(); ++i) { + const auto candidate = m_documents[i].toObject(); + if (candidate.value("identity").toObject().value("canonicalPath").toString() == identity.value("canonicalPath").toString()) { + if (sameIdentity(candidate.value("identity").toObject().toVariantMap(), identity)) + entry = candidate; + m_documents.removeAt(i); + break; + } + } + if (entry.isEmpty()) { + entry.insert("documentId", QUuid::createUuid().toString(QUuid::WithoutBraces)); + entry.insert("location", QJsonObject{}); + } + return entry; +} + +void StateStore::storeDocument(QJsonObject entry, const QVariantMap &identity) +{ + entry.insert("identity", QJsonObject::fromVariantMap(identity)); + entry.insert("recent", m_recentLimit > 0); + entry.insert("updatedAt", QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + m_documents.prepend(entry); + while (m_documents.size() > 1000) + m_documents.removeLast(); + int recentCount = 0; + for (qsizetype i = 0; i < m_documents.size(); ++i) { + auto record = m_documents[i].toObject(); + if (record.value("recent").toBool() && ++recentCount > m_recentLimit) { + record.insert("recent", false); + m_documents[i] = record; + } + } + m_dirty = true; + m_debounce.start(); + if (!m_maximumInterval.isActive()) + m_maximumInterval.start(); + emit changed(); +} + +bool StateStore::rememberArchiveEntry(const QString &path, const QString &entry, + const QVariantMap &openedIdentity) +{ + if (!m_hasLock) + return fail(tr("保存先のロックを取得できないため、読書状態は読み取り専用です。")); + if (!m_rememberPosition && m_recentLimit == 0) + return true; + if (!safeArchiveEntry(entry)) + return fail(tr("アーカイブの入口が無効なため、選択内容を保存できません。")); + const auto identity = fileIdentity(path); + if (!sameIdentity(openedIdentity, identity)) + return fail(tr("アーカイブが変更されたため、入口の選択内容を保存しません。")); + auto record = takeDocument(identity); + record.insert("archiveEntry", entry); + storeDocument(record, identity); + return true; +} + +QString StateStore::archiveEntry(const QString &path, const QStringList ¤tCandidates) const +{ + if ((!m_rememberPosition && m_recentLimit == 0) || currentCandidates.size() > 20000) + return {}; + const auto identity = fileIdentity(path); + if (identity.isEmpty()) + return {}; + for (const auto &value : m_documents) { + const auto record = value.toObject(); + if (!sameIdentity(record.value("identity").toObject().toVariantMap(), identity)) + continue; + const auto entry = record.value("archiveEntry").toString(); + return safeArchiveEntry(entry) && currentCandidates.contains(entry) ? entry : QString{}; + } + return {}; +} + +QVariantMap StateStore::restore(const QString &path) const +{ + return restoreOpened(path, fileIdentity(path)).record; +} + +StateStore::Restoration StateStore::restoreOpened(const QString &path, const QVariantMap &openedIdentity) const +{ + if (!m_rememberPosition) + return {}; + const auto identity = fileIdentity(path); + const auto canonical = identity.value("canonicalPath", openedIdentity.value("canonicalPath", QFileInfo(path).absoluteFilePath())).toString(); + for (const auto &value : m_documents) { + const auto entry = value.toObject(); + const auto saved = entry.value("identity").toObject().toVariantMap(); + if (saved.value("canonicalPath").toString() != canonical + || entry.value("location").toObject().isEmpty()) + continue; + if (!sameIdentity(saved, identity) || !sameIdentity(openedIdentity, identity)) + return {{}, true}; + return {entry.toVariantMap(), false}; + } + return {}; +} + +QVariantList StateStore::recentDocuments() const +{ + QVariantList result; + if (m_recentLimit == 0) + return result; + for (const auto &value : m_documents) { + if (value.toObject().value("recent").toBool()) { + result.append(value.toObject().toVariantMap()); + if (result.size() >= m_recentLimit) + break; + } + } + return result; +} + +bool StateStore::flush() +{ + m_debounce.stop(); + m_maximumInterval.stop(); + if (!m_dirty && !m_migrationPending) + return true; + if (!m_hasLock) + return fail(tr("読書状態は読み取り専用です。")); + if (m_migrationPending) { + QJsonArray current, backup; + const bool currentValid = decodeState(readState(m_path), current); + const bool backupValid = decodeState(readState(m_path + ".bak"), backup); + const bool currentChanged = currentValid && removeTransientLocationUrls(current); + const bool backupChanged = backupValid && removeTransientLocationUrls(backup); + // Migrate each version in place, preserving the older logical position. + // Recover a damaged current file from the sanitized backup if needed. + bool written = true; + if (backupChanged) + written = atomicWrite(m_path + ".bak", encodeState(backup)); + if (currentChanged || (!currentValid && backupChanged)) + written = atomicWrite(m_path, encodeState(currentValid ? current : backup)) && written; + if (!written) + return fail(tr("読書状態から以前の一時 URL を除去できません。保存先の権限と空き容量を確認してください。")); + m_migrationPending = false; + } + if (!m_dirty) + return true; + const auto bytes = encodeState(m_documents); + if (bytes.size() > maximumStateBytes) + return fail(tr("読書状態が 8 MiB の保存上限を超えています。")); + QJsonArray previous; + if (decodeState(readState(m_path), previous)) { + removeTransientLocationUrls(previous); + if (!atomicWrite(m_path + ".bak", encodeState(previous))) + return fail(tr("前回の読書状態のバックアップを保存できません。")); + } else if (decodeState(readState(m_path + ".bak"), previous) && removeTransientLocationUrls(previous)) { + if (!atomicWrite(m_path + ".bak", encodeState(previous))) + return fail(tr("読書状態のバックアップから以前の一時 URL を除去できません。")); + } + if (!atomicWrite(m_path, bytes)) + return fail(tr("読書状態を保存できません。保存先の権限と空き容量を確認してください。")); + m_dirty = false; + m_lastError.clear(); + return true; +} + +bool StateStore::clearHistory() +{ + if (!m_hasLock) + return fail(tr("読書状態は読み取り専用です。")); + // Write the empty current file first; if that fails, retain the in-memory history. + const auto empty = QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", QJsonArray{}}}) + .toJson(QJsonDocument::Compact); + if (!atomicWrite(m_path, empty)) + return fail(tr("保存された履歴を消去できません。")); + m_documents = {}; + m_dirty = false; + m_migrationPending = false; + m_debounce.stop(); + m_maximumInterval.stop(); + emit changed(); + if (QFileInfo::exists(m_path + ".bak") && !QFile::remove(m_path + ".bak")) + return fail(tr("履歴は消去しましたが、以前の読書状態のバックアップを削除できませんでした。")); + return true; +} + +} // namespace docview diff --git a/src/core/state.h b/src/core/state.h new file mode 100644 index 0000000..8b80fd9 --- /dev/null +++ b/src/core/state.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace docview { + +class ConfigManager; + +class StateStore : public QObject { + Q_OBJECT + Q_PROPERTY(bool readOnly READ readOnly CONSTANT) + Q_PROPERTY(int count READ count NOTIFY changed) +public: + explicit StateStore(const QString &stateFile = {}, QObject *parent = nullptr); + ~StateStore() override; + void setConfig(const ConfigManager &config); + void forceReadOnly(); + bool readOnly() const { return !m_hasLock; } + int count() const { return m_documents.size(); } + QString lastError() const { return m_lastError; } + QString filePath() const { return m_path; } + Q_INVOKABLE bool remember(const QString &path, const QVariantMap &location, double zoom = 100); + Q_INVOKABLE QVariantMap restore(const QString &path) const; + // Reader sessions must bind their displayed location to the identity they + // opened, not whichever file happens to occupy the path when saving. + bool rememberOpened(const QString &path, const QVariantMap &openedIdentity, + const QVariantMap &location, double zoom = 100); + struct Restoration { QVariantMap record; bool identityMismatch = false; }; + Restoration restoreOpened(const QString &path, const QVariantMap &openedIdentity) const; + static bool sameFileIdentity(const QVariantMap &left, const QVariantMap &right); + Q_INVOKABLE QVariantList recentDocuments() const; + // ZIP entry choices share history privacy and identity checks, not the + // remember_position switch alone. Fully disabled history suppresses both. + bool rememberArchiveEntry(const QString &path, const QString &entry, const QVariantMap &openedIdentity); + QString archiveEntry(const QString &path, const QStringList ¤tCandidates) const; + Q_INVOKABLE bool flush(); + // Controller must present the count before invoking this destructive history operation. + Q_INVOKABLE bool clearHistory(); + static QVariantMap fileIdentity(const QString &path); +signals: + void changed(); + void errorOccurred(const QString &message); +private: + bool fail(const QString &message); + bool load(); + QJsonObject takeDocument(const QVariantMap &identity); + void storeDocument(QJsonObject entry, const QVariantMap &identity); + QString m_path; + QString m_lastError; + QJsonArray m_documents; + std::unique_ptr m_lock; + QTimer m_debounce; + QTimer m_maximumInterval; + bool m_hasLock = false; + bool m_dirty = false; + bool m_migrationPending = false; + bool m_rememberPosition = true; + bool m_storeTextAnchor = false; + int m_recentLimit = 20; +}; + +} // namespace docview diff --git a/src/core/types.cpp b/src/core/types.cpp new file mode 100644 index 0000000..96dc62b --- /dev/null +++ b/src/core/types.cpp @@ -0,0 +1,35 @@ +#include "types.h" + +#include +#include + +namespace docview { + +bool ResponseStamp::matches(const ResponseStamp ¤t) const +{ + return sessionId == current.sessionId && generation == current.generation + && revision == current.revision; +} + +std::optional physicalPageToIndex(std::int64_t physicalPage, int pageCount) +{ + if (physicalPage < 1 || physicalPage > pageCount) + return std::nullopt; + return static_cast(physicalPage - 1); +} + +bool validLocation(const DocumentLocation &location) +{ + return std::visit([](const auto &value) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return value.pageIndex >= 0 && std::isfinite(value.xPt) && std::isfinite(value.yPt) + && value.viewRotation % 90 == 0; + } else { + return std::isfinite(value.progression) && value.progression >= 0 + && value.progression <= 1; + } + }, location); +} + +} // namespace docview diff --git a/src/core/types.h b/src/core/types.h new file mode 100644 index 0000000..676b3d3 --- /dev/null +++ b/src/core/types.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace docview { + +enum class DocumentFormat { Pdf, Html, ZipHtml, Epub }; +enum class CapabilityState { Supported, Unavailable, Loading, Unsupported }; +enum class SessionState { Empty, Opening, PasswordRequired, Ready, Recovering, Failed, Closing }; + +struct Capability { + CapabilityState state = CapabilityState::Loading; + std::string reasonCode; +}; + +// Format adapters populate all eight capabilities; Loading is distinct from +// an absent feature. Unsupported capabilities carry a stable reason code. +struct DocumentCapabilities { + Capability pageNavigation; + Capability chapterNavigation; + Capability outline; + Capability headings; + Capability textSearch; + Capability textSelection; + Capability reflow; + Capability password; +}; + +constexpr std::string_view capabilityStateName(CapabilityState state) +{ + switch (state) { + case CapabilityState::Supported: return "supported"; + case CapabilityState::Unavailable: return "unavailable"; + case CapabilityState::Loading: return "loading"; + case CapabilityState::Unsupported: return "unsupported"; + } + return "unsupported"; +} + +struct DocumentIdentity { + std::string documentId; + std::string canonicalPath; + std::string fileIdentity; + std::int64_t size = 0; + std::int64_t mtime = 0; + std::optional contentHash; +}; + +struct PdfLocation { + int pageIndex = 0; + std::optional pageLabel; + double xPt = 0; + double yPt = 0; + int viewRotation = 0; + std::string fitMode = "fit-width"; +}; + +struct HtmlLocation { + std::string resourcePath; + std::optional fragment; + std::optional domPath; + std::optional textQuote; + double progression = 0; +}; + +struct EpubLocation { + std::string packagePath; + std::string spineIdref; + std::string href; + std::optional cfi; + std::optional fragment; + std::optional textQuote; + double progression = 0; +}; + +using DocumentLocation = std::variant; + +struct TocNode { + std::string id; + std::optional parentId; + std::string title; + std::optional target; + std::string source; + std::vector children; +}; + +struct Heading { + std::string id; + std::optional level; + std::string title; + DocumentLocation target; + std::string source; + std::string precision = "exact"; +}; + +// All asynchronous replies must match both generations before they may be shown. +struct ResponseStamp { + std::string sessionId; + std::uint64_t generation = 0; + std::uint64_t revision = 0; + bool matches(const ResponseStamp ¤t) const; +}; + +std::optional physicalPageToIndex(std::int64_t physicalPage, int pageCount); +bool validLocation(const DocumentLocation &location); + +} // namespace docview diff --git a/src/pdf/link_border_reader.cpp b/src/pdf/link_border_reader.cpp new file mode 100644 index 0000000..209b963 --- /dev/null +++ b/src/pdf/link_border_reader.cpp @@ -0,0 +1,356 @@ +#include "link_border_reader.h" +#include +#include "pdf_metadata_context.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +using Invalid = PdfMetadataInvalid; +using Limit = PdfMetadataLimit; +struct Unsupported : std::exception {}; +constexpr qsizetype maxAppearances = 16 * 1024 * 1024; +double number(QPDFObjectHandle value, double low = -1000000, double high = 1000000) { + if (!value.isNumber()) throw Invalid{}; + const double n = value.getNumericValue(); + if (!std::isfinite(n) || n < low || n > high) throw Invalid{}; + return n; +} +QByteArray num(double v) { if (std::abs(v) < 0.0000005) v = 0; return QByteArray::number(v, 'f', 6); } +QByteArray point(double x, double y) { return num(x) + ' ' + num(y); } +QByteArray polygon(std::initializer_list> points) { + QByteArray result; bool first = true; + for (auto [x, y] : points) { result += point(x, y) + (first ? " m\n" : " l\n"); first = false; } + return result + "h f\n"; +} +struct Border { + int index = 0; + int flags = 0; + bool isLink = true; + FS_RECTF rect{}, bounds{}, sourceRect{}; + QByteArray appearance; + bool installed = false; +}; +// PDF cubic approximation of a rounded rectangle, generated from numeric values +// only. PDF strings/names from the document never become drawing operators. +QByteArray path(double l, double b, double r, double t, double rx, double ry) { + if (!rx || !ry) return point(l, b) + ' ' + point(r - l, t - b) + " re\n"; + constexpr double k = 0.5522847498307936; + rx = std::min(rx, (r - l) / 2); ry = std::min(ry, (t - b) / 2); + QByteArray out = point(l + rx, b) + " m\n" + point(r - rx, b) + " l\n"; + const auto curve = [&](double x1, double y1, double x2, double y2, double x3, double y3) { + return point(x1, y1) + ' ' + point(x2, y2) + ' ' + point(x3, y3) + " c\n"; + }; + out += curve(r - rx + k * rx, b, r, b + ry - k * ry, r, b + ry); + out += point(r, t - ry) + " l\n" + curve(r, t - ry + k * ry, r - rx + k * rx, t, r - rx, t); + out += point(l + rx, t) + " l\n" + curve(l + rx - k * rx, t, l, t - ry + k * ry, l, t - ry); + out += point(l, b + ry) + " l\n" + curve(l, b + ry - k * ry, l + rx - k * rx, b, l + rx, b); + return out + "h\n"; +} +bool decodeBorder(QPDFObjectHandle annotation, int index, Border &out) { + if (!annotation.isDictionary()) return false; + const bool isLink = annotation.getKey("/Subtype").isNameAndEquals("/Link"); + auto flags = annotation.getKey("/F"); + if (!flags.isNull() && (!flags.isInteger() || flags.getIntValue() < 0 || flags.getIntValue() > 1023)) throw Invalid{}; + const int f = flags.isNull() ? 0 : flags.getIntValueAsInt(); + if (f & (FPDF_ANNOT_FLAG_HIDDEN | FPDF_ANNOT_FLAG_NOVIEW)) return false; + const bool textIcon = annotation.getKey("/Subtype").isNameAndEquals("/Text"); + if (!isLink && !textIcon && !(f & FPDF_ANNOT_FLAG_NOZOOM)) return false; + auto rect = annotation.getKey("/Rect"); + if (!rect.isArray() || rect.getArrayNItems() != 4) throw Invalid{}; + double l = number(rect.getArrayItem(0)), b = number(rect.getArrayItem(1)), r = number(rect.getArrayItem(2)), t = number(rect.getArrayItem(3)); + if (l > r) std::swap(l, r); if (b > t) std::swap(b, t); + if (!textIcon && (l == r || b == t)) return false; + out.index = index; out.flags = f; out.isLink = isLink; + out.rect = {float(l), float(t), float(r), float(b)}; + out.sourceRect = out.rect; + auto ap = annotation.getKey("/AP"); + if (!ap.isNull()) { + if (!ap.isDictionary()) throw Invalid{}; + auto normal = ap.getKey("/N"); + // A deliberately empty stream/state dictionary is still a supplied AP. + if (!normal.isNull()) { + if (!normal.isStream() && !normal.isDictionary()) throw Invalid{}; + if (!(f & (FPDF_ANNOT_FLAG_NOZOOM | FPDF_ANNOT_FLAG_NOROTATE))) return false; + out.bounds = out.rect; + out.installed = true; // Preserve the complete AP dictionary/resources. + return true; + } + } + if (textIcon) { + out.bounds = out.rect; out.installed = true; + return true; // PDFium's captured generated icon replaces these bounds. + } + if (!isLink) return false; // PDFium owns other subtypes' generated appearances. + double width = 1, rx = 0, ry = 0; + std::string style = "/S"; + QPDFObjectHandle dash; + auto bs = annotation.getKey("/BS"); + if (!bs.isNull()) { + if (!bs.isDictionary()) throw Invalid{}; + auto w = bs.getKey("/W"), s = bs.getKey("/S"); + if (!w.isNull()) width = number(w, 0, 10000); + if (!s.isNull()) { if (!s.isName()) throw Invalid{}; style = s.getName(); } + if (style != "/S" && style != "/D" && style != "/U" && style != "/B" && style != "/I") throw Unsupported{}; + if (style == "/D") dash = bs.getKey("/D"); + } else { + auto border = annotation.getKey("/Border"); + if (!border.isNull()) { + if (!border.isArray() || border.getArrayNItems() < 3 || border.getArrayNItems() > 4) throw Invalid{}; + rx = number(border.getArrayItem(0), 0, 10000); ry = number(border.getArrayItem(1), 0, 10000); + width = number(border.getArrayItem(2), 0, 10000); + if (border.getArrayNItems() == 4) { dash = border.getArrayItem(3); style = "/D"; } + } + } + if (!width) return false; + auto color = annotation.getKey("/C"); + QByteArray stroke = "0 G\n", fill = "0 g\n"; + double rgb[3]{0, 0, 0}; + if (!color.isNull()) { + if (!color.isArray()) throw Invalid{}; + const int n = color.getArrayNItems(); + if (n == 0) return false; + if (n != 1 && n != 3 && n != 4) throw Invalid{}; + double components[4]{}; QByteArray values; + for (int i = 0; i < n; ++i) { components[i] = number(color.getArrayItem(i), 0, 1); values += num(components[i]) + ' '; } + stroke = values + (n == 1 ? "G\n" : n == 3 ? "RG\n" : "K\n"); + fill = values + (n == 1 ? "g\n" : n == 3 ? "rg\n" : "k\n"); + for (int i = 0; i < 3; ++i) rgb[i] = n == 1 ? components[0] : n == 3 ? components[i] : 1 - std::min(1., components[i] + components[3]); + } + const double half = width / 2; + out.bounds = {float(l - half), float(t + half), float(r + half), float(b - half)}; + QByteArray content = "q\n" + stroke + fill + num(width) + " w\n0 J\n0 j\n"; + auto alpha = annotation.getKey("/CA"); + if (!alpha.isNull() && number(alpha, 0, 1) < 1) content += "/GS gs\n"; // SetAP supplies the GS ExtGState. + if (style == "/D") { + content += '['; + if (!dash.isInitialized() || dash.isNull()) content += "3 "; + else { + if (!dash.isArray()) throw Invalid{}; + const int n = dash.getArrayNItems(); if (n > 64) throw Limit{}; + double total = 0; + for (int i = 0; i < n; ++i) { const double part = number(dash.getArrayItem(i), 0, 10000); total += part; content += num(part) + ' '; } + if (n && total == 0) throw Invalid{}; + } + content += "] 0 d\n"; + } + if (style == "/U") content += point(l, b) + " m\n" + point(r, b) + " l S\n"; + else if (style == "/B" || style == "/I") { + // A raised/recessed rim: opposite light/dark facets, never flattened to S. + const double inset = std::min({width, (r - l) / 2, (t - b) / 2}); + const auto shade = [&](bool light) { QByteArray s; for (double c : rgb) s += num(light ? .5 + .5 * c : .5 * c) + ' '; return s + "rg\n"; }; + content += shade(style == "/B") + polygon({{l-half,b-half},{l-half,t+half},{r+half,t+half},{r-inset,t-inset},{l+inset,t-inset},{l+inset,b+inset}}); + content += shade(style == "/I") + polygon({{l-half,b-half},{r+half,b-half},{r+half,t+half},{r-inset,t-inset},{r-inset,b+inset},{l+inset,b+inset}}); + } else content += path(l, b, r, t, rx, ry) + "S\n"; + content += "Q\n"; + if (content.size() > 16384) throw Limit{}; + out.appearance = std::move(content); + return true; +} +void report(QString *code, QString *message, const char *name, const QString &description) { + if (code) *code = QString::fromLatin1(name); if (message) *message = description; +} +} +struct LinkBorderReader::RenderScope::Impl { + struct Change { FPDF_ANNOTATION annotation; FS_RECTF original; }; + std::vector changes; + ~Impl() { for (auto &change : changes) { FPDFAnnot_SetRect(change.annotation, &change.original); FPDFPage_CloseAnnot(change.annotation); } } +}; +struct LinkBorderReader::Impl { + std::shared_ptr context; + QHash> borders; + qsizetype appearanceBytes = 0; + QHash> iconRects; + QHash sharedIcons; + QHash> unsupportedNoZoom; + int iconFailure = 0; + int maximumNativeIcons = 16384; + void nativeIcons(int index, FPDF_PAGE page) { + if (iconFailure == 2) throw Limit{}; + if (iconFailure) throw Invalid{}; + try { collectNativeIcons(index, page); } + catch (const Limit &) { iconFailure = 2; throw; } + catch (...) { iconFailure = 1; throw; } + } + void collectNativeIcons(int index, FPDF_PAGE page) { + if (iconRects.contains(index)) return; + if (!context || index < 0 || index >= context->pageCount()) throw Invalid{}; + if (!FPDFPage_GetAnnotCount(page)) { iconRects.insert(index, {}); return; } + auto annotations = context->page(index).getKey("/Annots"); + if (!annotations.isNull() && !annotations.isArray()) throw Invalid{}; + const int count = annotations.isNull() ? 0 : annotations.getArrayNItems(); + if (count > 4096) throw Limit{}; + if (count != FPDFPage_GetAnnotCount(page)) throw Invalid{}; + struct Icon { int index; QString identity; FPDF_ANNOTATION annotation; FS_RECTF source; bool known; }; + std::vector icons; + struct Restore { + std::vector &icons; + ~Restore() { for (auto &icon : icons) { FPDFAnnot_SetRect(icon.annotation, &icon.source); FPDFPage_CloseAnnot(icon.annotation); } } + } restore{icons}; + bool generate = false; + QSet unsupported; + for (int i = 0; i < count; ++i) { + context->check(); auto value = annotations.getArrayItem(i); + if (!value.isDictionary()) continue; + const auto flags = value.getKey("/F"); + if (flags.isInteger() && (flags.getIntValue() & (FPDF_ANNOT_FLAG_HIDDEN | FPDF_ANNOT_FLAG_NOVIEW))) continue; + const auto ap = value.getKey("/AP"); + const auto type = value.getKey("/Subtype"); + if (!type.isNameAndEquals("/Text")) { + if (!type.isNameAndEquals("/Link") && flags.isInteger() && (flags.getIntValue() & FPDF_ANNOT_FLAG_NOZOOM) && + (ap.isNull() || (ap.isDictionary() && ap.getKey("/N").isNull()))) unsupported.insert(i); + continue; + } + if (!ap.isNull() && (!ap.isDictionary() || !ap.getKey("/N").isNull())) continue; + auto rect = value.getKey("/Rect"); + if (!rect.isArray() || rect.getArrayNItems() != 4) throw Invalid{}; + const FS_RECTF source{float(number(rect.getArrayItem(0))), float(number(rect.getArrayItem(3))), + float(number(rect.getArrayItem(2))), float(number(rect.getArrayItem(1)))}; + const auto identity = value.isIndirect() ? QStringLiteral("object:%1:%2").arg(value.getObjGen().getObj()).arg(value.getObjGen().getGen()) + : QStringLiteral("page:%1:%2").arg(index).arg(i); + auto annotation = FPDFPage_GetAnnot(page, i); if (!annotation) throw Invalid{}; + const bool known = sharedIcons.contains(identity); + icons.push_back({i, identity, annotation, source, known}); + if (!known && FPDFAnnot_GetAP(annotation, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2) generate = true; + } + if (generate) { + // The native pass constructs an annotation list for the whole page. + // Other missing appearances (e.g. Ink) can also change their Rect: + // hide every non-target for this pass and restore its exact flags. + struct FlagRestore { + std::vector> values; + ~FlagRestore() { for (auto i = values.rbegin(); i != values.rend(); ++i) { FPDFAnnot_SetFlags(i->first, i->second); FPDFPage_CloseAnnot(i->first); } } + } flags; + for (int i = 0; i < count; ++i) { + context->check(); + const bool target = std::any_of(icons.cbegin(), icons.cend(), [i](const Icon &icon) { return icon.index == i && !icon.known; }); + if (target) continue; + auto annotation = FPDFPage_GetAnnot(page, i); if (!annotation) throw Invalid{}; + const int original = FPDFAnnot_GetFlags(annotation); flags.values.push_back({annotation, original}); + if (!FPDFAnnot_SetFlags(annotation, original | FPDF_ANNOT_FLAG_HIDDEN)) throw Invalid{}; + } + // Construct the native appearance with a bounded one-pixel target. + // This uses the same PDFium renderer, never another parser/renderer. + quint32 pixel = 0xffffffff; + auto bitmap = FPDFBitmap_CreateEx(1, 1, FPDFBitmap_BGRA, &pixel, 4); + if (!bitmap) throw Limit{}; + FPDF_RenderPageBitmap(bitmap, page, 0, 0, 1, 1, 0, FPDF_ANNOT); + FPDFBitmap_Destroy(bitmap); + } + QHash result; + for (const auto &icon : icons) { + context->check(); FS_RECTF rect{}; + if (sharedIcons.contains(icon.identity)) rect = sharedIcons.value(icon.identity); + else { + if (sharedIcons.size() >= maximumNativeIcons) throw Limit{}; + if (FPDFAnnot_GetAP(icon.annotation, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2 || + !FPDFAnnot_GetRect(icon.annotation, &rect) || !std::isfinite(rect.left) || !std::isfinite(rect.top) || + !std::isfinite(rect.right) || !std::isfinite(rect.bottom) || rect.left >= rect.right || rect.bottom >= rect.top) throw Invalid{}; + sharedIcons.insert(icon.identity, rect); + } + result.insert(icon.index, rect); + } + context->check(); iconRects.insert(index, result); unsupportedNoZoom.insert(index, unsupported); + } +}; +LinkBorderReader::RenderScope::RenderScope(std::unique_ptr state) : d(std::move(state)) {} +LinkBorderReader::RenderScope::~RenderScope() = default; +LinkBorderReader::LinkBorderReader(std::shared_ptr context, int maximumNativeIcons) : d(std::make_unique()) { + d->context = std::move(context); d->maximumNativeIcons = std::clamp(maximumNativeIcons, 1, 16384); +} +LinkBorderReader::~LinkBorderReader() = default; +bool LinkBorderReader::prepareNativeIcons(int index, FPDF_PAGE page, QString *code, QString *message) { + try { d->context->beginOperation(); d->nativeIcons(index, page); return true; } + catch (const Limit &) { report(code, message, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfAnnotations", "PDFの注釈アイコンが安全上限を超えました。")); } + catch (...) { report(code, message, "E_PDF_CORRUPT", QCoreApplication::translate("PdfAnnotations", "PDFの注釈アイコンを解析できません。")); } + return false; +} +bool LinkBorderReader::nativeIconRect(int page, int annotation, FS_RECTF *rect) const { + const auto p = d->iconRects.constFind(page); + if (p == d->iconRects.cend()) return false; + const auto a = p->constFind(annotation); if (a == p->cend()) return false; + *rect = a.value(); return true; +} +bool LinkBorderReader::supportsNoZoom(int page, int annotation) const { return !d->unsupportedNoZoom.value(page).contains(annotation); } +std::unique_ptr LinkBorderReader::prepare(int index, FPDF_PAGE page, QString *code, QString *message, + double physicalScale, double devicePixelRatio) { + auto state = std::make_unique(); + try { + if (!d->context || index < 0 || index >= d->context->pageCount() || !std::isfinite(physicalScale) || physicalScale < .05 || physicalScale > 32 || + !std::isfinite(devicePixelRatio) || devicePixelRatio < .5 || devicePixelRatio > 8) throw Invalid{}; + d->context->beginOperation(); + d->nativeIcons(index, page); + // The scan-like workload has no annotations: do not walk or retain its + // page tree merely to discover that there are no link borders. + if (FPDFPage_GetAnnotCount(page) == 0) + return std::unique_ptr(new RenderScope(std::move(state))); + if (!d->borders.contains(index)) { + auto annotations = d->context->page(index).getKey("/Annots"); + if (!annotations.isNull() && !annotations.isArray()) throw Invalid{}; + const int count = annotations.isNull() ? 0 : annotations.getArrayNItems(); + if (count > 4096) throw Limit{}; + if (count != FPDFPage_GetAnnotCount(page)) throw Invalid{}; + std::vector decoded; + qsizetype pageBytes = 0; + for (int i = 0; i < count; ++i) { + d->context->check(); Border border; + if (decodeBorder(annotations.getArrayItem(i), i, border)) { + FS_RECTF icon; + if (nativeIconRect(index, i, &icon)) border.rect = border.bounds = icon; + if (d->appearanceBytes + pageBytes + border.appearance.size() > maxAppearances) throw Limit{}; + pageBytes += border.appearance.size(); decoded.push_back(std::move(border)); + } + } + d->context->check(); d->borders.insert(index, std::move(decoded)); d->appearanceBytes += pageBytes; + } + for (auto &border : d->borders[index]) { + FPDF_ANNOTATION annotation = FPDFPage_GetAnnot(page, border.index); + if (!annotation) throw Invalid{}; + FS_RECTF original{}; + const bool matches = (FPDFAnnot_GetSubtype(annotation) == FPDF_ANNOT_LINK) == border.isLink && FPDFAnnot_GetRect(annotation, &original) + && std::abs(std::min(original.left, original.right) - border.sourceRect.left) < .01 && std::abs(std::max(original.left, original.right) - border.sourceRect.right) < .01 + && std::abs(std::max(original.top, original.bottom) - border.sourceRect.top) < .01 && std::abs(std::min(original.top, original.bottom) - border.sourceRect.bottom) < .01; + if (!matches) { FPDFPage_CloseAnnot(annotation); throw Invalid{}; } + state->changes.push_back({annotation, original}); + // SetAP creates an indirect stream retained by the PDF document. + // Install only once, including aliases reused on other pages. + if (!border.installed && FPDFAnnot_GetAP(annotation, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2) { + // SetAP derives its BBox from Rect. Install in the original + // unscaled coordinate system before applying the render-only Rect. + if (!FPDFAnnot_SetRect(annotation, &border.bounds)) throw Invalid{}; + QByteArray text((border.appearance.size() + 1) * 2, '\0'); + for (qsizetype i = 0; i < border.appearance.size(); ++i) qToLittleEndian(quint8(border.appearance[i]), text.data() + i * 2); + if (!FPDFAnnot_SetAP(annotation, FPDF_ANNOT_APPEARANCEMODE_NORMAL, reinterpret_cast(text.constData()))) throw Invalid{}; + } + border.installed = true; + const double factor = border.flags & FPDF_ANNOT_FLAG_NOZOOM ? devicePixelRatio / physicalScale : 1; + double dx = (border.bounds.left - border.rect.left) * factor; + double dy = (border.bounds.top - border.rect.top) * factor; + // Native NoRotate pivots around the render Rect's top-left. Rotate + // the generated border's margin so the original Rect stays the pivot. + const int rotation = border.flags & FPDF_ANNOT_FLAG_NOROTATE ? FPDFPage_GetRotation(page) : 0; + if (rotation == 1) { const auto x = dx; dx = -dy; dy = x; } + else if (rotation == 2) { dx = -dx; dy = -dy; } + else if (rotation == 3) { const auto x = dx; dx = dy; dy = -x; } + const double left = border.rect.left + dx, top = border.rect.top + dy; + const FS_RECTF renderRect{float(left), float(top), + float(left + (border.bounds.right - border.bounds.left) * factor), + float(top - (border.bounds.top - border.bounds.bottom) * factor)}; + if (!FPDFAnnot_SetRect(annotation, &renderRect)) throw Invalid{}; + } + d->context->check(); + return std::unique_ptr(new RenderScope(std::move(state))); + } catch (const Limit &) { report(code, message, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfAnnotations", "PDFのリンク枠が解析・描画の安全上限を超えました。")); } + catch (const Unsupported &) { report(code, message, "E_PDF_ANNOT_UNSUPPORTED", QCoreApplication::translate("PdfAnnotations", "このリンク枠のスタイルには対応していません。")); } + catch (...) { report(code, message, "E_PDF_CORRUPT", QCoreApplication::translate("PdfAnnotations", "PDFのリンク枠の構造が無効です。")); } + return {}; +} +} diff --git a/src/pdf/link_border_reader.h b/src/pdf/link_border_reader.h new file mode 100644 index 0000000..91a63d7 --- /dev/null +++ b/src/pdf/link_border_reader.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include + +namespace docview { +class PdfMetadataContext; +// Supplemental, worker-only metadata reader. Never writes or serializes a PDF. +// PDFium's public annotation API cannot read /BS or /Border's dash array. +class LinkBorderReader { +public: + explicit LinkBorderReader(std::shared_ptr, int maximumNativeIcons = 16384); + ~LinkBorderReader(); + // Keeps temporary appearance rectangles alive only for the render call. + // Destroy before closing page. The original annotation rectangles are restored. + class RenderScope { + public: + ~RenderScope(); + private: + friend class LinkBorderReader; + struct Impl; + explicit RenderScope(std::unique_ptr); + std::unique_ptr d; + }; + std::unique_ptr prepare(int pageIndex, FPDF_PAGE page, QString *code, QString *message, + double physicalScale = 1, double devicePixelRatio = 1); + // Resolve PDFium's viewer-defined Text icon once, while retaining the source + // rectangle. The same geometry is used by drawing and comment hit testing. + bool prepareNativeIcons(int pageIndex, FPDF_PAGE page, QString *code, QString *message); + bool nativeIconRect(int pageIndex, int annotationIndex, FS_RECTF *rect) const; + bool supportsNoZoom(int pageIndex, int annotationIndex) const; +private: + struct Impl; + std::unique_ptr d; +}; +} diff --git a/src/pdf/main.cpp b/src/pdf/main.cpp new file mode 100644 index 0000000..cc34c6c --- /dev/null +++ b/src/pdf/main.cpp @@ -0,0 +1,104 @@ +#include "pdf_document.h" +#include "common/ipc.h" +#include "common/worker_runtime.h" +#include "common/worker_font_client.h" +#include +#include +#include +#include + +using namespace docview; +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + QCommandLineParser args; + addWorkerOptions(args); + args.process(app); + QString sandboxError; + auto runtime = startWorkerRuntime(args, {}, &sandboxError); + if (!runtime) return 5; + IpcChannel channel(runtime->transport.get()); + WorkerFontClient fonts(&channel, runtime.get()); + PdfDocument document([&](const QString &operation, const QCborMap &payload, int timeoutMs) { + return fonts.call(operation, payload, timeoutMs); + }); + QString session; qint64 generation = -1; + bool handling = false; + QObject::connect(&channel, &IpcChannel::protocolError, &app, [&] { app.exit(6); }); + QObject::connect(&fonts, &WorkerFontClient::protocolError, &app, [&] { app.exit(6); }); + const auto execute = [&](const QCborMap &request) { + QCborMap reply = request; reply.remove(QStringLiteral("payload")); + const auto payload = request.value(QStringLiteral("payload")).toMap(); + const QString operation = request.value(QStringLiteral("operation")).toString(); + QString code, error; QCborMap result; + auto integer = [&](const char *name, qint64 low, qint64 high, bool required = true) { + const QString key = QString::fromLatin1(name); + if (!payload.contains(key)) return !required; + auto value = payload.value(key); + return value.isInteger() && value.toInteger() >= low && value.toInteger() <= high; + }; + bool valid = true; + if (operation == "render" || operation == "headings" || operation == "links" || operation == "search" || operation == "pageInfo") valid = integer("page", 0, 99999); + if (operation == "render") valid = valid && integer("width", 1, 514) && integer("height", 1, 514) && integer("x", 0, 1000000) && integer("y", 0, 1000000) && integer("renderRevision", 0, 0x7fffffffffffffffLL) && integer("rotation", 0, 270, false) && (payload.value(QStringLiteral("scale")).isDouble() || payload.value(QStringLiteral("scale")).isInteger()); + if (operation == "render" && payload.contains(QStringLiteral("devicePixelRatio"))) valid = valid && + (payload.value(QStringLiteral("devicePixelRatio")).isDouble() || payload.value(QStringLiteral("devicePixelRatio")).isInteger()); + if (operation == "pages" || operation == "outline") valid = valid && integer("cursor", 0, 100000, false) && integer("limit", 1, 256, false); + if (operation == "search") valid = valid && integer("start", 0, 1000000, false) && integer("limit", 1, 100, false) && payload.value(QStringLiteral("query")).isString(); + if (operation == "open" && payload.contains(QStringLiteral("password"))) valid = payload.value(QStringLiteral("password")).isString(); + if (!valid) { code = QStringLiteral("E_PROTOCOL_INVALID"); error = QCoreApplication::translate("PdfWorker", "PDF操作の引数が無効です。"); } + else if (operation == "open") { + QString password = payload.value(QStringLiteral("password")).toString(); + if (password.size() > 1024) { code = QStringLiteral("E_PROTOCOL_INVALID"); error = QCoreApplication::translate("PdfWorker", "パスワードが長すぎます。"); } + else if (document.openFile(&runtime->source, password, &code, &error)) { + result = document.metadata(); + if (result.contains(QStringLiteral("error"))) document.close(); + } + password.fill(QChar('\0')); + } else if (operation == "pages") result = document.pages(int(payload.value(QStringLiteral("cursor")).toInteger()), int(payload.value(QStringLiteral("limit")).toInteger(256))); + else if (operation == "outline") result = document.outline(int(payload.value(QStringLiteral("cursor")).toInteger()), int(payload.value(QStringLiteral("limit")).toInteger(128))); + else if (operation == "render") result = document.render(payload, &code, &error); + else if (operation == "headings") result = document.headings(int(payload.value(QStringLiteral("page")).toInteger(-1))); + else if (operation == "links") result = document.links(int(payload.value(QStringLiteral("page")).toInteger(-1))); + else if (operation == "search") result = document.search(payload.value(QStringLiteral("query")).toString(), int(payload.value(QStringLiteral("page")).toInteger(-1)), int(payload.value(QStringLiteral("start")).toInteger()), int(payload.value(QStringLiteral("limit")).toInteger(100))); + else if (operation == "pageInfo") result = document.mapPoint(payload, &code, &error); + else if (operation == "close") { document.close(); result.insert(QStringLiteral("closed"), true); } + else if (operation == "cancel") result.insert(QStringLiteral("cancelled"), true); + else if (operation == "ping") result.insert(QStringLiteral("ready"), true); + else { code = QStringLiteral("E_PROTOCOL_INVALID"); error = QCoreApplication::translate("PdfWorker", "未対応のPDF操作です。"); } + if (!document.fontError().isEmpty() || fonts.failed()) { + const auto fontError = !document.fontError().isEmpty() ? document.fontError() + : QCborMap{{QStringLiteral("code"), fonts.errorCode()}, {QStringLiteral("message"), fonts.errorMessage()}}; + reply.insert(QStringLiteral("error"), fontError); + } + else if (result.contains(QStringLiteral("error"))) { reply.insert(QStringLiteral("error"), result.value(QStringLiteral("error"))); } + else if (!code.isEmpty()) reply.insert(QStringLiteral("error"), QCborMap{{QStringLiteral("code"), code}, {QStringLiteral("message"), error}, {QStringLiteral("retryable"), code.startsWith("E_PASSWORD")}}); + else { + auto warnings = result.value(QStringLiteral("warnings")).toArray(); + for (const auto &warning : document.fontWarnings()) if (!warnings.contains(warning)) warnings.append(warning); + if (!warnings.empty()) result.insert(QStringLiteral("warnings"), warnings); + reply.insert(QStringLiteral("result"), result); + } + if (operation != "render" && QCborValue(reply).toCbor().size() > MaxControlFrame) { + reply.remove(QStringLiteral("result")); + reply.insert(QStringLiteral("error"), QCborMap{{QStringLiteral("code"), QStringLiteral("E_LIMIT_EXCEEDED")}, {QStringLiteral("message"), QCoreApplication::translate("PdfWorker", "文書の索引情報が応答サイズの上限を超えています。")}}); + } + fonts.clearParentRequest(); + handling = false; + if (!channel.send(reply)) app.exit(7); + if (operation == "close") { + if (!runtime->flushWrites()) app.exit(7); + else QTimer::singleShot(0, &app, &QCoreApplication::quit); + } + }; + QObject::connect(&channel, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { + // Font replies must remain routable inside the callback's bounded + // event loop. PDFium itself never runs in the readyRead stack. + if (fonts.routeReply(request)) return; + if (handling || !request.value(QStringLiteral("payload")).isMap()) { app.exit(6); return; } + if (session.isEmpty()) { session = request.value(QStringLiteral("sessionId")).toString(); generation = request.value(QStringLiteral("generation")).toInteger(); } + if (request.value(QStringLiteral("sessionId")).toString() != session || request.value(QStringLiteral("generation")).toInteger() != generation) { app.exit(6); return; } + if (!fonts.setParentRequest(request)) { app.exit(6); return; } + handling = true; // Reserve before queuing, not when execution starts. + QMetaObject::invokeMethod(&app, [&, request] { execute(request); }, Qt::QueuedConnection); + }); + return app.exec(); +} diff --git a/src/pdf/pdf_document.cpp b/src/pdf/pdf_document.cpp new file mode 100644 index 0000000..a54454d --- /dev/null +++ b/src/pdf/pdf_document.cpp @@ -0,0 +1,478 @@ +#include "pdf_document.h" +#include +#include "link_border_reader.h" +#include "pdf_metadata_context.h" +#include "structure_reader.h" +#include "core/types.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +constexpr int kMaxStringBytes = 8192, kMaxNodes = 1000, kMaxCharacters = 1000000; +constexpr int kMaxOutlineNodes = 20000; +constexpr qsizetype kMetadataReplyBytes = 512 * 1024; +constexpr qsizetype kPageBatchBytes = 256 * 1024; +QString key(const char *s) { return QString::fromLatin1(s); } +template QString utf16(Function fn, bool *limited = nullptr) { + unsigned long n = fn(nullptr, 0); + if (limited) *limited = n > kMaxStringBytes; + if (n < 2 || n > kMaxStringBytes || n % 2) return {}; + QByteArray bytes(int(n), '\0'); + if (fn(bytes.data(), n) != n) return {}; + QString result; result.reserve(int(n / 2 - 1)); + for (int i = 0; i + 2 < int(n); i += 2) result += QChar(qFromLittleEndian(bytes.constData() + i)); + return result; +} +QCborMap failure(const QString &code, const QString &message) { + return {{key("error"), QCborMap{{key("code"), code}, {key("message"), message}}}}; +} +void setError(QString *code, QString *message, const char *c, const QString &m) { + if (code) *code = key(c); if (message) *message = m; +} +QCborArray rectArray(double left, double bottom, double right, double top) { return {left, bottom, right, top}; } +QString pageLabel(FPDF_DOCUMENT doc, int page, bool *limited = nullptr) { + return utf16([&](void *b, unsigned long n) { return FPDF_GetPageLabel(doc, page, b, n); }, limited); +} +} +struct PdfDocument::Impl { + SystemFontAdapter fonts; + explicit Impl(SystemFontAdapter::Fetch fetch) : fonts(std::move(fetch)) {} + FPDF_DOCUMENT document = nullptr; + FPDF_FORMHANDLE form = nullptr; + FPDF_FORMFILLINFO formInfo{}; + FPDF_FILEACCESS access{}; + std::unique_ptr ownedFile; + QFile *file = nullptr; + std::unique_ptr linkBorders; + std::shared_ptr metadata; + std::unique_ptr structure; + bool initialized = false; + struct OutlineItem { FPDF_BOOKMARK bookmark; int depth; int parent; }; + QVector outlineItems; + bool outlineIndexed = false, outlineTruncated = false; + QCborArray outlineWarnings; + struct Page { + FPDF_PAGE value{}; + FPDF_FORMHANDLE form{}; + Page(FPDF_DOCUMENT document, FPDF_FORMHANDLE forms, int index) : value(FPDF_LoadPage(document, index)), form(forms) { + if (value && form) FORM_OnAfterLoadPage(value, form); + } + ~Page() { if (value) { if (form) FORM_OnBeforeClosePage(value, form); FPDF_ClosePage(value); } } + }; + void init() { + if (initialized) return; + FPDF_LIBRARY_CONFIG config{}; config.version = 2; + FPDF_InitLibraryWithConfig(&config); initialized = true; + // Always install a memory-only provider. An absent test provider may + // use PDFium's built-in fonts, never its OS filesystem mapper. + FPDF_SetSystemFontInfo(fonts.interface()); + } + QCborMap destination(FPDF_DEST dest) const { + if (!dest) return {}; + const int index = FPDFDest_GetDestPageIndex(document, dest); + if (index < 0 || index >= FPDF_GetPageCount(document)) return {}; + FPDF_BOOL hasX = 0, hasY = 0, hasZoom = 0; FS_FLOAT x = 0, y = 0, zoom = 0; + FPDFDest_GetLocationInPage(dest, &hasX, &hasY, &hasZoom, &x, &y, &zoom); + QCborMap result{{key("page"), index}, {key("precision"), hasY ? key("exact") : key("page")}}; + if (hasX && std::isfinite(x)) result.insert(key("x"), x); + if (hasY && std::isfinite(y)) result.insert(key("y"), y); + return result; + } + QCborMap actionTarget(FPDF_DEST dest, FPDF_ACTION action) const { + if (dest) { auto result = destination(dest); if (!result.isEmpty()) { result.insert(key("kind"), key("internal")); return result; } } + if (action && FPDFAction_GetType(action) == PDFACTION_GOTO) { + auto result = destination(FPDFAction_GetDest(document, action)); + if (!result.isEmpty()) { result.insert(key("kind"), key("internal")); return result; } + } + if (action && FPDFAction_GetType(action) == PDFACTION_URI) { + auto n = FPDFAction_GetURIPath(document, action, nullptr, 0); + if (n > 1 && n <= kMaxStringBytes) { + QByteArray bytes(int(n), '\0'); FPDFAction_GetURIPath(document, action, bytes.data(), n); + QUrl url = QUrl::fromEncoded(bytes.left(int(n) - 1), QUrl::StrictMode); + if (url.isValid() && !url.host().isEmpty() && (url.scheme() == "https" || url.scheme() == "http")) + return {{key("kind"), key("external")}, {key("url"), url.toString(QUrl::FullyEncoded)}}; + } + } + return {{key("kind"), key("blocked")}, {key("reason"), key("E_LINK_BLOCKED")}}; + } + void indexOutline() { + if (outlineIndexed) return; + outlineIndexed = true; + QSet seen; + QList stack{{FPDFBookmark_GetFirstChild(document, nullptr), 0, -1}}; + bool depthLimited = false, repeated = false, nodeLimited = false; + while (!stack.empty()) { + auto current = stack.takeLast(); + if (!current.bookmark) continue; + if (current.depth > 64) { depthLimited = true; continue; } + if (seen.contains(quintptr(current.bookmark))) { repeated = true; continue; } + if (outlineItems.size() >= kMaxOutlineNodes) { nodeLimited = true; break; } + seen.insert(quintptr(current.bookmark)); + const int index = static_cast(outlineItems.size()); + outlineItems.append(current); + stack.append({FPDFBookmark_GetNextSibling(document, current.bookmark), current.depth, current.parent}); + stack.append({FPDFBookmark_GetFirstChild(document, current.bookmark), current.depth + 1, index}); + } + outlineTruncated = nodeLimited || depthLimited || repeated; + if (nodeLimited) outlineWarnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 目次は安全上限の先頭20000件まで表示します。")); + if (depthLimited) outlineWarnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 64階層を超える目次の枝を省略しました。")); + if (repeated) outlineWarnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 循環または重複参照のある目次の枝を省略しました。")); + } + QCborMap outlineBatch(int cursor, int limit, qsizetype byteLimit) { + indexOutline(); + if (cursor < 0 || cursor > outlineItems.size() || limit < 1 || limit > 256) + return failure(key("E_PROTOCOL_INVALID"), QCoreApplication::translate("PdfDocument", "目次の取得範囲が無効です。")); + QCborArray items, warnings = outlineWarnings; + qsizetype bytes = 4096; // Container, counters, warnings, and IPC-envelope headroom. + bool titleLimited = false; + for (int i = cursor; i < std::min(static_cast(outlineItems.size()), cursor + limit); ++i) { + const auto current = outlineItems[i]; + auto target = actionTarget(FPDFBookmark_GetDest(document, current.bookmark), FPDFBookmark_GetAction(current.bookmark)); + bool limited = false; + QString title = utf16([&](void *b, unsigned long n) { return FPDFBookmark_GetTitle(current.bookmark, b, n); }, &limited); + if (title.isEmpty()) title = limited ? QCoreApplication::translate("PdfDocument", "目次項目(名称が長さ制限を超えています)") : QCoreApplication::translate("PdfDocument", "目次項目(名称なし)"); + target.insert(key("title"), title); + target.insert(key("depth"), current.depth); + target.insert(key("source"), key("pdf-outline")); + target.insert(key("id"), QString("outline-%1").arg(i)); + if (current.parent >= 0) target.insert(key("parentId"), QString("outline-%1").arg(current.parent)); + const qsizetype size = QCborValue(target).toCbor().size(); + if (bytes + size > byteLimit) break; + bytes += size; + titleLimited = titleLimited || limited; + items.append(target); + } + if (titleLimited) warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 長すぎる目次名を代替表示しています。")); + const int next = cursor + static_cast(items.size()); + return {{key("outline"), items}, {key("outlineCount"), outlineItems.size()}, + {key("nextOutlineCursor"), next < outlineItems.size() ? next : -1}, + {key("truncated"), outlineTruncated}, {key("warnings"), warnings}}; + } +}; +PdfDocument::PdfDocument(SystemFontAdapter::Fetch fetch) : d(std::make_unique(std::move(fetch))) {} +PdfDocument::~PdfDocument() { close(); if (d->initialized) FPDF_DestroyLibrary(); } +void PdfDocument::close() { + d->linkBorders.reset(); + d->structure.reset(); + d->metadata.reset(); + if (d->form) FPDFDOC_ExitFormFillEnvironment(d->form); + d->form = nullptr; + if (d->document) FPDF_CloseDocument(d->document); + d->document = nullptr; d->file = nullptr; d->ownedFile.reset(); + d->outlineItems.clear(); d->outlineWarnings = {}; d->outlineIndexed = false; d->outlineTruncated = false; +} +bool PdfDocument::open(const QString &path, const QString &password, QString *code, QString *error) { + close(); auto file = std::make_unique(path); + if (!file->open(QIODevice::ReadOnly)) { setError(code, error, "E_SOURCE_UNREADABLE", QCoreApplication::translate("PdfDocument", "PDFを読み取れません。")); return false; } + if (!openFile(file.get(), password, code, error)) return false; + d->ownedFile = std::move(file); return true; +} +bool PdfDocument::openFile(QFile *file, const QString &password, QString *code, QString *error) { + if (code) code->clear(); if (error) error->clear(); + close(); + if (!file || !file->isOpen() || !file->isReadable() || file->size() <= 0 || quint64(file->size()) > std::numeric_limits::max()) { + setError(code, error, "E_SOURCE_UNREADABLE", QCoreApplication::translate("PdfDocument", "PDFの入力が無効です。")); return false; + } + d->init(); d->file = file; d->access.m_FileLen = static_cast(file->size()); d->access.m_Param = file; + d->access.m_GetBlock = [](void *parameter, unsigned long offset, unsigned char *buffer, unsigned long size) -> int { + auto *source = static_cast(parameter); + if (quint64(offset) + quint64(size) > quint64(source->size())) return 0; + return source->seek(qint64(offset)) && source->read(reinterpret_cast(buffer), qint64(size)) == qint64(size); + }; + QByteArray secret = password.toUtf8(); + d->document = FPDF_LoadCustomDocument(&d->access, secret.isEmpty() ? nullptr : secret.constData()); + if (d->fonts.failed()) { + secret.fill('\0'); + if (code) *code = d->fonts.errorCode(); if (error) *error = d->fonts.errorMessage(); close(); return false; + } + if (!d->document) { + secret.fill('\0'); + const auto reason = FPDF_GetLastError(); + if (reason == FPDF_ERR_PASSWORD) setError(code, error, password.isEmpty() ? "E_PASSWORD_REQUIRED" : "E_PASSWORD_INVALID", QCoreApplication::translate("PdfDocument", "PDFのパスワードを入力してください。")); + else setError(code, error, "E_PDF_CORRUPT", QCoreApplication::translate("PdfDocument", "PDFを解析できません。")); + d->file = nullptr; return false; + } + const int count = pageCount(); + if (count < 1 || count > 100000) { secret.fill('\0'); close(); setError(code, error, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfDocument", "PDFのページ数が制限を超えています。")); return false; } + d->metadata = std::make_shared(); + const bool bordersReady = d->metadata->open(file, secret, count, code, error); + secret.fill('\0'); + if (!bordersReady) { close(); return false; } + d->linkBorders = std::make_unique(d->metadata); + d->structure = std::make_unique(d->metadata); + d->formInfo = {}; d->formInfo.version = 1; + // Read-only callbacks: no timers, actions, JavaScript platform or input events. + d->formInfo.FFI_Invalidate = [](FPDF_FORMFILLINFO *, FPDF_PAGE, double, double, double, double) {}; + d->formInfo.FFI_OutputSelectedRect = [](FPDF_FORMFILLINFO *, FPDF_PAGE, double, double, double, double) {}; + d->formInfo.FFI_SetCursor = [](FPDF_FORMFILLINFO *, int) {}; + d->formInfo.FFI_SetTimer = [](FPDF_FORMFILLINFO *, int, TimerCallback) { return 0; }; + d->formInfo.FFI_KillTimer = [](FPDF_FORMFILLINFO *, int) {}; + d->formInfo.FFI_GetLocalTime = [](FPDF_FORMFILLINFO *) { return FPDF_SYSTEMTIME{}; }; + d->formInfo.FFI_OnChange = [](FPDF_FORMFILLINFO *) {}; + d->formInfo.FFI_GetPage = [](FPDF_FORMFILLINFO *, FPDF_DOCUMENT, int) -> FPDF_PAGE { return nullptr; }; + d->formInfo.FFI_GetCurrentPage = [](FPDF_FORMFILLINFO *, FPDF_DOCUMENT) -> FPDF_PAGE { return nullptr; }; + d->formInfo.FFI_GetRotation = [](FPDF_FORMFILLINFO *, FPDF_PAGE page) { return FPDFPage_GetRotation(page); }; + d->form = FPDFDOC_InitFormFillEnvironment(d->document, &d->formInfo); + return true; +} +int PdfDocument::pageCount() const { return d->document ? FPDF_GetPageCount(d->document) : 0; } +QCborMap PdfDocument::pages(int cursor, int limit) { + if (!d->document) return failure(key("E_NOT_OPEN"), QCoreApplication::translate("PdfDocument", "PDFが開かれていません。")); + if (cursor < 0 || cursor > pageCount() || limit < 1 || limit > 256) + return failure(key("E_PROTOCOL_INVALID"), QCoreApplication::translate("PdfDocument", "ページ情報の取得範囲が無効です。")); + QCborArray pages, warnings; + qsizetype bytes = 4096; + bool labelsLimited = false; + for (int i = cursor; i < std::min(pageCount(), cursor + limit); ++i) { + Impl::Page page(d->document, nullptr, i); + if (!page.value) return failure(key("E_PDF_CORRUPT"), QCoreApplication::translate("PdfDocument", "PDFのページ構造が破損しています。")); + FS_RECTF bounds{}; FPDF_GetPageBoundingBox(page.value, &bounds); + if (!std::isfinite(FPDF_GetPageWidthF(page.value)) || !std::isfinite(FPDF_GetPageHeightF(page.value)) || + !std::isfinite(bounds.left) || !std::isfinite(bounds.bottom) || !std::isfinite(bounds.right) || !std::isfinite(bounds.top)) + return failure(key("E_PDF_CORRUPT"), QCoreApplication::translate("PdfDocument", "ページの寸法が無効です。")); + bool limited = false; + const QCborMap item{{key("pageIndex"), i}, {key("width"), FPDF_GetPageWidthF(page.value)}, {key("height"), FPDF_GetPageHeightF(page.value)}, + {key("label"), pageLabel(d->document, i, &limited)}, {key("rotation"), FPDFPage_GetRotation(page.value) * 90}, + {key("cropBox"), rectArray(bounds.left, bounds.bottom, bounds.right, bounds.top)}}; + const qsizetype size = QCborValue(item).toCbor().size(); + if (bytes + size > kPageBatchBytes) break; + bytes += size; pages.append(item); labelsLimited = labelsLimited || limited; + } + if (labelsLimited) warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 長すぎるページラベルを省略しています。実ページ番号で移動できます。")); + return {{key("pageCount"), pageCount()}, {key("pages"), pages}, + {key("nextPageCursor"), cursor + pages.size() < pageCount() ? cursor + pages.size() : -1}, {key("warnings"), warnings}}; +} +QCborMap PdfDocument::outline(int cursor, int limit) { + if (!d->document) return failure(key("E_NOT_OPEN"), QCoreApplication::translate("PdfDocument", "PDFが開かれていません。")); + return d->outlineBatch(cursor, limit, kMetadataReplyBytes); +} +QCborMap PdfDocument::metadata(int cursor, int limit) { + auto result = pages(cursor, limit); + if (result.contains(key("error"))) return result; + const auto outline = d->outlineBatch(0, 64, 128 * 1024); + DocumentCapabilities declared; + declared.pageNavigation.state = CapabilityState::Supported; + declared.chapterNavigation = {CapabilityState::Unsupported, "E_CHAPTER_NAVIGATION_UNSUPPORTED"}; + declared.outline.state = d->outlineItems.empty() ? CapabilityState::Unavailable : CapabilityState::Supported; + declared.textSelection = {CapabilityState::Unsupported, "E_TEXT_SELECTION_UNSUPPORTED"}; + declared.reflow = {CapabilityState::Unsupported, "E_REFLOW_UNSUPPORTED"}; + declared.password.state = CapabilityState::Unavailable; // A successfully opened PDF needs no prompt. + QCborMap capabilities, capabilityReasons; + const std::pair fields[] = { + {"pageNavigation", &declared.pageNavigation}, {"chapterNavigation", &declared.chapterNavigation}, + {"outline", &declared.outline}, {"headings", &declared.headings}, {"textSearch", &declared.textSearch}, + {"textSelection", &declared.textSelection}, {"reflow", &declared.reflow}, {"password", &declared.password}}; + for (const auto &[name, capability] : fields) { + capabilities.insert(key(name), QString::fromUtf8(capabilityStateName(capability->state).data())); + if (!capability->reasonCode.empty()) capabilityReasons.insert(key(name), QString::fromStdString(capability->reasonCode)); + } + QCborArray warnings = result.value(key("warnings")).toArray(); + for (const auto &warning : outline.value(key("warnings")).toArray()) warnings.append(warning); + const int formType = FPDF_GetFormType(d->document); + if (formType == FORMTYPE_XFA_FULL || formType == FORMTYPE_XFA_FOREGROUND) warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_FEATURE_UNSUPPORTED: 動的XFAの表示には対応していません。")); + bool titleLimited = false, authorLimited = false; + result.insert(key("title"), utf16([&](void *b, unsigned long n) { return FPDF_GetMetaText(d->document, "Title", b, n); }, &titleLimited)); + result.insert(key("author"), utf16([&](void *b, unsigned long n) { return FPDF_GetMetaText(d->document, "Author", b, n); }, &authorLimited)); + if (titleLimited || authorLimited) warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 長すぎる文書名または著者名を省略しています。")); + result.insert(key("outline"), outline.value(key("outline"))); + result.insert(key("outlineCount"), outline.value(key("outlineCount"))); + result.insert(key("nextOutlineCursor"), outline.value(key("nextOutlineCursor"))); + result.insert(key("outlineTruncated"), d->outlineTruncated); + result.insert(key("headings"), QCborArray{}); + result.insert(key("isTagged"), bool(FPDFCatalog_IsTagged(d->document))); + result.insert(key("capabilities"), capabilities); + result.insert(key("capabilityReasons"), capabilityReasons); + result.insert(key("warnings"), warnings); + if (QCborValue(result).toCbor().size() > kMetadataReplyBytes) + return failure(key("E_LIMIT_EXCEEDED"), QCoreApplication::translate("PdfDocument", "文書情報が安全な応答サイズを超えました。")); + return result; +} +QCborMap PdfDocument::render(const QCborMap &r, QString *code, QString *error) { + if (code) code->clear(); if (error) error->clear(); + const qint64 rawIndex = r.value(key("page")).toInteger(-1); + const int index = rawIndex >= 0 && rawIndex < pageCount() ? int(rawIndex) : -1; + const double scale = r.value(key("scale")).toDouble(1); + const double devicePixelRatio = r.value(key("devicePixelRatio")).toDouble(1); + const qint64 rotation = r.value(key("rotation")).toInteger(); + const qint64 x = r.value(key("x")).toInteger(), y = r.value(key("y")).toInteger(); + const qint64 width = r.value(key("width")).toInteger(), height = r.value(key("height")).toInteger(); + if (index < 0 || index >= pageCount() || !std::isfinite(scale) || scale < .05 || scale > 32 || + !std::isfinite(devicePixelRatio) || devicePixelRatio < .5 || devicePixelRatio > 8 || rotation < 0 || rotation > 270 || rotation % 90 || + x < 0 || y < 0 || x > 1000000 || y > 1000000 || width < 1 || height < 1 || width > 514 || height > 514) { + setError(code, error, "E_PROTOCOL_INVALID", QCoreApplication::translate("PdfDocument", "PDF描画要求が範囲外です。")); return {}; + } + Impl::Page page(d->document, d->form, index); + if (!page.value) { setError(code, error, "E_PDF_CORRUPT", QCoreApplication::translate("PdfDocument", "ページを解析できません。")); return {}; } + // PDFium's native NoRotate compensation considers intrinsic page rotation, + // not the separate render argument. Combine both for this render only. + struct RotationScope { + FPDF_PAGE page; int original; + ~RotationScope() { FPDFPage_SetRotation(page, original); } + } pageRotation{page.value, FPDFPage_GetRotation(page.value)}; + FPDFPage_SetRotation(page.value, (pageRotation.original + int(rotation / 90)) % 4); + auto linkAppearance = d->linkBorders->prepare(index, page.value, code, error, scale, devicePixelRatio); + if (!linkAppearance) return {}; + double pw = FPDF_GetPageWidthF(page.value), ph = FPDF_GetPageHeightF(page.value); + if (!std::isfinite(pw) || !std::isfinite(ph) || pw <= 0 || ph <= 0 || pw * scale > 1000000 || ph * scale > 1000000) { + setError(code, error, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfDocument", "ページ寸法が制限を超えています。")); return {}; + } + const int pixelWidth = std::max(1, int(std::ceil(pw * scale))), pixelHeight = std::max(1, int(std::ceil(ph * scale))); + constexpr int border = 16; + const int paddedWidth = int(width) + border * 2, paddedHeight = int(height) + border * 2; + QByteArray padded(paddedWidth * paddedHeight * 4, '\0'); + auto bitmap = FPDFBitmap_CreateEx(paddedWidth, paddedHeight, FPDFBitmap_BGRA, padded.data(), paddedWidth * 4); + if (!bitmap) { setError(code, error, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfDocument", "描画バッファを確保できません。")); return {}; } + FPDFBitmap_FillRect(bitmap, 0, 0, paddedWidth, paddedHeight, 0xffffffff); + FPDF_RenderPageBitmap(bitmap, page.value, border - int(x), border - int(y), pixelWidth, pixelHeight, 0, FPDF_ANNOT); + if (d->form) FPDF_FFLDraw(d->form, bitmap, page.value, border - int(x), border - int(y), pixelWidth, pixelHeight, 0, FPDF_ANNOT); + FPDFBitmap_Destroy(bitmap); + if (d->fonts.failed()) { + if (code) *code = d->fonts.errorCode(); if (error) *error = d->fonts.errorMessage(); return {}; + } + QByteArray data(int(width * height * 4), '\0'); + for (int row = 0; row < height; ++row) memcpy(data.data() + row * width * 4, padded.constData() + ((row + border) * paddedWidth + border) * 4, size_t(width * 4)); + return {{key("width"), width}, {key("height"), height}, {key("stride"), width * 4}, {key("data"), data}, + {key("pixelFormat"), key("BGRA8888")}, {key("page"), index}, {key("renderRevision"), r.value(key("renderRevision"))}, + {key("x"), x}, {key("y"), y}, {key("pagePixelWidth"), pixelWidth}, {key("pagePixelHeight"), pixelHeight}}; +} +QCborMap PdfDocument::headings(int index) { + if (index < 0 || index >= pageCount()) return failure(key("E_PROTOCOL_INVALID"), QCoreApplication::translate("PdfDocument", "ページ番号が無効です。")); + Impl::Page page(d->document, nullptr, index); + if (!page.value) return failure(key("E_PDF_CORRUPT"), QCoreApplication::translate("PdfDocument", "ページを解析できません。")); + return d->structure->headings(index, page.value); +} + +QCborMap PdfDocument::links(int index) { + if (index < 0 || index >= pageCount()) return failure(key("E_PROTOCOL_INVALID"), QCoreApplication::translate("PdfDocument", "ページ番号が無効です。")); + Impl::Page page(d->document, nullptr, index); + if (!page.value) return failure(key("E_PDF_CORRUPT"), QCoreApplication::translate("PdfDocument", "ページを解析できません。")); + QString iconCode, iconMessage; + if (!d->linkBorders->prepareNativeIcons(index, page.value, &iconCode, &iconMessage)) return failure(iconCode, iconMessage); + QCborArray items, annotations, warnings; FPDF_LINK link{}; int cursor = 0; + qsizetype responseBytes = 4096; // Warnings, containers and the IPC envelope. + bool responseLimited = false, contentsLimited = false; + const auto appendBounded = [&](QCborArray &array, const QCborMap &item) { + const qsizetype size = QCborValue(item).toCbor().size(); + if (responseBytes + size > kMetadataReplyBytes) { responseLimited = true; return false; } + responseBytes += size; array.append(item); return true; + }; + while (items.size() < kMaxNodes && FPDFLink_Enumerate(page.value, &cursor, &link)) { + FS_RECTF rect{}; if (!FPDFLink_GetAnnotRect(link, &rect)) continue; + auto target = d->actionTarget(FPDFLink_GetDest(d->document, link), FPDFLink_GetAction(link)); + auto annotation = FPDFLink_GetAnnot(page.value, link); + if (annotation) { + const int flags = FPDFAnnot_GetFlags(annotation); + FPDFPage_CloseAnnot(annotation); + if (flags & (FPDF_ANNOT_FLAG_HIDDEN | FPDF_ANNOT_FLAG_NOVIEW)) continue; + target.insert(key("flags"), flags & (FPDF_ANNOT_FLAG_NOZOOM | FPDF_ANNOT_FLAG_NOROTATE)); + } + target.insert(key("rect"), rectArray(rect.left, rect.bottom, rect.right, rect.top)); + if (!appendBounded(items, target)) break; + } + if (items.size() == kMaxNodes && FPDFLink_Enumerate(page.value, &cursor, &link)) + warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: ページ内リンクは安全上限の先頭1000件まで表示します。残りのリンク情報を省略しました。")); + const int totalAnnotations = FPDFPage_GetAnnotCount(page.value); + const int count = std::clamp(totalAnnotations, 0, kMaxNodes); + if (totalAnnotations > kMaxNodes) + warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: ページ内の注釈情報は安全上限の先頭1000件まで確認します。残りのコメントやフォームの情報を省略しました。")); + for (int i = 0; i < count; ++i) { + auto annot = FPDFPage_GetAnnot(page.value, i); if (!annot) continue; + int flags = FPDFAnnot_GetFlags(annot); + if (flags & (FPDF_ANNOT_FLAG_HIDDEN | FPDF_ANNOT_FLAG_NOVIEW)) { FPDFPage_CloseAnnot(annot); continue; } + if ((flags & FPDF_ANNOT_FLAG_NOZOOM) && !d->linkBorders->supportsNoZoom(index, i)) { + flags &= ~FPDF_ANNOT_FLAG_NOZOOM; + const auto warning = QCoreApplication::translate("PdfDocument", "保存された外観がない一部の注釈では、倍率固定の表示を再現できません。"); + if (!warnings.contains(warning)) warnings.append(warning); + } + auto type = FPDFAnnot_GetSubtype(annot); + bool limited = false; + auto contents = utf16([&](void *b, unsigned long n) { return FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast(b), n); }, &limited); + if (limited) { + contentsLimited = true; + contents = QCoreApplication::translate("PdfDocument", "注釈本文は長さ制限を超えているため表示できません。"); + } + if (!contents.isEmpty()) { FS_RECTF rect{}; FPDFAnnot_GetRect(annot, &rect); d->linkBorders->nativeIconRect(index, i, &rect); + const bool appended = appendBounded(annotations, QCborMap{{key("text"), contents}, {key("subtype"), type}, {key("flags"), flags & (FPDF_ANNOT_FLAG_NOZOOM | FPDF_ANNOT_FLAG_NOROTATE)}, {key("rect"), rectArray(rect.left, rect.bottom, rect.right, rect.top)}}); + if (!appended) { FPDFPage_CloseAnnot(annot); break; } + } + const bool missingAppearance = FPDFAnnot_GetAP(annot, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2; + if (type == FPDF_ANNOT_WIDGET && missingAppearance) { + const auto warning = QCoreApplication::translate("PdfDocument", "保存されたフォーム外観がないため、一部の項目を正しく表示できない可能性があります。"); + if (!warnings.contains(warning)) warnings.append(warning); + } + FPDFPage_CloseAnnot(annot); + } + if (contentsLimited) + warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: 長さ制限を超えた注釈本文を省略しています。注釈の存在は一覧に表示します。")); + if (responseLimited) + warnings.append(QCoreApplication::translate("PdfDocument", "E_PDF_METADATA_LIMIT: ページ内のリンク・注釈情報が応答サイズの安全上限を超えたため、一部を省略しています。")); + return {{key("page"), index}, {key("links"), items}, {key("annotations"), annotations}, {key("warnings"), warnings}}; +} +QCborMap PdfDocument::search(const QString &query, int index, int start, int limit) { + if (index < 0 || index >= pageCount() || query.isEmpty() || query.size() > 1024 || start < 0 || start > kMaxCharacters) + return failure(key("E_PROTOCOL_INVALID"), QCoreApplication::translate("PdfDocument", "検索条件が無効です。")); + Impl::Page page(d->document, nullptr, index); + if (!page.value) return failure(key("E_PDF_CORRUPT"), QCoreApplication::translate("PdfDocument", "ページを解析できません。")); + FPDF_TEXTPAGE textPage = FPDFText_LoadPage(page.value); + if (!textPage) return {{key("matches"), QCborArray{}}, {key("hasText"), false}, {key("page"), index}, {key("nextCursor"), -1}}; + QByteArray queryBytes((query.size() + 1) * 2, '\0'); + for (qsizetype i = 0; i < query.size(); ++i) qToLittleEndian(query[i].unicode(), queryBytes.data() + i * 2); + FPDF_SCHHANDLE search = FPDFText_FindStart(textPage, reinterpret_cast(queryBytes.constData()), 0, start); + QCborArray matches; int nextCursor = -1; + if (search) { + while (matches.size() < std::clamp(limit, 1, 100) && FPDFText_FindNext(search)) { + int first = FPDFText_GetSchResultIndex(search), length = FPDFText_GetSchCount(search); + if (first < 0 || length < 1 || first > kMaxCharacters || length > kMaxCharacters - first) break; + QCborArray rects; + int count = std::clamp(FPDFText_CountRects(textPage, first, length), 0, 100); + for (int i = 0; i < count; ++i) { double left, top, right, bottom; if (FPDFText_GetRect(textPage, i, &left, &top, &right, &bottom)) rects.append(rectArray(left, bottom, right, top)); } + matches.append(QCborMap{{key("page"), index}, {key("start"), first}, {key("length"), length}, {key("rects"), rects}}); nextCursor = first + length; + } + if (matches.size() < std::clamp(limit, 1, 100)) nextCursor = -1; + FPDFText_FindClose(search); + } + bool hasText = FPDFText_CountChars(textPage) > 0; FPDFText_ClosePage(textPage); + return {{key("matches"), matches}, {key("hasText"), hasText}, {key("page"), index}, {key("nextCursor"), nextCursor}}; +} +QCborMap PdfDocument::mapPoint(const QCborMap &r, QString *code, QString *error) { + if (code) code->clear(); if (error) error->clear(); + const qint64 rawIndex = r.value(key("page")).toInteger(-1); + const int index = rawIndex >= 0 && rawIndex < pageCount() ? int(rawIndex) : -1; + const double scale = r.value(key("scale")).toDouble(1); + const qint64 rawRotation = r.value(key("rotation")).toInteger(); + int rotation = rawRotation >= 0 && rawRotation <= 270 ? int(rawRotation) : -1; + double x = r.value(key("x")).toDouble(), y = r.value(key("y")).toDouble(); + if (index < 0 || index >= pageCount() || !std::isfinite(scale) || scale < .05 || scale > 32 || !std::isfinite(x) || !std::isfinite(y) || std::abs(x) > 1000000 || std::abs(y) > 1000000 || rotation < 0 || rotation > 270 || rotation % 90) { + setError(code, error, "E_PROTOCOL_INVALID", QCoreApplication::translate("PdfDocument", "座標変換要求が無効です。")); return {}; + } + Impl::Page page(d->document, nullptr, index); + if (!page.value) { setError(code, error, "E_PDF_CORRUPT", QCoreApplication::translate("PdfDocument", "ページを解析できません。")); return {}; } + double pw = FPDF_GetPageWidthF(page.value), ph = FPDF_GetPageHeightF(page.value); if (rotation % 180) std::swap(pw, ph); + if (!std::isfinite(pw) || !std::isfinite(ph) || pw * scale > 1000000 || ph * scale > 1000000 || pw <= 0 || ph <= 0) { setError(code, error, "E_LIMIT_EXCEEDED", QCoreApplication::translate("PdfDocument", "ページ寸法が無効です。")); return {}; } + int width = std::max(1, int(std::ceil(pw * scale))), height = std::max(1, int(std::ceil(ph * scale))); + if (r.value(key("direction")).toString() == "deviceToPage") FPDF_DeviceToPage(page.value, 0, 0, width, height, rotation / 90, int(x), int(y), &x, &y); + else { int dx = 0, dy = 0; FPDF_PageToDevice(page.value, 0, 0, width, height, rotation / 90, x, y, &dx, &dy); x = dx; y = dy; } + return {{key("page"), index}, {key("x"), x}, {key("y"), y}}; +} +QCborMap PdfDocument::fontError() const { + return d->fonts.failed() ? QCborMap{{key("code"), d->fonts.errorCode()}, {key("message"), d->fonts.errorMessage()}} : QCborMap{}; +} +QStringList PdfDocument::fontWarnings() const { return d->fonts.warnings(); } +} diff --git a/src/pdf/pdf_document.h b/src/pdf/pdf_document.h new file mode 100644 index 0000000..9510e63 --- /dev/null +++ b/src/pdf/pdf_document.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include "system_font_adapter.h" + +namespace docview { +// All calls, including destruction, must occur on the one PDF worker thread. +// This adapter is never linked into the GUI process. +class PdfDocument { +public: + explicit PdfDocument(SystemFontAdapter::Fetch fontFetch = {}); + ~PdfDocument(); + PdfDocument(const PdfDocument &) = delete; + PdfDocument &operator=(const PdfDocument &) = delete; + bool open(const QString &path, const QString &password, QString *code, QString *error); + bool openFile(QFile *file, const QString &password, QString *code, QString *error); + void close(); + int pageCount() const; + QCborMap metadata(int cursor = 0, int limit = 256); + QCborMap pages(int cursor = 0, int limit = 256); + QCborMap outline(int cursor = 0, int limit = 128); + QCborMap render(const QCborMap &request, QString *code, QString *error); + QCborMap headings(int page); + QCborMap links(int page); + QCborMap search(const QString &query, int page, int start = 0, int limit = 100); + QCborMap mapPoint(const QCborMap &request, QString *code, QString *error); + QCborMap fontError() const; + QStringList fontWarnings() const; +private: + struct Impl; + std::unique_ptr d; +}; +} diff --git a/src/pdf/pdf_metadata_context.cpp b/src/pdf/pdf_metadata_context.cpp new file mode 100644 index 0000000..c09bfc1 --- /dev/null +++ b/src/pdf/pdf_metadata_context.cpp @@ -0,0 +1,240 @@ +#include "pdf_metadata_context.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +constexpr qint64 maxOperationRead = 32 * 1024 * 1024, maxTotalRead = 256 * 1024 * 1024; +// Logical position belongs to this reader. PDFium seeks the same inherited QFile +// independently; neither parser relies on the other parser's file position. +class QPDF_DLL_CLASS Source final : public InputSource { + public: + explicit Source(QFile *file) : file(file), size(file->size()) { + begin(); + } + void begin() { + operationRead = 0; + budgetExceeded = false; + timer.restart(); + } + void check() const { + if (budgetExceeded || warningBudgetExceeded || timer.elapsed() > 10000) + throw PdfMetadataLimit{}; + } + void stopForWarnings() { + warningBudgetExceeded = true; + throw PdfMetadataLimit{}; + } + std::string const &getName() const override { + static const std::string name("DocView PDF input"); + return name; + } + qpdf_offset_t tell() override { + return offset; + } + void rewind() override { + offset = 0; + } + void seek(qpdf_offset_t delta, int whence) override { + check(); + const qint64 base = whence == SEEK_SET ? 0 + : whence == SEEK_CUR ? offset + : whence == SEEK_END ? size + : -1; + if (base < 0 || delta < -base || delta > size - base) + throw PdfMetadataInvalid{}; + offset = base + delta; + } + size_t read(char *buffer, size_t length) override { + check(); + last_offset = offset; + const qint64 count = qint64(std::min(length, quint64(size - offset))); + if (count > maxOperationRead - operationRead || count > maxTotalRead - totalRead) { + budgetExceeded = true; + throw PdfMetadataLimit{}; + } + if (!file->seek(offset)) + throw PdfMetadataInvalid{}; + const qint64 read = file->read(buffer, count); + if (read < 0) + throw PdfMetadataInvalid{}; + offset += read; + operationRead += read; + totalRead += read; + return size_t(read); + } + void unreadCh(char) override { + if (offset <= 0) + throw PdfMetadataInvalid{}; + --offset; + } + qpdf_offset_t findAndSkipNextEOL() override { + qint64 eol = -1; + char buffer[4096]; + while (offset < size) { + const auto start = offset; + const auto count = read(buffer, sizeof(buffer)); + for (size_t i = 0; i < count; ++i) { + if (buffer[i] == '\r' || buffer[i] == '\n') { + if (eol < 0) + eol = start + qint64(i); + } else if (eol >= 0) { + offset = start + qint64(i); + return eol; + } + } + } + return eol < 0 ? size : eol; + } + + private: + QFile *file; + qint64 size, offset = 0, operationRead = 0, totalRead = 0; + bool budgetExceeded = false; + bool warningBudgetExceeded = false; + QElapsedTimer timer; +}; +// QPDF stores a warning before writing it here. Stop at a bounded prefix and +// retain no diagnostic text (which may contain PDF content). A sticky source +// flag also stops parsing if qpdf catches a pipeline exception internally. +class QPDF_DLL_CLASS WarningSink final : public Pipeline { + public: + explicit WarningSink(std::shared_ptr source) + : Pipeline("DocView bounded PDF diagnostics", nullptr), source(std::move(source)) {} + void write(const unsigned char *data, size_t length) override { + source->check(); + if (length > 64 * 1024 - bytes) + source->stopForWarnings(); + bytes += length; + for (size_t i = 0; i < length; ++i) + if (data[i] == '\n' && ++lines > 32) + source->stopForWarnings(); + } + void finish() override {} + + private: + std::shared_ptr source; + size_t bytes = 0, lines = 0; +}; +} // namespace +struct PdfMetadataContext::Impl { + std::shared_ptr source; + std::unique_ptr pdf; + struct Node { + QPDFObjectHandle value; + int depth; + }; + std::vector pending; + std::vector pages; + std::set seen; + int pageCount = 0, visited = 0; + int pageFailure = 0; + QPDFObjectHandle page(int index) { + while (int(pages.size()) <= index) { + source->check(); + if (pending.empty()) + throw PdfMetadataInvalid{}; + auto node = pending.back(); + pending.pop_back(); + if (++visited > 200000 || node.depth > 64) + throw PdfMetadataLimit{}; + if (!node.value.isDictionary() || !node.value.isIndirect() || + !seen.insert(node.value.getObjGen()).second) + throw PdfMetadataInvalid{}; + if (node.value.getKey("/Type").isNameAndEquals("/Page")) { + pages.push_back(node.value); + continue; + } + if (!node.value.getKey("/Type").isNameAndEquals("/Pages")) + throw PdfMetadataInvalid{}; + auto kids = node.value.getKey("/Kids"); + if (!kids.isArray()) + throw PdfMetadataInvalid{}; + const int n = kids.getArrayNItems(); + if (n > 100000 || pending.size() + size_t(n) > 100000) + throw PdfMetadataLimit{}; + for (int i = n - 1; i >= 0; --i) + pending.push_back({kids.getArrayItem(i), node.depth + 1}); + } + return pages.at(size_t(index)); + } +}; +PdfMetadataContext::PdfMetadataContext() : d(std::make_unique()) {} +PdfMetadataContext::~PdfMetadataContext() = default; +bool PdfMetadataContext::open(QFile *file, const QByteArray &password, int pageCount, QString *code, + QString *message) { + d = std::make_unique(); + try { + d->source = std::make_shared(file); + d->pdf = std::make_unique(); + d->pageCount = pageCount; + const auto logger = QPDFLogger::create(); + logger->setInfo(logger->discard()); + logger->setWarn(std::make_shared(d->source)); + logger->setError(logger->discard()); + d->pdf->setLogger(logger); + d->pdf->setSuppressWarnings(false); + d->pdf->setAttemptRecovery(false); + d->pdf->processInputSource(d->source, password.isEmpty() ? nullptr : password.constData()); + // Do not use setMaxWarnings: in qpdf 12.4.1 a nonzero value also + // eagerly repairs the page tree during processInputSource, removing + // null Kids and shifting indices. WarningSink applies the bound + // without requesting that traversal, including during initial parse. + d->pending.push_back({d->pdf->getRoot().getKey("/Pages"), 0}); + d->source->check(); + return true; + } catch (const PdfMetadataLimit &) { + if (code) + *code = QStringLiteral("E_LIMIT_EXCEEDED"); + if (message) + *message = QCoreApplication::translate("PdfMetadata", "PDFメタデータの解析が安全上限を超えました。"); + } catch (...) { + if (code) + *code = QStringLiteral("E_PDF_CORRUPT"); + if (message) + *message = QCoreApplication::translate("PdfMetadata", "PDFメタデータの構造を解析できません。"); + } + return false; +} +void PdfMetadataContext::beginOperation() { + if (!d->source || !d->pdf) + throw PdfMetadataInvalid{}; + d->source->begin(); +} +void PdfMetadataContext::check() const { + d->source->check(); +} +int PdfMetadataContext::pageCount() const { + return d->pageCount; +} +QPDFObjectHandle PdfMetadataContext::root() { + check(); + return d->pdf->getRoot(); +} +QPDFObjectHandle PdfMetadataContext::page(int index) { + if (index < 0 || index >= d->pageCount) + throw PdfMetadataInvalid{}; + // A failed walk must never resume after the failed node was popped: that + // would silently shift subsequent pages to the wrong indices. + if (d->pageFailure == 2) + throw PdfMetadataLimit{}; + if (d->pageFailure) + throw PdfMetadataInvalid{}; + try { + return d->page(index); + } catch (const PdfMetadataLimit &) { + d->pageFailure = 2; + throw; + } catch (...) { + d->pageFailure = 1; + throw; + } +} +} // namespace docview diff --git a/src/pdf/pdf_metadata_context.h b/src/pdf/pdf_metadata_context.h new file mode 100644 index 0000000..ba7dcbb --- /dev/null +++ b/src/pdf/pdf_metadata_context.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include +#include + +class QPDFObjectHandle; +namespace docview { +struct PdfMetadataInvalid : std::exception {}; +struct PdfMetadataLimit : std::exception {}; +// One QPDF instance and cumulative read budget per worker document. Both link +// appearance and logical-structure adapters borrow it; the Main never sees it. +class PdfMetadataContext { + public: + PdfMetadataContext(); + ~PdfMetadataContext(); + bool open(QFile *, const QByteArray &password, int pageCount, QString *code, QString *message); + void beginOperation(); + void check() const; + int pageCount() const; + QPDFObjectHandle root(); + QPDFObjectHandle page(int index); + + private: + struct Impl; + std::unique_ptr d; +}; +} // namespace docview diff --git a/src/pdf/structure_reader.cpp b/src/pdf/structure_reader.cpp new file mode 100644 index 0000000..81b193d --- /dev/null +++ b/src/pdf/structure_reader.cpp @@ -0,0 +1,822 @@ +#include "structure_reader.h" +#include +#include "pdf_metadata_context.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +using Obj = QPDFObjectHandle; +using Id = QPDFObjGen; +struct StructureError { + QString reason; +}; +QString key(const char *s) { + return QString::fromLatin1(s); +} +Id identity(Obj o) { + if (!o.isIndirect()) + throw StructureError{QCoreApplication::translate("PdfStructure", "構造の所有元が間接参照ではありません。")}; + return o.getObjGen(); +} +struct Matrix { + double a = 1, b = 0, c = 0, d = 1, e = 0, f = 0; + Matrix operator*(const Matrix &v) const { + return {a * v.a + c * v.b, b * v.a + d * v.b, a * v.c + c * v.d, + b * v.c + d * v.d, a * v.e + c * v.f + e, b * v.e + d * v.f + f}; + } + bool close(const Matrix &v) const { + const double x[]{a, b, c, d, e, f}, y[]{v.a, v.b, v.c, v.d, v.e, v.f}; + for (int i = 0; i < 6; ++i) + if (!std::isfinite(x[i]) || !std::isfinite(y[i]) || + std::abs(x[i] - y[i]) > 0.001 * std::max({1., std::abs(x[i]), std::abs(y[i])})) + return false; + return true; + } +}; +double number(Obj o) { + if (!o.isNumber()) + throw PdfMetadataInvalid{}; + const double v = o.getNumericValue(); + if (!std::isfinite(v) || std::abs(v) > 1000000) + throw PdfMetadataInvalid{}; + return v; +} +int integer(Obj o) { + if (!o.isInteger() || o.getIntValue() < 0 || o.getIntValue() > 1000000) + throw PdfMetadataInvalid{}; + return o.getIntValueAsInt(); +} +Matrix matrix(Obj o) { + if (o.isNull()) + return {}; + if (!o.isArray() || o.getArrayNItems() != 6) + throw PdfMetadataInvalid{}; + return {number(o.getArrayItem(0)), number(o.getArrayItem(1)), number(o.getArrayItem(2)), + number(o.getArrayItem(3)), number(o.getArrayItem(4)), number(o.getArrayItem(5))}; +} +QString text(Obj o) { + if (o.isNull()) + return {}; + if (!o.isString()) + throw PdfMetadataInvalid{}; + const auto s = o.getUTF8Value(); + if (s.size() > 16384) + throw PdfMetadataLimit{}; + return QString::fromUtf8(s).left(4096); +} +struct Invocation { + Obj object, resources; + Id owner; + int parent = -1; + Matrix local; + QSet mcids; + std::vector children; + bool reliable = true; +}; +struct Mark { + QString text; + QRectF bounds; + bool hasBounds = false; +}; +struct Physical { + Matrix local; + QSet mcids; + std::vector children; + std::map marks; +}; +// Decompression is stopped while bytes are produced, before creating a large +// buffer. Images are never piped; only page and actually invoked Form content. +class QPDF_DLL_CLASS BoundedContent final : public Pipeline { + public: + BoundedContent(PdfMetadataContext &c, qsizetype &total) + : Pipeline("DocView bounded content", nullptr), context(c), total(total) {} + void write(const unsigned char *p, size_t n) override { + try { + context.check(); + } catch (const PdfMetadataLimit &) { + exceeded = true; + throw; + } + if (n > size_t(16 * 1024 * 1024 - bytes.size()) || n > size_t(32 * 1024 * 1024 - total)) { + exceeded = true; + throw PdfMetadataLimit{}; + } + bytes.append(reinterpret_cast(p), qsizetype(n)); + total += qsizetype(n); + } + void finish() override { + context.check(); + } + QByteArray bytes; + bool exceeded = false; + + private: + PdfMetadataContext &context; + qsizetype &total; +}; +struct Scan { + PdfMetadataContext &context; + Obj page, root, parentTree, roleMap; + Id pageId; + int pageIndex; + int structureNodes = 0, tokens = 0, objectCount = 0; + qsizetype decodedBytes = 0; + std::map streamCache; + std::vector calls; + std::vector physical; + std::map> ownerPhysical; + std::map parentEntries; + QHash textOwner; + bool formMatchLimited = false; + bool hasText = false, responseLimited = false; + explicit Scan(PdfMetadataContext &c, int index) + : context(c), page(c.page(index)), root(c.root().getKey("/StructTreeRoot")), pageId(identity(page)), + pageIndex(index) {} + void step() { + context.check(); + if (++structureNodes > 10000) + throw PdfMetadataLimit{}; + } + QByteArray decode(Obj stream) { + if (!stream.isStream()) + throw PdfMetadataInvalid{}; + const auto id = identity(stream); + if (auto found = streamCache.find(id); found != streamCache.end()) + return found->second; + BoundedContent sink(context, decodedBytes); + bool filtering = false; + const bool success = stream.pipeStreamData(&sink, &filtering, 0, qpdf_dl_specialized, true, false); + context.check(); + // qpdf may convert a downstream pipeline exception to false. Retain the + // quota classification rather than reporting a corrupt compression filter. + if (sink.exceeded) + throw PdfMetadataLimit{}; + if (!success) + throw StructureError{QCoreApplication::translate("PdfStructure", "Formまたはページ内容の圧縮形式を解析できません。")}; + streamCache.emplace(id, sink.bytes); + return sink.bytes; + } + Obj inheritedResources(Obj object) { + std::set seen; + for (int depth = 0; depth <= 64; ++depth) { + step(); + if (!object.isDictionary() || !seen.insert(identity(object)).second) + throw PdfMetadataInvalid{}; + auto resources = object.getKey("/Resources"); + if (!resources.isNull()) { + if (!resources.isDictionary()) + throw PdfMetadataInvalid{}; + return resources; + } + object = object.getKey("/Parent"); + if (object.isNull()) + return Obj::newDictionary(); + } + throw PdfMetadataLimit{}; + } + QByteArray contents(Obj value) { + if (value.isNull()) + return {}; + if (value.isStream()) + return decode(value); + if (!value.isArray() || value.getArrayNItems() > 1024) + throw PdfMetadataLimit{}; + QByteArray result; + for (int i = 0; i < value.getArrayNItems(); ++i) { + const auto part = decode(value.getArrayItem(i)); + if (result.size() + part.size() + 1 > 32 * 1024 * 1024) + throw PdfMetadataLimit{}; + result += part; + result += '\n'; + } + return result; + } + Obj operand(QPDFTokenizer &tokenizer, const std::shared_ptr &input, + QPDFTokenizer::Token token) { + const auto type = token.getType(); + std::string raw = token.getRawValue(); + if (type == QPDFTokenizer::tt_array_open || type == QPDFTokenizer::tt_dict_open) { + int depth = 1; + while (depth) { + if (++tokens > 1000000) + throw PdfMetadataLimit{}; + context.check(); + token = tokenizer.readToken(input, "DocView content", false, 65536); + if (token.getType() == QPDFTokenizer::tt_eof) + throw PdfMetadataInvalid{}; + if (token.getType() == QPDFTokenizer::tt_array_open || + token.getType() == QPDFTokenizer::tt_dict_open) + ++depth; + if (token.getType() == QPDFTokenizer::tt_array_close || + token.getType() == QPDFTokenizer::tt_dict_close) + --depth; + if (depth > 64 || raw.size() + token.getRawValue().size() + 1 > 1024 * 1024) + throw PdfMetadataLimit{}; + raw += ' '; + raw += token.getRawValue(); + } + } + return Obj::parse(raw, "DocView content operand"); + } + void parseCall(int index, int depth, std::set ancestors) { + if (depth > 64 || calls.size() > 10000) + throw PdfMetadataLimit{}; + const auto owner = calls[index].owner; + if (!ancestors.insert(owner).second) + throw StructureError{QCoreApplication::translate("PdfStructure", "Form XObjectの呼出が循環しています。")}; + auto object = calls[index].object; + const QByteArray bytes = index == 0 ? contents(object.getKey("/Contents")) : decode(object); + auto input = std::make_shared( + "DocView decoded content", std::string(bytes.constData(), size_t(bytes.size()))); + QPDFTokenizer tokenizer; + tokenizer.allowEOF(); + // PDFium exposes the caller CTM on a Form object. That Form's /Matrix + // is incorporated in its children (including nested Form transforms), + // not in the Form object's own GetMatrix result. + Matrix ctm = index == 0 ? Matrix{} : matrix(object.getDict().getKey("/Matrix")); + std::vector graphics; + std::vector operands; + int markedDepth = 0; + bool inlineDictionary = false; + static const QSet harmless = { + "w", "J", "j", "M", "d", "ri", "i", "gs", "m", "l", "c", "v", "y", "h", "re", "S", + "s", "f", "F", "f*", "B", "B*", "b", "b*", "n", "W", "W*", "BT", "ET", "Tc", "Tw", "Tz", + "TL", "Tf", "Tr", "Ts", "Td", "TD", "Tm", "T*", "Tj", "TJ", "'", "\"", "d0", "d1", "CS", "cs", + "SC", "SCN", "sc", "scn", "G", "g", "RG", "rg", "K", "k", "sh", "MP", "DP", "BX", "EX"}; + while (true) { + context.check(); + if (++tokens > 1000000) + throw PdfMetadataLimit{}; + auto token = tokenizer.readToken(input, "DocView content", false, 65536); + if (token.getType() == QPDFTokenizer::tt_eof) + break; + if (token.getType() != QPDFTokenizer::tt_word) { + if (operands.size() >= 256) + throw PdfMetadataLimit{}; + operands.push_back(operand(tokenizer, input, token)); + continue; + } + const auto op = token.getValue(); + if (op == "q") { + if (!operands.empty() || graphics.size() >= 64) + throw PdfMetadataInvalid{}; + graphics.push_back(ctm); + } else if (op == "Q") { + if (!operands.empty() || graphics.empty()) + throw PdfMetadataInvalid{}; + ctm = graphics.back(); + graphics.pop_back(); + } else if (op == "cm") { + if (operands.size() != 6) + throw PdfMetadataInvalid{}; + ctm = ctm * matrix(Obj::newArray(operands)); + } else if (op == "BDC" || op == "BMC") { + if (++markedDepth > 64 || operands.size() != (op == "BDC" ? 2u : 1u) || !operands[0].isName()) + throw PdfMetadataInvalid{}; + if (op == "BDC") { + auto property = operands[1]; + if (property.isName()) { + auto props = calls[index].resources.getKey("/Properties"); + if (!props.isDictionary()) + throw PdfMetadataInvalid{}; + property = props.getKey(property.getName()); + } + if (!property.isDictionary()) + throw PdfMetadataInvalid{}; + auto mcid = property.getKey("/MCID"); + if (!mcid.isNull()) + calls[index].mcids.insert(integer(mcid)); + } + } else if (op == "EMC") { + if (!operands.empty() || markedDepth == 0) + throw PdfMetadataInvalid{}; + --markedDepth; + } else if (op == "Do") { + if (operands.size() != 1 || !operands[0].isName()) + throw PdfMetadataInvalid{}; + auto objects = calls[index].resources.getKey("/XObject"); + if (!objects.isDictionary()) + throw PdfMetadataInvalid{}; + auto form = objects.getKey(operands[0].getName()); + if (!form.isStream()) + throw PdfMetadataInvalid{}; + auto dictionary = form.getDict(); + if (dictionary.getKey("/Subtype").isNameAndEquals("/Form")) { + if (calls.size() >= 10000) + throw PdfMetadataLimit{}; + auto resources = dictionary.getKey("/Resources"); + if (resources.isNull()) + resources = calls[index].resources; + if (!resources.isDictionary()) + throw PdfMetadataInvalid{}; + int child = int(calls.size()); + calls[index].children.push_back(child); + calls.push_back({form, resources, identity(form), index, ctm}); + parseCall(child, depth + 1, ancestors); + } else if (!dictionary.getKey("/Subtype").isNameAndEquals("/Image")) + calls[index].reliable = false; + } else if (op == "BI") { + if (inlineDictionary || !operands.empty()) + throw PdfMetadataInvalid{}; + inlineDictionary = true; + } else if (op == "ID") { + if (!inlineDictionary) + throw PdfMetadataInvalid{}; + tokenizer.expectInlineImage(input); + auto image = tokenizer.readToken(input, "DocView inline image", false, 16 * 1024 * 1024); + if (image.getType() != QPDFTokenizer::tt_inline_image) + throw PdfMetadataInvalid{}; + auto end = tokenizer.readToken(input, "DocView inline image end", false, 64); + if (!end.isWord("EI")) + throw PdfMetadataInvalid{}; + inlineDictionary = false; + } else if (!harmless.contains(QByteArray::fromStdString(op))) + calls[index].reliable = false; + operands.clear(); + } + if (!graphics.empty() || markedDepth || !operands.empty() || inlineDictionary) + throw PdfMetadataInvalid{}; + } + void scanPhysical(FPDF_PAGE pageObject) { + physical.emplace_back(); + const auto walk = [&](auto &&self, FPDF_PAGEOBJECT object, int parent, int depth) -> void { + if (depth > 64 || ++objectCount > 100000) + throw PdfMetadataLimit{}; + if (!object) + throw PdfMetadataInvalid{}; + const int mcid = FPDFPageObj_GetMarkedContentID(object); + if (mcid >= 0) + physical[parent].mcids.insert(mcid); + textOwner.insert(quintptr(object), parent); + if (FPDFPageObj_GetType(object) != FPDF_PAGEOBJ_FORM) + return; + FS_MATRIX m{}; + if (!FPDFPageObj_GetMatrix(object, &m)) + throw PdfMetadataInvalid{}; + const int child = int(physical.size()); + physical[parent].children.push_back(child); + physical.push_back({{m.a, m.b, m.c, m.d, m.e, m.f}}); + const int n = FPDFFormObj_CountObjects(object); + if (n < 0 || n > 100000) + throw PdfMetadataLimit{}; + for (int i = 0; i < n; ++i) + self(self, FPDFFormObj_GetObject(object, i), child, depth + 1); + }; + const int n = FPDFPage_CountObjects(pageObject); + if (n < 0 || n > 100000) + throw PdfMetadataLimit{}; + for (int i = 0; i < n; ++i) + walk(walk, FPDFPage_GetObject(pageObject, i), 0, 0); + auto textPage = FPDFText_LoadPage(pageObject); + if (!textPage) + return; + const auto guard = qScopeGuard([&] { FPDFText_ClosePage(textPage); }); + const int chars = FPDFText_CountChars(textPage); + if (chars > 1000000) + throw PdfMetadataLimit{}; + hasText = chars > 0; + for (int i = 0; i < chars; ++i) { + context.check(); + auto object = FPDFText_GetTextObject(textPage, i); + if (!object || !textOwner.contains(quintptr(object))) + continue; + int mcid = FPDFPageObj_GetMarkedContentID(object); + if (mcid < 0) + continue; + auto &mark = physical[textOwner.value(quintptr(object))].marks[mcid]; + const char32_t c = FPDFText_GetUnicode(textPage, i); + if (c && c <= 0x10ffff && mark.text.size() < 4096) + mark.text += QString::fromUcs4(&c, 1); + double l, r, b, t; + if (FPDFText_GetCharBox(textPage, i, &l, &r, &b, &t) && std::isfinite(l) && std::isfinite(r) && + std::isfinite(b) && std::isfinite(t) && l <= r && b <= t) { + const QRectF bounds(QPointF(l, b), QPointF(r, t)); + mark.bounds = mark.hasBounds ? mark.bounds.united(bounds) : bounds; + mark.hasBounds = true; + } + } + } + void match(int logical, int actual) { + ownerPhysical[calls[logical].owner].push_back(actual); + const auto &l = calls[logical]; + const auto &a = physical[actual]; + if (!l.reliable || l.children.size() != a.children.size()) { + formMatchLimited = true; + return; + } + std::set used; + for (int child : l.children) { + int found = -1, count = 0; + for (int candidate : a.children) + if (calls[child].local.close(physical[candidate].local) && + calls[child].mcids == physical[candidate].mcids) { + found = candidate; + ++count; + } + if (count != 1 || !used.insert(found).second) { + formMatchLimited = true; + continue; + } + match(child, found); + } + } + Obj parentEntry(int target) { + if (const auto found = parentEntries.find(target); found != parentEntries.end()) + return found->second; + struct Node { + Obj value; + int depth; + }; + std::vector pending{{parentTree, 0}}; + std::set seen; + Obj result = Obj::newNull(); + while (!pending.empty()) { + auto [node, depth] = pending.back(); + pending.pop_back(); + step(); + if (depth > 64 || !node.isDictionary()) + throw PdfMetadataInvalid{}; + if (node.isIndirect() && !seen.insert(node.getObjGen()).second) + throw PdfMetadataInvalid{}; + auto limits = node.getKey("/Limits"); + if (!limits.isNull()) { + if (!limits.isArray() || limits.getArrayNItems() != 2) + throw PdfMetadataInvalid{}; + const int low = integer(limits.getArrayItem(0)), high = integer(limits.getArrayItem(1)); + if (low > high) + throw PdfMetadataInvalid{}; + if (target < low || target > high) + continue; + } + auto nums = node.getKey("/Nums"), kids = node.getKey("/Kids"); + if (!nums.isNull()) { + if (!nums.isArray() || nums.getArrayNItems() % 2 || nums.getArrayNItems() > 20000) + throw PdfMetadataLimit{}; + int prior = -1; + for (int i = 0; i < nums.getArrayNItems(); i += 2) { + step(); + int key = integer(nums.getArrayItem(i)); + if (key <= prior) + throw PdfMetadataInvalid{}; + prior = key; + if (key == target) { + if (!result.isNull()) + throw PdfMetadataInvalid{}; + result = nums.getArrayItem(i + 1); + } + } + } + if (!kids.isNull()) { + if (!kids.isArray() || kids.getArrayNItems() > 10000 || !nums.isNull()) + throw PdfMetadataInvalid{}; + if (pending.size() + size_t(kids.getArrayNItems()) > 10000) + throw PdfMetadataLimit{}; + for (int i = 0; i < kids.getArrayNItems(); ++i) + pending.push_back({kids.getArrayItem(i), depth + 1}); + } + } + parentEntries.emplace(target, result); + return result; + } + QString role(Obj element) { + auto type = element.getKey("/S"); + if (!type.isName()) + throw PdfMetadataInvalid{}; + std::set seen; + std::string name = type.getName(); + for (int depth = 0; depth <= 64; ++depth) { + step(); + if (!seen.insert(name).second) + throw StructureError{QCoreApplication::translate("PdfStructure", "RoleMapの見出し名が循環しています。")}; + if (name == "/H" || (name.size() == 3 && name[1] == 'H' && name[2] >= '1' && name[2] <= '6')) + return QString::fromStdString(name.substr(1)); + if (!roleMap.isDictionary()) + return QString::fromStdString(name.substr(1)); + auto mapped = roleMap.getKey(name); + if (mapped.isNull()) + return QString::fromStdString(name.substr(1)); + if (!mapped.isName()) + throw PdfMetadataInvalid{}; + name = mapped.getName(); + } + throw PdfMetadataLimit{}; + } + std::vector order(Obj element) { + std::vector result; + std::set seen; + for (int depth = 0; depth <= 64; ++depth) { + step(); + if (element.isSameObjectAs(root)) { + std::reverse(result.begin(), result.end()); + return result; + } + if (!element.isDictionary() || !seen.insert(identity(element)).second) + throw StructureError{QCoreApplication::translate("PdfStructure", "構造要素の親参照が循環または欠落しています。")}; + auto parent = element.getKey("/P"); + if (!parent.isDictionary()) + throw PdfMetadataInvalid{}; + auto k = parent.getKey("/K"); + int found = -1; + if (k.isArray()) { + if (k.getArrayNItems() > 10000) + throw PdfMetadataLimit{}; + for (int i = 0; i < k.getArrayNItems(); ++i) { + step(); + if (k.getArrayItem(i).isSameObjectAs(element)) { + if (found >= 0) + throw PdfMetadataInvalid{}; + found = i; + } + } + } else if (k.isSameObjectAs(element)) + found = 0; + if (found < 0) + throw StructureError{QCoreApplication::translate("PdfStructure", "構造要素の親と子の順序を確認できません。")}; + result.push_back(found); + element = parent; + } + throw PdfMetadataLimit{}; + } + Obj inheritedPage(Obj element) { + std::set seen; + for (int depth = 0; depth <= 64 && !element.isSameObjectAs(root); ++depth) { + step(); + if (!element.isDictionary() || !seen.insert(identity(element)).second) + throw PdfMetadataInvalid{}; + auto pg = element.getKey("/Pg"); + if (!pg.isNull()) + return pg; + element = element.getKey("/P"); + } + return Obj::newNull(); + } + struct Ref { + Id owner; + int mcid; + }; + void verifyReference(const Invocation &call, int mcid, Obj heading) { + const auto dictionary = call.object.isStream() ? call.object.getDict() : call.object; + const auto parentKey = dictionary.getKey("/StructParents"); + if (parentKey.isNull()) + throw StructureError{QCoreApplication::translate("PdfStructure", "MCR所有元のStructParentsを確認できません。")}; + auto entries = parentEntry(integer(parentKey)); + if (!entries.isArray() || mcid >= entries.getArrayNItems()) + throw PdfMetadataInvalid{}; + auto element = entries.getArrayItem(mcid); + std::set seen; + for (int depth = 0; depth <= 64; ++depth) { + step(); + if (element.isSameObjectAs(heading)) + return; + if (!element.isDictionary() || element.isSameObjectAs(root) || + !seen.insert(identity(element)).second) + break; + element = element.getKey("/P"); + } + throw StructureError{QCoreApplication::translate("PdfStructure", "ParentTreeと見出しMCRの所有元が一致しません。")}; + } + void references(Obj value, Obj pg, std::vector &out, int depth, std::set ancestors, + Obj expectedParent = Obj::newNull()) { + step(); + if (depth > 64) + throw PdfMetadataLimit{}; + if (value.isArray()) { + if (value.getArrayNItems() > 10000) + throw PdfMetadataLimit{}; + for (int i = 0; i < value.getArrayNItems(); ++i) + references(value.getArrayItem(i), pg, out, depth + 1, ancestors, expectedParent); + return; + } + if (value.isInteger()) { + if (!pg.isDictionary() || !pg.getKey("/Type").isNameAndEquals("/Page")) + throw StructureError{QCoreApplication::translate("PdfStructure", "見出しのページ所有元を確認できません。")}; + if (identity(pg) == pageId) + out.push_back({pageId, integer(value)}); + return; + } + if (value.isNull()) + return; + if (!value.isDictionary()) + throw PdfMetadataInvalid{}; + auto type = value.getKey("/Type"); + if (type.isNameAndEquals("/OBJR") || !value.getKey("/StmOwn").isNull()) + throw StructureError{QCoreApplication::translate("PdfStructure", "注釈所有のOBJR/StmOwn構造には対応していません。")}; + auto localPg = value.getKey("/Pg"); + if (!localPg.isNull()) + pg = localPg; + if (type.isNameAndEquals("/MCR") || !value.getKey("/MCID").isNull()) { + if (!pg.isDictionary() || !pg.getKey("/Type").isNameAndEquals("/Page")) + throw StructureError{QCoreApplication::translate("PdfStructure", "MCRのページ参照を確認できません。")}; + auto stream = value.getKey("/Stm"); + Id owner = pageId; + if (!stream.isNull()) { + if (!stream.isStream() || !stream.getDict().getKey("/Subtype").isNameAndEquals("/Form")) + throw StructureError{QCoreApplication::translate("PdfStructure", "MCRのストリーム所有元に対応していません。")}; + owner = identity(stream); + } + const int mcid = integer(value.getKey("/MCID")); + if (identity(pg) == pageId) + out.push_back({owner, mcid}); + return; + } + if (!value.getKey("/S").isName() || !ancestors.insert(identity(value)).second) + throw StructureError{QCoreApplication::translate("PdfStructure", "見出しの子構造が循環または破損しています。")}; + if (!expectedParent.isNull() && !value.getKey("/P").isSameObjectAs(expectedParent)) + throw StructureError{QCoreApplication::translate("PdfStructure", "見出しの子構造と親参照が一致しません。")}; + references(value.getKey("/K"), pg, out, depth + 1, ancestors, value); + } + QCborArray headings() { + parentTree = root.getKey("/ParentTree"); + roleMap = root.getKey("/RoleMap"); + if (!parentTree.isDictionary()) + throw StructureError{QCoreApplication::translate("PdfStructure", "見出しのParentTreeがありません。")}; + std::map candidates; + std::set owners; + for (const auto &call : calls) { + if (!owners.insert(call.owner).second) + continue; + auto dictionary = call.object.isStream() ? call.object.getDict() : call.object; + auto parentKey = dictionary.getKey("/StructParents"); + if (parentKey.isNull()) + continue; + auto parents = parentEntry(integer(parentKey)); + if (!parents.isArray() || parents.getArrayNItems() > 1000000) + throw PdfMetadataInvalid{}; + for (int mcid : call.mcids) { + step(); + if (mcid >= parents.getArrayNItems()) + throw PdfMetadataInvalid{}; + auto element = parents.getArrayItem(mcid); + if (element.isNull()) + continue; + std::set seen; + for (int depth = 0; !element.isSameObjectAs(root); ++depth) { + step(); + if (depth > 64) + throw PdfMetadataLimit{}; + if (!element.isDictionary() || !seen.insert(identity(element)).second) + throw StructureError{ + QCoreApplication::translate("PdfStructure", "ParentTreeからの親参照が循環または破損しています。")}; + const auto type = role(element); + if (type == "H" || + (type.size() == 2 && type[0] == 'H' && type[1] >= '1' && type[1] <= '6')) + candidates.emplace(identity(element), element); + element = element.getKey("/P"); + } + } + } + struct Heading { + Obj element; + std::vector path; + }; + std::vector sorted; + for (auto &[id, element] : candidates) + sorted.push_back({element, order(element)}); + std::sort(sorted.begin(), sorted.end(), + [](const Heading &a, const Heading &b) { return a.path < b.path; }); + QCborArray result; + qsizetype responseBytes = 4096; + for (const auto &heading : sorted) { + std::vector refs; + references(heading.element, inheritedPage(heading.element), refs, 0, {}); + QString title, reason; + QRectF bounds; + bool hasBounds = false, ambiguous = false; + std::set> seen; + if (refs.empty()) + throw StructureError{QCoreApplication::translate("PdfStructure", "ParentTreeが示すページに見出しのMCRが存在しません。")}; + for (const auto &ref : refs) { + if (!seen.insert({ref.owner, ref.mcid}).second) + continue; + const auto found = ownerPhysical.find(ref.owner); + const auto call = std::find_if(calls.begin(), calls.end(), [&](const Invocation &v) { + return v.owner == ref.owner && v.mcids.contains(ref.mcid); + }); + if (call == calls.end()) { + ambiguous = true; + reason = QCoreApplication::translate("PdfStructure", "MCRが参照するFormまたはMCIDは、このページの実際の呼出で確認できません。"); + continue; + } + verifyReference(*call, ref.mcid, heading.element); + if (found == ownerPhysical.end() || found->second.empty()) { + ambiguous = true; + reason = QCoreApplication::translate("PdfStructure", "Formと描画オブジェクトの対応が一意でないため、ページ先頭へ移動します。"); + continue; + } + if (found->second.size() > 1) { + ambiguous = true; + reason = QCoreApplication::translate("PdfStructure", "同じFormが複数回呼び出されているため、ページ先頭へ移動します。"); + } + const auto &marks = physical[found->second.front()].marks; + auto mark = marks.find(ref.mcid); + if (mark == marks.end()) + continue; + if (title.size() < 4096) + title += mark->second.text.left(4096 - title.size()); + if (mark->second.hasBounds) { + bounds = hasBounds ? bounds.united(mark->second.bounds) : mark->second.bounds; + hasBounds = true; + } + } + if (title.simplified().isEmpty()) + title = text(heading.element.getKey("/ActualText")); + if (title.simplified().isEmpty()) + title = text(heading.element.getKey("/T")); + if (title.simplified().isEmpty()) + title = text(heading.element.getKey("/Alt")); + if (title.simplified().isEmpty()) + title = QCoreApplication::translate("PdfStructure", "見出し(名称なし)"); + const auto type = role(heading.element); + const auto id = identity(heading.element); + const bool exact = hasBounds && !ambiguous; + QCborMap item{ + {key("id"), QString("heading-%1-%2-%3").arg(pageIndex).arg(id.getObj()).arg(id.getGen())}, + {key("title"), title.simplified().left(4096)}, + {key("level"), type.size() == 2 ? type[1].digitValue() : 0}, + {key("page"), pageIndex}, + {key("source"), key("pdf-tag")}, + {key("precision"), exact ? key("exact") : key("page")}}; + if (exact) { + item.insert(key("x"), bounds.left()); + item.insert(key("y"), bounds.bottom()); + item.insert(key("rect"), + QCborArray{bounds.left(), bounds.top(), bounds.right(), bounds.bottom()}); + } else + item.insert(key("reason"), + reason.isEmpty() + ? QCoreApplication::translate("PdfStructure", "所有元付きMCIDの文字座標を取得できないため、ページ先頭へ移動します。") + : reason); + const qsizetype size = QCborValue(item).toCbor().size(); + if (result.size() >= 1000 || responseBytes + size > 512 * 1024) { + responseLimited = true; + break; + } + responseBytes += size; + result.append(item); + } + return result; + } +}; +} // namespace +StructureReader::StructureReader(std::shared_ptr c) : context(std::move(c)) {} +QCborMap StructureReader::headings(int index, FPDF_PAGE page) { + QCborMap result{{key("headings"), QCborArray{}}, + {key("page"), index}, + {key("complete"), true}, + {key("truncated"), false}, + {key("hasText"), false}}; + try { + context->beginOperation(); + auto root = context->root().getKey("/StructTreeRoot"); + if (root.isNull()) + return result; + if (!root.isDictionary()) + throw PdfMetadataInvalid{}; + Scan scan(*context, index); + scan.calls.push_back({scan.page, scan.inheritedResources(scan.page), scan.pageId}); + scan.parseCall(0, 0, {}); + scan.scanPhysical(page); + scan.match(0, 0); + result.insert(key("hasText"), scan.hasText); + result.insert(key("headings"), scan.headings()); + context->check(); + if (scan.responseLimited) { + result.insert(key("truncated"), true); + result.insert(key("complete"), false); + result.insert(key("structureLimited"), true); + result.insert( + key("unavailableReason"), + QCoreApplication::translate("PdfStructure", "見出しの応答サイズが安全上限を超えたため、先頭の項目だけを表示します。")); + } + } catch (const PdfMetadataLimit &) { + result.insert(key("truncated"), true); + result.insert(key("complete"), false); + result.insert(key("structureLimited"), true); + result.insert(key("unavailableReason"), + QCoreApplication::translate("PdfStructure", "PDFの構造・Form内容が解析の安全上限を超えました。")); + } catch (const StructureError &error) { + result.insert(key("structureLimited"), true); + result.insert(key("unavailableReason"), error.reason); + } catch (...) { + result.insert(key("structureLimited"), true); + result.insert(key("unavailableReason"), + QCoreApplication::translate("PdfStructure", "PDFの構造・Form参照が破損しているため見出しを確認できません。")); + } + return result; +} +} // namespace docview diff --git a/src/pdf/structure_reader.h b/src/pdf/structure_reader.h new file mode 100644 index 0000000..d78e50e --- /dev/null +++ b/src/pdf/structure_reader.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include +#include +namespace docview { +class PdfMetadataContext; +class StructureReader { + public: + explicit StructureReader(std::shared_ptr); + QCborMap headings(int index, FPDF_PAGE page); + + private: + std::shared_ptr context; +}; +} // namespace docview diff --git a/src/pdf/system_font_adapter.cpp b/src/pdf/system_font_adapter.cpp new file mode 100644 index 0000000..22c5953 --- /dev/null +++ b/src/pdf/system_font_adapter.cpp @@ -0,0 +1,266 @@ +#include "system_font_adapter.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace docview { +namespace { +constexpr qint64 MaxFontBytes = 64 * 1024 * 1024, MaxCacheBytes = 128 * 1024 * 1024; +constexpr qint64 MaxTransferredBytes = 512 * 1024 * 1024; +constexpr int MaxReadBytes = 65536, MaxFaces = 256, MaxTables = 4096, MaxRequests = 256; +constexpr quint32 Ttcf = 0x74746366; +QString k(const char *value) { return QString::fromLatin1(value); } +bool range(quint64 offset, quint64 length, quint64 size) { return offset <= size && length <= size - offset; } +bool integer(const QCborMap &map, const char *name, qint64 low, qint64 high) { + const auto value = map.value(k(name)); + return value.isInteger() && value.toInteger() >= low && value.toInteger() <= high; +} +bool charsetValid(int value) { + switch (value) { + case 0: case 1: case 2: case 128: case 129: case 134: case 136: case 161: + case 163: case 177: case 178: case 204: case 222: case 238: return true; + default: return false; + } +} +bool localName(const QByteArray &name, bool emptyAllowed = false) { + return (emptyAllowed || !name.isEmpty()) && name.size() <= 256 && !name.contains('\0'); +} +struct FontBlob { + struct Slice { quint32 offset; quint32 length; }; + QByteArray bytes; + QHash tables; + quint32 faceOffset = 0; + bool collection = false; + + static std::shared_ptr parse(QByteArray bytes, int faceIndex, qint64 selectedOffset) { + if (bytes.size() < 12 || bytes.size() > MaxFontBytes) return {}; + const auto u16 = [&](quint64 at) { return qFromBigEndian(bytes.constData() + at); }; + const auto u32 = [&](quint64 at) { return qFromBigEndian(bytes.constData() + at); }; + const bool ttc = u32(0) == Ttcf; + QList offsets; + if (ttc) { + if (u32(4) != 0x00010000 && u32(4) != 0x00020000) return {}; + const quint32 faces = u32(8); + if (!faces || faces > MaxFaces || !range(12, quint64(faces) * 4, bytes.size())) return {}; + if (u32(4) == 0x00020000) { + const auto signatureEntry = 12 + quint64(faces) * 4; + if (!range(signatureEntry, 12, bytes.size())) return {}; + const auto tag = u32(signatureEntry), length = u32(signatureEntry + 4), at = u32(signatureEntry + 8); + if ((!tag && (length || at)) || (tag && (tag != 0x44534947 || !range(at, length, bytes.size())))) return {}; + } + QSet seen; + for (quint32 i = 0; i < faces; ++i) { + const auto offset = u32(12 + quint64(i) * 4); + if (offset < 12 + quint64(faces) * 4 + (u32(4) == 0x00020000 ? 12 : 0) || offset % 4 || seen.contains(offset)) return {}; + seen.insert(offset); offsets.append(offset); + } + } else offsets.append(0); + if (faceIndex < 0 && selectedOffset < 0) return {}; + if (faceIndex >= offsets.size()) return {}; + if (selectedOffset >= 0) { + if (quint64(selectedOffset) > std::numeric_limits::max()) return {}; + const auto index = offsets.indexOf(quint32(selectedOffset)); + if (index < 0 || (faceIndex >= 0 && index != faceIndex)) return {}; + faceIndex = index; + } + if (faceIndex < 0) return {}; + auto blob = std::make_shared(); + blob->faceOffset = offsets[faceIndex]; blob->collection = ttc; + // Validate every directory that PDFium/FreeType can access, not merely + // the selected face. Offsets in a TTC are collection-relative. + for (int face = 0; face < offsets.size(); ++face) { + const quint64 offset = offsets[face]; + if (!range(offset, 12, bytes.size())) return {}; + const auto signature = u32(offset); + if (signature != 0x00010000 && signature != 0x4f54544f && signature != 0x74727565) return {}; + const auto count = u16(offset + 4); + if (!count || count > MaxTables || !range(offset + 12, quint64(count) * 16, bytes.size())) return {}; + QSet tags; + for (quint32 i = 0; i < count; ++i) { + const auto entry = offset + 12 + quint64(i) * 16; + const auto tag = u32(entry), at = u32(entry + 8), size = u32(entry + 12); + if (!tag || tags.contains(tag) || !range(at, size, bytes.size())) return {}; + tags.insert(tag); + // Initial contract is static outline fonts; do not silently + // reinterpret named instances or color-only glyph providers. + if (face == faceIndex && (tag == 0x66766172 || tag == 0x434f4c52 || tag == 0x43424454 || tag == 0x73626978 || tag == 0x53564720)) return {}; + if (face == faceIndex) blob->tables.insert(tag, {at, size}); + } + } + blob->bytes = std::move(bytes); + return blob; + } + Slice slice(quint32 tag) const { + if (tag == Ttcf) return {0, collection ? quint32(bytes.size()) : 0}; + if (!tag) return {faceOffset, quint32(bytes.size()) - faceOffset}; + return tables.value(tag, {0, 0}); + } +}; +} + +struct SystemFontAdapter::Impl { + struct Interface : FPDF_SYSFONTINFO { Impl *owner = nullptr; } info{}; + struct Handle { std::shared_ptr blob; QByteArray face; int charset; }; + struct Entry { std::shared_ptr blob; quint64 used; }; + Fetch fetch; + QHash cache; + QSet handles; + QSet requests; + qint64 cost = 0, transferred = 0, budget; + quint64 clock = 0; + QString code, message; + QStringList warnings; + explicit Impl(Fetch value, qint64 limit) : fetch(std::move(value)), budget(std::clamp(limit, 1, MaxCacheBytes)) {} + ~Impl() { qDeleteAll(handles); } + void fail(const QString &newCode, const QString &why) { + if (code.isEmpty()) { code = newCode; message = why; } + } + void invalid() { fail(k("E_WORKER_CRASH"), QCoreApplication::translate("PdfFontAdapter", "フォントの応答または表構造が無効です。")); } + void warn(const QString &why) { if (warnings.size() < 64 && !warnings.contains(why)) warnings.append(why); } + QCborMap call(const char *operation, const QCborMap &payload, QElapsedTimer &timer) { + const qint64 remaining = 15000 - timer.elapsed(); + if (remaining <= 0) { fail(k("E_WORKER_TIMEOUT"), QCoreApplication::translate("PdfFontAdapter", "フォントの取得が時間制限を超えました。")); return {}; } + const auto result = fetch(k(operation), payload, int(std::min(5000, remaining))); + if (timer.elapsed() >= 15000) { fail(k("E_WORKER_TIMEOUT"), QCoreApplication::translate("PdfFontAdapter", "フォントの取得が時間制限を超えました。")); return {}; } + if (result.contains(k("error"))) { + const auto error = result.value(k("error")).toMap(); + const auto errorCode = error.value(k("code")).toString(); + const auto errorMessage = error.value(k("message")).toString(); + if (result.size() != 1 || error.size() != 2 || + (errorCode != "E_FONT_LIMIT" && errorCode != "E_FONT_FAILED" && errorCode != "E_WORKER_CRASH" && errorCode != "E_WORKER_TIMEOUT") || + errorMessage.isEmpty() || errorMessage.size() > 4096 || errorMessage.contains(QChar::Null)) invalid(); + else fail(errorCode, errorMessage); + } + return result; + } + bool makeRoom(qint64 size) { + while (cost > budget - size) { + auto oldest = cache.end(); + for (auto it = cache.begin(); it != cache.end(); ++it) + if (it->blob.use_count() == 1 && (oldest == cache.end() || it->used < oldest->used)) oldest = it; + if (oldest == cache.end()) { fail(k("E_FONT_LIMIT"), QCoreApplication::translate("PdfFontAdapter", "使用中のフォントがメモリー上限を超えています。")); return false; } + cost -= oldest->blob->bytes.size(); cache.erase(oldest); + } + return true; + } + void *map(int weight, bool italic, int charset, int pitch, const char *face) { + if (!code.isEmpty()) return nullptr; + if (!face) { invalid(); return nullptr; } + int length = 0; while (length <= 256 && face[length]) ++length; + if (length > 256 || weight < 0 || weight > 1000 || !charsetValid(charset) || (pitch < 0 || pitch > 0x52 || (pitch & 0x0f) > 2)) { invalid(); return nullptr; } + const QByteArray name(face, length); + QCborMap request{{k("faceLocal"), name}, {k("weight"), weight}, {k("italic"), italic}, {k("charset"), charset}, {k("pitchFamily"), pitch}}; + const auto requestKey = QCborValue(request).toCbor(); + if (!requests.contains(requestKey) && requests.size() >= MaxRequests) { fail(k("E_FONT_LIMIT"), QCoreApplication::translate("PdfFontAdapter", "フォント選択の種類が上限を超えています。")); return nullptr; } + requests.insert(requestKey); + if (!fetch) { warn(QCoreApplication::translate("PdfFontAdapter", "代替フォントを取得できないため内蔵フォントを使用します。")); return nullptr; } + QElapsedTimer timer; timer.start(); + auto selected = call("font.map", request, timer); + if (!code.isEmpty()) return nullptr; + if (!selected.value(k("found")).isBool()) { invalid(); return nullptr; } + if (!selected.value(k("found")).toBool()) { + if (selected.size() != 1) invalid(); + else warn(QCoreApplication::translate("PdfFontAdapter", "要求されたフォントが見つからないため内蔵の代替を使用します。")); + return nullptr; + } + const auto id = selected.value(k("fontId")).toString(); + const bool validId = id.size() == 32 && std::all_of(id.cbegin(), id.cend(), [](QChar c) { return (c >= QChar('0') && c <= QChar('9')) || (c >= QChar('a') && c <= QChar('f')); }); + const auto actual = selected.value(k("actualFaceLocal")).toByteArray(); + const auto sha = selected.value(k("sha256")).toByteArray(); + const bool indexPresent = selected.contains(k("faceIndex")), offsetPresent = selected.contains(k("faceOffset")); + if (selected.size() != 8 || !selected.value(k("fontId")).isString() || !validId || + !selected.value(k("actualFaceLocal")).isByteArray() || !localName(actual) || + !integer(selected, "charset", 0, 255) || !charsetValid(int(selected.value(k("charset")).toInteger())) || + !integer(selected, "size", 1, MaxFontBytes) || !selected.value(k("sha256")).isByteArray() || sha.size() != 32 || + !selected.value(k("substituted")).isBool() || (indexPresent == offsetPresent) || + (indexPresent && !integer(selected, "faceIndex", 0, 65535)) || + (offsetPresent && !integer(selected, "faceOffset", 0, selected.value(k("size")).toInteger() - 1))) { invalid(); return nullptr; } + const qint64 size = selected.value(k("size")).toInteger(); + const int index = int(selected.value(k("faceIndex")).toInteger(-1)); + const qint64 offset = selected.value(k("faceOffset")).toInteger(-1); + const QByteArray key = sha + ':' + QByteArray::number(index) + ':' + QByteArray::number(offset); + std::shared_ptr blob; + auto found = cache.find(key); + if (found != cache.end()) { blob = found->blob; found->used = ++clock; if (blob->bytes.size() != size) invalid(); } + else if (transferred > MaxTransferredBytes - size) fail(k("E_FONT_LIMIT"), QCoreApplication::translate("PdfFontAdapter", "フォント転送量が上限を超えています。")); + else if (makeRoom(size)) { + QByteArray bytes; bytes.reserve(size); + while (bytes.size() < size && code.isEmpty()) { + const qint64 at = bytes.size(), length = std::min(MaxReadBytes, size - at); + const auto chunk = call("font.read", {{k("fontId"), id}, {k("offset"), at}, {k("length"), length}}, timer); + if (!code.isEmpty()) break; + if (chunk.size() != 3 || !chunk.value(k("fontId")).isString() || chunk.value(k("fontId")).toString() != id || + !integer(chunk, "offset", at, at) || !chunk.value(k("data")).isByteArray() || chunk.value(k("data")).toByteArray().size() != length) { invalid(); break; } + bytes.append(chunk.value(k("data")).toByteArray()); transferred += length; + } + if (code.isEmpty()) { + if (QCryptographicHash::hash(bytes, QCryptographicHash::Sha256) != sha) invalid(); + else { blob = FontBlob::parse(std::move(bytes), index, offset); if (!blob) fail(k("E_FONT_FAILED"), QCoreApplication::translate("PdfFontAdapter", "フォントの表構造が無効、または未対応です。")); } + } + } + // Close before exposing a callback handle, including cache hits. A + // failed transfer is fatal to the operation, not a missing font. + if (timer.elapsed() < 15000) { + const auto closed = call("font.close", {{k("fontId"), id}}, timer); + if (code.isEmpty() && (closed.size() != 1 || !closed.value(k("released")).isBool() || !closed.value(k("released")).toBool())) invalid(); + } else fail(k("E_WORKER_TIMEOUT"), QCoreApplication::translate("PdfFontAdapter", "フォントの取得が時間制限を超えました。")); + if (!code.isEmpty() || !blob) return nullptr; + if (!cache.contains(key)) { cost += blob->bytes.size(); cache.insert(key, {blob, ++clock}); } + if (handles.size() >= 256) { fail(k("E_FONT_LIMIT"), QCoreApplication::translate("PdfFontAdapter", "使用中のフォント数が上限を超えています。")); return nullptr; } + if (selected.value(k("substituted")).toBool()) warn(QCoreApplication::translate("PdfFontAdapter", "原本に埋め込まれていないフォントを代替しています。")); + auto *handle = new Handle{std::move(blob), actual, int(selected.value(k("charset")).toInteger())}; + handles.insert(handle); return handle; + } + static Impl &from(FPDF_SYSFONTINFO *info) { return *static_cast(info)->owner; } + Handle *checked(void *value) const { auto *handle = static_cast(value); return handles.contains(handle) ? handle : nullptr; } +}; + +SystemFontAdapter::SystemFontAdapter(Fetch fetch, qint64 budget) : d(std::make_unique(std::move(fetch), budget)) { + auto &info = d->info; info.owner = d.get(); info.version = 2; + info.Release = [](FPDF_SYSFONTINFO *) noexcept {}; // The enclosing document owns us. + info.MapFont = [](FPDF_SYSFONTINFO *self, int weight, FPDF_BOOL italic, int charset, int pitch, const char *face, FPDF_BOOL *exact) noexcept -> void * { + auto &impl = Impl::from(self); if (exact) *exact = false; + try { return impl.map(weight, italic != 0, charset, pitch, face); } + catch (...) { impl.fail(k("E_FONT_FAILED"), QCoreApplication::translate("PdfFontAdapter", "フォントの処理に失敗しました。")); return nullptr; } + }; + info.GetFont = [](FPDF_SYSFONTINFO *self, const char *face) noexcept -> void * { return self->MapFont(self, 400, false, FXFONT_DEFAULT_CHARSET, 0, face, nullptr); }; + info.GetFontData = [](FPDF_SYSFONTINFO *self, void *value, unsigned int table, unsigned char *buffer, unsigned long capacity) noexcept -> unsigned long { + const auto *handle = Impl::from(self).checked(value); if (!handle) return 0; + const auto slice = handle->blob->slice(table); + if (buffer && capacity) { + // PDFium's TTC checksum samples 1024 bytes regardless of return + // length. Initialize the complete caller-owned buffer, including + // the tail of a shorter collection and missing tables. + std::memset(buffer, 0, capacity); + std::memcpy(buffer, handle->blob->bytes.constData() + slice.offset, std::min(capacity, slice.length)); + } + return slice.length; + }; + info.GetFaceName = [](FPDF_SYSFONTINFO *self, void *value, char *buffer, unsigned long capacity) noexcept -> unsigned long { + const auto *handle = Impl::from(self).checked(value); if (!handle) return 0; + const unsigned long length = static_cast(handle->face.size()) + 1; + if (buffer && capacity) std::memcpy(buffer, handle->face.constData(), std::min(capacity, length)); + return length; + }; + info.GetFontCharset = [](FPDF_SYSFONTINFO *self, void *value) noexcept -> int { + const auto *handle = Impl::from(self).checked(value); return handle ? handle->charset : FXFONT_DEFAULT_CHARSET; + }; + info.DeleteFont = [](FPDF_SYSFONTINFO *self, void *value) noexcept { + auto &impl = Impl::from(self); auto *handle = impl.checked(value); if (handle) { impl.handles.remove(handle); delete handle; } + }; +} +SystemFontAdapter::~SystemFontAdapter() = default; +FPDF_SYSFONTINFO *SystemFontAdapter::interface() { return &d->info; } +bool SystemFontAdapter::failed() const { return !d->code.isEmpty(); } +QString SystemFontAdapter::errorCode() const { return d->code; } +QString SystemFontAdapter::errorMessage() const { return d->message; } +QStringList SystemFontAdapter::warnings() const { return d->warnings; } +qint64 SystemFontAdapter::cacheBytes() const { return d->cost; } +qint64 SystemFontAdapter::transferredBytes() const { return d->transferred; } +} diff --git a/src/pdf/system_font_adapter.h b/src/pdf/system_font_adapter.h new file mode 100644 index 0000000..eae7ebd --- /dev/null +++ b/src/pdf/system_font_adapter.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace docview { +// Worker-thread only. The callback owns immutable font snapshots, never paths +// or OS font handles. Keep this object alive until FPDF_DestroyLibrary returns. +class SystemFontAdapter { +public: + using Fetch = std::function; + // A caller may lower the budget; it can never exceed the 128 MiB limit. + explicit SystemFontAdapter(Fetch fetch = {}, qint64 cacheBudgetBytes = 128ll * 1024 * 1024); + ~SystemFontAdapter(); + SystemFontAdapter(const SystemFontAdapter &) = delete; + SystemFontAdapter &operator=(const SystemFontAdapter &) = delete; + FPDF_SYSFONTINFO *interface(); + bool failed() const; + QString errorCode() const; + QString errorMessage() const; + QStringList warnings() const; + qint64 cacheBytes() const; + qint64 transferredBytes() const; +private: + struct Impl; + std::unique_ptr d; +}; +} diff --git a/src/web/renderer_watchdog.cpp b/src/web/renderer_watchdog.cpp new file mode 100644 index 0000000..be652a3 --- /dev/null +++ b/src/web/renderer_watchdog.cpp @@ -0,0 +1,218 @@ +#include "renderer_watchdog.h" +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include "windows_renderer_identity.h" +#endif +#ifdef Q_OS_LINUX +#include +#include +#include +#endif + +namespace docview { +#ifdef Q_OS_WIN +struct RendererWatchdog::WindowsRenderer { winrenderer::Identity identity; }; +#endif +bool parseRendererProcStat(const QByteArray &stat, quint64 pageSize, RendererProcessInfo *result) { + if (!result || !pageSize || stat.size() > 65536) return false; + // comm is parenthesized and may itself contain spaces or parentheses. + const auto end = stat.lastIndexOf(')'); + if (end < 2 || stat.indexOf('(') < 1 || end + 2 >= stat.size()) return false; + const auto fields = stat.mid(end + 1).simplified().split(' '); + if (fields.size() < 22 || fields[0].size() != 1) return false; + bool parentOk = false, timeOk = false, rssOk = false; + const auto parent = fields[1].toLongLong(&parentOk); + const auto start = fields[19].toULongLong(&timeOk); + const auto rss = fields[21].toLongLong(&rssOk); + if (!parentOk || !timeOk || !rssOk || parent < 0 || !start || rss < 0 || quint64(rss) > std::numeric_limits::max() / pageSize) return false; + *result = {parent, start, quint64(rss) * pageSize}; + return true; +} +bool isRendererCommandLine(const QByteArray &command, const QByteArray &executable) { + if (command.isEmpty() || command.size() > 65536 || executable.isEmpty() || executable.contains('\0')) return false; + auto args = command.split('\0'); + while (!args.isEmpty() && args.last().isEmpty()) args.removeLast(); + if (args.size() > 1) return args.first() == executable && args.contains("--type=renderer"); + if (args.isEmpty()) return false; + // Chromium's Linux zygote rewrites the process title into one argv element. + // Match the known executable and its first exact option, never a substring + // inside another argument (for example an authored URL or file name). + const auto prefix = executable + " --type=renderer"; + return args.first() == prefix || args.first().startsWith(prefix + ' '); +} + +RendererWatchdog::RendererWatchdog(QObject *parent, quint64 limitBytes, const QString &rendererExecutable, int responseTimeoutMs) + : QObject(parent), responseTimeoutMs_(qBound(100, responseTimeoutMs, DefaultResponseTimeoutMs)), + limitBytes_(limitBytes), parentPid_(QCoreApplication::applicationPid()), rendererExecutable_(rendererExecutable) { +#ifdef Q_OS_LINUX + const long size = ::sysconf(_SC_PAGESIZE); + if (size > 0) pageSize_ = quint64(size); + if (rendererExecutable_.isEmpty()) rendererExecutable_ = QDir(QLibraryInfo::path(QLibraryInfo::LibraryExecutablesPath)).filePath("QtWebEngineProcess"); +#endif +#ifdef Q_OS_WIN + if (rendererExecutable_.isEmpty()) rendererExecutable_ = QDir(QLibraryInfo::path(QLibraryInfo::LibraryExecutablesPath)).filePath("QtWebEngineProcess.exe"); +#endif + timer_.setInterval(1000); + clock_.start(); + connect(&timer_, &QTimer::timeout, this, &RendererWatchdog::checkNow); + timer_.start(); +} +RendererWatchdog::~RendererWatchdog() { + const auto tokens = renderers_.keys(); + for (const auto &key : tokens) { const auto renderer = renderers_.value(key); removeSession(renderer.token); } +} +bool RendererWatchdog::readProcess(qint64 pid, RendererProcessInfo *info) const { +#ifdef Q_OS_LINUX + if (pid <= 1 || pid > std::numeric_limits::max()) return false; + QFile stat(QStringLiteral("/proc/%1/stat").arg(pid)); + return stat.open(QIODevice::ReadOnly) && parseRendererProcStat(stat.read(65537), pageSize_, info); +#elif defined(Q_OS_WIN) + if (pid <= 1 || quint64(pid) > std::numeric_limits::max()) return false; + winresource::Handle handle(OpenProcess(PROCESS_QUERY_INFORMATION | SYNCHRONIZE, FALSE, static_cast(pid))); + winrenderer::BasicInformation basic{}; std::uint64_t started = 0, bytes = 0; + if (!handle.valid() || !winrenderer::basic(handle.get(), &basic) || !winrenderer::creationTime(handle.get(), &started) || !winrenderer::workingSet(handle.get(), &bytes)) return false; + *info = {qint64(basic.parent), quint64(started), quint64(bytes)}; return true; +#else + Q_UNUSED(pid); Q_UNUSED(info); return false; +#endif +} +bool RendererWatchdog::isDescendant(qint64 pid) const { + QSet seen; + for (int depth = 0; depth < 128 && pid > 1 && pid != parentPid_; ++depth) { + if (seen.contains(pid)) return false; + seen.insert(pid); + RendererProcessInfo info; + if (!readProcess(pid, &info)) return false; + pid = info.parentPid; + } + return pid == parentPid_; +} +void RendererWatchdog::removeSession(const QString &token) { + removeSlot(token, 0); + removeSlot(token, 1); +} +void RendererWatchdog::removeSlot(const QString &token, int slot) { + const auto found = renderers_.find(token + ':' + QString::number(slot)); + if (found == renderers_.end()) return; +#ifdef Q_OS_LINUX + if (found->processFd >= 0) ::close(found->processFd); +#endif + renderers_.erase(found); +} +bool RendererWatchdog::registerRenderer(const QString &token, qint64 pid, QString *error, int slot) { + const auto reject = [error](const QString &code) { if (error) *error = code; return false; }; + if (token.isEmpty() || token.size() > 128 || slot < 0 || slot > 1) return reject("E_RENDERER_IDENTITY_INVALID"); + if (!pid) { removeSlot(token, slot); return true; } + const auto key = token + ':' + QString::number(slot); +#if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal) + RendererProcessInfo before; + if (pid == parentPid_ || !readProcess(pid, &before) || !isDescendant(pid)) return reject("E_RENDERER_IDENTITY_INVALID"); + const auto expectedExecutable = QFileInfo(rendererExecutable_).canonicalFilePath(); + const auto executableMatches = [&] { + return !expectedExecutable.isEmpty() && QFileInfo(QStringLiteral("/proc/%1/exe").arg(pid)).symLinkTarget() == expectedExecutable; + }; + if (!executableMatches()) return reject("E_RENDERER_IDENTITY_INVALID"); + QFile command(QStringLiteral("/proc/%1/cmdline").arg(pid)); + if (!command.open(QIODevice::ReadOnly)) return reject("E_RENDERER_IDENTITY_INVALID"); + const auto arguments = command.read(65537); + if (!isRendererCommandLine(arguments, QFile::encodeName(rendererExecutable_))) return reject("E_RENDERER_IDENTITY_INVALID"); + const int processFd = int(::syscall(SYS_pidfd_open, int(pid), 0)); + if (processFd < 0) return reject("E_SANDBOX_UNAVAILABLE"); + RendererProcessInfo after; + if (!readProcess(pid, &after) || after.startTime != before.startTime || !isDescendant(pid) || !executableMatches()) { + ::close(processFd); return reject("E_RENDERER_IDENTITY_INVALID"); + } + removeSlot(token, slot); + renderers_.insert(key, Renderer{token, pid, after.startTime, processFd}); + return true; +#elif defined(Q_OS_WIN) + if (pid <= 1 || quint64(pid) > std::numeric_limits::max() || pid == parentPid_) return reject("E_RENDERER_IDENTITY_INVALID"); + auto windows = std::make_shared(); + if (!windows->identity.attach(static_cast(pid), static_cast(parentPid_), QDir::toNativeSeparators(rendererExecutable_).toStdWString())) + return reject("E_RENDERER_IDENTITY_INVALID"); + removeSlot(token, slot); + Renderer renderer; renderer.token = token; renderer.pid = pid; renderer.startTime = windows->identity.created; renderer.windows = std::move(windows); + renderers_.insert(key, std::move(renderer)); return true; +#else + Q_UNUSED(pid); + return reject("E_SANDBOX_UNAVAILABLE"); +#endif +} +quint32 RendererWatchdog::beginProbe(const QString &token, int slot) { + if (slot < 0 || slot > 1 || nextProbe_ == std::numeric_limits::max()) return 0; + auto found = renderers_.find(token + ':' + QString::number(slot)); + if (found == renderers_.end() || found->probe) return 0; + found->probe = ++nextProbe_; + found->probeStarted = clock_.elapsed(); + return found->probe; +} +bool RendererWatchdog::acknowledgeProbe(const QString &token, int slot, quint32 probe) { + if (!probe || slot < 0 || slot > 1) return false; + auto found = renderers_.find(token + ':' + QString::number(slot)); + if (found == renderers_.end() || found->probe != probe) return false; + found->probe = 0; + return true; +} +void RendererWatchdog::checkNow() { + const auto now = clock_.elapsed(); + // A suspended host (or suspended GUI event loop) cannot deliver callbacks. + // Resume with a fresh budget instead of treating that pause as renderer work. + if (now - lastCheck_ > 5000) { + for (auto &renderer : renderers_) if (renderer.probe) renderer.probeStarted = now; + } + lastCheck_ = now; + const auto tokens = renderers_.keys(); + for (const auto &key : tokens) { + const auto found = renderers_.constFind(key); + if (found == renderers_.cend()) continue; + const auto renderer = *found; + const auto token = renderer.token; + const int slot = key.endsWith(":1") ? 1 : 0; + const bool timedOut = renderer.probe && now - renderer.probeStarted >= responseTimeoutMs_; +#ifdef Q_OS_WIN + if (!renderer.windows) { removeSession(token); emit monitoringFailed(token, "E_RENDERER_IDENTITY_INVALID"); continue; } + const auto &identity = renderer.windows->identity; + const auto wait = WaitForSingleObject(identity.process.get(), 0); + if (wait == WAIT_OBJECT_0) { removeSlot(token, slot); continue; } + std::uint64_t currentBytes = 0; + if (wait != WAIT_TIMEOUT || !identity.verifyAncestry(static_cast(parentPid_)) || !winrenderer::workingSet(identity.process.get(), ¤tBytes)) { + removeSession(token); emit monitoringFailed(token, "E_RENDERER_IDENTITY_INVALID"); continue; + } + if (currentBytes <= limitBytes_ && !timedOut) continue; + // The retained handle, never a newly opened numeric PID, is terminated. + const bool killed = TerminateProcess(identity.process.get(), 137); + removeSession(token); + if (killed && timedOut) emit responseTimedOut(token, "E_RENDERER_TIMEOUT"); + else if (killed) emit limitExceeded(token, "E_RENDERER_MEMORY_LIMIT"); + else emit monitoringFailed(token, "E_SANDBOX_UNAVAILABLE"); +#else + RendererProcessInfo current; + if (!readProcess(renderer.pid, ¤t) || current.startTime != renderer.startTime) { + removeSlot(token, slot); // exited or PID was reused: never signal the replacement + continue; + } + if (current.residentBytes <= limitBytes_ && !timedOut) continue; + if (!isDescendant(renderer.pid)) { + removeSession(token); emit monitoringFailed(token, "E_RENDERER_IDENTITY_INVALID"); continue; + } +#if defined(Q_OS_LINUX) && defined(SYS_pidfd_send_signal) + // pidfd_send_signal addresses the registered process, never whichever + // process may have acquired its numeric PID between /proc reads. + const bool killed = ::syscall(SYS_pidfd_send_signal, renderer.processFd, SIGKILL, nullptr, 0) == 0; + removeSession(token); + if (killed && timedOut) emit responseTimedOut(token, "E_RENDERER_TIMEOUT"); + else if (killed) emit limitExceeded(token, "E_RENDERER_MEMORY_LIMIT"); + else emit monitoringFailed(token, "E_SANDBOX_UNAVAILABLE"); +#else + removeSession(token); emit monitoringFailed(token, "E_SANDBOX_UNAVAILABLE"); +#endif +#endif + } +} +} diff --git a/src/web/renderer_watchdog.h b/src/web/renderer_watchdog.h new file mode 100644 index 0000000..c863b40 --- /dev/null +++ b/src/web/renderer_watchdog.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace docview { +struct RendererProcessInfo { + qint64 parentPid = -1; + quint64 startTime = 0; + quint64 residentBytes = 0; +}; +bool parseRendererProcStat(const QByteArray &stat, quint64 pageSize, RendererProcessInfo *result); +bool isRendererCommandLine(const QByteArray &command, const QByteArray &executable); + +// Registers only an identified Chromium renderer descended from this process. +// On Linux a pidfd pins the process identity before any termination is allowed. +class RendererWatchdog : public QObject { + Q_OBJECT +public: + static constexpr quint64 DefaultLimitBytes = 1536ull * 1024 * 1024; + static constexpr int DefaultResponseTimeoutMs = 30000; + explicit RendererWatchdog(QObject *parent = nullptr, quint64 limitBytes = DefaultLimitBytes, + const QString &rendererExecutable = QString(), + int responseTimeoutMs = DefaultResponseTimeoutMs); + ~RendererWatchdog() override; + bool registerRenderer(const QString &token, qint64 pid, QString *error = nullptr, int slot = 0); + void removeSession(const QString &token); + void removeSlot(const QString &token, int slot); + // One outstanding probe per retained process slot. IDs never repeat during + // the watchdog lifetime; a late callback cannot acknowledge a replacement. + quint32 beginProbe(const QString &token, int slot); + bool acknowledgeProbe(const QString &token, int slot, quint32 probe); + qsizetype monitoredCount() const { return renderers_.size(); } +public slots: + void checkNow(); +signals: + void limitExceeded(QString token, QString code); + void monitoringFailed(QString token, QString code); + void responseTimedOut(QString token, QString code); +private: +#ifdef Q_OS_WIN + struct WindowsRenderer; +#endif + struct Renderer { + QString token; + qint64 pid = -1; quint64 startTime = 0; int processFd = -1; + quint32 probe = 0; + qint64 probeStarted = 0; +#ifdef Q_OS_WIN + std::shared_ptr windows; +#endif + }; + bool isDescendant(qint64 pid) const; + bool readProcess(qint64 pid, RendererProcessInfo *info) const; + QHash renderers_; + QTimer timer_; + QElapsedTimer clock_; + qint64 lastCheck_ = 0; + quint32 nextProbe_ = 0; + int responseTimeoutMs_; + quint64 limitBytes_; + quint64 pageSize_ = 0; + qint64 parentPid_ = -1; + QString rendererExecutable_; +}; +} diff --git a/src/web/resource_policy.cpp b/src/web/resource_policy.cpp new file mode 100644 index 0000000..2e33f5f --- /dev/null +++ b/src/web/resource_policy.cpp @@ -0,0 +1,291 @@ +#include "resource_policy.h" +#include +#include +#include +#ifdef Q_OS_WIN +#include "windows_resource_handles.h" +#include +#include +#endif +#ifdef Q_OS_UNIX +#include +#include +#include +#endif +namespace docview { +QString resourceFailureCode(ResourceFailure failure) { + switch (failure) { + case ResourceFailure::None: case ResourceFailure::Cancelled: return {}; + case ResourceFailure::Missing: return "E_RESOURCE_MISSING"; + case ResourceFailure::Blocked: case ResourceFailure::AccessDenied: case ResourceFailure::UnsupportedType: return "E_RESOURCE_BLOCKED"; + case ResourceFailure::ResourceLimit: return "E_RESOURCE_LIMIT"; + case ResourceFailure::ArchiveLimit: return "E_ARCHIVE_LIMIT"; + case ResourceFailure::ArchiveCorrupt: return "E_ARCHIVE_CORRUPT"; + case ResourceFailure::StorageFull: return "E_STORAGE_FULL"; + case ResourceFailure::StorageFailed: return "E_STORAGE_FAILED"; + case ResourceFailure::ReadFailed: return "E_RESOURCE_IO"; + case ResourceFailure::WorkerFailed: return "E_WORKER_CRASH"; + case ResourceFailure::WorkerTimeout: return "E_WORKER_TIMEOUT"; + } + return "E_RESOURCE_IO"; +} +QString resourceFailureKey(ResourceFailure failure) { + switch (failure) { + case ResourceFailure::None: case ResourceFailure::Cancelled: return {}; + case ResourceFailure::Missing: return "broker.missing"; + case ResourceFailure::Blocked: return "broker.blocked"; + case ResourceFailure::AccessDenied: return "broker.denied"; + case ResourceFailure::UnsupportedType: return "broker.type"; + case ResourceFailure::ResourceLimit: return "broker.limit"; + case ResourceFailure::ArchiveLimit: return "broker.archive_limit"; + case ResourceFailure::ArchiveCorrupt: return "broker.corrupt"; + case ResourceFailure::StorageFull: return "broker.storage_full"; + case ResourceFailure::StorageFailed: return "broker.storage_failed"; + case ResourceFailure::ReadFailed: return "broker.io"; + case ResourceFailure::WorkerFailed: return "broker.worker_failed"; + case ResourceFailure::WorkerTimeout: return "broker.worker_timeout"; + } + return "broker.io"; +} +namespace { +ResourceFailure ioFailure(int code, bool writing = false) { + switch (code) { + case ENOENT: return writing ? ResourceFailure::StorageFailed : ResourceFailure::Missing; + case EACCES: case EPERM: return ResourceFailure::AccessDenied; + case ELOOP: case ENOTDIR: case EISDIR: case ENAMETOOLONG: return ResourceFailure::Blocked; + case EFBIG: return ResourceFailure::ResourceLimit; + case ENOSPC: return writing ? ResourceFailure::StorageFull : ResourceFailure::ReadFailed; +#ifdef EDQUOT + case EDQUOT: return writing ? ResourceFailure::StorageFull : ResourceFailure::ReadFailed; +#endif + default: return writing ? ResourceFailure::StorageFailed : ResourceFailure::ReadFailed; + } +} +#ifdef Q_OS_WIN +ResourceFailure windowsFailure(winresource::Failure failure, bool writing = false) { + switch (failure) { + case winresource::Failure::Missing: return writing ? ResourceFailure::StorageFailed : ResourceFailure::Missing; + case winresource::Failure::Denied: return ResourceFailure::AccessDenied; + case winresource::Failure::Blocked: return ResourceFailure::Blocked; + case winresource::Failure::Limit: return ResourceFailure::ResourceLimit; + case winresource::Failure::Full: return writing ? ResourceFailure::StorageFull : ResourceFailure::ReadFailed; + default: return writing ? ResourceFailure::StorageFailed : ResourceFailure::ReadFailed; + } +} +#endif +} +qint64 ResourceFile::readData(char *data, qint64 maxSize) { + errno = 0; + const auto count = QFile::readData(data, maxSize); + if (count < 0 && !reported_.exchange(true)) { + const auto failure = error() == QFileDevice::PermissionsError ? ResourceFailure::AccessDenied : ioFailure(errno); + // A read error on an already opened handle is never an absent path. + emit readFailed(failure == ResourceFailure::Missing ? ResourceFailure::ReadFailed : failure); + } + return count; +} +bool safeResourcePath(const QString &p) { + if (p.isEmpty() || p.toUtf8().size() > 1024 || p.startsWith('/') || p.contains('\\') || p.contains(':') || + p.contains(QChar(0)) || p.contains(QChar::ReplacementCharacter)) + return false; + const auto parts = p.split('/'); + if (parts.size() > 64) + return false; + static const QRegularExpression reserved("^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\\.|$)", + QRegularExpression::CaseInsensitiveOption); + for (const auto &part : parts) { + if (part.isEmpty() || part == "." || part == ".." || part.endsWith('.') || part.endsWith(' ') || + reserved.match(part).hasMatch()) + return false; + for (auto c : part) + if (c.unicode() < 32 || c.unicode() == 127 || QStringLiteral("<>?*|\"").contains(c)) + return false; + } + return true; +} +QString pathFromDocumentUrl(const QUrl &u, const QString &t) { + if (!u.isValid() || u.scheme() != "doc" || u.host() != t || !u.userInfo().isEmpty() || u.port() != -1) + return {}; + const auto encoded = u.path(QUrl::FullyEncoded); + static const QRegularExpression bad("%(?:2f|5c|00|25(?:2e|2f|5c|00|25))", + QRegularExpression::CaseInsensitiveOption); + if (bad.match(encoded).hasMatch()) + return {}; + QString p = u.path(QUrl::FullyDecoded); + if (p.startsWith('/')) + p.remove(0, 1); + return safeResourcePath(p) ? p : QString{}; +} +std::unique_ptr openResource(const QString &root, const QString &relative, ResourceFailure *error) { + if (error) *error = ResourceFailure::None; + const auto fail = [&](ResourceFailure failure) { + if (error) *error = failure; + return std::unique_ptr{}; + }; + if (!safeResourcePath(relative)) return fail(ResourceFailure::Blocked); +#ifdef Q_OS_UNIX + int fd = ::open(QFile::encodeName(root).constData(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) return fail(ioFailure(errno)); + const auto parts = relative.split('/'); + for (qsizetype i = 0; i < parts.size(); ++i) { + const bool last = i + 1 == parts.size(); + const int next = ::openat(fd, QFile::encodeName(parts[i]).constData(), + O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | (last ? 0 : O_DIRECTORY)); + const auto failure = next < 0 ? ioFailure(errno) : ResourceFailure::None; + ::close(fd); fd = next; + if (next < 0) return fail(failure); + } + struct stat st{}; + if (::fstat(fd, &st)) { const auto failure = ioFailure(errno); ::close(fd); return fail(failure); } + if (!S_ISREG(st.st_mode) || st.st_nlink != 1) { ::close(fd); return fail(ResourceFailure::Blocked); } + if (st.st_size < 0 || st.st_size > 256ll * 1024 * 1024) { ::close(fd); return fail(ResourceFailure::ResourceLimit); } + auto file = std::make_unique(); + if (!file->open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); return fail(ResourceFailure::ReadFailed); + } + return file; +#elif defined(Q_OS_WIN) + winresource::Failure why{}; + winresource::DirectoryChain directories; + if (!directories.openRoot(QDir::fromNativeSeparators(root).toStdWString(), &why)) return fail(windowsFailure(why)); + const auto parts = relative.split('/'); + for (qsizetype i = 0; i + 1 < parts.size(); ++i) + if (!directories.append(parts[i].toStdWString(), false, &why)) return fail(windowsFailure(why)); + auto handle = winresource::child(directories.last(), parts.last().toStdWString(), false, false, false, &why); + if (!handle.valid()) return fail(windowsFailure(why)); + const int fd = _open_osfhandle(reinterpret_cast(handle.get()), _O_RDONLY | _O_BINARY); + if (fd < 0) return fail(ResourceFailure::ReadFailed); + handle.release(); // CRT descriptor, then QFile, owns the native handle. + auto file = std::make_unique(); + if (!file->open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { _close(fd); return fail(ResourceFailure::ReadFailed); } + return file; +#else + Q_UNUSED(root); + return fail(ResourceFailure::Blocked); +#endif +} +QByteArray resourceMime(const QString &p) { + const QString e = p.section('.', -1).toLower(); + static const QHash m = { + {"html", "text/html"}, {"htm", "text/html"}, {"xhtml", "application/xhtml+xml"}, + {"css", "text/css"}, {"svg", "image/svg+xml"}, {"png", "image/png"}, + {"jpg", "image/jpeg"}, {"jpeg", "image/jpeg"}, {"gif", "image/gif"}, + {"webp", "image/webp"}, {"avif", "image/avif"}, {"woff", "font/woff"}, + {"woff2", "font/woff2"}, {"ttf", "font/ttf"}, {"otf", "font/otf"}, + {"txt", "text/plain"}}; + return m.value(e, "application/octet-stream"); +} +} // namespace docview + +#include +namespace docview { +#ifdef Q_OS_WIN +struct ResourceWriter::WindowsState { + winresource::DirectoryChain directories; + std::wstring name; +}; +#endif +ResourceWriter::ResourceWriter(const QString &root, const QString &relative) { + if (!safeResourcePath(relative)) { failure_ = ResourceFailure::Blocked; return; } +#ifdef Q_OS_UNIX + directory_ = ::open(QFile::encodeName(root).constData(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory_ < 0) { failure_ = ioFailure(errno, true); return; } + const auto parts = relative.split('/'); + for (int i = 0; i + 1 < parts.size(); ++i) { + const auto name = QFile::encodeName(parts[i]); + int next = ::openat(directory_, name.constData(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0 && errno == ENOENT && ::mkdirat(directory_, name.constData(), 0700) == 0) + next = ::openat(directory_, name.constData(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + const auto failure = next < 0 ? ioFailure(errno, true) : ResourceFailure::None; + ::close(directory_); directory_ = next; + if (next < 0) { failure_ = failure; return; } + } + name_ = QFile::encodeName(parts.last()); + temporary_ = ".docview-" + QUuid::createUuid().toByteArray(QUuid::Id128) + ".part"; + int fd = ::openat(directory_, temporary_.constData(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) { failure_ = ioFailure(errno, true); return; } + if (!file_.open(fd, QIODevice::WriteOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); failure_ = ResourceFailure::StorageFailed; + } +#elif defined(Q_OS_WIN) + winresource::Failure why{}; + windows_ = std::make_unique(); + if (!windows_->directories.openRoot(QDir::fromNativeSeparators(root).toStdWString(), &why)) { failure_ = windowsFailure(why, true); return; } + const auto parts = relative.split('/'); + for (qsizetype i = 0; i + 1 < parts.size(); ++i) + if (!windows_->directories.append(parts[i].toStdWString(), true, &why)) { failure_ = windowsFailure(why, true); return; } + windows_->name = parts.last().toStdWString(); + const auto temporary = (".docview-" + QUuid::createUuid().toString(QUuid::Id128) + ".part").toStdWString(); + auto handle = winresource::child(windows_->directories.last(), temporary, false, true, true, &why); + if (!handle.valid()) { failure_ = windowsFailure(why, true); return; } + const int fd = _open_osfhandle(reinterpret_cast(handle.get()), _O_WRONLY | _O_BINARY); + if (fd < 0) { winresource::removeOnClose(handle.get()); failure_ = ResourceFailure::StorageFailed; return; } + handle.release(); + if (!file_.open(fd, QIODevice::WriteOnly, QFileDevice::AutoCloseHandle)) { + winresource::removeOnClose(reinterpret_cast(_get_osfhandle(fd))); _close(fd); + failure_ = ResourceFailure::StorageFailed; + } +#else + Q_UNUSED(root); failure_ = ResourceFailure::Blocked; +#endif +} +ResourceWriter::~ResourceWriter() { +#ifdef Q_OS_WIN + if (!committed_ && file_.isOpen()) + winresource::removeOnClose(reinterpret_cast(_get_osfhandle(file_.handle()))); +#endif + file_.close(); +#ifdef Q_OS_UNIX + if (directory_ >= 0) { + if (!committed_ && !temporary_.isEmpty()) + ::unlinkat(directory_, temporary_.constData(), 0); + ::close(directory_); + } +#endif +} +bool ResourceWriter::isValid() const { + return file_.isOpen(); +} +bool ResourceWriter::write(const QByteArray &data) { + if (failure_ != ResourceFailure::None) return false; + if (!isValid()) { failure_ = ResourceFailure::StorageFailed; return false; } + if (data.size() > 65536 || size_ + data.size() > 256ll * 1024 * 1024) { + failure_ = ResourceFailure::ResourceLimit; return false; + } + errno = 0; + if (file_.write(data) != data.size()) { failure_ = ioFailure(errno, true); return false; } + size_ += data.size(); return true; +} +bool ResourceWriter::commit() { + if (failure_ != ResourceFailure::None) return false; + if (!isValid()) { failure_ = ResourceFailure::StorageFailed; return false; } + errno = 0; + if (!file_.flush()) { failure_ = ioFailure(errno, true); return false; } +#ifdef Q_OS_UNIX + if (::fsync(file_.handle()) != 0) { failure_ = ioFailure(errno, true); return false; } + file_.close(); + struct stat existing{}; + const int present = ::fstatat(directory_, name_.constData(), &existing, AT_SYMLINK_NOFOLLOW); + if (present == 0 && (!S_ISREG(existing.st_mode) || existing.st_nlink != 1)) { + failure_ = ResourceFailure::Blocked; return false; + } + if (present < 0 && errno != ENOENT) { failure_ = ioFailure(errno, true); return false; } + if (::renameat(directory_, temporary_.constData(), directory_, name_.constData()) != 0) { + failure_ = ioFailure(errno, true); return false; + } + committed_ = true; return true; +#elif defined(Q_OS_WIN) + if (!windows_) { failure_ = ResourceFailure::StorageFailed; return false; } + winresource::Failure why{}; + const auto handle = reinterpret_cast(_get_osfhandle(file_.handle())); + if (!winresource::inspect(handle, false, nullptr, &why)) { failure_ = windowsFailure(why, true); return false; } + if (!FlushFileBuffers(handle)) { failure_ = windowsFailure(winresource::windowsFailure(GetLastError()), true); return false; } + if (!winresource::publishNew(handle, windows_->directories.last(), windows_->name, &why)) { + failure_ = windowsFailure(why, true); return false; + } + committed_ = true; file_.close(); return true; +#else + failure_ = ResourceFailure::Blocked; return false; +#endif +} +} // namespace docview diff --git a/src/web/resource_policy.h b/src/web/resource_policy.h new file mode 100644 index 0000000..a3196b9 --- /dev/null +++ b/src/web/resource_policy.h @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +namespace docview { +// Internal, finite outcomes: raw OS/worker messages never cross into the UI. +enum class ResourceFailure { + None, Cancelled, Missing, Blocked, AccessDenied, UnsupportedType, + ResourceLimit, ArchiveLimit, ArchiveCorrupt, StorageFull, StorageFailed, + ReadFailed, WorkerFailed, WorkerTimeout +}; +QString resourceFailureCode(ResourceFailure failure); +QString resourceFailureKey(ResourceFailure failure); +class ResourceFile : public QFile { + Q_OBJECT + public: + using QFile::QFile; + signals: + void readFailed(docview::ResourceFailure failure); + protected: + qint64 readData(char *data, qint64 maxSize) override; + private: + std::atomic reported_{false}; +}; +bool safeResourcePath(const QString &path); +QString pathFromDocumentUrl(const QUrl &url, const QString &token); +std::unique_ptr openResource(const QString &root, const QString &relative, ResourceFailure *error = nullptr); +QByteArray resourceMime(const QString &path); +} // namespace docview +namespace docview { +class ResourceWriter { + public: + ResourceWriter(const QString &root, const QString &relative); + ~ResourceWriter(); + bool isValid() const; + bool write(const QByteArray &data); + bool commit(); + ResourceFailure failure() const { return failure_; } + qint64 size() const { + return size_; + } + + private: + QFile file_; + int directory_ = -1; + QByteArray temporary_, name_; + qint64 size_ = 0; + bool committed_ = false; + ResourceFailure failure_ = ResourceFailure::None; +#ifdef Q_OS_WIN + struct WindowsState; + std::unique_ptr windows_; +#endif +}; +} // namespace docview +Q_DECLARE_METATYPE(docview::ResourceFailure) diff --git a/src/web/web_profile.cpp b/src/web/web_profile.cpp new file mode 100644 index 0000000..e0dc958 --- /dev/null +++ b/src/web/web_profile.cpp @@ -0,0 +1,164 @@ +#include "web_profile.h" +#include "resource_policy.h" +#include +#include +#include +#include +#include +#include +#include +namespace docview { +void registerDocumentScheme() { + QWebEngineUrlScheme scheme("doc"); + scheme.setSyntax(QWebEngineUrlScheme::Syntax::Host); + scheme.setFlags(QWebEngineUrlScheme::SecureScheme); + QWebEngineUrlScheme::registerScheme(scheme); +} +ResourceHandler::ResourceHandler(QString token, QString root, QSet entries, Extract extract, + QObject *p) + : QWebEngineUrlSchemeHandler(p), token_(std::move(token)), root_(std::move(root)), + entries_(std::move(entries)), extract_(std::move(extract)) {} +void ResourceHandler::authorizeTopLevel(const QUrl &url) { + auto p = pathFromDocumentUrl(url, token_); + if (!p.isEmpty()) + topLevels_.insert(p); +} +void ResourceHandler::reportNetworkBlocked(int count) { + if (!revoked_ && count > 0) + emit blocked("E_RESOURCE_BLOCKED", "network", qMin(count, 999999)); +} +void ResourceHandler::reject(QWebEngineUrlRequestJob *job, ResourceFailure failure) { + auto code = QWebEngineUrlRequestJob::RequestFailed; + if (failure == ResourceFailure::Missing) code = QWebEngineUrlRequestJob::UrlNotFound; + else if (failure == ResourceFailure::Cancelled) code = QWebEngineUrlRequestJob::RequestAborted; + else if (failure == ResourceFailure::Blocked || failure == ResourceFailure::AccessDenied || failure == ResourceFailure::UnsupportedType) + code = QWebEngineUrlRequestJob::RequestDenied; + job->fail(code); + if (!revoked_ && failure != ResourceFailure::Cancelled) + emit blocked(resourceFailureCode(failure), resourceFailureKey(failure), 1); +} +void ResourceHandler::requestStarted(QWebEngineUrlRequestJob *j) { + const QString path = pathFromDocumentUrl(j->requestUrl(), token_); + const QUrl origin = j->initiator(); + const bool validOrigin = (origin.scheme() == "doc" && origin.host() == token_ && origin.port() == -1 && + origin.userInfo().isEmpty()) || + (origin.isEmpty() && topLevels_.contains(path)); + if (revoked_ || path.isEmpty() || !validOrigin || j->requestMethod() != "GET") { + reject(j, revoked_ ? ResourceFailure::Cancelled : ResourceFailure::Blocked); + return; + } + if (!entries_.isEmpty() && !entries_.contains(path)) { + reject(j, ResourceFailure::Missing); + return; + } + if (extract_) { + QPointer weak(j); + extract_(path, [this, weak, path](ResourceFailure failure) { + if (!weak) + return; + if (failure != ResourceFailure::None) { + reject(weak, failure); + return; + } + serve(weak, path); + }); + } else + serve(j, path); +} +void ResourceHandler::serve(QWebEngineUrlRequestJob *j, const QString &p) { + if (revoked_) { + j->fail(QWebEngineUrlRequestJob::RequestAborted); + return; + } + ResourceFailure failure; + auto f = openResource(root_, p, &failure); + if (!f) { reject(j, failure); return; } + if (auto *resource = qobject_cast(f.get())) + connect(resource, &ResourceFile::readFailed, this, [this](ResourceFailure why) { + if (!revoked_) emit blocked(resourceFailureCode(why), resourceFailureKey(why), 1); + }, Qt::QueuedConnection); + const QByteArray mime = mimeTypes_.value(p, resourceMime(p)); + const QList allowed = {"text/html", + "application/xhtml+xml", + "text/css", + "image/svg+xml", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", + "font/woff", + "font/woff2", + "font/ttf", + "font/otf", + "application/font-sfnt", + "application/vnd.ms-opentype", + "application/font-woff", + "text/plain"}; + if (!allowed.contains(mime)) { + reject(j, ResourceFailure::UnsupportedType); + return; + } + j->setAdditionalResponseHeaders( + {{"Content-Security-Policy", + "default-src 'none'; script-src 'none'; connect-src 'none'; object-src 'none'; frame-src 'none'; " + "child-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'self'; img-src 'self' data:; " + "style-src 'self' 'unsafe-inline'; font-src 'self'"}, + {"X-Content-Type-Options", "nosniff"}, + {"Cache-Control", "no-store"}}); + f->setParent(j); + j->reply(mime, f.release()); +} +DocumentInterceptor::DocumentInterceptor(QString token, QObject *parent) + : QWebEngineUrlRequestInterceptor(parent), token_(std::move(token)) { + auto *timer = new QTimer(this); + timer->setInterval(250); + connect(timer, &QTimer::timeout, this, [this] { + const int count = pendingBlocked_.exchange(0, std::memory_order_relaxed); + if (count) emit blockedRequests(count); + }); + timer->start(); +} +void DocumentInterceptor::interceptRequest(QWebEngineUrlRequestInfo &i) { + const auto u = i.requestUrl(); + const auto origin = i.firstPartyUrl(); + const bool same = origin.isEmpty() || (origin.scheme() == "doc" && origin.host() == token_); + bool allow = same && i.requestMethod() == "GET" && !pathFromDocumentUrl(u, token_).isEmpty(); + if (u.scheme() == "data" && i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeImage && same) + allow = true; + if (i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeSubFrame || + i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeScript || + i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeXhr || + i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypePing || + i.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeServiceWorker) + allow = false; + i.block(!allow); + if (!allow) { + // Chromium waits here: no profile calls, UI, per-resource queued + // signals, or captured URLs on the interception thread. + auto count = pendingBlocked_.load(std::memory_order_relaxed); + while (count < 999999 && !pendingBlocked_.compare_exchange_weak( + count, count + 1, std::memory_order_relaxed)) {} + } +} +QQuickWebEngineProfile *createDocumentProfile(const QString &token, ResourceHandler *h, QObject *p) { + auto profile = new QQuickWebEngineProfile(p); + profile->setOffTheRecord(true); + profile->setHttpCacheType(QQuickWebEngineProfile::MemoryHttpCache); + profile->setHttpCacheMaximumSize(32 * 1024 * 1024); + profile->setPersistentCookiesPolicy(QQuickWebEngineProfile::NoPersistentCookies); + profile->setSpellCheckEnabled(false); + profile->setPushServiceEnabled(false); + auto *interceptor = new DocumentInterceptor(token, profile); + QObject::connect(interceptor, &DocumentInterceptor::blockedRequests, + h, &ResourceHandler::reportNetworkBlocked); + profile->setUrlRequestInterceptor(interceptor); + h->setParent(profile); + profile->installUrlSchemeHandler("doc", h); + profile->cookieStore()->setCookieFilter( + [](const QWebEngineCookieStore::FilterRequest &) { return false; }); + QObject::connect(profile, &QQuickWebEngineProfile::downloadRequested, profile, + [](QQuickWebEngineDownloadRequest *d) { d->cancel(); }); + return profile; +} +} // namespace docview diff --git a/src/web/web_profile.h b/src/web/web_profile.h new file mode 100644 index 0000000..48ae0cd --- /dev/null +++ b/src/web/web_profile.h @@ -0,0 +1,54 @@ +#pragma once +#include "resource_policy.h" +#include +#include +#include +#include +#include +#include +#include +namespace docview { +class ResourceHandler : public QWebEngineUrlSchemeHandler { + Q_OBJECT + public: + using Completion = std::function; + using Extract = std::function; + ResourceHandler(QString token, QString root, QSet entries, Extract extract, + QObject *parent = nullptr); + void requestStarted(QWebEngineUrlRequestJob *job) override; + void setMimeTypes(const QHash &m) { + mimeTypes_ = m; + } + void authorizeTopLevel(const QUrl &url); + void reportNetworkBlocked(int count); + void revoke() { + revoked_ = true; + } + signals: + void blocked(QString code, QString category, int count); + + private: + void reject(QWebEngineUrlRequestJob *job, ResourceFailure failure); + void serve(QWebEngineUrlRequestJob *job, const QString &path); + QString token_, root_; + QSet entries_, topLevels_; + Extract extract_; + QHash mimeTypes_; + bool revoked_ = false; +}; +class DocumentInterceptor : public QWebEngineUrlRequestInterceptor { + Q_OBJECT + public: + DocumentInterceptor(QString token, QObject *p = nullptr); + void interceptRequest(QWebEngineUrlRequestInfo &info) override; + signals: + void blockedRequests(int count); + + private: + QString token_; + std::atomic pendingBlocked_{0}; +}; +QQuickWebEngineProfile *createDocumentProfile(const QString &token, ResourceHandler *handler, + QObject *parent); +void registerDocumentScheme(); +} // namespace docview diff --git a/src/web/windows_renderer_identity.h b/src/web/windows_renderer_identity.h new file mode 100644 index 0000000..2ab9376 --- /dev/null +++ b/src/web/windows_renderer_identity.h @@ -0,0 +1,113 @@ +#pragma once +#ifdef _WIN32 +#include "windows_resource_handles.h" +#include +#include +#include +#include +#include + +namespace docview::winrenderer { +using winresource::Handle; +struct BasicInformation { + NTSTATUS exitStatus; + PPEB peb; + ULONG_PTR affinityMask; + LONG basePriority; + ULONG_PTR pid; + ULONG_PTR parent; +}; +inline bool basic(HANDLE process, BasicInformation *value) { + // This layout and parent field are documented by Microsoft. Resolve at + // runtime and fail closed if this version of Windows cannot provide it. + // https://learn.microsoft.com/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess + using Query = NTSTATUS(NTAPI *)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG); + static const auto query = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQueryInformationProcess")); + ULONG length = 0; + return query && query(process, static_cast(0), value, sizeof(*value), &length) >= 0 && + length == sizeof(*value) && value->pid == GetProcessId(process) && value->pid && + value->parent <= std::numeric_limits::max(); +} +inline bool creationTime(HANDLE process, std::uint64_t *value) { + FILETIME start{}, end{}, kernel{}, user{}; + if (!GetProcessTimes(process, &start, &end, &kernel, &user)) return false; + *value = (std::uint64_t(start.dwHighDateTime) << 32) | start.dwLowDateTime; + return *value != 0; +} +inline bool workingSet(HANDLE process, std::uint64_t *bytes) { + PROCESS_MEMORY_COUNTERS counters{}; counters.cb = sizeof(counters); + if (!GetProcessMemoryInfo(process, &counters, sizeof(counters))) return false; + *bytes = counters.WorkingSetSize; return true; +} +inline bool hasRendererArgument(HANDLE process) { + // Cross-bitness PEB access is deliberately unsupported, never guessed. + using Wow64 = BOOL(WINAPI *)(HANDLE, USHORT *, USHORT *); + static const auto wow64 = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "IsWow64Process2")); + USHORT ours = 0, nativeOurs = 0, theirs = 0, nativeTheirs = 0; + if (!wow64 || !wow64(GetCurrentProcess(), &ours, &nativeOurs) || !wow64(process, &theirs, &nativeTheirs) || ours != theirs || nativeOurs != nativeTheirs) return false; + BasicInformation info{}; if (!basic(process, &info) || !info.peb) return false; + PEB peb{}; SIZE_T read = 0; + const SIZE_T pebBytes = offsetof(PEB, ProcessParameters) + sizeof(peb.ProcessParameters); + if (!ReadProcessMemory(process, info.peb, &peb, pebBytes, &read) || read != pebBytes || !peb.ProcessParameters) return false; + RTL_USER_PROCESS_PARAMETERS parameters{}; + const SIZE_T parameterBytes = offsetof(RTL_USER_PROCESS_PARAMETERS, CommandLine) + sizeof(parameters.CommandLine); + if (!ReadProcessMemory(process, peb.ProcessParameters, ¶meters, parameterBytes, &read) || read != parameterBytes) return false; + const auto command = parameters.CommandLine; + if (!command.Buffer || !command.Length || command.Length % sizeof(wchar_t) || command.Length > command.MaximumLength) return false; + std::vector buffer(command.Length / sizeof(wchar_t) + 1, 0); + if (!ReadProcessMemory(process, command.Buffer, buffer.data(), command.Length, &read) || read != command.Length) return false; + if (std::wstring(buffer.data()).size() * sizeof(wchar_t) != command.Length) return false; + int argc = 0; auto argv = CommandLineToArgvW(buffer.data(), &argc); + if (!argv) return false; + bool renderer = false; int types = 0; + for (int i = 1; i < argc; ++i) { + const std::wstring argument(argv[i]); + if (argument.rfind(L"--type=", 0) == 0) { ++types; renderer = argument == L"--type=renderer"; } + } + LocalFree(argv); return renderer && types == 1; +} +inline bool sameImage(HANDLE process, const std::wstring &expected) { + std::vector path(32768, 0); DWORD length = static_cast(path.size()); + if (!QueryFullProcessImageNameW(process, 0, path.data(), &length) || !length || length >= path.size()) return false; + Handle image(CreateFileW(path.data(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + Handle allowed(CreateFileW(expected.c_str(), FILE_READ_ATTRIBUTES, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + BY_HANDLE_FILE_INFORMATION a{}, b{}; + if (!image.valid() || !allowed.valid() || !winresource::inspect(image.get(), false, &a) || !winresource::inspect(allowed.get(), false, &b)) return false; + return a.dwVolumeSerialNumber == b.dwVolumeSerialNumber && a.nFileIndexHigh == b.nFileIndexHigh && a.nFileIndexLow == b.nFileIndexLow; +} +struct Identity { + Handle process; + std::uint64_t created = 0; + std::vector ancestors; + bool verifyAncestry(DWORD root) const { + BasicInformation child{}; std::uint64_t childTime = 0; + if (!process.valid() || !basic(process.get(), &child) || !creationTime(process.get(), &childTime) || childTime != created) return false; + for (const auto &parent : ancestors) { + BasicInformation info{}; std::uint64_t parentTime = 0; + if (!basic(parent.get(), &info) || !creationTime(parent.get(), &parentTime) || + child.parent != info.pid || parentTime > childTime) return false; + child = info; childTime = parentTime; + } + std::uint64_t rootTime = 0; + return child.pid == root && creationTime(GetCurrentProcess(), &rootTime) && childTime == rootTime; + } + bool attach(DWORD pid, DWORD root, const std::wstring &expectedImage) { + if (!pid || pid == root) return false; + process = Handle(OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid)); + if (!process.valid() || WaitForSingleObject(process.get(), 0) != WAIT_TIMEOUT || + !creationTime(process.get(), &created)) return false; + BasicInformation child{}; if (!basic(process.get(), &child)) return false; + std::set seen{pid}; + for (unsigned depth = 0; child.pid != root && depth < 128; ++depth) { + const DWORD parentPid = static_cast(child.parent); + if (!parentPid || !seen.insert(parentPid).second) return false; + Handle parent(OpenProcess(PROCESS_QUERY_INFORMATION | SYNCHRONIZE, FALSE, parentPid)); + if (!parent.valid() || !basic(parent.get(), &child)) return false; + ancestors.push_back(std::move(parent)); + } + return verifyAncestry(root) && sameImage(process.get(), expectedImage) && hasRendererArgument(process.get()) && + WaitForSingleObject(process.get(), 0) == WAIT_TIMEOUT; + } +}; +} +#endif diff --git a/src/web/windows_resource_handles.h b/src/web/windows_resource_handles.h new file mode 100644 index 0000000..f666b47 --- /dev/null +++ b/src/web/windows_resource_handles.h @@ -0,0 +1,165 @@ +#pragma once + +// Native, handle-relative file operations. No untrusted path is reopened by name. +// NtCreateFile's RootDirectory and FILE_OPEN_REPARSE_POINT semantics: +// https://learn.microsoft.com/windows/win32/api/winternl/nf-winternl-ntcreatefile +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include +#include + +namespace docview::winresource { +enum class Failure { None, Missing, Denied, Blocked, Limit, Full, Io }; +inline Failure windowsFailure(DWORD code) { + switch (code) { + case ERROR_FILE_NOT_FOUND: case ERROR_PATH_NOT_FOUND: return Failure::Missing; + case ERROR_ACCESS_DENIED: case ERROR_PRIVILEGE_NOT_HELD: + case ERROR_SHARING_VIOLATION: case ERROR_LOCK_VIOLATION: return Failure::Denied; + case ERROR_DIRECTORY: case ERROR_INVALID_NAME: case ERROR_CANT_ACCESS_FILE: + case ERROR_REPARSE_TAG_INVALID: case ERROR_REPARSE_TAG_MISMATCH: return Failure::Blocked; + case ERROR_FILE_TOO_LARGE: return Failure::Limit; + case ERROR_DISK_FULL: case ERROR_HANDLE_DISK_FULL: return Failure::Full; + default: return Failure::Io; + } +} +inline void setFailure(Failure *out, Failure value) { if (out) *out = value; } +inline Failure ntFailure(NTSTATUS status) { + using Convert = ULONG(WINAPI *)(NTSTATUS); + static const auto convert = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlNtStatusToDosError")); + return convert ? windowsFailure(convert(status)) : Failure::Io; +} +inline bool safeComponent(const std::wstring &name) { + if (name.empty() || name.size() > 512 || name == L"." || name == L".." || name.back() == L'.' || name.back() == L' ') return false; + for (const auto c : name) if (c < 32 || c == 127 || std::wstring(L"\\/:<>?*|\"").find(c) != std::wstring::npos) return false; + auto base = name.substr(0, name.find(L'.')); + for (auto &c : base) c = static_cast(std::towupper(c)); + if (base == L"CON" || base == L"PRN" || base == L"AUX" || base == L"NUL") return false; + if (base.size() == 4 && (base.substr(0, 3) == L"COM" || base.substr(0, 3) == L"LPT") && + ((base[3] >= L'1' && base[3] <= L'9') || base[3] == L'\u00b9' || base[3] == L'\u00b2' || base[3] == L'\u00b3')) return false; + return true; +} +class Handle { +public: + explicit Handle(HANDLE value = INVALID_HANDLE_VALUE) : value_(value) {} + ~Handle() { if (valid()) CloseHandle(value_); } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + Handle(Handle &&other) noexcept : value_(other.release()) {} + Handle &operator=(Handle &&other) noexcept { + if (this != &other) { if (valid()) CloseHandle(value_); value_ = other.release(); } + return *this; + } + bool valid() const { return value_ != nullptr && value_ != INVALID_HANDLE_VALUE; } + HANDLE get() const { return value_; } + HANDLE release() { const auto value = value_; value_ = INVALID_HANDLE_VALUE; return value; } +private: + HANDLE value_; +}; + +inline bool inspect(HANDLE handle, bool directory, BY_HANDLE_FILE_INFORMATION *information = nullptr, Failure *failure = nullptr) { + BY_HANDLE_FILE_INFORMATION info{}; + if (GetFileType(handle) != FILE_TYPE_DISK) { setFailure(failure, Failure::Blocked); return false; } + if (!GetFileInformationByHandle(handle, &info)) { setFailure(failure, windowsFailure(GetLastError())); return false; } + if ((info.dwFileAttributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE)) || + bool(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != directory || (!directory && info.nNumberOfLinks != 1)) { + setFailure(failure, Failure::Blocked); return false; + } + if (!directory && (info.nFileSizeHigh || info.nFileSizeLow > 256u * 1024u * 1024u)) { + setFailure(failure, Failure::Limit); return false; + } + if (information) *information = info; + return true; +} + +inline Handle child(HANDLE parent, const std::wstring &name, bool directory, bool create, bool write = false, Failure *failure = nullptr) { + if (!safeComponent(name)) { setFailure(failure, Failure::Blocked); return Handle(); } + using Create = NTSTATUS(NTAPI *)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, PIO_STATUS_BLOCK, + PLARGE_INTEGER, ULONG, ULONG, ULONG, ULONG, PVOID, ULONG); + static const auto createFile = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtCreateFile")); + if (!createFile) { setFailure(failure, Failure::Io); return Handle(); } + UNICODE_STRING string{}; + string.Buffer = const_cast(name.data()); + string.Length = string.MaximumLength = static_cast(name.size() * sizeof(wchar_t)); + OBJECT_ATTRIBUTES attributes{}; + attributes.Length = sizeof(attributes); attributes.RootDirectory = parent; + attributes.ObjectName = &string; attributes.Attributes = 0x40; // OBJ_CASE_INSENSITIVE + IO_STATUS_BLOCK status{}; HANDLE result = INVALID_HANDLE_VALUE; + // FILE_OPEN/FILE_CREATE/FILE_OPEN_IF; synchronous file-position semantics. + const ULONG disposition = create ? (directory ? 3u : 2u) : 1u; + const ULONG options = 0x00200000u | 0x20u | (directory ? 1u : 0x40u); + const ACCESS_MASK access = directory ? FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE + : (write ? FILE_WRITE_DATA | FILE_READ_ATTRIBUTES | DELETE | SYNCHRONIZE : FILE_GENERIC_READ); + // No share-write/delete on directories: prevent reparse mutation and swaps. + // Read files also exclude concurrent write/delete; temporary files are exclusive. + const ULONG sharing = write ? 0u : FILE_SHARE_READ; + const auto code = createFile(&result, access, &attributes, &status, nullptr, FILE_ATTRIBUTE_NORMAL, + sharing, disposition, options, nullptr, 0); + if (code < 0) { setFailure(failure, ntFailure(code)); return Handle(); } + Handle handle(result); + return inspect(result, directory, nullptr, failure) ? std::move(handle) : Handle(); +} + +struct DirectoryChain { + std::vector handles; + HANDLE last() const { return handles.empty() ? INVALID_HANDLE_VALUE : handles.back().get(); } + bool append(const std::wstring &component, bool create, Failure *failure = nullptr) { + auto next = child(last(), component, true, create, false, failure); + if (!next.valid()) return false; + handles.push_back(std::move(next)); return true; + } + bool openRoot(const std::wstring &path, Failure *failure = nullptr) { + setFailure(failure, Failure::Blocked); + // Deliberately reject UNC, device namespaces and drive-relative paths. + if (path.size() < 3 || !((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')) || path[1] != L':' || path[2] != L'/') return false; + const std::wstring drive = L"\\\\?\\" + path.substr(0, 2) + L"\\"; + Handle root(CreateFileW(drive.c_str(), FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr)); + if (!root.valid()) { setFailure(failure, windowsFailure(GetLastError())); return false; } + if (!inspect(root.get(), true, nullptr, failure)) return false; + handles.push_back(std::move(root)); + for (size_t start = 3; start < path.size();) { + const auto end = path.find(L'/', start); + const auto part = path.substr(start, end == std::wstring::npos ? end : end - start); + if (part.empty() || part.back() == L'.' || part.back() == L' ' || !append(part, false, failure)) return false; + if (end == std::wstring::npos) break; + start = end + 1; + } + return true; + } +}; + +inline bool removeOnClose(HANDLE handle) { + FILE_DISPOSITION_INFO disposition{}; disposition.DeleteFile = TRUE; + return SetFileInformationByHandle(handle, FileDispositionInfo, &disposition, sizeof(disposition)); +} +inline bool publishNew(HANDLE file, HANDLE directory, const std::wstring &name, Failure *failure = nullptr) { + // No replacement: a racing existing regular file, hard link or reparse point + // must fail the same atomic operation, without inspecting then reopening it. + // Use the NT entry point: the destination is interpreted relative to the + // retained directory object, without Win32 current-directory path expansion. + // FILE_RENAME_INFORMATION has the same layout as FILE_RENAME_INFO. + // https://learn.microsoft.com/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information + using Set = NTSTATUS(NTAPI *)(HANDLE, PIO_STATUS_BLOCK, PVOID, ULONG, FILE_INFORMATION_CLASS); + static const auto set = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtSetInformationFile")); + if (!set || !safeComponent(name)) { setFailure(failure, Failure::Blocked); return false; } + const size_t bytes = sizeof(FILE_RENAME_INFO) + name.size() * sizeof(wchar_t); + std::vector storage(bytes + sizeof(wchar_t), 0); + auto *rename = reinterpret_cast(storage.data()); + rename->ReplaceIfExists = FALSE; rename->RootDirectory = directory; + rename->FileNameLength = static_cast(name.size() * sizeof(wchar_t)); + std::memcpy(rename->FileName, name.data(), name.size() * sizeof(wchar_t)); + IO_STATUS_BLOCK status{}; + const auto code = set(file, &status, rename, static_cast(bytes), static_cast(10)); + if (code < 0) { setFailure(failure, ntFailure(code)); return false; } + return true; +} +} +#endif diff --git a/tests/PDF-VALIDATION.md b/tests/PDF-VALIDATION.md new file mode 100644 index 0000000..1769a28 --- /dev/null +++ b/tests/PDF-VALIDATION.md @@ -0,0 +1,232 @@ +# PDF implementation validation + +Validated on Linux x64 with Qt 6.11.2 and PDFium 155.0.8057.0, pinned to +`a5a7089234f121990b336b3841008009dca143bf`. The non-V8 build disables PDF +JavaScript and XFA. Exact binary provenance and SHA-256 are in +`cmake/pdfium.lock.json`; `python3 cmake/fetch_pdfium.py` installs that archive. + +The current [v2 source-build candidate](results/pdfium-intent-context/README.md) +preserves rendering intents, pattern initial state and uncolored tiling stroke color. +Dedicated Arch and Ubuntu builds each pass 972 color probes and all 24 CTest groups; +the upstream run passes 1,979 tests. It is integrated into `build-context` and the +Ubuntu validation4 deb. Original dependencies and earlier packages remain available +for comparison. The v1 corpus had eight incorrect shading-pattern expectations; +v2 corrects those rows without changing the PDF/profile bytes and retains the old +failures and measurements. See the [current integration record](results/pdfium-intent-context/integration-record.json). + +The historical [CMYK CLUT comparison](results/cmyk-lut/README.md) exposes a provider +rendering-intent discrepancy: on both Arch and Ubuntu, 30 of 48 ICC vector/image probes +exceed the unchanged analytic tolerance. The fixed PDFium uses perceptual ICC conversion +despite this PDF's relative-colorimetric intent. Diagnosis does not grant acceptance; +the [additional review page](results/cmyk-lut/index.html) retains the difference. + +The [current human review gallery](results/pdfium-intent-context/render-review/index.html) assembles 21 image +pairs with per-page expectations and known differences. Nineteen compare against +Poppler; two compare TTF/TTC within PDFium. Sources, images and the worker used +for that recorded run are bound by hashes. All 83 decoded images match the prior +gallery exactly. All remain pending human review; creating or testing the +gallery does not grant G-RENDER acceptance. + +## Validation revision + +The older integrated, comparison and performance records remain historical evidence +for the binary hashes recorded with each run. Current checks include the latest +identity, navigation, metadata-omission and translation changes. +The historical completion-final run passed all 20 CTest groups; its [log](results/completion-final/ctest/LastTest.log) +and [build hashes](results/completion-final/build-record.json) are retained. The [six renderer comparisons](results/completion-final/comparison-run.json), +[relocated package checks](results/completion-final/linux-development-package/README.md) +and [watchdog](results/completion-final/worker-deadlines/README.md) have also completed. +The [v2 PDF performance run](results/pdfium-intent-context/performance/README.md) +passed four conditions, 120 cold process starts, 750 operations and 960 scroll inputs, +plus the approximately 1 GB / 5,000-page stress case. No measured provisional budget +was exceeded. These are headless software-rendering reference measurements; physical +display and reference-hardware acceptance remain incomplete. See [validation status](../docs/VALIDATION.md). + +## Automated checks + +The current `test_pdf` run records 75 passes (the earlier run had 69), including annotation geometry and lifetime checks, +26 tagged-PDF boundary rows, bounded shared-metadata checks, production worker +cases, Japanese broker integration, fatal-font-error rows, and QtTest setup and cleanup: metadata/page labels/CropBox/Rotate; explicit and named outline +destinations; RoleMap and multiple MCIDs in H1/H2; external versus blocked Launch +links and comment contents; text search; saved widget appearance; full-page +versus tile pixels; inverse coordinates; password required/invalid/correct; +corruption and render limits; and real sandboxed PDF worker round trips. A +malformed page tree with a missing first page is rejected before commit, and +its document handle is closed; it cannot trigger repeated page-metadata loads. +The suite also checks 1,200-item outline pagination, encoded-byte limits with +long Unicode labels, explicit 20,000-item truncation, string-limit warnings, +eight capability states/reasons, and the tagged versus untagged declaration. + +The latest metadata regression cases are `annotationContentsLengthBoundary`, +`annotationAndLinkCountLimitsAreExplicit` (comments/links at 1,000 and 1,001), and +`annotationResponsePreservesBoundedPrefixThroughWorker`. A Contents value over +4,095 UTF-16 units (8,192-byte buffer including its terminator) keeps a fixed +explanation in the annotation list and reports an omission warning. Page-local +link/annotation count limits and the 512 KiB metadata response budget return an +explicit warning with the retained prefix. The production-worker case uses 100 +long Japanese comments and verifies bounded replies and unchanged source bytes. +These cases pass in the current 75-pass suite, without increasing the safety limits. + +Controller regressions now distinguish an outline containing only external or +blocked actions from one with usable internal destinations. Only internal targets +are considered for heading fallback and its capability state. The same GUI run +also replaces an open PDF's source file, verifies that its old reading position is +not rebound to the replacement, and checks the unresolved-location notice on +explicit reopen. [Focused and full-GUI evidence](results/completion-regressions/controller-state.md). + +The worker test uses the production `WorkerProcess` launcher, completes the +bootstrap ping, parses the fixture only after Linux Landlock and seccomp are +active, renders a tile, and stops cleanly. It must run in an environment +permitting the test harness to create its own local socket. The worker never +disables its own sandbox for testing. The launcher also has a Windows handle +transport, but these results were obtained on Linux and establish no Windows +sandbox result. + +The [tagged-PDF boundary corpus](fixtures/pdf/headings/README.md) adds a transformed +Form XObject, a rotated CropBox with structure order different from draw order, +a heading spanning two pages with nested and direct MCR children, and explicit +page-precision fallbacks for vector-only headings and reused stream-local MCIDs. +The regression checks authored PDF-point regions, title/order/page/level, all +four user rotations, and inverse coordinates within 0.5 pt. The Form-only ParentTree fixture now yields an exact H2 using the shared +worker-side qpdf structure adapter; a GUI regression follows the target. +The expanded corpus also checks nested Forms, owner-qualified MCID collisions, +repeated calls, structure cycles and bounded stream decompression. +The corpus exposed and fixed mixed-child ordering and non-text MCID collision +bugs; the ordinary RoleMap/multiple-MCID cases continue to pass. + +`test_pdf_canvas` includes 21 passes including setup and cleanup. It distinguishes +the page nearest the viewport centre (ties choose the previous page) from the +leading logical reading location, and preserves the central PDF point on zoom. A separate +`pdf_canvas_hidpi` CTest group repeats the annotation pixel/hit-target case at DPR 2 +(three QtTest passes including setup and cleanup). It compares both directions of the canvas coordinate transform +against PDFium for a nonzero CropBox, document Rotate 90 and user rotations +0/90/180/270. It verifies PDF-point restoration after zoom and horizontal pan, +separate jumps to two headings on the same page, invalid/clipped coordinates, +a jump whose page metadata arrives later, high zoom limits and search-result navigation. +The actual sandboxed worker also verifies that prefetch disabled produces no +adjacent-page requests, enabled prefetch warms a bounded adjacent viewport, +and a page jump uses those pixels immediately. An in-flight prefetch test +changes the zoom before completion, rejects the obsolete reply, and confirms +the previous visible resolution remains cached. Prefetch is limited to eight +tiles per adjacent page, runs only after visible work and queued operations, +and cannot insert a tile when doing so would evict existing cache entries. +Real-worker cases also inject a delayed fatal font reply: visible and prefetch +failures stop the poisoned worker even after a zoom revision, while a reply for +a replaced document is ignored. Missing-font warnings remain nonfatal and +reach document information. GUI regressions reopen the same unchanged source +with a new worker after failure in prefetch, search, cancelled search or page +metadata. + +Late metadata cannot resurrect a destination cancelled by a newer jump, zoom, +or explicit cancellation. Theme background changes preserve white PDF paper. +The real-worker cache regression checks the 64–512 MiB budget clamp, nonzero +charged image bytes after rendering, cost below budget, cache clearing at +document replacement, actual visible/prefetch request counters, and exactly one +discarded old-revision reply. Repeated viewport readiness checks do not inflate +the request counts. These counters are lifetime samples; budget/cost report +QCache's KiB-rounded image charge and are separate from measured process RSS. + +A two-pixel offscreen margin was insufficient to preserve antialiasing for a +glyph crossing a tile boundary. The worker now renders a sixteen-pixel margin, +then copies only the requested tile. The fixture's tile and full-page crop +compare byte for byte, including the red saved form appearance. IPC remains a +512-pixel tile, and the margin is not transmitted. + +The base fixtures are generated locally by `tests/fixtures/pdf/generate.py`; +`tests/fixtures/pdf/generate_headings.py` regenerates 26 structure fixtures and +four shared-context boundary fixtures, with a SHA-256/expectation manifest, +without external dependencies. +They contain no third-party document content. The `qpdf` CLI regenerates +the encrypted fixture (reader password: `reader`); libqpdf is also a worker runtime +dependency for supplemental link-border and structure metadata. No password is saved by the +application. + +## Independent renderer comparison + +Run `python3 tests/compare_pdf_renderers.py` after building `pdf-worker-evidence`. +The exporter uses the production sandboxed worker, font broker and IPC; it does +not link PDFium into the main process. Set +`DOCVIEW_BUILD_DIR=build-pdf` to use another build directory. The comparison +uses system Poppler when available, because the bundled document-tools wrapper +has a different fontconfig environment. + +Fixture SHA-256: +`e8b0b037679a3449de5d48f1b13a8562486c9ce19385a036d682cab906815a22`. +PDFium and Poppler 26.08.0 at 144 DPI both produced a 400 × 400 CropBox image. +154,110 of 160,000 pixels matched exactly. 4,024 pixels had a maximum channel +difference greater than 16; mean absolute channel error was 2.26274 / 255. +These descriptive metrics are not a performance or compatibility acceptance +threshold. The record contains the worker and font-broker binary hashes. + +Evidence is written to `tests/results/pdf/{pdfium,poppler,difference,comparison}.png` +and `comparison.json`. Inspection confirms all three text strings, the black +rectangle and the saved red widget appearance are present in both images. Text +antialiasing differs. The no-appearance text-comment icon differs by renderer. +The former missing default link borders now match Poppler in the 8,448-pixel +comparison region. Other differences remain visible in the evidence and are +not accepted as golden-image success. + +The [link-border corpus](results/link-borders/README.md) adds 20 annotation styles, +four intrinsic and four user rotations, tiled/full output equality, original +rectangle and file preservation, encrypted input, malformed data and bounded +metadata/AP handling. Supplemental metadata is read through libqpdf in the +worker, and numeric viewing-only appearances are generated through PDFium's +public API. Supplied appearances are preserved. NoZoom/NoRotate now use temporary +Rect and page-rotation compensation, with logical zoom separated from DPR. +The [annotation-flag corpus](results/pdf-annotation-flags/README.md) exercises +generated borders, saved link/comment appearances, and native Text icons. +Other missing-AP annotation types retain an explicit NoZoom limitation. Canvas tests compare +actual colored pixels with focus/click bounds at 100% and 200% DPR. These are +scoped rendering results, not a complete G-RENDER gate. + +The [expanded synthetic corpus](fixtures/pdf/extended/README.md) adds five +documents and nine pages with embedded Japanese text, authored vertical glyph +positions and ruby, RGB/RGBA images, soft masks, alpha blending, clipping, +shading, CMYK swatches, Standard-14 substitution, varied CropBox/rotation, and +ICCBased RGB vector and raster content. +The [extended comparison evidence](results/pdf-extended/README.md) records +matching dimensions and geometry/alpha probes, Japanese extraction/search, +independent Poppler images and visual inspection. Run +`python3 tests/compare_pdf_extended.py` to regenerate that evidence. + +The [ICC comparison](results/pdf-icc/README.md) renders a self-authored linear-RGB +matrix/TRC profile at 50%, 100% and 400%. All 54 probes agree with the analytic +sRGB transfer function within the stated rounding tolerance. A DeviceRGB control +distinguishes actual profile application from ignored ICC data. Dimensions and +the original file hash also match. Run `python3 tests/compare_pdf_icc.py` with +Pillow and pypdf installed. This is not calibrated CMYK/printing acceptance. + +The [large-PDF stress record](STRESS-PDF-VALIDATION.md) covers a 5,000-page, +1,009,828,104-byte generated PDF with 1,280 genuinely referenced uncompressed +raster streams. Three opens, 100 keyboard operations and a direct page-5,000 +screen capture completed with the sandbox enabled; the large input was removed +after hashes, timings, memory and screenshots were retained. This workload is +separate from rendering compatibility and reference-machine acceptance. + +The [real TTC experiment](results/font-collection/README.md) constructs a private +two-face collection from locally installed OFL fonts. The production broker +selects regular face 0 and bold face 1; both pages' 892,800 pixels match the +separate-TTF condition exactly, and regular/bold remain distinct. Font programs, +configuration and cache are temporary and removed after verification. This +checks real Linux TrueType collection selection, not Windows/CJK/CFF collections. + +## Outstanding acceptance coverage + +G-RENDER is **not complete**: the synthetic comparisons have no human-approved +golden image and do not establish arbitrary CID/CMap compatibility, +calibrated ICC/CMYK, complex transparency +groups, malformed appearances or production-corpus compatibility. The original embedded Japanese +fixture uses explicitly positioned vertical glyphs; the new unembedded fixture +adds native UniJIS-UCS2-V writing mode for two requested Japanese families. Generated link borders, saved link/comment appearances, and native Text icons +now cover NoZoom/NoRotate; other subtype-specific synthesized appearances +still need wider validation. + +G-HEADINGS remains scoped to the generated corpus: RoleMap, direct/nested/interleaved +MCIDs, transformed Form text, rotated pages and cross-page fragments are covered. +A shared qpdf context now reads Form-only StructParents and verifies stream owners, +ParentTree membership and actual Form invocation. Public PDFium APIs supply text +and geometry. Repeated or ambiguously matched Forms keep explicit page-precision +fallbacks; malformed/cyclic/bounded-out structures report limitations. The main +process has no added PDF parser or native PDF objects. Annotation-owned marked +content remains explicitly unsupported, and broader production structures require +corpus validation. These results do not establish comprehensive tagged-PDF support. diff --git a/tests/STRESS-PDF-VALIDATION.md b/tests/STRESS-PDF-VALIDATION.md new file mode 100644 index 0000000..620d37d --- /dev/null +++ b/tests/STRESS-PDF-VALIDATION.md @@ -0,0 +1,72 @@ +# 大容量 PDF の負荷試験記録 + +最新のUI修正後の再測定は [ui-finalの記録](results/ui-final/stress/README.md) を参照してください。[completion-finalの記録](results/completion-final/stress/README.md)と以下は、以前の本体による測定履歴です。資料構造と再現手順は共通です。 + +2026-09-19、Arch Linux x86_64 / kernel 7.2.2-zen1-1-zen / Qt 6.11.2、Xvfb の専用 X11 表示と Qt Quick software rendering で当時のビルドを測定した。画面は 1400×900、アプリ窓は 1100×800、DPR 1。PDFium ワーカーの Landlock/seccomp と資源制限は有効のままである。以下は [stress-final/report.json](results/stress-final/report.json) の結果で、[従来の記録](results/stress/report.json) は旧ビルドの履歴として保持する。 + +## 入力と再現方法 + +`generate_stress_pdf.py` は、オリジナルの紙面テクスチャ・ブロック文字・バーコードを持つ **1,280 個の異なる 512 × 512 RGB 画像**を、フィルターなしの各 768 KiB PDF image stream として書く。5,000 ページの全てに画像を描画し、画像を `pageIndex % 1280` で再利用する。全画像が少なくとも一度参照される。サイズを稼ぐだけの未参照データや padding はない。 + +- PDF サイズ: **1,009,828,104 bytes**(約 963.05 MiB / 0.940 GiB)。 +- 実ラスター payload: **1,006,632,960 bytes**(960 MiB)。 +- SHA-256: `a8657fdaef99e4e6d3eb650bfc4afe9ded74dee4873c69698fcd73021617dc2e`。 +- ページ: 600 × 800 pt。画像矩形: `[44, 180, 556, 692]`。ページ番号・画像番号の本文マーカーと二つの色マーカーを持つ。 +- 生成前空き容量: 318,780,846,080 bytes。出力見積りに加え 512 MiB の余裕を要求する。 +- ラスター用バッファ: テンプレートと現在の画像の計 1.5 MiB。全文書を RAM に保持せず、逐次書き込みと SHA-256 更新を行う。 + +通常のビルド後、次で生成・検査・GUI 試験・削除まで実行する。 + +```sh +python3 tests/test_stress_generator.py +python3 tests/test_stress_runner.py +python3 tests/run_stress_pdf.py --xvfb --binary build/docview --output tests/results/stress-final +``` + +最初のコマンドは 5 ページ・2 画像のみを使用する軽量試験で、再現性、画像の固有性と参照、xref、空き容量不足、既存ファイル保護を確認する。二番目の新しい runner 回帰は GUI/大容量 PDF を使わず、出力 schema、実 open 数、メモリ圧迫、no-op 除外、close 後解放、起動失敗時の旧集計除去、実行ファイル改変時の失敗を検査する。今回 3 試験が通過し、[軽量試験ログ](results/stress-final/runner-tests.log)を保存した。三番目は `build/stress/` 内の一時ディレクトリに大容量 PDF を作り、成功・失敗のどちらでも回収する。利用者の設定・履歴には一時 XDG ディレクトリを使用する。 + +## 測定結果 + +アプリ自身が focused window にキーイベントを送り、`frameSwapped` で初回応答を記録し、全可視タイルがそろった後のフレームを最終品質とする。これは提示キュー時刻であり、物理ディスプレイの scanout を確認する値ではない。`--benchmark-runs 3 --benchmark-close-cycles 2 --benchmark-operations 100 --benchmark-scroll-steps 0` を明示し、実際に 3 open、操作前の単純 open/close 2 回、操作後の close 1 回を行った。3 回の open は毎回新しいワーカー、100 操作はページ移動 80 回と拡大/fit-width 20 回から成る。キャッシュ分類は位置が変わったページ移動だけを含み、no-op は 0 件だった。継続スクロールはこの有限試験では実施していない。生成直後の OS ファイルキャッシュは残している。 + +| 項目 | 件数 | P50 | P95 | 最大 | +|---|---:|---:|---:|---:| +| open → 可視本文の提示 | 3 | 96.71 ms | 97.70 ms | 97.70 ms | +| キー → 初回応答フレーム | 100 | 6.61 ms | 8.14 ms | 11.69 ms | +| キー → 全可視タイルの最終品質 | 100 | 12.01 ms | 24.12 ms | 40.35 ms | +| キャッシュ済みページ移動 | 68 | 11.86 ms | 15.87 ms | 18.87 ms | +| 新規可視タイルを要するページ移動 | 12 | 18.67 ms | 40.35 ms | 40.35 ms | + +| メモリ・要求 | 実測 | +|---|---:| +| アプリのピーク RSS | 410,173,440 bytes | +| アプリと子プロセス合計のピーク RSS | 752,041,984 bytes | +| 各 open 完了時のプロセス合計 RSS | 379,916,288 / 384,729,088 / 386,531,328 bytes | +| タイルキャッシュのピーク charged bytes | 268,423,168 bytes | +| タイルキャッシュ上限 | 268,435,456 bytes(256 MiB) | +| 可視/先読みタイル要求 | 205 / 281 | +| 描画待ち/queued render/active render の最大 | 4 / 3 / 1 | +| 破棄済み queued requests | 28 | +| 最大メモリ圧迫段階 | 0(通常解像度条件を維持) | + +表はアプリ内の 100 ms 間隔の標本である。独立した 50 ms 間隔の `/proc` 監視はアプリ 413,401,088 bytes、子を含む合計 755,404,800 bytes を観測した。標本時刻が異なるため値は一致しない。合計 RSS は共有ページをプロセスごとに数えるため PSS ではなく、Xvfb は含めない。有限間隔のサンプルなので瞬間的な全ピークを保証しない。 + +全 3 close で Empty とタイル cache 0 を確認した。単純 open/close 2 回後の合計 RSS は 132,026,368 / 134,127,616 bytes、操作後の最終 close は 376,856,576 bytes だった。プロセスの allocator 等に残る RSS を含み、2 回の均一条件だけで長期リークを判定する試験ではない。 + +## 終端ページとクリーンアップ + +独立した `pdfinfo` が 5,000 ページを認識し、`pdftotext` が 1・1,280・5,000 ページの予定マーカーを抽出した。1,280 個の画像 digest は全て異なる。先頭・中間・末尾の image stream を再読込してハッシュ一致と xref を検査した。アプリが試験後に算出した全文書 SHA-256 も生成時の値と一致した。 + +別プロセスで `--page 5000 --smoke-output ...` を実行し、Ready / pdf、可視 PDF 寸法、viewportReady、PNG/runtime 保存の成功を満たして終了した。[保存画面](results/stress-final/last-page/screen.png)を `view_image` で目視し、`DOCVIEW STRESS PAGE 05000 OF 05000 IMAGE 1159`、ラスター画像、ステータス `5000 / 5000` を確認した。自動 OCR 検証ではない。メモリ圧迫 0 の集計対象は benchmark プロセスであり、smoke の runtime 出力には圧迫段階の時系列は含まれない。 + +証跡は [report.json](results/stress-final/report.json)、[benchmark.json](results/stress-final/benchmark.json)、[source-manifest.json](results/stress-final/source-manifest.json) に保存した。原本 SHA は benchmark 終了時と smoke 後の独立読込の両方で生成時と一致した。大容量 PDF は削除済みで、`sourceRemoved=true` と `build/stress/` に PDF が残っていないことを確認した。全工程 14.291 s、benchmark プロセス 7.468 s、smoke プロセス 4.893 s。 + +次の主 3 実行ファイルは試験前後で SHA-256 が一致した。report の `executableSha256`/`executableSha256After`/`executablesUnchanged` に保存する。 + +| 実行ファイル | SHA-256 | +|---|---| +| docview | `9612bccb5ce864174c1898843a90fa75c2598ac47e8abb3b9822d35c78d766c5` | +| docview-pdf-worker | `b199d491f411dc32c60c5f19bd07c24d0cc85fb5e43ca1f08f085615574dc461` | +| docview-archive-worker | `312dc77c52f9b40441f339381ae5209cac4d11d8131c9f1f239aa025713f0966` | + +この試験は大容量・大量ページの取扱い、遠方移動、キャッシュ上限の観測である。3 回の open は 30 回の性能受入条件を置き換えず、仮想表示器は設計書の基準機ではない。1,280 画像を再利用する合成文書は、5,000 ページ全てが異なる高解像度スキャン、圧縮画像の複雑な復号、実書籍の互換性、長時間のリーク検証を網羅しない。 diff --git a/tests/benchmark.py b/tests/benchmark.py new file mode 100644 index 0000000..370eeca --- /dev/null +++ b/tests/benchmark.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Fixed input sequences and explicit close/reopen memory measurements. + +Use a dedicated display or Xvfb. Chromium and worker sandboxes stay enabled. +Runtime completion is distinct from budget observations and target acceptance. +""" +import argparse +import hashlib +import json +import math +import os +import pathlib +import statistics +import subprocess +import tempfile + +root = pathlib.Path(__file__).resolve().parents[1] +p = argparse.ArgumentParser() +p.add_argument('--binary', type=pathlib.Path, default=root/'build/docview') +p.add_argument('--runs', type=int, default=30) +p.add_argument('--operations', type=int, default=100, help='operations in each applicable navigation series') +p.add_argument('--close-cycles', type=int, default=10) +p.add_argument('--scroll-steps', type=int, default=240) +p.add_argument('--cold-process-runs', type=int, default=0, help='fresh process and fresh XDG app-cache opens; OS file cache retained') +p.add_argument('--formats', nargs='+', default=['pdf', 'html', 'zip', 'epub']) +p.add_argument('--corpus', choices=['smoke', 'standard', 'fonts'], default='smoke') +p.add_argument('--output', type=pathlib.Path, default=root/'tests/results/performance') +a = p.parse_args() +if not 0 <= a.cold_process_runs <= 100: + p.error('--cold-process-runs must be between 0 and 100') +a.output.mkdir(parents=True, exist_ok=True) + +def file_hash(path): + with path.open('rb') as source: + return hashlib.file_digest(source, 'sha256').hexdigest() + +def stats(samples): + if not samples: + return {'n': 0} + ordered = sorted(samples) + return {'n': len(samples), 'p50_ms': statistics.median(samples), + 'p95_ms': ordered[max(0, math.ceil(len(ordered)*.95)-1)], + 'min_ms': ordered[0], 'max_ms': ordered[-1]} + +def budget(stats_, limit): + return {'limit_ms': limit, 'observed_met': stats_['p95_ms'] <= limit} if stats_.get('n') else {'status': 'not-measured', 'limit_ms': limit} + +executables = {name: file_hash(a.binary.resolve().parent / name) for name in + [a.binary.name, 'docview-pdf-worker', 'docview-archive-worker']} +runner_hash = file_hash(pathlib.Path(__file__)) +fixtures = {'pdf': 'pdf/navigation.pdf', 'html': 'web/html/index.html', 'zip': 'web/html-book.zip', 'epub': 'web/reflow-rtl.epub'} +if a.corpus == 'standard': + fixtures = {'pdf': 'performance/standard-500.pdf', 'html': 'performance/html/index.html', + 'zip': 'performance/standard-html.zip', 'epub': 'performance/standard-500.epub', + 'pdf-headings': 'performance/standard-headings-500.pdf'} + if 'pdf' in a.formats and 'pdf-headings' not in a.formats: + a.formats.append('pdf-headings') +elif a.corpus == 'fonts': + fixtures = {'pdf': 'pdf/fonts/unembedded-japanese.pdf'} + a.formats = ['pdf'] +unknown = set(a.formats) - fixtures.keys() +if unknown: + p.error('formats unavailable in this corpus: '+', '.join(sorted(unknown))) +# Clear this invocation's selected evidence before starting any process. A +# failure or interruption must not leave later selected cases from an old run. +for kind in a.formats: + (a.output/(kind+'.json')).unlink(missing_ok=True) + cold_directory = a.output/'cold'/kind + for old in cold_directory.glob('[0-9][0-9][0-9].json'): + old.unlink() +failures = [] +with tempfile.TemporaryDirectory(prefix='docview-benchmark-') as temporary: + directory = pathlib.Path(temporary) + config = directory/'config.toml' + config.write_text('schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n', encoding='utf-8') + env = os.environ.copy() + for name, leaf in [('XDG_CONFIG_HOME', 'config'), ('XDG_STATE_HOME', 'state'), ('XDG_CACHE_HOME', 'cache')]: + env[name] = str(directory/leaf) + for kind in a.formats: + result = a.output/(kind+'.json') + # Never reuse a previous success when this invocation fails before it + # produces a record (including a timeout or an executable startup error). + result.unlink(missing_ok=True) + source = root/'tests/fixtures'/fixtures[kind] + source_hash = file_hash(source) + command = [str(a.binary.resolve()), '--config', str(config), '--benchmark-output', str(result.resolve()), + '--benchmark-runs', str(a.runs), '--benchmark-operations', str(a.operations), + '--benchmark-close-cycles', str(a.close_cycles), '--benchmark-scroll-steps', str(a.scroll_steps), str(source)] + try: + completed = subprocess.run(command, env=env, check=False, timeout=max(180, a.runs*3+a.operations*6+a.scroll_steps*.05)) + except subprocess.TimeoutExpired: + failures.append(kind+': process timeout') + continue + except OSError: + failures.append(kind+': executable startup failed') + continue + if not result.exists(): + failures.append(kind+': no measurement record') + continue + values = json.loads(result.read_text()) + values['harnessReportedSuccess'] = values.get('success', False) + values['success'] = values.get('success', False) and completed.returncode == 0 + values['processExitCode'] = completed.returncode + actions = values['operations'] + summary = {'open': stats(values['openFrameMs']), + 'response': stats([v['firstResponseFrameMs'] for v in actions if 'firstResponseFrameMs' in v]), + 'final_quality': stats([v['finalQualityFrameMs'] for v in actions if 'finalQualityFrameMs' in v])} + summary['cached_pdf_navigation'] = stats([v['finalQualityFrameMs'] for v in actions if v['command'] == 'nav.page' and v.get('cacheHit') is True and v.get('positionChanged') is True]) + summary['uncached_pdf_navigation'] = stats([v['finalQualityFrameMs'] for v in actions if v['command'] == 'nav.page' and v.get('cacheHit') is False and v.get('positionChanged') is True]) + summary['excluded_noop_page_navigation'] = sum(v['command'] == 'nav.page' and v.get('positionChanged') is not True for v in actions) + summary['first_open_in_process'] = stats(values['openFrameMs'][:1]) + summary['same_process_document_reopen'] = stats(values['openFrameMs'][1:]) + values['openTimingMode'] = 'First sample opens in a fresh application process; subsequent samples explicitly close/reopen the document while retaining process-global/browser/font and OS caches.' + cold_samples, cold_failures, cold_maximum_pressure = [], 0, 0 + for trial in range(a.cold_process_runs): + cold_output = a.output/'cold'/kind/f'{trial + 1:03}.json' + cold_output.parent.mkdir(parents=True, exist_ok=True) + cold_output.unlink(missing_ok=True) + with tempfile.TemporaryDirectory(prefix='docview-cold-') as cold: + cold_env = env.copy() + for variable, leaf in [('XDG_CONFIG_HOME', 'config'), ('XDG_STATE_HOME', 'state'), ('XDG_CACHE_HOME', 'cache')]: + cold_env[variable] = str(pathlib.Path(cold)/leaf) + cold_command = [str(a.binary.resolve()), '--config', str(config), '--benchmark-output', str(cold_output.resolve()), + '--benchmark-runs', '1', '--benchmark-operations', '0', '--benchmark-close-cycles', '0', '--benchmark-scroll-steps', '0', str(source)] + try: + cold_run = subprocess.run(cold_command, env=cold_env, check=False, timeout=45) + cold_value = json.loads(cold_output.read_text()) if cold_output.exists() else {} + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): + cold_run, cold_value = None, {} + pressure = cold_value.get('maximumMemoryPressureLevel') + if isinstance(pressure, int): + cold_maximum_pressure = max(cold_maximum_pressure, pressure) + if cold_run is None or cold_run.returncode or not cold_value.get('success') or pressure != 0 or len(cold_value.get('openFrameMs', [])) != 1: + cold_failures += 1 + else: + cold_samples.append(cold_value['openFrameMs'][0]) + summary['fresh_process_fresh_app_cache_open'] = stats(cold_samples) + values['coldProcessOpen'] = {'requested': a.cold_process_runs, 'failed': cold_failures, + 'maximumMemoryPressureLevel': cold_maximum_pressure, + 'samplesMs': cold_samples, 'status': 'measured' if a.cold_process_runs else 'not-measured', + 'method': 'New application process and empty XDG config/state/cache roots per sample. System fonts and OS file cache retained; not fully OS-cold. Qt startup precedes the timed file-open event.'} + if cold_failures: failures.append(kind+': cold process open failure') + summary['navigation_series'] = {name: stats([v['finalQualityFrameMs'] for v in actions if v.get('series') == name]) + for name in ['page-scroll', 'heading', 'chapter']} + summary['continuous_scroll_frames'] = stats(values.get('continuousScroll', {}).get('frameIntervalMs', [])) + summary['continuous_scroll_input_dispatch'] = stats(values.get('continuousScroll', {}).get('dispatchIntervalMs', [])) + summary['ui_timer_intervals'] = stats(values.get('uiTimerIntervalMs', [])) + expected = values.get('uiTimerExpectedIntervalMs', 16) + summary['ui_timer_excess_delay_ms'] = max([0]+[max(0, v-expected) for v in values.get('uiTimerIntervalMs', [])]) + cycles = values.get('openCloseCycles', []) + summary['explicit_close_cycles'] = len(cycles) + uniform_cycles = [v for v in cycles if v.get('workload') == 'open-first-visible-only'] + summary['uniform_open_close_cycles'] = len(uniform_cycles) + summary['all_closes_empty'] = bool(cycles) and all(v['stateEmpty'] and v['cacheBytesAfterClose'] == 0 for v in cycles) + for name in ['applicationBytes', 'processGroupBytes']: + samples = [v['afterClose'][name] for v in uniform_cycles if v['afterClose'].get('available')] + if samples: + summary['post_close_'+name] = {'n': len(samples), 'first': samples[0], 'last': samples[-1], + 'min': min(samples), 'max': max(samples), 'last_minus_first': samples[-1]-samples[0], + 'interpretation': 'Finite repeated-close observation; retained RSS alone is not a leak verdict.'} + values['summary'] = summary + values['navigationCoverage'] = {v['series']: {'status': v['status'], 'measured': v['count'], + 'minimum100Operations': v['count'] >= 100 if v['status'] == 'measured' else None} + for v in values.get('operationSeries', [])} + values['proposedBudgetObservations'] = { + 'visual_response': budget(summary['response'], 50), + 'app_cache_cold_open': budget(summary['fresh_process_fresh_app_cache_open'], 2000 if kind in ('pdf', 'pdf-headings', 'html') else 5000), + 'cached_pdf_navigation': budget(summary['cached_pdf_navigation'], 100), + 'uncached_pdf_navigation': budget(summary['uncached_pdf_navigation'], 500), + 'continuous_scroll': budget(summary['continuous_scroll_frames'], 33), + 'process_group_memory': {'limit_bytes': 1024**3, 'observed_met': values['processGroupRssPeakBytes'] <= 1024**3} if values['processGroupRssPeakBytes'] >= 0 else {'status': 'not-measured'}, + 'tile_cache_within_configured_budget': values['tileCachePeakChargedBytes'] <= values['tileCacheBudgetBytes'], + 'acceptance': 'Informational comparison with original proposed budgets; not reference-hardware/target-OS acceptance.'} + values['originalUnchanged'] = source_hash == file_hash(source) == values['documentSha256'] + values['corpus'] = a.corpus + values['executableSha256'] = executables + values['runnerSha256'] = runner_hash + checks = {'requested_open_timings': len(values['openFrameMs']) == a.runs, + 'full_resolution_conditions': values.get('maximumMemoryPressureLevel') == 0 and cold_maximum_pressure == 0, + 'uniform_open_close_cycles': len(uniform_cycles) >= a.close_cycles, + 'close_releases_document': summary['all_closes_empty'], + 'source_unchanged': values['originalUnchanged'], + 'navigation_moved': all(v.get('positionChanged') is True for v in actions if v.get('series') in ('heading', 'chapter')), + 'page_navigation_moved': summary['excluded_noop_page_navigation'] == 0, + 'scroll_dispatched': values.get('continuousScroll', {}).get('dispatchedInputs') == a.scroll_steps} + values['measurementChecks'] = checks + values['success'] = values['success'] and not cold_failures and all(checks.values()) + if not values['success']: + failures.append(kind+': measurement failed') + result.write_text(json.dumps(values, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') + print(kind, json.dumps(summary), flush=True) +if failures: + raise SystemExit('; '.join(failures)) diff --git a/tests/cmyk_lut/correct_intent_oracle.py b/tests/cmyk_lut/correct_intent_oracle.py new file mode 100644 index 0000000..5b26bc8 --- /dev/null +++ b/tests/cmyk_lut/correct_intent_oracle.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Preserve the original PDF bytes while correcting pattern entry-state expectations.""" +import argparse +import hashlib +import json +from pathlib import Path +import shutil + +ROOT = Path(__file__).resolve().parents[2] +ORIGINAL = ROOT / 'tests/fixtures/pdf/rendering-intents' +MANIFEST_SHA = 'b0db4ee3e57658b09d0ae10fcf8175986c3ab80ccd64442e3718802257ecf543' + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + assert sha(ORIGINAL / 'manifest.json') == MANIFEST_SHA + spec = json.loads((ORIGINAL / 'manifest.json').read_text()) + files = {spec['file']: spec['sha256'], **{ + row['file']: row['sha256'] for row in spec['profiles'].values()}} + for name, expected in files.items(): + assert sha(ORIGINAL / name) == expected + spec['oracleRevision'] = 2 + spec['oracleCorrection'] = { + 'sourceManifestSha256': MANIFEST_SHA, + 'correctorSha256': sha(Path(__file__)), + 'case': 'shading-pattern-inherit', + 'affectedProbesPerScale': 8, + 'reason': 'A shading pattern without an ExtGState RI uses the graphics state at entry to its defining content stream, not the paint-time RI. These patterns are defined in page streams whose entry RI is RelativeColorimetric.', + 'reference': 'https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf', + 'section': '4.6.3 Table 4.23, printed page 231', + 'pdfAndProfilesByteIdentical': True, + } + changed = [] + for index, probe in enumerate(spec['probes']): + if probe['case'] == 'shading-pattern-inherit': + changed.append({'probeIndex': index, 'previousIntentIndex': probe['expectedIntentIndex']}) + probe['expectedIntentIndex'] = 1 + probe['expectedIntent'] = 'RelativeColorimetric' + assert len(changed) == 8 + spec['oracleCorrection']['rows'] = changed + args.output.mkdir(parents=True, exist_ok=False) + for name in files: + shutil.copyfile(ORIGINAL / name, args.output / name) + (args.output / 'manifest.json').write_text(json.dumps(spec, indent=2) + '\n') + print(json.dumps({'probes': len(spec['probes']), 'correctedRows': len(changed), + 'manifestSha256': sha(args.output / 'manifest.json')})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/diagnose.py b/tests/cmyk_lut/diagnose.py new file mode 100644 index 0000000..bb934b4 --- /dev/null +++ b/tests/cmyk_lut/diagnose.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Explain the retained failing CMYK comparison without relaxing its criterion.""" +from io import BytesIO +import hashlib +import json +from pathlib import Path + +from PIL import Image, ImageCms +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = ROOT / 'tests/results/cmyk-lut' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + corpus=ROOT/'tests/fixtures/pdf/cmyk-lut' + spec=json.loads((corpus/'manifest.json').read_text()) + pdf=PdfReader(corpus/spec['file']) + assert b'/RelativeColorimetric ri' in pdf.pages[0].get_contents().get_data() + assert pdf.pages[0]['/Resources']['/XObject']['/Sample']['/Intent']=='/RelativeColorimetric' + source=json.loads((RESULTS/'pdfium-source/record.json').read_text()) + for row in source['files']: + assert sha(RESULTS/'pdfium-source'/row['path'])==row['sha256'] + transform_source=(RESULTS/'pdfium-source/core/fxcodec/icc/icc_transform.cpp').read_text() + assert 'TYPE_BGR_8, INTENT_PERCEPTUAL, /*dwFlags=*/0' in transform_source + profile=ImageCms.ImageCmsProfile(BytesIO((corpus/spec['profileFile']).read_bytes())) + transformations={intent:ImageCms.buildTransformFromOpenProfiles(profile,ImageCms.createProfile('sRGB'), + 'CMYK','RGB',renderingIntent=value) for intent,value in [('perceptual',0),('relative',1)]} + colors={} + for probe in spec['probes']: + if probe['kind']!='icc-image': continue + pixel=Image.new('CMYK',(1,1),tuple(round(c*255) for c in probe['components'])) + colors[probe['sample']]={name:list(ImageCms.applyTransform(pixel,t).getpixel((0,0))) for name,t in transformations.items()} + rows=[] + for os_name in ('arch','ubuntu'): + report=json.loads((RESULTS/os_name/'report.json').read_text()) + assert not report['success'] and report['fixture']['sha256']==spec['sha256'] + assert report['originalUnchanged'] and report['binariesUnchanged'] + assert report['runnerSha256']==sha(ROOT/'tests/cmyk_lut/run.py') + for name,digest in report['evidenceSha256'].items(): + assert sha(RESULTS/os_name/name)==digest + failed=0; matches=[] + for scale in report['scales']: + failed+=sum(not p['success'] for p in scale['probes']) + for probe in scale['probes']: + if probe['kind']!='icc-image': continue + pair=colors[probe['sample']] + errors={name:max(abs(a-b) for a,b in zip(probe['actualRgb'][name],pair[intent])) + for name,intent in [('pdfium','perceptual'),('poppler','relative')]} + matches.append({'scale':scale['scale'],'sample':probe['sample'],'actualRgb':probe['actualRgb'], + 'cmmIntentColors':pair,'errorsAgainstRespectiveIntent':errors}) + failed_by_renderer={name:sum(p['maxChannelError'].get(name,0)>spec['tolerance8Bit'] + for scale in report['scales'] for p in scale['probes']) + for name in ('pdfium','poppler')} + maximum=max(p['maxChannelError'].get('pdfium',0) for scale in report['scales'] for p in scale['probes']) + assert failed==failed_by_renderer['pdfium']==30 and failed_by_renderer['poppler']==0 and maximum==74 + assert all(max(m['errorsAgainstRespectiveIntent'].values())<=1 for m in matches) + rows.append({'os':os_name,'comparisonReportSha256':sha(RESULTS/os_name/'report.json'), + 'analyticProbes':48,'failedPdfiumAnalyticProbes':failed,'failedPopplerAnalyticProbes':failed_by_renderer['poppler'], + 'maximumPdfiumAnalyticChannelDifference':maximum,'intentMatchedImageProbes':matches}) + result={'diagnosisVerified':True,'renderingAcceptance':False,'relativeIntentPreserved':False, + 'toleranceUnchanged':spec['tolerance8Bit'],'profileSha256':spec['profileSha256'], + 'sourceRevision':source['revision'],'sourceEvidenceSha256':sha(RESULTS/'pdfium-source/record.json'), + 'diagnoserSha256':sha(Path(__file__)),'cmmVersion':ImageCms.core.littlecms_version,'results':rows, + 'finding':'At this fixed PDFium revision, ICC conversion selects INTENT_PERCEPTUAL regardless of the ' + 'relative-colorimetric intent in the test PDF. On both OSs, all eight image swatches at all ' + 'three scales match LittleCMS perceptual output within one channel level. Poppler matches ' + 'relative-colorimetric output. This explains, but does not accept or repair, the difference.', + 'remaining':'G-RENDER requires review and resolution/explicit compatibility judgment. No renderer ' + 'patch, engine substitution or user-visible detection was implemented in this comparison.'} + (RESULTS/'diagnosis.json').write_text(json.dumps(result,indent=2)+'\n') + print(json.dumps({'diagnosisVerified':True,'renderingAcceptance':False,'failedAnalyticProbes':60, + 'intentMatchedImageProbes':48})) + + +if __name__=='__main__': main() diff --git a/tests/cmyk_lut/export_pdfium_context_patch.py b/tests/cmyk_lut/export_pdfium_context_patch.py new file mode 100644 index 0000000..c78438d --- /dev/null +++ b/tests/cmyk_lut/export_pdfium_context_patch.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Export the second PDFium candidate, preserving the first candidate evidence.""" +import difflib +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + results = ROOT / 'tests/results/pdfium-intent-context' + materialization = json.loads((ROOT / 'tests/results/pdfium-intent-build/source-materialization.json').read_text()) + repository = next(row for row in materialization['repositories'] if row['path'] == '.') + archive = ROOT / repository['archive'] + inventory = ROOT / repository['inventory'] + assert sha(archive) == repository['archiveSha256'] + assert sha(inventory) == repository['inventorySha256'] + source = ROOT / 'build-pdfium-intent-context/source' + originals = {row['path']: row for line in inventory.open() + if (row := json.loads(line))['archived'] and row['path'].startswith('core/')} + changed = {} + for name, row in originals.items(): + assert row['mode'] == '100644' + if sha(source / name) != row['sha256']: + changed[name] = row + added = {str(path.relative_to(source)) for path in (source / 'core').rglob('*') + if path.is_file() and str(path.relative_to(source)) not in originals} + assert all(Path(name).suffix in ('.h', '.cpp') for name in added) + chunks = [] + records = [] + with tarfile.open(archive) as tar: + for name in sorted(changed.keys() | added): + before = b'' if name in added else tar.extractfile('pdfium/' + name).read() + if name in changed: + assert hashlib.sha256(before).hexdigest() == changed[name]['sha256'] + after = (source / name).read_bytes() + diff = ''.join(difflib.unified_diff( + before.decode().splitlines(keepends=True), + after.decode().splitlines(keepends=True), + fromfile='/dev/null' if name in added else 'a/' + name, + tofile='b/' + name)) + assert diff + chunks.append('diff --git a/' + name + ' b/' + name + '\n' + diff) + records.append({'path': name, 'added': name in added, + 'beforeSha256': None if name in added else hashlib.sha256(before).hexdigest(), + 'afterSha256': hashlib.sha256(after).hexdigest()}) + destination = ROOT / 'cmake/patches/pdfium-rendering-intent-context.patch' + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(''.join(chunks)) + snapshot = results / 'patches' / (sha(destination) + '.patch') + snapshot.parent.mkdir(exist_ok=True) + if snapshot.exists(): + assert snapshot.read_bytes() == destination.read_bytes() + else: + snapshot.write_bytes(destination.read_bytes()) + # Check that this reviewable patch, rather than an unrecorded local change, + # reproduces every modified core source without fuzzy matching. + with tempfile.TemporaryDirectory(prefix='docview-pdfium-patch-') as folder: + check_root = Path(folder) + with tarfile.open(archive) as tar: + for name in changed: + target = check_root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(tar.extractfile('pdfium/' + name).read()) + result = subprocess.run(['patch', '-p1', '--batch', '--fuzz=0', '-i', str(destination)], + cwd=check_root, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + for row in records: + assert sha(check_root / row['path']) == row['afterSha256'] + record = {'success': True, 'exporterSha256': sha(Path(__file__)), + 'source': str(source.relative_to(ROOT)), + 'parentRecord': 'tests/results/pdfium-intent-build/record.json', + 'parentRecordSha256': sha(ROOT / 'tests/results/pdfium-intent-build/record.json'), + 'baseRevision': repository['revision'], 'archiveSha256': sha(archive), + 'patch': str(destination.relative_to(ROOT)), 'patchSha256': sha(destination), + 'patchSnapshot': str(snapshot.relative_to(ROOT)), + 'appliesWithoutFuzz': True, 'appliedSourcesByteIdentical': True, + 'files': records, + 'scope': 'Candidate core patch only. Provider shared-library/public-header patches are recorded separately. Production dependency is not replaced by this exporter.'} + (results / 'candidate-patch.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps({'files': len(records), 'patchBytes': destination.stat().st_size, + 'patchSha256': record['patchSha256']})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/export_pdfium_patch.py b/tests/cmyk_lut/export_pdfium_patch.py new file mode 100644 index 0000000..f47d1c7 --- /dev/null +++ b/tests/cmyk_lut/export_pdfium_patch.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Export the isolated PDFium core changes against the verified fixed archive.""" +import difflib +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + results = ROOT / 'tests/results/pdfium-intent-build' + materialization = json.loads((results / 'source-materialization.json').read_text()) + repository = next(row for row in materialization['repositories'] if row['path'] == '.') + archive = ROOT / repository['archive'] + inventory = ROOT / repository['inventory'] + assert sha(archive) == repository['archiveSha256'] + assert sha(inventory) == repository['inventorySha256'] + source = ROOT / materialization['source'] + originals = {row['path']: row for line in inventory.open() + if (row := json.loads(line))['archived'] and row['path'].startswith('core/')} + changed = {} + for name, row in originals.items(): + assert row['mode'] == '100644' + if sha(source / name) != row['sha256']: + changed[name] = row + added = {str(path.relative_to(source)) for path in (source / 'core').rglob('*') + if path.is_file() and str(path.relative_to(source)) not in originals} + assert all(Path(name).suffix in ('.h', '.cpp') for name in added) + chunks = [] + records = [] + with tarfile.open(archive) as tar: + for name in sorted(changed.keys() | added): + before = b'' if name in added else tar.extractfile('pdfium/' + name).read() + if name in changed: + assert hashlib.sha256(before).hexdigest() == changed[name]['sha256'] + after = (source / name).read_bytes() + diff = ''.join(difflib.unified_diff( + before.decode().splitlines(keepends=True), + after.decode().splitlines(keepends=True), + fromfile='/dev/null' if name in added else 'a/' + name, + tofile='b/' + name)) + assert diff + chunks.append('diff --git a/' + name + ' b/' + name + '\n' + diff) + records.append({'path': name, 'added': name in added, + 'beforeSha256': None if name in added else hashlib.sha256(before).hexdigest(), + 'afterSha256': hashlib.sha256(after).hexdigest()}) + destination = ROOT / 'cmake/patches/pdfium-rendering-intent.patch' + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(''.join(chunks)) + snapshot = results / 'patches' / (sha(destination) + '.patch') + snapshot.parent.mkdir(exist_ok=True) + if snapshot.exists(): + assert snapshot.read_bytes() == destination.read_bytes() + else: + snapshot.write_bytes(destination.read_bytes()) + # Check that this reviewable patch, rather than an unrecorded local change, + # reproduces every modified core source without fuzzy matching. + with tempfile.TemporaryDirectory(prefix='docview-pdfium-patch-') as folder: + check_root = Path(folder) + with tarfile.open(archive) as tar: + for name in changed: + target = check_root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(tar.extractfile('pdfium/' + name).read()) + result = subprocess.run(['patch', '-p1', '--batch', '--fuzz=0', '-i', str(destination)], + cwd=check_root, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + for row in records: + assert sha(check_root / row['path']) == row['afterSha256'] + record = {'success': True, 'exporterSha256': sha(Path(__file__)), + 'baseRevision': repository['revision'], 'archiveSha256': sha(archive), + 'patch': str(destination.relative_to(ROOT)), 'patchSha256': sha(destination), + 'patchSnapshot': str(snapshot.relative_to(ROOT)), + 'appliesWithoutFuzz': True, 'appliedSourcesByteIdentical': True, + 'files': records, + 'scope': 'Candidate core patch only. Provider shared-library/public-header patches are recorded separately. Production dependency is not replaced by this exporter.'} + (results / 'candidate-patch.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps({'files': len(records), 'patchBytes': destination.stat().st_size, + 'patchSha256': record['patchSha256']})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/fetch_pdfium_build_tools.py b/tests/cmyk_lut/fetch_pdfium_build_tools.py new file mode 100644 index 0000000..8bac8b5 --- /dev/null +++ b/tests/cmyk_lut/fetch_pdfium_build_tools.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Fetch only the fixed Linux GN, Clang and sysroot inputs for the isolated build.""" +from concurrent.futures import ThreadPoolExecutor +import hashlib +import json +from pathlib import Path +import time +import urllib.request + +ROOT=Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: return hashlib.file_digest(stream,'sha256').hexdigest() + + +def main(): + pins=json.loads((ROOT/'tests/results/source-correspondence/pdfium/dependency-pins.json').read_text()) + source=ROOT/'build-pdfium-intent/source' + clang=next(row for row in pins['root']['deps']['third_party/llvm-build/Release+Asserts']['objects'] + if row['object_name'].startswith('Linux_x64/clang-llvmorg-')) + sysroot=json.loads((source/'build/linux/sysroot_scripts/sysroots.json').read_text())['bullseye_amd64'] + gn_version=pins['root']['vars']['gn_version'] + inputs=[{'name':'clang.tar.xz','url':'https://commondatastorage.googleapis.com/chromium-browser-clang/'+clang['object_name']+'?generation='+str(clang['generation']), + 'expectedSha256':clang['sha256sum'],'expectedBytes':clang['size_bytes']}, + {'name':'sysroot.tar.xz','url':sysroot['URL']+'/'+sysroot['Sha256Sum'],'expectedSha256':sysroot['Sha256Sum']}, + {'name':'gn.zip','url':'https://chrome-infra-packages.appspot.com/dl/gn/gn/linux-amd64/+/'+gn_version, + 'cipdPackage':'gn/gn/linux-amd64','cipdVersion':gn_version}] + cache=ROOT/'build-pdfium-intent/downloads'; cache.mkdir(parents=True,exist_ok=False) + def fetch(row): + path=cache/row['name']; total=0; started=time.monotonic() + with urllib.request.urlopen(row['url'],timeout=60) as response, path.open('xb') as stream: + content_length=response.headers.get('Content-Length') + if content_length: assert int(content_length)<=512*1024**2 + for chunk in iter(lambda:response.read(1024*1024),b''): + total+=len(chunk) + assert total<=512*1024**2 and time.monotonic()-started<=600 + stream.write(chunk) + digest=sha(path) + if 'expectedSha256' in row: assert row['expectedSha256']==digest + if 'expectedBytes' in row: assert row['expectedBytes']==total + print(row['name']+' '+str(total)+' bytes; SHA-256 '+digest,flush=True) + return {**row,'path':str(path.relative_to(ROOT)),'bytes':total,'sha256':digest} + with ThreadPoolExecutor(max_workers=3) as pool: rows=list(pool.map(fetch,inputs)) + record={'success':True,'fetcherSha256':sha(Path(__file__)),'inputs':rows, + 'scope':'Clang and sysroot checked against source-pinned SHA-256. GN fetched from the official CIPD fixed Git-revision selector; archive SHA-256 recorded. No downloaded executable or installer has been run.'} + (ROOT/'tests/results/pdfium-intent-build/tool-downloads.json').write_text(json.dumps(record,indent=2)+'\n') + + +if __name__=='__main__':main() diff --git a/tests/cmyk_lut/intents.py b/tests/cmyk_lut/intents.py new file mode 100644 index 0000000..23dc04c --- /dev/null +++ b/tests/cmyk_lut/intents.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Strict ICC rendering-intent probes against direct CMM, PDFium and Poppler. + +The direct oracle never reads rendered pixels. It uses the embedded profile and +an independently selected expected intent. It may share LittleCMS algorithms +with the renderers, and is not claimed to be an independent CMM implementation. +""" +import argparse +import ctypes as C +import ctypes.util +from io import BytesIO +import hashlib +import itertools +import json +import math +import os +from pathlib import Path +import platform +import re +import shutil +import struct +import subprocess + +from PIL import Image, ImageChops, ImageCms, ImageDraw +from pypdf import PdfReader + +ROOT=Path(__file__).resolve().parents[2] +CORPUS=ROOT/'tests/fixtures/pdf/rendering-intents' +INTENTS=('Perceptual','RelativeColorimetric','Saturation','AbsoluteColorimetric') + + +def sha(path): + with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest() + + +class Cmm: + """Public LittleCMS API, normalized CMYK doubles -> unsigned 8-bit sRGB.""" + def __init__(self,profiles): + library=ctypes.util.find_library('lcms2') + if not library:raise RuntimeError('LittleCMS2 shared library is required') + self.lib=C.CDLL(library);self.transforms={};self.profiles=[];self.buffers=[] + declarations={ + 'cmsOpenProfileFromMem':([C.c_void_p,C.c_uint32],C.c_void_p), + 'cmsCreate_sRGBProfile':([],C.c_void_p), + 'cmsCreateTransform':([C.c_void_p,C.c_uint32,C.c_void_p,C.c_uint32,C.c_uint32,C.c_uint32],C.c_void_p), + 'cmsDoTransform':([C.c_void_p,C.c_void_p,C.c_void_p,C.c_uint32],None), + 'cmsDeleteTransform':([C.c_void_p],None),'cmsCloseProfile':([C.c_void_p],C.c_int), + 'cmsGetEncodedCMMversion':([],C.c_uint32)} + for name,(args,result) in declarations.items(): + function=getattr(self.lib,name);function.argtypes=args;function.restype=result + output=self.lib.cmsCreate_sRGBProfile();assert output;self.profiles.append(output) + for identifier,data in profiles.items(): + buffer=C.create_string_buffer(data) + self.buffers.append(buffer) + profile=self.lib.cmsOpenProfileFromMem(buffer,len(data));assert profile;self.profiles.append(profile) + for intent in range(4): + # lcms2.h public pixel-format macros: FLOAT_SH(1), PT_CMYK=6, + # PT_RGB=4, CHANNELS_SH(n), BYTES_SH(n). Double CMYK uses %. + transform=self.lib.cmsCreateTransform(profile,(1<<22)|(6<<16)|(4<<3), + output,(4<<16)|(3<<3)|1,intent,0) + assert transform;self.transforms[identifier,intent]=transform + self.version=self.lib.cmsGetEncodedCMMversion() + self.libraryPaths=[] + maps=Path('/proc/self/maps') + if maps.exists(): + paths={line.split()[-1] for line in maps.read_text().splitlines() + if '/' in line and 'liblcms2.so' in line} + self.libraryPaths=[{'file':Path(p).name,'sha256':sha(Path(p))} for p in sorted(paths) if Path(p).is_file()] + + def color(self,profile,intent,values): + assert len(values)==4 and all(math.isfinite(x) and 0<=x<=1 for x in values) + source=(C.c_double*4)(*(x*100 for x in values));target=(C.c_ubyte*3)() + self.lib.cmsDoTransform(self.transforms[profile,intent],source,target,1) + return list(target) + + def close(self): + for transform in self.transforms.values():self.lib.cmsDeleteTransform(transform) + for profile in self.profiles:self.lib.cmsCloseProfile(profile) + self.transforms.clear();self.profiles.clear();self.buffers.clear() + + +def tags_from(profile): + assert len(profile)==struct.unpack_from('>I',profile)[0] + assert profile[8:24]==bytes.fromhex('02100000')+b'scnrCMYKLab ' and profile[36:40]==b'acsp' + tags={} + for index in range(struct.unpack_from('>I',profile,128)[0]): + signature,offset,size=struct.unpack_from('>4sII',profile,132+12*index) + assert offset%4==0 and offset>=132 and offset+size<=len(profile) and signature not in tags + tags[signature]=profile[offset:offset+size] + return tags + + +def verify_fixture(corpus,spec): + source=corpus/spec['file'];assert sha(source)==spec['sha256'] + assert sha(ROOT/'tests/fixtures/pdf/generate_rendering_intents.py')==spec['generatorSha256'] + corrected=spec.get('oracleRevision')==2 + if corrected: + correction=spec['oracleCorrection'] + assert correction['correctorSha256']==sha(ROOT/'tests/cmyk_lut/correct_intent_oracle.py') + assert correction['sourceManifestSha256']=='b0db4ee3e57658b09d0ae10fcf8175986c3ab80ccd64442e3718802257ecf543' + original=ROOT/'tests/fixtures/pdf/cmyk-lut/synthetic-cmyk-lab.icc' + assert sha(original)==spec['baseProfileSha256'] + profiles={identifier:(corpus/item['file']).read_bytes() for identifier,item in spec['profiles'].items()} + assert profiles['original']==original.read_bytes() + vertex_count=0 + # Independent coefficient representation of the written linear CLUTs. + coefficients={b'A2B0':[(100,(-12,-15,-10,-35)),(0,(-15,18,0,0)),(0,(-12,0,20,0))], + b'A2B1':[(100,(-20,-20,-20,-40)),(0,(-20,40,0,-20)),(0,(-20,0,40,-20))], + b'A2B2':[(100,(-25,-25,-20,-30)),(0,(20,-40,0,20)),(0,(0,-20,35,-15))]} + for identifier,data in profiles.items(): + item=spec['profiles'][identifier] + assert hashlib.sha256(data).hexdigest()==item['sha256'] and len(data)==item['size'] + tags=tags_from(data) + assert set(tags)=={b'desc',b'cprt',b'wtpt',*(t.encode() for t in item['tables'])} + white=struct.unpack_from('>3i',tags[b'wtpt'],8) + assert all(abs(actual/65536-expected)<=1/65536 for actual,expected in zip(white,item['mediaWhitePoint'])) + for tag in item['tables']: + lut=tags[tag.encode()] + assert len(lut)==176 and lut[:12]==b'mft2'+bytes(4)+bytes((4,3,2,0)) + assert struct.unpack_from('>HH',lut,48)==(2,2) + assert lut[52:68]==struct.pack('>HH',0,65535)*4 and lut[164:176]==struct.pack('>HH',0,65535)*3 + for i,values in enumerate(itertools.product((0,1),repeat=4)): + encoded=struct.unpack_from('>3H',lut,68+i*6) + actual=(encoded[0]*100/65280,encoded[1]/256-128,encoded[2]/256-128) + expected=[offset+sum(a*b for a,b in zip(vector,values)) for offset,vector in coefficients[tag.encode()]] + assert max(abs(a-b) for a,b in zip(actual,expected))<.002 + vertex_count+=1 + pdf=PdfReader(source);assert len(pdf.pages)==spec['pageCount'] + identities={} + for index,page in enumerate(pdf.pages,1): + assert list(page.mediabox)==[0,0,*spec['pageSizePt']] + probes=[p for p in spec['probes'] if p['page']==index];assert probes + identifier=probes[0]['profile'];resources=page['/Resources'];space=resources['/ColorSpace']['/CS'] + # pypdf 4 leaves array members indirect; dictionary lookup alone dereferences. + embedded_profile=space[1].get_object() + assert space[0]=='/ICCBased' and embedded_profile['/N']==4 and embedded_profile.get_data()==profiles[identifier] + assert resources['/ColorSpace']['/IX'][0]=='/Indexed' + lookup=resources['/ColorSpace']['/IX'][3] + assert (lookup.original_bytes if isinstance(lookup,str) else bytes(lookup))==bytes(spec['sampleBytes']) + xobjects=resources['/XObject'];shared=xobjects.raw_get('/Shared') + identities.setdefault(identifier,shared.idnum);assert identities[identifier]==shared.idnum + assert '/Intent' not in xobjects['/Shared'] and xobjects['/Shared'].get_data()==bytes(spec['sampleBytes']) + assert xobjects['/Indexed'].get_data()==b'\0' + for font_name in ('T3Rect','T3Image',*(f'T3Override{i}' for i in range(4))): + glyph_font=resources['/Font']['/'+font_name] + assert glyph_font['/Subtype']=='/Type3' and glyph_font['/FirstChar']==glyph_font['/LastChar']==65 + glyph=glyph_font['/CharProcs']['/A'].get_data();assert glyph.startswith(b'1000 0 d0\n') + if font_name=='T3Image':assert b'/Shared Do' in glyph + elif font_name.startswith('T3Override'):assert ('/'+INTENTS[int(font_name[-1])]+' ri').encode() in glyph + else:assert b' ri' not in glyph + assert '/RelativeColorimetric ri' not in xobjects['/Inherited'].get_data().decode() + for i,intent in enumerate(INTENTS): + assert resources['/ExtGState']['/I'+str(i)]['/RI']=='/'+intent + assert xobjects['/Image'+str(i)]['/Intent']=='/'+intent + assert ('/'+intent+' ri').encode() in xobjects['/Form'+str(i)].get_data() + assert ('/I'+str(i)+' gs').encode() in xobjects['/FormGs'+str(i)].get_data() + assert resources['/Pattern']['/P'+str(i)]['/ExtGState']['/RI']=='/'+intent + content=page.get_contents().get_data() + for probe in probes: + assert ('\n'.join(probe['commandSequence'])+'\n').encode() in content + expected=probe['expectedIntentIndex'];assert INTENTS[expected]==probe['expectedIntent'] + current=1 if probe['graphicsStateIntent']=='default' else INTENTS.index(probe['graphicsStateIntent']) + if corrected and probe['case']=='shading-pattern-inherit': + assert expected==1 + elif probe['case'] in ('nested-q-inner','form-ri-override','form-gs-override','image-intent-override','shading-pattern-RI-override','type3-ri-override'): + assert expected==(current+1)%4 + else:assert expected==current + assert len(spec['probes'])==len(spec['cases'])*4*2 + return profiles,{'pagesVerified':len(pdf.pages),'probesVerified':len(spec['probes']), + 'clutVerticesVerified':vertex_count,'sharedImageObjectIds':identities, + 'originalProfileByteIdentical':True,'oracleRevision':2 if corrected else 1, + 'knownIncorrectPatternOracle':not corrected} + + +def cmm_checks(profiles,spec,cmm): + sample=tuple(spec['sampleBytes']);checks=[];colors={};bpc_checks=[] + for identifier,data in profiles.items(): + profile=ImageCms.ImageCmsProfile(BytesIO(data));colors[identifier]=[] + for intent in range(4): + expected=cmm.color(identifier,intent,[x/255 for x in sample]);colors[identifier].append(expected) + transform=ImageCms.buildTransformFromOpenProfiles(profile,ImageCms.createProfile('sRGB'),'CMYK','RGB',renderingIntent=intent) + actual=list(ImageCms.applyTransform(Image.new('CMYK',(1,1),sample),transform).getpixel((0,0))) + error=max(abs(a-b) for a,b in zip(actual,expected)) + checks.append({'profile':identifier,'intent':INTENTS[intent],'directCmmRgb':expected, + 'pillow8BitRgb':actual,'maxChannelError':error,'success':error<=1}) + if identifier=='distinct': + # Pillow 10.2 (Ubuntu 24.04) exposes the same public flag in + # FLAGS; later releases expose the Flags enum instead. + bpc_flag=(ImageCms.Flags.BLACKPOINTCOMPENSATION if hasattr(ImageCms,'Flags') + else ImageCms.FLAGS['BLACKPOINTCOMPENSATION']) + assert int(bpc_flag)==8192 + with_bpc=ImageCms.buildTransformFromOpenProfiles(profile,ImageCms.createProfile('sRGB'),'CMYK','RGB', + renderingIntent=intent,flags=bpc_flag) + bpc=list(ImageCms.applyTransform(Image.new('CMYK',(1,1),sample),with_bpc).getpixel((0,0))) + assert max(abs(a-b) for a,b in zip(bpc,expected))<=1 + bpc_checks.append({'intent':INTENTS[intent],'rgb':bpc,'flags':8192,'matchesWithoutBpc':True}) + assert all(c['success'] for c in checks) + separation=[max(abs(a-b) for a,b in zip(colors['distinct'][i],colors['distinct'][j])) for i,j in itertools.combinations(range(4),2)] + assert min(separation)>2*spec['tolerance8Bit'],'Derived profile must distinguish every intent beyond tolerance' + return {'checks':checks,'distinctProfileBlackPointCompensationChecks':bpc_checks, + 'distinctProfileMinimumPairwiseMaxChannelSeparation':min(separation), + 'originalProfileIntentEquivalence':'Relative/Saturation/Absolute may coincide; distinct profile covers their dispatch.'} + + +def loaded_pdfium(worker,environment,requested): + text=subprocess.run(['ldd',str(worker)],capture_output=True,text=True,env=environment,check=True,timeout=30).stdout + match=re.search(r'^\s*libpdfium\.so\s+=>\s+(/.+?)\s+\(0x',text,re.M) + assert match,'ldd did not resolve worker libpdfium.so' + path=Path(match[1]).resolve(strict=True) + if requested:assert path==requested.resolve(strict=True),'Requested library lost to loader/RPATH precedence' + return {'path':str(path),'sha256':sha(path),'ldd':text,'selectionVerified':True} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build-dir',type=Path) + parser.add_argument('--pdfium-library',type=Path,help='Prepend this library directory to LD_LIBRARY_PATH and verify ldd resolves it') + parser.add_argument('--corpus',type=Path,default=CORPUS) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--self-check',action='store_true',help='Validate fixture/CMM only; not rendering acceptance') + parser.add_argument('--scales',type=float,nargs='+',default=[.5,1.,2.]) + args=parser.parse_args() + if not args.self_check and not args.build_dir:parser.error('--build-dir is required unless --self-check') + if not 1<=len(args.scales)<=4 or any(not math.isfinite(s) or s<.25 or s>4 for s in args.scales):parser.error('scales must be 1..4 finite values in .25..4') + if len(set(args.scales))!=len(args.scales):parser.error('scales must be unique') + output=args.output.resolve();output.mkdir(parents=True,exist_ok=False) + spec=json.loads((args.corpus/'manifest.json').read_text());source=(args.corpus/spec['file']).resolve() + report={'schemaVersion':1,'success':False,'fixtureValidationSuccess':False,'renderingAcceptance':False, + 'mode':'fixture-self-check' if args.self_check else 'renderer-comparison','commands':[],'scales':[], + 'runnerSha256':sha(Path(__file__)),'fixtureManifestSha256':sha(args.corpus/'manifest.json'), + 'fixtureSha256':sha(source),'pythonVersion':platform.python_version(),'pillowLcmsVersion':ImageCms.core.littlecms_version, + 'osRelease':platform.freedesktop_os_release(),'tolerance8Bit':spec['tolerance8Bit'], + 'oracleRevision':spec.get('oracleRevision',1),'oracleKnownIssue':spec.get('oracleRevision')!=2, + 'scope':'Direct explicit-intent CMM expected colors; production isolated PDF worker and independent Poppler. ' + 'CMM may share LittleCMS code with PDF renderers. Synthetic profiles only; no physical display/press, ' + 'human-approved golden or general ICC conformance assertion.'} + cmm=None;binaries={};environment=os.environ.copy() + try: + profiles,verified=verify_fixture(args.corpus,spec);report['fixtureVerification']=verified + cmm=Cmm(profiles);report['directCmm']={'encodedVersion':cmm.version,'libraries':cmm.libraryPaths, + 'inputFormat':'TYPE_CMYK_DBL, 0..100 percent','outputFormat':'TYPE_RGB_8, cmsCreate_sRGBProfile','flags':0} + report['cmmValidation']=cmm_checks(profiles,spec,cmm);report['fixtureValidationSuccess']=True + if not args.self_check: + build=args.build_dir.resolve();binaries={name:sha(build/name) for name in ('pdf-worker-evidence','docview-pdf-worker')} + report['binaries']=binaries + if args.pdfium_library: + environment['LD_LIBRARY_PATH']=str(args.pdfium_library.resolve().parent)+(':'+environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else '') + assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT'),'Loader injection would invalidate evidence' + report['pdfiumLibrary']=loaded_pdfium(build/'docview-pdf-worker',environment,args.pdfium_library) + for program in ('qpdf','pdftoppm'):assert shutil.which(program),program+' required' + report['popplerVersion']=subprocess.run(['pdftoppm','-v'],capture_output=True,text=True,check=True).stderr.splitlines()[0] + def run(label,command,env=None): + log=output/(label+'.log') + with log.open('w') as f:result=subprocess.run(command,stdout=f,stderr=subprocess.STDOUT,env=env,timeout=300) + report['commands'].append({'name':label,'command':command,'exitCode':result.returncode,'logSha256':sha(log)}) + assert result.returncode==0,label+' failed' + run('qpdf-check',['qpdf','--check',str(source)]) + for scale in args.scales: + label='scale-'+str(scale).replace('.','_');folder=output/label;(folder/'pdfium').mkdir(parents=True) + run(label+'-pdfium',[str(build/'pdf-worker-evidence'),str(source),str(folder/'pdfium'),str(scale)],environment) + run(label+'-poppler',['pdftoppm','-cropbox','-r',str(72*scale),'-png',str(source),str(folder/'poppler')]) + metadata=json.loads((folder/'pdfium/metadata.json').read_text()) + assert metadata['pageCount']==spec['pageCount'] and metadata['renderScale']==scale and 'sandbox enabled' in metadata['transport'] + row={'scale':scale,'probes':[],'pdfiumSuccess':True,'popplerSuccess':True,'pages':[]} + for page in range(1,spec['pageCount']+1): + paths={'pdfium':folder/'pdfium'/f'page-{page}.png','poppler':folder/f'poppler-{page}.png'} + images={key:Image.open(path).convert('RGB') for key,path in paths.items()} + dimensions=[math.ceil(v*scale) for v in spec['pageSizePt']] + assert all(list(image.size)==dimensions for image in images.values()) + for probe in (p for p in spec['probes'] if p['page']==page): + x,y=probe['pointPt'];pixel=(round(x*scale),round((spec['pageSizePt'][1]-y)*scale)) + components=probe['components'] + if 'shading' in probe: + shading=probe['shading'];t=((pixel[0]+.5)/scale-shading['xStart'])/(shading['xEnd']-shading['xStart']) + components=[a+(b-a)*t for a,b in zip(shading['c0'],shading['c1'])] + expected=cmm.color(probe['profile'],probe['expectedIntentIndex'],components) + actual={key:list(image.getpixel(pixel)) for key,image in images.items()} + errors={key:max(abs(a-b) for a,b in zip(color,expected)) for key,color in actual.items()} + matches={key:error<=spec['tolerance8Bit'] for key,error in errors.items()} + row['probes'].append({**probe,'pointPx':pixel,'sampledComponents':components,'expectedRgb':expected, + 'actualRgb':actual,'maxChannelError':errors,'matches':matches,'success':all(matches.values())}) + for key,matched in matches.items():row[key+'Success'] &= matched + diff=ImageChops.difference(images['pdfium'],images['poppler']);diff.save(folder/f'difference-{page}.png') + sheet=Image.new('RGB',(1260,420),'#eeeeee');draw=ImageDraw.Draw(sheet) + for column,(label2,image) in enumerate([*images.items(),('difference',diff)]): + draw.text((column*420+8,7),f'{scale:g}x page {page}: {label2}',fill='black') + thumb=image.copy();thumb.thumbnail((410,380));sheet.paste(thumb,(column*420+5,30)) + sheet.save(folder/f'comparison-{page}.png') + row['pages'].append({'page':page,'images':{key:sha(path) for key,path in paths.items()}}) + row['success']=row['pdfiumSuccess'] and row['popplerSuccess'];report['scales'].append(row) + report['originalUnchanged']=sha(source)==spec['sha256'] and all(sha(args.corpus/item['file'])==item['sha256'] for item in spec['profiles'].values()) + report['binariesUnchanged']=all(sha(build/name)==value for name,value in binaries.items()) and sha(Path(report['pdfiumLibrary']['path']))==report['pdfiumLibrary']['sha256'] + report['pdfiumSuccess']=all(row['pdfiumSuccess'] for row in report['scales']) + report['popplerSuccess']=all(row['popplerSuccess'] for row in report['scales']) + report['renderingAcceptance']=report['success']=report['pdfiumSuccess'] and report['popplerSuccess'] and report['originalUnchanged'] and report['binariesUnchanged'] and not report['oracleKnownIssue'] + except Exception as error: + report['error']=type(error).__name__+': '+str(error) + raise + finally: + if cmm:cmm.close() + report['evidenceSha256']={str(p.relative_to(output)):sha(p) for p in sorted(output.rglob('*')) if p.is_file()} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps({key:report.get(key) for key in ('mode','fixtureValidationSuccess','success','pdfiumSuccess','popplerSuccess')})) + if not args.self_check and not report['success']:raise SystemExit('Intent validation failed; retained report preserves every failed probe') + + +if __name__=='__main__':main() diff --git a/tests/cmyk_lut/prepare_pdfium_source.py b/tests/cmyk_lut/prepare_pdfium_source.py new file mode 100644 index 0000000..28b974c --- /dev/null +++ b/tests/cmyk_lut/prepare_pdfium_source.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Materialize previously verified fixed Git archives for an isolated PDFium build.""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import tarfile + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: return hashlib.file_digest(stream,'sha256').hexdigest() + + +def main(): + reports=[ROOT/'tests/results/source-archives/pdfium-git/report.json', + ROOT/'tests/results/source-archives/pdfium-gitlink/report.json'] + repositories=[] + for path in reports: + report=json.loads(path.read_text()); assert report['success'] + repositories.extend(report['repositories']) + source=ROOT/'build-pdfium-intent/source' + source.mkdir(parents=True,exist_ok=False) + output=ROOT/'tests/results/pdfium-intent-build'; output.mkdir(parents=True,exist_ok=False) + materialized=[]; links=[]; total=0; count=0 + for repository in repositories: + archive=ROOT/repository['archive']; inventory=ROOT/repository['inventory'] + assert sha(archive)==repository['archiveSha256'] and sha(inventory)==repository['inventorySha256'] + expected={r['path']:r for line in inventory.open() if (r:=json.loads(line))['archived']} + prefix='pdfium/'+('' if repository['path']=='.' else repository['path']+'/') + found=set() + with tarfile.open(archive) as stream: + for member in stream: + assert member.name.startswith(prefix) + relative=member.name.removeprefix(prefix) + if member.isdir(): continue + assert relative in expected and relative not in found + found.add(relative); row=expected[relative] + name=PurePosixPath(member.name.removeprefix('pdfium/')) + assert not name.is_absolute() and '..' not in name.parts and '\\' not in str(name) + path=source/name; assert path.resolve().is_relative_to(source) and not path.exists() + path.parent.mkdir(parents=True,exist_ok=True) + if member.issym(): + assert row['mode']=='120000' and member.linkname==row['linkTarget'] + target=PurePosixPath(member.linkname) + assert not target.is_absolute() and (path.parent/target).resolve().is_relative_to(source) + assert hashlib.sha256(member.linkname.encode()).hexdigest()==row['sha256'] + links.append((path,member.linkname)) + continue + assert member.isfile() and row['mode'] in ('100644','100755') and member.size==row['bytes'] + total+=member.size; count+=1 + assert member.size<=256*1024**2 and total<=2*1024**3 and count<=100000 + digest=hashlib.sha256() + with stream.extractfile(member) as incoming, path.open('xb') as outgoing: + for chunk in iter(lambda:incoming.read(1024*1024),b''): + digest.update(chunk); outgoing.write(chunk) + assert digest.hexdigest()==row['sha256'] + path.chmod(0o755 if row['mode']=='100755' else 0o644) + assert found==expected.keys() + materialized.append({k:repository[k] for k in ('path','revision','archive','archiveSha256','inventory','inventorySha256')}) + for path,target in links: + assert (path.parent/target).resolve().is_file() + path.symlink_to(target) + record={'success':True,'preparerSha256':sha(Path(__file__)),'source':str(source.relative_to(ROOT)), + 'repositories':materialized,'regularFiles':count,'regularBytes':total,'symlinks':len(links), + 'sourceReports':{str(p.relative_to(ROOT)):sha(p) for p in reports}, + 'scope':'Fixed sources materialized for a separate local build. No source hooks, downloads or build executed by this preparer. Production dependency remains unchanged.'} + (output/'source-materialization.json').write_text(json.dumps(record,indent=2)+'\n') + print(json.dumps({'repositories':len(materialized),'regularFiles':count,'regularBytes':total,'symlinks':len(links)})) + + +if __name__=='__main__':main() diff --git a/tests/cmyk_lut/prepare_pdfium_tools.py b/tests/cmyk_lut/prepare_pdfium_tools.py new file mode 100644 index 0000000..d9cf8f5 --- /dev/null +++ b/tests/cmyk_lut/prepare_pdfium_tools.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Materialize the three verified, pinned tools without running download hooks.""" +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import tarfile +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / 'build-pdfium-intent/source' +RESULTS = ROOT / 'tests/results/pdfium-intent-build' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + downloads = json.loads((RESULTS / 'tool-downloads.json').read_text()) + destinations = { + 'clang.tar.xz': 'third_party/llvm-build/Release+Asserts', + 'sysroot.tar.xz': 'build/linux/debian_bullseye_amd64-sysroot', + 'gn.zip': 'buildtools/linux64', + } + records = [] + for row in downloads['inputs']: + archive = ROOT / row['path'] + assert sha(archive) == row['sha256'] + destination = SOURCE / destinations[row['name']] + already_extracted = destination.exists() + destination.mkdir(parents=True, exist_ok=True) + if row['name'].endswith('.tar.xz'): + with tarfile.open(archive) as tar: + members = tar.getmembers() + assert len(members) < 30000 + assert sum(member.size for member in members) < 1024**3 + for member in members: + assert member.isfile() or member.isdir() or member.issym() or member.islnk() + assert not PurePosixPath(member.name).is_absolute() + assert '..' not in PurePosixPath(member.name).parts + # Python's data filter also rejects links that escape the destination. + tarfile.data_filter(member, str(destination)) + if not already_extracted: + tar.extractall(destination, members=members, filter='data') + for member in members: + target = destination / member.name + if member.isfile() or member.islnk(): + with tar.extractfile(member) as stream: + assert sha(target) == hashlib.file_digest(stream, 'sha256').hexdigest() + elif member.issym(): + assert target.is_symlink() and str(target.readlink()) == member.linkname + else: + with zipfile.ZipFile(archive) as zip_file: + assert set(zip_file.namelist()) == {'gn', '.cipdpkg/manifest.json'} + manifest = json.loads(zip_file.read('.cipdpkg/manifest.json')) + assert manifest['package_name'] == row['cipdPackage'] + for member in zip_file.infolist(): + assert member.file_size < 16 * 1024**2 + target = destination / member.filename + target.parent.mkdir(parents=True, exist_ok=True) + if not already_extracted: + target.write_bytes(zip_file.read(member)) + target.chmod((member.external_attr >> 16) & 0o777) + assert target.read_bytes() == zip_file.read(member) + records.append({'archive': row['path'], 'sha256': row['sha256'], + 'destination': str(destination.relative_to(ROOT))}) + stamp = SOURCE / 'third_party/llvm-build/Release+Asserts/cr_build_revision' + stamp.write_text('llvmorg-24-init-3796-g20e97c4b-4\n') + gclient = SOURCE / 'build/config/gclient_args.gni' + gclient_text = ('android_ndk_version = "2@30.0.16138531"\n' + 'build_with_chromium = false\ncheckout_android = false\n' + 'checkout_libpng = true\ncheckout_skia = false\n') + if gclient.exists(): + assert gclient.read_text() == gclient_text + else: + gclient.write_text(gclient_text) + versions = {} + for name, relative in [('gn', 'buildtools/linux64/gn'), + ('clang', 'third_party/llvm-build/Release+Asserts/bin/clang'), + ('lld', 'third_party/llvm-build/Release+Asserts/bin/ld.lld')]: + result = subprocess.run([str(SOURCE / relative), '--version'], check=True, + capture_output=True, text=True) + versions[name] = result.stdout.strip() + assert '150a9d6ba0aa' in versions['gn'] + assert 'clang version 24.0.0git' in versions['clang'] + record = {'success': True, 'preparerSha256': sha(Path(__file__)), + 'tools': records, 'versions': versions, + 'gclientArgsSha256': sha(gclient), + 'scope': 'Pinned tools extracted; version commands only. No system installation or source download hooks.'} + (RESULTS / 'tool-materialization.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps(record, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/prepare_reference_cmm.py b/tests/cmyk_lut/prepare_reference_cmm.py new file mode 100644 index 0000000..288b94b --- /dev/null +++ b/tests/cmyk_lut/prepare_reference_cmm.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Prepare an isolated, pinned LittleCMS reference for the dedicated Ubuntu VM. + +No package installation, system linker change, PDFium replacement, or downloads. +The PDFium allocator integration is reversed only in the dedicated build copy. +Other vendored changes remain and are explicitly identified in the build record. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +import tarfile + +ROOT=Path(__file__).resolve().parents[2] +REVISION='b76633e60c8387a77268fb3359277ca25b5fd75c' + + +def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() +def write_json(path,value): path.write_text(json.dumps(value,indent=2)+'\n') +def json_hash(value): return hashlib.sha256(json.dumps(value,sort_keys=True,separators=(',',':')).encode()).hexdigest() + + +def pack(args): + source=args.source.resolve(strict=True);output=args.output.resolve();output.mkdir(parents=True,exist_ok=False) + readme=(source/'README.pdfium').read_text() + assert 'Version: 2.19\n' in readme and 'Revision: '+REVISION+'\n' in readme + assert re.search(r'#define\s+LCMS_VERSION\s+2190\b',(source/'include/lcms2.h').read_text()) + files={} + for p in sorted(source.rglob('*')): + if p.is_dir():continue + assert p.is_file() and not p.is_symlink() and p.resolve().is_relative_to(source) + relative=p.relative_to(source) + allowed=(len(relative.parts)==1 and (p.name in ('LICENSE','README.pdfium') or p.suffix=='.patch')) or (relative.parts[0] in ('src','include') and p.suffix in ('.c','.h')) + assert allowed and p.stat().st_size<=2*1024*1024 + files['third_party/lcms/'+str(relative)]=p.read_bytes() + assert len(files)<100 and sum(map(len,files.values()))<16*1024*1024 + reference=json.loads(args.reference_report.read_text());other=json.loads(args.comparison_report.read_text()) + assert reference['directCmm']['encodedVersion']==2190 and other['directCmm']['encodedVersion']==2140 + rows=[];comparisons=[] + for sr,so in zip(reference['scales'],other['scales'],strict=True): + assert sr['scale']==so['scale'] + for a,b in zip(sr['probes'],so['probes'],strict=True): + fields=('page','profile','case','column','expectedIntentIndex','sampledComponents') + assert all(a[k]==b[k] for k in fields) + row={k:a[k] for k in fields};row.update(scale=sr['scale'],expectedRgb=a['expectedRgb']) + rows.append(row) + comparisons.append({'scale':sr['scale'],'page':a['page'],'case':a['case'],'column':a['column'],'profile':a['profile'], + 'referenceExpectedRgb':a['expectedRgb'],'system214ExpectedRgb':b['expectedRgb'], + 'pdfiumRgbEqual':a['actualRgb']['pdfium']==b['actualRgb']['pdfium'], + 'pdfiumRgb':a['actualRgb']['pdfium'],'expectedRgbEqual':a['expectedRgb']==b['expectedRgb']}) + assert len(rows)==600 + profiles={} + corpus=ROOT/'tests/fixtures/pdf/rendering-intents' + manifest=json.loads((corpus/'manifest.json').read_text()) + for key,item in manifest['profiles'].items(): + path=corpus/item['file'];assert sha(path)==item['sha256'] + files['profiles/'+path.name]=path.read_bytes();profiles[key]={'file':'profiles/'+path.name,'sha256':sha(path)} + expectations={'referenceReportSha256':sha(args.reference_report),'referenceDirectCmm':reference['directCmm'], + 'comparisonReportSha256':sha(args.comparison_report),'profiles':profiles,'probes':rows, + 'scope':'CMM numerical reproducibility only; the older shading-pattern-inherit state expectation is not adopted as PDF specification acceptance.'} + files['expected.json']=(json.dumps(expectations,indent=2)+'\n').encode() + files['intents.py']=Path(__file__).with_name('intents.py').read_bytes() + files['prepare_reference_cmm.py']=Path(__file__).read_bytes() + archive=output/'reference-lcms219.tar' + with tarfile.open(archive,'w',format=tarfile.PAX_FORMAT) as tar: + for name,data in sorted(files.items()): + info=tarfile.TarInfo(name);info.size=len(data);info.mode=0o644;info.mtime=0 + tar.addfile(info,io.BytesIO(data)) + inventory=[{'path':name,'size':len(data),'sha256':hashlib.sha256(data).hexdigest()} for name,data in sorted(files.items())] + record={'schemaVersion':1,'upstreamVersion':'2.19','upstreamRevision':REVISION, + 'sourceScope':'Fixed PDFium-vendored source; allocator integration will be reversed in build copy only.', + 'archive':archive.name,'archiveSha256':sha(archive),'files':inventory, + 'sourceInventorySha256':json_hash([r for r in inventory if r['path'].startswith('third_party/')]), + 'referenceReportSha256':sha(args.reference_report),'comparisonReportSha256':sha(args.comparison_report)} + write_json(output/'bundle.json',record) + write_json(output/'cross-os-before.json',{'scope':expectations['scope'],'rows':comparisons, + 'total':len(comparisons),'allPdfiumRgbEqual':all(r['pdfiumRgbEqual'] for r in comparisons), + 'differentExpectedRgb':sum(not r['expectedRgbEqual'] for r in comparisons)}) + print(json.dumps({'archive':str(archive),'sha256':record['archiveSha256'],'files':len(inventory)})) + + +def build(args): + bundle=args.bundle.resolve(strict=True);record=json.loads(bundle.read_text());archive=bundle.parent/record['archive'] + assert sha(archive)==record['archiveSha256'] + output=args.output.resolve();output.mkdir(parents=True,exist_ok=False) + source=output/'source';source.mkdir() + rows={r['path']:r for r in record['files']};assert len(rows)==len(record['files'])<=100 + with tarfile.open(archive,'r:') as tar: + entries=tar.getmembers();assert len(entries)==len(rows) + seen=set() + for entry in entries: + name=entry.name;relative=Path(name) + assert name in rows and name not in seen and entry.isfile() and not relative.is_absolute() and '..' not in relative.parts + assert entry.size==rows[name]['size']<=2*1024*1024 + seen.add(name);data=tar.extractfile(entry).read() + assert hashlib.sha256(data).hexdigest()==rows[name]['sha256'] + path=source/relative;path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(data) + lcms=source/'third_party/lcms';cmserr=lcms/'src/cmserr.c';before=sha(cmserr) + patch_source=lcms/'0000-cmserr-changes.patch' + # This file first includes a historical patch-file diff. Select only its + # final, actual cmserr.c delta, never execute arbitrary packaged hooks. + marker='diff --git a/third_party/lcms/src/cmserr.c b/third_party/lcms/src/cmserr.c\n' + patch_text=patch_source.read_text();assert patch_text.count('\n'+marker)==1 + delta=patch_text[patch_text.index('\n'+marker)+1:] + assert delta.count('\ndiff --git ')==0 + patch_file=output/'restore-standard-allocator.patch';patch_file.write_text(delta) + commands=[] + def run(label,command,**kwargs): + result=subprocess.run(command,capture_output=True,text=True,timeout=300,**kwargs) + log=output/(label+'.log');log.write_text(result.stdout+result.stderr) + commands.append({'command':command,'exitCode':result.returncode,'log':log.name,'logSha256':sha(log)}) + assert result.returncode==0,label+' failed; see '+str(log) + return result.stdout + run('allocator-restore',['patch','--batch','--fuzz=0','--reverse','-p1','-i',str(patch_file)],cwd=source) + assert 'FXMEM_' not in cmserr.read_text() and '#include "core/' not in cmserr.read_text() + changes=[] + for row in record['files']: + now=sha(source/row['path']) + if now!=row['sha256']:changes.append({'path':row['path'],'before':row['sha256'],'after':now}) + assert len(changes)==1 and changes[0]['path']=='third_party/lcms/src/cmserr.c' and changes[0]['before']==before + compiler=shutil.which('gcc');assert compiler + version=run('compiler-version',[compiler,'--version']);target=run('compiler-target',[compiler,'-dumpmachine']).strip() + library_dir=output/'lib';library_dir.mkdir();library=library_dir/'liblcms2.so.2.0.19' + inputs=[str(p) for p in sorted((lcms/'src').glob('*.c'))];assert len(inputs)==26 + compile_command=[compiler,'-std=c99','-O2','-fPIC','-D_POSIX_C_SOURCE=200809L','-shared', + '-Wl,-soname,liblcms2.so.2','-Wl,-z,defs','-I'+str(source),'-I'+str(lcms/'include'), + *inputs,'-lm','-lpthread','-o',str(library)] + run('compile',compile_command) + (library_dir/'liblcms2.so.2').symlink_to(library.name);(library_dir/'liblcms2.so').symlink_to(library.name) + dependencies=run('ldd',['ldd',str(library)]);assert 'not found' not in dependencies + dynamic=run('readelf',['readelf','-d',str(library)]) + environment=os.environ.copy();environment['LD_LIBRARY_PATH']=str(library_dir) + assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT') + run('runtime-version',[sys.executable,'-c', + 'import ctypes; from PIL import ImageCms; c=ctypes.CDLL("liblcms2.so.2"); print(c.cmsGetEncodedCMMversion(), ImageCms.core.littlecms_version); assert c.cmsGetEncodedCMMversion()==2190; assert ImageCms.core.littlecms_version=="2.19"'],env=environment) + result={'schemaVersion':1,'success':True,'bundleSha256':sha(bundle),'archiveSha256':record['archiveSha256'], + 'sourceInventorySha256':record['sourceInventorySha256'],'upstreamVersion':'2.19','upstreamRevision':REVISION, + 'derivedSourceChanges':changes,'allocatorPatchSha256':sha(patch_file), + 'remainingVendorPatches':'Retained; this is a pinned PDFium-vendored reference build with only its allocator integration reversed.', + 'compiler':{'path':compiler,'sha256':sha(Path(compiler).resolve()),'version':version,'target':target}, + 'library':{'path':str(library),'size':library.stat().st_size,'sha256':sha(library),'soname':'liblcms2.so.2'}, + 'dependencies':dependencies,'dynamicSection':dynamic,'commands':commands, + 'osRelease':platform.freedesktop_os_release(),'pythonVersion':platform.python_version(), + 'scope':'Isolated reference library only; no system installation or loader configuration changes.'} + write_json(output/'build.json',result) + print(json.dumps({'success':True,'library':str(library),'sha256':sha(library)})) + + +def proof(args): + # Must start a fresh Python interpreter with the desired LD_LIBRARY_PATH; + # changing it after import cannot select an already loaded native library. + output=args.output.resolve();output.mkdir(parents=True,exist_ok=False) + data=json.loads(args.expected.read_text());source=args.expected.resolve().parent + sys.path.insert(0,str(source)) + from intents import Cmm + from PIL import Image,ImageCms + profiles={key:(source/row['file']).read_bytes() for key,row in data['profiles'].items()} + assert all(sha(source/row['file'])==row['sha256'] for row in data['profiles'].values()) + cmm=Cmm(profiles) + try: + assert cmm.version==args.version + paths={line.split()[-1] for line in Path('/proc/self/maps').read_text().splitlines() if 'liblcms2.so' in line and '/' in line} + assert len(paths)==1 + actual=Path(next(iter(paths))).resolve() + if args.library:assert actual==args.library.resolve(strict=True) + assert ImageCms.core.littlecms_version==f'{args.version//1000}.{(args.version%1000)//10}' + module=Path(ImageCms.core.__file__) + ldd=subprocess.run(['ldd',str(module)],capture_output=True,text=True,check=True,timeout=20).stdout + match=re.search(r'liblcms2\.so\.2 => (\S+)',ldd);assert match and Path(match[1]).resolve()==actual + pillow={} + for key,profile in profiles.items(): + for i in range(4): + pillow[key,i]=ImageCms.buildTransformFromOpenProfiles(ImageCms.ImageCmsProfile(io.BytesIO(profile)),ImageCms.createProfile('sRGB'),'CMYK','RGB',renderingIntent=i) + rows=[];quantized=[] + for p in data['probes']: + color=cmm.color(p['profile'],p['expectedIntentIndex'],p['sampledComponents']) + rows.append({**p,'actualRgb':color,'equal':color==p['expectedRgb'], + 'maxChannelError':max(abs(a-b) for a,b in zip(color,p['expectedRgb']))}) + for key in profiles: + for i in range(4): + pixel=(51,128,204,77) + direct=cmm.color(key,i,[v/255 for v in pixel]) + pil=list(ImageCms.applyTransform(Image.new('CMYK',(1,1),pixel),pillow[key,i]).getpixel((0,0))) + quantized.append({'profile':key,'intent':i,'directRgb':direct,'pillowRgb':pil, + 'maxChannelError':max(abs(a-b) for a,b in zip(direct,pil))}) + report={'schemaVersion':1,'success':all(p['equal'] for p in rows) and all(p['maxChannelError']<=1 for p in quantized), + 'referenceReportSha256':data['referenceReportSha256'],'expectedFileSha256':sha(args.expected), + 'expectedScope':data['scope'],'pythonVersion':platform.python_version(),'encodedVersion':cmm.version, + 'pillowVersion':__import__('PIL').__version__,'pillowLcmsVersion':ImageCms.core.littlecms_version, + 'loadedLibrary':{'path':str(actual),'sha256':sha(actual)},'mappedLibraries':sorted(paths), + 'pillowExtension':{'path':str(module),'sha256':sha(module),'ldd':ldd}, + 'total':len(rows),'identical':sum(p['equal'] for p in rows),'different':sum(not p['equal'] for p in rows), + 'pillowChecks':quantized,'probes':rows, + 'limitations':'Numerical matching at the declared probes, not independent-CMM or PDF semantic correctness.'} + write_json(output/'proof.json',report) + print(json.dumps({k:report[k] for k in ('success','encodedVersion','pillowLcmsVersion','total','identical','different')})) + if args.version==2190:assert report['success'],'Pinned CMM differs from host reference' + finally:cmm.close() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__);commands=parser.add_subparsers(dest='command',required=True) + p=commands.add_parser('pack');p.add_argument('--source',type=Path,required=True);p.add_argument('--reference-report',type=Path,required=True);p.add_argument('--comparison-report',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.set_defaults(function=pack) + p=commands.add_parser('build');p.add_argument('--bundle',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.set_defaults(function=build) + p=commands.add_parser('proof');p.add_argument('--expected',type=Path,required=True);p.add_argument('--output',type=Path,required=True);p.add_argument('--version',type=int,choices=(2140,2190),required=True);p.add_argument('--library',type=Path);p.set_defaults(function=proof) + args=parser.parse_args();args.function(args) + + +if __name__=='__main__':main() diff --git a/tests/cmyk_lut/record.py b/tests/cmyk_lut/record.py new file mode 100644 index 0000000..6fb58ee --- /dev/null +++ b/tests/cmyk_lut/record.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Publish failing color evidence without converting diagnosis into acceptance.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[2] +RESULTS=ROOT/'tests/results/cmyk-lut' + + +def sha(path): + with path.open('rb') as stream: return hashlib.file_digest(stream,'sha256').hexdigest() + + +def main(): + prior=json.loads((RESULTS/'prior-validation-record.json').read_text()) + assert sha(RESULTS/'prior-validation-record.json')=='c80457d32c246e40743f59f5e602ff0f71d59d496b33a72618a97475659c9013' + for name,digest in prior['evidenceSha256'].items(): assert sha(ROOT/name)==digest,name + sources={str(p.relative_to(ROOT)):sha(p) for directory in ('src','qml','cmake','resources') + for p in (ROOT/directory).rglob('*') if p.is_file() and '__pycache__' not in p.parts} + sources.update({name:sha(ROOT/name) for name in ('CMakeLists.txt','resources.qrc')}) + assert sources==prior['sourceSha256'] + for name,digest in prior['builds']['arch']['binaries'].items(): assert sha(ROOT/'build'/name)==digest + package=prior['ubuntuPackage'] + assert sha(ROOT/'build-ubuntu-vm/packages'/package['archive'])==package['archiveSha256'] + diagnosis=json.loads((RESULTS/'diagnosis.json').read_text()) + assert diagnosis['diagnosisVerified'] and not diagnosis['renderingAcceptance'] + assert not diagnosis['relativeIntentPreserved'] and diagnosis['toleranceUnchanged']==4 + assert diagnosis['diagnoserSha256']==sha(ROOT/'tests/cmyk_lut/diagnose.py') + reports={} + for os_name in ('arch','ubuntu'): + report=json.loads((RESULTS/os_name/'report.json').read_text()) + assert not report['success'] and report['originalUnchanged'] and report['binariesUnchanged'] + assert len(report['scales'])==3 + for name,digest in report['evidenceSha256'].items(): assert sha(RESULTS/os_name/name)==digest + assert report['binaries']['docview-pdf-worker']==prior['builds'][os_name]['binaries']['docview-pdf-worker'] + assert report['runnerSha256']==sha(ROOT/'tests/cmyk_lut/run.py') + entry=next(r for r in diagnosis['results'] if r['os']==os_name) + assert entry['comparisonReportSha256']==sha(RESULTS/os_name/'report.json') + assert entry['failedPdfiumAnalyticProbes']==30 and entry['failedPopplerAnalyticProbes']==0 + reports[os_name]=report + assert sha(ROOT/'build/pdf-worker-evidence')==reports['arch']['binaries']['pdf-worker-evidence'] + assert sha(ROOT/'.deps/pdfium/lib/libpdfium.so')=='08f6ea53a64f6fbb9f5ba8d9c02e0051987c6a9175f3fb5ba25f219174731aaa' + reproduced=json.loads((RESULTS/'reproduction.json').read_text()) + for name,digest in reproduced['filesByteIdentical'].items(): + assert sha(ROOT/'tests/fixtures/pdf/cmyk-lut'/name)==digest==sha(RESULTS/'reproduction'/name) + shutdown=json.loads((RESULTS/'shutdown.json').read_text()) + assert shutdown['qemuExitCode']==0 and shutdown['guestPowerDownObserved'] + assert sha(RESULTS/'shutdown-tail.txt')==shutdown['tailSha256'] + gallery=[''' +ICC CMYK 描画比較 + +

ICC CMYK の追加描画比較

+

色の一致は未達です。 Arch・Ubuntu各48点中30点が許容差を超えました。原因の照合は完了していますが、描画の受入や人による承認は完了していません。

+

相対的な測色を指定した資料に対して、固定PDFiumは知覚的なICC変換を使用します。上2段の右端の暗色に明瞭な差があります。下段はプロファイルを適用しないDeviceCMYKの対照です。

+

試験条件・原因・未完了事項 / 生成PDF / 数値による原因照合

+

各画像を開くと元の解像度で確認できます。差分画像は絶対RGB差で、色差の許容性や印刷色を判定する画像ではありません。

'''] + for os_name,label in [('arch','Arch Linux'),('ubuntu','Ubuntu 24.04')]: + gallery.append('

'+label+'

') + for scale in reports[os_name]['scales']: + folder=os_name+'/scale-'+str(scale['scale']).replace('.','_') + gallery.append(f'

{scale["scale"]:g}倍 / {scale["dpi"]:g} dpi

') + for title,name in [('PDFium / 本番隔離ワーカー','pdfium/page-1.png'),('Poppler / 独立した比較','poppler.png'),('絶対RGB差','difference.png')]: + path=folder+'/'+name + assert (RESULTS/path).is_file() + gallery.append(f'
{title}
{title}の描画比較
') + gallery.append('
') + gallery.append('
') + (RESULTS/'index.html').write_text('\n'.join(gallery)) + tools=['tests/cmyk_lut/run.py','tests/cmyk_lut/diagnose.py','tests/cmyk_lut/record.py','tests/fixtures/pdf/generate_cmyk_lut.py','tests/render_pdf_worker.cpp'] + new_requirement='Resolve or explicitly review the fixed PDFium ICC rendering-intent discrepancy demonstrated by the CMYK CLUT fixture; runtime detection/notice is not yet implemented' + record={**prior,'schemaVersion':4,'recordedAtUtc':datetime.now(timezone.utc).isoformat(), + 'scope':'Unchanged validation3 product plus a new, failing CMYK ICC rendering comparison on Arch and Ubuntu. ' + 'Prior passing functional/package tests remain scoped to their original runs; color acceptance is not achieved.', + 'goalComplete':False,'colorRenderingFinding':diagnosis, + 'colorRenderingReports':{name:{'path':'tests/results/cmyk-lut/'+name+'/report.json','sha256':sha(RESULTS/name/'report.json')} for name in reports}, + 'previousValidation':{'record':'tests/results/cmyk-lut/prior-validation-record.json','sha256':sha(RESULTS/'prior-validation-record.json'), + 'scope':'Validation3 package, all prior test executions and their exact product identities. No product bytes changed in the ICC investigation.'}, + 'remainingAcceptance':prior['remainingAcceptance']+[new_requirement], + 'validationToolsSha256':{**prior['validationToolsSha256'],**{name:sha(ROOT/name) for name in tools}}, + 'evidenceSha256':{**prior['evidenceSha256'],**{str(p.relative_to(ROOT)):sha(p) for folder in (RESULTS,ROOT/'tests/fixtures/pdf/cmyk-lut') + for p in sorted(folder.rglob('*')) if p.is_file() and p.suffix!='.pyc' and p!=RESULTS/'record.json'}}} + encoded=json.dumps(record,ensure_ascii=False,indent=2)+'\n' + (RESULTS/'record.json').write_text(encoded); (ROOT/'docs/validation-record.json').write_text(encoded) + print(json.dumps({'colorRenderingAcceptance':False,'failedAnalyticProbes':60,'productUnchanged':True,'goalComplete':False})) + + +if __name__=='__main__': main() diff --git a/tests/cmyk_lut/record_candidate.py b/tests/cmyk_lut/record_candidate.py new file mode 100644 index 0000000..eb086ef --- /dev/null +++ b/tests/cmyk_lut/record_candidate.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Add isolated-candidate evidence while preserving production failure history.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = ROOT / 'tests/results/pdfium-intent-build' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def read(relative): + return json.loads((RESULTS / relative).read_text()) + + +def main(): + previous = RESULTS / 'prior-validation-record.json' + assert sha(previous) == 'e171862993beb61a9786ffee1f6a4a869ba30e3c3bd03223918a20d8e41f297b' + prior = json.loads(previous.read_text()) + for name, digest in prior['evidenceSha256'].items(): + assert sha(ROOT / name) == digest, name + for name, digest in prior['sourceSha256'].items(): + assert sha(ROOT / name) == digest, name + for name, digest in prior['validationToolsSha256'].items(): + assert sha(ROOT / name) == digest, name + for name, digest in prior['builds']['arch']['binaries'].items(): + assert sha(ROOT / 'build' / name) == digest, name + package = prior['ubuntuPackage'] + assert sha(ROOT / 'build-ubuntu-vm/packages' / package['archive']) == package['archiveSha256'] + assert sha(ROOT / '.deps/pdfium/lib/libpdfium.so') == '08f6ea53a64f6fbb9f5ba8d9c02e0051987c6a9175f3fb5ba25f219174731aaa' + candidate = read('candidate-final/report.json') + assert candidate['upstreamTestsPass'] and candidate['candidatePdfiumProbesPass'] + assert candidate['allBinaryInputsUnchanged'] and not candidate['productionDependencyReplaced'] + assert not candidate['success'] and not candidate['intentPopplerSuccess'] + assert [(r['tests'], r['failures']) for r in candidate['upstreamTests']] == [(1104, 0), (872, 0)] + for name, digest in candidate['evidenceSha256'].items(): + assert sha(RESULTS / 'candidate-final' / name) == digest, name + for name, digest in candidate['inputs'].items(): + assert sha(Path(name)) == digest, name + intents = read('candidate-final/intents/report.json') + probes = [p for scale in intents['scales'] for p in scale['probes']] + assert len(probes) == 600 and all(p['matches']['pdfium'] for p in probes) + comparison_failures = [p for p in probes if not p['matches']['poppler']] + assert len(comparison_failures) == 18 + assert {p['case'] for p in comparison_failures} == {'shading-pattern-RI-override'} + baseline = read('intents-baseline/report.json') + assert sum(not p['matches']['pdfium'] for s in baseline['scales'] for p in s['probes']) == 150 + ctest = read('docview-ctest/report.json') + assert ctest['exitCode'] == 0 and ctest['libraryUnchanged'] + assert ctest['librarySha256'] == intents['pdfiumLibrary']['sha256'] + suite = ET.parse(RESULTS / 'docview-ctest/tests.xml').getroot() + assert suite.tag == 'testsuite' and suite.attrib['tests'] == '22' and suite.attrib['failures'] == '0' + patch = read('candidate-patch.json') + assert patch['success'] and patch['appliesWithoutFuzz'] and patch['appliedSourcesByteIdentical'] + assert sha(ROOT / patch['patch']) == patch['patchSha256'] + assert sha(ROOT / patch['patchSnapshot']) == patch['patchSha256'] + for row in patch['files']: + assert sha(ROOT / 'build-pdfium-intent/source' / row['path']) == row['afterSha256'] + reproduced = read('fixture-reproduction.json') + assert reproduced['success'] and len(reproduced['filesByteIdentical']) == 4 + for name, digest in reproduced['filesByteIdentical'].items(): + assert sha(RESULTS / 'reproduced-fixture' / name) == digest + assert sha(ROOT / 'tests/fixtures/pdf/rendering-intents' / name) == digest + tools = ['tests/cmyk_lut/' + name + '.py' for name in + ('prepare_pdfium_source', 'fetch_pdfium_build_tools', 'prepare_pdfium_tools', + 'export_pdfium_patch', 'validate_pdfium_candidate', 'record_candidate', 'intents')] + tools.append('tests/fixtures/pdf/generate_rendering_intents.py') + candidate_summary = { + 'productionDependencyReplaced': False, 'candidatePdfiumProbesPass': True, + 'humanRenderingAcceptance': False, 'crossRendererComparisonAccepted': False, + 'sourceRevision': patch['baseRevision'], 'patch': patch['patch'], + 'patchSha256': patch['patchSha256'], 'changedCoreFiles': len(patch['files']), + 'patchSnapshot': patch['patchSnapshot'], + 'library': intents['pdfiumLibrary']['path'], 'librarySha256': intents['pdfiumLibrary']['sha256'], + 'upstreamTests': candidate['upstreamTests'], 'docviewCtestGroups': 22, + 'cmykAnalyticProbesPassed': 48, 'intentProbesPassed': 600, + 'popplerFailedProbes': 18, 'popplerFailureCase': 'shading-pattern-RI-override', + 'report': 'tests/results/pdfium-intent-build/candidate-final/report.json', + 'reportSha256': sha(RESULTS / 'candidate-final/report.json'), + 'scope': 'Arch isolated candidate with existing application/worker binaries; no adoption into the Ubuntu package, Windows execution, calibrated-output or human golden acceptance.'} + evidence = {str(path.relative_to(ROOT)): sha(path) + for folder in (RESULTS, ROOT / 'tests/fixtures/pdf/rendering-intents') + for path in sorted(folder.rglob('*')) if path.is_file() + and '__pycache__' not in path.parts and path != RESULTS / 'record.json'} + record = {**prior, 'schemaVersion': 5, 'recordedAtUtc': datetime.now(timezone.utc).isoformat(), + 'scope': 'Unchanged validation3 product plus an isolated source-built PDFium rendering-intent candidate. Candidate numeric/upstream/application tests pass; comparator differences and final adoption/acceptance remain.', + 'goalComplete': False, 'pdfiumRenderingIntentCandidate': candidate_summary, + 'previousValidation': {'record': str(previous.relative_to(ROOT)), 'sha256': sha(previous), + 'scope': 'Unchanged product and original failing ICC evidence; retained without rewriting its verdict.'}, + 'sourceSha256': {**prior['sourceSha256'], patch['patch']: patch['patchSha256']}, + 'validationToolsSha256': {**prior['validationToolsSha256'], **{name: sha(ROOT / name) for name in tools}}, + 'evidenceSha256': {**prior['evidenceSha256'], **evidence}, + 'remainingAcceptance': [*prior['remainingAcceptance'], + 'Review and adopt the source-built rendering-intent patch; validate Ubuntu packaging and remaining rendering boundaries (including dedicated soft-mask/tiling probes). Independent Poppler shading-pattern intent differences remain recorded.']} + encoded = json.dumps(record, ensure_ascii=False, indent=2) + '\n' + (RESULTS / 'record.json').write_text(encoded) + (ROOT / 'docs/validation-record.json').write_text(encoded) + print(json.dumps({'goalComplete': False, 'candidateProbesPassed': 648, + 'upstreamTestsPassed': 1976, 'docviewGroupsPassed': 22, + 'evidenceFiles': len(record['evidenceSha256'])})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/record_context_candidate.py b/tests/cmyk_lut/record_context_candidate.py new file mode 100644 index 0000000..0a21582 --- /dev/null +++ b/tests/cmyk_lut/record_context_candidate.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Freeze a build/test anchor for the corrected PDFium candidate, before staging.""" +import hashlib +import json +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = ROOT / 'tests/results/pdfium-intent-context' +PARENT = ROOT / 'tests/results/pdfium-intent-build/record.json' +PARENT_SHA = '47b4dc2efbf8e671f904610b3047400dbd3ceb0fa10d6bd53fb04f3727943e7e' +LIBRARY_SHA = 'cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def rel(path): + return str(path.relative_to(ROOT)) + + +def check_nested(path): + data = json.loads(path.read_text()) + for name, digest in data.get('evidenceSha256', {}).items(): + assert sha(path.parent / name) == digest, name + return data + + +def main(): + destination = RESULTS / 'record.json' + assert not destination.exists(), 'The build anchor is immutable; create another version for changed inputs.' + assert sha(PARENT) == PARENT_SHA + parent = json.loads(PARENT.read_text()) + source = ROOT / 'build-pdfium-intent-context/source' + library = source / 'out/patched/libpdfium.so' + assert sha(library) == LIBRARY_SHA + patch = json.loads((RESULTS / 'candidate-patch.json').read_text()) + assert patch['success'] and patch['appliesWithoutFuzz'] and patch['appliedSourcesByteIdentical'] + assert patch['parentRecordSha256'] == PARENT_SHA + assert sha(ROOT / patch['patch']) == sha(ROOT / patch['patchSnapshot']) == patch['patchSha256'] + for row in patch['files']: + assert sha(source / row['path']) == row['afterSha256'], row['path'] + build = json.loads((RESULTS / 'build-2.json').read_text()) + assert build['exitCode'] == 0 + upstream = json.loads((RESULTS / 'upstream-tests-execution.json').read_text()) + assert upstream['success'] and upstream['librarySha256'] == LIBRARY_SHA + tests = 0 + for row in upstream['commands']: + assert row['exitCode'] == 0 + data = row['results'] + assert data['failures'] == data['errors'] == data['disabled'] == 0 + assert sha(Path(row['command'][0])) == row['executableSha256'] + tests += data['tests'] + assert tests == 1979 + arch = check_nested(RESULTS / 'arch/report.json') + assert arch['candidateTestsPass'] and arch['allBinaryInputsUnchanged'] + assert arch['inputs'][str(library)] == LIBRARY_SHA + assert arch['intents']['probeCount'] == 600 and arch['transparency']['probeCount'] == 324 + for name in ('cmyk', 'intents', 'transparency'): + check_nested(RESULTS / 'arch' / name / 'report.json') + junit = ET.parse(RESULTS / 'arch/tests.xml').getroot() + cases = list(junit.iter('testcase')) + assert len(cases) == 22 and not list(junit.iter('failure')) and not list(junit.iter('error')) + evidence = {rel(path): sha(path) for path in sorted(RESULTS.rglob('*')) + if path.is_file() and path != destination} + for name in ('source-materialization', 'tool-materialization', 'provider-patches'): + path = ROOT / ('tests/results/pdfium-intent-build/' + name + '.json') + assert sha(path) == parent['evidenceSha256'][rel(path)] + evidence[rel(path)] = sha(path) + evidence[rel(PARENT)] = PARENT_SHA + sources = {name: value for name, value in parent['sourceSha256'].items() + if name.startswith('resources/licenses/pdfium-supplemental/')} + for name, value in sources.items(): + assert sha(ROOT / name) == value + tools = [Path(__file__), ROOT / 'tests/cmyk_lut/export_pdfium_context_patch.py', + ROOT / 'tests/cmyk_lut/intents.py', ROOT / 'tests/cmyk_lut/transparency.py', + ROOT / 'tests/cmyk_lut/correct_intent_oracle.py', ROOT / 'tests/cmyk_lut/validate_context_candidate.py', + ROOT / 'tests/fixtures/pdf/generate_intent_transparency.py'] + sources.update({rel(path): sha(path) for path in tools}) + for folder in ('rendering-intents-context', 'intent-transparency'): + sources.update({rel(path): sha(path) for path in (ROOT / 'tests/fixtures/pdf' / folder).iterdir() if path.is_file()}) + record = {'schemaVersion': 6, 'goalComplete': False, + 'parent': {'path': rel(PARENT), 'sha256': PARENT_SHA}, + 'scope': 'Immutable Linux candidate build and Arch test anchor before staging. Ubuntu, package adoption and release acceptance are recorded separately.', + 'pdfiumRenderingIntentCandidate': { + 'sourceRevision': patch['baseRevision'], 'source': rel(source), + 'library': str(library), 'librarySha256': LIBRARY_SHA, + 'patch': patch['patch'], 'patchSha256': patch['patchSha256'], + 'patchSnapshot': patch['patchSnapshot'], 'report': rel(RESULTS / 'arch/report.json'), + 'reportSha256': sha(RESULTS / 'arch/report.json'), + 'productionDependencyReplaced': False, 'comparisonAccepted': False, + 'candidateProbesPass': True, 'candidateProbeCount': 972, + 'upstreamTestCount': tests, 'docviewCTestGroups': 22, + 'oracleRevision': 2, + 'supersedes': 'The old shading-pattern-inherit expectation was incorrect. The historical v1 pass remains a numerical result against that old expectation, not pattern conformance evidence.'}, + 'evidenceSha256': evidence, 'sourceSha256': sources} + destination.write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps({'recordSha256': sha(destination), 'evidenceFiles': len(evidence), + 'sourceFiles': len(sources), 'candidateProbeCount': 972})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/record_context_integration.py b/tests/cmyk_lut/record_context_integration.py new file mode 100644 index 0000000..a2c5de9 --- /dev/null +++ b/tests/cmyk_lut/record_context_integration.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Record the tested v2 deliverables without rewriting their fixed build anchor.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / 'tests/results/pdfium-intent-context' +PARENT = ROOT / 'tests/results/pdfium-intent-build/record.json' +PARENT_SHA = '47b4dc2efbf8e671f904610b3047400dbd3ceb0fa10d6bd53fb04f3727943e7e' +ANCHOR_SHA = '80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def relative(path): + return str(path.relative_to(ROOT)) + + +def reference(path): + return {'path': relative(path), 'sha256': sha(path)} + + +def main(): + current = ROOT / 'docs/validation-record.json' + destination = OUT / 'integration-record.json' + assert not destination.exists(), 'Integration records are immutable.' + assert sha(current) == sha(PARENT) == PARENT_SHA + assert sha(OUT / 'record.json') == ANCHOR_SHA + parent = json.loads(PARENT.read_text()) + anchor = json.loads((OUT / 'record.json').read_text()) + required = { + 'archCandidate': OUT / 'arch/report.json', + 'ubuntuCandidate': OUT / 'ubuntu/report.json', + 'archNewBuild': OUT / 'arch-integration/report.json', + 'ubuntuNewBuildAndPackage': OUT / 'ubuntu-package/report.json', + 'sdkFreeUbuntu': OUT / 'ubuntu-package/clean-vm/report.json', + 'performance': OUT / 'performance/report.json', + 'renderReview': OUT / 'render-review/report.json', + } + reports = {key: json.loads(path.read_text()) for key, path in required.items()} + for key, data in reports.items(): + assert data.get('candidateTestsPass', data.get('success')) is True, key + assert reports['renderReview']['humanApproved'] is False + arch = reports['archNewBuild'] + for name, digest in arch['binaries'].items(): + assert sha(ROOT / 'build-context' / name) == digest, name + library = ROOT / '.deps/pdfium-context/lib/libpdfium.so' + assert sha(library) == anchor['pdfiumRenderingIntentCandidate']['librarySha256'] + assert sha(ROOT / '.deps/pdfium/lib/libpdfium.so') == '08f6ea53a64f6fbb9f5ba8d9c02e0051987c6a9175f3fb5ba25f219174731aaa' + source_paths = {ROOT / name for name in parent['sourceSha256']} + source_paths.update(ROOT / name for name in parent['validationToolsSha256']) + for folder in ('tools', 'cmake', 'tests/cmyk_lut'): + source_paths.update(path for path in (ROOT / folder).rglob('*') + if path.is_file() and '__pycache__' not in path.parts) + required_sources = tuple(ROOT / name for name in ( + 'CMakeLists.txt', 'tests/test_stage_pdfium_candidate.py', + 'tests/test_pdfium_candidate_integration.py', 'tests/ubuntu_vm/context_package.py', + 'tests/ubuntu_vm/package_smoke.py', 'tests/ubuntu_vm/clean_package.py', + 'tests/compare_pdf_extended.py', 'tests/generate_render_review.py')) + for path in required_sources: + assert path.is_file(), relative(path) + source_paths.update(required_sources) + sources = {relative(path): sha(path) for path in sorted(source_paths) if path.is_file()} + changed = [{'path': name, 'previousSha256': digest, 'currentSha256': sources[name]} + for name, digest in {**parent['sourceSha256'], **parent['validationToolsSha256']}.items() + if name in sources and sources[name] != digest] + evidence = {} + for folder in (OUT, ROOT / 'tests/results/pdfium-intent-transparency', + ROOT / 'tests/results/pdfium-intent-ubuntu'): + for path in sorted(folder.rglob('*')): + if path.is_file() and path != destination and '__pycache__' not in path.parts: + evidence[relative(path)] = sha(path) + record = { + 'schemaVersion': 7, 'recordedAtUtc': datetime.now(timezone.utc).isoformat(), + 'goalComplete': False, + 'scope': 'Linux v2 candidate integrated into dedicated Arch and Ubuntu builds and a local validation deb. Earlier evidence is retained by immutable parent references. Target Windows/physical/human/release gates remain incomplete.', + 'previousValidation': reference(PARENT), + 'candidateBuildAnchor': reference(OUT / 'record.json'), + 'pdfiumRenderingIntentCandidate': anchor['pdfiumRenderingIntentCandidate'], + 'candidateIntegration': { + 'selectedLibrary': reference(library), + 'originalProviderPreserved': True, + 'buildInfo': reference(ROOT / '.deps/pdfium-context/BUILDINFO.json'), + 'reviewedLock': reference(ROOT / 'cmake/pdfium-candidate-v2.lock.json'), + 'archExecutable': 'build-context/docview', + 'archInstalledExecutable': 'build-context/install/bin/docview', + 'ubuntuPackageRecord': reference(required['ubuntuNewBuildAndPackage']), + }, + 'executables': arch['binaries'], + 'validationReports': {key: reference(path) for key, path in required.items()}, + 'oracleCorrection': reference(ROOT / 'tests/fixtures/pdf/rendering-intents-context/manifest.json'), + 'historicalOracleLimitation': 'The v1 shading-pattern-inherit expectations selected paint-time RI incorrectly. Its numerical pass is preserved as historical evidence, not pattern conformance. Revision 2 keeps the original PDF/profile bytes and corrects eight rows.', + 'referenceCmm': reference(ROOT / 'tests/results/pdfium-intent-ubuntu/reference-cmm/verification.json'), + 'remainingAcceptance': [ + 'Windows 11 native build, sandbox/pipe/IME execution and distribution tests', + 'Target-OS physical GPU/display/input, reference hardware and long-duration acceptance', + 'Human approval of G-RENDER reference images and documented comparison differences', + 'Final public distribution/license decision and complete corresponding-source/notice assessment', + ], + 'sourceSha256': sources, 'changedSincePreviousRecord': changed, + 'documentationSha256': {name: sha(ROOT / name) for name in ( + 'README.md', 'docs/PDFIUM-CANDIDATE.md', 'docs/VALIDATION.md', + 'docs/ACCEPTANCE-AUDIT.md', 'docs/UBUNTU-PACKAGE.md', + 'tests/PDF-VALIDATION.md')}, + 'evidenceSha256': evidence, + } + encoded = json.dumps(record, indent=2) + '\n' + destination.write_text(encoded) + current.write_text(encoded) + print(json.dumps({'schemaVersion': 7, 'sha256': sha(current), + 'evidenceFiles': len(evidence), 'sourceFiles': len(sources), + 'goalComplete': False})) + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/run.py b/tests/cmyk_lut/run.py new file mode 100644 index 0000000..9d59cbd --- /dev/null +++ b/tests/cmyk_lut/run.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Validate a known CMYK CLUT through the production isolated worker and Poppler.""" +import argparse +import hashlib +from io import BytesIO +import json +import math +import os +from pathlib import Path +import platform +import struct +import subprocess + +from PIL import Image, ImageChops, ImageCms, ImageDraw, ImageStat +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build-dir', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + build = args.build_dir.resolve(); output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + corpus = ROOT / 'tests/fixtures/pdf/cmyk-lut' + spec = json.loads((corpus / 'manifest.json').read_text()) + source = corpus / spec['file']; icc_file = corpus / spec['profileFile'] + assert sha(source) == spec['sha256'] and sha(icc_file) == spec['profileSha256'] + assert sha(ROOT / 'tests/fixtures/pdf/generate_cmyk_lut.py') == spec['generatorSha256'] + binaries = {name: sha(build / name) for name in ('pdf-worker-evidence', 'docview-pdf-worker')} + profile = icc_file.read_bytes() + assert len(profile) == spec['profileBytes'] == struct.unpack_from('>I', profile)[0] + assert profile[8:24] == bytes.fromhex('02100000') + b'scnrCMYKLab ' and profile[36:40] == b'acsp' + tags = {} + for i in range(struct.unpack_from('>I', profile, 128)[0]): + sig, offset, size = struct.unpack_from('>4sII', profile, 132+12*i) + assert offset % 4 == 0 and offset+size <= len(profile) and sig not in tags + tags[sig] = profile[offset:offset+size] + assert set(tags) == {b'desc', b'cprt', b'wtpt', b'A2B0'} + lut = tags[b'A2B0'] + assert len(lut) == 176 and lut[:12] == b'mft2' + bytes(4) + bytes((4,3,2,0)) + assert struct.unpack_from('>HH', lut, 48) == (2,2) + # Decode and check all 16 four-dimensional CLUT vertices independently. + for index in range(16): + c, m, y, k = [(index >> shift) & 1 for shift in (3,2,1,0)] + encoded = struct.unpack_from('>3H', lut, 68+index*6) + lab = [encoded[0]*100/65280, encoded[1]/256-128, encoded[2]/256-128] + expected = [100-12*c-15*m-10*y-35*k, 18*m-15*c, 20*y-12*c] + assert max(abs(a-b) for a,b in zip(lab,expected)) < .002 + pdf = PdfReader(source) + assert len(pdf.pages) == 1 and list(pdf.pages[0].mediabox)[2:] == spec['pageSizePt'] + resources = pdf.pages[0]['/Resources'] + vector_space = resources['/ColorSpace']['/TestCMYK'] + image_object = resources['/XObject']['/Sample'].get_object() + for space in (vector_space, image_object['/ColorSpace']): + assert space[0] == '/ICCBased' and space[1].get_object()['/N'] == 4 + assert space[1].get_object()['/Alternate'] == '/DeviceCMYK' + assert space[1].get_object().get_data() == profile + assert image_object['/Width'] == 8 and image_object['/Height'] == 1 and image_object['/BitsPerComponent'] == 8 + pixel_values = bytes(round(c*255) for p in spec['probes'] if p['kind'] == 'icc-image' for c in p['components']) + assert image_object.get_data() == pixel_values + icc = ImageCms.ImageCmsProfile(BytesIO(profile)) + transform = ImageCms.buildTransformFromOpenProfiles(icc, ImageCms.createProfile('sRGB'), 'CMYK', 'RGB', renderingIntent=1) + cmm_probes = [] + for probe in spec['probes']: + if probe['kind'] != 'icc-image': continue + pixel = Image.new('CMYK',(1,1),tuple(round(c*255) for c in probe['components'])) + actual = ImageCms.applyTransform(pixel, transform).getpixel((0,0)) + error = max(abs(a-b) for a,b in zip(actual,probe['expectedRgb'])) + cmm_probes.append({'sample':probe['sample'], 'expectedRgb':probe['expectedRgb'], 'actualRgb':actual, + 'maxChannelError':error, 'success':error <= spec['tolerance8Bit']}) + report = {'success':False, 'osRelease':platform.freedesktop_os_release(), 'fixture':spec, + 'python':platform.python_version(), 'pillowLcmsVersion':ImageCms.core.littlecms_version, + 'runnerSha256':sha(Path(__file__)), 'binaries':binaries, + 'rendererVersion':subprocess.run(['/usr/bin/pdftoppm','-v'],capture_output=True,text=True,check=True).stderr.splitlines()[0], + 'embeddedProfileAndImageBytesVerified':True, 'clutVerticesVerified':16, 'cmmProbes':cmm_probes, + 'scope':'Synthetic ICC v2 CMYK input -> Lab four-dimensional CLUT, vectors and images. ' + 'Production isolated worker at three scales and independent Poppler. ' + 'The CMM probe uses Pillow/LittleCMS and is not a third independent PDF renderer. ' + 'No calibrated press/display, arbitrary ICC conformance or human-approved golden.', + 'commands':[], 'scales':[]} + + def run(name, command): + with (output / (name+'.log')).open('w') as log: + result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, timeout=180) + report['commands'].append({'name':name, 'command':command, 'exitCode':result.returncode, + 'logSha256':sha(output / (name+'.log'))}) + assert result.returncode == 0, name + + try: + assert all(p['success'] for p in cmm_probes), 'Profile differs from the analytic model' + run('qpdf-check',['/usr/bin/qpdf','--check',str(source)]) + sheets=[] + for scale in (.5, 1., 4.): + name = 'scale-'+str(scale).replace('.','_'); folder=output/name + (folder/'pdfium').mkdir(parents=True) + run(name+'-pdfium',[str(build/'pdf-worker-evidence'),str(source),str(folder/'pdfium'),str(scale)]) + run(name+'-poppler',['/usr/bin/pdftoppm','-singlefile','-cropbox','-r',str(72*scale),'-png',str(source),str(folder/'poppler')]) + meta=json.loads((folder/'pdfium/metadata.json').read_text()) + assert meta['renderScale']==scale and 'sandbox enabled' in meta['transport'] + images={'pdfium':Image.open(folder/'pdfium/page-1.png').convert('RGB'), + 'poppler':Image.open(folder/'poppler.png').convert('RGB')} + dimensions=[math.ceil(v*scale) for v in spec['pageSizePt']] + assert all(list(im.size)==dimensions for im in images.values()) + row={'scale':scale, 'dpi':72*scale, 'dimensions':dimensions, 'probes':[], 'success':True} + for probe in spec['probes']: + x,y=probe['pointPt']; point=(round(x*scale),round((spec['pageSizePt'][1]-y)*scale)) + colors={key:list(im.getpixel(point)) for key,im in images.items()} + errors={key:max(abs(a-b) for a,b in zip(color,probe['expectedRgb'])) for key,color in colors.items()} if 'expectedRgb' in probe else {} + matched=all(error<=spec['tolerance8Bit'] for error in errors.values()) + row['probes'].append({**probe,'pointPx':point,'actualRgb':colors,'maxChannelError':errors, + 'checkedAgainstAnalyticColor':bool(errors),'success':matched}) + row['success'] &= matched + # A profile ignored in favor of its DeviceCMYK Alternate cannot pass. + black={p['kind']:p for p in row['probes'] if p['sample']==4} + differences={key:min(black['icc-vector']['actualRgb'][key][i]-black['device-control']['actualRgb'][key][i] + for i in range(3)) for key in images} + row['blackProfileMinusAlternateMinChannel']=differences + row['profileEffectDetected']=all(value>=80 for value in differences.values()) + row['success'] &= row['profileEffectDetected'] + diff=ImageChops.difference(images['pdfium'],images['poppler']); diff.save(folder/'difference.png') + row['meanAbsoluteChannelError']=sum(ImageStat.Stat(diff).mean)/3 + sheet=Image.new('RGB',(1500,340),'#eeeeee'); draw=ImageDraw.Draw(sheet) + for col,(label,im) in enumerate([('PDFium - isolated worker',images['pdfium']),('Poppler - independent',images['poppler']),('Absolute RGB difference',diff)]): + draw.text((col*500+10,10),f'{scale:g}x / {label}',fill='black') + thumb=im.copy(); thumb.thumbnail((488,295)); sheet.paste(thumb,(col*500+(500-thumb.width)//2,35)) + sheet.save(folder/'comparison.png'); sheets.append(sheet); report['scales'].append(row) + overview=Image.new('RGB',(1500,340*len(sheets)),'white') + for i,sheet in enumerate(sheets): overview.paste(sheet,(0,340*i)) + overview.save(output/'overview.png') + report['originalUnchanged']=sha(source)==spec['sha256'] and sha(icc_file)==spec['profileSha256'] + report['binariesUnchanged']=all(sha(build/name)==digest for name,digest in binaries.items()) + report['success']=all(row['success'] for row in report['scales']) and report['originalUnchanged'] and report['binariesUnchanged'] + finally: + report['evidenceSha256']={str(path.relative_to(output)):sha(path) for path in sorted(output.rglob('*')) if path.is_file()} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps({'success':report['success'],'scales':len(report['scales']), + 'analyticImageAndVectorProbes':sum(p['checkedAgainstAnalyticColor'] for row in report['scales'] for p in row['probes'])})) + if not report['success']: raise SystemExit('Color validation failed; inspect the retained report') + + +if __name__=='__main__': + main() diff --git a/tests/cmyk_lut/run_ubuntu_candidate.py b/tests/cmyk_lut/run_ubuntu_candidate.py new file mode 100644 index 0000000..b5dc21d --- /dev/null +++ b/tests/cmyk_lut/run_ubuntu_candidate.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Validate the fixed rendering-intent candidate in the dedicated Ubuntu guest.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +EXPECTED_LIBRARY = '0c968f503ce549bad5301af2cc3fd0b83c37437315ac0e62dff439d6a1c6cc4d' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--build-dir', type=Path, default=Path.home() / 'docview-build') + args = parser.parse_args() + os_release = platform.freedesktop_os_release() + assert os_release['ID'] == 'ubuntu' and os_release['VERSION_ID'] == '24.04' + library = args.library.resolve() + assert sha(library) == EXPECTED_LIBRARY + output = args.output.resolve() + build = args.build_dir.resolve() + inputs = [library, ROOT / '.deps/pdfium/lib/libpdfium.so'] + inputs += [build / name for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker', 'pdf-worker-evidence')] + identities = {str(path): sha(path) for path in inputs} + output.mkdir(parents=True, exist_ok=False) + env = os.environ.copy() + assert not env.get('LD_PRELOAD') and not env.get('LD_AUDIT') + assert not env.get('QTWEBENGINE_DISABLE_SANDBOX') + env.pop('WAYLAND_DISPLAY', None) + env.update(LD_LIBRARY_PATH=str(library.parent) + ':/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib', + QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', + QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_SCALE_FACTOR='1', + QT_AUTO_SCREEN_SCALE_FACTOR='0', XDG_SESSION_TYPE='x11') + report = {'schemaVersion': 1, 'success': False, 'osRelease': os_release, + 'platform': platform.platform(), 'pythonVersion': platform.python_version(), + 'runnerSha256': sha(Path(__file__)), 'inputs': identities, 'commands': [], + 'productionDependencyReplaced': False, + 'scope': 'Same fixed Linux candidate library as Arch, used by the existing Ubuntu-built application and workers. Dedicated Ubuntu VM, Xvfb/software, ordinary worker sandbox. No package installation or physical-display acceptance.'} + + def run(label, command): + start = time.monotonic() + log = output / (label + '.log') + with log.open('w') as stream: + result = subprocess.run(command, cwd=ROOT, env=env, + stdout=stream, stderr=subprocess.STDOUT, timeout=900) + report['commands'].append({'name': label, 'command': command, 'exitCode': result.returncode, + 'seconds': time.monotonic() - start, 'logSha256': sha(log)}) + print(label, result.returncode, flush=True) + return result.returncode + + try: + linked = subprocess.run(['ldd', str(build / 'docview-pdf-worker')], env=env, + capture_output=True, text=True, check=True).stdout + assert str(library) in linked and 'not found' not in linked + (output / 'worker-ldd.txt').write_text(linked) + run('cmyk', [sys.executable, str(ROOT / 'tests/cmyk_lut/run.py'), + '--build-dir', str(build), '--output', str(output / 'cmyk')]) + run('intents', [sys.executable, str(ROOT / 'tests/cmyk_lut/intents.py'), + '--build-dir', str(build), '--pdfium-library', str(library), + '--scales', '.5', '1', '4', '--output', str(output / 'intents')]) + ctest_status = run('ctest', ['xvfb-run', '-a', '-s', '-screen 0 1100x760x24', + 'dbus-run-session', '--', 'ctest', '--test-dir', str(build), + '--output-on-failure', '--output-junit', str(output / 'tests.xml')]) + shutil.copyfile(build / 'Testing/Temporary/LastTest.log', output / 'LastTest.log') + cmyk = json.loads((output / 'cmyk/report.json').read_text()) + intents = json.loads((output / 'intents/report.json').read_text()) + report['cmykSuccess'] = cmyk['success'] + report['intentPdfiumSuccess'] = intents.get('pdfiumSuccess', False) + report['intentPopplerSuccess'] = intents.get('popplerSuccess', False) + report['ctestSuccess'] = ctest_status == 0 + report['allBinaryInputsUnchanged'] = all(sha(Path(path)) == digest for path, digest in identities.items()) + report['candidateTestsPass'] = (report['cmykSuccess'] and report['intentPdfiumSuccess'] + and report['ctestSuccess'] and report['allBinaryInputsUnchanged']) + report['success'] = report['candidateTestsPass'] and intents['renderingAcceptance'] + finally: + report['evidenceSha256'] = {str(path.relative_to(output)): sha(path) + for path in sorted(output.rglob('*')) if path.is_file()} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: report.get(key) for key in ('success', 'candidateTestsPass', + 'intentPopplerSuccess', 'allBinaryInputsUnchanged')})) + if not report['success']: + raise SystemExit('Comparison not fully accepted; candidate and comparator verdicts are retained separately.') + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/transparency.py b/tests/cmyk_lut/transparency.py new file mode 100644 index 0000000..413605d --- /dev/null +++ b/tests/cmyk_lut/transparency.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Check RI masks/patterns against declared PDF state rules and a direct CMM. + +This runner never treats agreement between renderers as the expected value. +The numerical DeviceRGB mask convention is distinct from intent selection. +""" +import argparse +from io import BytesIO +import itertools +import json +import math +import os +from pathlib import Path +import platform +import shutil +import subprocess +from PIL import Image, ImageChops, ImageCms, ImageDraw +from pypdf import PdfReader +from intents import Cmm, INTENTS, loaded_pdfium, sha + +ROOT=Path(__file__).resolve().parents[2] +CORPUS=ROOT/'tests/fixtures/pdf/intent-transparency' +PROFILE_SHA='b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315' + + +def preserved_inputs(): + paths=[ROOT/'tests/fixtures/pdf/generate_rendering_intents.py',ROOT/'tests/cmyk_lut/intents.py'] + paths += sorted((ROOT/'tests/fixtures/pdf/rendering-intents').glob('*')) + return {str(p.relative_to(ROOT)):sha(p) for p in paths if p.is_file()} + + +def verify(corpus,spec): + assert spec['schemaVersion']==1 and spec['pageCount']==4 + assert spec['pageSizePt']==[880,850] and spec['sample']==[0,.75,.25,0] + assert spec['tolerance8Bit']==4 and len(spec['cases'])==27 + assert len(spec['probes'])==108 + assert sha(corpus/spec['file'])==spec['sha256'] + assert sha(ROOT/'tests/fixtures/pdf/generate_intent_transparency.py')==spec['generatorSha256'] + profile=(corpus/spec['profile']['file']).read_bytes() + assert len(profile)==984 and sha(corpus/spec['profile']['file'])==PROFILE_SHA==spec['profile']['sha256'] + assert profile==(ROOT/spec['profile']['origin']).read_bytes() + pdf=PdfReader(corpus/spec['file']);assert len(pdf.pages)==4 + identities={};probe_ids=set() + for page_index,page in enumerate(pdf.pages,1): + assert list(page.mediabox)==[0,0,880,850] + resources=page['/Resources'];patterns=resources['/Pattern'];gs=resources['/ExtGState'] + assert resources['/ColorSpace']['/CS'][1].get_object().get_data()==profile + assert resources['/ColorSpace']['/PCS'][0]=='/Pattern' + for key in ('U','ULarge'): + stencil=patterns['/'+key] + assert stencil['/PaintType']==2 + assert stencil.get_data().split()==[b'0',b'0',str(stencil['/XStep']).encode(),str(stencil['/YStep']).encode(),b're',b'f'] + for key in ('Tile','TileForm','ShadeForm'): + obj=patterns.raw_get('/'+key) if key=='Tile' else resources['/XObject'].raw_get('/'+key) + identities.setdefault(key,obj.idnum);assert identities[key]==obj.idnum + assert b' ri' not in patterns['/Tile'].get_data() + assert '/ExtGState' not in patterns['/Shade'] + for label in ('Tile','Shade'): + form=resources['/XObject']['/'+label+'Form'] + assert form.get_data().startswith(b'/AbsoluteColorimetric ri\n') + assert form['/Resources']['/Pattern'].raw_get('/Tile').idnum==identities['Tile'] + for i,intent in enumerate(INTENTS): + assert ('/'+intent+' ri').encode() in patterns['/TileRi'+str(i)].get_data() + assert patterns['/ShadeRi'+str(i)]['/ExtGState']['/RI']=='/'+intent + for key in ('Mask','OpaqueBC','BC0','BC1','BC2','BC3',*[f'MaskRi{i}' for i in range(4)], + *[f'MaskGs{i}' for i in range(4)],*[f'MaskQ{i}' for i in range(4)]): + mask=gs['/'+key]['/SMask'];group=mask['/G'] + assert mask['/S']=='/Luminosity' and mask['/TR']=='/Identity' + assert group['/Group']['/CS']=='/DeviceRGB' and bool(group['/Group']['/I']) + assert group['/Resources']['/ColorSpace']['/CS'][1].get_object().get_data()==profile + if key.startswith('BC'):assert group.get_data().strip()==b'' + assert gs['/NoMask']['/SMask']=='/None' + assert gs['/Mask']['/SMask'].raw_get('/G').idnum==gs['/OpaqueBC']['/SMask'].raw_get('/G').idnum + content=page.get_contents().get_data() + assert content.startswith(b'1 g 0 0 880 850 re f\n') + for p in (p for p in spec['probes'] if p['page']==page_index): + assert p['id'] not in probe_ids;probe_ids.add(p['id']) + assert ('\n'.join(p['commandSequence'])+'\n').encode() in content + case=p['case'];column=p['column'];current=(2,0,2,1)[column] if case.endswith('-reuse') else column + assert p['callerIntentIndex']==current and p['callerIntent']==INTENTS[current] + expected=current;oracle='icc' + if case in ('tiling-page-initial','tiling-colored-qQ','shading-page-initial'):expected=1 + elif case in ('tiling-colored-ri','tiling-colored-gs','tiling-form-ri','shading-pattern-ri','shading-form-ri', + 'mask-internal-ri','mask-internal-gs'):expected=(current+1)%4 + if case.startswith('mask-'): + oracle='luminosity-icc' + if case in ('mask-q-inner-none','mask-none'):oracle='black' + elif case=='mask-BC-only':oracle='luminosity-bc' + assert p['expectedIntentIndex']==expected and p['expectedIntent']==INTENTS[expected] + assert p['oracle']==oracle and p['components']==[0,.75,.25,0] + if oracle=='luminosity-bc':assert p['backgroundRgb']==[[1,0,0],[0,1,0],[0,0,1],[0,.5,1]][column] + return profile,{'pages':4,'probes':len(probe_ids),'reusedObjectIds':identities, + 'uncoloredStencilsContainOnlyGeometry':True,'profileByteIdentical':True, + 'expectedIntentIndependentlyChecked':True} + + +def oracle(probe,cmm): + kind=probe['oracle'] + if kind=='black':return [0,0,0],{'kind':kind} + if kind=='luminosity-bc':rgb=[255*c for c in probe['backgroundRgb']] + else:rgb=cmm.color('distinct',probe['expectedIntentIndex'],probe['components']) + if kind=='icc':return rgb,{'kind':kind,'directCmmRgb8':rgb} + luminance=sum(w*c for w,c in zip((.30,.59,.11),rgb)) + return [round(255-luminance)]*3,{'kind':kind,'groupRgb8':rgb,'luminosity8Bit':luminance, + 'recommendedDeviceRgbWeights':[.30,.59,.11]} + + +def cmm_checks(profile,cmm,spec): + colors=[cmm.color('distinct',i,spec['sample']) for i in range(4)] + lumas=[sum(w*c for w,c in zip((.30,.59,.11),rgb)) for rgb in colors] + separation=min(abs(a-b) for a,b in itertools.combinations(lumas,2)) + assert separation>2*spec['tolerance8Bit'] + source=ImageCms.ImageCmsProfile(BytesIO(profile));sample=tuple(round(v*255) for v in spec['sample']) + checks=[] + for i in range(4): + transform=ImageCms.buildTransformFromOpenProfiles(source,ImageCms.createProfile('sRGB'),'CMYK','RGB',renderingIntent=i) + quantized=list(ImageCms.applyTransform(Image.new('CMYK',(1,1),sample),transform).getpixel((0,0))) + assert max(abs(a-b) for a,b in zip(quantized,colors[i]))<=1 + checks.append({'intent':INTENTS[i],'directCmmRgb8':colors[i],'pillowQuantizedRgb8':quantized, + 'luminosity8Bit':lumas[i],'blackOnWhiteGray8Bit':round(255-lumas[i])}) + return {'values':checks,'minimumLuminositySeparation':separation, + 'noRenderedPixelsUsedForExpectedValues':True} + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build-dir',type=Path) + parser.add_argument('--pdfium-library',type=Path) + parser.add_argument('--corpus',type=Path,default=CORPUS) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--self-check',action='store_true') + parser.add_argument('--scales',type=float,nargs='+',default=[.5,1.,2.]) + args=parser.parse_args() + if not args.self_check and not args.build_dir:parser.error('--build-dir required for rendering') + if not 1<=len(args.scales)<=4 or len(set(args.scales))!=len(args.scales) or any(not math.isfinite(v) or not .25<=v<=4 for v in args.scales): + parser.error('scales must be 1..4 unique finite values in .25..4') + output=args.output.resolve();output.mkdir(parents=True,exist_ok=False) + old=preserved_inputs();spec=json.loads((args.corpus/'manifest.json').read_text());source=(args.corpus/spec['file']).resolve() + report={'schemaVersion':1,'success':False,'renderingAcceptance':False,'fixtureValidationSuccess':False, + 'mode':'fixture-self-check' if args.self_check else 'renderer-comparison', + 'runnerSha256':sha(Path(__file__)),'sharedCmmAdapterSha256':sha(Path(__file__).with_name('intents.py')), + 'fixtureManifestSha256':sha(args.corpus/'manifest.json'),'fixtureSha256':sha(source), + 'pythonVersion':platform.python_version(),'pillowLcmsVersion':ImageCms.core.littlecms_version, + 'tolerance8Bit':4,'commands':[],'scales':[],'preservedLegacyInputs':old, + 'scope':'Synthetic ICC RI dispatch and state probes. Direct public LittleCMS calls supply colors, not renderer pixels; ' + 'the renderers may use the same CMM. DeviceRGB luminosity uses the PDF recommended weights, which are device-dependent. ' + 'No physical-display, press, general ICC, or all-PDF acceptance claim.'} + cmm=None;environment=os.environ.copy();binaries={} + try: + profile,verification=verify(args.corpus,spec);report['fixtureVerification']=verification + cmm=Cmm({'distinct':profile}) + report['directCmm']={'encodedVersion':cmm.version,'libraries':cmm.libraryPaths, + 'input':'CMYK double percent','output':'8-bit sRGB','flags':0} + report['cmmChecks']=cmm_checks(profile,cmm,spec);report['fixtureValidationSuccess']=True + if not args.self_check: + build=args.build_dir.resolve() + binaries={name:sha(build/name) for name in ('pdf-worker-evidence','docview-pdf-worker')};report['binaries']=binaries + if args.pdfium_library: + environment['LD_LIBRARY_PATH']=str(args.pdfium_library.resolve().parent)+(':'+environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else '') + assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT') + report['pdfiumLibrary']=loaded_pdfium(build/'docview-pdf-worker',environment,args.pdfium_library) + for program in ('qpdf','pdftoppm'):assert shutil.which(program) + report['popplerVersion']=subprocess.run(['pdftoppm','-v'],capture_output=True,text=True,check=True).stderr.splitlines()[0] + def run(label,command,env=None): + log=output/(label+'.log') + with log.open('w') as f:result=subprocess.run(command,stdout=f,stderr=subprocess.STDOUT,env=env,timeout=300) + report['commands'].append({'name':label,'command':command,'exitCode':result.returncode,'logSha256':sha(log)}) + assert result.returncode==0,label+' failed' + run('qpdf-check',['qpdf','--check',str(source)]) + for scale in args.scales: + label='scale-'+str(scale).replace('.','_');folder=output/label;(folder/'pdfium').mkdir(parents=True) + run(label+'-pdfium',[str(build/'pdf-worker-evidence'),str(source),str(folder/'pdfium'),str(scale)],environment) + run(label+'-poppler',['pdftoppm','-cropbox','-r',str(72*scale),'-png',str(source),str(folder/'poppler')]) + metadata=json.loads((folder/'pdfium/metadata.json').read_text()) + assert metadata['pageCount']==spec['pageCount'] and metadata['renderScale']==scale and 'sandbox enabled' in metadata['transport'] + row={'scale':scale,'probes':[],'pdfiumSuccess':True,'popplerSuccess':True,'pages':[]} + for page in range(1,5): + paths={'pdfium':folder/'pdfium'/f'page-{page}.png','poppler':folder/f'poppler-{page}.png'} + images={key:Image.open(path).convert('RGB') for key,path in paths.items()} + assert all(list(im.size)==[math.ceil(v*scale) for v in spec['pageSizePt']] for im in images.values()) + for probe in (p for p in spec['probes'] if p['page']==page): + x,y=probe['pointPt'];pixel=(round(x*scale),round((850-y)*scale)) + expected,details=oracle(probe,cmm) + # Interior 3x3 minimum/maximum detects seams/unstable edge + # samples instead of silently selecting a favorable pixel. + samples={key:[list(im.getpixel((pixel[0]+dx,pixel[1]+dy))) for dy in (-1,0,1) for dx in (-1,0,1)] for key,im in images.items()} + actual={key:values[4] for key,values in samples.items()} + errors={key:max(abs(a-b) for color in values for a,b in zip(color,expected)) for key,values in samples.items()} + matches={key:err<=4 for key,err in errors.items()} + row['probes'].append({**probe,'pointPx':pixel,'oracleDetails':details,'expectedRgb':expected, + 'actualRgb':actual,'neighborhoodMinRgb':{k:[min(c[i] for c in v) for i in range(3)] for k,v in samples.items()}, + 'neighborhoodMaxRgb':{k:[max(c[i] for c in v) for i in range(3)] for k,v in samples.items()}, + 'maxChannelError':errors,'matches':matches}) + for key,match in matches.items():row[key+'Success'] &= match + diff=ImageChops.difference(images['pdfium'],images['poppler']);diff.save(folder/f'difference-{page}.png') + sheet=Image.new('RGB',(1260,440),'#eeeeee');draw=ImageDraw.Draw(sheet) + for col,(key,im) in enumerate([*images.items(),('difference',diff)]): + draw.text((col*420+8,7),f'{scale:g}x page {page}: {key}',fill='black') + thumb=im.copy();thumb.thumbnail((410,400));sheet.paste(thumb,(col*420+5,30)) + sheet.save(folder/f'comparison-{page}.png') + row['pages'].append({'page':page,'images':{key:sha(path) for key,path in paths.items()}}) + row['failedProbes']={engine:sum(not p['matches'][engine] for p in row['probes']) for engine in ('pdfium','poppler')} + row['success']=row['pdfiumSuccess'] and row['popplerSuccess'];report['scales'].append(row) + report['pdfiumSuccess']=all(r['pdfiumSuccess'] for r in report['scales']) + report['popplerSuccess']=all(r['popplerSuccess'] for r in report['scales']) + report['binariesUnchanged']=all(sha(build/n)==value for n,value in binaries.items()) and sha(Path(report['pdfiumLibrary']['path']))==report['pdfiumLibrary']['sha256'] + report['originalUnchanged']=sha(source)==spec['sha256'] and sha(args.corpus/spec['profile']['file'])==PROFILE_SHA + report['legacyInputsUnchanged']=preserved_inputs()==old + if not args.self_check: + report['success']=report['renderingAcceptance']=all(report[k] for k in ('pdfiumSuccess','popplerSuccess','binariesUnchanged','originalUnchanged','legacyInputsUnchanged')) + except Exception as error: + report['error']=type(error).__name__+': '+str(error) + raise + finally: + if cmm:cmm.close() + report['evidenceSha256']={str(p.relative_to(output)):sha(p) for p in sorted(output.rglob('*')) if p.is_file()} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps({key:report.get(key) for key in ('mode','fixtureValidationSuccess','success','pdfiumSuccess','popplerSuccess','legacyInputsUnchanged')})) + if not args.self_check and not report['success']:raise SystemExit('Pixel criteria failed; every failing probe remains in report.json') + + +if __name__=='__main__':main() diff --git a/tests/cmyk_lut/validate_context_candidate.py b/tests/cmyk_lut/validate_context_candidate.py new file mode 100644 index 0000000..8d41dfb --- /dev/null +++ b/tests/cmyk_lut/validate_context_candidate.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Run corrected RI, transparency and original CMYK probes plus DocView CTest.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--build-dir', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + library, build = args.library.resolve(), args.build_dir.resolve() + binaries = [library, ROOT / '.deps/pdfium/lib/libpdfium.so'] + [build / name for name in + ('docview', 'docview-pdf-worker', 'docview-archive-worker', 'pdf-worker-evidence')] + identities = {str(path): sha(path) for path in binaries} + environment = os.environ.copy() + assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT') + assert not environment.get('QTWEBENGINE_DISABLE_SANDBOX') + environment.pop('WAYLAND_DISPLAY', None) + environment.update(LD_LIBRARY_PATH=str(library.parent) + ( + ':' + environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else ''), + QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', + QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_SCALE_FACTOR='1', + QT_AUTO_SCREEN_SCALE_FACTOR='0', XDG_SESSION_TYPE='x11') + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + report = {'schemaVersion': 1, 'candidateTestsPass': False, 'acceptanceComplete': False, + 'platform': platform.platform(), 'osRelease': platform.freedesktop_os_release(), + 'inputs': identities, 'runnerSha256': sha(Path(__file__)), 'commands': [], + 'productionDependencyReplaced': False, + 'scope': 'Fixed candidate selected only for this test process tree. Generated fixtures and corrected stream-entry RI expectations; no physical display, Windows, human golden or release acceptance.'} + + def run(label, command): + start = time.monotonic() + log = output / (label + '.log') + with log.open('w') as stream: + result = subprocess.run(command, cwd=ROOT, env=environment, + stdout=stream, stderr=subprocess.STDOUT, timeout=900) + report['commands'].append({'name': label, 'command': command, + 'exitCode': result.returncode, 'seconds': time.monotonic() - start, + 'logSha256': sha(log)}) + print(label, result.returncode, flush=True) + return result.returncode + + try: + linked = subprocess.run(['ldd', str(build / 'docview-pdf-worker')], env=environment, + capture_output=True, text=True, check=True).stdout + assert str(library) in linked and 'not found' not in linked + (output / 'worker-ldd.txt').write_text(linked) + run('cmyk', [sys.executable, str(ROOT / 'tests/cmyk_lut/run.py'), + '--build-dir', str(build), '--output', str(output / 'cmyk')]) + run('intents', [sys.executable, str(ROOT / 'tests/cmyk_lut/intents.py'), + '--build-dir', str(build), '--pdfium-library', str(library), + '--corpus', str(ROOT / 'tests/fixtures/pdf/rendering-intents-context'), + '--scales', '.5', '1', '4', '--output', str(output / 'intents')]) + run('transparency', [sys.executable, str(ROOT / 'tests/cmyk_lut/transparency.py'), + '--build-dir', str(build), '--pdfium-library', str(library), + '--scales', '.5', '1', '2', '--output', str(output / 'transparency')]) + ctest_status = run('ctest', ['xvfb-run', '-a', '-s', '-screen 0 1100x760x24', + 'dbus-run-session', '--', 'ctest', '--test-dir', str(build), + '--output-on-failure', '--output-junit', str(output / 'tests.xml')]) + shutil.copyfile(build / 'Testing/Temporary/LastTest.log', output / 'LastTest.log') + reports = {name: json.loads((output / name / 'report.json').read_text()) + for name in ('cmyk', 'intents', 'transparency')} + report['cmykSuccess'] = reports['cmyk']['success'] + report['correctedOracle'] = reports['intents']['oracleRevision'] == 2 and not reports['intents']['oracleKnownIssue'] + for name in ('intents', 'transparency'): + data = reports[name] + report[name] = {key: data.get(key, False) for key in + ('pdfiumSuccess', 'popplerSuccess', 'fixtureValidationSuccess', 'binariesUnchanged', 'originalUnchanged')} + report[name]['probeCount'] = sum(len(row['probes']) for row in data['scales']) + report['ctestSuccess'] = ctest_status == 0 + report['allBinaryInputsUnchanged'] = all(sha(Path(path)) == value for path, value in identities.items()) + report['candidateTestsPass'] = all((report['cmykSuccess'], report['correctedOracle'], + report['ctestSuccess'], report['allBinaryInputsUnchanged'], *[ + report[name][key] for name in ('intents', 'transparency') for key in + ('pdfiumSuccess', 'fixtureValidationSuccess', 'binariesUnchanged', 'originalUnchanged')])) + report['comparisonAgreement'] = all(report[name]['popplerSuccess'] for name in ('intents', 'transparency')) + finally: + report['evidenceSha256'] = {str(path.relative_to(output)): sha(path) + for path in sorted(output.rglob('*')) if path.is_file()} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: report.get(key) for key in + ('candidateTestsPass', 'comparisonAgreement', 'allBinaryInputsUnchanged', 'acceptanceComplete')})) + if not report['candidateTestsPass']: + raise SystemExit('Candidate regression failed; all individual outcomes are retained.') + + +if __name__ == '__main__': + main() diff --git a/tests/cmyk_lut/validate_pdfium_candidate.py b/tests/cmyk_lut/validate_pdfium_candidate.py new file mode 100644 index 0000000..ada778a --- /dev/null +++ b/tests/cmyk_lut/validate_pdfium_candidate.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Run the isolated candidate's upstream tests and both ICC regression corpora.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--build-dir', type=Path, default=ROOT / 'build') + parser.add_argument('--source', type=Path, default=ROOT / 'build-pdfium-intent/source') + parser.add_argument('--variant', choices=('baseline', 'patched'), default='patched') + args = parser.parse_args() + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + source = args.source.resolve() + binaries = source / 'out' / args.variant + library = binaries / 'libpdfium.so' + environment = os.environ.copy() + assert not environment.get('LD_PRELOAD') and not environment.get('LD_AUDIT') + environment['LD_LIBRARY_PATH'] = str(binaries) + ( + ':' + environment['LD_LIBRARY_PATH'] if environment.get('LD_LIBRARY_PATH') else '') + environment['LOCPATH'] = str(ROOT / 'build-pdfium-intent/locales') + inputs = [library, binaries / 'pdfium_unittests', binaries / 'pdfium_embeddertests', + args.build_dir / 'pdf-worker-evidence', args.build_dir / 'docview-pdf-worker', + ROOT / '.deps/pdfium/lib/libpdfium.so'] + identities = {str(path.resolve()): sha(path) for path in inputs} + report = {'schemaVersion': 1, 'variant': args.variant, 'success': False, + 'runnerSha256': sha(Path(__file__)), 'inputs': identities, 'commands': [], + 'productionDependencyReplaced': False, + 'scope': 'Isolated Linux source-build candidate. No Windows, Ubuntu, calibrated display, human golden, or release acceptance claimed.'} + + def run(label, command, cwd=ROOT): + start = time.monotonic() + log = output / (label + '.log') + with log.open('w') as stream: + result = subprocess.run(command, cwd=cwd, env=environment, + stdout=stream, stderr=subprocess.STDOUT, timeout=600) + report['commands'].append({'name': label, 'command': command, + 'exitCode': result.returncode, + 'seconds': time.monotonic() - start, + 'logSha256': sha(log)}) + return result.returncode + + try: + loader = subprocess.run(['ldd', str(args.build_dir.resolve() / 'docview-pdf-worker')], + env=environment, capture_output=True, text=True, check=True) + assert str(library) in loader.stdout + (output / 'worker-ldd.txt').write_text(loader.stdout) + upstream = [] + for target in ('pdfium_unittests', 'pdfium_embeddertests'): + result = output / (target + '.json') + status = run(target, [str(binaries / target), '--gtest_output=json:' + str(result)], source) + data = json.loads(result.read_text()) if result.exists() else {} + upstream.append({'target': target, 'exitCode': status, + **{key: data.get(key) for key in ('tests', 'failures', 'disabled', 'errors', 'time')}}) + report['upstreamTests'] = upstream + run('cmyk', [sys.executable, str(ROOT / 'tests/cmyk_lut/run.py'), + '--build-dir', str(args.build_dir.resolve()), '--output', str(output / 'cmyk')]) + run('intents', [sys.executable, str(ROOT / 'tests/cmyk_lut/intents.py'), + '--build-dir', str(args.build_dir.resolve()), + '--pdfium-library', str(library), '--scales', '.5', '1', '4', + '--output', str(output / 'intents')]) + cmyk = json.loads((output / 'cmyk/report.json').read_text()) + intents = json.loads((output / 'intents/report.json').read_text()) + report['cmykSuccess'] = cmyk['success'] + report['intentPdfiumSuccess'] = intents.get('pdfiumSuccess', False) + report['intentPopplerSuccess'] = intents.get('popplerSuccess', False) + report['intentRenderingAcceptance'] = intents['renderingAcceptance'] + report['upstreamTestsPass'] = all(row['exitCode'] == 0 and row['failures'] == 0 and row['errors'] == 0 for row in upstream) + report['allBinaryInputsUnchanged'] = all(sha(Path(path)) == digest for path, digest in identities.items()) + report['candidatePdfiumProbesPass'] = cmyk['success'] and report['intentPdfiumSuccess'] + report['success'] = (report['upstreamTestsPass'] and report['candidatePdfiumProbesPass'] + and report['intentRenderingAcceptance'] and report['allBinaryInputsUnchanged']) + finally: + report['evidenceSha256'] = {str(path.relative_to(output)): sha(path) + for path in sorted(output.rglob('*')) if path.is_file()} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: report.get(key) for key in ('success', 'upstreamTestsPass', + 'candidatePdfiumProbesPass', 'intentPopplerSuccess', 'allBinaryInputsUnchanged')})) + if not report['success']: + raise SystemExit('Validation not fully accepted; see separate candidate and comparison results.') + + +if __name__ == '__main__': + main() diff --git a/tests/compare_font_collection.py b/tests/compare_font_collection.py new file mode 100644 index 0000000..c4b03ef --- /dev/null +++ b/tests/compare_font_collection.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Compare real production-worker TTC face 0/1 rendering with separate TTFs. + +Fonts/config/cache exist only inside TemporaryDirectory. No font is installed +or retained in the evidence directory. Requires fontTools, Pillow, fontconfig, +and the pdf-worker-evidence build target (which does not link PDFium itself). +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import tempfile +import time +from xml.sax.saxutils import escape + +import fontTools +from fontTools.ttLib import TTCollection, TTFont +from PIL import Image, ImageChops, ImageStat + +ROOT = Path(__file__).resolve().parent.parent +FAMILY = "DocView Collection Test" + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def make_pdf(path): + stream = (b"BT /Regular 24 Tf 30 150 Td (Collection face 0123456789 AVWA) Tj ET\n" + b"BT /Bold 24 Tf 30 70 Td (Collection face 0123456789 AVWA) Tj ET\n") + objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 430 240] /Resources << /Font << /Regular 4 0 R /Bold 5 0 R >> >> /Contents 6 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"endstream"] + output = bytearray(b"%PDF-1.7\n"); offsets = [0] + for index, obj in enumerate(objects, 1): + offsets.append(len(output)); output += f"{index} 0 obj\n".encode() + obj + b"\nendobj\n" + xref = len(output); output += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() + for offset in offsets[1:]: + output += f"{offset:010d} 00000 n \n".encode() + output += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + path.write_bytes(output) + + +def rename_copy(source, target, style): + # Format conversion is a modified font. Rename primary names so no + # Reserved Font Name is used by our private derived test fonts. + font = TTFont(source, recalcTimestamp=False) + replacements = {1: FAMILY, 3: f"DocViewCollectionTest-{style}", 4: f"{FAMILY} {style}", + 6: f"DocViewCollectionTest-{style}", 16: FAMILY, 18: f"{FAMILY} {style}", + 21: FAMILY, 25: "DocViewCollectionTest"} + for record in font["name"].names: + if record.nameID in replacements: + record.string = replacements[record.nameID].encode(record.getEncoding()) + font.save(target); font.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-dir", type=Path, default=ROOT / os.environ.get("DOCVIEW_BUILD_DIR", "build")) + parser.add_argument("--regular", type=Path, default=Path("/usr/share/fonts/liberation/LiberationSans-Regular.ttf")) + parser.add_argument("--bold", type=Path, default=Path("/usr/share/fonts/liberation/LiberationSans-Bold.ttf")) + parser.add_argument("--license", type=Path, default=Path("/usr/share/licenses/ttf-liberation/LICENSE")) + parser.add_argument("--output", type=Path, default=ROOT / "tests/results/font-collection") + args = parser.parse_args() + executable = args.build_dir.resolve() / "pdf-worker-evidence" + if not executable.is_file(): + raise SystemExit("Build the pdf-worker-evidence target first.") + license_text = args.license.read_text() + assert "SIL OPEN FONT LICENSE Version 1.1" in license_text + assert all(p.is_file() for p in (args.regular, args.bold)) + assert shutil.disk_usage(tempfile.gettempdir()).free > 64 * 1024 * 1024 + output = args.output.resolve(); output.mkdir(parents=True, exist_ok=True) + pair_pdf = output / "regular-bold.pdf"; make_pdf(pair_pdf) + cases = {"paired-styles": pair_pdf, "existing-standard14": ROOT / "tests/fixtures/pdf/extended/unembedded-font.pdf"} + report = {"status": "incomplete", "fontTools_version": fontTools.__version__, + "fontconfig_version": subprocess.run(["fc-match", "--version"], capture_output=True, text=True, check=True).stderr.strip(), + "worker_sha256": digest(args.build_dir / "docview-pdf-worker"), + "evidence_exporter_sha256": digest(executable), + "font_sources": [{"path": str(p), "sha256": digest(p), "bytes": p.stat().st_size} for p in (args.regular, args.bold)], + "license": {"path": str(args.license), "sha256": digest(args.license), "identifier": "OFL-1.1"}, + "generated_family": FAMILY, "fonts_redistributed": False, + "selection_transport": "production WorkerProcess + FontBroker + sandboxed PDF worker; no direct PDFium exporter", "runs": [], "comparisons": []} + temporary = None + with tempfile.TemporaryDirectory(prefix="docview-ttc-") as name: + temporary = Path(name) + standalone = temporary / "standalone"; collection_dir = temporary / "collection" + standalone.mkdir(); collection_dir.mkdir() + paths = [standalone / "regular.ttf", standalone / "bold.ttf"] + for source, path, style in zip((args.regular, args.bold), paths, ("Regular", "Bold")): + rename_copy(source, path, style) + collection = TTCollection() + collection.fonts = [TTFont(path, recalcTimestamp=False) for path in paths] + ttc_path = collection_dir / "two-faces.ttc" + collection.save(ttc_path, shareTables=False); collection.close() + ttc_data = ttc_path.read_bytes() + assert ttc_data[:4] == b"ttcf" and struct.unpack_from(">I", ttc_data, 8)[0] == 2 + report["generated_fonts"] = { + "standalone": [{"style": style, "bytes": p.stat().st_size, "sha256": digest(p)} for p, style in zip(paths, ("Regular", "Bold"))], + "collection": {"bytes": len(ttc_data), "sha256": digest(ttc_path), "face_offsets": list(struct.unpack_from(">2I", ttc_data, 12)), "shared_tables": False}} + for mode, fonts in (("standalone", standalone), ("collection", collection_dir)): + cache = temporary / (mode + "-cache"); cache.mkdir() + config = temporary / (mode + ".conf") + config.write_text('\n\n' + '\n' + escape(str(fonts)) + '\n' + escape(str(cache)) + + '\nsans-serif' + FAMILY + + '\n\n') + env = os.environ.copy(); env["FONTCONFIG_FILE"] = str(config); env["FONTCONFIG_PATH"] = str(temporary) + listing = subprocess.check_output(["fc-list", "--format=%{file}\t%{index}\t%{style}\n"], env=env, text=True) + index = [] + for line in listing.splitlines(): + path, face, style = line.split("\t"); assert Path(path).parent == fonts + index.append({"file": Path(path).name, "face_index": int(face), "style": style}) + assert len(index) == 2 + if mode == "collection": assert {item["face_index"] for item in index} == {0, 1} + for case, source in cases.items(): + destination = output / mode / case + started = time.monotonic() + process = subprocess.run([str(executable), str(source), str(destination)], env=env, + text=True, capture_output=True, timeout=60) + if process.returncode: + raise RuntimeError(f"{mode}/{case}: exit {process.returncode}\n{process.stdout}\n{process.stderr}") + metadata = json.loads((destination / "metadata.json").read_text()) + selected = [s for s in metadata["fontSelections"] if s.get("found")] + assert selected and all(s["actualFace"] == FAMILY for s in selected) + assert metadata["fontBytesTransferred"] > 0 + if mode == "collection" and case == "paired-styles": + assert {s["faceIndex"] for s in selected} == {0, 1} + assert all(s["faceIndex"] == (1 if s["requestedWeight"] >= 600 else 0) for s in selected) + report["runs"].append({"mode": mode, "case": case, "source_sha256": digest(source), + "elapsed_seconds": time.monotonic() - started, "font_index": index, + "selections": selected, "font_bytes_transferred": metadata["fontBytesTransferred"]}) + for case in cases: + baseline = output / "standalone" / case / "page-1.png" + collected = output / "collection" / case / "page-1.png" + left = Image.open(baseline).convert("RGB"); right = Image.open(collected).convert("RGB") + assert left.size == right.size + difference = ImageChops.difference(left, right) + histogram = ImageChops.lighter(ImageChops.lighter(difference.getchannel("R"), difference.getchannel("G")), difference.getchannel("B")).histogram() + report["comparisons"].append({"case": case, "dimensions": list(left.size), "equal_pixels": histogram[0], + "different_pixels": sum(histogram[1:]), "mean_absolute_channel_error": sum(ImageStat.Stat(difference).mean) / 3, + "standalone_png_sha256": digest(baseline), "collection_png_sha256": digest(collected)}) + assert difference.getbbox() is None, f"Raster mismatch in {case}" + if case == "paired-styles": + regular = right.crop((50, 120, 830, 200)).convert("L") + bold = right.crop((50, 280, 830, 360)).convert("L") + regular_ink = sum(regular.histogram()[:128]); bold_ink = sum(bold.histogram()[:128]) + assert bold_ink > regular_ink > 0 and ImageChops.difference(regular, bold).getbbox() is not None + report["style_probe"] = {"regular_dark_pixels": regular_ink, "bold_dark_pixels": bold_ink, + "equal_text_at_equal_font_size": True, "regular_bold_distinct": True} + report["temporary_fonts_removed"] = not temporary.exists() + assert report["temporary_fonts_removed"] + report["status"] = "pass" + (output / "comparison.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/compare_link_borders.py b/tests/compare_link_borders.py new file mode 100644 index 0000000..f8b8211 --- /dev/null +++ b/tests/compare_link_borders.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Read-only link annotation evidence through the production sandboxed worker. + +Poppler is an independent comparison, not an all-features correctness oracle. +Run after building pdf-worker-evidence. Requires Pillow and Poppler utils. +""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import time +from PIL import Image, ImageChops, ImageStat + +ROOT = Path(__file__).resolve().parent.parent + +def sha(path): + with path.open('rb') as source: + return hashlib.file_digest(source, 'sha256').hexdigest() + +def stats(a, b): + assert a.size == b.size + diff = ImageChops.difference(a, b) + pixels = list(diff.getdata()) + return {'size': list(a.size), 'pixels': len(pixels), 'equal_pixels': sum(max(p) == 0 for p in pixels), + 'pixels_max_delta_gt_16': sum(max(p) > 16 for p in pixels), + 'mean_absolute_channel_error': sum(ImageStat.Stat(diff).mean) / 3} + +def region(rect, rotation, margin=6): + points = [] + for x in [rect[0], rect[2]]: + for y in [rect[1], rect[3]]: + x0, y0 = (x - 20) * 2, (450 - y) * 2 + points.append([(x0, y0), (840 - y0, x0), (1020 - x0, 840 - y0), (y0, 1020 - x0)][rotation // 90]) + return (min(p[0] for p in points) - margin, min(p[1] for p in points) - margin, + max(p[0] for p in points) + margin, max(p[1] for p in points) + margin) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build-dir', type=Path, default=ROOT / 'build') + parser.add_argument('--output', type=Path, default=ROOT / 'tests/results/link-borders') + args = parser.parse_args() + out = args.output.resolve(); out.mkdir(parents=True, exist_ok=True) + build = args.build_dir.resolve(); helper = build / 'pdf-worker-evidence' + fixture = ROOT / 'tests/fixtures/pdf/link-borders/styles.pdf' + before = sha(fixture) + start = time.monotonic() + subprocess.run([str(helper), str(fixture), str(out / 'worker')], check=True, timeout=60) + elapsed = time.monotonic() - start + reference = subprocess.run(['/usr/bin/pdftoppm', '-cropbox', '-r', '144', '-png', str(fixture), str(out / 'poppler')], capture_output=True, check=True, timeout=60) + (out / 'poppler-stderr.txt').write_bytes(reference.stderr) + manifest = json.loads((fixture.parent / 'manifest.json').read_text())['fixtures'][0] + common = {'default', 'solid-rgb', 'dashed-border', 'dashed-bs-odd', 'dashed-default', 'underline', + 'bs-overrides-border', 'zero-width', 'transparent', 'hidden', 'no-view', 'print-flag-visible'} + records = [] + for index, rotation in enumerate([0, 90, 180, 270], 1): + a = Image.open(out / f'worker/page-{index}.png').convert('RGB') + b = Image.open(out / f'poppler-{index}.png').convert('RGB') + diff = ImageChops.difference(a, b); diff.save(out / f'difference-{index}.png') + comparisons = [] + for case in manifest['annotations']: + bounds = region(case['rect'], rotation) + row = {'name': case['name'], 'reference_supported': case['name'] in common, **stats(a.crop(bounds), b.crop(bounds))} + if case['name'] in common: + # At 144dpi the grid-aligned borders should match except small + # independent rasterizer antialias/color-rounding differences. + assert row['mean_absolute_channel_error'] < 3, row + comparisons.append(row) + records.append({'page': index, 'rotation': rotation, 'whole_page': stats(a, b), 'annotations': comparisons}) + navigation = ROOT / 'tests/fixtures/pdf/navigation.pdf' + navigation_before = sha(navigation) + subprocess.run([str(helper), str(navigation), str(out / 'navigation-worker')], check=True, timeout=60) + subprocess.run(['/usr/bin/pdftoppm', '-cropbox', '-f', '1', '-singlefile', '-r', '144', '-png', str(navigation), str(out / 'navigation-poppler')], capture_output=True, check=True, timeout=60) + a = Image.open(out / 'navigation-worker/page-1.png').convert('RGB') + b = Image.open(out / 'navigation-poppler.png').convert('RGB') + # The exact region where the original default link border was absent. + navigation_comparison = stats(a.crop((18, 368, 282, 400)), b.crop((18, 368, 282, 400))) + assert navigation_comparison['mean_absolute_channel_error'] < 3, navigation_comparison + assert before == sha(fixture) and navigation_before == sha(navigation) + result = {'transport': 'production WorkerProcess, sandboxed PDFWorker and FontBroker; no direct PDFium exporter', + 'worker_sha256': sha(build / 'docview-pdf-worker'), 'exporter_sha256': sha(helper), + 'qpdf_version': subprocess.check_output(['qpdf', '--version'], text=True).splitlines()[0], + 'poppler_version': subprocess.run(['/usr/bin/pdftoppm', '-v'], capture_output=True, text=True).stderr.splitlines()[0], + 'fixture_sha256': before, 'source_unchanged': True, 'four_page_export_seconds': elapsed, + 'reference_status': 'independent Poppler comparison; manually inspect images; not a golden-image acceptance gate', + 'reference_limitations': manifest['reference_limitations'], + 'known_limitations': ['NoZoom/NoRotate geometry for generated links, saved normal APs, and native Text icons is covered separately by tests/results/pdf-annotation-flags.', + 'Other annotation types without a supplied AP retain PDFium generation behavior; this corpus does not certify their NoZoom handling.', + 'Text annotation icon appearance is viewer-specific and is not a link border defect.', + 'This closes tested standard link border cases, not the entire G-RENDER gate.'], + 'pages': records, 'navigation_fixture_sha256': navigation_before, 'navigation_default_link_region': navigation_comparison} + (out / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps({'source_unchanged': True, 'four_page_export_seconds': elapsed, + 'standard_case_comparisons': len(common) * 4, 'navigation_default_link_region': navigation_comparison}, indent=2)) + +if __name__ == '__main__': main() diff --git a/tests/compare_pdf_extended.py b/tests/compare_pdf_extended.py new file mode 100644 index 0000000..69bb898 --- /dev/null +++ b/tests/compare_pdf_extended.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Compare the original extended corpus with independent Poppler rendering. + +No pixel metric is interpreted as a calibrated compatibility acceptance score. +""" +from pathlib import Path +import argparse +import hashlib +import json +import os +import subprocess +import sys +from PIL import Image, ImageChops, ImageDraw, ImageStat + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--build-dir", type=Path, default=ROOT / os.environ.get("DOCVIEW_BUILD_DIR", "build")) +parser.add_argument("--output", type=Path, default=ROOT / "tests/results/pdf-extended") +parser.add_argument("--pdfium-library", type=Path, default=ROOT / ".deps/pdfium/lib/libpdfium.so", + help="Selected worker library to identify in the evidence record.") +args = parser.parse_args() +BUILD = args.build_dir.resolve() +CORPUS = ROOT / "tests/fixtures/pdf/extended" +OUT = args.output.resolve() +OUT.mkdir(parents=True, exist_ok=True) +# The runtime wrapper can carry a private Fontconfig setup. Prefer an explicit +# system installation so Standard-14 fallback uses that installation's fonts. +def poppler_binary(name): + return os.environ.get("DOCVIEW_" + name.upper(), str(Path("/usr/bin") / name) if (Path("/usr/bin") / name).is_file() else name) + +PDFTOPPM, PDFTOTEXT, PDFFONTS = (poppler_binary(name) for name in ("pdftoppm", "pdftotext", "pdffonts")) +manifest = json.loads((CORPUS / "manifest.json").read_text()) +helper = BUILD / "pdf-worker-evidence" +if not helper.is_file(): + raise SystemExit("Build the pdf-worker-evidence target before comparison.") +report = {"reference_status": "Independent Poppler comparison of synthetic source; not a human-approved golden image or full compatibility acceptance", + "platform": sys.platform, "transport": "production sandboxed PDF worker and FontBroker", "worker_sha256": hashlib.sha256((BUILD / "docview-pdf-worker").read_bytes()).hexdigest(), "broker_sha256": hashlib.sha256((BUILD / "libdocview_font_broker.a").read_bytes()).hexdigest(), "resolution_dpi": 144, "pdfium_adapter_sha256": hashlib.sha256((BUILD / "libdocview_pdf.a").read_bytes()).hexdigest(), + "pdfium_library_sha256": hashlib.sha256(args.pdfium_library.resolve().read_bytes()).hexdigest(), + "poppler_version": subprocess.run([PDFTOPPM, "-v"], text=True, capture_output=True).stderr.splitlines()[0], + "poppler_binary": PDFTOPPM, "documents": []} +report["fontconfig_matches"] = {family: subprocess.check_output(["fc-match", family], text=True).strip() + for family in ["Helvetica", "Times-Roman", "Courier"]} +overview = [] +for specification in manifest["documents"]: + source = CORPUS / specification["file"] + if hashlib.sha256(source.read_bytes()).hexdigest() != specification["sha256"]: + raise SystemExit("Fixture hash differs from source manifest: " + source.name) + target = OUT / source.stem + (target / "pdfium").mkdir(parents=True, exist_ok=True) + (target / "poppler").mkdir(exist_ok=True) + subprocess.run([str(helper), str(source), str(target / "pdfium")], check=True) + subprocess.run([PDFTOPPM, "-cropbox", "-r", "144", "-png", str(source), str(target / "poppler/page")], check=True) + subprocess.run([PDFTOTEXT, "-layout", str(source), str(target / "poppler/text.txt")], check=True) + fonts = subprocess.check_output([PDFFONTS, str(source)], text=True) + (target / "fonts.txt").write_text(fonts) + doc = {"file": specification["file"], "fixture_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "pages": []} + for index in range(1, specification["page_count"] + 1): + a = Image.open(target / "pdfium" / f"page-{index}.png").convert("RGB") + b = Image.open(target / "poppler" / f"page-{index}.png").convert("RGB") + same_size = a.size == b.size + page = {"page": index, "pdfium_size": list(a.size), "poppler_size": list(b.size), "dimensions_match": same_size} + expected_size = specification["expected"].get("page_sizes_pt") + if expected_size: + page["expected_size_px"] = [v * 2 for v in expected_size[index - 1]] + elif specification["file"] == "page-geometry.pdf": + geometry = specification["expected"]["pages"][index - 1] + page["expected_size_px"] = [v * 2 for v in geometry["display_size_pt"]] + left, bottom, right, top = geometry["crop_box_pt"] + w, h = right - left, top - bottom + checks = [] + for marker in geometry["markers_pdf_coordinates"]: + x, y = marker["x"] - left, marker["y"] - bottom + coordinates = {0: (x, h - y), 90: (y, x), 180: (w - x, y), 270: (h - y, w - x)}[geometry["rotation"]] + point = tuple(int(v * 2) for v in coordinates) + actual_a, actual_b = a.getpixel(point), b.getpixel(point) + expected = marker["rgb"] + checks.append({"point_px": point, "expected_rgb": expected, "pdfium_rgb": actual_a, "poppler_rgb": actual_b, + "both_match_source_color": max(abs(actual_a[k] - expected[k]) for k in range(3)) <= 2 and max(abs(actual_b[k] - expected[k]) for k in range(3)) <= 2}) + page["source_geometry_marker_checks"] = checks + page["dimensions_match_source"] = list(a.size) == page.get("expected_size_px") and list(b.size) == page.get("expected_size_px") + if specification["file"] == "graphics-compositing.pdf": + probes = [] + for probe in specification["expected"]["alpha_probes"]: + x, y = probe["point_pt"] + point = (x * 2, (760 - y) * 2) + actual_a, actual_b, expected = a.getpixel(point), b.getpixel(point), probe["expected_rgb"] + probes.append({"point_px": point, "region": probe["region"], "expected_rgb": expected, + "pdfium_rgb": actual_a, "poppler_rgb": actual_b, + "both_match_source_color": max(abs(actual_a[k] - expected[k]) for k in range(3)) <= 2 and max(abs(actual_b[k] - expected[k]) for k in range(3)) <= 2}) + page["source_alpha_checks"] = probes + if same_size: + difference = ImageChops.difference(a, b) + difference.save(target / f"difference-{index}.png") + histogram = ImageChops.lighter(ImageChops.lighter(difference.getchannel("R"), difference.getchannel("G")), difference.getchannel("B")).histogram() + page["equal_pixels"] = histogram[0] + page["pixels_max_delta_gt_16"] = sum(histogram[17:]) + page["mean_absolute_channel_error"] = sum(ImageStat.Stat(difference).mean) / 3 + else: + difference = Image.new("RGB", a.size, "#ff8080") + tile = Image.new("RGB", (1200, 490), "#eeeeee"); draw = ImageDraw.Draw(tile) + for column, (title, picture) in enumerate([(source.stem + f" p{index}: PDFium", a), ("Poppler independent renderer", b), ("Absolute RGB difference", difference)]): + draw.text((column * 400 + 10, 10), title, fill="black") + preview = picture.copy(); preview.thumbnail((385, 450)); tile.paste(preview, (column * 400 + (400 - preview.width) // 2, 33)) + tile.save(target / f"comparison-{index}.png") + overview.append(tile) + doc["pages"].append(page) + if specification["file"] == "japanese-layout.pdf": + doc["japanese_text_extracted_by_poppler"] = "日本語の文字配置と埋込みフォント" in (target / "poppler/text.txt").read_text() + meta = json.loads((target / "pdfium/metadata.json").read_text()) + doc["japanese_search_matches_pdfium"] = len(meta.get("japaneseSearch", {}).get("matches", [])) + doc["font_listing"] = fonts + report["documents"].append(doc) +sheet = Image.new("RGB", (1200, len(overview) * 490), "white") +for index, tile in enumerate(overview): sheet.paste(tile, (0, index * 490)) +sheet.save(OUT / "overview.png") +(OUT / "comparison.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n") +summary = {"documents": len(report["documents"]), "pages": len(overview), + "all_dimensions_match_source": all(page["dimensions_match_source"] for doc in report["documents"] for page in doc["pages"]), + "all_geometry_markers_match_source": all(check["both_match_source_color"] for doc in report["documents"] for page in doc["pages"] for check in page.get("source_geometry_marker_checks", [])), + "all_alpha_probes_match_source": all(check["both_match_source_color"] for doc in report["documents"] for page in doc["pages"] for check in page.get("source_alpha_checks", [])), + "japanese_text_and_search": all(doc.get("japanese_text_extracted_by_poppler", True) and doc.get("japanese_search_matches_pdfium", 1) > 0 for doc in report["documents"]), + "report": str(OUT / "comparison.json")} +print(json.dumps(summary, indent=2)) +if not all(summary[key] for key in ["all_dimensions_match_source", "all_geometry_markers_match_source", "all_alpha_probes_match_source", "japanese_text_and_search"]): + raise SystemExit("A deterministic source assertion failed; see comparison.json") diff --git a/tests/compare_pdf_fonts.py b/tests/compare_pdf_fonts.py new file mode 100644 index 0000000..a097390 --- /dev/null +++ b/tests/compare_pdf_fonts.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Production worker vs independent Poppler for unembedded Japanese CID H/V.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +from PIL import Image, ImageChops, ImageDraw, ImageStat + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--build-dir", type=Path, default=ROOT / os.environ.get("DOCVIEW_BUILD_DIR", "build")) +parser.add_argument("--output", type=Path, default=ROOT / "tests/results/pdf-fonts") +args = parser.parse_args() +BUILD = args.build_dir.resolve() +CORPUS = ROOT / "tests/fixtures/pdf/fonts" +OUT = args.output.resolve() +OUT.mkdir(parents=True, exist_ok=True) +specification = json.loads((CORPUS / "manifest.json").read_text())["documents"][0] +source = CORPUS / specification["file"] +assert hashlib.sha256(source.read_bytes()).hexdigest() == specification["sha256"] +subprocess.run([str(BUILD / "pdf-worker-evidence"), str(source), str(OUT / "worker")], check=True) +poppler = os.environ.get("DOCVIEW_PDFTOPPM", "/usr/bin/pdftoppm") +subprocess.run([poppler, "-r", "144", "-png", str(source), str(OUT / "poppler")], check=True) +subprocess.run(["/usr/bin/pdftotext", "-layout", str(source), str(OUT / "poppler-text.txt")], check=True) +fonts = subprocess.check_output(["/usr/bin/pdffonts", str(source)], text=True) +(OUT / "font-list.txt").write_text(fonts) +metadata = json.loads((OUT / "worker/metadata.json").read_text()) +report = { + "reference_status": "Independent renderer comparison; no human-approved golden image or comprehensive CID/CMap acceptance", + "fixture_sha256": specification["sha256"], + "worker_sha256": hashlib.sha256((BUILD / "docview-pdf-worker").read_bytes()).hexdigest(), + "broker_sha256": hashlib.sha256((BUILD / "libdocview_font_broker.a").read_bytes()).hexdigest(), + "transport": metadata["transport"], + "poppler_version": subprocess.run([poppler, "-v"], text=True, capture_output=True).stderr.splitlines()[0], + "font_selections": metadata["fontSelections"], + "font_bytes_transferred": metadata["fontBytesTransferred"], + "japanese_search_matches_first_page": len(metadata.get("japaneseSearch", {}).get("matches", [])), + "pages": [], +} +for page in (1, 2): + a = Image.open(OUT / "worker" / f"page-{page}.png").convert("RGB") + b = Image.open(OUT / f"poppler-{page}.png").convert("RGB") + assert a.size == b.size == (1200, 1520) + difference = ImageChops.difference(a, b) + difference.save(OUT / f"difference-{page}.png") + histogram = ImageChops.lighter(ImageChops.lighter(difference.getchannel("R"), difference.getchannel("G")), difference.getchannel("B")).histogram() + report["pages"].append({"page": page, "size": list(a.size), "dimensions_match_source": True, + "equal_pixels": histogram[0], "pixels_max_delta_gt_16": sum(histogram[17:]), + "mean_absolute_channel_error": sum(ImageStat.Stat(difference).mean) / 3}) + comparison = Image.new("RGB", (1200, 550), "#eeeeee") + draw = ImageDraw.Draw(comparison) + for col, (title, picture) in enumerate((("Sandboxed DocView / PDFium", a), ("Independent Poppler", b), ("Absolute RGB difference", difference))): + draw.text((col * 400 + 8, 10), title, fill="black") + picture.thumbnail((390, 500)); comparison.paste(picture, (col * 400 + 5, 35)) + comparison.save(OUT / f"comparison-{page}.png") +assert report["font_bytes_transferred"] > 0 +assert any(item.get("charset") == 128 and item.get("found") for item in report["font_selections"]) +assert report["japanese_search_matches_first_page"] == 2 +(OUT / "comparison.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") +print(json.dumps(report, ensure_ascii=False, indent=2)) diff --git a/tests/compare_pdf_icc.py b/tests/compare_pdf_icc.py new file mode 100644 index 0000000..ba21869 --- /dev/null +++ b/tests/compare_pdf_icc.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Compare a real ICCBased PDF through the sandboxed worker at three scales.""" +from pathlib import Path +from io import BytesIO +import argparse +import hashlib +import json +import math +import os +import subprocess +from PIL import Image, ImageChops, ImageDraw, ImageStat, ImageCms + +ROOT=Path(__file__).resolve().parent.parent +parser=argparse.ArgumentParser(description=__doc__) +parser.add_argument("--build-dir",type=Path,default=ROOT/os.environ.get("DOCVIEW_BUILD_DIR","build")) +parser.add_argument("--output",type=Path,default=ROOT/"tests/results/pdf-icc") +args=parser.parse_args() +from pypdf import PdfReader +BUILD=args.build_dir.resolve() +CORPUS=ROOT/"tests/fixtures/pdf/extended" +OUT=args.output.resolve(); OUT.mkdir(parents=True,exist_ok=True) +manifest=json.loads((CORPUS/"manifest.json").read_text()) +spec=next(item for item in manifest["documents"] if item["file"]=="icc-linear-rgb.pdf") +source=CORPUS/spec["file"]; before=hashlib.sha256(source.read_bytes()).hexdigest() +assert before==spec["sha256"] +profile=(CORPUS/"linear-srgb.icc").read_bytes() +assert hashlib.sha256(profile).hexdigest()==manifest["color_profile"]["sha256"] +reader=PdfReader(source) +color_space=reader.pages[0]["/Resources"]["/ColorSpace"]["/Linear"] +assert str(color_space[0])=="/ICCBased" +assert color_space[1].get_object().get_data()==profile +image=reader.pages[0]["/Resources"]["/XObject"]["/Sample"].get_object() +assert str(image["/ColorSpace"][0])=="/ICCBased" +assert image["/ColorSpace"][1].get_object().get_data()==profile +cms_profile=ImageCms.ImageCmsProfile(BytesIO(profile)) +report={"scope":"Self-authored ICCBased RGB matrix/TRC vector and raster at actual worker render scales; not full ICC/CMYK/printing acceptance", + "fixture_sha256_before":before,"profile":manifest["color_profile"], + "worker_sha256":hashlib.sha256((BUILD/"docview-pdf-worker").read_bytes()).hexdigest(), + "profile_copyright_readback":ImageCms.getProfileCopyright(cms_profile).strip(), + "profile_description_readback":ImageCms.getProfileDescription(cms_profile).strip(), + "profile_embedded_in_vector_and_image":True,"transport":"production sandboxed PDFWorker, WorkerProcess, FontBroker, tile IPC", + "poppler_version":subprocess.run(["/usr/bin/pdftoppm","-v"],capture_output=True,text=True).stderr.splitlines()[0], + "sha256":{str(path.relative_to(ROOT)):hashlib.sha256(path.read_bytes()).hexdigest() for path in + [BUILD/"pdf-worker-evidence",BUILD/"docview-pdf-worker",BUILD/"libdocview_pdf.a",BUILD/"libdocview_font_broker.a",ROOT/"tests/render_pdf_worker.cpp",ROOT/"tests/compare_pdf_icc.py",ROOT/"tests/fixtures/pdf/icc_generator.py"]}, + "reference":"Analytic sRGB transfer function of stated linear RGB values; independent Poppler raster; no human-approved golden", + "scales":[]} +overview=[]; success=True +for scale in [.5,1.,4.]: + folder=OUT/("scale-"+str(scale).replace(".","_")); (folder/"pdfium").mkdir(parents=True,exist_ok=True) + subprocess.run([str(BUILD/"pdf-worker-evidence"),str(source),str(folder/"pdfium"),str(scale)],check=True) + subprocess.run(["/usr/bin/pdftoppm","-singlefile","-cropbox","-r",str(72*scale),"-png",str(source),str(folder/"poppler")],check=True) + a=Image.open(folder/"pdfium/page-1.png").convert("RGB"); b=Image.open(folder/"poppler.png").convert("RGB") + meta=json.loads((folder/"pdfium/metadata.json").read_text()); assert meta["renderScale"]==scale + size=[math.ceil(v*scale) for v in spec["expected"]["page_sizes_pt"][0]] + entry={"scale":scale,"dpi":72*scale,"expected_size":size,"pdfium_size":list(a.size),"poppler_size":list(b.size), + "dimensions_match_source":list(a.size)==size==list(b.size),"probes":[]} + success=success and entry["dimensions_match_source"] + for probe in spec["expected"]["icc_probes"]: + x,y=probe["point_pt"]; point=(round(x*scale),round((350-y)*scale)) + actual_a,actual_b=a.getpixel(point),b.getpixel(point) + expected=probe["expected_rgb"]; tolerance=spec["expected"]["probe_tolerance_rgb"] + check=dict(probe,point_px=point,pdfium_rgb=actual_a,poppler_rgb=actual_b, + both_match_analytic_color=all(abs(actual[k]-expected[k])<=tolerance for actual in (actual_a,actual_b) for k in range(3))) + entry["probes"].append(check);success=success and check["both_match_analytic_color"] + diff=ImageChops.difference(a,b);diff.save(folder/"difference.png") + entry["mean_absolute_channel_error"]=sum(ImageStat.Stat(diff).mean)/3 + entry["icc_midgray_differs_from_device_control"]=all(abs(entry["probes"][6][which][0]-entry["probes"][7][which][0])>40 for which in ("pdfium_rgb","poppler_rgb")) + success=success and entry["icc_midgray_differs_from_device_control"] + sheet=Image.new("RGB",(1500,350),"#ededed");draw=ImageDraw.Draw(sheet) + for col,(name,img) in enumerate([("PDFium / production worker",a),("Poppler / independent",b),("Absolute RGB difference",diff)]): + draw.text((col*500+10,12),f"{scale:g}x {name}",fill="black");thumb=img.copy();thumb.thumbnail((488,300)) + sheet.paste(thumb,(col*500+(500-thumb.width)//2,42)) + sheet.save(folder/"comparison.png");overview.append(sheet);report["scales"].append(entry) +report["fixture_sha256_after"]=hashlib.sha256(source.read_bytes()).hexdigest() +report["original_unchanged"]=report["fixture_sha256_after"]==before;success=success and report["original_unchanged"] +report["all_source_assertions_pass"]=success +canvas=Image.new("RGB",(1500,len(overview)*350),"white") +for i,sheet in enumerate(overview):canvas.paste(sheet,(0,i*350)) +canvas.save(OUT/"overview.png") +(OUT/"comparison.json").write_text(json.dumps(report,ensure_ascii=False,indent=2)+"\n") +print(json.dumps({"all_source_assertions_pass":success,"scales":len(report["scales"]),"probe_count":sum(len(x["probes"]) for x in report["scales"]),"original_unchanged":report["original_unchanged"],"profile_copyright":report["profile_copyright_readback"]},indent=2)) +if not success: raise SystemExit(f"ICC source assertion failed; see {OUT / 'comparison.json'}") diff --git a/tests/compare_pdf_renderers.py b/tests/compare_pdf_renderers.py new file mode 100644 index 0000000..c2309c7 --- /dev/null +++ b/tests/compare_pdf_renderers.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Generate independent PDFium/Poppler comparison evidence. +Requires the built pdf-worker-evidence target, Pillow and Poppler utils. +The result is comparison evidence; it is not a human-approved golden image. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +from PIL import Image, ImageChops, ImageDraw + +root = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--build-dir', type=Path, default=root / os.environ.get('DOCVIEW_BUILD_DIR', 'build')) +parser.add_argument('--output', type=Path, default=root / 'tests/results/pdf') +args = parser.parse_args() +build = args.build_dir.resolve() +out = args.output.resolve() +out.mkdir(parents=True, exist_ok=True) +helper = build / 'pdf-worker-evidence' +if not helper.is_file(): raise SystemExit('Build the pdf-worker-evidence target before comparison.') +fixture = root / 'tests/fixtures/pdf/navigation.pdf' +subprocess.run([str(helper), str(fixture), str(out / 'worker')], check=True) +shutil.copyfile(out / 'worker/page-1.png', out / 'pdfium.png') +poppler = '/usr/bin/pdftoppm' if Path('/usr/bin/pdftoppm').exists() else shutil.which('pdftoppm') +if not poppler: raise SystemExit('Poppler pdftoppm is required for the independent comparison.') +subprocess.run([poppler, '-cropbox', '-f', '1', '-singlefile', '-r', '144', '-png', str(fixture), str(out / 'poppler')], check=True) +a = Image.open(out / 'pdfium.png').convert('RGB') +b = Image.open(out / 'poppler.png').convert('RGB') +if a.size != b.size: raise SystemExit('Renderer dimensions disagree.') +diff = ImageChops.difference(a, b) +diff.save(out / 'difference.png') +pixels = list(diff.getdata()); count = len(pixels) +metrics = {'transport': 'production sandboxed PDF worker and FontBroker', 'worker_sha256': hashlib.sha256((build / 'docview-pdf-worker').read_bytes()).hexdigest(), 'broker_sha256': hashlib.sha256((build / 'libdocview_font_broker.a').read_bytes()).hexdigest(), 'fixture_sha256': hashlib.sha256(fixture.read_bytes()).hexdigest(), 'size': list(a.size), 'pixels': count, + 'equal_pixels': sum(max(p) == 0 for p in pixels), 'pixels_max_delta_gt_16': sum(max(p) > 16 for p in pixels), + 'mean_absolute_channel_error': sum(sum(p) for p in pixels) / (count * 3), + 'reference_status': 'independent Poppler comparison, not human-approved golden image'} +(out / 'comparison.json').write_text(json.dumps(metrics, indent=2) + '\n') +canvas = Image.new('RGB', (1200, 430), '#eeeeee'); draw = ImageDraw.Draw(canvas) +for x, label, image in [(0, 'PDFium 155.0.8057.0', a), (400, 'Poppler independent renderer', b), (800, 'Absolute RGB difference', diff)]: + draw.text((x + 10, 9), label, fill='black'); canvas.paste(image, (x, 30)) +canvas.save(out / 'comparison.png') +print(json.dumps(metrics, indent=2)) diff --git a/tests/fixtures/archive-ratio/generate.py b/tests/fixtures/archive-ratio/generate.py new file mode 100644 index 0000000..0692cdb --- /dev/null +++ b/tests/fixtures/archive-ratio/generate.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Deterministic authored ZIP inputs for the bounded-input expansion guard.""" +import hashlib +import json +from pathlib import Path +import struct +import zlib + +ROOT = Path(__file__).resolve().parent + + +def build(output_size: int, padded_size: int | None = None) -> tuple[bytes, dict]: + plain = b"x" * output_size + compressor = zlib.compressobj(9, zlib.DEFLATED, -15) + compressed = compressor.compress(plain) + compressor.flush() + payload = compressed if padded_size is None else compressed.ljust(padded_size, b"\0") + name = b"index.html" + crc = zlib.crc32(plain) + local = struct.pack(" None: + manifest = {"generator": "generate.py; Python stdlib zlib, raw DEFLATE", + "content": "Authored repeated ASCII x; no external material or fonts.", "files": {}} + for name, size, padded, expectation in [ + ("padded-33mib.zip", 33 * 1024 * 1024, 256 * 1024, "open succeeds; extraction E_ARCHIVE_LIMIT before more than 32 MiB is sent"), + ("threshold-32mib.zip", 32 * 1024 * 1024, None, "open and extraction succeed; ratio threshold is strictly greater than 32 MiB"), + ("unpadded-33mib.zip", 33 * 1024 * 1024, None, "open E_ARCHIVE_LIMIT from metadata preflight"), + ]: + archive, info = build(size, padded) + (ROOT / name).write_bytes(archive) + manifest["files"][name] = info | {"expected": expectation} + (ROOT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/archive-ratio/manifest.json b/tests/fixtures/archive-ratio/manifest.json new file mode 100644 index 0000000..780490a --- /dev/null +++ b/tests/fixtures/archive-ratio/manifest.json @@ -0,0 +1,30 @@ +{ + "generator": "generate.py; Python stdlib zlib, raw DEFLATE", + "content": "Authored repeated ASCII x; no external material or fonts.", + "files": { + "padded-33mib.zip": { + "output_bytes": 34603008, + "deflate_stream_bytes": 33647, + "zip_declared_compressed_bytes": 262144, + "sha256": "fd84b25c229191d4efb24939c14a20506ea442e4becf2164d034a4bdc983291a", + "output_sha256": "7d641a2a7b1c449f586b444bd47a2005d83be0e142596f6d1a52da9c2256c96b", + "expected": "open succeeds; extraction E_ARCHIVE_LIMIT before more than 32 MiB is sent" + }, + "threshold-32mib.zip": { + "output_bytes": 33554432, + "deflate_stream_bytes": 32617, + "zip_declared_compressed_bytes": 32617, + "sha256": "2f49d2152f9d10daad3109eecb532bd3e2c720f15708f1c1338c14785b7d7ef1", + "output_sha256": "05f052c8f6da8ee5228ec291820b559c4be183773b9e97a6b82e30dacff85dd3", + "expected": "open and extraction succeed; ratio threshold is strictly greater than 32 MiB" + }, + "unpadded-33mib.zip": { + "output_bytes": 34603008, + "deflate_stream_bytes": 33647, + "zip_declared_compressed_bytes": 33647, + "sha256": "205c8ef001b932dc20cc0672bb515f72420202c5a147de7a6b8496439745ea5c", + "output_sha256": "7d641a2a7b1c449f586b444bd47a2005d83be0e142596f6d1a52da9c2256c96b", + "expected": "open E_ARCHIVE_LIMIT from metadata preflight" + } + } +} diff --git a/tests/fixtures/archive-ratio/padded-33mib.zip b/tests/fixtures/archive-ratio/padded-33mib.zip new file mode 100644 index 0000000..20ca70a Binary files /dev/null and b/tests/fixtures/archive-ratio/padded-33mib.zip differ diff --git a/tests/fixtures/archive-ratio/threshold-32mib.zip b/tests/fixtures/archive-ratio/threshold-32mib.zip new file mode 100644 index 0000000..a2a8b60 Binary files /dev/null and b/tests/fixtures/archive-ratio/threshold-32mib.zip differ diff --git a/tests/fixtures/archive-ratio/unpadded-33mib.zip b/tests/fixtures/archive-ratio/unpadded-33mib.zip new file mode 100644 index 0000000..c0e90e3 Binary files /dev/null and b/tests/fixtures/archive-ratio/unpadded-33mib.zip differ diff --git a/tests/fixtures/pdf/annotation-flags/flags.pdf b/tests/fixtures/pdf/annotation-flags/flags.pdf new file mode 100644 index 0000000..473d2b6 --- /dev/null +++ b/tests/fixtures/pdf/annotation-flags/flags.pdf @@ -0,0 +1,348 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 38 /Kids [6 0 R 8 0 R 10 0 R 12 0 R 14 0 R 16 0 R 18 0 R 20 0 R 22 0 R 24 0 R 26 0 R 28 0 R 30 0 R 32 0 R 34 0 R 36 0 R 38 0 R 40 0 R 42 0 R 44 0 R 46 0 R 48 0 R 50 0 R 52 0 R 54 0 R 56 0 R 58 0 R 60 0 R 62 0 R 64 0 R 66 0 R 68 0 R 70 0 R 72 0 R 74 0 R 76 0 R 81 0 R 83 0 R] >> +endobj +3 0 obj +<< /Length 63 /Type /XObject /Subtype /Form /BBox [0 0 32 16] /Matrix [2 0 0 3 7 11] /Resources << /ExtGState << /Opaque << /Type /ExtGState /ca 1 /CA 1 >> >> >> >> +stream +q /Opaque gs 1 0 0 rg 0 0 16 16 re f 0 0 1 rg 16 0 16 16 re f Q +endstream +endobj +4 0 obj +<< /Length 23 /Type /XObject /Subtype /Form /BBox [0 0 32 16] /Resources << >> >> +stream +0 1 0 rg 0 0 32 16 re f +endstream +endobj +5 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (generated-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [5 0 R] >> +endobj +7 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (generated-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +8 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [7 0 R] >> +endobj +9 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (generated-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +10 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [9 0 R] >> +endobj +11 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (generated-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +12 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [11 0 R] >> +endobj +13 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (generated-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +14 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [13 0 R] >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (generated-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +16 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [15 0 R] >> +endobj +17 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (generated-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +18 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [17 0 R] >> +endobj +19 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (generated-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +20 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [19 0 R] >> +endobj +21 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (generated-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +22 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [21 0 R] >> +endobj +23 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (generated-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +24 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [23 0 R] >> +endobj +25 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (generated-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +26 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [25 0 R] >> +endobj +27 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (generated-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /C [1 0 0] /BS << /W 2 /S /S >> >> +endobj +28 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [27 0 R] >> +endobj +29 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (stored-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +30 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [29 0 R] >> +endobj +31 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (stored-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +32 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [31 0 R] >> +endobj +33 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (stored-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +34 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [33 0 R] >> +endobj +35 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 8 /Contents (stored-link flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +36 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [35 0 R] >> +endobj +37 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (stored-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +38 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [37 0 R] >> +endobj +39 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (stored-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +40 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [39 0 R] >> +endobj +41 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (stored-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +42 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [41 0 R] >> +endobj +43 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 16 /Contents (stored-link flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +44 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [43 0 R] >> +endobj +45 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (stored-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +46 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [45 0 R] >> +endobj +47 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (stored-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +48 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [47 0 R] >> +endobj +49 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (stored-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +50 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [49 0 R] >> +endobj +51 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /Contents (stored-link flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +52 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [51 0 R] >> +endobj +53 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 8 /Contents (stored-comment flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +54 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [53 0 R] >> +endobj +55 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 8 /Contents (stored-comment flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +56 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [55 0 R] >> +endobj +57 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 8 /Contents (stored-comment flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +58 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [57 0 R] >> +endobj +59 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 8 /Contents (stored-comment flags 8) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +60 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [59 0 R] >> +endobj +61 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 16 /Contents (stored-comment flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +62 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [61 0 R] >> +endobj +63 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 16 /Contents (stored-comment flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +64 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [63 0 R] >> +endobj +65 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 16 /Contents (stored-comment flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +66 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [65 0 R] >> +endobj +67 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 16 /Contents (stored-comment flags 16) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +68 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [67 0 R] >> +endobj +69 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 24 /Contents (stored-comment flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +70 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [69 0 R] >> +endobj +71 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 24 /Contents (stored-comment flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +72 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << >> /Annots [71 0 R] >> +endobj +73 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 24 /Contents (stored-comment flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +74 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << >> /Annots [73 0 R] >> +endobj +75 0 obj +<< /Type /Annot /Subtype /Stamp /Rect [42 48 74 64] /F 24 /Contents (stored-comment flags 24) /A << /S /URI /URI (https://example.org/flags) >> /AP << /N << /On 3 0 R /Off 4 0 R >> >> /AS /On >> +endobj +76 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << >> /Annots [75 0 R] >> +endobj +77 0 obj +<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /C [1 0 0] /BS << /W 2 >> >> +endobj +78 0 obj +<< /Type /Annot /Subtype /Link /Rect [41 47 75 65] /F 24 /AP << /N 4 0 R >> >> +endobj +79 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 80 36 96] /F 26 /Contents (Invisible comment) /AP << /N 3 0 R >> >> +endobj +80 0 obj +<< /Type /Annot /Subtype /Link /Rect [80 80 96 96] /F 56 /Contents (Invisible comment) /AP << /N 3 0 R >> >> +endobj +81 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [77 0 R 78 0 R 79 0 R 80 0 R] >> +endobj +82 0 obj +<< /Type /Annot /Subtype /Link /Rect [108 48 140 64] /F 24 /AP << /N 3 0 R >> >> +endobj +83 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << >> /Annots [82 0 R] >> +endobj +xref +0 84 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000380 00000 n +0000000641 00000 n +0000000779 00000 n +0000000970 00000 n +0000001108 00000 n +0000001299 00000 n +0000001438 00000 n +0000001629 00000 n +0000001770 00000 n +0000001962 00000 n +0000002104 00000 n +0000002298 00000 n +0000002438 00000 n +0000002632 00000 n +0000002773 00000 n +0000002967 00000 n +0000003109 00000 n +0000003303 00000 n +0000003445 00000 n +0000003639 00000 n +0000003779 00000 n +0000003973 00000 n +0000004114 00000 n +0000004308 00000 n +0000004450 00000 n +0000004644 00000 n +0000004786 00000 n +0000004991 00000 n +0000005131 00000 n +0000005336 00000 n +0000005477 00000 n +0000005682 00000 n +0000005824 00000 n +0000006029 00000 n +0000006171 00000 n +0000006378 00000 n +0000006518 00000 n +0000006725 00000 n +0000006866 00000 n +0000007073 00000 n +0000007215 00000 n +0000007422 00000 n +0000007564 00000 n +0000007771 00000 n +0000007911 00000 n +0000008118 00000 n +0000008259 00000 n +0000008466 00000 n +0000008608 00000 n +0000008815 00000 n +0000008957 00000 n +0000009166 00000 n +0000009306 00000 n +0000009515 00000 n +0000009656 00000 n +0000009865 00000 n +0000010007 00000 n +0000010216 00000 n +0000010358 00000 n +0000010569 00000 n +0000010709 00000 n +0000010920 00000 n +0000011061 00000 n +0000011272 00000 n +0000011414 00000 n +0000011625 00000 n +0000011767 00000 n +0000011978 00000 n +0000012118 00000 n +0000012329 00000 n +0000012470 00000 n +0000012681 00000 n +0000012823 00000 n +0000013034 00000 n +0000013176 00000 n +0000013278 00000 n +0000013373 00000 n +0000013498 00000 n +0000013623 00000 n +0000013784 00000 n +0000013881 00000 n +trailer +<< /Size 84 /Root 1 0 R >> +startxref +14021 +%%EOF diff --git a/tests/fixtures/pdf/annotation-flags/manifest.json b/tests/fixtures/pdf/annotation-flags/manifest.json new file mode 100644 index 0000000..4dacb30 --- /dev/null +++ b/tests/fixtures/pdf/annotation-flags/manifest.json @@ -0,0 +1,464 @@ +{ + "generator": "generate_annotation_flags.py", + "license": "Self-created test content; same license as DocView.", + "sha256": "ec4a93fab82fd79efa2c8d9de5a83ad64350d7d02c061638ceda06007e1b8d4c", + "cropBox": [ + 8, + 12, + 120, + 116 + ], + "cases": [ + { + "page": 0, + "intrinsicRotation": 0, + "kind": "generated-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 1, + "intrinsicRotation": 90, + "kind": "generated-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 2, + "intrinsicRotation": 180, + "kind": "generated-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 3, + "intrinsicRotation": 270, + "kind": "generated-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 4, + "intrinsicRotation": 0, + "kind": "generated-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 5, + "intrinsicRotation": 90, + "kind": "generated-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 6, + "intrinsicRotation": 180, + "kind": "generated-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 7, + "intrinsicRotation": 270, + "kind": "generated-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 8, + "intrinsicRotation": 0, + "kind": "generated-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 9, + "intrinsicRotation": 90, + "kind": "generated-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 10, + "intrinsicRotation": 180, + "kind": "generated-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 11, + "intrinsicRotation": 270, + "kind": "generated-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 12, + "intrinsicRotation": 0, + "kind": "stored-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 13, + "intrinsicRotation": 90, + "kind": "stored-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 14, + "intrinsicRotation": 180, + "kind": "stored-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 15, + "intrinsicRotation": 270, + "kind": "stored-link", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 16, + "intrinsicRotation": 0, + "kind": "stored-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 17, + "intrinsicRotation": 90, + "kind": "stored-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 18, + "intrinsicRotation": 180, + "kind": "stored-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 19, + "intrinsicRotation": 270, + "kind": "stored-link", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 20, + "intrinsicRotation": 0, + "kind": "stored-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 21, + "intrinsicRotation": 90, + "kind": "stored-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 22, + "intrinsicRotation": 180, + "kind": "stored-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 23, + "intrinsicRotation": 270, + "kind": "stored-link", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 24, + "intrinsicRotation": 0, + "kind": "stored-comment", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 25, + "intrinsicRotation": 90, + "kind": "stored-comment", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 26, + "intrinsicRotation": 180, + "kind": "stored-comment", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 27, + "intrinsicRotation": 270, + "kind": "stored-comment", + "flags": 8, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 28, + "intrinsicRotation": 0, + "kind": "stored-comment", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 29, + "intrinsicRotation": 90, + "kind": "stored-comment", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 30, + "intrinsicRotation": 180, + "kind": "stored-comment", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 31, + "intrinsicRotation": 270, + "kind": "stored-comment", + "flags": 16, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 32, + "intrinsicRotation": 0, + "kind": "stored-comment", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 33, + "intrinsicRotation": 90, + "kind": "stored-comment", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 34, + "intrinsicRotation": 180, + "kind": "stored-comment", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 35, + "intrinsicRotation": 270, + "kind": "stored-comment", + "flags": 24, + "rect": [ + 42, + 48, + 74, + 64 + ] + }, + { + "page": 36, + "intrinsicRotation": 0, + "kind": "visibility-zorder", + "flags": 24 + }, + { + "page": 37, + "intrinsicRotation": 0, + "kind": "crop-clipping", + "flags": 24, + "rect": [ + 108, + 48, + 140, + 64 + ] + } + ], + "expected": "At 100% one PDF point is one logical pixel. NoZoom preserves logical 32x16 size across zoom and scales only with DPR. NoRotate preserves upright red-left/blue-right appearance around the original Rect upper-left through intrinsic plus user rotation. Generated red border extends one point beyond Rect. This is synthetic geometry, not a general PDF compatibility golden." +} diff --git a/tests/fixtures/pdf/annotation-flags/text-icons-manifest.json b/tests/fixtures/pdf/annotation-flags/text-icons-manifest.json new file mode 100644 index 0000000..9962e1a --- /dev/null +++ b/tests/fixtures/pdf/annotation-flags/text-icons-manifest.json @@ -0,0 +1,17 @@ +{ + "license": "Self-created test content; same license as DocView.", + "sha256": "44879caa880ad8c892e2f0cdca883fe5b8e471a92aecabb5583fb78d3fd34d86", + "originalRect": [ + 42, + 48, + 74, + 64 + ], + "nativeIconRect": [ + 42, + 48, + 62, + 68 + ], + "expected": "Twelve pages: flags 8/16/24, each with intrinsic rotations 0/90/180/270. Four pages share each annotation. Capture the native generated AP once, retain source Rect, use the captured 20x20 icon Rect for drawing and hit testing." +} diff --git a/tests/fixtures/pdf/annotation-flags/text-icons.pdf b/tests/fixtures/pdf/annotation-flags/text-icons.pdf new file mode 100644 index 0000000..28ecbb8 --- /dev/null +++ b/tests/fixtures/pdf/annotation-flags/text-icons.pdf @@ -0,0 +1,88 @@ +%PDF-1.7 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 12 /Kids [6 0 R 7 0 R 8 0 R 9 0 R 11 0 R 12 0 R 13 0 R 14 0 R 16 0 R 17 0 R 18 0 R 19 0 R] >> +endobj +3 0 obj +<< /Length 68 >> +stream +BT /F1 10 Tf 12 100 Td (Native icon with unembedded body text) Tj ET +endstream +endobj +4 0 obj +<< /Type /Annot /Subtype /Ink /Rect [80 24 100 34] /F 8 /Contents (Native ink) /BS << /W 4 >> /InkList [[80 24 90 34 100 24]] >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Text /Rect [42 48 74 64] /F 8 /Contents (Native text icon) /C [1 0 0] >> +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [5 0 R 4 0 R 4 0 R] >> +endobj +7 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [5 0 R 4 0 R 4 0 R] >> +endobj +8 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [5 0 R 4 0 R 4 0 R] >> +endobj +9 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [5 0 R 4 0 R 4 0 R] >> +endobj +10 0 obj +<< /Type /Annot /Subtype /Text /Rect [42 48 74 64] /F 16 /Contents (Native text icon) /C [1 0 0] >> +endobj +11 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [10 0 R 4 0 R 4 0 R] >> +endobj +12 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [10 0 R 4 0 R 4 0 R] >> +endobj +13 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [10 0 R 4 0 R 4 0 R] >> +endobj +14 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [10 0 R 4 0 R 4 0 R] >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Text /Rect [42 48 74 64] /F 24 /Contents (Native text icon) /C [1 0 0] >> +endobj +16 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 0 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [15 0 R 4 0 R 4 0 R] >> +endobj +17 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 90 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [15 0 R 4 0 R 4 0 R] >> +endobj +18 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 180 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [15 0 R 4 0 R 4 0 R] >> +endobj +19 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate 270 /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [15 0 R 4 0 R 4 0 R] >> +endobj +xref +0 20 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000190 00000 n +0000000308 00000 n +0000000452 00000 n +0000000566 00000 n +0000000805 00000 n +0000001045 00000 n +0000001286 00000 n +0000001527 00000 n +0000001643 00000 n +0000001884 00000 n +0000002126 00000 n +0000002369 00000 n +0000002612 00000 n +0000002728 00000 n +0000002969 00000 n +0000003211 00000 n +0000003454 00000 n +trailer +<< /Size 20 /Root 1 0 R >> +startxref +3697 +%%EOF diff --git a/tests/fixtures/pdf/cmyk-lut/README.md b/tests/fixtures/pdf/cmyk-lut/README.md new file mode 100644 index 0000000..a01a799 --- /dev/null +++ b/tests/fixtures/pdf/cmyk-lut/README.md @@ -0,0 +1,5 @@ +# CMYK ICC CLUT試験資料 + +`icc-cmyk-lut.pdf`は、同じCMYK入力をICCベクター・ICC画像・DeviceCMYK対照で表示する生成資料である。自作ICC入力プロファイルのCLUT頂点と数式から色の期待値を定める。制作条件・数値・既知の差は[描画比較記録](../../../results/cmyk-lut/README.md)を参照する。 + +`manifest.json`はPDFとICCのSHA-256、16個のICC色測定点、8個の対照点、生成器hash、測定対象外の範囲を保持する。物理的な印刷条件やモニターを校正するためのプロファイルではない。PDFiumと期待色の差が見つかっており、正解画像の人による承認は未完了である。 diff --git a/tests/fixtures/pdf/cmyk-lut/icc-cmyk-lut.pdf b/tests/fixtures/pdf/cmyk-lut/icc-cmyk-lut.pdf new file mode 100644 index 0000000..3a4c01c Binary files /dev/null and b/tests/fixtures/pdf/cmyk-lut/icc-cmyk-lut.pdf differ diff --git a/tests/fixtures/pdf/cmyk-lut/manifest.json b/tests/fixtures/pdf/cmyk-lut/manifest.json new file mode 100644 index 0000000..001d974 --- /dev/null +++ b/tests/fixtures/pdf/cmyk-lut/manifest.json @@ -0,0 +1,535 @@ +{ + "file": "icc-cmyk-lut.pdf", + "sha256": "3cc5ea798bc61b0d1af2fc1ee256bedc961ca40b05ad2002a9fee6a9227330fe", + "profileFile": "synthetic-cmyk-lab.icc", + "profileSha256": "1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7", + "profileBytes": 588, + "pageSizePt": [ + 720, + 420 + ], + "profile": { + "version": "2.1", + "class": "scnr", + "input": "CMYK", + "pcs": "Lab ", + "tag": "A2B0", + "type": "mft2", + "gridPointsPerChannel": 2, + "mapping": [ + "L=100-12*C-15*M-10*Y-35*K", + "a=18*M-15*C", + "b=20*Y-12*C" + ], + "labEncoding": "ICC v2 L: 0..65280 for 0..100; a/b: (value+128)*256", + "source": "Self-authored binary ICC input profile; no borrowed ICC profile" + }, + "probes": [ + { + "kind": "icc-vector", + "sample": 0, + "pointPt": [ + 71, + 292 + ], + "components": [ + 0, + 0, + 0, + 0 + ], + "lab": [ + 100, + 0, + 0 + ], + "expectedRgb": [ + 255, + 255, + 255 + ] + }, + { + "kind": "icc-image", + "sample": 0, + "pointPt": [ + 76.5, + 194 + ], + "components": [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + "lab": [ + 100.0, + 0.0, + 0.0 + ], + "expectedRgb": [ + 255, + 255, + 255 + ] + }, + { + "kind": "device-control", + "sample": 0, + "pointPt": [ + 71, + 96 + ], + "components": [ + 0, + 0, + 0, + 0 + ] + }, + { + "kind": "icc-vector", + "sample": 1, + "pointPt": [ + 152, + 292 + ], + "components": [ + 1, + 0, + 0, + 0 + ], + "lab": [ + 88, + -15, + -12 + ], + "expectedRgb": [ + 178, + 230, + 243 + ] + }, + { + "kind": "icc-image", + "sample": 1, + "pointPt": [ + 157.5, + 194 + ], + "components": [ + 1.0, + 0.0, + 0.0, + 0.0 + ], + "lab": [ + 88.0, + -15.0, + -12.0 + ], + "expectedRgb": [ + 178, + 230, + 243 + ] + }, + { + "kind": "device-control", + "sample": 1, + "pointPt": [ + 152, + 96 + ], + "components": [ + 1, + 0, + 0, + 0 + ] + }, + { + "kind": "icc-vector", + "sample": 2, + "pointPt": [ + 233, + 292 + ], + "components": [ + 0, + 1, + 0, + 0 + ], + "lab": [ + 85, + 18, + 0 + ], + "expectedRgb": [ + 246, + 200, + 213 + ] + }, + { + "kind": "icc-image", + "sample": 2, + "pointPt": [ + 238.5, + 194 + ], + "components": [ + 0.0, + 1.0, + 0.0, + 0.0 + ], + "lab": [ + 85.0, + 18.0, + 0.0 + ], + "expectedRgb": [ + 246, + 200, + 213 + ] + }, + { + "kind": "device-control", + "sample": 2, + "pointPt": [ + 233, + 96 + ], + "components": [ + 0, + 1, + 0, + 0 + ] + }, + { + "kind": "icc-vector", + "sample": 3, + "pointPt": [ + 314, + 292 + ], + "components": [ + 0, + 0, + 1, + 0 + ], + "lab": [ + 90, + 0, + 20 + ], + "expectedRgb": [ + 238, + 226, + 188 + ] + }, + { + "kind": "icc-image", + "sample": 3, + "pointPt": [ + 319.5, + 194 + ], + "components": [ + 0.0, + 0.0, + 1.0, + 0.0 + ], + "lab": [ + 90.0, + 0.0, + 20.0 + ], + "expectedRgb": [ + 238, + 226, + 188 + ] + }, + { + "kind": "device-control", + "sample": 3, + "pointPt": [ + 314, + 96 + ], + "components": [ + 0, + 0, + 1, + 0 + ] + }, + { + "kind": "icc-vector", + "sample": 4, + "pointPt": [ + 395, + 292 + ], + "components": [ + 0, + 0, + 0, + 1 + ], + "lab": [ + 65, + 0, + 0 + ], + "expectedRgb": [ + 158, + 158, + 158 + ] + }, + { + "kind": "icc-image", + "sample": 4, + "pointPt": [ + 400.5, + 194 + ], + "components": [ + 0.0, + 0.0, + 0.0, + 1.0 + ], + "lab": [ + 65.0, + 0.0, + 0.0 + ], + "expectedRgb": [ + 158, + 158, + 158 + ] + }, + { + "kind": "device-control", + "sample": 4, + "pointPt": [ + 395, + 96 + ], + "components": [ + 0, + 0, + 0, + 1 + ] + }, + { + "kind": "icc-vector", + "sample": 5, + "pointPt": [ + 476, + 292 + ], + "components": [ + 0.25, + 0.5, + 0.75, + 0.2 + ], + "lab": [ + 75.0, + 5.25, + 12.0 + ], + "expectedRgb": [ + 201, + 181, + 163 + ] + }, + { + "kind": "icc-image", + "sample": 5, + "pointPt": [ + 481.5, + 194 + ], + "components": [ + 0.25098039215686274, + 0.5019607843137255, + 0.7490196078431373, + 0.2 + ], + "lab": [ + 74.96862745098039, + 5.270588235294118, + 11.968627450980392 + ], + "expectedRgb": [ + 201, + 181, + 163 + ] + }, + { + "kind": "device-control", + "sample": 5, + "pointPt": [ + 476, + 96 + ], + "components": [ + 0.25, + 0.5, + 0.75, + 0.2 + ] + }, + { + "kind": "icc-vector", + "sample": 6, + "pointPt": [ + 557, + 292 + ], + "components": [ + 0.6, + 0.2, + 0.4, + 0.1 + ], + "lab": [ + 82.3, + -5.4, + 0.8000000000000007 + ], + "expectedRgb": [ + 195, + 208, + 203 + ] + }, + { + "kind": "icc-image", + "sample": 6, + "pointPt": [ + 562.5, + 194 + ], + "components": [ + 0.6, + 0.2, + 0.4, + 0.10196078431372549 + ], + "lab": [ + 82.23137254901961, + -5.4, + 0.8000000000000007 + ], + "expectedRgb": [ + 194, + 208, + 203 + ] + }, + { + "kind": "device-control", + "sample": 6, + "pointPt": [ + 557, + 96 + ], + "components": [ + 0.6, + 0.2, + 0.4, + 0.1 + ] + }, + { + "kind": "icc-vector", + "sample": 7, + "pointPt": [ + 638, + 292 + ], + "components": [ + 1, + 1, + 1, + 1 + ], + "lab": [ + 28, + 3, + 8 + ], + "expectedRgb": [ + 74, + 64, + 54 + ] + }, + { + "kind": "icc-image", + "sample": 7, + "pointPt": [ + 643.5, + 194 + ], + "components": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "lab": [ + 28.0, + 3.0, + 8.0 + ], + "expectedRgb": [ + 74, + 64, + 54 + ] + }, + { + "kind": "device-control", + "sample": 7, + "pointPt": [ + 638, + 96 + ], + "components": [ + 1, + 1, + 1, + 1 + ] + } + ], + "tolerance8Bit": 4, + "references": [ + "https://archive.color.org/files/icc32.pdf", + "https://www.color.org/chardata/rgb/srgb.xalter" + ], + "limits": [ + "Synthetic CMYK input CLUT; not a measured print profile or calibrated press proof.", + "No human-approved golden; no physical display or Windows validation." + ], + "generatorSha256": "ed93e85cdf4e398409d49bbf941abfe79062c5a35bde359d75f14e4a8746c8f8" +} diff --git a/tests/fixtures/pdf/cmyk-lut/synthetic-cmyk-lab.icc b/tests/fixtures/pdf/cmyk-lut/synthetic-cmyk-lab.icc new file mode 100644 index 0000000..b1b3229 Binary files /dev/null and b/tests/fixtures/pdf/cmyk-lut/synthetic-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/comments.pdf b/tests/fixtures/pdf/comments.pdf new file mode 100644 index 0000000..28bc307 --- /dev/null +++ b/tests/fixtures/pdf/comments.pdf @@ -0,0 +1,30 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Annots [4 0 R 5 0 R] /Resources << >> >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Contents (Literal comment) /F 4 >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Text /Rect [1000 1000 1020 1020] /Contents (Outside CropBox comment) /F 4 >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000256 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +487 +%%EOF diff --git a/tests/fixtures/pdf/corrupt.pdf b/tests/fixtures/pdf/corrupt.pdf new file mode 100644 index 0000000..aef3ed1 --- /dev/null +++ b/tests/fixtures/pdf/corrupt.pdf @@ -0,0 +1,2 @@ +%PDF-1.7 +This is deliberately corrupt. diff --git a/tests/fixtures/pdf/extended/FONT-OFL.txt b/tests/fixtures/pdf/extended/FONT-OFL.txt new file mode 100644 index 0000000..3fa230b --- /dev/null +++ b/tests/fixtures/pdf/extended/FONT-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tests/fixtures/pdf/extended/LCMS2-LICENSE.txt b/tests/fixtures/pdf/extended/LCMS2-LICENSE.txt new file mode 100644 index 0000000..85480b5 --- /dev/null +++ b/tests/fixtures/pdf/extended/LCMS2-LICENSE.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Marti Maria Saguer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject +to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/fixtures/pdf/extended/README.md b/tests/fixtures/pdf/extended/README.md new file mode 100644 index 0000000..4daf19e --- /dev/null +++ b/tests/fixtures/pdf/extended/README.md @@ -0,0 +1,73 @@ +# Extended synthetic PDF corpus + +These five original documents contain nine pages. Their drawing source and +explicit expected geometry are in `../extended_generator.py` and `manifest.json`. +They are reproducible test fixtures, not approved golden images. + +| Document | Pages | Covered content | +| --- | ---: | --- | +| japanese-layout.pdf | 2 | Embedded Japanese TrueType subset, horizontal text, explicit ruby coordinates, vertical glyph positions, text matrices at 0/90/180/270 degrees | +| graphics-compositing.pdf | 1 | Original RGB and RGBA images, soft mask, 50% vector transparency and overlap, circular clipping, axial shading, CMYK swatches | +| page-geometry.pdf | 4 | Different MediaBox dimensions, offset CropBox, Rotate values 0/90/270/180, four known color markers per page | +| unembedded-font.pdf | 1 | Standard-14 Helvetica, Times-Roman and Courier substitution baseline | +| icc-linear-rgb.pdf | 1 | Embedded linear-sRGB matrix/TRC profile in ICCBased vector and raster colors, plus identical DeviceRGB control values | + +The Japanese text uses BIZ UDPGothic Regular. The font is subset-embedded, and +its SIL Open Font License 1.1 is included in `FONT-OFL.txt`. The font file hash +and upstream license URL are recorded in the manifest. The full font file is +not redistributed here. The license permits embedding and redistribution; +these documents do not modify the original font's reserved name. + +Generate with Python containing ReportLab, pypdf and Pillow: + +```sh +python3 tests/fixtures/pdf/extended_generator.py +``` + +The default font location is +`/usr/local/share/fonts/BIZ_UDPGothic/BIZUDPGothic-Regular.ttf`. +Set `DOCVIEW_JAPANESE_FONT` to another installation of that font with its adjacent +`OFL.txt` when necessary. A changed font file changes the fixture hashes. + +After building DocView's `pdf-worker-evidence` target and installing Poppler command-line tools: + +```sh +python3 tests/compare_pdf_extended.py +``` + +The comparison generates full-resolution PNGs, difference images, contact sheets, +text extraction and a machine-readable report under `tests/results/pdf-extended`. +It assembles pages from the production sandboxed PDF worker's 512-pixel tiles, using FontBroker for unembedded fonts, and +compares them with independently rendered Poppler pages at 144 dpi. CropBox and +Rotate are honored. Deterministic size, marker, alpha and Japanese text/search +assertion failures produce a nonzero exit status; image-difference metrics do +not have an invented acceptance threshold. + +The script defaults to system `/usr/bin` Poppler tools when present. Override +with `DOCVIEW_PDFTOPPM`, `DOCVIEW_PDFTOTEXT` and `DOCVIEW_PDFFONTS`, or choose a +different build directory with `DOCVIEW_BUILD_DIR`. + +Explicit limitations: vertical text uses individual positioned glyphs, not native +vertical CID metrics or OpenType shaping; ruby positions are authored directly; +CMYK has no calibrated ICC golden; unembedded font coverage is Standard-14 Latin +only. This corpus does not establish arbitrary-document, Windows, accessibility, +print-production or full PDF compatibility. + +## ICC profile and additional scale checks + +`../icc_generator.py` creates `linear-srgb.icc` from D65 white, sRGB primaries +and gamma 1 through public LittleCMS APIs. It uses no downloaded or borrowed +profile. The generated copyright tag reads `No copyright, use freely`; the +LittleCMS MIT license is retained in `LCMS2-LICENSE.txt`. The manifest records +the numeric parameters, profile hash, library version and upstream source. +The profile's creation timestamp is fixed, and its optional ID is zero, for +reproducibility. The same profile bytes are embedded for vector and image colors. + +Run `python3 tests/fixtures/pdf/icc_generator.py` to add/rebuild only this fixture +without requiring the Japanese font. The full generator includes it as well. +`python3 tests/compare_pdf_icc.py` compares actual worker scales 0.5, 1 and 4 +against Poppler at 36/72/288 dpi. It checks 54 analytic color probes, page +dimensions and original SHA-256. Linear 0.5 gray must differ materially from +DeviceRGB 0.5; this prevents an ignored ICC profile from passing. Evidence is +in [pdf-icc](../../../results/pdf-icc/README.md). This one RGB profile does not +establish arbitrary ICC LUT, CMYK press calibration or print color proofing. diff --git a/tests/fixtures/pdf/extended/graphics-compositing.pdf b/tests/fixtures/pdf/extended/graphics-compositing.pdf new file mode 100644 index 0000000..8fb82c6 --- /dev/null +++ b/tests/fixtures/pdf/extended/graphics-compositing.pdf @@ -0,0 +1,129 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 96 /Length 770 /Subtype /Image + /Type /XObject /Width 96 +>> +stream +Gb"/eb=uP&&-Zt`KLJ3YaD-k^$T89A5Q*ko]2*2nDdV>'nltbXI%?Vu\)%#XpZc)FkL,m`a/Lag29tgj>b@o:a%[iE"`irt\m?N"80mR+Ed!;h]g0ju;$/J51FMJH"`f>cfB4U(aX0pU!]d5J6=)+$IGNFNg)],T'?PNNS4Fhm09G3@'(l;dFs:3FOCKQ!p&[7d7-\)$B:5]L(W`!Xp$`#C6_`o<']`dendstream +endobj +4 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 96 /Length 436 /SMask 5 0 R + /Subtype /Image /Type /XObject /Width 96 +>> +stream +Gb"0NYn=i,&4BAFTbT>?TQUB/+^bKugVU8h=46!:!d7piQ]!TiLQ0\nA=-PGhZaN]P9Dfc4n3lq/!tu]/UX+T]a5RLsF9^#E7-EM;5]enZf0jIp3[JNtdiD;4S;rCRS9uV$jqjO3)Ed)6~>endstream +endobj +5 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceGray /Decode [ 0 1 ] /Filter [ /ASCII85Decode /FlateDecode ] /Height 96 /Length 304 + /Subtype /Image /Type /XObject /Width 96 +>> +stream +Gb"0S5n\f8#Xm(NrrM7A3Eqf<"D;E3/dhbS([pfDKe[mgbWLrJAk+Im:1B'X'+R0&E9b7@TLT>3Ns8u5$3YIH&ELNU0^fApGY]I$mfQ.1(J+/`!.eJ\./Y83p.>0^JB$=E,&[\(5*\&.@\:mE"N3KprsBJ7"oLl`\:6Vfji7Y6J#be&rbT@Jp=m@8YPs!"OADF@%?0KFe\BGlIeE`Qo)I>'pD`]%O*lFWI_R8fJ'.Mpo)5nBIkKXYbO68l'NE%CWPaTE~>endstream +endobj +6 0 obj +<< +/ColorSpace /DeviceRGB /Coords [ 40 205 525 205 ] /Extend [true true] /Function 10 0 R /ShadingType 2 +>> +endobj +7 0 obj +<< +/Contents 12 0 R /MediaBox [ 0 0 600 760 ] /Parent 11 0 R /Resources << +/ExtGState << +/gRLs0 << +/ca .5 +>> /gRLs1 << +/ca 1 +>> +>> /Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /Shading << +/Sh0 6 0 R +>> /XObject << +/FormXob.dde330c7bd98d143ad6dbfc31b4d18ea 4 0 R /FormXob.fd3160a8deaece01bd46d36268643177 3 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/PageMode /UseNone /Pages 11 0 R /Type /Catalog +>> +endobj +9 0 obj +<< +/Author (DocView tests) /CreationDate (D:20000101000000+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (DocView synthetic rendering fixture - graphics-compositing.pdf) /Trapped /False +>> +endobj +10 0 obj +<< +/Bounds [ .5 ] /Domain [0.0 1.0] /Encode [ 0 1 0 1 ] /FunctionType 3 /Functions [ 13 0 R 14 0 R ] +>> +endobj +11 0 obj +<< +/Count 1 /Kids [ 7 0 R ] /Type /Pages +>> +endobj +12 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 845 +>> +stream +Gatm9gMY_1&:O:SbY+cjMTjqjIO$ibPuJGK[=):r@q`ZT`bP;HbjJqLW1m*#!M8t1B$NEC1PPtI%s?KG"`T_lmu@H\ON`64:d?dhgE['(L^A]q;5poh)[T*0/qPu(Z2opN")sn#EcVO!UQDS8o>>'fE'&UloS94=52,>]_eA]3D1KgOIK.![M:oB_:lsC&7PXm/F%I0p,WaEm/!DPsrb,3;#>6,l=W`QsC\'D10!!ZRk:0UsV#WF[cNnREDD5PgcVQW6WZ`=rlm'/;ie@Q12G"k-1gs%BRe9WN.d$YpNp508tV'*A8'6LNJ)%b`$tS=6Zs9UKK@YR'5(5`$P=l1[GPT]28J)$4K2]0f_b%<32\Bi[HVIQHg<)e!A0oXIU#-PbH?\gWNAePNr&b\l)b#J#=06oCCm"4KA_D$"=M\A32$PmjQrjZ)e3\B9j[;N,$chPCRk@#!X%\]:7(cr>@@J@K*j12).Jf@e*'Ng"?,lCgQcMq8-cU%j0>gfM%@P4ft+tP*W^V$$d?!W5[^)K!#UiZJ?>:k1rb#QV/c)endstream +endobj +13 0 obj +<< +/C0 [ .078431 .490196 .47451 ] /C1 [ 1 1 1 ] /Domain [ 0 1 ] /FunctionType 2 /N 1 +>> +endobj +14 0 obj +<< +/C0 [ 1 1 1 ] /C1 [ .85098 .313725 .313725 ] /Domain [ 0 1 ] /FunctionType 2 /N 1 +>> +endobj +xref +0 15 +0000000000 65535 f +0000000061 00000 n +0000000092 00000 n +0000000199 00000 n +0000001157 00000 n +0000001794 00000 n +0000002303 00000 n +0000002426 00000 n +0000002814 00000 n +0000002883 00000 n +0000003202 00000 n +0000003322 00000 n +0000003382 00000 n +0000004318 00000 n +0000004422 00000 n +trailer +<< +/ID +[<998c181bcc6a27b405fdaeda1882afd9><998c181bcc6a27b405fdaeda1882afd9>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 9 0 R +/Root 8 0 R +/Size 15 +>> +startxref +4526 +%%EOF diff --git a/tests/fixtures/pdf/extended/icc-linear-rgb.pdf b/tests/fixtures/pdf/extended/icc-linear-rgb.pdf new file mode 100644 index 0000000..6c3d4dd Binary files /dev/null and b/tests/fixtures/pdf/extended/icc-linear-rgb.pdf differ diff --git a/tests/fixtures/pdf/extended/japanese-layout.pdf b/tests/fixtures/pdf/extended/japanese-layout.pdf new file mode 100644 index 0000000..e7e3368 Binary files /dev/null and b/tests/fixtures/pdf/extended/japanese-layout.pdf differ diff --git a/tests/fixtures/pdf/extended/linear-srgb.icc b/tests/fixtures/pdf/extended/linear-srgb.icc new file mode 100644 index 0000000..8f3bc9e Binary files /dev/null and b/tests/fixtures/pdf/extended/linear-srgb.icc differ diff --git a/tests/fixtures/pdf/extended/manifest.json b/tests/fixtures/pdf/extended/manifest.json new file mode 100644 index 0000000..64292fc --- /dev/null +++ b/tests/fixtures/pdf/extended/manifest.json @@ -0,0 +1,776 @@ +{ + "origin": "Original deterministic vector, text and raster drawings authored for DocView tests", + "reference_status": "Synthetic source specifies content and coordinates; no human-approved golden image exists", + "font": { + "family": "BIZ UDPGothic Regular", + "source_file": "BIZUDPGothic-Regular.ttf", + "sha256": "22f41ce68f1ce62477ca4ec1e2c0c400cc545ddff31d713e8edf87808614aeb2", + "license": "SIL Open Font License 1.1", + "license_copy": "FONT-OFL.txt", + "upstream": "https://github.com/googlefonts/morisawa-biz-ud-gothic", + "upstream_license": "https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/OFL.txt", + "embedding": "Subset embedded by ReportLab; OFL permits embedding and redistribution. No reserved font name is changed." + }, + "documents": [ + { + "file": "japanese-layout.pdf", + "sha256": "47011b3c7c7363bc9549881ade31b836e1efdae555504076627978dbde136f00", + "page_count": 2, + "expected": { + "page_sizes_pt": [ + [ + 540, + 720 + ], + [ + 540, + 720 + ] + ], + "horizontal_text": "日本語の文字配置と埋込みフォント", + "vertical_columns": [ + { + "x": 420, + "top": 590, + "step": 32, + "text": "日本語縦書き検証" + }, + { + "x": 374, + "top": 590, + "step": 32, + "text": "天地左右回転配置" + } + ], + "text_matrix_rotations_degrees": [ + 0, + 90, + 180, + 270 + ], + "font_embedded": true + }, + "limits": [ + "Manual vertical glyph placement does not test native vertical CID metrics or OpenType shaping.", + "Ruby placement is explicit source geometry, not automatic ruby layout." + ] + }, + { + "file": "graphics-compositing.pdf", + "sha256": "ddaec9f619dde57995e9baff6a560151e9757d4b1bdce9b630299a5f0438d49e", + "page_count": 1, + "expected": { + "page_sizes_pt": [ + [ + 600, + 760 + ] + ], + "rgb_image_pixels": [ + 96, + 96 + ], + "alpha": 0.5, + "alpha_probes": [ + { + "point_pt": [ + 80, + 370 + ], + "expected_rgb": [ + 244, + 119, + 121 + ], + "region": "red over gray" + }, + { + "point_pt": [ + 180, + 390 + ], + "expected_rgb": [ + 116, + 119, + 248 + ], + "region": "blue over gray" + }, + { + "point_pt": [ + 130, + 390 + ], + "expected_rgb": [ + 122, + 59, + 188 + ], + "region": "blue over red over gray" + } + ], + "clip_circle_pt": [ + 390, + 395, + 64 + ], + "axial_shading_band_pt": [ + 40, + 205, + 485, + 62 + ], + "cmyk_swatches": [ + [ + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1 + ] + ] + }, + "limits": [ + "CMYK output depends on renderer color conversion; no approved ICC reference is supplied.", + "Pixel differences are evidence, not a calibrated quality threshold." + ] + }, + { + "file": "page-geometry.pdf", + "sha256": "e5c0e4cb6d73c322235c9d7c89cf1e2e925ac7d3c22c7672b595a193fa133a0f", + "page_count": 4, + "expected": { + "pages": [ + { + "media_box_pt": [ + 0, + 0, + 420, + 320 + ], + "crop_box_pt": [ + 30, + 40, + 390, + 300 + ], + "rotation": 0, + "display_size_pt": [ + 360, + 260 + ], + "markers_pdf_coordinates": [ + { + "x": 50, + "y": 60, + "rgb": [ + 255, + 0, + 0 + ] + }, + { + "x": 370, + "y": 60, + "rgb": [ + 0, + 170, + 0 + ] + }, + { + "x": 370, + "y": 280, + "rgb": [ + 0, + 0, + 255 + ] + }, + { + "x": 50, + "y": 280, + "rgb": [ + 255, + 170, + 0 + ] + } + ] + }, + { + "media_box_pt": [ + 0, + 0, + 560, + 360 + ], + "crop_box_pt": [ + 20, + 30, + 520, + 330 + ], + "rotation": 90, + "display_size_pt": [ + 300, + 500 + ], + "markers_pdf_coordinates": [ + { + "x": 40, + "y": 50, + "rgb": [ + 255, + 0, + 0 + ] + }, + { + "x": 500, + "y": 50, + "rgb": [ + 0, + 170, + 0 + ] + }, + { + "x": 500, + "y": 310, + "rgb": [ + 0, + 0, + 255 + ] + }, + { + "x": 40, + "y": 310, + "rgb": [ + 255, + 170, + 0 + ] + } + ] + }, + { + "media_box_pt": [ + 0, + 0, + 300, + 600 + ], + "crop_box_pt": [ + 15, + 50, + 285, + 550 + ], + "rotation": 270, + "display_size_pt": [ + 500, + 270 + ], + "markers_pdf_coordinates": [ + { + "x": 35, + "y": 70, + "rgb": [ + 255, + 0, + 0 + ] + }, + { + "x": 265, + "y": 70, + "rgb": [ + 0, + 170, + 0 + ] + }, + { + "x": 265, + "y": 530, + "rgb": [ + 0, + 0, + 255 + ] + }, + { + "x": 35, + "y": 530, + "rgb": [ + 255, + 170, + 0 + ] + } + ] + }, + { + "media_box_pt": [ + 0, + 0, + 480, + 480 + ], + "crop_box_pt": [ + 60, + 80, + 420, + 400 + ], + "rotation": 180, + "display_size_pt": [ + 360, + 320 + ], + "markers_pdf_coordinates": [ + { + "x": 80, + "y": 100, + "rgb": [ + 255, + 0, + 0 + ] + }, + { + "x": 400, + "y": 100, + "rgb": [ + 0, + 170, + 0 + ] + }, + { + "x": 400, + "y": 380, + "rgb": [ + 0, + 0, + 255 + ] + }, + { + "x": 80, + "y": 380, + "rgb": [ + 255, + 170, + 0 + ] + } + ] + } + ] + }, + "limits": [ + "Renderer comparisons must use CropBox and honor document Rotate." + ] + }, + { + "file": "unembedded-font.pdf", + "sha256": "37532f1b3a403c5527fe39807e2bb737b55472d8edf32cc1ca47668c7623e095", + "page_count": 1, + "expected": { + "page_sizes_pt": [ + [ + 400, + 300 + ] + ], + "base_fonts": [ + "Helvetica", + "Times-Roman", + "Courier" + ], + "font_embedded": false + }, + "limits": [ + "This is a controlled Standard-14 substitution baseline, not arbitrary missing Japanese font coverage.", + "Cross-OS fallback is not established by this Linux-only comparison." + ] + }, + { + "file": "icc-linear-rgb.pdf", + "sha256": "4a6fd71d761bb639d3587f39e94446b8b92c6448177bbcc161a622b94aac9a06", + "page_count": 1, + "expected": { + "page_sizes_pt": [ + [ + 600, + 350 + ] + ], + "icc_probes": [ + { + "kind": "icc-vector", + "point_pt": [ + 68, + 248 + ], + "components": [ + 0.0625, + 0.0625, + 0.0625 + ], + "expected_rgb": [ + 71, + 71, + 71 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 68, + 164 + ], + "components": [ + 0.0625, + 0.0625, + 0.0625 + ], + "expected_rgb": [ + 16, + 16, + 16 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 74, + 68 + ], + "components": [ + 16, + 16, + 16 + ], + "expected_rgb": [ + 71, + 71, + 71 + ] + }, + { + "kind": "icc-vector", + "point_pt": [ + 156, + 248 + ], + "components": [ + 0.25, + 0.25, + 0.25 + ], + "expected_rgb": [ + 137, + 137, + 137 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 156, + 164 + ], + "components": [ + 0.25, + 0.25, + 0.25 + ], + "expected_rgb": [ + 64, + 64, + 64 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 162, + 68 + ], + "components": [ + 64, + 64, + 64 + ], + "expected_rgb": [ + 137, + 137, + 137 + ] + }, + { + "kind": "icc-vector", + "point_pt": [ + 244, + 248 + ], + "components": [ + 0.5, + 0.5, + 0.5 + ], + "expected_rgb": [ + 188, + 188, + 188 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 244, + 164 + ], + "components": [ + 0.5, + 0.5, + 0.5 + ], + "expected_rgb": [ + 128, + 128, + 128 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 250, + 68 + ], + "components": [ + 128, + 128, + 128 + ], + "expected_rgb": [ + 188, + 188, + 188 + ] + }, + { + "kind": "icc-vector", + "point_pt": [ + 332, + 248 + ], + "components": [ + 0.75, + 0.75, + 0.75 + ], + "expected_rgb": [ + 225, + 225, + 225 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 332, + 164 + ], + "components": [ + 0.75, + 0.75, + 0.75 + ], + "expected_rgb": [ + 191, + 191, + 191 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 338, + 68 + ], + "components": [ + 191, + 191, + 191 + ], + "expected_rgb": [ + 224, + 224, + 224 + ] + }, + { + "kind": "icc-vector", + "point_pt": [ + 420, + 248 + ], + "components": [ + 0.5, + 0.125, + 0.0625 + ], + "expected_rgb": [ + 188, + 99, + 71 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 420, + 164 + ], + "components": [ + 0.5, + 0.125, + 0.0625 + ], + "expected_rgb": [ + 128, + 32, + 16 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 426, + 68 + ], + "components": [ + 128, + 32, + 16 + ], + "expected_rgb": [ + 188, + 99, + 71 + ] + }, + { + "kind": "icc-vector", + "point_pt": [ + 508, + 248 + ], + "components": [ + 0.0625, + 0.25, + 0.5 + ], + "expected_rgb": [ + 71, + 137, + 188 + ] + }, + { + "kind": "device-control", + "point_pt": [ + 508, + 164 + ], + "components": [ + 0.0625, + 0.25, + 0.5 + ], + "expected_rgb": [ + 16, + 64, + 128 + ] + }, + { + "kind": "icc-image", + "point_pt": [ + 514, + 68 + ], + "components": [ + 16, + 64, + 128 + ], + "expected_rgb": [ + 71, + 137, + 188 + ] + } + ], + "probe_tolerance_rgb": 3, + "profile_effect": "ICCBased 0.5 linear gray becomes approximately 188, distinct from DeviceRGB approximately 128." + }, + "limits": [ + "One RGB matrix/TRC profile, not arbitrary ICC LUT/CMYK/printing conformance.", + "Analytic sRGB color expectation; independent renderer comparison still requires visual review." + ] + } + ], + "color_profile": { + "file": "linear-srgb.icc", + "sha256": "343c89fd0eb35fff20bcd52b63d3b3b941702e443c46f0761ab367fcf67c6287", + "bytes": 568, + "source": "Self-created with LittleCMS cmsCreateRGBProfile using the stated numeric white point, primaries and gamma; no third-party ICC profile is copied.", + "lcms_encoded_version": 2190, + "lcms_license": "MIT; LCMS2-LICENSE.txt", + "upstream": "https://github.com/mm2/Little-CMS", + "profile_copyright_tag": "No copyright, use freely", + "white_xy": [ + 0.3127, + 0.329 + ], + "primaries_xy": [ + [ + 0.64, + 0.33 + ], + [ + 0.3, + 0.6 + ], + [ + 0.15, + 0.06 + ] + ], + "gamma": 1, + "timestamp_normalization": "Header creation time is fixed to 2026-09-19; optional profile ID is zero." + } +} diff --git a/tests/fixtures/pdf/extended/page-geometry.pdf b/tests/fixtures/pdf/extended/page-geometry.pdf new file mode 100644 index 0000000..78eccef --- /dev/null +++ b/tests/fixtures/pdf/extended/page-geometry.pdf @@ -0,0 +1,161 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< +/Producer (pypdf) +/Title (DocView page geometry fixture) +/Author (DocView tests) +>> +endobj +2 0 obj +<< +/Type /Pages +/Count 4 +/Kids [ 4 0 R 8 0 R 10 0 R 12 0 R ] +>> +endobj +3 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +4 0 obj +<< +/Contents 5 0 R +/MediaBox [ 0 0 420 320 ] +/Resources << +/Font 6 0 R +/ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> +/Rotate 0 +/Trans << +>> +/Type /Page +/CropBox [ 30 40 390 300 ] +/Parent 2 0 R +>> +endobj +5 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] +/Length 535 +>> +stream +Gatn"4\s!M%#4NU$BK^68Y"*G3(,!sqM\8OJ#t7@Ci3M=`<]p)8FfgQ_+Km?A>.,0d&R:V9X[CC3Z#<6/p;L/rp$WjYDEU3-Z^Hp5`5i*U&Bcba#mnrQWLu5!`?u$>MMeXZM2#dO@OWkRMD9)r8S2j_@-.SpA4?u!2^a7bP37]8'A8n;G_B#qge=&C?,]uBKqVa@/a(/I>ERVJC+IS4*TX^Xbdm:da@5MHaB%&L6>04X+6g8T@%D+O^*6=T85'hQ]K4jpmsou#&b[i +endstream +endobj +6 0 obj +<< +/F1 7 0 R +>> +endobj +7 0 obj +<< +/BaseFont /Helvetica +/Encoding /WinAnsiEncoding +/Name /F1 +/Subtype /Type1 +/Type /Font +>> +endobj +8 0 obj +<< +/Contents 9 0 R +/MediaBox [ 0 0 560 360 ] +/Resources << +/Font 6 0 R +/ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> +/Rotate 90 +/Trans << +>> +/Type /Page +/CropBox [ 20 30 520 330 ] +/Parent 2 0 R +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] +/Length 540 +>> +stream +Gatn"9i'Ou&;KZL(%8Cr.*VHrf/XU7\b(DcDH.3("@Pn+X(;n@r6@uFa/piadR:@\Sp+cfgG+?AAS[&-3:Ck\OhhP+4ZKeLr?kkD%AQY3F1`t#iM"q-=POJ#I"r=.-R.'lS5/4c:"j/;=iejQk[%9+n;P^?3;Gu5\X?SW5nfDLA$TE;34X^(N@6]!4#B.01%EiZaLbHQ2Q>RWA4b/QVt&1F:!'AUC":N%LW6jYNBZ+@(28=UUHjrE?:f7tL)7E]TcnUpT+/jU'k*40B/pC*,dtgPET("BC"[j.nsoMKmDJ$toTjfUO@SdrMS=^:>7Hu\-Y;fT@fl>?*CM'k>?C+YX[N%>J[uTJqa`$XV+kl)@.h_6,Uud3L/2)2r7W9a3$1G#]>[>5*$oe(Dm;YNan'QVTJ"e+dX?]h&rOp(6XMVqbs7:X'7Z;9'e'$Qlf2fl;quZ"8i5G~> +endstream +endobj +10 0 obj +<< +/Contents 11 0 R +/MediaBox [ 0 0 300 600 ] +/Resources << +/Font 6 0 R +/ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> +/Rotate 270 +/Trans << +>> +/Type /Page +/CropBox [ 15 50 285 550 ] +/Parent 2 0 R +>> +endobj +11 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] +/Length 546 +>> +stream +Gatn"9hu``%#46B.srlR%>b`GfcqW^V$BEOMfC:gGBr7]H:7TC`eltGFtZ2HkMN%GlqXH)TMi-^.*Y8/\+oDmI'*iD0@P;J?^9gpfY/8r;tE&Y]4^4(LBTC9fq%#2MW?f@C~> +endstream +endobj +12 0 obj +<< +/Contents 13 0 R +/MediaBox [ 0 0 480 480 ] +/Resources << +/Font 6 0 R +/ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> +/Rotate 180 +/Trans << +>> +/Type /Page +/CropBox [ 60 80 420 400 ] +/Parent 2 0 R +>> +endobj +13 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] +/Length 540 +>> +stream +Gatn"9lJN8&;KZL(%2a7.eVFh4?6;=HJq\cg^o7(VH&36U1GkOoC7c(euFhAYgIbZPBUW1G[uD_&*^ZA^u^N5%g92*+$dR7Qp9SFmKbtfajO;YiN_-ORcj<-XEZ^_6@&4bBFr'6)J@6/UFE\(;>49?"=CX0iJ9qnWCnd?TOS0DK.Upa%']o8[1ZoI04sIccjrgdWiIJiF`0ZV0bmPc:KC*+Fm3XHM=F0,+dPmfqb_g9&:.7ddk3TPAKs%g'M+RKWZ+b(P`Ql[RYJ-q`1W>lcrs\,%;CC8W$]hVd2Wrna0-X,-V1KOUpr`i'9Z9:>q!:Q&/(hl#1I.jRS\6nP?o=[(KbQWlYlua$Yg]W\=k&T"Z/'+-[!e8-d5NN2!ZXS#-oU@)2mA"0QOf-2Y]QK[p57cY7'O:&u1][eMP&O,N]GRDVeZ+_;bU+qr?LPNH4V+o/Fm+P4R!87U+N[[rA,phB2GU0D@-p4IYm:M +endstream +endobj +xref +0 14 +0000000000 65535 f +0000000015 00000 n +0000000117 00000 n +0000000196 00000 n +0000000245 00000 n +0000000461 00000 n +0000001087 00000 n +0000001118 00000 n +0000001225 00000 n +0000001442 00000 n +0000002073 00000 n +0000002293 00000 n +0000002931 00000 n +0000003151 00000 n +trailer +<< +/Size 14 +/Root 3 0 R +/Info 1 0 R +>> +startxref +3783 +%%EOF diff --git a/tests/fixtures/pdf/extended/unembedded-font.pdf b/tests/fixtures/pdf/extended/unembedded-font.pdf new file mode 100644 index 0000000..d608e03 --- /dev/null +++ b/tests/fixtures/pdf/extended/unembedded-font.pdf @@ -0,0 +1,80 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +5 0 obj +<< +/Contents 9 0 R /MediaBox [ 0 0 400 300 ] /Parent 8 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/PageMode /UseNone /Pages 8 0 R /Type /Catalog +>> +endobj +7 0 obj +<< +/Author (DocView tests) /CreationDate (D:20000101000000+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (DocView synthetic rendering fixture - unembedded-font.pdf) /Trapped /False +>> +endobj +8 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +9 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 419 +>> +stream +Gatmt>u/4Aaendstream +endobj +xref +0 10 +0000000000 65535 f +0000000061 00000 n +0000000112 00000 n +0000000219 00000 n +0000000328 00000 n +0000000433 00000 n +0000000626 00000 n +0000000694 00000 n +0000001008 00000 n +0000001067 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 7 0 R +/Root 6 0 R +/Size 10 +>> +startxref +1576 +%%EOF diff --git a/tests/fixtures/pdf/extended_generator.py b/tests/fixtures/pdf/extended_generator.py new file mode 100644 index 0000000..3e69e5f --- /dev/null +++ b/tests/fixtures/pdf/extended_generator.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Create deterministic, original PDF fixtures with explicit drawing geometry. + +Dependencies: ReportLab, pypdf, Pillow. The Japanese font is subset-embedded; +its adjacent SIL OFL license is copied alongside the generated documents. +""" +from pathlib import Path +from io import BytesIO +import hashlib +import json +import os +import shutil + +from PIL import Image, ImageDraw +from pypdf import PdfReader, PdfWriter +from pypdf.generic import NameObject, NumberObject, RectangleObject +from reportlab.lib import colors +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +HERE = Path(__file__).resolve().parent +OUT = HERE / "extended" +FONT = Path(os.environ.get("DOCVIEW_JAPANESE_FONT", "/usr/local/share/fonts/BIZ_UDPGothic/BIZUDPGothic-Regular.ttf")) +LICENSE = FONT.parent / "OFL.txt" +OUT.mkdir(parents=True, exist_ok=True) +if not FONT.is_file() or not LICENSE.is_file(): + raise SystemExit("Set DOCVIEW_JAPANESE_FONT to BIZUDPGothic-Regular.ttf with its adjacent OFL.txt.") +license_text = LICENSE.read_text(encoding="utf-8") +if "SIL OPEN FONT LICENSE Version 1.1" not in license_text: + raise SystemExit("The expected SIL Open Font License was not found.") +pdfmetrics.registerFont(TTFont("FixtureJapanese", str(FONT))) +shutil.copyfile(LICENSE, OUT / "FONT-OFL.txt") +manifest = { + "origin": "Original deterministic vector, text and raster drawings authored for DocView tests", + "reference_status": "Synthetic source specifies content and coordinates; no human-approved golden image exists", + "font": { + "family": "BIZ UDPGothic Regular", "source_file": FONT.name, + "sha256": hashlib.sha256(FONT.read_bytes()).hexdigest(), + "license": "SIL Open Font License 1.1", "license_copy": "FONT-OFL.txt", + "upstream": "https://github.com/googlefonts/morisawa-biz-ud-gothic", + "upstream_license": "https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/OFL.txt", + "embedding": "Subset embedded by ReportLab; OFL permits embedding and redistribution. No reserved font name is changed.", + }, + "documents": [], +} + + +def new_canvas(name, size): + result = canvas.Canvas(str(OUT / name), pagesize=size, invariant=1, pageCompression=1) + result.setTitle("DocView synthetic rendering fixture - " + name) + result.setAuthor("DocView tests") + return result + + +def label(c, x, y, text, size=11): + c.setFillColor(colors.HexColor("#263746")) + c.setFont("Helvetica", size) + c.drawString(x, y, text) + + +def japanese(c, x, y, text, size=21): + c.setFillColor(colors.HexColor("#132c35")) + c.setFont("FixtureJapanese", size) + c.drawString(x, y, text) + + +def record(name, pages, expected, limits): + manifest["documents"].append({"file": name, "sha256": hashlib.sha256((OUT / name).read_bytes()).hexdigest(), + "page_count": pages, "expected": expected, "limits": limits}) + + +c = new_canvas("japanese-layout.pdf", (540, 720)) +label(c, 38, 675, "EMBEDDED JAPANESE / HORIZONTAL", 17) +japanese(c, 38, 614, "日本語の文字配置と埋込みフォント", 23) +japanese(c, 38, 557, "漢字・ひらがな・カタカナ・1234", 21) +japanese(c, 38, 500, "読み取り専用の文書表示を検証します。", 20) +label(c, 38, 454, "ASCII text uses the same embedded Japanese font:") +japanese(c, 38, 420, "DocView 0123456789 ABC xyz", 20) +japanese(c, 55, 330, "漢字", 29) +japanese(c, 55, 364, "かんじ", 11) +label(c, 155, 341, "Ruby has explicit, independent PDF coordinates.") +c.setStrokeColor(colors.HexColor("#147d79")); c.setLineWidth(1) +c.rect(38, 220, 464, 76, fill=0) +japanese(c, 53, 262, "天地左右", 23) +label(c, 53, 236, "Four ideographs above must remain distinct and correctly placed.") +label(c, 38, 55, "540 x 720 pt / exact source coordinates / embedded TrueType subset") +c.showPage() +label(c, 38, 675, "EMBEDDED JAPANESE / EXPLICIT VERTICAL POSITIONS", 15) +columns = [{"x": 420, "top": 590, "step": 32, "text": "日本語縦書き検証"}, + {"x": 374, "top": 590, "step": 32, "text": "天地左右回転配置"}] +for col in columns: + for index, glyph in enumerate(col["text"]): + japanese(c, col["x"], col["top"] - index * col["step"], glyph, 24) + c.setStrokeColor(colors.HexColor("#d7e8e6")); c.line(col["x"] - 5, 315, col["x"] - 5, 620) +for x, y, angle in [(80, 525, 0), (230, 485, 90), (200, 310, 180), (65, 350, 270)]: + c.saveState(); c.translate(x, y); c.rotate(angle); japanese(c, 0, 0, "天地左右", 20); c.restoreState() +label(c, 38, 228, "Right column is read top to bottom; the next column is to its left.") +label(c, 38, 205, "Four lower-left samples use text matrices at 0 / 90 / 180 / 270 degrees.") +label(c, 38, 73, "This tests explicit glyph placement, not vertical CID metrics or shaping.") +label(c, 38, 55, "540 x 720 pt / each glyph has a documented baseline") +c.save() +record("japanese-layout.pdf", 2, + {"page_sizes_pt": [[540, 720], [540, 720]], "horizontal_text": "日本語の文字配置と埋込みフォント", + "vertical_columns": columns, "text_matrix_rotations_degrees": [0, 90, 180, 270], "font_embedded": True}, + ["Manual vertical glyph placement does not test native vertical CID metrics or OpenType shaping.", "Ruby placement is explicit source geometry, not automatic ruby layout."]) + +c = new_canvas("graphics-compositing.pdf", (600, 760)) +label(c, 35, 721, "IMAGES / ALPHA / CLIPPING / SHADING / CMYK", 17) +image = Image.new("RGB", (96, 96), "white"); drawing = ImageDraw.Draw(image) +for y in range(0, 96, 12): + for x in range(0, 96, 12): drawing.rectangle((x, y, x + 11, y + 11), fill="#147d79" if (x + y) // 12 % 2 == 0 else "#e9b44c") +drawing.line((0, 0, 95, 95), fill="black", width=4) +c.drawImage(ImageReader(image), 40, 524, width=144, height=144) +label(c, 40, 505, "96 x 96 RGB image, enlarged 1.5x") +alpha = Image.new("RGBA", (96, 96), (0, 0, 0, 0)); d = ImageDraw.Draw(alpha) +d.ellipse((4, 4, 92, 92), fill=(224, 63, 90, 150)); d.rectangle((15, 37, 81, 58), fill=(30, 70, 220, 190)) +c.setFillColor(colors.HexColor("#dfe7ed")); c.rect(310, 524, 144, 144, stroke=0, fill=1) +c.drawImage(ImageReader(alpha), 310, 524, width=144, height=144, mask="auto") +label(c, 310, 505, "RGBA image with soft mask") +c.setFillColor(colors.HexColor("#e8edf1")); c.rect(40, 330, 220, 135, fill=1, stroke=0) +c.saveState(); c.setFillColor(colors.red); c.setFillAlpha(0.5); c.rect(55, 345, 95, 95, fill=1, stroke=0) +c.setFillColor(colors.blue); c.setFillAlpha(0.5); c.rect(112, 365, 105, 75, fill=1, stroke=0); c.restoreState() +label(c, 40, 308, "Two vector rectangles at 50% alpha") +c.saveState(); path = c.beginPath(); path.circle(390, 395, 64); c.clipPath(path, stroke=0, fill=0) +c.drawImage(ImageReader(image), 300, 310, width=180, height=180); c.restoreState() +c.setStrokeColor(colors.HexColor("#263746")); c.circle(390, 395, 64, fill=0, stroke=1) +label(c, 310, 308, "Raster image clipped to a circle") +c.saveState(); path = c.beginPath(); path.rect(40, 205, 485, 62); c.clipPath(path, stroke=0, fill=0) +c.linearGradient(40, 205, 525, 205, [colors.HexColor("#147d79"), colors.white, colors.HexColor("#d95050")], [0, 0.5, 1]); c.restoreState() +label(c, 40, 185, "Axial PDF shading, clipped to a 485 x 62 pt band") +swatches = [(1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1)] +for index, value in enumerate(swatches): + c.setFillColorCMYK(*value); c.rect(40 + index * 124, 76, 105, 67, stroke=0, fill=1) + label(c, 40 + index * 124, 56, "CMYK " + "".join(map(str, value)), 10) +c.save() +record("graphics-compositing.pdf", 1, {"page_sizes_pt": [[600, 760]], "rgb_image_pixels": [96, 96], + "alpha": 0.5, "alpha_probes": [ + {"point_pt": [80, 370], "expected_rgb": [244, 119, 121], "region": "red over gray"}, + {"point_pt": [180, 390], "expected_rgb": [116, 119, 248], "region": "blue over gray"}, + {"point_pt": [130, 390], "expected_rgb": [122, 59, 188], "region": "blue over red over gray"}], + "clip_circle_pt": [390, 395, 64], "axial_shading_band_pt": [40, 205, 485, 62], "cmyk_swatches": swatches}, + ["CMYK output depends on renderer color conversion; no approved ICC reference is supplied.", "Pixel differences are evidence, not a calibrated quality threshold."]) + +geometries = [([420, 320], [30, 40, 390, 300], 0), ([560, 360], [20, 30, 520, 330], 90), + ([300, 600], [15, 50, 285, 550], 270), ([480, 480], [60, 80, 420, 400], 180)] +geometry_buffer = BytesIO(); c = canvas.Canvas(geometry_buffer, invariant=1, pageCompression=1) +expectations = [] +for number, (size, crop, rotation) in enumerate(geometries, 1): + c.setPageSize(size) + left, bottom, right, top = crop; width, height = right - left, top - bottom + c.setFillColor(colors.HexColor("#ffd6d6")); c.rect(0, 0, *size, fill=1, stroke=0) + c.setFillColor(colors.white); c.rect(left, bottom, width, height, fill=1, stroke=0) + c.setStrokeColor(colors.black); c.setLineWidth(1); c.rect(left + 1, bottom + 1, width - 2, height - 2, fill=0) + dots = [(left + 20, bottom + 20, [255, 0, 0]), (right - 20, bottom + 20, [0, 170, 0]), + (right - 20, top - 20, [0, 0, 255]), (left + 20, top - 20, [255, 170, 0])] + for x, y, rgb in dots: + c.setFillColor(colors.Color(*(v / 255 for v in rgb))); c.circle(x, y, 8, fill=1, stroke=0) + label(c, left + 35, bottom + height * 0.55, f"PAGE {number} / Rotate {rotation}", 15) + label(c, left + 35, bottom + height * 0.55 - 25, f"CropBox {crop}", 10) + label(c, left + 35, bottom + height * 0.55 - 45, "Pink margin must be excluded.", 10) + expectations.append({"media_box_pt": [0, 0, *size], "crop_box_pt": crop, "rotation": rotation, + "display_size_pt": [height, width] if rotation % 180 else [width, height], + "markers_pdf_coordinates": [{"x": x, "y": y, "rgb": rgb} for x, y, rgb in dots]}) + c.showPage() +c.save(); source = PdfReader(BytesIO(geometry_buffer.getvalue())); writer = PdfWriter() +for page, (_, crop, rotation) in zip(source.pages, geometries): + page.cropbox = RectangleObject(crop); page[NameObject("/Rotate")] = NumberObject(rotation); writer.add_page(page) +writer.add_metadata({"/Title": "DocView page geometry fixture", "/Author": "DocView tests"}) +with (OUT / "page-geometry.pdf").open("wb") as output: writer.write(output) +record("page-geometry.pdf", 4, {"pages": expectations}, ["Renderer comparisons must use CropBox and honor document Rotate."]) + +c = new_canvas("unembedded-font.pdf", (400, 300)) +label(c, 30, 252, "UNEMBEDDED STANDARD PDF FONT", 15) +label(c, 30, 205, "Helvetica: ABCDEFGHIJKLMNOPQRSTUVWXYZ", 12) +label(c, 30, 176, "abcdefghijklmnopqrstuvwxyz 0123456789", 12) +c.setFont("Times-Roman", 15); c.drawString(30, 137, "Times-Roman: replacement font baseline") +c.setFont("Courier", 14); c.drawString(30, 100, "Courier: 0123456789 fixed pitch") +label(c, 30, 46, "Standard-14 fonts are intentionally not embedded.", 11) +c.save() +record("unembedded-font.pdf", 1, {"page_sizes_pt": [[400, 300]], "base_fonts": ["Helvetica", "Times-Roman", "Courier"], "font_embedded": False}, + ["This is a controlled Standard-14 substitution baseline, not arbitrary missing Japanese font coverage.", "Cross-OS fallback is not established by this Linux-only comparison."]) + +from icc_generator import generate as generate_icc +icc_record, manifest["color_profile"] = generate_icc(OUT) +manifest["documents"].append(icc_record) +(OUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +print(f"Generated {len(manifest['documents'])} PDF fixtures under {OUT}") diff --git a/tests/fixtures/pdf/fonts/README.md b/tests/fixtures/pdf/fonts/README.md new file mode 100644 index 0000000..879e5d5 --- /dev/null +++ b/tests/fixtures/pdf/fonts/README.md @@ -0,0 +1,15 @@ +# Unembedded Japanese CID fixture + +`unembedded-japanese.pdf` is authored locally by `../generate_fonts.py`, using +ReportLab's predefined Japanese CID fonts. The two pages select +`UniJIS-UCS2-H` and `UniJIS-UCS2-V` for HeiseiMin-W3 and HeiseiKakuGo-W5. +No font program is embedded. The second page uses the PDF vertical writing +mode, including native punctuation positioning, rather than individually +positioning horizontal glyphs. The text is synthetic and contains no third-party +document content. Font names identify requests; installed substitutes can differ. + +The manifest records the input hash, authored text and page dimensions. +Generation requires ReportLab and pypdf. The generator checks both encoding +names and absence of FontFile/FontFile2/FontFile3 in the CID font descriptors. +Successful raster output alone does not establish character accuracy or full +CMap coverage; compare with Poppler and inspect both pages. diff --git a/tests/fixtures/pdf/fonts/manifest.json b/tests/fixtures/pdf/fonts/manifest.json new file mode 100644 index 0000000..24ed816 --- /dev/null +++ b/tests/fixtures/pdf/fonts/manifest.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "generator": "generate_fonts.py (ReportLab UnicodeCIDFont, invariant PDF)", + "documents": [ + { + "file": "unembedded-japanese.pdf", + "sha256": "9e74ac0c7d81a8c1d373cb28943a1943f8f68c0abd325e8e1012956bb71db057", + "pages": 2, + "sizePt": [ + 600, + 760 + ], + "fontProgramsEmbedded": false, + "encodings": [ + "/UniJIS-UCS2-H", + "/UniJIS-UCS2-V" + ], + "faces": [ + "HeiseiMin-W3", + "HeiseiKakuGo-W5" + ], + "expectedText": [ + "日本語の文書を表示します。", + "漢字とひらがな、カタカナ。", + "「縦書き」の句読点を確認。" + ], + "writingModes": [ + "horizontal", + "vertical" + ] + } + ] +} diff --git a/tests/fixtures/pdf/fonts/unembedded-japanese.pdf b/tests/fixtures/pdf/fonts/unembedded-japanese.pdf new file mode 100644 index 0000000..ab0602f --- /dev/null +++ b/tests/fixtures/pdf/fonts/unembedded-japanese.pdf @@ -0,0 +1,233 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 7 0 R /F6 8 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/BaseFont /HeiseiMin-W3 /DescendantFonts [ << +/BaseFont /HeiseiMin-W3 /CIDSystemInfo << +/Ordering (Japan1) /Registry (Adobe) /Supplement 2 +>> /DW 1000 /FontDescriptor << +/Ascent 723 /CapHeight 709 /Descent -241 /Flags 6 /FontBBox [ -123 -257 1001 910 ] /FontName /HeiseiMin-W3 + /ItalicAngle 0 /StemV 69 /Type /FontDescriptor /XHeight 450 +>> /Subtype /CIDFontType0 /Type /Font + /W [ 1 [ 250 333 408 500 ] 5 [ 500 833 778 180 333 ] 10 [ 333 500 564 250 333 250 278 500 ] 18 26 500 27 + 28 278 29 31 564 32 [ 444 921 722 667 ] 36 [ 667 722 611 556 722 ] 41 + [ 722 333 389 722 611 889 722 ] 48 [ 722 556 722 667 556 611 722 ] 55 [ 722 944 722 ] 58 [ 722 611 333 500 333 469 500 333 444 500 + 444 500 444 333 500 ] 73 [ 500 278 ] 75 + [ 278 500 278 778 500 ] 80 82 500 83 [ 333 389 278 500 ] 87 [ 500 722 500 ] 90 [ 500 444 480 200 480 333 ] + 97 [ 278 ] 99 [ 200 ] 101 [ 333 500 ] 103 [ 500 167 ] 107 [ 500 ] + 109 [ 500 333 ] 111 [ 333 556 ] 113 [ 556 500 ] 117 [ 250 ] 119 [ 350 333 444 ] + 123 [ 500 ] 126 [ 444 333 ] 128 137 333 138 [ 1000 889 276 611 722 889 310 667 278 ] 147 + [ 278 500 722 500 564 760 564 760 ] 157 158 300 159 [ 500 300 750 ] 162 163 750 164 + 169 722 170 [ 667 611 ] 172 174 611 175 178 333 + 179 185 722 187 191 722 192 [ 556 444 ] 194 203 + 444 204 207 278 208 214 500 216 222 500 + 223 [ 556 722 611 500 389 980 444 ] 231 [ 500 ] 323 [ 500 ] 325 [ 500 ] 327 389 + 500 ] +>> ] /Encoding /UniJIS-UCS2-H /Name /F3 /Subtype /Type0 /Type /Font +>> +endobj +5 0 obj +<< +/BaseFont /HeiseiKakuGo-W5 /DescendantFonts [ << +/BaseFont /HeiseiKakuGo-W5 /CIDSystemInfo << +/Ordering (Japan1) /Registry (Adobe) /Supplement 2 +>> /DW 1000 /FontDescriptor << +/Ascent 752 /CapHeight 737 /Descent -221 /Flags 4 /FontBBox [ -92 -250 1010 922 ] /FontName /HeiseKakuGo-W5 + /ItalicAngle 0 /StemH 0 /StemV 114 /Type /FontDescriptor /XHeight 553 +>> /Subtype /CIDFontType0 /Type /Font + /W [ 1 [ 277 305 500 668 668 906 727 305 445 445 + 508 668 305 379 305 539 ] 17 26 668 27 [ 305 305 668 668 668 566 871 727 637 652 + 699 574 555 676 687 242 492 664 582 789 + 707 734 582 734 605 605 641 668 727 945 + 609 609 574 445 668 445 668 668 590 555 + 609 547 602 574 391 609 582 234 277 539 + 234 895 582 605 602 602 387 508 441 582 + 562 781 531 570 555 449 246 449 668 ] 231 632 500 ] +>> ] /Encoding /UniJIS-UCS2-H /Name /F4 /Subtype /Type0 /Type /Font +>> +endobj +6 0 obj +<< +/Contents 13 0 R /MediaBox [ 0 0 600 760 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/BaseFont /HeiseiMin-W3 /DescendantFonts [ << +/BaseFont /HeiseiMin-W3 /CIDSystemInfo << +/Ordering (Japan1) /Registry (Adobe) /Supplement 2 +>> /DW 1000 /FontDescriptor << +/Ascent 723 /CapHeight 709 /Descent -241 /Flags 6 /FontBBox [ -123 -257 1001 910 ] /FontName /HeiseiMin-W3 + /ItalicAngle 0 /StemV 69 /Type /FontDescriptor /XHeight 450 +>> /Subtype /CIDFontType0 /Type /Font + /W [ 1 [ 250 333 408 500 ] 5 [ 500 833 778 180 333 ] 10 [ 333 500 564 250 333 250 278 500 ] 18 26 500 27 + 28 278 29 31 564 32 [ 444 921 722 667 ] 36 [ 667 722 611 556 722 ] 41 + [ 722 333 389 722 611 889 722 ] 48 [ 722 556 722 667 556 611 722 ] 55 [ 722 944 722 ] 58 [ 722 611 333 500 333 469 500 333 444 500 + 444 500 444 333 500 ] 73 [ 500 278 ] 75 + [ 278 500 278 778 500 ] 80 82 500 83 [ 333 389 278 500 ] 87 [ 500 722 500 ] 90 [ 500 444 480 200 480 333 ] + 97 [ 278 ] 99 [ 200 ] 101 [ 333 500 ] 103 [ 500 167 ] 107 [ 500 ] + 109 [ 500 333 ] 111 [ 333 556 ] 113 [ 556 500 ] 117 [ 250 ] 119 [ 350 333 444 ] + 123 [ 500 ] 126 [ 444 333 ] 128 137 333 138 [ 1000 889 276 611 722 889 310 667 278 ] 147 + [ 278 500 722 500 564 760 564 760 ] 157 158 300 159 [ 500 300 750 ] 162 163 750 164 + 169 722 170 [ 667 611 ] 172 174 611 175 178 333 + 179 185 722 187 191 722 192 [ 556 444 ] 194 203 + 444 204 207 278 208 214 500 216 222 500 + 223 [ 556 722 611 500 389 980 444 ] 231 [ 500 ] 323 [ 500 ] 325 [ 500 ] 327 389 + 500 ] +>> ] /Encoding /UniJIS-UCS2-V /Name /F5 /Subtype /Type0 /Type /Font +>> +endobj +8 0 obj +<< +/BaseFont /HeiseiKakuGo-W5 /DescendantFonts [ << +/BaseFont /HeiseiKakuGo-W5 /CIDSystemInfo << +/Ordering (Japan1) /Registry (Adobe) /Supplement 2 +>> /DW 1000 /FontDescriptor << +/Ascent 752 /CapHeight 737 /Descent -221 /Flags 4 /FontBBox [ -92 -250 1010 922 ] /FontName /HeiseKakuGo-W5 + /ItalicAngle 0 /StemH 0 /StemV 114 /Type /FontDescriptor /XHeight 553 +>> /Subtype /CIDFontType0 /Type /Font + /W [ 1 [ 277 305 500 668 668 906 727 305 445 445 + 508 668 305 379 305 539 ] 17 26 668 27 [ 305 305 668 668 668 566 871 727 637 652 + 699 574 555 676 687 242 492 664 582 789 + 707 734 582 734 605 605 641 668 727 945 + 609 609 574 445 668 445 668 668 590 555 + 609 547 602 574 391 609 582 234 277 539 + 234 895 582 605 602 602 387 508 441 582 + 562 781 531 570 555 449 246 449 668 ] 231 632 500 ] +>> ] /Encoding /UniJIS-UCS2-V /Name /F6 /Subtype /Type0 /Type /Font +>> +endobj +9 0 obj +<< +/Contents 14 0 R /MediaBox [ 0 0 600 760 ] /Parent 12 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +10 0 obj +<< +/PageMode /UseNone /Pages 12 0 R /Type /Catalog +>> +endobj +11 0 obj +<< +/Author (DocView synthetic test corpus) /CreationDate (D:20000101000000+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20000101000000+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (unspecified) /Title (DocView unembedded Japanese CID H/V fixture) /Trapped /False +>> +endobj +12 0 obj +<< +/Count 2 /Kids [ 6 0 R 9 0 R ] /Type /Pages +>> +endobj +13 0 obj +<< +/Length 1124 +>> +stream +1 0 0 1 0 0 cm BT /F1 12 Tf 14.4 TL ET +BT /F2 16 Tf 19.2 TL ET +BT 1 0 0 1 36 722 Tm (Unembedded Japanese CID fonts) Tj T* ET +BT /F1 11 Tf 13.2 TL ET +BT 1 0 0 1 36 700 Tm (UniJIS-UCS2-H: horizontal writing mode) Tj T* ET +BT /F1 10 Tf 12 TL ET +BT 1 0 0 1 36 648 Tm (HeiseiMin-W3) Tj T* ET +BT /F3 20 Tf 24 TL ET +BT 1 0 0 1 36 610 Tm /F3 20 Tf 24 TL (e\345g,\212\2360ne\207f\3700\222\210hy:0W0~0Y0\002) Tj T* ET +BT 1 0 0 1 36 568 Tm /F3 20 Tf 24 TL (o"[W0h0r0\2110L0j0\0010\2530\2770\2530\3120\002) Tj T* ET +BT 1 0 0 1 36 526 Tm /F3 20 Tf 24 TL (0\014~&f\3700M0\0150nS\345\212\255p\2710\222x\272\212\2150\002) Tj T* ET +BT /F1 10 Tf 12 TL ET +BT 1 0 0 1 36 388 Tm (HeiseiKakuGo-W5) Tj T* ET +BT /F4 20 Tf 24 TL ET +BT 1 0 0 1 36 350 Tm /F4 20 Tf 24 TL (e\345g,\212\2360ne\207f\3700\222\210hy:0W0~0Y0\002) Tj T* ET +BT 1 0 0 1 36 308 Tm /F4 20 Tf 24 TL (o"[W0h0r0\2110L0j0\0010\2530\2770\2530\3120\002) Tj T* ET +BT 1 0 0 1 36 266 Tm /F4 20 Tf 24 TL (0\014~&f\3700M0\0150nS\345\212\255p\2710\222x\272\212\2150\002) Tj T* ET +BT /F1 9 Tf 10.8 TL ET +BT 1 0 0 1 36 28 Tm (Synthetic fixture / page 1 / no font program embedded) Tj T* ET + +endstream +endobj +14 0 obj +<< +/Length 1224 +>> +stream +1 0 0 1 0 0 cm BT /F1 12 Tf 14.4 TL ET +BT /F2 16 Tf 19.2 TL ET +BT 1 0 0 1 36 722 Tm (Unembedded Japanese CID fonts) Tj T* ET +BT /F1 11 Tf 13.2 TL ET +BT 1 0 0 1 36 700 Tm (UniJIS-UCS2-V: native vertical writing mode) Tj T* ET +BT /F1 10 Tf 12 TL ET +BT 1 0 0 1 330 660 Tm (HeiseiMin-W3) Tj T* ET +BT /F5 20 Tf 24 TL ET +BT 1 0 0 1 520 620 Tm /F5 20 Tf 24 TL (e\345g,\212\2360ne\207f\3700\222\210hy:0W0~0Y0\002) Tj T* ET +BT /F5 20 Tf 24 TL ET +BT 1 0 0 1 458 620 Tm /F5 20 Tf 24 TL (o"[W0h0r0\2110L0j0\0010\2530\2770\2530\3120\002) Tj T* ET +BT /F5 20 Tf 24 TL ET +BT 1 0 0 1 396 620 Tm /F5 20 Tf 24 TL (0\014~&f\3700M0\0150nS\345\212\255p\2710\222x\272\212\2150\002) Tj T* ET +BT /F1 10 Tf 12 TL ET +BT 1 0 0 1 50 660 Tm (HeiseiKakuGo-W5) Tj T* ET +BT /F6 20 Tf 24 TL ET +BT 1 0 0 1 240 620 Tm /F6 20 Tf 24 TL (e\345g,\212\2360ne\207f\3700\222\210hy:0W0~0Y0\002) Tj T* ET +BT /F6 20 Tf 24 TL ET +BT 1 0 0 1 178 620 Tm /F6 20 Tf 24 TL (o"[W0h0r0\2110L0j0\0010\2530\2770\2530\3120\002) Tj T* ET +BT /F6 20 Tf 24 TL ET +BT 1 0 0 1 116 620 Tm /F6 20 Tf 24 TL (0\014~&f\3700M0\0150nS\345\212\255p\2710\222x\272\212\2150\002) Tj T* ET +BT /F1 9 Tf 10.8 TL ET +BT 1 0 0 1 36 28 Tm (Synthetic fixture / page 2 / no font program embedded) Tj T* ET + +endstream +endobj +xref +0 15 +0000000000 65535 f +0000000061 00000 n +0000000142 00000 n +0000000249 00000 n +0000000361 00000 n +0000001864 00000 n +0000002754 00000 n +0000002949 00000 n +0000004452 00000 n +0000005342 00000 n +0000005537 00000 n +0000005607 00000 n +0000005924 00000 n +0000005990 00000 n +0000007166 00000 n +trailer +<< +/ID +[<538e4832305af263a0a7344d39731b90><538e4832305af263a0a7344d39731b90>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 11 0 R +/Root 10 0 R +/Size 15 +>> +startxref +8442 +%%EOF diff --git a/tests/fixtures/pdf/generate.py b/tests/fixtures/pdf/generate.py new file mode 100644 index 0000000..21e8cdb --- /dev/null +++ b/tests/fixtures/pdf/generate.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Generate DocView's small synthetic PDF corpus (no third-party content). + +The coordinates, tags, appearances and security actions are intentional and are +asserted by test_pdf. qpdf is needed only to regenerate the encrypted fixture. +""" +from pathlib import Path +import subprocess + +root = Path(__file__).parent + +def stream(data, extra=''): + data = data.encode('ascii') + return f'<< /Length {len(data)} {extra} >>\nstream\n'.encode() + data + b'\nendstream' + +def write_pdf(name, objects): + result = bytearray(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n') + offsets = [0] + for index, obj in enumerate(objects, 1): + offsets.append(len(result)) + if isinstance(obj, str): obj = obj.encode('ascii') + result += f'{index} 0 obj\n'.encode() + obj + b'\nendobj\n' + xref = len(result) + result += f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode() + for offset in offsets[1:]: result += f'{offset:010d} 00000 n \n'.encode() + result += f'trailer\n<< /Size {len(offsets)} /Root 1 0 R /Info 19 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode() + (root / name).write_bytes(result) + +objects = [ + '<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /S /D /St 1 >>] >> /StructTreeRoot 11 0 R /MarkInfo << /Marked true >> /AcroForm << /Fields [16 0 R] /DR << /Font << /F1 7 0 R >> >> /DA (/F1 12 Tf 0 g) >> /Names << /Dests << /Names [(second) [4 0 R /XYZ 40 200 null]] >> >> >>', + '<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [14 0 R 15 0 R 16 0 R 18 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Rotate 90 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>', + stream('0 g 40 50 40 40 re f\n/Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC\n/H2 <> BDC BT /F1 12 Tf 60 120 Td (Needle) Tj ET EMC\n/H2 <> BDC BT /F1 12 Tf 60 100 Td (Second MCID) Tj ET EMC'), + stream('BT /F1 20 Tf 40 100 Td (Second Page Needle) Tj ET'), + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + '<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 2 >>', + '<< /Title (First heading) /Parent 8 0 R /Dest [3 0 R /XYZ 60 180 null] /Next 10 0 R >>', + '<< /Title (Named second) /Parent 8 0 R /Dest (second) /Prev 9 0 R >>', + '<< /Type /StructTreeRoot /K [12 0 R 13 0 R] /RoleMap << /Chapter /H1 >> /ParentTree << /Nums [0 [12 0 R 13 0 R 13 0 R]] >> >>', + '<< /Type /StructElem /S /Chapter /P 11 0 R /Pg 3 0 R /K 0 >>', + '<< /Type /StructElem /S /H2 /P 11 0 R /Pg 3 0 R /K [1 2] >>', + '<< /Type /Annot /Subtype /Link /Rect [30 30 90 45] /A << /S /URI /URI (https://example.org/) >> >>', + '<< /Type /Annot /Subtype /Link /Rect [100 30 160 45] /A << /S /Launch /F (/tmp/never-execute) >> >>', + '<< /Type /Annot /Subtype /Widget /FT /Tx /T (ReadonlyField) /V (Saved) /Ff 1 /F 4 /Rect [120 60 160 90] /P 3 0 R /AP << /N 17 0 R >> >>', + stream('1 0 0 rg 0 0 40 30 re f', '/Type /XObject /Subtype /Form /BBox [0 0 40 30] /Resources << >>'), + '<< /Type /Annot /Subtype /Text /Rect [180 200 200 220] /Contents (Read-only comment) /F 4 >>', + '<< /Title (DocView PDF fixture) /Author (DocView tests) >>' +] +write_pdf('navigation.pdf', objects) +subprocess.run(['qpdf', '--encrypt', 'reader', 'owner-secret', '256', '--', str(root / 'navigation.pdf'), str(root / 'password.pdf')], check=True) +(root / 'corrupt.pdf').write_bytes(b'%PDF-1.7\nThis is deliberately corrupt.\n') + +missing = list(objects) +missing[15] = missing[15].replace(' /AP << /N 17 0 R >>', '') +write_pdf('missing-appearance.pdf', missing) +reused = list(objects) +reused[2] = reused[2].replace('/Resources <<', '/Resources << /XObject << /Fm 20 0 R >>', 1) +reused[4] = stream('0 g /Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC /Fm Do') +reused[11] = reused[11].replace('/S /Chapter', '/S /Chapter /T (Original Heading)') +reused.append(stream('/H1 <> BDC BT /F1 12 Tf 50 50 Td (Different Form Heading) Tj ET EMC', '/Type /XObject /Subtype /Form /BBox [0 0 240 260] /Resources << /Font << /F1 7 0 R >> >>')) +write_pdf('reused-form-mcid.pdf', reused) + +broken_pages = list(objects) +broken_pages[1] = broken_pages[1].replace('/Kids [3 0 R 4 0 R]', '/Kids [null 4 0 R]') +write_pdf('missing-first-page.pdf', broken_pages) diff --git a/tests/fixtures/pdf/generate_annotation_flags.py b/tests/fixtures/pdf/generate_annotation_flags.py new file mode 100644 index 0000000..0e2b43c --- /dev/null +++ b/tests/fixtures/pdf/generate_annotation_flags.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Deterministic, self-authored PDF annotation geometry; no external content.""" +from pathlib import Path +import hashlib +import json + +root = Path(__file__).parent / "annotation-flags" +root.mkdir(exist_ok=True) +objects = ["", ""] +def add(value): + objects.append(value.encode("ascii") if isinstance(value, str) else value) + return len(objects) +def stream(content, extra): + data = content.encode("ascii") + return add(f"<< /Length {len(data)} {extra} >>\nstream\n".encode() + data + b"\nendstream") + +# Non-identity AP matrix, named graphics state, and selected appearance state +# must survive rectangle compensation. Normalization still yields red left / +# blue right in the annotation's 32x16 rectangle; Off is deliberately green. +normal = stream("q /Opaque gs 1 0 0 rg 0 0 16 16 re f 0 0 1 rg 16 0 16 16 re f Q", + "/Type /XObject /Subtype /Form /BBox [0 0 32 16] /Matrix [2 0 0 3 7 11] " + "/Resources << /ExtGState << /Opaque << /Type /ExtGState /ca 1 /CA 1 >> >> >>") +green = stream("0 1 0 rg 0 0 32 16 re f", "/Type /XObject /Subtype /Form /BBox [0 0 32 16] /Resources << >>") +pages, cases = [], [] +def page(annots, rotate, case): + pid = add(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate {rotate} " + f"/Resources << >> /Annots [{' '.join(f'{x} 0 R' for x in annots)}] >>") + pages.append(pid) + cases.append(dict(page=len(pages)-1, intrinsicRotation=rotate, **case)) + +for kind in ["generated-link", "stored-link", "stored-comment"]: + for flags in [8, 16, 24]: + for rotate in [0, 90, 180, 270]: + extra = ("/C [1 0 0] /BS << /W 2 /S /S >>" if kind == "generated-link" else + f"/AP << /N << /On {normal} 0 R /Off {green} 0 R >> >> /AS /On") + subtype = "Stamp" if kind == "stored-comment" else "Link" + annot = add(f"<< /Type /Annot /Subtype /{subtype} /Rect [42 48 74 64] /F {flags} " + f"/Contents ({kind} flags {flags}) /A << /S /URI /URI (https://example.org/flags) >> {extra} >>") + page([annot], rotate, dict(kind=kind, flags=flags, rect=[42,48,74,64])) + +# Annots order is retained: the later green appearance covers the generated +# border; Hidden / NoView appearances at distinct rectangles remain invisible. +annots = [add("<< /Type /Annot /Subtype /Link /Rect [42 48 74 64] /F 24 /C [1 0 0] /BS << /W 2 >> >>"), + add(f"<< /Type /Annot /Subtype /Link /Rect [41 47 75 65] /F 24 /AP << /N {green} 0 R >> >>")] +for flag, rect in [(26, "20 80 36 96"), (56, "80 80 96 96")]: + annots.append(add(f"<< /Type /Annot /Subtype /Link /Rect [{rect}] /F {flag} /Contents (Invisible comment) /AP << /N {normal} 0 R >> >>")) +page(annots, 0, dict(kind="visibility-zorder", flags=24)) + +# Right-edge clipping of an upright unscaled AP remains clipped to the CropBox. +edge = add(f"<< /Type /Annot /Subtype /Link /Rect [108 48 140 64] /F 24 /AP << /N {normal} 0 R >> >>") +page([edge], 0, dict(kind="crop-clipping", flags=24, rect=[108,48,140,64])) +objects[0] = b"<< /Type /Catalog /Pages 2 0 R >>" +objects[1] = f"<< /Type /Pages /Count {len(pages)} /Kids [{' '.join(f'{p} 0 R' for p in pages)}] >>".encode() +data = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") +offsets = [0] +for index, value in enumerate(objects, 1): + offsets.append(len(data)) + data += f"{index} 0 obj\n".encode() + value + b"\nendobj\n" +xref = len(data) +data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() +for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode() +data += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() +(root / "flags.pdf").write_bytes(data) +(root / "manifest.json").write_text(json.dumps(dict(generator="generate_annotation_flags.py", + license="Self-created test content; same license as DocView.", sha256=hashlib.sha256(data).hexdigest(), + cropBox=[8,12,120,116], cases=cases, expected="At 100% one PDF point is one logical pixel. " + "NoZoom preserves logical 32x16 size across zoom and scales only with DPR. NoRotate preserves upright " + "red-left/blue-right appearance around the original Rect upper-left through intrinsic plus user rotation. " + "Generated red border extends one point beyond Rect. This is synthetic geometry, not a general PDF compatibility golden."), indent=2)+"\n") + +# Native Text icons have no supplied AP. Shared annotation objects exercise +# capture-once behavior across rotated pages without accumulating AP streams. +text_objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b""] +text_content = b"BT /F1 10 Tf 12 100 Td (Native icon with unembedded body text) Tj ET" +text_objects.append(f"<< /Length {len(text_content)} >>\nstream\n".encode() + text_content + b"\nendstream") +text_objects.append(b"<< /Type /Annot /Subtype /Ink /Rect [80 24 100 34] /F 8 /Contents (Native ink) /BS << /W 4 >> /InkList [[80 24 90 34 100 24]] >>") +text_pages = [] +for flags in [8,16,24]: + aid = len(text_objects) + 1 + text_objects.append(f"<< /Type /Annot /Subtype /Text /Rect [42 48 74 64] /F {flags} /Contents (Native text icon) /C [1 0 0] >>".encode()) + for rotation in [0,90,180,270]: + text_pages.append(len(text_objects)+1) + text_objects.append(f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 128 128] /CropBox [8 12 120 116] /Rotate {rotation} " + f"/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >> /Contents 3 0 R /Annots [{aid} 0 R 4 0 R 4 0 R] >>".encode()) +text_objects[1] = f"<< /Type /Pages /Count 12 /Kids [{' '.join(f'{p} 0 R' for p in text_pages)}] >>".encode() +data = bytearray(b"%PDF-1.7\n") +offsets = [0] +for index, value in enumerate(text_objects, 1): + offsets.append(len(data)) + data += f"{index} 0 obj\n".encode() + value + b"\nendobj\n" +xref = len(data) +data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() +for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode() +data += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() +(root / "text-icons.pdf").write_bytes(data) +(root / "text-icons-manifest.json").write_text(json.dumps(dict(license="Self-created test content; same license as DocView.", + sha256=hashlib.sha256(data).hexdigest(), originalRect=[42,48,74,64], nativeIconRect=[42,48,62,68], + expected="Twelve pages: flags 8/16/24, each with intrinsic rotations 0/90/180/270. Four pages share each annotation. " + "Capture the native generated AP once, retain source Rect, use the captured 20x20 icon Rect for drawing and hit testing."), indent=2)+"\n") diff --git a/tests/fixtures/pdf/generate_cmyk_lut.py b/tests/fixtures/pdf/generate_cmyk_lut.py new file mode 100644 index 0000000..fa7eab6 --- /dev/null +++ b/tests/fixtures/pdf/generate_cmyk_lut.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Author one CMYK ICCBased PDF with a known four-dimensional Lab CLUT. + +The ICC v2 input profile is encoded directly from the ICC specification; +no renderer/CMM output or borrowed press profile defines the expected values. +""" +import argparse +from io import BytesIO +import hashlib +import itertools +import json +from pathlib import Path +import struct + +from pypdf import PdfReader, PdfWriter +from pypdf.generic import ArrayObject, DecodedStreamObject, DictionaryObject, NameObject, NumberObject +from reportlab.pdfgen import canvas + +WIDTH, HEIGHT = 720, 420 +SAMPLES = [(0, 0, 0, 0), (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), + (0, 0, 0, 1), (.25, .5, .75, .2), (.6, .2, .4, .1), (1, 1, 1, 1)] + + +def model(cmyk): + c, m, y, k = cmyk + return 100 - 12*c - 15*m - 10*y - 35*k, 18*m - 15*c, 20*y - 12*c + + +def expected_rgb(cmyk): + # CIELAB inverse, D50 -> D65 Bradford adaptation, then standard sRGB. + lightness, a, b = model(cmyk) + fy = (lightness + 16) / 116 + f = (fy + a/500, fy, fy - b/200) + xyz = [white * (v**3 if v > 6/29 else 3*(6/29)**2*(v-4/29)) + for white, v in zip((.9642, 1, .8249), f)] + def multiply(matrix, vector): + return [sum(a*b for a, b in zip(row, vector)) for row in matrix] + d65 = multiply(((.9555766, -.0230393, .0631636), (-.0282895, 1.0099416, .0210077), + (.0122982, -.0204830, 1.3299098)), xyz) + linear = multiply(((3.2404542, -1.5371385, -.4985314), (-.969266, 1.8760108, .041556), + (.0556434, -.2040259, 1.0572252)), d65) + return [round(255*max(0, min(1, 12.92*v if v <= .0031308 else 1.055*v**(1/2.4)-.055))) for v in linear] + + +def profile_bytes(): + def fixed(values): + return struct.pack('>' + 'i'*len(values), *(round(v*65536) for v in values)) + header = bytearray(128) + header[8:12] = bytes.fromhex('02100000') + header[12:24] = b'scnrCMYKLab ' + header[24:36] = struct.pack('>6H', 2026, 9, 20, 0, 0, 0) + header[36:40] = b'acsp' + header[68:80] = fixed((.9642, 1, .8249)) + header[80:84] = b'DCVW' + description = b'DocView synthetic CMYK Lab CLUT\0' + desc = b'desc' + bytes(4) + struct.pack('>I', len(description)) + description + bytes(78) + copyright_tag = b'text' + bytes(4) + b'No copyright, use freely. Synthetic test profile; not a press characterization.\0' + table = bytearray(b'mft2' + bytes(4) + bytes((4, 3, 2, 0))) + table += fixed((1, 0, 0, 0, 1, 0, 0, 0, 1)) + struct.pack('>HH', 2, 2) + table += struct.pack('>HH', 0, 65535) * 4 + for cmyk in itertools.product((0, 1), repeat=4): + lightness, a, b = model(cmyk) + table += struct.pack('>3H', round(lightness/100*65280), round((a+128)*256), round((b+128)*256)) + table += struct.pack('>HH', 0, 65535) * 3 + assert len(table) == 176 + tags = [(b'desc', desc), (b'cprt', copyright_tag), + (b'wtpt', b'XYZ ' + bytes(4) + fixed((.9642, 1, .8249))), (b'A2B0', bytes(table))] + body = bytearray(); entries = bytearray(); offset = 132 + 12*len(tags) + for signature, data in tags: + entries += signature + struct.pack('>II', offset+len(body), len(data)) + body += data + bytes((-len(data)) % 4) + result = header + struct.pack('>I', len(tags)) + entries + body + result[:4] = struct.pack('>I', len(result)) + return bytes(result) + + +def generate(output): + output.mkdir(parents=True, exist_ok=False) + profile = profile_bytes() + (output / 'synthetic-cmyk-lab.icc').write_bytes(profile) + buffer = BytesIO() + page = canvas.Canvas(buffer, pagesize=(WIDTH, HEIGHT), invariant=1, pageCompression=0) + page.setTitle('ICC CMYK - four-dimensional lookup table') + page.setAuthor('DocView test corpus') + page.setFont('Helvetica-Bold', 18) + page.drawString(36, 385, 'ICC CMYK - FOUR-DIMENSIONAL LOOKUP TABLE') + page.setFont('Helvetica', 10) + page.drawString(36, 365, 'Synthetic CMYK to Lab mapping. Fixed D50 white point. No press calibration.') + for y, label in [(336, 'A ICCBased vector'), (238, 'B ICCBased image (8-bit components)'), + (140, 'C DeviceCMYK control (profile not applied)')]: + page.setFont('Helvetica-Bold', 10); page.drawString(36, y, label) + page.setFont('Helvetica', 9) + for i, label in enumerate(['White', 'Cyan', 'Magenta', 'Yellow', 'Black', 'Mix A', 'Mix B', 'All inks']): + page.drawCentredString(36+i*81+35, 48, label) + page.setFont('Helvetica', 9) + page.drawString(36, 25, 'A/B must agree with the analytic Lab mapping. C deliberately uses a different color space.') + page.save() + writer = PdfWriter(); writer.append(PdfReader(BytesIO(buffer.getvalue()))) + target = writer.pages[0] + icc = DecodedStreamObject(); icc.set_data(profile) + icc.update({NameObject('/N'): NumberObject(4), NameObject('/Alternate'): NameObject('/DeviceCMYK')}) + icc_ref = writer._add_object(icc) + space = ArrayObject([NameObject('/ICCBased'), icc_ref]) + target['/Resources'][NameObject('/ColorSpace')] = DictionaryObject({NameObject('/TestCMYK'): space}) + image = DecodedStreamObject(); image.set_data(bytes(round(v*255) for cmyk in SAMPLES for v in cmyk)) + image.update({NameObject('/Type'): NameObject('/XObject'), NameObject('/Subtype'): NameObject('/Image'), + NameObject('/Width'): NumberObject(8), NameObject('/Height'): NumberObject(1), + NameObject('/BitsPerComponent'): NumberObject(8), NameObject('/ColorSpace'): space, + NameObject('/Intent'): NameObject('/RelativeColorimetric')}) + target['/Resources'][NameObject('/XObject')] = DictionaryObject({NameObject('/Sample'): writer._add_object(image)}) + probes, commands = [], ['/RelativeColorimetric ri'] + for i, components in enumerate(SAMPLES): + x = 36+i*81 + operands = ' '.join(f'{v:g}' for v in components) + commands += [f'/TestCMYK cs {operands} scn {x} 260 70 64 re f', + f'{operands} k {x} 64 70 64 re f'] + probes.append({'kind': 'icc-vector', 'sample': i, 'pointPt': [x+35, 292], 'components': list(components), + 'lab': list(model(components)), 'expectedRgb': expected_rgb(components)}) + quantized = [round(v*255)/255 for v in components] + probes.append({'kind': 'icc-image', 'sample': i, 'pointPt': [36+(i+.5)*81, 194], 'components': quantized, + 'lab': list(model(quantized)), 'expectedRgb': expected_rgb(quantized)}) + probes.append({'kind': 'device-control', 'sample': i, 'pointPt': [x+35, 96], 'components': list(components)}) + commands += ['q 648 0 0 64 36 162 cm /Sample Do Q'] + content = DecodedStreamObject(); content.set_data(('\n'.join(commands)+'\n').encode()) + target[NameObject('/Contents')] = ArrayObject([target.raw_get('/Contents'), writer._add_object(content)]) + pdf = output / 'icc-cmyk-lut.pdf' + with pdf.open('wb') as stream: writer.write(stream) + def digest(data): return hashlib.sha256(data).hexdigest() + record = {'file': pdf.name, 'sha256': digest(pdf.read_bytes()), 'profileFile': 'synthetic-cmyk-lab.icc', + 'profileSha256': digest(profile), 'profileBytes': len(profile), 'pageSizePt': [WIDTH, HEIGHT], + 'profile': {'version': '2.1', 'class': 'scnr', 'input': 'CMYK', 'pcs': 'Lab ', + 'tag': 'A2B0', 'type': 'mft2', 'gridPointsPerChannel': 2, + 'mapping': ['L=100-12*C-15*M-10*Y-35*K', 'a=18*M-15*C', 'b=20*Y-12*C'], + 'labEncoding': 'ICC v2 L: 0..65280 for 0..100; a/b: (value+128)*256', + 'source': 'Self-authored binary ICC input profile; no borrowed ICC profile'}, + 'probes': probes, 'tolerance8Bit': 4, + 'references': ['https://archive.color.org/files/icc32.pdf', 'https://www.color.org/chardata/rgb/srgb.xalter'], + 'limits': ['Synthetic CMYK input CLUT; not a measured print profile or calibrated press proof.', + 'No human-approved golden; no physical display or Windows validation.'], + 'generatorSha256': digest(Path(__file__).read_bytes())} + (output / 'manifest.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps({'fixtureSha256': record['sha256'], 'profileSha256': record['profileSha256'], 'probes': len(probes)})) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + generate(parser.parse_args().output) diff --git a/tests/fixtures/pdf/generate_comments.py b/tests/fixtures/pdf/generate_comments.py new file mode 100644 index 0000000..df46f12 --- /dev/null +++ b/tests/fixtures/pdf/generate_comments.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Generate synthetic read-only comment keyboard fixtures, without dependencies.""" +from pathlib import Path + +objects = [ + b'<< /Type /Catalog /Pages 2 0 R >>', + b'<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Annots [4 0 R 5 0 R] /Resources << >> >>', + b'<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Contents (Literal comment) /F 4 >>', + b'<< /Type /Annot /Subtype /Text /Rect [1000 1000 1020 1020] /Contents (Outside CropBox comment) /F 4 >>', +] +data = bytearray(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n') +offsets = [0] +for index, obj in enumerate(objects, 1): + offsets.append(len(data)) + data += f'{index} 0 obj\n'.encode() + obj + b'\nendobj\n' +xref = len(data) +data += f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode() +for offset in offsets[1:]: + data += f'{offset:010d} 00000 n \n'.encode() +data += f'trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode() +Path(__file__).with_name('comments.pdf').write_bytes(data) + +# Sub-pixel page at 25% zoom: keyboard focus must not invert scroll bounds. +tiny = bytes(data) +for original, replacement in [(b'[0 0 240 260]', b'[0 0 1 1]'), + (b'[20 30 220 230]', b'[0 0 1 1]'), + (b'[100 100 120 120]', b'[0 0 0.5 0.5]'), + (b'[1000 1000 1020 1020]', b'[0 0 0.5 0.5]')]: + tiny = tiny.replace(original, replacement.ljust(len(original), b' ')) +# Preserve fixed byte widths so the original xref remains exact. +assert len(tiny) == len(data) +Path(__file__).with_name('tiny-comments.pdf').write_bytes(tiny) diff --git a/tests/fixtures/pdf/generate_fonts.py b/tests/fixtures/pdf/generate_fonts.py new file mode 100644 index 0000000..c8b353e --- /dev/null +++ b/tests/fixtures/pdf/generate_fonts.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Author deterministic unembedded Japanese CID H/V fixtures (ReportLab).""" +import hashlib +import json +from pathlib import Path + +from pypdf import PdfReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfgen.canvas import Canvas + +ROOT = Path(__file__).resolve().parent / "fonts" +ROOT.mkdir(exist_ok=True) +OUTPUT = ROOT / "unembedded-japanese.pdf" +TEXTS = ["日本語の文書を表示します。", "漢字とひらがな、カタカナ。", "「縦書き」の句読点を確認。"] +for face in ("HeiseiMin-W3", "HeiseiKakuGo-W5"): + for vertical in (False, True): + font = UnicodeCIDFont(face, isVertical=vertical) + font.name = font.fontName = face + ("-V" if vertical else "-H") + pdfmetrics.registerFont(font) + +canvas = Canvas(str(OUTPUT), pagesize=(600, 760), invariant=1, pageCompression=0) +canvas.setTitle("DocView unembedded Japanese CID H/V fixture") +canvas.setAuthor("DocView synthetic test corpus") +for page, vertical in enumerate((False, True), 1): + canvas.setFont("Helvetica-Bold", 16) + canvas.drawString(36, 722, "Unembedded Japanese CID fonts") + canvas.setFont("Helvetica", 11) + canvas.drawString(36, 700, "UniJIS-UCS2-" + ("V: native vertical writing mode" if vertical else "H: horizontal writing mode")) + for row, face in enumerate(("HeiseiMin-W3", "HeiseiKakuGo-W5")): + canvas.setFont("Helvetica", 10) + if vertical: + canvas.drawString(330 - row * 280, 660, face) + for col, text in enumerate(TEXTS): + canvas.setFont(face + "-V", 20) + canvas.drawString(520 - row * 280 - col * 62, 620, text) + else: + canvas.drawString(36, 648 - row * 260, face) + canvas.setFont(face + "-H", 20) + for line, text in enumerate(TEXTS): + canvas.drawString(36, 610 - row * 260 - line * 42, text) + canvas.setFont("Helvetica", 9) + canvas.drawString(36, 28, f"Synthetic fixture / page {page} / no font program embedded") + canvas.showPage() +canvas.save() + +reader = PdfReader(OUTPUT) +encodings = set() +for page in reader.pages: + for ref in page["/Resources"]["/Font"].values(): + font = ref.get_object() + if font.get("/Subtype") != "/Type0": + continue + encodings.add(str(font["/Encoding"])) + for descendant in font["/DescendantFonts"]: + descriptor = descendant.get_object()["/FontDescriptor"].get_object() + assert not any(key in descriptor for key in ("/FontFile", "/FontFile2", "/FontFile3")) +assert encodings == {"/UniJIS-UCS2-H", "/UniJIS-UCS2-V"}, encodings +manifest = { + "schemaVersion": 1, + "generator": "generate_fonts.py (ReportLab UnicodeCIDFont, invariant PDF)", + "documents": [{ + "file": OUTPUT.name, + "sha256": hashlib.sha256(OUTPUT.read_bytes()).hexdigest(), + "pages": 2, + "sizePt": [600, 760], + "fontProgramsEmbedded": False, + "encodings": sorted(encodings), + "faces": ["HeiseiMin-W3", "HeiseiKakuGo-W5"], + "expectedText": TEXTS, + "writingModes": ["horizontal", "vertical"], + }], +} +(ROOT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") +print(json.dumps(manifest, ensure_ascii=False, indent=2)) diff --git a/tests/fixtures/pdf/generate_headings.py b/tests/fixtures/pdf/generate_headings.py new file mode 100644 index 0000000..f38011d --- /dev/null +++ b/tests/fixtures/pdf/generate_headings.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Deterministic, self-authored tagged-PDF boundary corpus (no external tools).""" +from pathlib import Path +import hashlib +import json +import zlib + +ROOT = Path(__file__).parent / 'headings' +ROOT.mkdir(exist_ok=True) + + +def stream(text, extra=''): + data = text.encode('ascii') + return f'<< /Length {len(data)} {extra} >>\nstream\n'.encode() + data + b'\nendstream' + + +def write(name, objects, expected): + data = bytearray(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n') + offsets = [0] + for number, obj in enumerate(objects, 1): + offsets.append(len(data)) + data += f'{number} 0 obj\n'.encode() + (obj.encode('ascii') if isinstance(obj, str) else obj) + b'\nendobj\n' + xref = len(data) + data += f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode() + for offset in offsets[1:]: + data += f'{offset:010d} 00000 n \n'.encode() + data += f'trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode() + (ROOT / name).write_bytes(data) + return {'file': name, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), 'expected': expected} + + +font = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>' +records = [] +records.append(write('transformed-form.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >>', + stream('/H2 <> BDC q 1.5 0 0 1.25 55 70 cm /Fm Do Q EMC'), + font, + stream('/H2 <> BDC BT /F1 16 Tf 10 30 Td (Form heading) Tj ET EMC', '/Type /XObject /Subtype /Form /BBox [0 0 160 80] /StructParents 1 /Resources << /Font << /F1 5 0 R >> >>'), + '<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Nums [0 [8 0 R] 1 [null null null null 8 0 R]] >> /ParentTreeNextKey 2 >>', + '<< /Type /StructElem /S /H2 /P 7 0 R /Pg 3 0 R /T (Form heading) /K [0 << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 4 >>] >>' +], {'pages': 1, 'headings': [{'page': 0, 'title': 'Form heading', 'level': 2, 'precision': 'exact', 'text_region_pt': [70, 102, 240, 130]}], 'form_matrix': [1.5, 0, 0, 1.25, 55, 70], 'local_baseline': [10, 30]})) + +records.append(write('rotated-tags.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 260] /CropBox [20 30 280 230] /Rotate 90 /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + stream('/H3 <> BDC BT /F1 16 Tf 60 80 Td (Rotated second) Tj ET EMC\n/H1 <> BDC BT /F1 20 Tf 50 170 Td (Rotated first) Tj ET EMC'), + font, + '<< /Type /StructTreeRoot /K [7 0 R 8 0 R] /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> >>', + '<< /Type /StructElem /S /H1 /P 6 0 R /Pg 3 0 R /K 0 >>', + '<< /Type /StructElem /S /H3 /P 6 0 R /Pg 3 0 R /K 1 >>' +], {'pages': 1, 'crop_box': [20, 30, 280, 230], 'rotation': 90, 'headings': [ + {'page': 0, 'title': 'Rotated first', 'level': 1, 'precision': 'exact', 'text_region_pt': [50, 168, 170, 190]}, + {'page': 0, 'title': 'Rotated second', 'level': 3, 'precision': 'exact', 'text_region_pt': [60, 78, 180, 96]} +], 'order': 'structure order, intentionally different from content-stream draw order'})) + +records.append(write('cross-page-structure.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>', + '<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 1 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>', + stream('/Span <> BDC BT /F1 16 Tf 40 60 Td (Across page one) Tj ET EMC\n/H1 <> BDC BT /F1 16 Tf 170 60 Td ( middle) Tj ET EMC'), + stream('/P <> BDC BT /F1 16 Tf 40 120 Td (Unrelated paragraph) Tj ET EMC\n/H2 <> BDC BT /F1 16 Tf 40 160 Td (Following heading) Tj ET EMC\n/Span <> BDC BT /F1 16 Tf 40 210 Td (Across page two) Tj ET EMC'), + font, + '<< /Type /StructTreeRoot /K [9 0 R] /ParentTree << /Nums [0 [12 0 R 10 0 R] 1 [13 0 R 11 0 R 14 0 R]] >> /ParentTreeNextKey 2 >>', + '<< /Type /StructElem /S /Sect /P 8 0 R /K [10 0 R 11 0 R 14 0 R] >>', + '<< /Type /StructElem /S /H1 /P 9 0 R /T (Across pages) /K [12 0 R << /Type /MCR /Pg 3 0 R /MCID 1 >> 13 0 R] >>', + '<< /Type /StructElem /S /H2 /P 9 0 R /Pg 4 0 R /K 1 >>', + '<< /Type /StructElem /S /Span /P 10 0 R /Pg 3 0 R /K 0 >>', + '<< /Type /StructElem /S /Span /P 10 0 R /Pg 4 0 R /K 0 >>', + '<< /Type /StructElem /S /P /P 9 0 R /Pg 4 0 R /K 2 >>' +], {'pages': 2, 'headings': [ + {'page': 0, 'title': 'Across page one middle', 'level': 1, 'precision': 'exact', 'text_region_pt': [40, 55, 230, 78]}, + {'page': 1, 'title': 'Across page two', 'level': 1, 'precision': 'exact', 'text_region_pt': [40, 205, 170, 228]}, + {'page': 1, 'title': 'Following heading', 'level': 2, 'precision': 'exact', 'text_region_pt': [40, 155, 180, 178]} +], 'scope': 'One heading spans two page-local Span elements with an interleaved direct MCR; MCID 0 is reused independently on both pages. Results are page-local fragments.'})) + +records.append(write('vector-heading.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << >> /Contents 4 0 R >>', + stream('/H4 <> BDC 0 0 1 rg 40 180 150 20 re f EMC'), + '<< /Type /StructTreeRoot /K [6 0 R] /ParentTree << /Nums [0 [6 0 R]] >> >>', + '<< /Type /StructElem /S /H4 /P 5 0 R /Pg 3 0 R /ActualText (Vector section) /K 0 >>' +], {'pages': 1, 'headings': [{'page': 0, 'title': 'Vector section', 'level': 4, 'precision': 'page', 'reason_contains': '座標'}], 'drawn_region_pt': [40, 180, 190, 200], 'fallback': 'No text glyph bounding box is available for the marked vector path.'})) + +records.append(write('form-only-structure.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >>', + stream('q 1 0 0 1 40 100 cm /Fm Do Q'), + font, + stream('/H2 <> BDC BT /F1 16 Tf 10 30 Td (Form only heading) Tj ET EMC', '/Type /XObject /Subtype /Form /BBox [0 0 200 80] /StructParents 0 /Resources << /Font << /F1 5 0 R >> >>'), + '<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Nums [0 [8 0 R]] >> /ParentTreeNextKey 1 >>', + '<< /Type /StructElem /S /H2 /P 7 0 R /Pg 3 0 R /T (Form only heading) /K << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 0 >> >>' +], {'pages': 1, 'headings': [{'page': 0, 'title': 'Form only heading', 'level': 2, 'precision': 'exact', 'text_region_pt': [50, 125, 190, 147]}], + 'scope': 'Form StructParents and source-qualified MCR; no page StructParents.'})) + +records.append(write('form-vector-mcid-collision.pdf', [ + '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >>', + stream('/H1 <> BDC 0 0 1 rg 40 180 180 20 re f EMC q 1 0 0 1 40 100 cm /Fm Do Q'), + font, + stream('/P <> BDC BT /F1 12 Tf 10 30 Td (Unrelated Form paragraph) Tj ET EMC', '/Type /XObject /Subtype /Form /BBox [0 0 240 80] /StructParents 1 /Resources << /Font << /F1 5 0 R >> >>'), + '<< /Type /StructTreeRoot /K [8 0 R 9 0 R] /ParentTree << /Nums [0 [8 0 R] 1 [9 0 R]] >> /ParentTreeNextKey 2 >>', + '<< /Type /StructElem /S /H1 /P 7 0 R /Pg 3 0 R /T (Page vector heading) /K 0 >>', + '<< /Type /StructElem /S /P /P 7 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 0 >> >>' +], {'pages': 1, 'headings': [{'page': 0, 'title': 'Page vector heading', 'level': 1, 'precision': 'page', 'reason_contains': 'MCID'}], + 'fallback': 'MCID 0 names a page vector and unrelated Form text. Non-text page objects must participate in stream collision detection.'})) + +def form_case(name, forms, content, expected, *, rolemap='', root_order=None, mutate=None): + """Create source-qualified MCRs with an indirect ParentTree Kids node.""" + objects = ['<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', '', stream(content), font, ''] + heading_ids = [7 + len(forms) + i for i, form in enumerate(forms)] + names = ' '.join(f'/Fm{i} {7+i} 0 R' for i in range(len(forms))) + objects[2] = f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << {names} >> >> /Contents 4 0 R >>' + for i, form in enumerate(forms): + extra = f'/Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents {i} /Resources << /Font << /F1 5 0 R >> /XObject << {names} >> /Properties << /HeadingProperty << /MCID 0 >> >> >>' + if 'matrix' in form: extra += f' /Matrix [{form["matrix"]}]' + objects.append(stream(form['content'], extra)) + for i, form in enumerate(forms): + title = f'/T ({form["title"]})' if 'title' in form else '' + objects.append(f'<< /Type /StructElem /S /{form.get("role", "H2")} /P 6 0 R /Pg 3 0 R {title} /K << /Type /MCR /Pg 3 0 R /Stm {7+i} 0 R /MCID 0 >> >>') + parent_id = len(objects) + 1 + nums = ' '.join(f'{i} [{heading_ids[i]} 0 R]' for i in range(len(forms))) + objects.append(f'<< /Limits [0 {len(forms)-1}] /Nums [{nums}] >>') + order = root_order if root_order is not None else range(len(forms)) + children = ' '.join(f'{heading_ids[i]} 0 R' for i in order) + objects[5] = f'<< /Type /StructTreeRoot /K [{children}] /ParentTree << /Kids [{parent_id} 0 R] >> {rolemap} >>' + if mutate: mutate(objects, heading_ids) + records.append(write(name, objects, {'pages': 1, **expected})) + +def tagged(label, tag='H2'): + return f'/{tag} <> BDC BT /F1 12 Tf 10 30 Td ({label}) Tj ET EMC' + +def exact(title, region, level=2): + return {'page': 0, 'title': title, 'level': level, 'precision': 'exact', 'text_region_pt': region} + +def fallback(title, reason): + return {'page': 0, 'title': title, 'level': 2, 'precision': 'page', 'reason_contains': reason} + +form_case('named-property-rolemap.pdf', [{'content': '/Section /HeadingProperty BDC BT /F1 12 Tf 10 30 Td (Named property heading) Tj ET EMC', 'role': 'Section'}], + 'q 1 0 0 1 40 100 cm /Fm0 Do Q', {'headings': [exact('Named property heading', [50, 127, 190, 142], 3)]}, rolemap='/RoleMap << /Section /H3 >>') +form_case('nested-form-matrix.pdf', [{'content': 'q 1.5 0 0 1.25 10 20 cm /Fm1 Do Q', 'matrix': '1.2 0 0 0.9 5 7'}, + {'content': tagged('Nested heading'), 'matrix': '1 0 0 1 4 6'}], + 'q 1 0 0 1 40 100 cm /Fm0 Do Q', {'headings': [exact('Nested heading', [82, 161, 245, 181])]}) +form_case('different-form-same-mcid.pdf', [{'content': tagged('First form')}, {'content': tagged('Second form')}], + 'q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm1 Do Q', + {'headings': [exact('Second form', [90, 87, 170, 102]), exact('First form', [40, 207, 105, 222])]}, root_order=[1, 0]) +form_case('ambiguous-form-matrix.pdf', [{'content': tagged('Do not borrow first'), 'title': 'First structure'}, {'content': tagged('Do not borrow second'), 'title': 'Second structure'}], + 'q 1 0 0 1 40 100 cm /Fm0 Do /Fm1 Do Q', + {'headings': [fallback('First structure', '一意'), fallback('Second structure', '一意')]}) +form_case('repeated-form.pdf', [{'content': tagged('Repeated heading')}], + 'q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm0 Do Q', + {'headings': [fallback('Repeated heading', '複数回')]}) +form_case('unused-form-resource.pdf', [{'content': tagged('Used heading')}, {'content': tagged('Unused heading')}], + 'q 1 0 0 1 40 100 cm /Fm0 Do Q', {'headings': [exact('Used heading', [50, 127, 140, 142])]}) +form_case('inline-image-before-form.pdf', [{'content': tagged('After image')}], + 'q 1 0 0 1 40 100 cm BI /W 1 /H 1 /BPC 8 /CS /G ID X EI /Fm0 Do Q', + {'headings': [exact('After image', [50, 127, 125, 142])]}) + +limited = {'headings': [], 'limited': True} +form_case('cyclic-form.pdf', [{'content': '/Fm0 Do'}], '/Fm0 Do', limited) +form_case('cyclic-rolemap.pdf', [{'content': tagged('Hidden by cycle'), 'role': 'Foo'}], '/Fm0 Do', limited, rolemap='/RoleMap << /Foo /Bar /Bar /Foo >>') +form_case('cyclic-parent.pdf', [{'content': tagged('Parent cycle')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, o[ids[0]-1].replace('/P 6 0 R', f'/P {ids[0]} 0 R'))) +form_case('cyclic-children.pdf', [{'content': tagged('Child cycle')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, f'<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K {ids[0]} 0 R >>')) +form_case('broken-mcr-page.pdf', [{'content': tagged('Wrong page')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, o[ids[0]-1].replace('/Type /MCR /Pg 3 0 R', '/Type /MCR /Pg 7 0 R'))) +form_case('broken-mcr-stream.pdf', [{'content': tagged('Wrong stream')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, o[ids[0]-1].replace('/Stm 7 0 R', '/Stm 3 0 R'))) +form_case('annotation-owned-mcr.pdf', [{'content': tagged('Annotation-owned')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, o[ids[0]-1].replace('/Stm 7 0 R', '/Stm 7 0 R /StmOwn 3 0 R'))) +form_case('broken-graphics-stack.pdf', [{'content': tagged('Graphics stack')}], 'Q /Fm0 Do', limited) +form_case('cyclic-number-tree.pdf', [{'content': tagged('Number tree')}], '/Fm0 Do', limited, + mutate=lambda o, ids: o.__setitem__(len(o)-1, f'<< /Limits [0 0] /Kids [{len(o)} 0 R] >>')) +form_case('conflicting-parent-tree-owner.pdf', [{'content': tagged('First owner')}, {'content': tagged('Second owner')}], + 'q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm1 Do Q', limited, + mutate=lambda o, ids: o.__setitem__(ids[0]-1, o[ids[0]-1].replace('/Stm 7 0 R', '/Stm 8 0 R'))) + +def compressed_content(size): + def mutate(objects, ids): + body = ('q 1 0 0 1 40 100 cm /Fm0 Do Q').encode() + data = zlib.compress(b' ' * (size-len(body)) + body) + objects[3] = f'<< /Length {len(data)} /Filter /FlateDecode >>\nstream\n'.encode() + data + b'\nendstream' + return mutate + +form_case('stream-quota-at-limit.pdf', [{'content': tagged('At stream limit')}], '', + {'headings': [exact('At stream limit', [50, 127, 140, 142])]}, mutate=compressed_content(16*1024*1024)) +form_case('stream-quota-over-limit.pdf', [{'content': tagged('Over stream limit')}], '', + {'headings': [], 'limited': True, 'truncated': True}, mutate=compressed_content(16*1024*1024+1)) + +long_title = '' +many = ['<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /StructParents 0 /Resources << >> /Contents 4 0 R >>', + stream('\n'.join(f'/H2 <> BDC 10 {i*4} 20 2 re f EMC' for i in range(70))), ''] +for i in range(70): + many.append(f'<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K {i} /ActualText {long_title} >>') +refs = ' '.join(f'{i+6} 0 R' for i in range(70)) +many[4] = f'<< /Type /StructTreeRoot /K [{refs}] /ParentTree << /Nums [0 [{refs}]] >> >>' +records.append(write('bounded-heading-response.pdf', many, {'pages': 1, 'headings': [], 'response_limited': True})) + +deep = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Count 2 /Kids [3 0 R 70 0 R] >>'] +for obj in range(3, 69): + deep.append(f'<< /Type /Pages /Parent {obj-1} 0 R /Count 1 /Kids [{obj+1} 0 R] >>') +deep.append('<< /Type /Page /Parent 68 0 R /MediaBox [0 0 100 100] /Resources << >> >>') +deep.append('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>') +context_record = write('context-deep-tree.pdf', deep, {'scope': 'Context-only regression: after a depth failure, the second page must never be assigned index zero.'}) +context_records = [context_record] +for count in (32, 33): + context_records.append(write(f'context-warnings-{count}.pdf', [ + f'<< /Type /Catalog /Pages 2 0 R /Duplicate 1 /Duplicate 2 /Probe [' + ' '.join(f'{i+4} 0 R' for i in range(count-1)) + '] >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>'] + + ['<< /Duplicate 1 /Duplicate 2 >>'] * (count-1), + {'scope': 'Context-only cumulative diagnostic limit, starting in initial parse; warning bodies must never reach stdout/stderr.', 'accepted': count == 32})) +long_key = '/' + 'A' * (64*1024) +context_records.append(write('context-warning-bytes.pdf', [ + f'<< /Type /Catalog /Pages 2 0 R {long_key} 1 {long_key} 2 >>', + '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>'], + {'scope': 'Context-only diagnostic byte limit during initial parse; no warning text is retained or emitted.', 'accepted': False})) +(ROOT / 'manifest.json').write_text(json.dumps({'origin': 'Self-created deterministic PDF syntax, Helvetica Standard-14 text and vector paths; no third-party document content.', 'generator': '../generate_headings.py', 'documents': records, 'context_documents': context_records}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') +print(f'Generated {len(records)} heading fixtures in {ROOT}') diff --git a/tests/fixtures/pdf/generate_intent_transparency.py b/tests/fixtures/pdf/generate_intent_transparency.py new file mode 100644 index 0000000..a743e1f --- /dev/null +++ b/tests/fixtures/pdf/generate_intent_transparency.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Self-authored RI probes for luminosity masks and pattern initial state.""" +import argparse +import hashlib +import json +from pathlib import Path +from pypdf import PdfWriter +from pypdf.generic import (ArrayObject, BooleanObject, DecodedStreamObject, + DictionaryObject, FloatObject, NameObject, NumberObject) + +ROOT = Path(__file__).resolve().parents[3] +SOURCE = ROOT / 'tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc' +PROFILE_SHA = 'b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315' +INTENTS = ('Perceptual', 'RelativeColorimetric', 'Saturation', 'AbsoluteColorimetric') +SAMPLE = (0, .75, .25, 0) +WIDTH, HEIGHT = 880, 850 +CASES = ( + 'direct-icc-control', 'mask-caller-ri', 'mask-internal-ri', 'mask-internal-gs', + 'mask-group-qQ', 'mask-q-inner-none', 'mask-Q-restores', 'mask-none', + 'mask-BC-only', 'mask-opaque-over-BC', + 'tiling-page-initial', 'tiling-colored-ri', 'tiling-colored-gs', 'tiling-colored-qQ', + 'tiling-form-initial', 'tiling-form-initial-reuse', 'tiling-form-ri', + 'tiling-uncolored-fill', 'tiling-uncolored-fill-gs', + 'tiling-uncolored-stroke-small', 'tiling-uncolored-stroke-large', + 'shading-page-initial', 'shading-pattern-ri', 'shading-form-initial', + 'shading-form-initial-reuse', 'shading-form-ri', 'direct-shading-control', +) + + +def digest(data): return hashlib.sha256(data).hexdigest() +def n(value): return NameObject('/' + value) +def a(values): return ArrayObject([FloatObject(v) for v in values]) +def d(**values): return DictionaryObject({n(k): v for k, v in values.items()}) +def numbers(values): return ' '.join(format(v, '.10g') for v in values) + + +def generate(output): + output.mkdir(parents=True, exist_ok=False) + profile = SOURCE.read_bytes() + assert digest(profile) == PROFILE_SHA + (output / SOURCE.name).write_bytes(profile) + writer = PdfWriter(); writer.pdf_header = '%PDF-1.7' + writer.add_metadata({'/Title': 'Rendering intent: soft masks and pattern initial state', + '/Author': 'DocView synthetic regression corpus'}) + + def stream(data, **entries): + obj = DecodedStreamObject(); obj.set_data(data.encode() if isinstance(data, str) else data) + obj.update(d(**entries)); return writer._add_object(obj) + + cs = ArrayObject([n('ICCBased'), stream(profile, N=NumberObject(4), Alternate=n('DeviceCMYK'))]) + colors = d(CS=cs, PCS=ArrayObject([n('Pattern'), cs])) + gs = d(**{'I'+str(i): d(Type=n('ExtGState'), RI=n(intent)) for i, intent in enumerate(INTENTS)}) + gs[n('NoMask')] = d(Type=n('ExtGState'), SMask=n('None')) + font = writer._add_object(d(Type=n('Font'), Subtype=n('Type1'), BaseFont=n('Helvetica'))) + base = d(ColorSpace=colors, ExtGState=gs) + paint = '/CS cs ' + numbers(SAMPLE) + ' scn ' + group_attributes = d(S=n('Transparency'), CS=n('DeviceRGB'), I=BooleanObject(True), K=BooleanObject(False)) + + def group(prefix='', empty=False): + return stream(prefix + '\n' + ('' if empty else paint + f'0 0 {WIDTH} {HEIGHT} re f\n'), + Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,WIDTH,HEIGHT)), + Group=group_attributes, Resources=base) + + shared_group = group() + empty_group = group(empty=True) + def mask(name, group_ref, bc=(0,0,0)): + gs[n(name)] = d(Type=n('ExtGState'), SMask=d(S=n('Luminosity'), G=group_ref, + BC=a(bc), TR=n('Identity'))) + mask('Mask', shared_group) + mask('OpaqueBC', shared_group, (0,.5,1)) + backgrounds = ((1,0,0), (0,1,0), (0,0,1), (0,.5,1)) + for i, intent in enumerate(INTENTS): + mask('MaskRi'+str(i), group('/'+intent+' ri')) + mask('MaskGs'+str(i), group('/I'+str(i)+' gs')) + mask('MaskQ'+str(i), group('q /'+intent+' ri Q')) + mask('BC'+str(i), empty_group, backgrounds[i]) + + patterns = d() + def tiling(prefix='', colored=True, size=16): + content = prefix+'\n'+(paint if colored else '')+f'0 0 {size} {size} re f\n' + return stream(content, Type=n('Pattern'), PatternType=NumberObject(1), PaintType=NumberObject(1 if colored else 2), + TilingType=NumberObject(2), BBox=a((0,0,size,size)), XStep=NumberObject(size), YStep=NumberObject(size), + Resources=base if colored else d()) + patterns[n('Tile')] = tiling() + patterns[n('U')] = tiling(colored=False, size=8) + patterns[n('ULarge')] = tiling(colored=False, size=1024) + for i, intent in enumerate(INTENTS): + patterns[n('TileRi'+str(i))] = tiling('/'+intent+' ri') + patterns[n('TileGs'+str(i))] = tiling('/I'+str(i)+' gs') + patterns[n('TileQ'+str(i))] = tiling('q /'+intent+' ri Q') + # Constant ICC axial function eliminates interpolation-position uncertainty. + function = writer._add_object(d(FunctionType=NumberObject(2), Domain=a((0,1)), C0=a(SAMPLE), C1=a(SAMPLE), N=NumberObject(1))) + shading = writer._add_object(d(ShadingType=NumberObject(2), ColorSpace=cs, Coords=a((0,0,WIDTH,0)), + Function=function, Extend=ArrayObject([BooleanObject(True),BooleanObject(True)]))) + patterns[n('Shade')] = d(Type=n('Pattern'), PatternType=NumberObject(2), Shading=shading) + for i, intent in enumerate(INTENTS): + patterns[n('ShadeRi'+str(i))] = d(Type=n('Pattern'), PatternType=NumberObject(2), Shading=shading, + ExtGState=d(RI=n(intent))) + # A shared Form is invoked under distinct initial RI values. Its painting RI + # is always Absolute, which must not replace the pattern's stream-start RI. + form_resources = d(ColorSpace=colors, ExtGState=gs, Pattern=patterns) + xobjects = d() + for label, pattern_name in (('Tile','Tile'),('Shade','Shade')): + xobjects[n(label+'Form')] = stream('/AbsoluteColorimetric ri\n/Pattern cs /'+pattern_name+' scn 0 0 90 42 re f\n', + Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,90,42)), Resources=form_resources) + for i in range(4): + xobjects[n(label+'FormRi'+str(i))] = stream('/AbsoluteColorimetric ri\n/Pattern cs /'+pattern_name+'Ri'+str(i)+' scn 0 0 90 42 re f\n', + Type=n('XObject'), Subtype=n('Form'), BBox=a((0,0,90,42)), Resources=form_resources) + resources = d(ColorSpace=colors, ExtGState=gs, Pattern=patterns, XObject=xobjects, + Shading=d(A=shading), Font=d(F=font)) + probes=[] + for group_index in range((len(CASES)+6)//7): + page=writer.add_blank_page(WIDTH,HEIGHT);page[n('Resources')]=resources + commands=[f'1 g 0 0 {WIDTH} {HEIGHT} re f', + f'0 g BT /F 18 Tf 24 818 Td (Soft masks and pattern state - {group_index+1}) Tj ET', + '0 g BT /F 9 Tf 24 799 Td (Synthetic ICC; expected intent comes from PDF state rules, colors from a direct CMM.) Tj ET'] + for i,intent in enumerate(INTENTS): + commands.append(f'0 g BT /F 9 Tf {320+i*132} 771 Td ({intent}) Tj ET') + for row,case in enumerate(CASES[group_index*7:group_index*7+7]): + y=684-row*96 + commands.append(f'0 g BT /F 10 Tf 24 {y+22} Td ({case}) Tj ET') + for column in range(4): + current=(2,0,2,1)[column] if case.endswith('-reuse') else column + other=(current+1)%4;expected=current;kind='icc';bc=None + x=320+column*132;rect=f'{x} {y} 90 42 re f' + start=['q','/'+INTENTS[current]+' ri'];body=[] + if case=='direct-icc-control': body=[paint+rect] + elif case.startswith('mask-'): + kind='luminosity-icc';mask_name='Mask' + if case=='mask-internal-ri':mask_name='MaskRi'+str(other);expected=other + elif case=='mask-internal-gs':mask_name='MaskGs'+str(other);expected=other + elif case=='mask-group-qQ':mask_name='MaskQ'+str(other) + elif case=='mask-BC-only':mask_name='BC'+str(column);bc=backgrounds[column];kind='luminosity-bc' + elif case=='mask-opaque-over-BC':mask_name='OpaqueBC' + body=['/'+mask_name+' gs'] + if case=='mask-q-inner-none':body+=['q /NoMask gs','0 g '+rect,'Q'];kind='black' + elif case=='mask-Q-restores':body+=['q /NoMask gs Q','0 g '+rect] + elif case=='mask-none':body+=['/NoMask gs','0 g '+rect];kind='black' + else:body+=['0 g '+rect] + elif case.startswith('tiling-uncolored'): + if case.endswith('-gs'):start=['q','/I'+str(current)+' gs'] + if 'stroke' in case: + pattern='ULarge' if case.endswith('-large') else 'U' + body=['1 0 0 rg','/PCS CS '+numbers(SAMPLE)+' /'+pattern+' SCN', + f'30 w {x} {y+21} m {x+90} {y+21} l S'] + else:body=['/PCS cs '+numbers(SAMPLE)+' /U scn '+rect] + elif case.startswith('tiling-') or case.startswith('shading-'): + label='Tile' if case.startswith('tiling-') else 'Shade';pattern=label + if '-form-' in case: + form_name=label+'Form' + if case.endswith('-ri'):form_name+='Ri'+str(other);expected=other + body=[f'1 0 0 1 {x} {y} cm /'+form_name+' Do'] + else: + expected=1 + if case.endswith('-ri'):pattern+='Ri'+str(other);expected=other + elif case.endswith('-gs'):pattern+='Gs'+str(other);expected=other + elif case.endswith('-qQ'):pattern+='Q'+str(other) + body=['/Pattern cs /'+pattern+' scn '+rect] + elif case=='direct-shading-control':body=[f'{x} {y} 90 42 re W n','/A sh'] + else:raise AssertionError(case) + sequence=start+body+['Q'];commands+=sequence + probes.append({'id':case+'-'+str(column),'case':case,'column':column,'page':group_index+1, + 'callerIntentIndex':current,'callerIntent':INTENTS[current], + 'expectedIntentIndex':expected,'expectedIntent':INTENTS[expected], + 'oracle':kind,'components':list(SAMPLE),'backgroundRgb':list(bc) if bc else None, + 'pointPt':[x+45,y+21],'commandSequence':sequence}) + page[n('Contents')]=stream('\n'.join(commands)+'\n') + target=output/'intent-transparency.pdf';writer.write(target) + manifest={'schemaVersion':1,'file':target.name,'sha256':digest(target.read_bytes()), + 'generatorSha256':digest(Path(__file__).read_bytes()),'pageCount':len(writer.pages),'pageSizePt':[WIDTH,HEIGHT], + 'profile':{'file':SOURCE.name,'sha256':PROFILE_SHA,'size':len(profile),'origin':str(SOURCE.relative_to(ROOT))}, + 'sample':list(SAMPLE),'tolerance8Bit':4,'cases':list(CASES),'probes':probes, + 'specification':{'url':'https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf', + 'printedPages':{'uncoloredForbiddenOperators':218,'tilingInitialState':223,'shadingInitialState':231, + 'deviceRgbGray':377,'luminosityMask':440,'softMaskDictionary':446,'transparentPatterns':453}}, + 'luminosityOracle':{'weights':[.30,.59,.11],'foregroundRgb':[0,0,0],'pageBackgroundRgb':[255,255,255], + 'formula':'round(255 - dot(weights, directCmmRgb8))', + 'qualification':'DeviceRGB gray conversion is device-dependent; these are the PDF recommended weights. Intent dispatch is tested separately from that numerical convention.'}} + (output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + (output/'README.md').write_text('''# Soft-mask and pattern rendering-intent probes + +This self-authored 4-page PDF contains 108 declared pixel probes. The existing +984-byte distinct CMYK/Lab ICC profile is copied without alteration. Its four +intent outputs for CMYK (0, .75, .25, 0) have separated luminances; no PDF renderer +supplies the expected colors. + +[Adobe PDF Reference 1.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf) +pp. 218, 223, 231 and 453 define pattern state. Colored tiling and shading patterns +start with their resource-owning page/Form stream's initial state. A Form is +reused under different initial intents, then changes its painting intent to +Absolute. Explicit pattern overrides and q/Q are separate controls. Uncolored +stencils contain no color operators or `ri`; their ICC base color is supplied by +the caller. Small/large uncolored strokes distinguish stroke from fill color. + +Pages 440–446 define luminosity soft masks and their backdrop. The mask's RGB +group contains one opaque ICC rectangle, or no objects for the BC-only controls. +The direct CMM RGB result is reduced with the recommended .30/.59/.11 weights; +black over white has value 255 minus that luminosity. This device-dependent +formula is a declared test convention, not universal device calibration. + +Run `tests/cmyk_lut/transparency.py --self-check --output NEW_DIRECTORY`, or pass +`--build-dir build --pdfium-library PATH/libpdfium.so --output NEW_DIRECTORY`. +The runner records all failures, validates the sandbox helper and library hash, +and judges PDFium and Poppler separately against the direct CMM. LittleCMS may +be shared with the renderers; this is not an independent CMM implementation or +general ICC conformance acceptance. The older 8-page intent fixture is unchanged. +''') + print(json.dumps({'file':str(target),'sha256':manifest['sha256'],'pages':len(writer.pages),'probes':len(probes)})) + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output',type=Path,default=Path(__file__).parent/'intent-transparency') + generate(parser.parse_args().output) diff --git a/tests/fixtures/pdf/generate_link_borders.py b/tests/fixtures/pdf/generate_link_borders.py new file mode 100644 index 0000000..445dc0c --- /dev/null +++ b/tests/fixtures/pdf/generate_link_borders.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Deterministic self-created links; no third-party document or font content.""" +from pathlib import Path +import hashlib +import json + +ROOT = Path(__file__).parent / 'link-borders' +ROOT.mkdir(exist_ok=True) + +def stream(content, extra=''): + content = content.encode('ascii') + return f'<< /Length {len(content)} {extra} >>\nstream\n'.encode() + content + b'\nendstream' + +def write(name, objects): + data = bytearray(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n') + offsets = [0] + for i, value in enumerate(objects, 1): + offsets.append(len(data)) + if isinstance(value, str): value = value.encode('ascii') + data += f'{i} 0 obj\n'.encode() + value + b'\nendobj\n' + xref = len(data) + data += f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode() + for offset in offsets[1:]: data += f'{offset:010d} 00000 n \n'.encode() + data += f'trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n'.encode() + (ROOT / name).write_bytes(data) + return {'file': name, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest()} + +CASES = [ + ('default', ''), + ('solid-rgb', '/BS << /W 3 /S /S >> /C [0.8 0.1 0.2]'), + ('dashed-border', '/Border [0 0 2 [6 3]] /C [0 0.2 0.8]'), + ('dashed-bs-odd', '/BS << /W 2 /S /D /D [7 3 2] >> /C [0.1 0.5 0.2]'), + ('dashed-default', '/BS << /W 2 /S /D >>'), + ('underline', '/BS << /W 3 /S /U >> /C [0.6 0.1 0.8]'), + ('bs-overrides-border', '/Border [9 9 12 [1 1]] /BS << /W 2 /S /S >> /C [0 0.3 0.7]'), + ('zero-width', '/BS << /W 0 >>'), + ('transparent', '/BS << /W 4 >> /C []'), + ('hidden', '/BS << /W 4 >> /F 2'), + ('no-view', '/BS << /W 4 >> /F 32'), + ('print-flag-visible', '/BS << /W 2 >> /F 4'), + ('gray', '/BS << /W 3 >> /C [0.5]'), + ('cmyk', '/BS << /W 3 >> /C [1 0 0 0]'), + ('rounded', '/Border [8 6 2] /C [0.6 0.3 0.1]'), + ('beveled', '/BS << /W 3 /S /B >> /C [0.2 0.6 0.8]'), + ('inset', '/BS << /W 3 /S /I >> /C [0.2 0.6 0.8]'), + ('appearance-preserved', '/BS << /W 8 /S /D >> /C [1 0 0] /AP << /N 7 0 R >>'), + ('empty-appearance-preserved', '/BS << /W 8 >> /AP << /N 8 0 R >>'), + ('alpha', '/BS << /W 3 >> /C [0 0 1] /CA 0.5'), +] + +# All pages share the same annotations, but their CropBox and Rotate differ. +# Rects straddle 512px tile boundaries at the evidence renderer's scale 2. +objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Count 4 /Kids [3 0 R 4 0 R 5 0 R 6 0 R] >>', + '', '', '', '', + stream('0 0.8 0 rg 0 0 100 30 re f', '/Type /XObject /Subtype /Form /BBox [0 0 100 30] /Resources << >>'), + stream('', '/Type /XObject /Subtype /Form /BBox [0 0 100 30] /Resources << >>'), +] +expected = [] +for index, (label, extra) in enumerate(CASES): + x = 40 + (index % 4) * 120 + y = 50 + (index // 4) * 75 + rect = [x, y, x + 100, y + 30] + objects.append(f'<< /Type /Annot /Subtype /Link /Rect [{" ".join(map(str, rect))}] ' + f'/A << /S /URI /URI (https://example.org/{label}) >> {extra} >>') + expected.append({'name': label, 'rect': rect, 'has_border': label not in {'zero-width', 'transparent', 'hidden', 'no-view', 'empty-appearance-preserved'}}) +annots = ' '.join(f'{i + 9} 0 R' for i in range(len(CASES))) +for page, rotation in enumerate([0, 90, 180, 270]): + objects[page + 2] = f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 550 470] /CropBox [20 30 530 450] /Rotate {rotation} /Resources << >> /Annots [{annots}] >>' +records = [write('styles.pdf', objects)] +records[0]['pages'] = 4 +records[0]['annotations'] = expected +records[0]['reference_limitations'] = ['Poppler ignores gray/CMYK border colors, rounded corners, bevel/inset, and alpha in its no-AP link fallback; assert these independently.', 'Saved normal AP must be preserved without adding a second border, even if Poppler adds one.'] + +for name, extra in [('unknown-style', '/BS << /S /Unknown >>'), ('no-zoom', '/F 8'), ('no-rotate', '/F 16'), ('invalid-dash', '/BS << /S /D /D [0 0] >>'), ('oversized-dash', '/BS << /S /D /D [' + '1 ' * 65 + '] >>'), ('invalid-color', '/C [0 1]'), ('invalid-ap', '/AP << /N 42 >>')]: + data = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Count 1 /Kids [3 0 R] >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >>', + f'<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] {extra} >>'] + record = write(name + '.pdf', data) + if name in {'no-zoom', 'no-rotate'}: + record['expected'] = 'Valid annotation flag; rendering succeeds. Geometry coverage is in annotation-flags/flags.pdf.' + else: + record['render_error'] = 'E_PDF_ANNOT_UNSUPPORTED' if name == 'unknown-style' else 'E_LIMIT_EXCEEDED' if name == 'oversized-dash' else 'E_PDF_CORRUPT' + records.append(record) +retry = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>', '', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [5 0 R] >>'] +for index in range(512): + retry.append('<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [' + '1 ' * 64 + '] >> >>') +retry.append('<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /Unknown >> >>') +retry[2] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [' + ' '.join(f'{i} 0 R' for i in range(5, len(retry) + 1)) + '] >>' +record = write('retry-budget.pdf', retry) +record['expected'] = 'Forty failed page-0 decodes must not charge discarded appearances or prevent valid page-1 rendering.' +records.append(record) +(ROOT / 'manifest.json').write_text(json.dumps({'generator': 'generate_link_borders.py', 'license': 'Self-created test content; same license as DocView.', 'fixtures': records}, indent=2) + '\n') diff --git a/tests/fixtures/pdf/generate_rendering_intents.py b/tests/fixtures/pdf/generate_rendering_intents.py new file mode 100644 index 0000000..37e62a2 --- /dev/null +++ b/tests/fixtures/pdf/generate_rendering_intents.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Generate self-authored ICC intent/state/cache probes without renderer goldens.""" +import argparse +import hashlib +import itertools +import json +from pathlib import Path +import struct + +from pypdf import PdfWriter +from pypdf.generic import (ArrayObject, ByteStringObject, DecodedStreamObject, DictionaryObject, + FloatObject, NameObject, NumberObject) + +ROOT = Path(__file__).resolve().parents[3] +BASE = ROOT / 'tests/fixtures/pdf/cmyk-lut' +INTENTS = ('Perceptual', 'RelativeColorimetric', 'Saturation', 'AbsoluteColorimetric') +SAMPLE_BYTES = (51, 128, 204, 77) +SAMPLE = tuple(x/255 for x in SAMPLE_BYTES) +WIDTH, HEIGHT = 820, 800 +CASES = ('default-relative', 'ri-vector', 'extgstate-ri', 'nested-q-inner', 'nested-Q-restore', + 'form-inherit-shared', 'form-inherit-shared-gs', 'form-ri-override', 'form-gs-override', 'form-return-restore', + 'image-inherit-shared-forward', 'image-inherit-shared-reverse', 'image-intent-override', + 'image-inherit-shared-gs', 'image-override-return', 'indexed-vector', 'indexed-image', 'stroke', 'text', 'axial-shading', + 'shading-pattern-inherit', 'shading-pattern-RI-override', + 'type3-inherit-rectangle', 'type3-inherit-image', 'type3-ri-override') + + +def sha(data): return hashlib.sha256(data).hexdigest() +def array(values): return ArrayObject([FloatObject(x) for x in values]) +def name(value): return NameObject('/'+value) +def dictionary(**values): return DictionaryObject({name(k):v for k,v in values.items()}) +def fixed(values): return struct.pack('>'+'i'*len(values), *(round(v*65536) for v in values)) +def operands(values): return ' '.join(f'{v:.10f}' for v in values) + + +def derived_model(tag, values): + c,m,y,k = values + if tag == 'A2B0': return (100-12*c-15*m-10*y-35*k,18*m-15*c,20*y-12*c) + if tag == 'A2B1': return (100-20*c-20*m-20*y-40*k,40*m-20*c-20*k,40*y-20*c-20*k) + if tag == 'A2B2': return (100-25*c-25*m-20*y-30*k,-40*m+20*c+20*k,35*y-20*m-15*k) + raise ValueError(tag) + + +def derived_profile(base): + # Reuse the existing input profile's v2 header, CLUT layout and attribution; + # add explicitly distinct self-authored intent tables and a non-D50 medium. + tags = {} + for i in range(struct.unpack_from('>I',base,128)[0]): + signature,offset,size=struct.unpack_from('>4sII',base,132+12*i) + tags[signature]=base[offset:offset+size] + tags[b'wtpt']=b'XYZ '+bytes(4)+fixed((.82,.86,.72)) + for signature in ('A2B0','A2B1','A2B2'): + lut=bytearray(tags[b'A2B0']) + for i,values in enumerate(itertools.product((0,1),repeat=4)): + lightness,a,b=derived_model(signature,values) + struct.pack_into('>3H',lut,68+6*i,round(lightness/100*65280),round((a+128)*256),round((b+128)*256)) + tags[signature.encode()]=bytes(lut) + description=b'DocView synthetic distinct rendering intent tables\0' + tags[b'desc']=b'desc'+bytes(4)+struct.pack('>I',len(description))+description+bytes(78) + header=bytearray(base[:128]);entries=bytearray();body=bytearray();offset=132+12*len(tags) + for signature,data in sorted(tags.items()): + entries+=signature+struct.pack('>II',offset+len(body),len(data)) + body+=data+bytes((-len(data))%4) + result=header+struct.pack('>I',len(tags))+entries+body + result[:4]=struct.pack('>I',len(result)) + return bytes(result) + + +def generate(output): + output.mkdir(parents=True,exist_ok=False) + base_spec=json.loads((BASE/'manifest.json').read_text()) + base=(BASE/base_spec['profileFile']).read_bytes() + assert sha(base)==base_spec['profileSha256'] + profiles={'original':base,'distinct':derived_profile(base)} + writer=PdfWriter();writer.pdf_header='%PDF-1.7' + writer.add_metadata({'/Title':'Rendering intent: explicit state, inheritance and cache', + '/Author':'DocView synthetic regression corpus'}) + rows=[];profile_records={} + for profile_id,profile in profiles.items(): + filename=profile_id+'-cmyk-lab.icc';(output/filename).write_bytes(profile) + profile_records[profile_id]={'file':filename,'sha256':sha(profile),'size':len(profile), + 'origin':'byte-identical existing synthetic fixture' if profile_id=='original' else 'self-authored extension of the existing CLUT', + 'mediaWhitePoint':[.9642,1,.8249] if profile_id=='original' else [.82,.86,.72], + 'tables':['A2B0'] if profile_id=='original' else ['A2B0','A2B1','A2B2']} + stream=DecodedStreamObject();stream.set_data(profile) + stream.update(dictionary(N=NumberObject(4),Alternate=name('DeviceCMYK'))) + space=ArrayObject([name('ICCBased'),writer._add_object(stream)]) + indexed=ArrayObject([name('Indexed'),space,NumberObject(0),ByteStringObject(bytes(SAMPLE_BYTES))]) + font=writer._add_object(dictionary(Type=name('Font'),Subtype=name('Type1'),BaseFont=name('Helvetica-Bold'))) + gs=dictionary(**{'I'+str(i):dictionary(Type=name('ExtGState'),RI=name(intent)) for i,intent in enumerate(INTENTS)}) + local_resources=dictionary(ColorSpace=dictionary(CS=space,IX=indexed),ExtGState=gs,Font=dictionary(F=font)) + image=DecodedStreamObject();image.set_data(bytes(SAMPLE_BYTES)) + image.update(dictionary(Type=name('XObject'),Subtype=name('Image'),Width=NumberObject(1),Height=NumberObject(1), + BitsPerComponent=NumberObject(8),ColorSpace=space)) + shared_image=writer._add_object(image) + fonts=dictionary(F=font) + glyph_resources=dictionary(ColorSpace=dictionary(CS=space),XObject=dictionary(Shared=shared_image)) + def type3_font(prefix='',image=False): + glyph=DecodedStreamObject() + painting='q 1000 0 0 1000 0 0 cm /Shared Do Q' if image else '/CS cs '+operands(SAMPLE)+' scn 0 0 1000 1000 re f' + glyph.set_data(('1000 0 d0\n'+prefix+'\n'+painting+'\n').encode()) + encoding=dictionary(Type=name('Encoding'),Differences=ArrayObject([NumberObject(65),name('A')])) + return writer._add_object(dictionary(Type=name('Font'),Subtype=name('Type3'),FontBBox=array((0,0,1000,1000)), + FontMatrix=array((.001,0,0,.001,0,0)),CharProcs=dictionary(A=writer._add_object(glyph)), + Encoding=encoding,FirstChar=NumberObject(65),LastChar=NumberObject(65),Widths=array((1000,)),Resources=glyph_resources)) + fonts[name('T3Rect')]=type3_font() + fonts[name('T3Image')]=type3_font(image=True) + for i,intent in enumerate(INTENTS):fonts[name('T3Override'+str(i))]=type3_font('/'+intent+' ri') + iximage=DecodedStreamObject();iximage.set_data(b'\0') + iximage.update(dictionary(Type=name('XObject'),Subtype=name('Image'),Width=NumberObject(1),Height=NumberObject(1), + BitsPerComponent=NumberObject(8),ColorSpace=indexed)) + xobjects=dictionary(Shared=shared_image,Indexed=writer._add_object(iximage)) + def form(prefix): + stream=DecodedStreamObject() + stream.set_data((prefix+'\n/CS cs '+operands(SAMPLE)+' scn 0 0 90 44 re f\n').encode()) + stream.update(dictionary(Type=name('XObject'),Subtype=name('Form'),BBox=array((0,0,90,44)),Resources=local_resources)) + return writer._add_object(stream) + xobjects[name('Inherited')]=form('') + for i,intent in enumerate(INTENTS): + copy=DecodedStreamObject();copy.set_data(bytes(SAMPLE_BYTES));copy.update(image) + copy[name('Intent')]=name(intent) + xobjects[name('Image'+str(i))]=writer._add_object(copy) + xobjects[name('Form'+str(i))]=form('/'+intent+' ri') + xobjects[name('FormGs'+str(i))]=form('/I'+str(i)+' gs') + shading_function=dictionary(FunctionType=NumberObject(2),Domain=array((0,1)), + C0=array((.1,.3,.6,.2)),C1=array((.5,.7,.9,.4)),N=NumberObject(1)) + shading=dictionary(ShadingType=NumberObject(2),ColorSpace=space,Coords=array((0,0,90,0)), + Function=writer._add_object(shading_function)) + from pypdf.generic import BooleanObject + shading[name('Extend')]=ArrayObject([BooleanObject(False),BooleanObject(False)]) + wide_shading=DictionaryObject(shading);wide_shading[name('Coords')]=array((0,0,WIDTH,0)) + wide_ref=writer._add_object(wide_shading) + patterns=dictionary(Inherited=dictionary(Type=name('Pattern'),PatternType=NumberObject(2),Shading=wide_ref)) + for i,intent in enumerate(INTENTS): + patterns[name('P'+str(i))]=dictionary(Type=name('Pattern'),PatternType=NumberObject(2),Shading=wide_ref, + ExtGState=dictionary(RI=name(intent))) + resources=dictionary(ColorSpace=dictionary(CS=space,IX=indexed),ExtGState=gs,Font=fonts, + XObject=xobjects,Shading=dictionary(A=writer._add_object(shading)),Pattern=patterns) + for group in range((len(CASES)+7)//8): + page=writer.add_blank_page(WIDTH,HEIGHT);page[name('Resources')]=resources + commands=['0 g BT /F 17 Tf 28 762 Td (ICC rendering intent - '+profile_id+f' - {group+1}/{(len(CASES)+7)//8}) Tj ET'] + commands+=['0 g BT /F 8 Tf 28 742 Td (Synthetic input profile; reference colors are computed independently by CMM, not copied from a renderer.) Tj ET'] + for column,intent in enumerate(INTENTS): + commands.append(f'0 g BT /F 10 Tf {310+column*125} 716 Td ({intent}) Tj ET') + for local_row,case in enumerate(CASES[group*8:group*8+8]): + y=625-local_row*80 + commands.append(f'0 g BT /F 10 Tf 28 {y+22} Td ({case}) Tj ET') + for column in range(4): + intent_index=3-column if case=='image-inherit-shared-reverse' else column + if case=='image-inherit-shared-gs': intent_index=(0,1,0,3)[column] + intent=INTENTS[intent_index];other=(intent_index+1)%4 + x=310+column*125 + start=['q'];body=[];expected=intent_index;point=(x+45,y+22);components=SAMPLE + if case=='default-relative': + # Start each cell at the page's untouched default. Other + # commands are isolated by q/Q, including all labels. + expected=1 + elif case in ('extgstate-ri','image-inherit-shared-gs','form-inherit-shared-gs'): start+=['/I'+str(intent_index)+' gs'] + else: start+=['/'+intent+' ri'] + paint=f'/CS cs {operands(SAMPLE)} scn {x} {y} 90 44 re f' + if case in ('default-relative','ri-vector','extgstate-ri'): body=[paint] + elif case=='nested-q-inner': body=['q /'+INTENTS[other]+' ri',paint,'Q'];expected=other + elif case=='nested-Q-restore': body=['q /'+INTENTS[other]+' ri Q',paint] + elif case.startswith('form-'): + form_name='Inherited' + if case=='form-ri-override': form_name='Form'+str(other);expected=other + if case=='form-gs-override': form_name='FormGs'+str(other);expected=other + if case=='form-return-restore': + # Invoke the override at the origin, then + # paint with the caller's restored graphics state. + body=[f'/Form{other} Do',paint] + else: body=[f'q 1 0 0 1 {x} {y} cm /{form_name} Do Q'] + elif case.startswith('image-') or case=='indexed-image': + image_name='Indexed' if case=='indexed-image' else 'Shared' + if case=='image-intent-override': image_name='Image'+str(other);expected=other + if case=='image-override-return': body=[f'/Image{other} Do',paint] + else: body=[f'q 90 0 0 44 {x} {y} cm /{image_name} Do Q'] + elif case=='indexed-vector': body=[f'/IX cs 0 scn {x} {y} 90 44 re f'] + elif case=='stroke': body=[f'/CS CS {operands(SAMPLE)} SCN 24 w {x} {y+22} m {x+90} {y+22} l S'] + elif case=='text': + body=[f'/CS cs {operands(SAMPLE)} scn BT /F 64 Tf 1 0 0 1 {x} {y} Tm (H) Tj ET'] + point=(x+8,y+29) + elif case=='axial-shading': + body=[f'q {x} {y} 90 44 re W n 1 0 0 1 {x} {y} cm /A sh Q'] + components=(.3,.5,.75,.3) + elif case.startswith('shading-pattern-'): + pattern='Inherited' + if case=='shading-pattern-RI-override':pattern='P'+str(other);expected=other + body=[f'/Pattern cs /{pattern} scn {x} {y} 90 44 re f'] + t=(x+45)/WIDTH;components=tuple(a+(b-a)*t for a,b in zip((.1,.3,.6,.2),(.5,.7,.9,.4))) + elif case.startswith('type3-'): + glyph_font='T3Image' if case=='type3-inherit-image' else 'T3Rect' + if case=='type3-ri-override':glyph_font='T3Override'+str(other);expected=other + body=[f'BT /{glyph_font} 44 Tf 1 0 0 1 {x} {y} Tm (A) Tj ET'] + point=(x+22,y+22) + commands+=start+body+['Q'] + rows.append({'page':len(writer.pages),'profile':profile_id,'case':case,'column':column, + 'graphicsStateIntent':INTENTS[intent_index] if case!='default-relative' else 'default', + 'expectedIntent':INTENTS[expected],'expectedIntentIndex':expected,'pointPt':list(point), + 'components':list(components),'commandSequence':start+body+['Q'], + **({'shading':{'xStart':x if case=='axial-shading' else 0,'xEnd':x+90 if case=='axial-shading' else WIDTH, + 'c0':[.1,.3,.6,.2],'c1':[.5,.7,.9,.4]}} + if case=='axial-shading' or case.startswith('shading-pattern-') else {})}) + content=DecodedStreamObject();content.set_data(('\n'.join(commands)+'\n').encode()) + page[name('Contents')]=writer._add_object(content) + pdf=output/'rendering-intents.pdf' + with pdf.open('wb') as target:writer.write(target) + manifest={'schemaVersion':1,'file':pdf.name,'sha256':sha(pdf.read_bytes()),'pageSizePt':[WIDTH,HEIGHT], + 'pageCount':len(writer.pages),'profiles':profile_records,'baseProfileSha256':sha(base),'sampleBytes':list(SAMPLE_BYTES), + 'intentOrder':list(INTENTS),'cases':list(CASES),'probes':rows,'tolerance8Bit':4, + 'generatorSha256':sha(Path(__file__).read_bytes()), + 'derivedProfileModels':{'A2B0':['100-12*C-15*M-10*Y-35*K','18*M-15*C','20*Y-12*C'], + 'A2B1':['100-20*C-20*M-20*Y-40*K','40*M-20*C-20*K','40*Y-20*C-20*K'], + 'A2B2':['100-25*C-25*M-20*Y-30*K','-40*M+20*C+20*K','35*Y-20*M-15*K']}, + 'references':['https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf', + 'https://archive.color.org/files/icc32.pdf','https://www.littlecms.com/'], + 'limits':['No measured press profile, human-approved golden, display calibration or broad ICC conformance claim.', + 'Independent direct-CMM oracle can share LittleCMS code with PDF renderers; not a separate CMM implementation.', + 'Text uses an interior point of the standard Helvetica-Bold H; axial samples account for raster pixel centers.']} + (output/'manifest.json').write_text(json.dumps(manifest,indent=2)+'\n') + print(json.dumps({'fileSha256':manifest['sha256'],'pages':len(writer.pages),'probes':len(rows)})) + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--output',required=True,type=Path) + generate(parser.parse_args().output) diff --git a/tests/fixtures/pdf/headings/README.md b/tests/fixtures/pdf/headings/README.md new file mode 100644 index 0000000..640113b --- /dev/null +++ b/tests/fixtures/pdf/headings/README.md @@ -0,0 +1,68 @@ +# Tagged-PDF boundary corpus + +The 26 tagged PDFs and 4 context-only PDFs are self-authored. They contain +Standard-14 Helvetica text and vector paths, with no third-party document content. +Regenerate the deterministic files and hashes with: + +```sh +python3 tests/fixtures/pdf/generate_headings.py +``` + +`manifest.json` records SHA-256, byte size, authored expectations and PDF-point +regions `[left, bottom, right, top]`. Regions follow the source placements, +font sizes and transforms; they are not copied from renderer output. None of the +tagged files has bookmarks, so an outline cannot conceal missing tag extraction. + +`test_pdf::taggedBoundaryCorpus` checks every tagged file's hash, page count, +title, level, structure order, destination precision, fallback reason and bbox. +Exact targets must map inside the page and round-trip within 0.5 pt at scale 2 +for user rotations 0, 90, 180 and 270 degrees. The PDFium drawing engine still +provides character boxes; a shared worker-only qpdf context now supplies the +source-qualified logical structure. + +| Files | Authored condition and expected behavior | +| --- | --- | +| `transformed-form.pdf` | Page wrapper MCID plus Form-owned MCR. Exact heading in `[70,102,240,130]`. | +| `rotated-tags.pdf` | Rotate 90 and offset CropBox. H3 is drawn first but H1 precedes it in structure order; exact targets follow structure order. | +| `cross-page-structure.pdf` | One H1 has interleaved nested Span and direct MCR children on two pages. Page-local fragments preserve K order without mixing reused MCID 0. | +| `vector-heading.pdf` | H4 vector with ActualText but no glyph bbox. Title retained with page precision and a coordinate reason. | +| `form-only-structure.pdf` | Only the Form has StructParents. One exact H2, `Form only heading`, in `[50,125,190,147]`. This is now a positive extraction case. | +| `form-vector-mcid-collision.pdf` | Page vector heading and unrelated Form text both use MCID 0. Keep the heading title at page precision; never borrow Form paragraph bounds. | +| `named-property-rolemap.pdf` | Form BDC references a named property and a custom RoleMap name. Extract actual text without a supplied title. | +| `nested-form-matrix.pdf` | Nested Forms with a nonidentity Form Matrix and independent caller CTMs. Exact target uses PDFium page-space character boxes once. | +| `different-form-same-mcid.pdf` | Two uniquely positioned Forms reuse MCID 0, with reverse structure order. Keep separate exact headings in that order. | +| `ambiguous-form-matrix.pdf` | Different Forms share matrix and MCID signature. Use titles and explicit page precision instead of relying on enumeration order. | +| `repeated-form.pdf` | One source Form is invoked twice. Preserve the heading at page precision with a repeated-Form reason. | +| `unused-form-resource.pdf` | Uninvoked Form remains in Resources. It must not create an extra heading. | +| `inline-image-before-form.pdf` | Inline image precedes a used Form. Skip image pixels and retain the exact heading. | +| `cyclic-form.pdf`, `cyclic-rolemap.pdf`, `cyclic-parent.pdf`, `cyclic-children.pdf`, `cyclic-number-tree.pdf` | Explicit structure limitation, no fabricated destination. | +| `broken-mcr-page.pdf`, `broken-mcr-stream.pdf`, `annotation-owned-mcr.pdf`, `broken-graphics-stack.pdf`, `conflicting-parent-tree-owner.pdf` | Invalid or unsupported ownership/graphics relation. Explicit limitation, no borrowed exact text or position. | +| `stream-quota-at-limit.pdf`, `stream-quota-over-limit.pdf` | Compressed operator data expands to exactly 16 MiB or one byte more. The first succeeds; the second is limited, truncated and incomplete. | +| `bounded-heading-response.pdf` | 70 vector headings have long Japanese ActualText. Return a nonempty validated prefix within 512 KiB CBOR and mark the response incomplete. | + +Context-only cases exercise the shared parser separately from PDFium: + +- `context-deep-tree.pdf`: a depth failure must remain sticky on retries; the + valid later sibling must never become page zero. +- `context-warnings-32.pdf` and `context-warnings-33.pdf`: one diagnostic occurs + during initial parse and the remainder during lazy object reads. The 32-line + boundary succeeds; crossing it remains limited after a new operation begins. +- `context-warning-bytes.pdf`: one initial diagnostic exceeds 64 KiB. Opening + stops without emitting its contents. + +The existing `../missing-first-page.pdf` separately checks that qpdf does not +repair `[null, page]` into a shortened page list before validation. This caught +the qpdf 12.4.1 `setMaxWarnings` startup side effect; diagnostics now use a bounded +private sink instead. + +The production worker suite uses the real sandbox, IPC and font broker for +Form-only/nested exact destinations, ambiguous/repeated page fallbacks, quota, +partial response and cancellation. A temporary encrypted version checks wrong +password rejection, the same borrowed QFile and unchanged source bytes. Worker +cases compare original SHA-256 before and after. See the [recorded Linux +results](../../../results/pdf-structure/README.md). + +Annotation-owned OBJR/StmOwn structures remain an explicit limitation. Repeated +or ambiguous Form instances deliberately retain page precision. These synthetic +cases do not establish native Windows behavior or general conformance for every +producer's tagged PDFs. diff --git a/tests/fixtures/pdf/headings/ambiguous-form-matrix.pdf b/tests/fixtures/pdf/headings/ambiguous-form-matrix.pdf new file mode 100644 index 0000000..0e090a4 --- /dev/null +++ b/tests/fixtures/pdf/headings/ambiguous-form-matrix.pdf @@ -0,0 +1,63 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R /Fm1 8 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 37 >> +stream +q 1 0 0 1 40 100 cm /Fm0 Do /Fm1 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [9 0 R 10 0 R] /ParentTree << /Kids [11 0 R] >> >> +endobj +7 0 obj +<< /Length 73 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Do not borrow first) Tj ET EMC +endstream +endobj +8 0 obj +<< /Length 74 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 1 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Do not borrow second) Tj ET EMC +endstream +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /T (First structure) /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /T (Second structure) /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +11 0 obj +<< /Limits [0 1] /Nums [0 [9 0 R] 1 [10 0 R]] >> +endobj +xref +0 12 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000313 00000 n +0000000401 00000 n +0000000471 00000 n +0000000566 00000 n +0000000880 00000 n +0000001195 00000 n +0000001330 00000 n +0000001467 00000 n +trailer +<< /Size 12 /Root 1 0 R >> +startxref +1532 +%%EOF diff --git a/tests/fixtures/pdf/headings/annotation-owned-mcr.pdf b/tests/fixtures/pdf/headings/annotation-owned-mcr.pdf new file mode 100644 index 0000000..5f4f8ef --- /dev/null +++ b/tests/fixtures/pdf/headings/annotation-owned-mcr.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 70 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Annotation-owned) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /StmOwn 3 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000816 00000 n +0000000945 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +998 +%%EOF diff --git a/tests/fixtures/pdf/headings/bounded-heading-response.pdf b/tests/fixtures/pdf/headings/bounded-heading-response.pdf new file mode 100644 index 0000000..0ca15dd --- /dev/null +++ b/tests/fixtures/pdf/headings/bounded-heading-response.pdf @@ -0,0 +1,382 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /StructParents 0 /Resources << >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 2901 >> +stream +/H2 <> BDC 10 0 20 2 re f EMC +/H2 <> BDC 10 4 20 2 re f EMC +/H2 <> BDC 10 8 20 2 re f EMC +/H2 <> BDC 10 12 20 2 re f EMC +/H2 <> BDC 10 16 20 2 re f EMC +/H2 <> BDC 10 20 20 2 re f EMC +/H2 <> BDC 10 24 20 2 re f EMC +/H2 <> BDC 10 28 20 2 re f EMC +/H2 <> BDC 10 32 20 2 re f EMC +/H2 <> BDC 10 36 20 2 re f EMC +/H2 <> BDC 10 40 20 2 re f EMC +/H2 <> BDC 10 44 20 2 re f EMC +/H2 <> BDC 10 48 20 2 re f EMC +/H2 <> BDC 10 52 20 2 re f EMC +/H2 <> BDC 10 56 20 2 re f EMC +/H2 <> BDC 10 60 20 2 re f EMC +/H2 <> BDC 10 64 20 2 re f EMC +/H2 <> BDC 10 68 20 2 re f EMC +/H2 <> BDC 10 72 20 2 re f EMC +/H2 <> BDC 10 76 20 2 re f EMC +/H2 <> BDC 10 80 20 2 re f EMC +/H2 <> BDC 10 84 20 2 re f EMC +/H2 <> BDC 10 88 20 2 re f EMC +/H2 <> BDC 10 92 20 2 re f EMC +/H2 <> BDC 10 96 20 2 re f EMC +/H2 <> BDC 10 100 20 2 re f EMC +/H2 <> BDC 10 104 20 2 re f EMC +/H2 <> BDC 10 108 20 2 re f EMC +/H2 <> BDC 10 112 20 2 re f EMC +/H2 <> BDC 10 116 20 2 re f EMC +/H2 <> BDC 10 120 20 2 re f EMC +/H2 <> BDC 10 124 20 2 re f EMC +/H2 <> BDC 10 128 20 2 re f EMC +/H2 <> BDC 10 132 20 2 re f EMC +/H2 <> BDC 10 136 20 2 re f EMC +/H2 <> BDC 10 140 20 2 re f EMC +/H2 <> BDC 10 144 20 2 re f EMC +/H2 <> BDC 10 148 20 2 re f EMC +/H2 <> BDC 10 152 20 2 re f EMC +/H2 <> BDC 10 156 20 2 re f EMC +/H2 <> BDC 10 160 20 2 re f EMC +/H2 <> BDC 10 164 20 2 re f EMC +/H2 <> BDC 10 168 20 2 re f EMC +/H2 <> BDC 10 172 20 2 re f EMC +/H2 <> BDC 10 176 20 2 re f EMC +/H2 <> BDC 10 180 20 2 re f EMC +/H2 <> BDC 10 184 20 2 re f EMC +/H2 <> BDC 10 188 20 2 re f EMC +/H2 <> BDC 10 192 20 2 re f EMC +/H2 <> BDC 10 196 20 2 re f EMC +/H2 <> BDC 10 200 20 2 re f EMC +/H2 <> BDC 10 204 20 2 re f EMC +/H2 <> BDC 10 208 20 2 re f EMC +/H2 <> BDC 10 212 20 2 re f EMC +/H2 <> BDC 10 216 20 2 re f EMC +/H2 <> BDC 10 220 20 2 re f EMC +/H2 <> BDC 10 224 20 2 re f EMC +/H2 <> BDC 10 228 20 2 re f EMC +/H2 <> BDC 10 232 20 2 re f EMC +/H2 <> BDC 10 236 20 2 re f EMC +/H2 <> BDC 10 240 20 2 re f EMC +/H2 <> BDC 10 244 20 2 re f EMC +/H2 <> BDC 10 248 20 2 re f EMC +/H2 <> BDC 10 252 20 2 re f EMC +/H2 <> BDC 10 256 20 2 re f EMC +/H2 <> BDC 10 260 20 2 re f EMC +/H2 <> BDC 10 264 20 2 re f EMC +/H2 <> BDC 10 268 20 2 re f EMC +/H2 <> BDC 10 272 20 2 re f EMC +/H2 <> BDC 10 276 20 2 re f EMC +endstream +endobj +5 0 obj +<< /Type /StructTreeRoot /K [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R 42 0 R 43 0 R 44 0 R 45 0 R 46 0 R 47 0 R 48 0 R 49 0 R 50 0 R 51 0 R 52 0 R 53 0 R 54 0 R 55 0 R 56 0 R 57 0 R 58 0 R 59 0 R 60 0 R 61 0 R 62 0 R 63 0 R 64 0 R 65 0 R 66 0 R 67 0 R 68 0 R 69 0 R 70 0 R 71 0 R 72 0 R 73 0 R 74 0 R 75 0 R] /ParentTree << /Nums [0 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R 42 0 R 43 0 R 44 0 R 45 0 R 46 0 R 47 0 R 48 0 R 49 0 R 50 0 R 51 0 R 52 0 R 53 0 R 54 0 R 55 0 R 56 0 R 57 0 R 58 0 R 59 0 R 60 0 R 61 0 R 62 0 R 63 0 R 64 0 R 65 0 R 66 0 R 67 0 R 68 0 R 69 0 R 70 0 R 71 0 R 72 0 R 73 0 R 74 0 R 75 0 R]] >> >> +endobj +6 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 0 /ActualText >> +endobj +7 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 1 /ActualText >> +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 2 /ActualText >> +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 3 /ActualText >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 4 /ActualText >> +endobj +11 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 5 /ActualText >> +endobj +12 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 6 /ActualText >> +endobj +13 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 7 /ActualText >> +endobj +14 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 8 /ActualText >> +endobj +15 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 9 /ActualText >> +endobj +16 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 10 /ActualText >> +endobj +17 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 11 /ActualText >> +endobj +18 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 12 /ActualText >> +endobj +19 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 13 /ActualText >> +endobj +20 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 14 /ActualText >> +endobj +21 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 15 /ActualText >> +endobj +22 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 16 /ActualText >> +endobj +23 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 17 /ActualText >> +endobj +24 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 18 /ActualText >> +endobj +25 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 19 /ActualText >> +endobj +26 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 20 /ActualText >> +endobj +27 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 21 /ActualText >> +endobj +28 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 22 /ActualText >> +endobj +29 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 23 /ActualText >> +endobj +30 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 24 /ActualText >> +endobj +31 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 25 /ActualText >> +endobj +32 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 26 /ActualText >> +endobj +33 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 27 /ActualText >> +endobj +34 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 28 /ActualText >> +endobj +35 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 29 /ActualText >> +endobj +36 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 30 /ActualText >> +endobj +37 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 31 /ActualText >> +endobj +38 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 32 /ActualText >> +endobj +39 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 33 /ActualText >> +endobj +40 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 34 /ActualText >> +endobj +41 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 35 /ActualText >> +endobj +42 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 36 /ActualText >> +endobj +43 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 37 /ActualText >> +endobj +44 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 38 /ActualText >> +endobj +45 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 39 /ActualText >> +endobj +46 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 40 /ActualText >> +endobj +47 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 41 /ActualText >> +endobj +48 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 42 /ActualText >> +endobj +49 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 43 /ActualText >> +endobj +50 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 44 /ActualText >> +endobj +51 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 45 /ActualText >> +endobj +52 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 46 /ActualText >> +endobj +53 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 47 /ActualText >> +endobj +54 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 48 /ActualText >> +endobj +55 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 49 /ActualText >> +endobj +56 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 50 /ActualText >> +endobj +57 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 51 /ActualText >> +endobj +58 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 52 /ActualText >> +endobj +59 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 53 /ActualText >> +endobj +60 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 54 /ActualText >> +endobj +61 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 55 /ActualText >> +endobj +62 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 56 /ActualText >> +endobj +63 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 57 /ActualText >> +endobj +64 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 58 /ActualText >> +endobj +65 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 59 /ActualText >> +endobj +66 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 60 /ActualText >> +endobj +67 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 61 /ActualText >> +endobj +68 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 62 /ActualText >> +endobj +69 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 63 /ActualText >> +endobj +70 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 64 /ActualText >> +endobj +71 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 65 /ActualText >> +endobj +72 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 66 /ActualText >> +endobj +73 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 67 /ActualText >> +endobj +74 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 68 /ActualText >> +endobj +75 0 obj +<< /Type /StructElem /S /H2 /P 5 0 R /Pg 3 0 R /K 69 /ActualText >> +endobj +xref +0 76 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000293 00000 n +0000003247 00000 n +0000004297 00000 n +0000020746 00000 n +0000037195 00000 n +0000053644 00000 n +0000070093 00000 n +0000086543 00000 n +0000102993 00000 n +0000119443 00000 n +0000135893 00000 n +0000152343 00000 n +0000168793 00000 n +0000185244 00000 n +0000201695 00000 n +0000218146 00000 n +0000234597 00000 n +0000251048 00000 n +0000267499 00000 n +0000283950 00000 n +0000300401 00000 n +0000316852 00000 n +0000333303 00000 n +0000349754 00000 n +0000366205 00000 n +0000382656 00000 n +0000399107 00000 n +0000415558 00000 n +0000432009 00000 n +0000448460 00000 n +0000464911 00000 n +0000481362 00000 n +0000497813 00000 n +0000514264 00000 n +0000530715 00000 n +0000547166 00000 n +0000563617 00000 n +0000580068 00000 n +0000596519 00000 n +0000612970 00000 n +0000629421 00000 n +0000645872 00000 n +0000662323 00000 n +0000678774 00000 n +0000695225 00000 n +0000711676 00000 n +0000728127 00000 n +0000744578 00000 n +0000761029 00000 n +0000777480 00000 n +0000793931 00000 n +0000810382 00000 n +0000826833 00000 n +0000843284 00000 n +0000859735 00000 n +0000876186 00000 n +0000892637 00000 n +0000909088 00000 n +0000925539 00000 n +0000941990 00000 n +0000958441 00000 n +0000974892 00000 n +0000991343 00000 n +0001007794 00000 n +0001024245 00000 n +0001040696 00000 n +0001057147 00000 n +0001073598 00000 n +0001090049 00000 n +0001106500 00000 n +0001122951 00000 n +0001139402 00000 n +trailer +<< /Size 76 /Root 1 0 R >> +startxref +1155853 +%%EOF diff --git a/tests/fixtures/pdf/headings/broken-graphics-stack.pdf b/tests/fixtures/pdf/headings/broken-graphics-stack.pdf new file mode 100644 index 0000000..d2a4d65 --- /dev/null +++ b/tests/fixtures/pdf/headings/broken-graphics-stack.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 9 >> +stream +Q /Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 68 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Graphics stack) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000361 00000 n +0000000431 00000 n +0000000518 00000 n +0000000816 00000 n +0000000931 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +984 +%%EOF diff --git a/tests/fixtures/pdf/headings/broken-mcr-page.pdf b/tests/fixtures/pdf/headings/broken-mcr-page.pdf new file mode 100644 index 0000000..3f7feaa --- /dev/null +++ b/tests/fixtures/pdf/headings/broken-mcr-page.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 64 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Wrong page) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 7 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000810 00000 n +0000000925 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +978 +%%EOF diff --git a/tests/fixtures/pdf/headings/broken-mcr-stream.pdf b/tests/fixtures/pdf/headings/broken-mcr-stream.pdf new file mode 100644 index 0000000..d3a844f --- /dev/null +++ b/tests/fixtures/pdf/headings/broken-mcr-stream.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 66 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Wrong stream) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 3 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000812 00000 n +0000000927 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +980 +%%EOF diff --git a/tests/fixtures/pdf/headings/conflicting-parent-tree-owner.pdf b/tests/fixtures/pdf/headings/conflicting-parent-tree-owner.pdf new file mode 100644 index 0000000..fb11507 --- /dev/null +++ b/tests/fixtures/pdf/headings/conflicting-parent-tree-owner.pdf @@ -0,0 +1,63 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R /Fm1 8 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 58 >> +stream +q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm1 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [9 0 R 10 0 R] /ParentTree << /Kids [11 0 R] >> >> +endobj +7 0 obj +<< /Length 65 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (First owner) Tj ET EMC +endstream +endobj +8 0 obj +<< /Length 66 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 1 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Second owner) Tj ET EMC +endstream +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +11 0 obj +<< /Limits [0 1] /Nums [0 [9 0 R] 1 [10 0 R]] >> +endobj +xref +0 12 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000313 00000 n +0000000422 00000 n +0000000492 00000 n +0000000587 00000 n +0000000893 00000 n +0000001200 00000 n +0000001315 00000 n +0000001431 00000 n +trailer +<< /Size 12 /Root 1 0 R >> +startxref +1496 +%%EOF diff --git a/tests/fixtures/pdf/headings/context-deep-tree.pdf b/tests/fixtures/pdf/headings/context-deep-tree.pdf new file mode 100644 index 0000000..196c8b6 --- /dev/null +++ b/tests/fixtures/pdf/headings/context-deep-tree.pdf @@ -0,0 +1,290 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 2 /Kids [3 0 R 70 0 R] >> +endobj +3 0 obj +<< /Type /Pages /Parent 2 0 R /Count 1 /Kids [4 0 R] >> +endobj +4 0 obj +<< /Type /Pages /Parent 3 0 R /Count 1 /Kids [5 0 R] >> +endobj +5 0 obj +<< /Type /Pages /Parent 4 0 R /Count 1 /Kids [6 0 R] >> +endobj +6 0 obj +<< /Type /Pages /Parent 5 0 R /Count 1 /Kids [7 0 R] >> +endobj +7 0 obj +<< /Type /Pages /Parent 6 0 R /Count 1 /Kids [8 0 R] >> +endobj +8 0 obj +<< /Type /Pages /Parent 7 0 R /Count 1 /Kids [9 0 R] >> +endobj +9 0 obj +<< /Type /Pages /Parent 8 0 R /Count 1 /Kids [10 0 R] >> +endobj +10 0 obj +<< /Type /Pages /Parent 9 0 R /Count 1 /Kids [11 0 R] >> +endobj +11 0 obj +<< /Type /Pages /Parent 10 0 R /Count 1 /Kids [12 0 R] >> +endobj +12 0 obj +<< /Type /Pages /Parent 11 0 R /Count 1 /Kids [13 0 R] >> +endobj +13 0 obj +<< /Type /Pages /Parent 12 0 R /Count 1 /Kids [14 0 R] >> +endobj +14 0 obj +<< /Type /Pages /Parent 13 0 R /Count 1 /Kids [15 0 R] >> +endobj +15 0 obj +<< /Type /Pages /Parent 14 0 R /Count 1 /Kids [16 0 R] >> +endobj +16 0 obj +<< /Type /Pages /Parent 15 0 R /Count 1 /Kids [17 0 R] >> +endobj +17 0 obj +<< /Type /Pages /Parent 16 0 R /Count 1 /Kids [18 0 R] >> +endobj +18 0 obj +<< /Type /Pages /Parent 17 0 R /Count 1 /Kids [19 0 R] >> +endobj +19 0 obj +<< /Type /Pages /Parent 18 0 R /Count 1 /Kids [20 0 R] >> +endobj +20 0 obj +<< /Type /Pages /Parent 19 0 R /Count 1 /Kids [21 0 R] >> +endobj +21 0 obj +<< /Type /Pages /Parent 20 0 R /Count 1 /Kids [22 0 R] >> +endobj +22 0 obj +<< /Type /Pages /Parent 21 0 R /Count 1 /Kids [23 0 R] >> +endobj +23 0 obj +<< /Type /Pages /Parent 22 0 R /Count 1 /Kids [24 0 R] >> +endobj +24 0 obj +<< /Type /Pages /Parent 23 0 R /Count 1 /Kids [25 0 R] >> +endobj +25 0 obj +<< /Type /Pages /Parent 24 0 R /Count 1 /Kids [26 0 R] >> +endobj +26 0 obj +<< /Type /Pages /Parent 25 0 R /Count 1 /Kids [27 0 R] >> +endobj +27 0 obj +<< /Type /Pages /Parent 26 0 R /Count 1 /Kids [28 0 R] >> +endobj +28 0 obj +<< /Type /Pages /Parent 27 0 R /Count 1 /Kids [29 0 R] >> +endobj +29 0 obj +<< /Type /Pages /Parent 28 0 R /Count 1 /Kids [30 0 R] >> +endobj +30 0 obj +<< /Type /Pages /Parent 29 0 R /Count 1 /Kids [31 0 R] >> +endobj +31 0 obj +<< /Type /Pages /Parent 30 0 R /Count 1 /Kids [32 0 R] >> +endobj +32 0 obj +<< /Type /Pages /Parent 31 0 R /Count 1 /Kids [33 0 R] >> +endobj +33 0 obj +<< /Type /Pages /Parent 32 0 R /Count 1 /Kids [34 0 R] >> +endobj +34 0 obj +<< /Type /Pages /Parent 33 0 R /Count 1 /Kids [35 0 R] >> +endobj +35 0 obj +<< /Type /Pages /Parent 34 0 R /Count 1 /Kids [36 0 R] >> +endobj +36 0 obj +<< /Type /Pages /Parent 35 0 R /Count 1 /Kids [37 0 R] >> +endobj +37 0 obj +<< /Type /Pages /Parent 36 0 R /Count 1 /Kids [38 0 R] >> +endobj +38 0 obj +<< /Type /Pages /Parent 37 0 R /Count 1 /Kids [39 0 R] >> +endobj +39 0 obj +<< /Type /Pages /Parent 38 0 R /Count 1 /Kids [40 0 R] >> +endobj +40 0 obj +<< /Type /Pages /Parent 39 0 R /Count 1 /Kids [41 0 R] >> +endobj +41 0 obj +<< /Type /Pages /Parent 40 0 R /Count 1 /Kids [42 0 R] >> +endobj +42 0 obj +<< /Type /Pages /Parent 41 0 R /Count 1 /Kids [43 0 R] >> +endobj +43 0 obj +<< /Type /Pages /Parent 42 0 R /Count 1 /Kids [44 0 R] >> +endobj +44 0 obj +<< /Type /Pages /Parent 43 0 R /Count 1 /Kids [45 0 R] >> +endobj +45 0 obj +<< /Type /Pages /Parent 44 0 R /Count 1 /Kids [46 0 R] >> +endobj +46 0 obj +<< /Type /Pages /Parent 45 0 R /Count 1 /Kids [47 0 R] >> +endobj +47 0 obj +<< /Type /Pages /Parent 46 0 R /Count 1 /Kids [48 0 R] >> +endobj +48 0 obj +<< /Type /Pages /Parent 47 0 R /Count 1 /Kids [49 0 R] >> +endobj +49 0 obj +<< /Type /Pages /Parent 48 0 R /Count 1 /Kids [50 0 R] >> +endobj +50 0 obj +<< /Type /Pages /Parent 49 0 R /Count 1 /Kids [51 0 R] >> +endobj +51 0 obj +<< /Type /Pages /Parent 50 0 R /Count 1 /Kids [52 0 R] >> +endobj +52 0 obj +<< /Type /Pages /Parent 51 0 R /Count 1 /Kids [53 0 R] >> +endobj +53 0 obj +<< /Type /Pages /Parent 52 0 R /Count 1 /Kids [54 0 R] >> +endobj +54 0 obj +<< /Type /Pages /Parent 53 0 R /Count 1 /Kids [55 0 R] >> +endobj +55 0 obj +<< /Type /Pages /Parent 54 0 R /Count 1 /Kids [56 0 R] >> +endobj +56 0 obj +<< /Type /Pages /Parent 55 0 R /Count 1 /Kids [57 0 R] >> +endobj +57 0 obj +<< /Type /Pages /Parent 56 0 R /Count 1 /Kids [58 0 R] >> +endobj +58 0 obj +<< /Type /Pages /Parent 57 0 R /Count 1 /Kids [59 0 R] >> +endobj +59 0 obj +<< /Type /Pages /Parent 58 0 R /Count 1 /Kids [60 0 R] >> +endobj +60 0 obj +<< /Type /Pages /Parent 59 0 R /Count 1 /Kids [61 0 R] >> +endobj +61 0 obj +<< /Type /Pages /Parent 60 0 R /Count 1 /Kids [62 0 R] >> +endobj +62 0 obj +<< /Type /Pages /Parent 61 0 R /Count 1 /Kids [63 0 R] >> +endobj +63 0 obj +<< /Type /Pages /Parent 62 0 R /Count 1 /Kids [64 0 R] >> +endobj +64 0 obj +<< /Type /Pages /Parent 63 0 R /Count 1 /Kids [65 0 R] >> +endobj +65 0 obj +<< /Type /Pages /Parent 64 0 R /Count 1 /Kids [66 0 R] >> +endobj +66 0 obj +<< /Type /Pages /Parent 65 0 R /Count 1 /Kids [67 0 R] >> +endobj +67 0 obj +<< /Type /Pages /Parent 66 0 R /Count 1 /Kids [68 0 R] >> +endobj +68 0 obj +<< /Type /Pages /Parent 67 0 R /Count 1 /Kids [69 0 R] >> +endobj +69 0 obj +<< /Type /Page /Parent 68 0 R /MediaBox [0 0 100 100] /Resources << >> >> +endobj +70 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >> +endobj +xref +0 71 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000128 00000 n +0000000199 00000 n +0000000270 00000 n +0000000341 00000 n +0000000412 00000 n +0000000483 00000 n +0000000554 00000 n +0000000626 00000 n +0000000699 00000 n +0000000773 00000 n +0000000847 00000 n +0000000921 00000 n +0000000995 00000 n +0000001069 00000 n +0000001143 00000 n +0000001217 00000 n +0000001291 00000 n +0000001365 00000 n +0000001439 00000 n +0000001513 00000 n +0000001587 00000 n +0000001661 00000 n +0000001735 00000 n +0000001809 00000 n +0000001883 00000 n +0000001957 00000 n +0000002031 00000 n +0000002105 00000 n +0000002179 00000 n +0000002253 00000 n +0000002327 00000 n +0000002401 00000 n +0000002475 00000 n +0000002549 00000 n +0000002623 00000 n +0000002697 00000 n +0000002771 00000 n +0000002845 00000 n +0000002919 00000 n +0000002993 00000 n +0000003067 00000 n +0000003141 00000 n +0000003215 00000 n +0000003289 00000 n +0000003363 00000 n +0000003437 00000 n +0000003511 00000 n +0000003585 00000 n +0000003659 00000 n +0000003733 00000 n +0000003807 00000 n +0000003881 00000 n +0000003955 00000 n +0000004029 00000 n +0000004103 00000 n +0000004177 00000 n +0000004251 00000 n +0000004325 00000 n +0000004399 00000 n +0000004473 00000 n +0000004547 00000 n +0000004621 00000 n +0000004695 00000 n +0000004769 00000 n +0000004843 00000 n +0000004917 00000 n +0000004991 00000 n +0000005081 00000 n +trailer +<< /Size 71 /Root 1 0 R >> +startxref +5170 +%%EOF diff --git a/tests/fixtures/pdf/headings/context-warning-bytes.pdf b/tests/fixtures/pdf/headings/context-warning-bytes.pdf new file mode 100644 index 0000000..fa3cfb7 --- /dev/null +++ b/tests/fixtures/pdf/headings/context-warning-bytes.pdf @@ -0,0 +1,22 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 1 /AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA 2 >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >> +endobj +xref +0 4 +0000000000 65535 f +0000000015 00000 n +0000131144 00000 n +0000131201 00000 n +trailer +<< /Size 4 /Root 1 0 R >> +startxref +131289 +%%EOF diff --git a/tests/fixtures/pdf/headings/context-warnings-32.pdf b/tests/fixtures/pdf/headings/context-warnings-32.pdf new file mode 100644 index 0000000..631bc49 --- /dev/null +++ b/tests/fixtures/pdf/headings/context-warnings-32.pdf @@ -0,0 +1,146 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Duplicate 1 /Duplicate 2 /Probe [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R] >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >> +endobj +4 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +5 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +6 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +7 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +8 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +9 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +10 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +11 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +12 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +13 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +14 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +15 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +16 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +17 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +18 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +19 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +20 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +21 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +22 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +23 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +24 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +25 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +26 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +27 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +28 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +29 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +30 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +31 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +32 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +33 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +34 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +xref +0 35 +0000000000 65535 f +0000000015 00000 n +0000000310 00000 n +0000000367 00000 n +0000000455 00000 n +0000000502 00000 n +0000000549 00000 n +0000000596 00000 n +0000000643 00000 n +0000000690 00000 n +0000000737 00000 n +0000000785 00000 n +0000000833 00000 n +0000000881 00000 n +0000000929 00000 n +0000000977 00000 n +0000001025 00000 n +0000001073 00000 n +0000001121 00000 n +0000001169 00000 n +0000001217 00000 n +0000001265 00000 n +0000001313 00000 n +0000001361 00000 n +0000001409 00000 n +0000001457 00000 n +0000001505 00000 n +0000001553 00000 n +0000001601 00000 n +0000001649 00000 n +0000001697 00000 n +0000001745 00000 n +0000001793 00000 n +0000001841 00000 n +0000001889 00000 n +trailer +<< /Size 35 /Root 1 0 R >> +startxref +1937 +%%EOF diff --git a/tests/fixtures/pdf/headings/context-warnings-33.pdf b/tests/fixtures/pdf/headings/context-warnings-33.pdf new file mode 100644 index 0000000..7adfbdb --- /dev/null +++ b/tests/fixtures/pdf/headings/context-warnings-33.pdf @@ -0,0 +1,150 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Duplicate 1 /Duplicate 2 /Probe [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R] >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >> +endobj +4 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +5 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +6 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +7 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +8 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +9 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +10 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +11 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +12 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +13 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +14 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +15 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +16 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +17 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +18 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +19 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +20 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +21 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +22 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +23 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +24 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +25 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +26 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +27 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +28 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +29 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +30 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +31 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +32 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +33 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +34 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +35 0 obj +<< /Duplicate 1 /Duplicate 2 >> +endobj +xref +0 36 +0000000000 65535 f +0000000015 00000 n +0000000317 00000 n +0000000374 00000 n +0000000462 00000 n +0000000509 00000 n +0000000556 00000 n +0000000603 00000 n +0000000650 00000 n +0000000697 00000 n +0000000744 00000 n +0000000792 00000 n +0000000840 00000 n +0000000888 00000 n +0000000936 00000 n +0000000984 00000 n +0000001032 00000 n +0000001080 00000 n +0000001128 00000 n +0000001176 00000 n +0000001224 00000 n +0000001272 00000 n +0000001320 00000 n +0000001368 00000 n +0000001416 00000 n +0000001464 00000 n +0000001512 00000 n +0000001560 00000 n +0000001608 00000 n +0000001656 00000 n +0000001704 00000 n +0000001752 00000 n +0000001800 00000 n +0000001848 00000 n +0000001896 00000 n +0000001944 00000 n +trailer +<< /Size 36 /Root 1 0 R >> +startxref +1992 +%%EOF diff --git a/tests/fixtures/pdf/headings/cross-page-structure.pdf b/tests/fixtures/pdf/headings/cross-page-structure.pdf new file mode 100644 index 0000000..00e463f --- /dev/null +++ b/tests/fixtures/pdf/headings/cross-page-structure.pdf @@ -0,0 +1,75 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 1 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +5 0 obj +<< /Length 134 >> +stream +/Span <> BDC BT /F1 16 Tf 40 60 Td (Across page one) Tj ET EMC +/H1 <> BDC BT /F1 16 Tf 170 60 Td ( middle) Tj ET EMC +endstream +endobj +6 0 obj +<< /Length 219 >> +stream +/P <> BDC BT /F1 16 Tf 40 120 Td (Unrelated paragraph) Tj ET EMC +/H2 <> BDC BT /F1 16 Tf 40 160 Td (Following heading) Tj ET EMC +/Span <> BDC BT /F1 16 Tf 40 210 Td (Across page two) Tj ET EMC +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +8 0 obj +<< /Type /StructTreeRoot /K [9 0 R] /ParentTree << /Nums [0 [12 0 R 10 0 R] 1 [13 0 R 11 0 R 14 0 R]] >> /ParentTreeNextKey 2 >> +endobj +9 0 obj +<< /Type /StructElem /S /Sect /P 8 0 R /K [10 0 R 11 0 R 14 0 R] >> +endobj +10 0 obj +<< /Type /StructElem /S /H1 /P 9 0 R /T (Across pages) /K [12 0 R << /Type /MCR /Pg 3 0 R /MCID 1 >> 13 0 R] >> +endobj +11 0 obj +<< /Type /StructElem /S /H2 /P 9 0 R /Pg 4 0 R /K 1 >> +endobj +12 0 obj +<< /Type /StructElem /S /Span /P 10 0 R /Pg 3 0 R /K 0 >> +endobj +13 0 obj +<< /Type /StructElem /S /Span /P 10 0 R /Pg 4 0 R /K 0 >> +endobj +14 0 obj +<< /Type /StructElem /S /P /P 9 0 R /Pg 4 0 R /K 2 >> +endobj +xref +0 15 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000178 00000 n +0000000321 00000 n +0000000464 00000 n +0000000650 00000 n +0000000921 00000 n +0000000991 00000 n +0000001135 00000 n +0000001218 00000 n +0000001346 00000 n +0000001417 00000 n +0000001491 00000 n +0000001565 00000 n +trailer +<< /Size 15 /Root 1 0 R >> +startxref +1635 +%%EOF diff --git a/tests/fixtures/pdf/headings/cyclic-children.pdf b/tests/fixtures/pdf/headings/cyclic-children.pdf new file mode 100644 index 0000000..eb09908 --- /dev/null +++ b/tests/fixtures/pdf/headings/cyclic-children.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 65 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Child cycle) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K 8 0 R >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000811 00000 n +0000000885 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +938 +%%EOF diff --git a/tests/fixtures/pdf/headings/cyclic-form.pdf b/tests/fixtures/pdf/headings/cyclic-form.pdf new file mode 100644 index 0000000..c552ea8 --- /dev/null +++ b/tests/fixtures/pdf/headings/cyclic-form.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 7 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/Fm0 Do +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000752 00000 n +0000000867 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +920 +%%EOF diff --git a/tests/fixtures/pdf/headings/cyclic-number-tree.pdf b/tests/fixtures/pdf/headings/cyclic-number-tree.pdf new file mode 100644 index 0000000..914a836 --- /dev/null +++ b/tests/fixtures/pdf/headings/cyclic-number-tree.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 65 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Number tree) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Kids [9 0 R] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000811 00000 n +0000000926 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +975 +%%EOF diff --git a/tests/fixtures/pdf/headings/cyclic-parent.pdf b/tests/fixtures/pdf/headings/cyclic-parent.pdf new file mode 100644 index 0000000..7c7bed7 --- /dev/null +++ b/tests/fixtures/pdf/headings/cyclic-parent.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 66 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Parent cycle) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 8 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000516 00000 n +0000000812 00000 n +0000000927 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +980 +%%EOF diff --git a/tests/fixtures/pdf/headings/cyclic-rolemap.pdf b/tests/fixtures/pdf/headings/cyclic-rolemap.pdf new file mode 100644 index 0000000..0b7c77b --- /dev/null +++ b/tests/fixtures/pdf/headings/cyclic-rolemap.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 7 >> +stream +/Fm0 Do +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> /RoleMap << /Foo /Bar /Bar /Foo >> >> +endobj +7 0 obj +<< /Length 69 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Hidden by cycle) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /Foo /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000359 00000 n +0000000429 00000 n +0000000550 00000 n +0000000849 00000 n +0000000965 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +1018 +%%EOF diff --git a/tests/fixtures/pdf/headings/different-form-same-mcid.pdf b/tests/fixtures/pdf/headings/different-form-same-mcid.pdf new file mode 100644 index 0000000..ca29489 --- /dev/null +++ b/tests/fixtures/pdf/headings/different-form-same-mcid.pdf @@ -0,0 +1,63 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R /Fm1 8 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 58 >> +stream +q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm1 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [10 0 R 9 0 R] /ParentTree << /Kids [11 0 R] >> >> +endobj +7 0 obj +<< /Length 64 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (First form) Tj ET EMC +endstream +endobj +8 0 obj +<< /Length 65 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 1 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Second form) Tj ET EMC +endstream +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +11 0 obj +<< /Limits [0 1] /Nums [0 [9 0 R] 1 [10 0 R]] >> +endobj +xref +0 12 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000313 00000 n +0000000422 00000 n +0000000492 00000 n +0000000587 00000 n +0000000892 00000 n +0000001198 00000 n +0000001313 00000 n +0000001429 00000 n +trailer +<< /Size 12 /Root 1 0 R >> +startxref +1494 +%%EOF diff --git a/tests/fixtures/pdf/headings/form-only-structure.pdf b/tests/fixtures/pdf/headings/form-only-structure.pdf new file mode 100644 index 0000000..2a9fefa --- /dev/null +++ b/tests/fixtures/pdf/headings/form-only-structure.pdf @@ -0,0 +1,48 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 28 >> +stream +q 1 0 0 1 40 100 cm /Fm Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Length 71 /Type /XObject /Subtype /Form /BBox [0 0 200 80] /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> >> +stream +/H2 <> BDC BT /F1 16 Tf 10 30 Td (Form only heading) Tj ET EMC +endstream +endobj +7 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Nums [0 [8 0 R]] >> /ParentTreeNextKey 1 >> +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 7 0 R /Pg 3 0 R /T (Form only heading) /K << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 0 >> >> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000301 00000 n +0000000380 00000 n +0000000450 00000 n +0000000676 00000 n +0000000787 00000 n +trailer +<< /Size 9 /Root 1 0 R >> +startxref +924 +%%EOF diff --git a/tests/fixtures/pdf/headings/form-vector-mcid-collision.pdf b/tests/fixtures/pdf/headings/form-vector-mcid-collision.pdf new file mode 100644 index 0000000..f8044ee --- /dev/null +++ b/tests/fixtures/pdf/headings/form-vector-mcid-collision.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 80 >> +stream +/H1 <> BDC 0 0 1 rg 40 180 180 20 re f EMC q 1 0 0 1 40 100 cm /Fm Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Length 77 /Type /XObject /Subtype /Form /BBox [0 0 240 80] /StructParents 1 /Resources << /Font << /F1 5 0 R >> >> >> +stream +/P <> BDC BT /F1 12 Tf 10 30 Td (Unrelated Form paragraph) Tj ET EMC +endstream +endobj +7 0 obj +<< /Type /StructTreeRoot /K [8 0 R 9 0 R] /ParentTree << /Nums [0 [8 0 R] 1 [9 0 R]] >> /ParentTreeNextKey 2 >> +endobj +8 0 obj +<< /Type /StructElem /S /H1 /P 7 0 R /Pg 3 0 R /T (Page vector heading) /K 0 >> +endobj +9 0 obj +<< /Type /StructElem /S /P /P 7 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 0 >> >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000318 00000 n +0000000449 00000 n +0000000519 00000 n +0000000751 00000 n +0000000878 00000 n +0000000973 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +1086 +%%EOF diff --git a/tests/fixtures/pdf/headings/inline-image-before-form.pdf b/tests/fixtures/pdf/headings/inline-image-before-form.pdf new file mode 100644 index 0000000..cf92ff4 --- /dev/null +++ b/tests/fixtures/pdf/headings/inline-image-before-form.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 64 >> +stream +q 1 0 0 1 40 100 cm BI /W 1 /H 1 /BPC 8 /CS /G ID X EI /Fm0 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 65 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (After image) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000417 00000 n +0000000487 00000 n +0000000574 00000 n +0000000869 00000 n +0000000984 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +1037 +%%EOF diff --git a/tests/fixtures/pdf/headings/manifest.json b/tests/fixtures/pdf/headings/manifest.json new file mode 100644 index 0000000..01e0558 --- /dev/null +++ b/tests/fixtures/pdf/headings/manifest.json @@ -0,0 +1,537 @@ +{ + "origin": "Self-created deterministic PDF syntax, Helvetica Standard-14 text and vector paths; no third-party document content.", + "generator": "../generate_headings.py", + "documents": [ + { + "file": "transformed-form.pdf", + "bytes": 1236, + "sha256": "55b6ee047e70b0dc463ac4b59ab3464bbb45a4cab12521ba23de41bc95197b98", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Form heading", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 70, + 102, + 240, + 130 + ] + } + ], + "form_matrix": [ + 1.5, + 0, + 0, + 1.25, + 55, + 70 + ], + "local_baseline": [ + 10, + 30 + ] + } + }, + { + "file": "rotated-tags.pdf", + "bytes": 1095, + "sha256": "8f2b850785b13d0de9ada1e8e31b15e6fe381f444eaaa1397acda481d1612c1e", + "expected": { + "pages": 1, + "crop_box": [ + 20, + 30, + 280, + 230 + ], + "rotation": 90, + "headings": [ + { + "page": 0, + "title": "Rotated first", + "level": 1, + "precision": "exact", + "text_region_pt": [ + 50, + 168, + 170, + 190 + ] + }, + { + "page": 0, + "title": "Rotated second", + "level": 3, + "precision": "exact", + "text_region_pt": [ + 60, + 78, + 180, + 96 + ] + } + ], + "order": "structure order, intentionally different from content-stream draw order" + } + }, + { + "file": "cross-page-structure.pdf", + "bytes": 2001, + "sha256": "52ec19216440feebdb80b2d25339282b82b219a017fdf7c057c618f92db89373", + "expected": { + "pages": 2, + "headings": [ + { + "page": 0, + "title": "Across page one middle", + "level": 1, + "precision": "exact", + "text_region_pt": [ + 40, + 55, + 230, + 78 + ] + }, + { + "page": 1, + "title": "Across page two", + "level": 1, + "precision": "exact", + "text_region_pt": [ + 40, + 205, + 170, + 228 + ] + }, + { + "page": 1, + "title": "Following heading", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 40, + 155, + 180, + 178 + ] + } + ], + "scope": "One heading spans two page-local Span elements with an interleaved direct MCR; MCID 0 is reused independently on both pages. Results are page-local fragments." + } + }, + { + "file": "vector-heading.pdf", + "bytes": 787, + "sha256": "0083680fe3b8d27525b4e9b442155444095b19a9f3d7921696531be12dcf2175", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Vector section", + "level": 4, + "precision": "page", + "reason_contains": "座標" + } + ], + "drawn_region_pt": [ + 40, + 180, + 190, + 200 + ], + "fallback": "No text glyph bounding box is available for the marked vector path." + } + }, + { + "file": "form-only-structure.pdf", + "bytes": 1167, + "sha256": "4ad6e541fe9df9d656ad78eb477a751eafa22f9147a501dfed68b4fd3ef6e235", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Form only heading", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 50, + 125, + 190, + 147 + ] + } + ], + "scope": "Form StructParents and source-qualified MCR; no page StructParents." + } + }, + { + "file": "form-vector-mcid-collision.pdf", + "bytes": 1352, + "sha256": "bc37979a4cb8a96872fb43c75b183aa82ac64c5934844e8233b79e7910640128", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Page vector heading", + "level": 1, + "precision": "page", + "reason_contains": "MCID" + } + ], + "fallback": "MCID 0 names a page vector and unrelated Form text. Non-text page objects must participate in stream collision detection." + } + }, + { + "file": "named-property-rolemap.pdf", + "bytes": 1321, + "sha256": "2da7e79e65977378ce7f2114bbfa3df1a23181c633cf7988d533388b4e89f142", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Named property heading", + "level": 3, + "precision": "exact", + "text_region_pt": [ + 50, + 127, + 190, + 142 + ] + } + ] + } + }, + { + "file": "nested-form-matrix.pdf", + "bytes": 1791, + "sha256": "8d872ae76bbd8164b34a4fb9d1a0b2cc73f900f5b2349f5486b10079b7442084", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Nested heading", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 82, + 161, + 245, + 181 + ] + } + ] + } + }, + { + "file": "different-form-same-mcid.pdf", + "bytes": 1800, + "sha256": "d337fb72b0434370a15b1d8470dca33e5492fe4cc01f352506a947fb135cfcf5", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Second form", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 90, + 87, + 170, + 102 + ] + }, + { + "page": 0, + "title": "First form", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 40, + 207, + 105, + 222 + ] + } + ] + } + }, + { + "file": "ambiguous-form-matrix.pdf", + "bytes": 1838, + "sha256": "25103b29c1fc392378f69e66f480e480868d1d85f764f431bb6002cba3f213ce", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "First structure", + "level": 2, + "precision": "page", + "reason_contains": "一意" + }, + { + "page": 0, + "title": "Second structure", + "level": 2, + "precision": "page", + "reason_contains": "一意" + } + ] + } + }, + { + "file": "repeated-form.pdf", + "bytes": 1302, + "sha256": "d05f07ddedd2a045f3f3f66bc554fad6fb2c651828080472130f139783e9596f", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Repeated heading", + "level": 2, + "precision": "page", + "reason_contains": "複数回" + } + ] + } + }, + { + "file": "unused-form-resource.pdf", + "bytes": 1776, + "sha256": "c203b8c8bf3afadd76e51692390c81d866fba0ce56b438a7b6613bd75f2bcbad", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "Used heading", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 50, + 127, + 140, + 142 + ] + } + ] + } + }, + { + "file": "inline-image-before-form.pdf", + "bytes": 1303, + "sha256": "3fc737049b0a92d72b813ae5d6455c4bb51da08f561371374b3acc99f7356ae5", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "After image", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 50, + 127, + 125, + 142 + ] + } + ] + } + }, + { + "file": "cyclic-form.pdf", + "bytes": 1185, + "sha256": "3143516932a8c9b4355325d251e0cc5d1af77edd368ad915c0bf6990643cf74a", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "cyclic-rolemap.pdf", + "bytes": 1284, + "sha256": "aa0772a34f4abd035e2e7d4aa6438a49774b791b9520d3269cffa52b1575a2fc", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "cyclic-parent.pdf", + "bytes": 1245, + "sha256": "46e4ac7e96e372b05f35718b675552f58ad618887e53ba31a044da748edd1036", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "cyclic-children.pdf", + "bytes": 1203, + "sha256": "7c9f49595fa3001b2c35c7eaf389d57dc2d6d7c910ef36d35ef0810c8e0d7935", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "broken-mcr-page.pdf", + "bytes": 1243, + "sha256": "aa1722f9ec4e4d5affd531e40a58c76be1ecfb60e4b2b7d20bbab53e459a4abd", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "broken-mcr-stream.pdf", + "bytes": 1245, + "sha256": "c2c20b93bf307df69ab5a5b962f2f399d761a987e372707b830be7ae12839596", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "annotation-owned-mcr.pdf", + "bytes": 1263, + "sha256": "282612ae3bfecdc233e6818ee4a680f3e4541f060979443952b595c9d76943cc", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "broken-graphics-stack.pdf", + "bytes": 1249, + "sha256": "ac27fe4c8e166a4f241737578b9e846df685b83b85cc8d9447f7c33203d66c15", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "cyclic-number-tree.pdf", + "bytes": 1240, + "sha256": "e1adb2ebb053be176f746b89b8e24946b1c9a86cbe05421f7691e49787cbc541", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "conflicting-parent-tree-owner.pdf", + "bytes": 1802, + "sha256": "6def473a2db3cef07e481de5ae6a9ae0837d60da1ceb66ffaa47e250de0b6876", + "expected": { + "pages": 1, + "headings": [], + "limited": true + } + }, + { + "file": "stream-quota-at-limit.pdf", + "bytes": 17615, + "sha256": "40a56f12e42fe67ce3b656027a7fc4348a5cb34d5ce8f0f38350567b2e178924", + "expected": { + "pages": 1, + "headings": [ + { + "page": 0, + "title": "At stream limit", + "level": 2, + "precision": "exact", + "text_region_pt": [ + 50, + 127, + 140, + 142 + ] + } + ] + } + }, + { + "file": "stream-quota-over-limit.pdf", + "bytes": 17617, + "sha256": "80cf057acd5c6c2454fb6cba20100559bb892e0828a23d3ce50405b5477fb735", + "expected": { + "pages": 1, + "headings": [], + "limited": true, + "truncated": true + } + }, + { + "file": "bounded-heading-response.pdf", + "bytes": 1157442, + "sha256": "820ea69c56ac33587114886aded122e9d7705e0ffdccaa6eefbb81ab4a05f6ce", + "expected": { + "pages": 1, + "headings": [], + "response_limited": true + } + } + ], + "context_documents": [ + { + "file": "context-deep-tree.pdf", + "bytes": 6656, + "sha256": "66dc1bc3c5fd062334fddc9e2f0ef420b168e0ed44397bb2374c6b35695d86de", + "expected": { + "scope": "Context-only regression: after a depth failure, the second page must never be assigned index zero." + } + }, + { + "file": "context-warnings-32.pdf", + "bytes": 2703, + "sha256": "1ed5ad72f3054844c158ffff87977616caaca63cbb864a50b1c9be96ac1a5ec6", + "expected": { + "scope": "Context-only cumulative diagnostic limit, starting in initial parse; warning bodies must never reach stdout/stderr.", + "accepted": true + } + }, + { + "file": "context-warnings-33.pdf", + "bytes": 2778, + "sha256": "29e105aa8c78d8b6a0ab1a5d7c7f99663abd952f1657c0c17c43ab24406822a1", + "expected": { + "scope": "Context-only cumulative diagnostic limit, starting in initial parse; warning bodies must never reach stdout/stderr.", + "accepted": false + } + }, + { + "file": "context-warning-bytes.pdf", + "bytes": 131435, + "sha256": "42c792b2fa035674a22bde94fe681bc89406e3f015be7e32f49d3a3410364319", + "expected": { + "scope": "Context-only diagnostic byte limit during initial parse; no warning text is retained or emitted.", + "accepted": false + } + } + ] +} diff --git a/tests/fixtures/pdf/headings/named-property-rolemap.pdf b/tests/fixtures/pdf/headings/named-property-rolemap.pdf new file mode 100644 index 0000000..32db0be --- /dev/null +++ b/tests/fixtures/pdf/headings/named-property-rolemap.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 29 >> +stream +q 1 0 0 1 40 100 cm /Fm0 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> /RoleMap << /Section /H3 >> >> +endobj +7 0 obj +<< /Length 86 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/Section /HeadingProperty BDC BT /F1 12 Tf 10 30 Td (Named property heading) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /Section /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000382 00000 n +0000000452 00000 n +0000000566 00000 n +0000000882 00000 n +0000001002 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +1055 +%%EOF diff --git a/tests/fixtures/pdf/headings/nested-form-matrix.pdf b/tests/fixtures/pdf/headings/nested-form-matrix.pdf new file mode 100644 index 0000000..86ed393 --- /dev/null +++ b/tests/fixtures/pdf/headings/nested-form-matrix.pdf @@ -0,0 +1,63 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R /Fm1 8 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 29 >> +stream +q 1 0 0 1 40 100 cm /Fm0 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [9 0 R 10 0 R] /ParentTree << /Kids [11 0 R] >> >> +endobj +7 0 obj +<< /Length 33 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> /Matrix [1.2 0 0 0.9 5 7] >> +stream +q 1.5 0 0 1.25 10 20 cm /Fm1 Do Q +endstream +endobj +8 0 obj +<< /Length 68 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 1 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> /Matrix [1 0 0 1 4 6] >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Nested heading) Tj ET EMC +endstream +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +11 0 obj +<< /Limits [0 1] /Nums [0 [9 0 R] 1 [10 0 R]] >> +endobj +xref +0 12 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000313 00000 n +0000000393 00000 n +0000000463 00000 n +0000000558 00000 n +0000000858 00000 n +0000001189 00000 n +0000001304 00000 n +0000001420 00000 n +trailer +<< /Size 12 /Root 1 0 R >> +startxref +1485 +%%EOF diff --git a/tests/fixtures/pdf/headings/repeated-form.pdf b/tests/fixtures/pdf/headings/repeated-form.pdf new file mode 100644 index 0000000..06bc4a7 --- /dev/null +++ b/tests/fixtures/pdf/headings/repeated-form.pdf @@ -0,0 +1,52 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 58 >> +stream +q 1 0 0 1 30 180 cm /Fm0 Do Q q 1 0 0 1 80 60 cm /Fm0 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Kids [9 0 R] >> >> +endobj +7 0 obj +<< /Length 70 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Repeated heading) Tj ET EMC +endstream +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +9 0 obj +<< /Limits [0 0] /Nums [0 [8 0 R]] >> +endobj +xref +0 10 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000302 00000 n +0000000411 00000 n +0000000481 00000 n +0000000568 00000 n +0000000868 00000 n +0000000983 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +1036 +%%EOF diff --git a/tests/fixtures/pdf/headings/rotated-tags.pdf b/tests/fixtures/pdf/headings/rotated-tags.pdf new file mode 100644 index 0000000..388d0ae --- /dev/null +++ b/tests/fixtures/pdf/headings/rotated-tags.pdf @@ -0,0 +1,46 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 260] /CropBox [20 30 280 230] /Rotate 90 /StructParents 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 137 >> +stream +/H3 <> BDC BT /F1 16 Tf 60 80 Td (Rotated second) Tj ET EMC +/H1 <> BDC BT /F1 20 Tf 50 170 Td (Rotated first) Tj ET EMC +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [7 0 R 8 0 R] /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> >> +endobj +7 0 obj +<< /Type /StructElem /S /H1 /P 6 0 R /Pg 3 0 R /K 0 >> +endobj +8 0 obj +<< /Type /StructElem /S /H3 /P 6 0 R /Pg 3 0 R /K 1 >> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000351 00000 n +0000000540 00000 n +0000000610 00000 n +0000000712 00000 n +0000000782 00000 n +trailer +<< /Size 9 /Root 1 0 R >> +startxref +852 +%%EOF diff --git a/tests/fixtures/pdf/headings/stream-quota-at-limit.pdf b/tests/fixtures/pdf/headings/stream-quota-at-limit.pdf new file mode 100644 index 0000000..c71a8f1 Binary files /dev/null and b/tests/fixtures/pdf/headings/stream-quota-at-limit.pdf differ diff --git a/tests/fixtures/pdf/headings/stream-quota-over-limit.pdf b/tests/fixtures/pdf/headings/stream-quota-over-limit.pdf new file mode 100644 index 0000000..49e695c Binary files /dev/null and b/tests/fixtures/pdf/headings/stream-quota-over-limit.pdf differ diff --git a/tests/fixtures/pdf/headings/transformed-form.pdf b/tests/fixtures/pdf/headings/transformed-form.pdf new file mode 100644 index 0000000..9619e69 --- /dev/null +++ b/tests/fixtures/pdf/headings/transformed-form.pdf @@ -0,0 +1,48 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << /XObject << /Fm 6 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 56 >> +stream +/H2 <> BDC q 1.5 0 0 1.25 55 70 cm /Fm Do Q EMC +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Length 66 /Type /XObject /Subtype /Form /BBox [0 0 160 80] /StructParents 1 /Resources << /Font << /F1 5 0 R >> >> >> +stream +/H2 <> BDC BT /F1 16 Tf 10 30 Td (Form heading) Tj ET EMC +endstream +endobj +7 0 obj +<< /Type /StructTreeRoot /K [8 0 R] /ParentTree << /Nums [0 [8 0 R] 1 [null null null null 8 0 R]] >> /ParentTreeNextKey 2 >> +endobj +8 0 obj +<< /Type /StructElem /S /H2 /P 7 0 R /Pg 3 0 R /T (Form heading) /K [0 << /Type /MCR /Pg 3 0 R /Stm 6 0 R /MCID 4 >>] >> +endobj +xref +0 9 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000318 00000 n +0000000425 00000 n +0000000495 00000 n +0000000716 00000 n +0000000857 00000 n +trailer +<< /Size 9 /Root 1 0 R >> +startxref +993 +%%EOF diff --git a/tests/fixtures/pdf/headings/unused-form-resource.pdf b/tests/fixtures/pdf/headings/unused-form-resource.pdf new file mode 100644 index 0000000..e3c068c --- /dev/null +++ b/tests/fixtures/pdf/headings/unused-form-resource.pdf @@ -0,0 +1,63 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 350] /Resources << /XObject << /Fm0 7 0 R /Fm1 8 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 29 >> +stream +q 1 0 0 1 40 100 cm /Fm0 Do Q +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +6 0 obj +<< /Type /StructTreeRoot /K [9 0 R 10 0 R] /ParentTree << /Kids [11 0 R] >> >> +endobj +7 0 obj +<< /Length 66 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 0 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Used heading) Tj ET EMC +endstream +endobj +8 0 obj +<< /Length 68 /Type /XObject /Subtype /Form /BBox [0 0 260 90] /StructParents 1 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R /Fm1 8 0 R >> /Properties << /HeadingProperty << /MCID 0 >> >> >> >> +stream +/H2 <> BDC BT /F1 12 Tf 10 30 Td (Unused heading) Tj ET EMC +endstream +endobj +9 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 7 0 R /MCID 0 >> >> +endobj +10 0 obj +<< /Type /StructElem /S /H2 /P 6 0 R /Pg 3 0 R /K << /Type /MCR /Pg 3 0 R /Stm 8 0 R /MCID 0 >> >> +endobj +11 0 obj +<< /Limits [0 1] /Nums [0 [9 0 R] 1 [10 0 R]] >> +endobj +xref +0 12 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000313 00000 n +0000000393 00000 n +0000000463 00000 n +0000000558 00000 n +0000000865 00000 n +0000001174 00000 n +0000001289 00000 n +0000001405 00000 n +trailer +<< /Size 12 /Root 1 0 R >> +startxref +1470 +%%EOF diff --git a/tests/fixtures/pdf/headings/vector-heading.pdf b/tests/fixtures/pdf/headings/vector-heading.pdf new file mode 100644 index 0000000..9e4247d --- /dev/null +++ b/tests/fixtures/pdf/headings/vector-heading.pdf @@ -0,0 +1,37 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 260] /StructParents 0 /Resources << >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 51 >> +stream +/H4 <> BDC 0 0 1 rg 40 180 150 20 re f EMC +endstream +endobj +5 0 obj +<< /Type /StructTreeRoot /K [6 0 R] /ParentTree << /Nums [0 [6 0 R]] >> >> +endobj +6 0 obj +<< /Type /StructElem /S /H4 /P 5 0 R /Pg 3 0 R /ActualText (Vector section) /K 0 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000115 00000 n +0000000172 00000 n +0000000293 00000 n +0000000395 00000 n +0000000485 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +584 +%%EOF diff --git a/tests/fixtures/pdf/icc_generator.py b/tests/fixtures/pdf/icc_generator.py new file mode 100644 index 0000000..e8cccf3 --- /dev/null +++ b/tests/fixtures/pdf/icc_generator.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Self-authored linear-sRGB ICCBased vector/image fixture; no borrowed ICC data. + +Uses public LittleCMS APIs to encode D65 + sRGB primaries + gamma 1. The +color expectation is the analytic sRGB transfer function, independent of PDF +renderer output. Only the ICC creation timestamp is normalized for determinism. +""" +from pathlib import Path +import ctypes as C +import ctypes.util +import hashlib +import json +import shutil +import struct + + +class xyY(C.Structure): + _fields_ = [("x", C.c_double), ("y", C.c_double), ("Y", C.c_double)] + + +class Primaries(C.Structure): + _fields_ = [("Red", xyY), ("Green", xyY), ("Blue", xyY)] + + +def linear_profile(): + cms = C.CDLL(ctypes.util.find_library("lcms2") or "liblcms2.so") + cms.cmsGetEncodedCMMversion.restype = C.c_uint32 + cms.cmsBuildGamma.argtypes = [C.c_void_p, C.c_double]; cms.cmsBuildGamma.restype = C.c_void_p + cms.cmsFreeToneCurve.argtypes = [C.c_void_p] + cms.cmsCreateRGBProfile.argtypes = [C.POINTER(xyY), C.POINTER(Primaries), C.POINTER(C.c_void_p)] + cms.cmsCreateRGBProfile.restype = C.c_void_p + cms.cmsSaveProfileToMem.argtypes = [C.c_void_p, C.c_void_p, C.POINTER(C.c_uint32)] + cms.cmsSaveProfileToMem.restype = C.c_int + cms.cmsCloseProfile.argtypes = [C.c_void_p] + curve = cms.cmsBuildGamma(None, 1.0) + if not curve: raise RuntimeError("LittleCMS gamma creation failed") + white = xyY(.3127, .3290, 1.) + primaries = Primaries(xyY(.64, .33, 1.), xyY(.30, .60, 1.), xyY(.15, .06, 1.)) + curves = (C.c_void_p * 3)(curve, curve, curve) + profile = cms.cmsCreateRGBProfile(C.byref(white), C.byref(primaries), curves) + cms.cmsFreeToneCurve(curve) + if not profile: raise RuntimeError("LittleCMS profile creation failed") + try: + length = C.c_uint32() + if not cms.cmsSaveProfileToMem(profile, None, C.byref(length)): raise RuntimeError("ICC size failed") + buffer = C.create_string_buffer(length.value) + if not cms.cmsSaveProfileToMem(profile, buffer, C.byref(length)): raise RuntimeError("ICC save failed") + data = bytearray(buffer.raw[:length.value]) + finally: cms.cmsCloseProfile(profile) + data[24:36] = struct.pack(">6H", 2026, 9, 19, 0, 0, 0) + data[84:100] = bytes(16) # Zero profile ID is permitted; no stale timestamp-derived ID. + assert data[36:40] == b"acsp" and data[16:20] == b"RGB " + return bytes(data), cms.cmsGetEncodedCMMversion() + + +def srgb(value): + encoded = 12.92 * value if value <= .0031308 else 1.055 * value ** (1 / 2.4) - .055 + return round(255 * encoded) + + +def pdf_stream(data, extra=b""): + return b"<< /Length " + str(len(data)).encode() + b" " + extra + b" >>\nstream\n" + data + b"\nendstream" + + +def generate(out): + out = Path(out); out.mkdir(parents=True, exist_ok=True) + profile, version = linear_profile() + (out / "linear-srgb.icc").write_bytes(profile) + license_path = Path("/usr/share/licenses/lcms2/LICENSE") + if license_path.is_file(): shutil.copyfile(license_path, out / "LCMS2-LICENSE.txt") + values = [(.0625, .0625, .0625), (.25, .25, .25), (.5, .5, .5), (.75, .75, .75), (.5, .125, .0625), (.0625, .25, .5)] + content = ["0 0 0 rg BT /F1 16 Tf 30 325 Td (EMBEDDED ICC: LINEAR RGB TO sRGB) Tj ET", + "BT /F1 10 Tf 30 298 Td (Top: ICCBased vector / Middle: DeviceRGB control / Bottom: ICCBased image) Tj ET", + "/RelativeColorimetric ri"] + probes = [] + for i, value in enumerate(values): + x = 30 + i * 88 + operands = " ".join(str(v) for v in value) + content += [f"/Linear cs {operands} scn {x} 216 76 64 re f", f"{operands} rg {x} 132 76 64 re f"] + probes.append({"kind": "icc-vector", "point_pt": [x+38, 248], "components": value, "expected_rgb": [srgb(v) for v in value]}) + probes.append({"kind": "device-control", "point_pt": [x+38, 164], "components": value, "expected_rgb": [round(v*255) for v in value]}) + quantized = [round(v*255) for v in value] + probes.append({"kind": "icc-image", "point_pt": [30+i*88+44, 68], "components": quantized, "expected_rgb": [srgb(v/255) for v in quantized]}) + content += ["q 528 0 0 48 30 44 cm /Sample Do Q", "0 0 0 rg BT /F1 10 Tf 30 22 Td (ICC gamma 1 / D65 / sRGB primaries / original data and coordinates) Tj ET"] + image = bytes(round(v*255) for rgb in values for v in rgb) + objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 350] /Resources << /Font << /F1 6 0 R >> /ColorSpace << /Linear [/ICCBased 5 0 R] >> /XObject << /Sample 7 0 R >> >> /Contents 4 0 R >>", + pdf_stream("\n".join(content).encode()), pdf_stream(profile,b"/N 3 /Alternate /DeviceRGB /Range [0 1 0 1 0 1]"), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + pdf_stream(image,b"/Type /XObject /Subtype /Image /Width 6 /Height 1 /BitsPerComponent 8 /Interpolate false /ColorSpace [/ICCBased 5 0 R] /Intent /RelativeColorimetric")] + data = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"); offsets=[0] + for i, obj in enumerate(objects,1): + offsets.append(len(data)); data += f"{i} 0 obj\n".encode()+obj+b"\nendobj\n" + xref=len(data); data += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode() + for offset in offsets[1:]: data += f"{offset:010d} 00000 n \n".encode() + data += f"trailer << /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + name="icc-linear-rgb.pdf"; (out/name).write_bytes(data) + record={"file":name,"sha256":hashlib.sha256(data).hexdigest(),"page_count":1, + "expected":{"page_sizes_pt":[[600,350]],"icc_probes":probes,"probe_tolerance_rgb":3, + "profile_effect":"ICCBased 0.5 linear gray becomes approximately 188, distinct from DeviceRGB approximately 128."}, + "limits":["One RGB matrix/TRC profile, not arbitrary ICC LUT/CMYK/printing conformance.","Analytic sRGB color expectation; independent renderer comparison still requires visual review."]} + origin={"file":"linear-srgb.icc","sha256":hashlib.sha256(profile).hexdigest(),"bytes":len(profile), + "source":"Self-created with LittleCMS cmsCreateRGBProfile using the stated numeric white point, primaries and gamma; no third-party ICC profile is copied.", + "lcms_encoded_version":version,"lcms_license":"MIT; LCMS2-LICENSE.txt", + "upstream":"https://github.com/mm2/Little-CMS","profile_copyright_tag":"No copyright, use freely", + "white_xy":[.3127,.3290],"primaries_xy":[[.64,.33],[.30,.60],[.15,.06]],"gamma":1, + "timestamp_normalization":"Header creation time is fixed to 2026-09-19; optional profile ID is zero."} + return record,origin + + +if __name__ == "__main__": + out=Path(__file__).resolve().parent/"extended"; record,origin=generate(out) + manifest_path=out/"manifest.json"; manifest=json.loads(manifest_path.read_text()) + manifest["documents"]=[d for d in manifest["documents"] if d["file"]!=record["file"]]+[record] + manifest["color_profile"]=origin + manifest_path.write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+"\n") + print(json.dumps({"fixture":record["sha256"],"profile":origin["sha256"]})) diff --git a/tests/fixtures/pdf/intent-transparency/README.md b/tests/fixtures/pdf/intent-transparency/README.md new file mode 100644 index 0000000..a223279 --- /dev/null +++ b/tests/fixtures/pdf/intent-transparency/README.md @@ -0,0 +1,27 @@ +# Soft-mask and pattern rendering-intent probes + +This self-authored 4-page PDF contains 108 declared pixel probes. The existing +984-byte distinct CMYK/Lab ICC profile is copied without alteration. Its four +intent outputs for CMYK (0, .75, .25, 0) have separated luminances; no PDF renderer +supplies the expected colors. + +[Adobe PDF Reference 1.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf) +pp. 218, 223, 231 and 453 define pattern state. Colored tiling and shading patterns +start with their resource-owning page/Form stream's initial state. A Form is +reused under different initial intents, then changes its painting intent to +Absolute. Explicit pattern overrides and q/Q are separate controls. Uncolored +stencils contain no color operators or `ri`; their ICC base color is supplied by +the caller. Small/large uncolored strokes distinguish stroke from fill color. + +Pages 440–446 define luminosity soft masks and their backdrop. The mask's RGB +group contains one opaque ICC rectangle, or no objects for the BC-only controls. +The direct CMM RGB result is reduced with the recommended .30/.59/.11 weights; +black over white has value 255 minus that luminosity. This device-dependent +formula is a declared test convention, not universal device calibration. + +Run `tests/cmyk_lut/transparency.py --self-check --output NEW_DIRECTORY`, or pass +`--build-dir build --pdfium-library PATH/libpdfium.so --output NEW_DIRECTORY`. +The runner records all failures, validates the sandbox helper and library hash, +and judges PDFium and Poppler separately against the direct CMM. LittleCMS may +be shared with the renderers; this is not an independent CMM implementation or +general ICC conformance acceptance. The older 8-page intent fixture is unchanged. diff --git a/tests/fixtures/pdf/intent-transparency/distinct-cmyk-lab.icc b/tests/fixtures/pdf/intent-transparency/distinct-cmyk-lab.icc new file mode 100644 index 0000000..4665539 Binary files /dev/null and b/tests/fixtures/pdf/intent-transparency/distinct-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/intent-transparency/intent-transparency.pdf b/tests/fixtures/pdf/intent-transparency/intent-transparency.pdf new file mode 100644 index 0000000..a0ad94b Binary files /dev/null and b/tests/fixtures/pdf/intent-transparency/intent-transparency.pdf differ diff --git a/tests/fixtures/pdf/intent-transparency/manifest.json b/tests/fixtures/pdf/intent-transparency/manifest.json new file mode 100644 index 0000000..a752ee3 --- /dev/null +++ b/tests/fixtures/pdf/intent-transparency/manifest.json @@ -0,0 +1,3198 @@ +{ + "schemaVersion": 1, + "file": "intent-transparency.pdf", + "sha256": "b3de35e26112d14deeca42999dc9bf994722fee30009ef6f309a0ca313e71173", + "generatorSha256": "8cc6b8fc07a6e51660b35a85cfc9d6bb7d2087a40d755aafcf3d9d2b253aa696", + "pageCount": 4, + "pageSizePt": [ + 880, + 850 + ], + "profile": { + "file": "distinct-cmyk-lab.icc", + "sha256": "b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315", + "size": 984, + "origin": "tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc" + }, + "sample": [ + 0, + 0.75, + 0.25, + 0 + ], + "tolerance8Bit": 4, + "cases": [ + "direct-icc-control", + "mask-caller-ri", + "mask-internal-ri", + "mask-internal-gs", + "mask-group-qQ", + "mask-q-inner-none", + "mask-Q-restores", + "mask-none", + "mask-BC-only", + "mask-opaque-over-BC", + "tiling-page-initial", + "tiling-colored-ri", + "tiling-colored-gs", + "tiling-colored-qQ", + "tiling-form-initial", + "tiling-form-initial-reuse", + "tiling-form-ri", + "tiling-uncolored-fill", + "tiling-uncolored-fill-gs", + "tiling-uncolored-stroke-small", + "tiling-uncolored-stroke-large", + "shading-page-initial", + "shading-pattern-ri", + "shading-form-initial", + "shading-form-initial-reuse", + "shading-form-ri", + "direct-shading-control" + ], + "probes": [ + { + "id": "direct-icc-control-0", + "case": "direct-icc-control", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 705 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0 0.75 0.25 0 scn 320 684 90 42 re f", + "Q" + ] + }, + { + "id": "direct-icc-control-1", + "case": "direct-icc-control", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 705 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0 0.75 0.25 0 scn 452 684 90 42 re f", + "Q" + ] + }, + { + "id": "direct-icc-control-2", + "case": "direct-icc-control", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 705 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0 0.75 0.25 0 scn 584 684 90 42 re f", + "Q" + ] + }, + { + "id": "direct-icc-control-3", + "case": "direct-icc-control", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 705 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0 0.75 0.25 0 scn 716 684 90 42 re f", + "Q" + ] + }, + { + "id": "mask-caller-ri-0", + "case": "mask-caller-ri", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 609 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Mask gs", + "0 g 320 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-caller-ri-1", + "case": "mask-caller-ri", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 609 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Mask gs", + "0 g 452 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-caller-ri-2", + "case": "mask-caller-ri", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 609 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Mask gs", + "0 g 584 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-caller-ri-3", + "case": "mask-caller-ri", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 609 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Mask gs", + "0 g 716 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-ri-0", + "case": "mask-internal-ri", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 513 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/MaskRi1 gs", + "0 g 320 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-ri-1", + "case": "mask-internal-ri", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 513 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/MaskRi2 gs", + "0 g 452 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-ri-2", + "case": "mask-internal-ri", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 513 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/MaskRi3 gs", + "0 g 584 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-ri-3", + "case": "mask-internal-ri", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 513 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/MaskRi0 gs", + "0 g 716 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-gs-0", + "case": "mask-internal-gs", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 417 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/MaskGs1 gs", + "0 g 320 396 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-gs-1", + "case": "mask-internal-gs", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 417 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/MaskGs2 gs", + "0 g 452 396 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-gs-2", + "case": "mask-internal-gs", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 417 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/MaskGs3 gs", + "0 g 584 396 90 42 re f", + "Q" + ] + }, + { + "id": "mask-internal-gs-3", + "case": "mask-internal-gs", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 417 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/MaskGs0 gs", + "0 g 716 396 90 42 re f", + "Q" + ] + }, + { + "id": "mask-group-qQ-0", + "case": "mask-group-qQ", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 321 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/MaskQ1 gs", + "0 g 320 300 90 42 re f", + "Q" + ] + }, + { + "id": "mask-group-qQ-1", + "case": "mask-group-qQ", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 321 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/MaskQ2 gs", + "0 g 452 300 90 42 re f", + "Q" + ] + }, + { + "id": "mask-group-qQ-2", + "case": "mask-group-qQ", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 321 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/MaskQ3 gs", + "0 g 584 300 90 42 re f", + "Q" + ] + }, + { + "id": "mask-group-qQ-3", + "case": "mask-group-qQ", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 321 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/MaskQ0 gs", + "0 g 716 300 90 42 re f", + "Q" + ] + }, + { + "id": "mask-q-inner-none-0", + "case": "mask-q-inner-none", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 225 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Mask gs", + "q /NoMask gs", + "0 g 320 204 90 42 re f", + "Q", + "Q" + ] + }, + { + "id": "mask-q-inner-none-1", + "case": "mask-q-inner-none", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 225 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Mask gs", + "q /NoMask gs", + "0 g 452 204 90 42 re f", + "Q", + "Q" + ] + }, + { + "id": "mask-q-inner-none-2", + "case": "mask-q-inner-none", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 225 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Mask gs", + "q /NoMask gs", + "0 g 584 204 90 42 re f", + "Q", + "Q" + ] + }, + { + "id": "mask-q-inner-none-3", + "case": "mask-q-inner-none", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 225 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Mask gs", + "q /NoMask gs", + "0 g 716 204 90 42 re f", + "Q", + "Q" + ] + }, + { + "id": "mask-Q-restores-0", + "case": "mask-Q-restores", + "column": 0, + "page": 1, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 129 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Mask gs", + "q /NoMask gs Q", + "0 g 320 108 90 42 re f", + "Q" + ] + }, + { + "id": "mask-Q-restores-1", + "case": "mask-Q-restores", + "column": 1, + "page": 1, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 129 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Mask gs", + "q /NoMask gs Q", + "0 g 452 108 90 42 re f", + "Q" + ] + }, + { + "id": "mask-Q-restores-2", + "case": "mask-Q-restores", + "column": 2, + "page": 1, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 129 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Mask gs", + "q /NoMask gs Q", + "0 g 584 108 90 42 re f", + "Q" + ] + }, + { + "id": "mask-Q-restores-3", + "case": "mask-Q-restores", + "column": 3, + "page": 1, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 129 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Mask gs", + "q /NoMask gs Q", + "0 g 716 108 90 42 re f", + "Q" + ] + }, + { + "id": "mask-none-0", + "case": "mask-none", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 705 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Mask gs", + "/NoMask gs", + "0 g 320 684 90 42 re f", + "Q" + ] + }, + { + "id": "mask-none-1", + "case": "mask-none", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 705 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Mask gs", + "/NoMask gs", + "0 g 452 684 90 42 re f", + "Q" + ] + }, + { + "id": "mask-none-2", + "case": "mask-none", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 705 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Mask gs", + "/NoMask gs", + "0 g 584 684 90 42 re f", + "Q" + ] + }, + { + "id": "mask-none-3", + "case": "mask-none", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "black", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 705 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Mask gs", + "/NoMask gs", + "0 g 716 684 90 42 re f", + "Q" + ] + }, + { + "id": "mask-BC-only-0", + "case": "mask-BC-only", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-bc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": [ + 1, + 0, + 0 + ], + "pointPt": [ + 365, + 609 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/BC0 gs", + "0 g 320 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-BC-only-1", + "case": "mask-BC-only", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-bc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": [ + 0, + 1, + 0 + ], + "pointPt": [ + 497, + 609 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/BC1 gs", + "0 g 452 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-BC-only-2", + "case": "mask-BC-only", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-bc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": [ + 0, + 0, + 1 + ], + "pointPt": [ + 629, + 609 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/BC2 gs", + "0 g 584 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-BC-only-3", + "case": "mask-BC-only", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-bc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": [ + 0, + 0.5, + 1 + ], + "pointPt": [ + 761, + 609 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/BC3 gs", + "0 g 716 588 90 42 re f", + "Q" + ] + }, + { + "id": "mask-opaque-over-BC-0", + "case": "mask-opaque-over-BC", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 513 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/OpaqueBC gs", + "0 g 320 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-opaque-over-BC-1", + "case": "mask-opaque-over-BC", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 513 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/OpaqueBC gs", + "0 g 452 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-opaque-over-BC-2", + "case": "mask-opaque-over-BC", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 513 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/OpaqueBC gs", + "0 g 584 492 90 42 re f", + "Q" + ] + }, + { + "id": "mask-opaque-over-BC-3", + "case": "mask-opaque-over-BC", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "luminosity-icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 513 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/OpaqueBC gs", + "0 g 716 492 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-page-initial-0", + "case": "tiling-page-initial", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 417 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Tile scn 320 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-page-initial-1", + "case": "tiling-page-initial", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 417 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Tile scn 452 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-page-initial-2", + "case": "tiling-page-initial", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 417 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Tile scn 584 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-page-initial-3", + "case": "tiling-page-initial", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 417 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Tile scn 716 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-ri-0", + "case": "tiling-colored-ri", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 321 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /TileRi1 scn 320 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-ri-1", + "case": "tiling-colored-ri", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 321 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /TileRi2 scn 452 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-ri-2", + "case": "tiling-colored-ri", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 321 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /TileRi3 scn 584 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-ri-3", + "case": "tiling-colored-ri", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 321 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /TileRi0 scn 716 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-gs-0", + "case": "tiling-colored-gs", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 225 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /TileGs1 scn 320 204 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-gs-1", + "case": "tiling-colored-gs", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 225 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /TileGs2 scn 452 204 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-gs-2", + "case": "tiling-colored-gs", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 225 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /TileGs3 scn 584 204 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-gs-3", + "case": "tiling-colored-gs", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 225 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /TileGs0 scn 716 204 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-qQ-0", + "case": "tiling-colored-qQ", + "column": 0, + "page": 2, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 129 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /TileQ1 scn 320 108 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-qQ-1", + "case": "tiling-colored-qQ", + "column": 1, + "page": 2, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 129 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /TileQ2 scn 452 108 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-qQ-2", + "case": "tiling-colored-qQ", + "column": 2, + "page": 2, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 129 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /TileQ3 scn 584 108 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-colored-qQ-3", + "case": "tiling-colored-qQ", + "column": 3, + "page": 2, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 129 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /TileQ0 scn 716 108 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-form-initial-0", + "case": "tiling-form-initial", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 705 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 320 684 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-1", + "case": "tiling-form-initial", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 705 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 452 684 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-2", + "case": "tiling-form-initial", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 705 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 684 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-3", + "case": "tiling-form-initial", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 705 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 1 716 684 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-reuse-0", + "case": "tiling-form-initial-reuse", + "column": 0, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 609 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 320 588 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-reuse-1", + "case": "tiling-form-initial-reuse", + "column": 1, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 609 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 452 588 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-reuse-2", + "case": "tiling-form-initial-reuse", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 609 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 588 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-initial-reuse-3", + "case": "tiling-form-initial-reuse", + "column": 3, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 609 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 716 588 cm /TileForm Do", + "Q" + ] + }, + { + "id": "tiling-form-ri-0", + "case": "tiling-form-ri", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 513 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 320 492 cm /TileFormRi1 Do", + "Q" + ] + }, + { + "id": "tiling-form-ri-1", + "case": "tiling-form-ri", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 513 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 452 492 cm /TileFormRi2 Do", + "Q" + ] + }, + { + "id": "tiling-form-ri-2", + "case": "tiling-form-ri", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 513 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 492 cm /TileFormRi3 Do", + "Q" + ] + }, + { + "id": "tiling-form-ri-3", + "case": "tiling-form-ri", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 513 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 1 716 492 cm /TileFormRi0 Do", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-0", + "case": "tiling-uncolored-fill", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 417 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/PCS cs 0 0.75 0.25 0 /U scn 320 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-1", + "case": "tiling-uncolored-fill", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 417 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/PCS cs 0 0.75 0.25 0 /U scn 452 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-2", + "case": "tiling-uncolored-fill", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 417 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/PCS cs 0 0.75 0.25 0 /U scn 584 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-3", + "case": "tiling-uncolored-fill", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 417 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/PCS cs 0 0.75 0.25 0 /U scn 716 396 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-gs-0", + "case": "tiling-uncolored-fill-gs", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 321 + ], + "commandSequence": [ + "q", + "/I0 gs", + "/PCS cs 0 0.75 0.25 0 /U scn 320 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-gs-1", + "case": "tiling-uncolored-fill-gs", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 321 + ], + "commandSequence": [ + "q", + "/I1 gs", + "/PCS cs 0 0.75 0.25 0 /U scn 452 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-gs-2", + "case": "tiling-uncolored-fill-gs", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 321 + ], + "commandSequence": [ + "q", + "/I2 gs", + "/PCS cs 0 0.75 0.25 0 /U scn 584 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-fill-gs-3", + "case": "tiling-uncolored-fill-gs", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 321 + ], + "commandSequence": [ + "q", + "/I3 gs", + "/PCS cs 0 0.75 0.25 0 /U scn 716 300 90 42 re f", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-small-0", + "case": "tiling-uncolored-stroke-small", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 225 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /U SCN", + "30 w 320 225 m 410 225 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-small-1", + "case": "tiling-uncolored-stroke-small", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 225 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /U SCN", + "30 w 452 225 m 542 225 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-small-2", + "case": "tiling-uncolored-stroke-small", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 225 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /U SCN", + "30 w 584 225 m 674 225 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-small-3", + "case": "tiling-uncolored-stroke-small", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 225 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /U SCN", + "30 w 716 225 m 806 225 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-large-0", + "case": "tiling-uncolored-stroke-large", + "column": 0, + "page": 3, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 129 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /ULarge SCN", + "30 w 320 129 m 410 129 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-large-1", + "case": "tiling-uncolored-stroke-large", + "column": 1, + "page": 3, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 129 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /ULarge SCN", + "30 w 452 129 m 542 129 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-large-2", + "case": "tiling-uncolored-stroke-large", + "column": 2, + "page": 3, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 129 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /ULarge SCN", + "30 w 584 129 m 674 129 l S", + "Q" + ] + }, + { + "id": "tiling-uncolored-stroke-large-3", + "case": "tiling-uncolored-stroke-large", + "column": 3, + "page": 3, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 129 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 rg", + "/PCS CS 0 0.75 0.25 0 /ULarge SCN", + "30 w 716 129 m 806 129 l S", + "Q" + ] + }, + { + "id": "shading-page-initial-0", + "case": "shading-page-initial", + "column": 0, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 705 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Shade scn 320 684 90 42 re f", + "Q" + ] + }, + { + "id": "shading-page-initial-1", + "case": "shading-page-initial", + "column": 1, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 705 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Shade scn 452 684 90 42 re f", + "Q" + ] + }, + { + "id": "shading-page-initial-2", + "case": "shading-page-initial", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 705 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Shade scn 584 684 90 42 re f", + "Q" + ] + }, + { + "id": "shading-page-initial-3", + "case": "shading-page-initial", + "column": 3, + "page": 4, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 705 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Shade scn 716 684 90 42 re f", + "Q" + ] + }, + { + "id": "shading-pattern-ri-0", + "case": "shading-pattern-ri", + "column": 0, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 609 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /ShadeRi1 scn 320 588 90 42 re f", + "Q" + ] + }, + { + "id": "shading-pattern-ri-1", + "case": "shading-pattern-ri", + "column": 1, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 609 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /ShadeRi2 scn 452 588 90 42 re f", + "Q" + ] + }, + { + "id": "shading-pattern-ri-2", + "case": "shading-pattern-ri", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 609 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /ShadeRi3 scn 584 588 90 42 re f", + "Q" + ] + }, + { + "id": "shading-pattern-ri-3", + "case": "shading-pattern-ri", + "column": 3, + "page": 4, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 609 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /ShadeRi0 scn 716 588 90 42 re f", + "Q" + ] + }, + { + "id": "shading-form-initial-0", + "case": "shading-form-initial", + "column": 0, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 513 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 320 492 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-1", + "case": "shading-form-initial", + "column": 1, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 513 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 452 492 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-2", + "case": "shading-form-initial", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 513 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 492 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-3", + "case": "shading-form-initial", + "column": 3, + "page": 4, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 513 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 1 716 492 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-reuse-0", + "case": "shading-form-initial-reuse", + "column": 0, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 417 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 320 396 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-reuse-1", + "case": "shading-form-initial-reuse", + "column": 1, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 417 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 452 396 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-reuse-2", + "case": "shading-form-initial-reuse", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 417 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 396 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-initial-reuse-3", + "case": "shading-form-initial-reuse", + "column": 3, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 417 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 716 396 cm /ShadeForm Do", + "Q" + ] + }, + { + "id": "shading-form-ri-0", + "case": "shading-form-ri", + "column": 0, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 321 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "1 0 0 1 320 300 cm /ShadeFormRi1 Do", + "Q" + ] + }, + { + "id": "shading-form-ri-1", + "case": "shading-form-ri", + "column": 1, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 321 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "1 0 0 1 452 300 cm /ShadeFormRi2 Do", + "Q" + ] + }, + { + "id": "shading-form-ri-2", + "case": "shading-form-ri", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 321 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "1 0 0 1 584 300 cm /ShadeFormRi3 Do", + "Q" + ] + }, + { + "id": "shading-form-ri-3", + "case": "shading-form-ri", + "column": 3, + "page": 4, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 321 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "1 0 0 1 716 300 cm /ShadeFormRi0 Do", + "Q" + ] + }, + { + "id": "direct-shading-control-0", + "case": "direct-shading-control", + "column": 0, + "page": 4, + "callerIntentIndex": 0, + "callerIntent": "Perceptual", + "expectedIntentIndex": 0, + "expectedIntent": "Perceptual", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 365, + 225 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "320 204 90 42 re W n", + "/A sh", + "Q" + ] + }, + { + "id": "direct-shading-control-1", + "case": "direct-shading-control", + "column": 1, + "page": 4, + "callerIntentIndex": 1, + "callerIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "expectedIntent": "RelativeColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 497, + 225 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "452 204 90 42 re W n", + "/A sh", + "Q" + ] + }, + { + "id": "direct-shading-control-2", + "case": "direct-shading-control", + "column": 2, + "page": 4, + "callerIntentIndex": 2, + "callerIntent": "Saturation", + "expectedIntentIndex": 2, + "expectedIntent": "Saturation", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 629, + 225 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "584 204 90 42 re W n", + "/A sh", + "Q" + ] + }, + { + "id": "direct-shading-control-3", + "case": "direct-shading-control", + "column": 3, + "page": 4, + "callerIntentIndex": 3, + "callerIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "expectedIntent": "AbsoluteColorimetric", + "oracle": "icc", + "components": [ + 0, + 0.75, + 0.25, + 0 + ], + "backgroundRgb": null, + "pointPt": [ + 761, + 225 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "716 204 90 42 re W n", + "/A sh", + "Q" + ] + } + ], + "specification": { + "url": "https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf", + "printedPages": { + "uncoloredForbiddenOperators": 218, + "tilingInitialState": 223, + "shadingInitialState": 231, + "deviceRgbGray": 377, + "luminosityMask": 440, + "softMaskDictionary": 446, + "transparentPatterns": 453 + } + }, + "luminosityOracle": { + "weights": [ + 0.3, + 0.59, + 0.11 + ], + "foregroundRgb": [ + 0, + 0, + 0 + ], + "pageBackgroundRgb": [ + 255, + 255, + 255 + ], + "formula": "round(255 - dot(weights, directCmmRgb8))", + "qualification": "DeviceRGB gray conversion is device-dependent; these are the PDF recommended weights. Intent dispatch is tested separately from that numerical convention." + } +} diff --git a/tests/fixtures/pdf/link-borders/invalid-ap.pdf b/tests/fixtures/pdf/link-borders/invalid-ap.pdf new file mode 100644 index 0000000..8c7c100 --- /dev/null +++ b/tests/fixtures/pdf/link-borders/invalid-ap.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /AP << /N 42 >> >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +310 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/invalid-color.pdf b/tests/fixtures/pdf/link-borders/invalid-color.pdf new file mode 100644 index 0000000..5906f0e --- /dev/null +++ b/tests/fixtures/pdf/link-borders/invalid-color.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /C [0 1] >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +303 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/invalid-dash.pdf b/tests/fixtures/pdf/link-borders/invalid-dash.pdf new file mode 100644 index 0000000..66cd5eb --- /dev/null +++ b/tests/fixtures/pdf/link-borders/invalid-dash.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [0 0] >> >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +319 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/manifest.json b/tests/fixtures/pdf/link-borders/manifest.json new file mode 100644 index 0000000..0a1e96f --- /dev/null +++ b/tests/fixtures/pdf/link-borders/manifest.json @@ -0,0 +1,266 @@ +{ + "generator": "generate_link_borders.py", + "license": "Self-created test content; same license as DocView.", + "fixtures": [ + { + "file": "styles.pdf", + "bytes": 5335, + "sha256": "add7f02f5295f39b9bd340151f7c7ea0b50786c89f66af791215df2c13badf1b", + "pages": 4, + "annotations": [ + { + "name": "default", + "rect": [ + 40, + 50, + 140, + 80 + ], + "has_border": true + }, + { + "name": "solid-rgb", + "rect": [ + 160, + 50, + 260, + 80 + ], + "has_border": true + }, + { + "name": "dashed-border", + "rect": [ + 280, + 50, + 380, + 80 + ], + "has_border": true + }, + { + "name": "dashed-bs-odd", + "rect": [ + 400, + 50, + 500, + 80 + ], + "has_border": true + }, + { + "name": "dashed-default", + "rect": [ + 40, + 125, + 140, + 155 + ], + "has_border": true + }, + { + "name": "underline", + "rect": [ + 160, + 125, + 260, + 155 + ], + "has_border": true + }, + { + "name": "bs-overrides-border", + "rect": [ + 280, + 125, + 380, + 155 + ], + "has_border": true + }, + { + "name": "zero-width", + "rect": [ + 400, + 125, + 500, + 155 + ], + "has_border": false + }, + { + "name": "transparent", + "rect": [ + 40, + 200, + 140, + 230 + ], + "has_border": false + }, + { + "name": "hidden", + "rect": [ + 160, + 200, + 260, + 230 + ], + "has_border": false + }, + { + "name": "no-view", + "rect": [ + 280, + 200, + 380, + 230 + ], + "has_border": false + }, + { + "name": "print-flag-visible", + "rect": [ + 400, + 200, + 500, + 230 + ], + "has_border": true + }, + { + "name": "gray", + "rect": [ + 40, + 275, + 140, + 305 + ], + "has_border": true + }, + { + "name": "cmyk", + "rect": [ + 160, + 275, + 260, + 305 + ], + "has_border": true + }, + { + "name": "rounded", + "rect": [ + 280, + 275, + 380, + 305 + ], + "has_border": true + }, + { + "name": "beveled", + "rect": [ + 400, + 275, + 500, + 305 + ], + "has_border": true + }, + { + "name": "inset", + "rect": [ + 40, + 350, + 140, + 380 + ], + "has_border": true + }, + { + "name": "appearance-preserved", + "rect": [ + 160, + 350, + 260, + 380 + ], + "has_border": true + }, + { + "name": "empty-appearance-preserved", + "rect": [ + 280, + 350, + 380, + 380 + ], + "has_border": false + }, + { + "name": "alpha", + "rect": [ + 400, + 350, + 500, + 380 + ], + "has_border": true + } + ], + "reference_limitations": [ + "Poppler ignores gray/CMYK border colors, rounded corners, bevel/inset, and alpha in its no-AP link fallback; assert these independently.", + "Saved normal AP must be preserved without adding a second border, even if Poppler adds one." + ] + }, + { + "file": "unknown-style.pdf", + "bytes": 479, + "sha256": "d4143011ea2114f4fbb0d39c10b4dac9d4a66a58cc6af6f70ee8d3feb6dc4fc5", + "render_error": "E_PDF_ANNOT_UNSUPPORTED" + }, + { + "file": "no-zoom.pdf", + "bytes": 462, + "sha256": "e84b3c14db58235d6d4020abb99e3ed76cb2b95b49a4a134a3e41957270b8bfe", + "expected": "Valid annotation flag; rendering succeeds. Geometry coverage is in annotation-flags/flags.pdf." + }, + { + "file": "no-rotate.pdf", + "bytes": 463, + "sha256": "ffacbbb08144ad880b6d582fa90199fabea4935601ceed5f9acc17a8d4231656", + "expected": "Valid annotation flag; rendering succeeds. Geometry coverage is in annotation-flags/flags.pdf." + }, + { + "file": "invalid-dash.pdf", + "bytes": 482, + "sha256": "41ad1db315a360b98bb84c2fb44e5cb65eff5a690d9246fb3cd18b1f262858d7", + "render_error": "E_PDF_CORRUPT" + }, + { + "file": "oversized-dash.pdf", + "bytes": 609, + "sha256": "51ecdef6659ea919b38985d21e4cc65f352f22654ecf47efd1670eea1b931640", + "render_error": "E_LIMIT_EXCEEDED" + }, + { + "file": "invalid-color.pdf", + "bytes": 466, + "sha256": "eeb32339cc56e21cb6c3f2964c035746e0e233cfb694eb9bb9dafd1850010465", + "render_error": "E_PDF_CORRUPT" + }, + { + "file": "invalid-ap.pdf", + "bytes": 473, + "sha256": "d604660b85493a4fe7f68062597b0e0f08f4a9d26029ad1b11afa661c629502b", + "render_error": "E_PDF_CORRUPT" + }, + { + "file": "retry-budget.pdf", + "bytes": 127908, + "sha256": "bb1145397f88a941e1597ed485fcab7a141bd6b11a82403a96bd646ec8d28e79", + "expected": "Forty failed page-0 decodes must not charge discarded appearances or prevent valid page-1 rendering." + } + ] +} diff --git a/tests/fixtures/pdf/link-borders/no-rotate.pdf b/tests/fixtures/pdf/link-borders/no-rotate.pdf new file mode 100644 index 0000000..14e0040 --- /dev/null +++ b/tests/fixtures/pdf/link-borders/no-rotate.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /F 16 >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +300 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/no-zoom.pdf b/tests/fixtures/pdf/link-borders/no-zoom.pdf new file mode 100644 index 0000000..5a3629c --- /dev/null +++ b/tests/fixtures/pdf/link-borders/no-zoom.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /F 8 >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +299 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/oversized-dash.pdf b/tests/fixtures/pdf/link-borders/oversized-dash.pdf new file mode 100644 index 0000000..0499512 --- /dev/null +++ b/tests/fixtures/pdf/link-borders/oversized-dash.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +446 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/retry-budget.pdf b/tests/fixtures/pdf/link-borders/retry-budget.pdf new file mode 100644 index 0000000..c5717b1 --- /dev/null +++ b/tests/fixtures/pdf/link-borders/retry-budget.pdf @@ -0,0 +1,2078 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R 34 0 R 35 0 R 36 0 R 37 0 R 38 0 R 39 0 R 40 0 R 41 0 R 42 0 R 43 0 R 44 0 R 45 0 R 46 0 R 47 0 R 48 0 R 49 0 R 50 0 R 51 0 R 52 0 R 53 0 R 54 0 R 55 0 R 56 0 R 57 0 R 58 0 R 59 0 R 60 0 R 61 0 R 62 0 R 63 0 R 64 0 R 65 0 R 66 0 R 67 0 R 68 0 R 69 0 R 70 0 R 71 0 R 72 0 R 73 0 R 74 0 R 75 0 R 76 0 R 77 0 R 78 0 R 79 0 R 80 0 R 81 0 R 82 0 R 83 0 R 84 0 R 85 0 R 86 0 R 87 0 R 88 0 R 89 0 R 90 0 R 91 0 R 92 0 R 93 0 R 94 0 R 95 0 R 96 0 R 97 0 R 98 0 R 99 0 R 100 0 R 101 0 R 102 0 R 103 0 R 104 0 R 105 0 R 106 0 R 107 0 R 108 0 R 109 0 R 110 0 R 111 0 R 112 0 R 113 0 R 114 0 R 115 0 R 116 0 R 117 0 R 118 0 R 119 0 R 120 0 R 121 0 R 122 0 R 123 0 R 124 0 R 125 0 R 126 0 R 127 0 R 128 0 R 129 0 R 130 0 R 131 0 R 132 0 R 133 0 R 134 0 R 135 0 R 136 0 R 137 0 R 138 0 R 139 0 R 140 0 R 141 0 R 142 0 R 143 0 R 144 0 R 145 0 R 146 0 R 147 0 R 148 0 R 149 0 R 150 0 R 151 0 R 152 0 R 153 0 R 154 0 R 155 0 R 156 0 R 157 0 R 158 0 R 159 0 R 160 0 R 161 0 R 162 0 R 163 0 R 164 0 R 165 0 R 166 0 R 167 0 R 168 0 R 169 0 R 170 0 R 171 0 R 172 0 R 173 0 R 174 0 R 175 0 R 176 0 R 177 0 R 178 0 R 179 0 R 180 0 R 181 0 R 182 0 R 183 0 R 184 0 R 185 0 R 186 0 R 187 0 R 188 0 R 189 0 R 190 0 R 191 0 R 192 0 R 193 0 R 194 0 R 195 0 R 196 0 R 197 0 R 198 0 R 199 0 R 200 0 R 201 0 R 202 0 R 203 0 R 204 0 R 205 0 R 206 0 R 207 0 R 208 0 R 209 0 R 210 0 R 211 0 R 212 0 R 213 0 R 214 0 R 215 0 R 216 0 R 217 0 R 218 0 R 219 0 R 220 0 R 221 0 R 222 0 R 223 0 R 224 0 R 225 0 R 226 0 R 227 0 R 228 0 R 229 0 R 230 0 R 231 0 R 232 0 R 233 0 R 234 0 R 235 0 R 236 0 R 237 0 R 238 0 R 239 0 R 240 0 R 241 0 R 242 0 R 243 0 R 244 0 R 245 0 R 246 0 R 247 0 R 248 0 R 249 0 R 250 0 R 251 0 R 252 0 R 253 0 R 254 0 R 255 0 R 256 0 R 257 0 R 258 0 R 259 0 R 260 0 R 261 0 R 262 0 R 263 0 R 264 0 R 265 0 R 266 0 R 267 0 R 268 0 R 269 0 R 270 0 R 271 0 R 272 0 R 273 0 R 274 0 R 275 0 R 276 0 R 277 0 R 278 0 R 279 0 R 280 0 R 281 0 R 282 0 R 283 0 R 284 0 R 285 0 R 286 0 R 287 0 R 288 0 R 289 0 R 290 0 R 291 0 R 292 0 R 293 0 R 294 0 R 295 0 R 296 0 R 297 0 R 298 0 R 299 0 R 300 0 R 301 0 R 302 0 R 303 0 R 304 0 R 305 0 R 306 0 R 307 0 R 308 0 R 309 0 R 310 0 R 311 0 R 312 0 R 313 0 R 314 0 R 315 0 R 316 0 R 317 0 R 318 0 R 319 0 R 320 0 R 321 0 R 322 0 R 323 0 R 324 0 R 325 0 R 326 0 R 327 0 R 328 0 R 329 0 R 330 0 R 331 0 R 332 0 R 333 0 R 334 0 R 335 0 R 336 0 R 337 0 R 338 0 R 339 0 R 340 0 R 341 0 R 342 0 R 343 0 R 344 0 R 345 0 R 346 0 R 347 0 R 348 0 R 349 0 R 350 0 R 351 0 R 352 0 R 353 0 R 354 0 R 355 0 R 356 0 R 357 0 R 358 0 R 359 0 R 360 0 R 361 0 R 362 0 R 363 0 R 364 0 R 365 0 R 366 0 R 367 0 R 368 0 R 369 0 R 370 0 R 371 0 R 372 0 R 373 0 R 374 0 R 375 0 R 376 0 R 377 0 R 378 0 R 379 0 R 380 0 R 381 0 R 382 0 R 383 0 R 384 0 R 385 0 R 386 0 R 387 0 R 388 0 R 389 0 R 390 0 R 391 0 R 392 0 R 393 0 R 394 0 R 395 0 R 396 0 R 397 0 R 398 0 R 399 0 R 400 0 R 401 0 R 402 0 R 403 0 R 404 0 R 405 0 R 406 0 R 407 0 R 408 0 R 409 0 R 410 0 R 411 0 R 412 0 R 413 0 R 414 0 R 415 0 R 416 0 R 417 0 R 418 0 R 419 0 R 420 0 R 421 0 R 422 0 R 423 0 R 424 0 R 425 0 R 426 0 R 427 0 R 428 0 R 429 0 R 430 0 R 431 0 R 432 0 R 433 0 R 434 0 R 435 0 R 436 0 R 437 0 R 438 0 R 439 0 R 440 0 R 441 0 R 442 0 R 443 0 R 444 0 R 445 0 R 446 0 R 447 0 R 448 0 R 449 0 R 450 0 R 451 0 R 452 0 R 453 0 R 454 0 R 455 0 R 456 0 R 457 0 R 458 0 R 459 0 R 460 0 R 461 0 R 462 0 R 463 0 R 464 0 R 465 0 R 466 0 R 467 0 R 468 0 R 469 0 R 470 0 R 471 0 R 472 0 R 473 0 R 474 0 R 475 0 R 476 0 R 477 0 R 478 0 R 479 0 R 480 0 R 481 0 R 482 0 R 483 0 R 484 0 R 485 0 R 486 0 R 487 0 R 488 0 R 489 0 R 490 0 R 491 0 R 492 0 R 493 0 R 494 0 R 495 0 R 496 0 R 497 0 R 498 0 R 499 0 R 500 0 R 501 0 R 502 0 R 503 0 R 504 0 R 505 0 R 506 0 R 507 0 R 508 0 R 509 0 R 510 0 R 511 0 R 512 0 R 513 0 R 514 0 R 515 0 R 516 0 R 517 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [5 0 R] >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +6 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +7 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +8 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +9 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +10 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +11 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +12 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +13 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +17 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +18 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +19 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +20 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +21 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +22 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +23 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +24 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +25 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +26 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +27 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +28 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +29 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +30 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +31 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +32 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +33 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +34 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +35 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +36 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +37 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +38 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +39 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +40 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +41 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +42 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +43 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +44 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +45 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +46 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +47 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +48 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +49 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +50 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +51 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +52 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +53 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +54 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +55 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +56 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +57 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +58 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +59 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +60 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +61 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +62 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +63 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +64 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +65 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +66 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +67 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +68 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +69 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +70 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +71 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +72 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +73 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +74 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +75 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +76 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +77 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +78 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +79 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +80 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +81 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +82 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +83 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +84 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +85 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +86 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +87 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +88 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +89 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +90 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +91 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +92 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +93 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +94 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +95 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +96 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +97 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +98 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +99 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +100 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +101 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +102 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +103 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +104 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +105 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +106 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +107 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +108 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +109 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +110 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +111 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +112 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +113 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +114 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +115 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +116 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +117 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +118 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +119 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +120 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +121 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +122 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +123 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +124 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +125 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +126 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +127 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +128 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +129 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +130 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +131 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +132 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +133 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +134 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +135 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +136 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +137 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +138 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +139 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +140 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +141 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +142 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +143 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +144 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +145 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +146 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +147 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +148 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +149 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +150 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +151 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +152 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +153 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +154 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +155 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +156 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +157 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +158 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +159 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +160 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +161 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +162 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +163 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +164 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +165 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +166 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +167 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +168 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +169 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +170 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +171 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +172 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +173 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +174 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +175 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +176 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +177 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +178 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +179 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +180 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +181 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +182 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +183 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +184 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +185 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +186 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +187 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +188 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +189 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +190 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +191 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +192 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +193 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +194 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +195 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +196 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +197 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +198 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +199 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +200 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +201 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +202 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +203 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +204 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +205 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +206 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +207 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +208 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +209 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +210 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +211 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +212 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +213 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +214 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +215 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +216 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +217 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +218 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +219 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +220 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +221 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +222 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +223 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +224 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +225 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +226 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +227 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +228 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +229 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +230 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +231 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +232 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +233 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +234 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +235 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +236 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +237 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +238 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +239 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +240 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +241 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +242 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +243 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +244 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +245 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +246 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +247 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +248 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +249 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +250 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +251 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +252 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +253 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +254 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +255 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +256 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +257 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +258 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +259 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +260 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +261 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +262 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +263 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +264 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +265 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +266 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +267 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +268 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +269 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +270 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +271 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +272 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +273 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +274 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +275 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +276 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +277 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +278 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +279 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +280 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +281 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +282 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +283 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +284 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +285 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +286 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +287 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +288 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +289 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +290 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +291 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +292 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +293 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +294 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +295 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +296 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +297 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +298 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +299 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +300 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +301 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +302 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +303 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +304 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +305 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +306 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +307 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +308 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +309 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +310 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +311 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +312 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +313 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +314 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +315 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +316 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +317 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +318 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +319 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +320 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +321 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +322 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +323 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +324 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +325 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +326 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +327 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +328 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +329 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +330 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +331 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +332 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +333 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +334 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +335 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +336 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +337 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +338 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +339 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +340 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +341 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +342 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +343 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +344 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +345 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +346 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +347 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +348 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +349 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +350 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +351 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +352 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +353 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +354 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +355 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +356 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +357 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +358 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +359 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +360 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +361 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +362 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +363 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +364 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +365 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +366 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +367 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +368 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +369 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +370 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +371 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +372 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +373 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +374 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +375 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +376 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +377 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +378 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +379 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +380 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +381 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +382 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +383 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +384 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +385 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +386 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +387 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +388 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +389 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +390 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +391 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +392 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +393 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +394 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +395 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +396 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +397 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +398 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +399 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +400 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +401 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +402 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +403 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +404 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +405 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +406 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +407 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +408 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +409 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +410 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +411 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +412 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +413 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +414 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +415 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +416 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +417 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +418 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +419 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +420 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +421 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +422 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +423 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +424 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +425 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +426 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +427 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +428 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +429 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +430 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +431 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +432 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +433 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +434 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +435 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +436 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +437 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +438 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +439 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +440 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +441 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +442 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +443 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +444 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +445 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +446 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +447 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +448 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +449 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +450 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +451 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +452 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +453 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +454 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +455 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +456 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +457 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +458 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +459 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +460 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +461 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +462 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +463 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +464 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +465 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +466 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +467 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +468 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +469 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +470 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +471 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +472 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +473 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +474 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +475 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +476 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +477 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +478 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +479 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +480 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +481 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +482 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +483 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +484 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +485 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +486 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +487 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +488 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +489 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +490 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +491 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +492 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +493 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +494 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +495 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +496 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +497 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +498 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +499 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +500 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +501 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +502 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +503 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +504 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +505 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +506 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +507 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +508 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +509 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +510 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +511 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +512 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +513 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +514 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +515 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +516 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /D /D [1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ] >> >> +endobj +517 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /Unknown >> >> +endobj +xref +0 518 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000127 00000 n +0000004229 00000 n +0000004333 00000 n +0000004552 00000 n +0000004771 00000 n +0000004990 00000 n +0000005209 00000 n +0000005428 00000 n +0000005648 00000 n +0000005868 00000 n +0000006088 00000 n +0000006308 00000 n +0000006528 00000 n +0000006748 00000 n +0000006968 00000 n +0000007188 00000 n +0000007408 00000 n +0000007628 00000 n +0000007848 00000 n +0000008068 00000 n +0000008288 00000 n +0000008508 00000 n +0000008728 00000 n +0000008948 00000 n +0000009168 00000 n +0000009388 00000 n +0000009608 00000 n +0000009828 00000 n +0000010048 00000 n +0000010268 00000 n +0000010488 00000 n +0000010708 00000 n +0000010928 00000 n +0000011148 00000 n +0000011368 00000 n +0000011588 00000 n +0000011808 00000 n +0000012028 00000 n +0000012248 00000 n +0000012468 00000 n +0000012688 00000 n +0000012908 00000 n +0000013128 00000 n +0000013348 00000 n +0000013568 00000 n +0000013788 00000 n +0000014008 00000 n +0000014228 00000 n +0000014448 00000 n +0000014668 00000 n +0000014888 00000 n +0000015108 00000 n +0000015328 00000 n +0000015548 00000 n +0000015768 00000 n +0000015988 00000 n +0000016208 00000 n +0000016428 00000 n +0000016648 00000 n +0000016868 00000 n +0000017088 00000 n +0000017308 00000 n +0000017528 00000 n +0000017748 00000 n +0000017968 00000 n +0000018188 00000 n +0000018408 00000 n +0000018628 00000 n +0000018848 00000 n +0000019068 00000 n +0000019288 00000 n +0000019508 00000 n +0000019728 00000 n +0000019948 00000 n +0000020168 00000 n +0000020388 00000 n +0000020608 00000 n +0000020828 00000 n +0000021048 00000 n +0000021268 00000 n +0000021488 00000 n +0000021708 00000 n +0000021928 00000 n +0000022148 00000 n +0000022368 00000 n +0000022588 00000 n +0000022808 00000 n +0000023028 00000 n +0000023248 00000 n +0000023468 00000 n +0000023688 00000 n +0000023908 00000 n +0000024128 00000 n +0000024348 00000 n +0000024568 00000 n +0000024788 00000 n +0000025008 00000 n +0000025228 00000 n +0000025449 00000 n +0000025670 00000 n +0000025891 00000 n +0000026112 00000 n +0000026333 00000 n +0000026554 00000 n +0000026775 00000 n +0000026996 00000 n +0000027217 00000 n +0000027438 00000 n +0000027659 00000 n +0000027880 00000 n +0000028101 00000 n +0000028322 00000 n +0000028543 00000 n +0000028764 00000 n +0000028985 00000 n +0000029206 00000 n +0000029427 00000 n +0000029648 00000 n +0000029869 00000 n +0000030090 00000 n +0000030311 00000 n +0000030532 00000 n +0000030753 00000 n +0000030974 00000 n +0000031195 00000 n +0000031416 00000 n +0000031637 00000 n +0000031858 00000 n +0000032079 00000 n +0000032300 00000 n +0000032521 00000 n +0000032742 00000 n +0000032963 00000 n +0000033184 00000 n +0000033405 00000 n +0000033626 00000 n +0000033847 00000 n +0000034068 00000 n +0000034289 00000 n +0000034510 00000 n +0000034731 00000 n +0000034952 00000 n +0000035173 00000 n +0000035394 00000 n +0000035615 00000 n +0000035836 00000 n +0000036057 00000 n +0000036278 00000 n +0000036499 00000 n +0000036720 00000 n +0000036941 00000 n +0000037162 00000 n +0000037383 00000 n +0000037604 00000 n +0000037825 00000 n +0000038046 00000 n +0000038267 00000 n +0000038488 00000 n +0000038709 00000 n +0000038930 00000 n +0000039151 00000 n +0000039372 00000 n +0000039593 00000 n +0000039814 00000 n +0000040035 00000 n +0000040256 00000 n +0000040477 00000 n +0000040698 00000 n +0000040919 00000 n +0000041140 00000 n +0000041361 00000 n +0000041582 00000 n +0000041803 00000 n +0000042024 00000 n +0000042245 00000 n +0000042466 00000 n +0000042687 00000 n +0000042908 00000 n +0000043129 00000 n +0000043350 00000 n +0000043571 00000 n +0000043792 00000 n +0000044013 00000 n +0000044234 00000 n +0000044455 00000 n +0000044676 00000 n +0000044897 00000 n +0000045118 00000 n +0000045339 00000 n +0000045560 00000 n +0000045781 00000 n +0000046002 00000 n +0000046223 00000 n +0000046444 00000 n +0000046665 00000 n +0000046886 00000 n +0000047107 00000 n +0000047328 00000 n +0000047549 00000 n +0000047770 00000 n +0000047991 00000 n +0000048212 00000 n +0000048433 00000 n +0000048654 00000 n +0000048875 00000 n +0000049096 00000 n +0000049317 00000 n +0000049538 00000 n +0000049759 00000 n +0000049980 00000 n +0000050201 00000 n +0000050422 00000 n +0000050643 00000 n +0000050864 00000 n +0000051085 00000 n +0000051306 00000 n +0000051527 00000 n +0000051748 00000 n +0000051969 00000 n +0000052190 00000 n +0000052411 00000 n +0000052632 00000 n +0000052853 00000 n +0000053074 00000 n +0000053295 00000 n +0000053516 00000 n +0000053737 00000 n +0000053958 00000 n +0000054179 00000 n +0000054400 00000 n +0000054621 00000 n +0000054842 00000 n +0000055063 00000 n +0000055284 00000 n +0000055505 00000 n +0000055726 00000 n +0000055947 00000 n +0000056168 00000 n +0000056389 00000 n +0000056610 00000 n +0000056831 00000 n +0000057052 00000 n +0000057273 00000 n +0000057494 00000 n +0000057715 00000 n +0000057936 00000 n +0000058157 00000 n +0000058378 00000 n +0000058599 00000 n +0000058820 00000 n +0000059041 00000 n +0000059262 00000 n +0000059483 00000 n +0000059704 00000 n +0000059925 00000 n +0000060146 00000 n +0000060367 00000 n +0000060588 00000 n +0000060809 00000 n +0000061030 00000 n +0000061251 00000 n +0000061472 00000 n +0000061693 00000 n +0000061914 00000 n +0000062135 00000 n +0000062356 00000 n +0000062577 00000 n +0000062798 00000 n +0000063019 00000 n +0000063240 00000 n +0000063461 00000 n +0000063682 00000 n +0000063903 00000 n +0000064124 00000 n +0000064345 00000 n +0000064566 00000 n +0000064787 00000 n +0000065008 00000 n +0000065229 00000 n +0000065450 00000 n +0000065671 00000 n +0000065892 00000 n +0000066113 00000 n +0000066334 00000 n +0000066555 00000 n +0000066776 00000 n +0000066997 00000 n +0000067218 00000 n +0000067439 00000 n +0000067660 00000 n +0000067881 00000 n +0000068102 00000 n +0000068323 00000 n +0000068544 00000 n +0000068765 00000 n +0000068986 00000 n +0000069207 00000 n +0000069428 00000 n +0000069649 00000 n +0000069870 00000 n +0000070091 00000 n +0000070312 00000 n +0000070533 00000 n +0000070754 00000 n +0000070975 00000 n +0000071196 00000 n +0000071417 00000 n +0000071638 00000 n +0000071859 00000 n +0000072080 00000 n +0000072301 00000 n +0000072522 00000 n +0000072743 00000 n +0000072964 00000 n +0000073185 00000 n +0000073406 00000 n +0000073627 00000 n +0000073848 00000 n +0000074069 00000 n +0000074290 00000 n +0000074511 00000 n +0000074732 00000 n +0000074953 00000 n +0000075174 00000 n +0000075395 00000 n +0000075616 00000 n +0000075837 00000 n +0000076058 00000 n +0000076279 00000 n +0000076500 00000 n +0000076721 00000 n +0000076942 00000 n +0000077163 00000 n +0000077384 00000 n +0000077605 00000 n +0000077826 00000 n +0000078047 00000 n +0000078268 00000 n +0000078489 00000 n +0000078710 00000 n +0000078931 00000 n +0000079152 00000 n +0000079373 00000 n +0000079594 00000 n +0000079815 00000 n +0000080036 00000 n +0000080257 00000 n +0000080478 00000 n +0000080699 00000 n +0000080920 00000 n +0000081141 00000 n +0000081362 00000 n +0000081583 00000 n +0000081804 00000 n +0000082025 00000 n +0000082246 00000 n +0000082467 00000 n +0000082688 00000 n +0000082909 00000 n +0000083130 00000 n +0000083351 00000 n +0000083572 00000 n +0000083793 00000 n +0000084014 00000 n +0000084235 00000 n +0000084456 00000 n +0000084677 00000 n +0000084898 00000 n +0000085119 00000 n +0000085340 00000 n +0000085561 00000 n +0000085782 00000 n +0000086003 00000 n +0000086224 00000 n +0000086445 00000 n +0000086666 00000 n +0000086887 00000 n +0000087108 00000 n +0000087329 00000 n +0000087550 00000 n +0000087771 00000 n +0000087992 00000 n +0000088213 00000 n +0000088434 00000 n +0000088655 00000 n +0000088876 00000 n +0000089097 00000 n +0000089318 00000 n +0000089539 00000 n +0000089760 00000 n +0000089981 00000 n +0000090202 00000 n +0000090423 00000 n +0000090644 00000 n +0000090865 00000 n +0000091086 00000 n +0000091307 00000 n +0000091528 00000 n +0000091749 00000 n +0000091970 00000 n +0000092191 00000 n +0000092412 00000 n +0000092633 00000 n +0000092854 00000 n +0000093075 00000 n +0000093296 00000 n +0000093517 00000 n +0000093738 00000 n +0000093959 00000 n +0000094180 00000 n +0000094401 00000 n +0000094622 00000 n +0000094843 00000 n +0000095064 00000 n +0000095285 00000 n +0000095506 00000 n +0000095727 00000 n +0000095948 00000 n +0000096169 00000 n +0000096390 00000 n +0000096611 00000 n +0000096832 00000 n +0000097053 00000 n +0000097274 00000 n +0000097495 00000 n +0000097716 00000 n +0000097937 00000 n +0000098158 00000 n +0000098379 00000 n +0000098600 00000 n +0000098821 00000 n +0000099042 00000 n +0000099263 00000 n +0000099484 00000 n +0000099705 00000 n +0000099926 00000 n +0000100147 00000 n +0000100368 00000 n +0000100589 00000 n +0000100810 00000 n +0000101031 00000 n +0000101252 00000 n +0000101473 00000 n +0000101694 00000 n +0000101915 00000 n +0000102136 00000 n +0000102357 00000 n +0000102578 00000 n +0000102799 00000 n +0000103020 00000 n +0000103241 00000 n +0000103462 00000 n +0000103683 00000 n +0000103904 00000 n +0000104125 00000 n +0000104346 00000 n +0000104567 00000 n +0000104788 00000 n +0000105009 00000 n +0000105230 00000 n +0000105451 00000 n +0000105672 00000 n +0000105893 00000 n +0000106114 00000 n +0000106335 00000 n +0000106556 00000 n +0000106777 00000 n +0000106998 00000 n +0000107219 00000 n +0000107440 00000 n +0000107661 00000 n +0000107882 00000 n +0000108103 00000 n +0000108324 00000 n +0000108545 00000 n +0000108766 00000 n +0000108987 00000 n +0000109208 00000 n +0000109429 00000 n +0000109650 00000 n +0000109871 00000 n +0000110092 00000 n +0000110313 00000 n +0000110534 00000 n +0000110755 00000 n +0000110976 00000 n +0000111197 00000 n +0000111418 00000 n +0000111639 00000 n +0000111860 00000 n +0000112081 00000 n +0000112302 00000 n +0000112523 00000 n +0000112744 00000 n +0000112965 00000 n +0000113186 00000 n +0000113407 00000 n +0000113628 00000 n +0000113849 00000 n +0000114070 00000 n +0000114291 00000 n +0000114512 00000 n +0000114733 00000 n +0000114954 00000 n +0000115175 00000 n +0000115396 00000 n +0000115617 00000 n +0000115838 00000 n +0000116059 00000 n +0000116280 00000 n +0000116501 00000 n +0000116722 00000 n +0000116943 00000 n +0000117164 00000 n +0000117385 00000 n +trailer +<< /Size 518 /Root 1 0 R >> +startxref +117478 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/styles.pdf b/tests/fixtures/pdf/link-borders/styles.pdf new file mode 100644 index 0000000..4ec792c --- /dev/null +++ b/tests/fixtures/pdf/link-borders/styles.pdf @@ -0,0 +1,128 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 4 /Kids [3 0 R 4 0 R 5 0 R 6 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 550 470] /CropBox [20 30 530 450] /Rotate 0 /Resources << >> /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 550 470] /CropBox [20 30 530 450] /Rotate 90 /Resources << >> /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R] >> +endobj +5 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 550 470] /CropBox [20 30 530 450] /Rotate 180 /Resources << >> /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R] >> +endobj +6 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 550 470] /CropBox [20 30 530 450] /Rotate 270 /Resources << >> /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R 27 0 R 28 0 R] >> +endobj +7 0 obj +<< /Length 26 /Type /XObject /Subtype /Form /BBox [0 0 100 30] /Resources << >> >> +stream +0 0.8 0 rg 0 0 100 30 re f +endstream +endobj +8 0 obj +<< /Length 0 /Type /XObject /Subtype /Form /BBox [0 0 100 30] /Resources << >> >> +stream + +endstream +endobj +9 0 obj +<< /Type /Annot /Subtype /Link /Rect [40 50 140 80] /A << /S /URI /URI (https://example.org/default) >> >> +endobj +10 0 obj +<< /Type /Annot /Subtype /Link /Rect [160 50 260 80] /A << /S /URI /URI (https://example.org/solid-rgb) >> /BS << /W 3 /S /S >> /C [0.8 0.1 0.2] >> +endobj +11 0 obj +<< /Type /Annot /Subtype /Link /Rect [280 50 380 80] /A << /S /URI /URI (https://example.org/dashed-border) >> /Border [0 0 2 [6 3]] /C [0 0.2 0.8] >> +endobj +12 0 obj +<< /Type /Annot /Subtype /Link /Rect [400 50 500 80] /A << /S /URI /URI (https://example.org/dashed-bs-odd) >> /BS << /W 2 /S /D /D [7 3 2] >> /C [0.1 0.5 0.2] >> +endobj +13 0 obj +<< /Type /Annot /Subtype /Link /Rect [40 125 140 155] /A << /S /URI /URI (https://example.org/dashed-default) >> /BS << /W 2 /S /D >> >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [160 125 260 155] /A << /S /URI /URI (https://example.org/underline) >> /BS << /W 3 /S /U >> /C [0.6 0.1 0.8] >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [280 125 380 155] /A << /S /URI /URI (https://example.org/bs-overrides-border) >> /Border [9 9 12 [1 1]] /BS << /W 2 /S /S >> /C [0 0.3 0.7] >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Link /Rect [400 125 500 155] /A << /S /URI /URI (https://example.org/zero-width) >> /BS << /W 0 >> >> +endobj +17 0 obj +<< /Type /Annot /Subtype /Link /Rect [40 200 140 230] /A << /S /URI /URI (https://example.org/transparent) >> /BS << /W 4 >> /C [] >> +endobj +18 0 obj +<< /Type /Annot /Subtype /Link /Rect [160 200 260 230] /A << /S /URI /URI (https://example.org/hidden) >> /BS << /W 4 >> /F 2 >> +endobj +19 0 obj +<< /Type /Annot /Subtype /Link /Rect [280 200 380 230] /A << /S /URI /URI (https://example.org/no-view) >> /BS << /W 4 >> /F 32 >> +endobj +20 0 obj +<< /Type /Annot /Subtype /Link /Rect [400 200 500 230] /A << /S /URI /URI (https://example.org/print-flag-visible) >> /BS << /W 2 >> /F 4 >> +endobj +21 0 obj +<< /Type /Annot /Subtype /Link /Rect [40 275 140 305] /A << /S /URI /URI (https://example.org/gray) >> /BS << /W 3 >> /C [0.5] >> +endobj +22 0 obj +<< /Type /Annot /Subtype /Link /Rect [160 275 260 305] /A << /S /URI /URI (https://example.org/cmyk) >> /BS << /W 3 >> /C [1 0 0 0] >> +endobj +23 0 obj +<< /Type /Annot /Subtype /Link /Rect [280 275 380 305] /A << /S /URI /URI (https://example.org/rounded) >> /Border [8 6 2] /C [0.6 0.3 0.1] >> +endobj +24 0 obj +<< /Type /Annot /Subtype /Link /Rect [400 275 500 305] /A << /S /URI /URI (https://example.org/beveled) >> /BS << /W 3 /S /B >> /C [0.2 0.6 0.8] >> +endobj +25 0 obj +<< /Type /Annot /Subtype /Link /Rect [40 350 140 380] /A << /S /URI /URI (https://example.org/inset) >> /BS << /W 3 /S /I >> /C [0.2 0.6 0.8] >> +endobj +26 0 obj +<< /Type /Annot /Subtype /Link /Rect [160 350 260 380] /A << /S /URI /URI (https://example.org/appearance-preserved) >> /BS << /W 8 /S /D >> /C [1 0 0] /AP << /N 7 0 R >> >> +endobj +27 0 obj +<< /Type /Annot /Subtype /Link /Rect [280 350 380 380] /A << /S /URI /URI (https://example.org/empty-appearance-preserved) >> /BS << /W 8 >> /AP << /N 8 0 R >> >> +endobj +28 0 obj +<< /Type /Annot /Subtype /Link /Rect [400 350 500 380] /A << /S /URI /URI (https://example.org/alpha) >> /BS << /W 3 >> /C [0 0 1] /CA 0.5 >> +endobj +xref +0 29 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000139 00000 n +0000000411 00000 n +0000000684 00000 n +0000000958 00000 n +0000001232 00000 n +0000001374 00000 n +0000001489 00000 n +0000001612 00000 n +0000001776 00000 n +0000001943 00000 n +0000002122 00000 n +0000002275 00000 n +0000002441 00000 n +0000002638 00000 n +0000002782 00000 n +0000002932 00000 n +0000003077 00000 n +0000003224 00000 n +0000003381 00000 n +0000003527 00000 n +0000003678 00000 n +0000003837 00000 n +0000004001 00000 n +0000004162 00000 n +0000004352 00000 n +0000004531 00000 n +trailer +<< /Size 29 /Root 1 0 R >> +startxref +4689 +%%EOF diff --git a/tests/fixtures/pdf/link-borders/unknown-style.pdf b/tests/fixtures/pdf/link-borders/unknown-style.pdf new file mode 100644 index 0000000..57ac3e3 --- /dev/null +++ b/tests/fixtures/pdf/link-borders/unknown-style.pdf @@ -0,0 +1,26 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Count 1 /Kids [3 0 R] >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Annots [4 0 R] >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Link /Rect [20 20 80 80] /BS << /S /Unknown >> >> +endobj +xref +0 5 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000225 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +316 +%%EOF diff --git a/tests/fixtures/pdf/missing-appearance.pdf b/tests/fixtures/pdf/missing-appearance.pdf new file mode 100644 index 0000000..208af0f --- /dev/null +++ b/tests/fixtures/pdf/missing-appearance.pdf @@ -0,0 +1,98 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /S /D /St 1 >>] >> /StructTreeRoot 11 0 R /MarkInfo << /Marked true >> /AcroForm << /Fields [16 0 R] /DR << /Font << /F1 7 0 R >> >> /DA (/F1 12 Tf 0 g) >> /Names << /Dests << /Names [(second) [4 0 R /XYZ 40 200 null]] >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [14 0 R 15 0 R 16 0 R 18 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Rotate 90 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +5 0 obj +<< /Length 221 >> +stream +0 g 40 50 40 40 re f +/Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 120 Td (Needle) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 100 Td (Second MCID) Tj ET EMC +endstream +endobj +6 0 obj +<< /Length 49 >> +stream +BT /F1 20 Tf 40 100 Td (Second Page Needle) Tj ET +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +8 0 obj +<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 2 >> +endobj +9 0 obj +<< /Title (First heading) /Parent 8 0 R /Dest [3 0 R /XYZ 60 180 null] /Next 10 0 R >> +endobj +10 0 obj +<< /Title (Named second) /Parent 8 0 R /Dest (second) /Prev 9 0 R >> +endobj +11 0 obj +<< /Type /StructTreeRoot /K [12 0 R 13 0 R] /RoleMap << /Chapter /H1 >> /ParentTree << /Nums [0 [12 0 R 13 0 R 13 0 R]] >> >> +endobj +12 0 obj +<< /Type /StructElem /S /Chapter /P 11 0 R /Pg 3 0 R /K 0 >> +endobj +13 0 obj +<< /Type /StructElem /S /H2 /P 11 0 R /Pg 3 0 R /K [1 2] >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [30 30 90 45] /A << /S /URI /URI (https://example.org/) >> >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [100 30 160 45] /A << /S /Launch /F (/tmp/never-execute) >> >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (ReadonlyField) /V (Saved) /Ff 1 /F 4 /Rect [120 60 160 90] /P 3 0 R >> +endobj +17 0 obj +<< /Length 23 /Type /XObject /Subtype /Form /BBox [0 0 40 30] /Resources << >> >> +stream +1 0 0 rg 0 0 40 30 re f +endstream +endobj +18 0 obj +<< /Type /Annot /Subtype /Text /Rect [180 200 200 220] /Contents (Read-only comment) /F 4 >> +endobj +19 0 obj +<< /Title (DocView PDF fixture) /Author (DocView tests) >> +endobj +xref +0 20 +0000000000 65535 f +0000000015 00000 n +0000000346 00000 n +0000000409 00000 n +0000000615 00000 n +0000000752 00000 n +0000001025 00000 n +0000001125 00000 n +0000001195 00000 n +0000001267 00000 n +0000001369 00000 n +0000001454 00000 n +0000001596 00000 n +0000001673 00000 n +0000001749 00000 n +0000001864 00000 n +0000001980 00000 n +0000002112 00000 n +0000002251 00000 n +0000002360 00000 n +trailer +<< /Size 20 /Root 1 0 R /Info 19 0 R >> +startxref +2435 +%%EOF diff --git a/tests/fixtures/pdf/missing-first-page.pdf b/tests/fixtures/pdf/missing-first-page.pdf new file mode 100644 index 0000000..91f79df --- /dev/null +++ b/tests/fixtures/pdf/missing-first-page.pdf @@ -0,0 +1,98 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /S /D /St 1 >>] >> /StructTreeRoot 11 0 R /MarkInfo << /Marked true >> /AcroForm << /Fields [16 0 R] /DR << /Font << /F1 7 0 R >> >> /DA (/F1 12 Tf 0 g) >> /Names << /Dests << /Names [(second) [4 0 R /XYZ 40 200 null]] >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [null 4 0 R] /Count 2 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [14 0 R 15 0 R 16 0 R 18 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Rotate 90 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +5 0 obj +<< /Length 221 >> +stream +0 g 40 50 40 40 re f +/Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 120 Td (Needle) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 100 Td (Second MCID) Tj ET EMC +endstream +endobj +6 0 obj +<< /Length 49 >> +stream +BT /F1 20 Tf 40 100 Td (Second Page Needle) Tj ET +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +8 0 obj +<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 2 >> +endobj +9 0 obj +<< /Title (First heading) /Parent 8 0 R /Dest [3 0 R /XYZ 60 180 null] /Next 10 0 R >> +endobj +10 0 obj +<< /Title (Named second) /Parent 8 0 R /Dest (second) /Prev 9 0 R >> +endobj +11 0 obj +<< /Type /StructTreeRoot /K [12 0 R 13 0 R] /RoleMap << /Chapter /H1 >> /ParentTree << /Nums [0 [12 0 R 13 0 R 13 0 R]] >> >> +endobj +12 0 obj +<< /Type /StructElem /S /Chapter /P 11 0 R /Pg 3 0 R /K 0 >> +endobj +13 0 obj +<< /Type /StructElem /S /H2 /P 11 0 R /Pg 3 0 R /K [1 2] >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [30 30 90 45] /A << /S /URI /URI (https://example.org/) >> >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [100 30 160 45] /A << /S /Launch /F (/tmp/never-execute) >> >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (ReadonlyField) /V (Saved) /Ff 1 /F 4 /Rect [120 60 160 90] /P 3 0 R /AP << /N 17 0 R >> >> +endobj +17 0 obj +<< /Length 23 /Type /XObject /Subtype /Form /BBox [0 0 40 30] /Resources << >> >> +stream +1 0 0 rg 0 0 40 30 re f +endstream +endobj +18 0 obj +<< /Type /Annot /Subtype /Text /Rect [180 200 200 220] /Contents (Read-only comment) /F 4 >> +endobj +19 0 obj +<< /Title (DocView PDF fixture) /Author (DocView tests) >> +endobj +xref +0 20 +0000000000 65535 f +0000000015 00000 n +0000000346 00000 n +0000000408 00000 n +0000000614 00000 n +0000000751 00000 n +0000001024 00000 n +0000001124 00000 n +0000001194 00000 n +0000001266 00000 n +0000001368 00000 n +0000001453 00000 n +0000001595 00000 n +0000001672 00000 n +0000001748 00000 n +0000001863 00000 n +0000001979 00000 n +0000002131 00000 n +0000002270 00000 n +0000002379 00000 n +trailer +<< /Size 20 /Root 1 0 R /Info 19 0 R >> +startxref +2454 +%%EOF diff --git a/tests/fixtures/pdf/navigation.pdf b/tests/fixtures/pdf/navigation.pdf new file mode 100644 index 0000000..4fea0e7 --- /dev/null +++ b/tests/fixtures/pdf/navigation.pdf @@ -0,0 +1,98 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /S /D /St 1 >>] >> /StructTreeRoot 11 0 R /MarkInfo << /Marked true >> /AcroForm << /Fields [16 0 R] /DR << /Font << /F1 7 0 R >> >> /DA (/F1 12 Tf 0 g) >> /Names << /Dests << /Names [(second) [4 0 R /XYZ 40 200 null]] >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Resources << /Font << /F1 7 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [14 0 R 15 0 R 16 0 R 18 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Rotate 90 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +5 0 obj +<< /Length 221 >> +stream +0 g 40 50 40 40 re f +/Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 120 Td (Needle) Tj ET EMC +/H2 <> BDC BT /F1 12 Tf 60 100 Td (Second MCID) Tj ET EMC +endstream +endobj +6 0 obj +<< /Length 49 >> +stream +BT /F1 20 Tf 40 100 Td (Second Page Needle) Tj ET +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +8 0 obj +<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 2 >> +endobj +9 0 obj +<< /Title (First heading) /Parent 8 0 R /Dest [3 0 R /XYZ 60 180 null] /Next 10 0 R >> +endobj +10 0 obj +<< /Title (Named second) /Parent 8 0 R /Dest (second) /Prev 9 0 R >> +endobj +11 0 obj +<< /Type /StructTreeRoot /K [12 0 R 13 0 R] /RoleMap << /Chapter /H1 >> /ParentTree << /Nums [0 [12 0 R 13 0 R 13 0 R]] >> >> +endobj +12 0 obj +<< /Type /StructElem /S /Chapter /P 11 0 R /Pg 3 0 R /K 0 >> +endobj +13 0 obj +<< /Type /StructElem /S /H2 /P 11 0 R /Pg 3 0 R /K [1 2] >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [30 30 90 45] /A << /S /URI /URI (https://example.org/) >> >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [100 30 160 45] /A << /S /Launch /F (/tmp/never-execute) >> >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (ReadonlyField) /V (Saved) /Ff 1 /F 4 /Rect [120 60 160 90] /P 3 0 R /AP << /N 17 0 R >> >> +endobj +17 0 obj +<< /Length 23 /Type /XObject /Subtype /Form /BBox [0 0 40 30] /Resources << >> >> +stream +1 0 0 rg 0 0 40 30 re f +endstream +endobj +18 0 obj +<< /Type /Annot /Subtype /Text /Rect [180 200 200 220] /Contents (Read-only comment) /F 4 >> +endobj +19 0 obj +<< /Title (DocView PDF fixture) /Author (DocView tests) >> +endobj +xref +0 20 +0000000000 65535 f +0000000015 00000 n +0000000346 00000 n +0000000409 00000 n +0000000615 00000 n +0000000752 00000 n +0000001025 00000 n +0000001125 00000 n +0000001195 00000 n +0000001267 00000 n +0000001369 00000 n +0000001454 00000 n +0000001596 00000 n +0000001673 00000 n +0000001749 00000 n +0000001864 00000 n +0000001980 00000 n +0000002132 00000 n +0000002271 00000 n +0000002380 00000 n +trailer +<< /Size 20 /Root 1 0 R /Info 19 0 R >> +startxref +2455 +%%EOF diff --git a/tests/fixtures/pdf/password.pdf b/tests/fixtures/pdf/password.pdf new file mode 100644 index 0000000..69b2b35 --- /dev/null +++ b/tests/fixtures/pdf/password.pdf @@ -0,0 +1,95 @@ +%PDF-1.7 +%¿÷¢þ +1 0 obj +<< /AcroForm << /DA /DR << /Font << /F1 3 0 R >> >> /Fields [ 4 0 R ] >> /Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 8 >> >> /MarkInfo << /Marked true >> /Names << /Dests << /Names [ <930121e75f2163b68fb966a28e4e23853de74b172d075c2ba92f8957b69b88ea> [ 5 0 R /XYZ 40 200 null ] ] >> >> /Outlines 6 0 R /PageLabels << /Nums [ 0 << /S /r >> 1 << /S /D /St 1 >> ] >> /Pages 7 0 R /StructTreeRoot 8 0 R /Type /Catalog >> +endobj +2 0 obj +<< /Author <9d1c24f3c84b91bf7ab0ba115388fedb979be3495ef656e009e97e6cbd971899> /Title >> +endobj +3 0 obj +<< /BaseFont /Helvetica /Subtype /Type1 /Type /Font >> +endobj +4 0 obj +<< /AP << /N 9 0 R >> /F 4 /FT /Tx /Ff 1 /P 10 0 R /Rect [ 120 60 160 90 ] /Subtype /Widget /T <205888fb48abfc80cc7a5410c6fe8a3ff5dc5b6a25ba0202fa773344d97815cc> /Type /Annot /V <6f0852a4359795436be36fa05f909acf00aee603f75e4a8582573c23ca176aef> >> +endobj +5 0 obj +<< /Contents 11 0 R /MediaBox [ 0 0 300 200 ] /Parent 7 0 R /Resources << /Font << /F1 3 0 R >> >> /Rotate 90 /Type /Page >> +endobj +6 0 obj +<< /Count 2 /First 12 0 R /Last 13 0 R /Type /Outlines >> +endobj +7 0 obj +<< /Count 2 /Kids [ 10 0 R 5 0 R ] /Type /Pages >> +endobj +8 0 obj +<< /K [ 14 0 R 15 0 R ] /ParentTree << /Nums [ 0 [ 14 0 R 15 0 R 15 0 R ] ] >> /RoleMap << /Chapter /H1 >> /Type /StructTreeRoot >> +endobj +9 0 obj +<< /BBox [ 0 0 40 30 ] /Resources << >> /Subtype /Form /Type /XObject /Length 48 /Filter /FlateDecode >> +stream +÷J>áY±[lõn|lt%®ëwaÁ¼ò44úv–­ÏeîúJ4Ão¨®Ñ×èhendstream +endobj +10 0 obj +<< /Annots [ 16 0 R 17 0 R 4 0 R 18 0 R ] /Contents 19 0 R /CropBox [ 20 30 220 230 ] /MediaBox [ 0 0 240 260 ] /Parent 7 0 R /Resources << /Font << /F1 3 0 R >> >> /StructParents 0 /Type /Page >> +endobj +11 0 obj +<< /Length 80 /Filter /FlateDecode >> +stream +i::õª(S×4 hF€ˆ,»܃WáÝq3MÛèT5²ǔ€½ä4ªO ¸KO‰uýíñ·-&|Þöï“;ÛÜ"¸‡À³w‰„Ú§Ûendstream +endobj +12 0 obj +<< /Dest [ 10 0 R /XYZ 60 180 null ] /Next 13 0 R /Parent 6 0 R /Title <3f0660f7e0364deb7a6d9d4f650779613b1b712ce997eae36743e725bfd6b2f0> >> +endobj +13 0 obj +<< /Dest <6c54eec3fdd7b6454631cb7edff8544dd8f9be77710609b3ebbf5f983d080076> /Parent 6 0 R /Prev 12 0 R /Title <00352414b78762c4d009b9918892f3eb6e451db750952440cd80869d86e4c040> >> +endobj +14 0 obj +<< /K 0 /P 8 0 R /Pg 10 0 R /S /Chapter /Type /StructElem >> +endobj +15 0 obj +<< /K [ 1 2 ] /P 8 0 R /Pg 10 0 R /S /H2 /Type /StructElem >> +endobj +16 0 obj +<< /A << /S /URI /URI <9f31a2fc839e3d166438d29c32f24ac7c0ff228493dcdd043cdbdf0d042b9852c2855a841aa8d31b908ffdace9a3f3ff> >> /Rect [ 30 30 90 45 ] /Subtype /Link /Type /Annot >> +endobj +17 0 obj +<< /A << /F /S /Launch >> /Rect [ 100 30 160 45 ] /Subtype /Link /Type /Annot >> +endobj +18 0 obj +<< /Contents /F 4 /Rect [ 180 200 200 220 ] /Subtype /Text /Type /Annot >> +endobj +19 0 obj +<< /Length 160 /Filter /FlateDecode >> +stream +GyèTzî9ޠÐÅû~¦y�߹�m('–È햽Ê;/Ú)[Y‰³­�4õv¿˜ò\.% dÉPQ“,—v�Ì`B!sˆãQb‰†‰.óêÖLÛöWÚÃC+bϳ¿ âÝא^“K¹ñ†jÐýs‹M1Õ]X7¢ fdÜfv4ìJ?ñ¹€5ó-Ã7½ì»ÜÍi_h*ΊÊÑG¾P“¼0W>oendstream +endobj +20 0 obj +<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <8e3a3c770c7192af39f46d574244ff959c9d96bed97e2e6efca10f68c284d0753228ca01dd122e5d93bb7fd442d9be9e> /OE <86589a9df8290fe4ba0630daffd444f5d19acb902d1a56dec5074491b1cf328d> /P -4 /Perms /R 6 /StmF /StdCF /StrF /StdCF /U <298201e5d0e71639e3bbc2a820c9db1c086c46ad5d6d38854af86f29a1e0a156289531fa6128de0c678f7d1c3e84be13> /UE /V 5 >> +endobj +xref +0 21 +0000000000 65535 f +0000000015 00000 n +0000000527 00000 n +0000000729 00000 n +0000000799 00000 n +0000001062 00000 n +0000001202 00000 n +0000001275 00000 n +0000001341 00000 n +0000001488 00000 n +0000001673 00000 n +0000001886 00000 n +0000002037 00000 n +0000002194 00000 n +0000002390 00000 n +0000002467 00000 n +0000002545 00000 n +0000002738 00000 n +0000002934 00000 n +0000003124 00000 n +0000003356 00000 n +trailer << /Info 2 0 R /Root 1 0 R /Size 21 /ID [] /Encrypt 20 0 R >> +startxref +3904 +%%EOF diff --git a/tests/fixtures/pdf/rendering-intents-context/README.md b/tests/fixtures/pdf/rendering-intents-context/README.md new file mode 100644 index 0000000..8b4840e --- /dev/null +++ b/tests/fixtures/pdf/rendering-intents-context/README.md @@ -0,0 +1,24 @@ +# Rendering intent expectations, revision 2 + +The PDF and both ICC profiles are byte-identical to `../rendering-intents`. +Only the eight `shading-pattern-inherit` expected-intent rows are corrected: +an absent pattern ExtGState RI inherits the graphics state at the defining +content stream's entry. These are page streams, with initial RelativeColorimetric. +The later paint-time `ri` does not replace that initial value. + +The original manifest is retained as historical failed-oracle evidence. Revision 2 +records its hash and the correction tool hash. No rendered pixel was used to +select the corrected intent. Explicit pattern RI overrides are unchanged. + +Reference: Adobe PDF Reference 1.4, section 4.6.3, Table 4.23, printed page 231. + +Regenerate into a new directory: + +```sh +python3 tests/cmyk_lut/correct_intent_oracle.py --output NEW_DIRECTORY +``` + +Validate with `tests/cmyk_lut/intents.py --corpus +tests/fixtures/pdf/rendering-intents-context`. The separate four-page +`intent-transparency` fixture covers initial page/Form state, repeated pattern +use, explicit overrides, uncolored fill/stroke and luminosity masks. diff --git a/tests/fixtures/pdf/rendering-intents-context/distinct-cmyk-lab.icc b/tests/fixtures/pdf/rendering-intents-context/distinct-cmyk-lab.icc new file mode 100644 index 0000000..4665539 Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents-context/distinct-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/rendering-intents-context/manifest.json b/tests/fixtures/pdf/rendering-intents-context/manifest.json new file mode 100644 index 0000000..b2808be --- /dev/null +++ b/tests/fixtures/pdf/rendering-intents-context/manifest.json @@ -0,0 +1,5574 @@ +{ + "schemaVersion": 1, + "file": "rendering-intents.pdf", + "sha256": "ce59287f6cb2c256c87fcea00a183fa655a165e5028056f28c8c78ddea6645b8", + "pageSizePt": [ + 820, + 800 + ], + "pageCount": 8, + "profiles": { + "original": { + "file": "original-cmyk-lab.icc", + "sha256": "1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7", + "size": 588, + "origin": "byte-identical existing synthetic fixture", + "mediaWhitePoint": [ + 0.9642, + 1, + 0.8249 + ], + "tables": [ + "A2B0" + ] + }, + "distinct": { + "file": "distinct-cmyk-lab.icc", + "sha256": "b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315", + "size": 984, + "origin": "self-authored extension of the existing CLUT", + "mediaWhitePoint": [ + 0.82, + 0.86, + 0.72 + ], + "tables": [ + "A2B0", + "A2B1", + "A2B2" + ] + } + }, + "baseProfileSha256": "1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7", + "sampleBytes": [ + 51, + 128, + 204, + 77 + ], + "intentOrder": [ + "Perceptual", + "RelativeColorimetric", + "Saturation", + "AbsoluteColorimetric" + ], + "cases": [ + "default-relative", + "ri-vector", + "extgstate-ri", + "nested-q-inner", + "nested-Q-restore", + "form-inherit-shared", + "form-inherit-shared-gs", + "form-ri-override", + "form-gs-override", + "form-return-restore", + "image-inherit-shared-forward", + "image-inherit-shared-reverse", + "image-intent-override", + "image-inherit-shared-gs", + "image-override-return", + "indexed-vector", + "indexed-image", + "stroke", + "text", + "axial-shading", + "shading-pattern-inherit", + "shading-pattern-RI-override", + "type3-inherit-rectangle", + "type3-inherit-image", + "type3-ri-override" + ], + "probes": [ + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 0, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 1, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 2, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 3, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 1 0 0 1 310 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 1 0 0 1 435 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "q 1 0 0 1 560 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 1 0 0 1 685 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 65 cm /Form1 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 65 cm /Form2 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 65 cm /Form3 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 65 cm /Form0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 625 cm /FormGs1 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 625 cm /FormGs2 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 625 cm /FormGs3 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 625 cm /FormGs0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Form1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Form2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Form3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Form0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 0, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 310 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 1, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 435 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 2, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 560 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 3, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 685 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 305 cm /Image1 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 305 cm /Image2 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 305 cm /Image3 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 305 cm /Image0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 310 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 90 0 0 44 435 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 560 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 90 0 0 44 685 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Image1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Image2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Image3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Image0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/IX cs 0 scn 310 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/IX cs 0 scn 435 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/IX cs 0 scn 560 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/IX cs 0 scn 685 65 90 44 re f", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 310 567 m 400 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 435 567 m 525 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 560 567 m 650 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 685 567 m 775 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 318, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 310 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 443, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 435 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 568, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 560 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 693, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 685 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 310 385 90 44 re W n 1 0 0 1 310 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 310, + "xEnd": 400, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 435 385 90 44 re W n 1 0 0 1 435 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 435, + "xEnd": 525, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 560 385 90 44 re W n 1 0 0 1 560 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 560, + "xEnd": 650, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 685 385 90 44 re W n 1 0 0 1 685 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 685, + "xEnd": 775, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Inherited scn 310 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Inherited scn 435 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Inherited scn 560 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Inherited scn 685 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /P1 scn 310 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /P2 scn 435 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /P3 scn 560 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /P0 scn 685 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Rect 44 Tf 1 0 0 1 310 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 435 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Rect 44 Tf 1 0 0 1 560 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 685 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Image 44 Tf 1 0 0 1 310 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 435 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Image 44 Tf 1 0 0 1 560 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 685 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 332, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Override1 44 Tf 1 0 0 1 310 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 457, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Override2 44 Tf 1 0 0 1 435 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 582, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Override3 44 Tf 1 0 0 1 560 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 707, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Override0 44 Tf 1 0 0 1 685 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 0, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 1, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 2, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 3, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 1 0 0 1 310 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 1 0 0 1 435 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "q 1 0 0 1 560 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 1 0 0 1 685 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 65 cm /Form1 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 65 cm /Form2 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 65 cm /Form3 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 65 cm /Form0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 625 cm /FormGs1 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 625 cm /FormGs2 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 625 cm /FormGs3 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 625 cm /FormGs0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Form1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Form2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Form3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Form0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 0, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 310 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 1, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 435 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 2, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 560 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 3, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 685 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 305 cm /Image1 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 305 cm /Image2 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 305 cm /Image3 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 305 cm /Image0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 310 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 90 0 0 44 435 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 560 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 90 0 0 44 685 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Image1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Image2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Image3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Image0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/IX cs 0 scn 310 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/IX cs 0 scn 435 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/IX cs 0 scn 560 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/IX cs 0 scn 685 65 90 44 re f", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 310 567 m 400 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 435 567 m 525 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 560 567 m 650 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 685 567 m 775 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 318, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 310 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 443, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 435 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 568, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 560 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 693, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 685 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 310 385 90 44 re W n 1 0 0 1 310 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 310, + "xEnd": 400, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 435 385 90 44 re W n 1 0 0 1 435 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 435, + "xEnd": 525, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 560 385 90 44 re W n 1 0 0 1 560 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 560, + "xEnd": 650, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 685 385 90 44 re W n 1 0 0 1 685 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 685, + "xEnd": 775, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Inherited scn 310 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Inherited scn 435 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Inherited scn 560 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Inherited scn 685 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /P1 scn 310 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /P2 scn 435 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /P3 scn 560 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /P0 scn 685 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Rect 44 Tf 1 0 0 1 310 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 435 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Rect 44 Tf 1 0 0 1 560 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 685 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Image 44 Tf 1 0 0 1 310 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 435 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Image 44 Tf 1 0 0 1 560 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 685 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 332, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Override1 44 Tf 1 0 0 1 310 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 457, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Override2 44 Tf 1 0 0 1 435 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 582, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Override3 44 Tf 1 0 0 1 560 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 707, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Override0 44 Tf 1 0 0 1 685 625 Tm (A) Tj ET", + "Q" + ] + } + ], + "tolerance8Bit": 4, + "generatorSha256": "822244e98fd6f10ed550882dd5c300609a87c8b9d5552bfbe5157925342849b5", + "derivedProfileModels": { + "A2B0": [ + "100-12*C-15*M-10*Y-35*K", + "18*M-15*C", + "20*Y-12*C" + ], + "A2B1": [ + "100-20*C-20*M-20*Y-40*K", + "40*M-20*C-20*K", + "40*Y-20*C-20*K" + ], + "A2B2": [ + "100-25*C-25*M-20*Y-30*K", + "-40*M+20*C+20*K", + "35*Y-20*M-15*K" + ] + }, + "references": [ + "https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf", + "https://archive.color.org/files/icc32.pdf", + "https://www.littlecms.com/" + ], + "limits": [ + "No measured press profile, human-approved golden, display calibration or broad ICC conformance claim.", + "Independent direct-CMM oracle can share LittleCMS code with PDF renderers; not a separate CMM implementation.", + "Text uses an interior point of the standard Helvetica-Bold H; axial samples account for raster pixel centers." + ], + "oracleRevision": 2, + "oracleCorrection": { + "sourceManifestSha256": "b0db4ee3e57658b09d0ae10fcf8175986c3ab80ccd64442e3718802257ecf543", + "correctorSha256": "1137d7193f36d008c6021ba71b9b8a73faf6df2198935605b0eff491df2e685f", + "case": "shading-pattern-inherit", + "affectedProbesPerScale": 8, + "reason": "A shading pattern without an ExtGState RI uses the graphics state at entry to its defining content stream, not the paint-time RI. These patterns are defined in page streams whose entry RI is RelativeColorimetric.", + "reference": "https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf", + "section": "4.6.3 Table 4.23, printed page 231", + "pdfAndProfilesByteIdentical": true, + "rows": [ + { + "probeIndex": 80, + "previousIntentIndex": 0 + }, + { + "probeIndex": 81, + "previousIntentIndex": 1 + }, + { + "probeIndex": 82, + "previousIntentIndex": 2 + }, + { + "probeIndex": 83, + "previousIntentIndex": 3 + }, + { + "probeIndex": 180, + "previousIntentIndex": 0 + }, + { + "probeIndex": 181, + "previousIntentIndex": 1 + }, + { + "probeIndex": 182, + "previousIntentIndex": 2 + }, + { + "probeIndex": 183, + "previousIntentIndex": 3 + } + ] + } +} diff --git a/tests/fixtures/pdf/rendering-intents-context/original-cmyk-lab.icc b/tests/fixtures/pdf/rendering-intents-context/original-cmyk-lab.icc new file mode 100644 index 0000000..b1b3229 Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents-context/original-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/rendering-intents-context/rendering-intents.pdf b/tests/fixtures/pdf/rendering-intents-context/rendering-intents.pdf new file mode 100644 index 0000000..68f6b4e Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents-context/rendering-intents.pdf differ diff --git a/tests/fixtures/pdf/rendering-intents/README.md b/tests/fixtures/pdf/rendering-intents/README.md new file mode 100644 index 0000000..ea6a059 --- /dev/null +++ b/tests/fixtures/pdf/rendering-intents/README.md @@ -0,0 +1,50 @@ +# ICC rendering-intent regression corpus + +`rendering-intents.pdf` contains 8 pages and 200 interior-color probes. It is self-authored PDF 1.7 test material, with no borrowed press characterization or renderer-generated golden. Its SHA-256 is `ce59287f6cb2c256c87fcea00a183fa655a165e5028056f28c8c78ddea6645b8`. Exact probe coordinates, intended graphics states, content operators and source hashes are in [manifest.json](manifest.json). + +Two ICC v2 CMYK→Lab input profiles exercise the same PDF operations: + +- `original-cmyk-lab.icc` is byte-identical to the existing `../cmyk-lut/synthetic-cmyk-lab.icc` (SHA-256 `1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7`). Its A2B0 table alone cannot distinguish Relative, Saturation and Absolute. +- `distinct-cmyk-lab.icc` extends that self-authored binary layout with separate A2B0/A2B1/A2B2 linear CLUTs and media white point `(0.82, 0.86, 0.72)` (SHA-256 `b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315`). The A2B1/A2B2 black endpoint is Lab `(0,0,0)` to isolate intent selection from a CMM's optional black-point compensation. The original profile is unchanged. + +For CMYK bytes `(51,128,204,77)`, direct LittleCMS 2.19 and Pillow/LittleCMS produce these RGB values: + +| Intent | Original profile | Distinct profile | +|---|---|---| +| Perceptual | 187,165,145 | 187,165,145 | +| RelativeColorimetric | 193,171,151 | 166,132,101 | +| Saturation | 193,171,151 | 127,143,114 | +| AbsoluteColorimetric | 193,171,151 | 153,124,95 | + +The smallest pairwise maximum-channel difference in the distinct profile is 13, greater than twice the fixed 4-level raster tolerance. The runner independently verifies all 64 CLUT vertices from coefficient equations, then computes reference colors directly from the ICC bytes and expected intent using public LittleCMS APIs with floating-point CMYK input. Pillow's 8-bit path cross-checks the eight sample colors within one level. A separate check confirms black-point compensation does not change the distinct-profile sample for any intent. This oracle does not read rendered pixels, but may share LittleCMS algorithms with PDFium/Poppler; it is not an independent CMM implementation. + +The matrix covers all four standard intents, with the declared default and override expectations preserved: + +- `ri` and ExtGState `/RI`; nested `q/Q` changes and restoration. +- Shared Form XObject inheritance through `ri` and `gs`; Form content `ri`/`gs` override; caller state restored after the Form returns. +- One Image XObject without `/Intent`, reused in forward/reverse intent order and with `gs` order Perceptual→Relative→Perceptual→Absolute. Image dictionary `/Intent` overrides the caller and does not change the following vector's state. +- Indexed ICC vector and image; stroking color; standard-font text; axial shading. +- Shading patterns inheriting the caller, and pattern ExtGState `/RI` override. +- Colored Type3 `d0` glyphs with rectangles or images, reusing the same font and charcode 65 under four caller intents, plus glyph-content `ri` override. + +Type3 glyph probe points lie inside a large filled rectangle. Standard-font text samples the interior of the Helvetica-Bold `H` stem. Shading expectations account for the actual sampled raster pixel center. The corpus does not cover unknown intent names, malformed intent types, soft-mask luminosity/compositing, tiling patterns, real printing profiles, arbitrary ICC profiles, physical displays, Windows or human-approved golden images. + +## Generate and validate + +Use Python with pypdf and Pillow; validation also needs a shared LittleCMS2 library. Full comparisons require qpdf, pdftoppm and the project's `pdf-worker-evidence`/`docview-pdf-worker` binaries. A fresh output directory is mandatory. + +```sh +python3 tests/fixtures/pdf/generate_rendering_intents.py --output /path/to/new-corpus +python3 tests/cmyk_lut/intents.py --self-check --output /path/to/new-self-check +python3 tests/cmyk_lut/intents.py --build-dir build \ + --pdfium-library /path/to/selected/libpdfium.so \ + --scales 0.5 1 2 --output /path/to/new-comparison +``` + +`--corpus` selects a newly generated corpus if needed. `--pdfium-library` prepends its directory to the child's library path, verifies the actual `ldd` resolution before rendering, and records its hash; an RPATH selection mismatch fails. The normal worker sandbox and font broker remain active. The script does not replace any installed library. + +`--self-check` exits successfully after validating the fixture/CMM, but deliberately records `success:false` and `renderingAcceptance:false`: it is not a rendering pass. A full report records PDFium and Poppler results separately; overall success currently requires both renderers, all probes and unchanged inputs/binaries. A failed comparison retains every probe and its exact CMM error, command logs, hashes and comparison images. The tolerance is not relaxed for another renderer's discrepancy. + +During initial 1× validation, the fixed unmodified PDFium baseline failed 150/200 probes, consistently exposing its Perceptual-only conversion. Poppler 26.05.0 matched 194/200; the six failures were confined to shading-pattern ExtGState `/RI` override, where its color matched the caller intent. These are diagnostic observations, not acceptance of either mismatch. Final patched-renderer results belong in separate evidence directories. Independent regeneration reproduced all four generated files byte-for-byte. + +References: [ISO 32000-1, clauses 8.4, 8.6.5.8, 8.7.4, 8.9.5, 8.10 and 9.6.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf), [ICC v2 specification](https://archive.color.org/files/icc32.pdf), [LittleCMS](https://www.littlecms.com/). diff --git a/tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc b/tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc new file mode 100644 index 0000000..4665539 Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents/distinct-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/rendering-intents/manifest.json b/tests/fixtures/pdf/rendering-intents/manifest.json new file mode 100644 index 0000000..282b0c9 --- /dev/null +++ b/tests/fixtures/pdf/rendering-intents/manifest.json @@ -0,0 +1,5529 @@ +{ + "schemaVersion": 1, + "file": "rendering-intents.pdf", + "sha256": "ce59287f6cb2c256c87fcea00a183fa655a165e5028056f28c8c78ddea6645b8", + "pageSizePt": [ + 820, + 800 + ], + "pageCount": 8, + "profiles": { + "original": { + "file": "original-cmyk-lab.icc", + "sha256": "1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7", + "size": 588, + "origin": "byte-identical existing synthetic fixture", + "mediaWhitePoint": [ + 0.9642, + 1, + 0.8249 + ], + "tables": [ + "A2B0" + ] + }, + "distinct": { + "file": "distinct-cmyk-lab.icc", + "sha256": "b9a40bfc18e8280eefe0fedc4e607467e1cc27adeee3beb77f4a2b83fc7e9315", + "size": 984, + "origin": "self-authored extension of the existing CLUT", + "mediaWhitePoint": [ + 0.82, + 0.86, + 0.72 + ], + "tables": [ + "A2B0", + "A2B1", + "A2B2" + ] + } + }, + "baseProfileSha256": "1708392182ecb3819bb0e4bd1139677be24c9884fff84ad3bb91af5dd42b78f7", + "sampleBytes": [ + 51, + 128, + 204, + 77 + ], + "intentOrder": [ + "Perceptual", + "RelativeColorimetric", + "Saturation", + "AbsoluteColorimetric" + ], + "cases": [ + "default-relative", + "ri-vector", + "extgstate-ri", + "nested-q-inner", + "nested-Q-restore", + "form-inherit-shared", + "form-inherit-shared-gs", + "form-ri-override", + "form-gs-override", + "form-return-restore", + "image-inherit-shared-forward", + "image-inherit-shared-reverse", + "image-intent-override", + "image-inherit-shared-gs", + "image-override-return", + "indexed-vector", + "indexed-image", + "stroke", + "text", + "axial-shading", + "shading-pattern-inherit", + "shading-pattern-RI-override", + "type3-inherit-rectangle", + "type3-inherit-image", + "type3-ri-override" + ], + "probes": [ + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 0, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 1, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 2, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "default-relative", + "column": 3, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 625 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "ri-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "extgstate-ri", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 465 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-q-inner", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "nested-Q-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 305 90 44 re f", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 1 0 0 1 310 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 1 0 0 1 435 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "q 1 0 0 1 560 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 1 0 0 1 685 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 65 cm /Form1 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 65 cm /Form2 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 65 cm /Form3 Do Q", + "Q" + ] + }, + { + "page": 1, + "profile": "original", + "case": "form-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 65 cm /Form0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 625 cm /FormGs1 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 625 cm /FormGs2 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 625 cm /FormGs3 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-gs-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 625 cm /FormGs0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Form1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Form2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Form3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "form-return-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Form0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-forward", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 0, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 310 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 1, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 435 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 2, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 560 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-reverse", + "column": 3, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 685 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 305 cm /Image1 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 305 cm /Image2 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 305 cm /Image3 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-intent-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 305 cm /Image0 Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 310 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 90 0 0 44 435 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 560 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 90 0 0 44 685 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Image1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Image2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Image3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "image-override-return", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Image0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 145 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/IX cs 0 scn 310 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/IX cs 0 scn 435 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/IX cs 0 scn 560 65 90 44 re f", + "Q" + ] + }, + { + "page": 2, + "profile": "original", + "case": "indexed-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/IX cs 0 scn 685 65 90 44 re f", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "indexed-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 310 567 m 400 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 435 567 m 525 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 560 567 m 650 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "stroke", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 685 567 m 775 567 l S", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 318, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 310 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 443, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 435 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 568, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 560 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "text", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 693, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 685 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 310 385 90 44 re W n 1 0 0 1 310 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 310, + "xEnd": 400, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 435 385 90 44 re W n 1 0 0 1 435 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 435, + "xEnd": 525, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 560 385 90 44 re W n 1 0 0 1 560 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 560, + "xEnd": 650, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "axial-shading", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 685 385 90 44 re W n 1 0 0 1 685 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 685, + "xEnd": 775, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Inherited scn 310 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Inherited scn 435 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Inherited scn 560 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-inherit", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Inherited scn 685 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /P1 scn 310 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /P2 scn 435 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /P3 scn 560 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "shading-pattern-RI-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /P0 scn 685 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Rect 44 Tf 1 0 0 1 310 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 435 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Rect 44 Tf 1 0 0 1 560 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-rectangle", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 685 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Image 44 Tf 1 0 0 1 310 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 435 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Image 44 Tf 1 0 0 1 560 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 3, + "profile": "original", + "case": "type3-inherit-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 685 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 332, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Override1 44 Tf 1 0 0 1 310 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 457, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Override2 44 Tf 1 0 0 1 435 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 582, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Override3 44 Tf 1 0 0 1 560 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 4, + "profile": "original", + "case": "type3-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 707, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Override0 44 Tf 1 0 0 1 685 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 0, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 1, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 2, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "default-relative", + "column": 3, + "graphicsStateIntent": "default", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 625 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "ri-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "extgstate-ri", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 465 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-q-inner", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 385 90 44 re f", + "Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q /RelativeColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q /Saturation ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q /AbsoluteColorimetric ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "nested-Q-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q /Perceptual ri Q", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 305 90 44 re f", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 225 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 1 0 0 1 310 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 1 0 0 1 435 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I2 gs", + "q 1 0 0 1 560 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 1 0 0 1 685 145 cm /Inherited Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 65 cm /Form1 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 65 cm /Form2 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 65 cm /Form3 Do Q", + "Q" + ] + }, + { + "page": 5, + "profile": "distinct", + "case": "form-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 65 cm /Form0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 1 0 0 1 310 625 cm /FormGs1 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 1 0 0 1 435 625 cm /FormGs2 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 1 0 0 1 560 625 cm /FormGs3 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-gs-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 1 0 0 1 685 625 cm /FormGs0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Form1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Form2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Form3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "form-return-restore", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Form0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 545 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-forward", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 487 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 465 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 0, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 310 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 1, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 435 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 2, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 560 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-reverse", + "column": 3, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 685 385 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 305 cm /Image1 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 305 cm /Image2 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 305 cm /Image3 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-intent-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 305 cm /Image0 Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 310 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I1 gs", + "q 90 0 0 44 435 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 2, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I0 gs", + "q 90 0 0 44 560 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-inherit-shared-gs", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/I3 gs", + "q 90 0 0 44 685 225 cm /Shared Do Q", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Image1 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 310 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Image2 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 435 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Image3 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 560 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "image-override-return", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Image0 Do", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn 685 145 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/IX cs 0 scn 310 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/IX cs 0 scn 435 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/IX cs 0 scn 560 65 90 44 re f", + "Q" + ] + }, + { + "page": 6, + "profile": "distinct", + "case": "indexed-vector", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/IX cs 0 scn 685 65 90 44 re f", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 90 0 0 44 310 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 90 0 0 44 435 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 90 0 0 44 560 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "indexed-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 90 0 0 44 685 625 cm /Indexed Do Q", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 310 567 m 400 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 435 567 m 525 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 560 567 m 650 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "stroke", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 567 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS CS 0.2000000000 0.5019607843 0.8000000000 0.3019607843 SCN 24 w 685 567 m 775 567 l S", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 318, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 310 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 443, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 435 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 568, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 560 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "text", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 693, + 494 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/CS cs 0.2000000000 0.5019607843 0.8000000000 0.3019607843 scn BT /F 64 Tf 1 0 0 1 685 465 Tm (H) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "q 310 385 90 44 re W n 1 0 0 1 310 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 310, + "xEnd": 400, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "q 435 385 90 44 re W n 1 0 0 1 435 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 435, + "xEnd": 525, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "q 560 385 90 44 re W n 1 0 0 1 560 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 560, + "xEnd": 650, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "axial-shading", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 407 + ], + "components": [ + 0.3, + 0.5, + 0.75, + 0.3 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "q 685 385 90 44 re W n 1 0 0 1 685 385 cm /A sh Q", + "Q" + ], + "shading": { + "xStart": 685, + "xEnd": 775, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 355, + 327 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /Inherited scn 310 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 480, + 327 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /Inherited scn 435 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 605, + 327 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /Inherited scn 560 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-inherit", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 730, + 327 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /Inherited scn 685 305 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 355, + 247 + ], + "components": [ + 0.2731707317073171, + 0.47317073170731705, + 0.7298780487804878, + 0.28658536585365857 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "/Pattern cs /P1 scn 310 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 480, + 247 + ], + "components": [ + 0.3341463414634146, + 0.5341463414634146, + 0.775609756097561, + 0.3170731707317073 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "/Pattern cs /P2 scn 435 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 605, + 247 + ], + "components": [ + 0.3951219512195122, + 0.5951219512195122, + 0.8213414634146341, + 0.3475609756097561 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "/Pattern cs /P3 scn 560 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "shading-pattern-RI-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 730, + 247 + ], + "components": [ + 0.45609756097560983, + 0.6560975609756097, + 0.8670731707317074, + 0.3780487804878049 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "/Pattern cs /P0 scn 685 225 90 44 re f", + "Q" + ], + "shading": { + "xStart": 0, + "xEnd": 820, + "c0": [ + 0.1, + 0.3, + 0.6, + 0.2 + ], + "c1": [ + 0.5, + 0.7, + 0.9, + 0.4 + ] + } + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Rect 44 Tf 1 0 0 1 310 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 435 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Rect 44 Tf 1 0 0 1 560 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-rectangle", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 167 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Rect 44 Tf 1 0 0 1 685 145 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 332, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Image 44 Tf 1 0 0 1 310 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 457, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 435 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 582, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Image 44 Tf 1 0 0 1 560 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 7, + "profile": "distinct", + "case": "type3-inherit-image", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 707, + 87 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Image 44 Tf 1 0 0 1 685 65 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 0, + "graphicsStateIntent": "Perceptual", + "expectedIntent": "RelativeColorimetric", + "expectedIntentIndex": 1, + "pointPt": [ + 332, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Perceptual ri", + "BT /T3Override1 44 Tf 1 0 0 1 310 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 1, + "graphicsStateIntent": "RelativeColorimetric", + "expectedIntent": "Saturation", + "expectedIntentIndex": 2, + "pointPt": [ + 457, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/RelativeColorimetric ri", + "BT /T3Override2 44 Tf 1 0 0 1 435 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 2, + "graphicsStateIntent": "Saturation", + "expectedIntent": "AbsoluteColorimetric", + "expectedIntentIndex": 3, + "pointPt": [ + 582, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/Saturation ri", + "BT /T3Override3 44 Tf 1 0 0 1 560 625 Tm (A) Tj ET", + "Q" + ] + }, + { + "page": 8, + "profile": "distinct", + "case": "type3-ri-override", + "column": 3, + "graphicsStateIntent": "AbsoluteColorimetric", + "expectedIntent": "Perceptual", + "expectedIntentIndex": 0, + "pointPt": [ + 707, + 647 + ], + "components": [ + 0.2, + 0.5019607843137255, + 0.8, + 0.30196078431372547 + ], + "commandSequence": [ + "q", + "/AbsoluteColorimetric ri", + "BT /T3Override0 44 Tf 1 0 0 1 685 625 Tm (A) Tj ET", + "Q" + ] + } + ], + "tolerance8Bit": 4, + "generatorSha256": "822244e98fd6f10ed550882dd5c300609a87c8b9d5552bfbe5157925342849b5", + "derivedProfileModels": { + "A2B0": [ + "100-12*C-15*M-10*Y-35*K", + "18*M-15*C", + "20*Y-12*C" + ], + "A2B1": [ + "100-20*C-20*M-20*Y-40*K", + "40*M-20*C-20*K", + "40*Y-20*C-20*K" + ], + "A2B2": [ + "100-25*C-25*M-20*Y-30*K", + "-40*M+20*C+20*K", + "35*Y-20*M-15*K" + ] + }, + "references": [ + "https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf", + "https://archive.color.org/files/icc32.pdf", + "https://www.littlecms.com/" + ], + "limits": [ + "No measured press profile, human-approved golden, display calibration or broad ICC conformance claim.", + "Independent direct-CMM oracle can share LittleCMS code with PDF renderers; not a separate CMM implementation.", + "Text uses an interior point of the standard Helvetica-Bold H; axial samples account for raster pixel centers." + ] +} diff --git a/tests/fixtures/pdf/rendering-intents/original-cmyk-lab.icc b/tests/fixtures/pdf/rendering-intents/original-cmyk-lab.icc new file mode 100644 index 0000000..b1b3229 Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents/original-cmyk-lab.icc differ diff --git a/tests/fixtures/pdf/rendering-intents/rendering-intents.pdf b/tests/fixtures/pdf/rendering-intents/rendering-intents.pdf new file mode 100644 index 0000000..68f6b4e Binary files /dev/null and b/tests/fixtures/pdf/rendering-intents/rendering-intents.pdf differ diff --git a/tests/fixtures/pdf/reused-form-mcid.pdf b/tests/fixtures/pdf/reused-form-mcid.pdf new file mode 100644 index 0000000..f4788c7 --- /dev/null +++ b/tests/fixtures/pdf/reused-form-mcid.pdf @@ -0,0 +1,102 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R /Outlines 8 0 R /PageLabels << /Nums [0 << /S /r >> 1 << /S /D /St 1 >>] >> /StructTreeRoot 11 0 R /MarkInfo << /Marked true >> /AcroForm << /Fields [16 0 R] /DR << /Font << /F1 7 0 R >> >> /DA (/F1 12 Tf 0 g) >> /Names << /Dests << /Names [(second) [4 0 R /XYZ 40 200 null]] >> >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 260] /CropBox [20 30 220 230] /Resources << /XObject << /Fm 20 0 R >> /Font << /F1 7 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [14 0 R 15 0 R 16 0 R 18 0 R] >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Rotate 90 /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +5 0 obj +<< /Length 82 >> +stream +0 g /Chapter <> BDC BT /F1 16 Tf 60 180 Td (Heading One) Tj ET EMC /Fm Do +endstream +endobj +6 0 obj +<< /Length 49 >> +stream +BT /F1 20 Tf 40 100 Td (Second Page Needle) Tj ET +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +8 0 obj +<< /Type /Outlines /First 9 0 R /Last 10 0 R /Count 2 >> +endobj +9 0 obj +<< /Title (First heading) /Parent 8 0 R /Dest [3 0 R /XYZ 60 180 null] /Next 10 0 R >> +endobj +10 0 obj +<< /Title (Named second) /Parent 8 0 R /Dest (second) /Prev 9 0 R >> +endobj +11 0 obj +<< /Type /StructTreeRoot /K [12 0 R 13 0 R] /RoleMap << /Chapter /H1 >> /ParentTree << /Nums [0 [12 0 R 13 0 R 13 0 R]] >> >> +endobj +12 0 obj +<< /Type /StructElem /S /Chapter /T (Original Heading) /P 11 0 R /Pg 3 0 R /K 0 >> +endobj +13 0 obj +<< /Type /StructElem /S /H2 /P 11 0 R /Pg 3 0 R /K [1 2] >> +endobj +14 0 obj +<< /Type /Annot /Subtype /Link /Rect [30 30 90 45] /A << /S /URI /URI (https://example.org/) >> >> +endobj +15 0 obj +<< /Type /Annot /Subtype /Link /Rect [100 30 160 45] /A << /S /Launch /F (/tmp/never-execute) >> >> +endobj +16 0 obj +<< /Type /Annot /Subtype /Widget /FT /Tx /T (ReadonlyField) /V (Saved) /Ff 1 /F 4 /Rect [120 60 160 90] /P 3 0 R /AP << /N 17 0 R >> >> +endobj +17 0 obj +<< /Length 23 /Type /XObject /Subtype /Form /BBox [0 0 40 30] /Resources << >> >> +stream +1 0 0 rg 0 0 40 30 re f +endstream +endobj +18 0 obj +<< /Type /Annot /Subtype /Text /Rect [180 200 200 220] /Contents (Read-only comment) /F 4 >> +endobj +19 0 obj +<< /Title (DocView PDF fixture) /Author (DocView tests) >> +endobj +20 0 obj +<< /Length 76 /Type /XObject /Subtype /Form /BBox [0 0 240 260] /Resources << /Font << /F1 7 0 R >> >> >> +stream +/H1 <> BDC BT /F1 12 Tf 50 50 Td (Different Form Heading) Tj ET EMC +endstream +endobj +xref +0 21 +0000000000 65535 f +0000000015 00000 n +0000000346 00000 n +0000000409 00000 n +0000000641 00000 n +0000000778 00000 n +0000000911 00000 n +0000001011 00000 n +0000001081 00000 n +0000001153 00000 n +0000001255 00000 n +0000001340 00000 n +0000001482 00000 n +0000001581 00000 n +0000001657 00000 n +0000001772 00000 n +0000001888 00000 n +0000002040 00000 n +0000002179 00000 n +0000002288 00000 n +0000002363 00000 n +trailer +<< /Size 21 /Root 1 0 R /Info 19 0 R >> +startxref +2579 +%%EOF diff --git a/tests/fixtures/pdf/tiny-comments.pdf b/tests/fixtures/pdf/tiny-comments.pdf new file mode 100644 index 0000000..6c708f4 --- /dev/null +++ b/tests/fixtures/pdf/tiny-comments.pdf @@ -0,0 +1,30 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] /CropBox [0 0 1 1] /Annots [4 0 R 5 0 R] /Resources << >> >> +endobj +4 0 obj +<< /Type /Annot /Subtype /Text /Rect [0 0 0.5 0.5] /Contents (Literal comment) /F 4 >> +endobj +5 0 obj +<< /Type /Annot /Subtype /Text /Rect [0 0 0.5 0.5] /Contents (Outside CropBox comment) /F 4 >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000256 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +487 +%%EOF diff --git a/tests/fixtures/performance/README.md b/tests/fixtures/performance/README.md new file mode 100644 index 0000000..928f5a1 --- /dev/null +++ b/tests/fixtures/performance/README.md @@ -0,0 +1,16 @@ +# Performance corpus + +All document content is generated locally by `tests/generate_performance_corpus.py`. Individual file sizes, SHA-256 hashes and font provenance are recorded in `manifest.json`. The 500-page/500-chapter corpus is separate from the large raster stress workload. + +## EPUB navigation destinations + +`standard-500.epub` contains 500 chapters, each with one h1 and twelve h2 headings. Its final section has `min-height:100vh`. This authored fixture style provides enough trailing room for all thirteen headings to have distinct scroll destinations, so the fixed benchmark can execute twelve forward and twelve backward moves without counting stationary end-clamped commands as physical navigation. The benchmark retains its original traversal span and requires every counted heading move to change the validated reading position. + +Only the EPUB `style.css` entry changed. Its chapter text, headings, images, spine and navigation document are unchanged. HTML, ZIP HTML and PDF fixtures are unchanged. + +| Fixture | Bytes | SHA-256 | Purpose | +|---|---:|---|---| +| `standard-500.epub` | 300640 | `c5816283075fe2915672cbb2daa33229d74eeffb11041ef4a1f927886cdd2aa6` | Fixed performance movement sequence with distinct terminal destinations | +| `standard-500-clamped.epub` | 300618 | `d4136e2c7e5e67bc10e640589c34fc629e80e66edd2ab2bd25dda238c7a2314d` | Original short final section, retained byte-for-byte for end-clamping regressions and old result interpretation | + +The generator reproduces both files byte-for-byte. The original failing measurement is preserved in `tests/results/benchmark-xhtml/clamp-diagnostic/epub.json`: the twelfth heading command was acknowledged, but both before and after positions were `chapter-0.xhtml`, progression 1. The corresponding 125-operation failure is preserved in `tests/results/benchmark-xhtml/standard-clamped/epub.json`. Product handling of consecutive clamped headings is tested independently on unmodified short-tail XHTML and must not be inferred from performance on the extended-tail fixture. diff --git a/tests/fixtures/performance/html/image.svg b/tests/fixtures/performance/html/image.svg new file mode 100644 index 0000000..e9dbe72 --- /dev/null +++ b/tests/fixtures/performance/html/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/performance/html/index.html b/tests/fixtures/performance/html/index.html new file mode 100644 index 0000000..bcaacad --- /dev/null +++ b/tests/fixtures/performance/html/index.html @@ -0,0 +1 @@ +章1

第1章

節 1.1

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/0)

検証用の円

節 1.2

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/1)

検証用の円

節 1.3

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/2)

検証用の円

節 1.4

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/3)

検証用の円

節 1.5

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/4)

検証用の円

節 1.6

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/5)

検証用の円

節 1.7

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/6)

検証用の円

節 1.8

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/7)

検証用の円

節 1.9

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/8)

検証用の円

節 1.10

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/9)

検証用の円

節 1.11

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/10)

検証用の円

節 1.12

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/11)

検証用の円

節 1.13

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/12)

検証用の円

節 1.14

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/13)

検証用の円

節 1.15

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/14)

検証用の円

節 1.16

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/15)

検証用の円

節 1.17

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/16)

検証用の円

節 1.18

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/17)

検証用の円

節 1.19

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/18)

検証用の円

節 1.20

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/19)

検証用の円

節 1.21

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/20)

検証用の円

節 1.22

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/21)

検証用の円

節 1.23

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/22)

検証用の円

節 1.24

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/23)

検証用の円

節 1.25

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/24)

検証用の円

節 1.26

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/25)

検証用の円

節 1.27

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/26)

検証用の円

節 1.28

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/27)

検証用の円

節 1.29

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/28)

検証用の円

節 1.30

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/29)

検証用の円

節 1.31

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/30)

検証用の円

節 1.32

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/31)

検証用の円

節 1.33

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/32)

検証用の円

節 1.34

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/33)

検証用の円

節 1.35

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/34)

検証用の円

節 1.36

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/35)

検証用の円

節 1.37

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/36)

検証用の円

節 1.38

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/37)

検証用の円

節 1.39

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/38)

検証用の円

節 1.40

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/39)

検証用の円

節 1.41

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/40)

検証用の円

節 1.42

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/41)

検証用の円

節 1.43

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/42)

検証用の円

節 1.44

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/43)

検証用の円

節 1.45

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/44)

検証用の円

節 1.46

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/45)

検証用の円

節 1.47

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/46)

検証用の円

節 1.48

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/47)

検証用の円

節 1.49

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/48)

検証用の円

節 1.50

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/49)

検証用の円

節 1.51

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/50)

検証用の円

節 1.52

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/51)

検証用の円

節 1.53

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/52)

検証用の円

節 1.54

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/53)

検証用の円

節 1.55

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/54)

検証用の円

節 1.56

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/55)

検証用の円

節 1.57

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/56)

検証用の円

節 1.58

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/57)

検証用の円

節 1.59

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/58)

検証用の円

節 1.60

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/59)

検証用の円

節 1.61

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/60)

検証用の円

節 1.62

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/61)

検証用の円

節 1.63

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/62)

検証用の円

節 1.64

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/63)

検証用の円

節 1.65

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/64)

検証用の円

節 1.66

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/65)

検証用の円

節 1.67

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/66)

検証用の円

節 1.68

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/67)

検証用の円

節 1.69

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/68)

検証用の円

節 1.70

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/69)

検証用の円

節 1.71

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/70)

検証用の円

節 1.72

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/71)

検証用の円

節 1.73

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/72)

検証用の円

節 1.74

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/73)

検証用の円

節 1.75

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/74)

検証用の円

節 1.76

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/75)

検証用の円

節 1.77

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/76)

検証用の円

節 1.78

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/77)

検証用の円

節 1.79

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/78)

検証用の円

節 1.80

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/79)

検証用の円

節 1.81

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/80)

検証用の円

節 1.82

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/81)

検証用の円

節 1.83

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/82)

検証用の円

節 1.84

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/83)

検証用の円

節 1.85

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/84)

検証用の円

節 1.86

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/85)

検証用の円

節 1.87

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/86)

検証用の円

節 1.88

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/87)

検証用の円

節 1.89

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/88)

検証用の円

節 1.90

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/89)

検証用の円

節 1.91

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/90)

検証用の円

節 1.92

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/91)

検証用の円

節 1.93

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/92)

検証用の円

節 1.94

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/93)

検証用の円

節 1.95

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/94)

検証用の円

節 1.96

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/95)

検証用の円

節 1.97

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/96)

検証用の円

節 1.98

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/97)

検証用の円

節 1.99

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/98)

検証用の円

節 1.100

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/99)

検証用の円

節 1.101

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/100)

検証用の円

節 1.102

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/101)

検証用の円

節 1.103

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/102)

検証用の円

節 1.104

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/103)

検証用の円

節 1.105

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/104)

検証用の円

節 1.106

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/105)

検証用の円

節 1.107

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/106)

検証用の円

節 1.108

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/107)

検証用の円

節 1.109

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/108)

検証用の円

節 1.110

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/109)

検証用の円

節 1.111

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/110)

検証用の円

節 1.112

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/111)

検証用の円

節 1.113

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/112)

検証用の円

節 1.114

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/113)

検証用の円

節 1.115

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/114)

検証用の円

節 1.116

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/115)

検証用の円

節 1.117

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/116)

検証用の円

節 1.118

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/117)

検証用の円

節 1.119

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/118)

検証用の円

節 1.120

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/119)

検証用の円

節 1.121

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/120)

検証用の円

節 1.122

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/121)

検証用の円

節 1.123

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/122)

検証用の円

節 1.124

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/123)

検証用の円

節 1.125

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/124)

検証用の円

節 1.126

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/125)

検証用の円

節 1.127

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/126)

検証用の円

節 1.128

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/127)

検証用の円

節 1.129

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/128)

検証用の円

節 1.130

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/129)

検証用の円

節 1.131

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/130)

検証用の円

節 1.132

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/131)

検証用の円

節 1.133

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/132)

検証用の円

節 1.134

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/133)

検証用の円

節 1.135

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/134)

検証用の円

節 1.136

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/135)

検証用の円

節 1.137

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/136)

検証用の円

節 1.138

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/137)

検証用の円

節 1.139

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/138)

検証用の円

節 1.140

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/139)

検証用の円

節 1.141

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/140)

検証用の円

節 1.142

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/141)

検証用の円

節 1.143

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/142)

検証用の円

節 1.144

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/143)

検証用の円

節 1.145

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/144)

検証用の円

節 1.146

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/145)

検証用の円

節 1.147

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/146)

検証用の円

節 1.148

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/147)

検証用の円

節 1.149

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/148)

検証用の円

節 1.150

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/149)

検証用の円

節 1.151

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/150)

検証用の円

節 1.152

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/151)

検証用の円

節 1.153

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/152)

検証用の円

節 1.154

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/153)

検証用の円

節 1.155

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/154)

検証用の円

節 1.156

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/155)

検証用の円

節 1.157

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/156)

検証用の円

節 1.158

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/157)

検証用の円

節 1.159

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/158)

検証用の円

節 1.160

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/159)

検証用の円

節 1.161

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/160)

検証用の円

節 1.162

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/161)

検証用の円

節 1.163

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/162)

検証用の円

節 1.164

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/163)

検証用の円

節 1.165

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/164)

検証用の円

節 1.166

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/165)

検証用の円

節 1.167

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/166)

検証用の円

節 1.168

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/167)

検証用の円

節 1.169

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/168)

検証用の円

節 1.170

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/169)

検証用の円

節 1.171

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/170)

検証用の円

節 1.172

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/171)

検証用の円

節 1.173

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/172)

検証用の円

節 1.174

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/173)

検証用の円

節 1.175

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/174)

検証用の円

節 1.176

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/175)

検証用の円

節 1.177

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/176)

検証用の円

節 1.178

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/177)

検証用の円

節 1.179

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/178)

検証用の円

節 1.180

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/179)

検証用の円

節 1.181

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/180)

検証用の円

節 1.182

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/181)

検証用の円

節 1.183

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/182)

検証用の円

節 1.184

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/183)

検証用の円

節 1.185

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/184)

検証用の円

節 1.186

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/185)

検証用の円

節 1.187

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/186)

検証用の円

節 1.188

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/187)

検証用の円

節 1.189

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/188)

検証用の円

節 1.190

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/189)

検証用の円

節 1.191

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/190)

検証用の円

節 1.192

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/191)

検証用の円

節 1.193

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/192)

検証用の円

節 1.194

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/193)

検証用の円

節 1.195

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/194)

検証用の円

節 1.196

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/195)

検証用の円

節 1.197

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/196)

検証用の円

節 1.198

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/197)

検証用の円

節 1.199

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/198)

検証用の円

節 1.200

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/199)

検証用の円

節 1.201

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/200)

検証用の円

節 1.202

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/201)

検証用の円

節 1.203

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/202)

検証用の円

節 1.204

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/203)

検証用の円

節 1.205

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/204)

検証用の円

節 1.206

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/205)

検証用の円

節 1.207

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/206)

検証用の円

節 1.208

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/207)

検証用の円

節 1.209

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/208)

検証用の円

節 1.210

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/209)

検証用の円

節 1.211

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/210)

検証用の円

節 1.212

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/211)

検証用の円

節 1.213

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/212)

検証用の円

節 1.214

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/213)

検証用の円

節 1.215

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/214)

検証用の円

節 1.216

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/215)

検証用の円

節 1.217

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/216)

検証用の円

節 1.218

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/217)

検証用の円

節 1.219

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/218)

検証用の円

節 1.220

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/219)

検証用の円

節 1.221

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/220)

検証用の円

節 1.222

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/221)

検証用の円

節 1.223

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/222)

検証用の円

節 1.224

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/223)

検証用の円

節 1.225

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/224)

検証用の円

節 1.226

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/225)

検証用の円

節 1.227

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/226)

検証用の円

節 1.228

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/227)

検証用の円

節 1.229

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/228)

検証用の円

節 1.230

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/229)

検証用の円

節 1.231

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/230)

検証用の円

節 1.232

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/231)

検証用の円

節 1.233

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/232)

検証用の円

節 1.234

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/233)

検証用の円

節 1.235

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/234)

検証用の円

節 1.236

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/235)

検証用の円

節 1.237

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/236)

検証用の円

節 1.238

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/237)

検証用の円

節 1.239

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/238)

検証用の円

節 1.240

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/239)

検証用の円

節 1.241

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/240)

検証用の円

節 1.242

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/241)

検証用の円

節 1.243

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/242)

検証用の円

節 1.244

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/243)

検証用の円

節 1.245

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/244)

検証用の円

節 1.246

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/245)

検証用の円

節 1.247

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/246)

検証用の円

節 1.248

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/247)

検証用の円

節 1.249

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/248)

検証用の円

節 1.250

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/249)

検証用の円

節 1.251

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/250)

検証用の円

節 1.252

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/251)

検証用の円

節 1.253

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/252)

検証用の円

節 1.254

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/253)

検証用の円

節 1.255

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/254)

検証用の円

節 1.256

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/255)

検証用の円

節 1.257

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/256)

検証用の円

節 1.258

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/257)

検証用の円

節 1.259

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/258)

検証用の円

節 1.260

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/259)

検証用の円

節 1.261

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/260)

検証用の円

節 1.262

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/261)

検証用の円

節 1.263

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/262)

検証用の円

節 1.264

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/263)

検証用の円

節 1.265

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/264)

検証用の円

節 1.266

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/265)

検証用の円

節 1.267

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/266)

検証用の円

節 1.268

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/267)

検証用の円

節 1.269

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/268)

検証用の円

節 1.270

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/269)

検証用の円

節 1.271

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/270)

検証用の円

節 1.272

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/271)

検証用の円

節 1.273

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/272)

検証用の円

節 1.274

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/273)

検証用の円

節 1.275

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/274)

検証用の円

節 1.276

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/275)

検証用の円

節 1.277

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/276)

検証用の円

節 1.278

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/277)

検証用の円

節 1.279

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/278)

検証用の円

節 1.280

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/279)

検証用の円

節 1.281

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/280)

検証用の円

節 1.282

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/281)

検証用の円

節 1.283

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/282)

検証用の円

節 1.284

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/283)

検証用の円

節 1.285

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/284)

検証用の円

節 1.286

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/285)

検証用の円

節 1.287

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/286)

検証用の円

節 1.288

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/287)

検証用の円

節 1.289

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/288)

検証用の円

節 1.290

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/289)

検証用の円

節 1.291

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/290)

検証用の円

節 1.292

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/291)

検証用の円

節 1.293

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/292)

検証用の円

節 1.294

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/293)

検証用の円

節 1.295

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/294)

検証用の円

節 1.296

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/295)

検証用の円

節 1.297

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/296)

検証用の円

節 1.298

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/297)

検証用の円

節 1.299

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/298)

検証用の円

節 1.300

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/299)

検証用の円

節 1.301

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/300)

検証用の円

節 1.302

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/301)

検証用の円

節 1.303

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/302)

検証用の円

節 1.304

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/303)

検証用の円

節 1.305

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/304)

検証用の円

節 1.306

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/305)

検証用の円

節 1.307

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/306)

検証用の円

節 1.308

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/307)

検証用の円

節 1.309

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/308)

検証用の円

節 1.310

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/309)

検証用の円

節 1.311

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/310)

検証用の円

節 1.312

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/311)

検証用の円

節 1.313

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/312)

検証用の円

節 1.314

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/313)

検証用の円

節 1.315

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/314)

検証用の円

節 1.316

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/315)

検証用の円

節 1.317

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/316)

検証用の円

節 1.318

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/317)

検証用の円

節 1.319

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/318)

検証用の円

節 1.320

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/319)

検証用の円

節 1.321

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/320)

検証用の円

節 1.322

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/321)

検証用の円

節 1.323

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/322)

検証用の円

節 1.324

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/323)

検証用の円

節 1.325

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/324)

検証用の円

節 1.326

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/325)

検証用の円

節 1.327

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/326)

検証用の円

節 1.328

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/327)

検証用の円

節 1.329

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/328)

検証用の円

節 1.330

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/329)

検証用の円

節 1.331

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/330)

検証用の円

節 1.332

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/331)

検証用の円

節 1.333

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/332)

検証用の円

節 1.334

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/333)

検証用の円

節 1.335

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/334)

検証用の円

節 1.336

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/335)

検証用の円

節 1.337

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/336)

検証用の円

節 1.338

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/337)

検証用の円

節 1.339

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/338)

検証用の円

節 1.340

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/339)

検証用の円

節 1.341

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/340)

検証用の円

節 1.342

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/341)

検証用の円

節 1.343

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/342)

検証用の円

節 1.344

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/343)

検証用の円

節 1.345

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/344)

検証用の円

節 1.346

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/345)

検証用の円

節 1.347

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/346)

検証用の円

節 1.348

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/347)

検証用の円

節 1.349

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/348)

検証用の円

節 1.350

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/349)

検証用の円

節 1.351

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/350)

検証用の円

節 1.352

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/351)

検証用の円

節 1.353

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/352)

検証用の円

節 1.354

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/353)

検証用の円

節 1.355

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/354)

検証用の円

節 1.356

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/355)

検証用の円

節 1.357

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/356)

検証用の円

節 1.358

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/357)

検証用の円

節 1.359

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/358)

検証用の円

節 1.360

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/359)

検証用の円

節 1.361

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/360)

検証用の円

節 1.362

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/361)

検証用の円

節 1.363

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/362)

検証用の円

節 1.364

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/363)

検証用の円

節 1.365

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/364)

検証用の円

節 1.366

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/365)

検証用の円

節 1.367

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/366)

検証用の円

節 1.368

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/367)

検証用の円

節 1.369

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/368)

検証用の円

節 1.370

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/369)

検証用の円

節 1.371

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/370)

検証用の円

節 1.372

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/371)

検証用の円

節 1.373

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/372)

検証用の円

節 1.374

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/373)

検証用の円

節 1.375

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/374)

検証用の円

節 1.376

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/375)

検証用の円

節 1.377

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/376)

検証用の円

節 1.378

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/377)

検証用の円

節 1.379

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/378)

検証用の円

節 1.380

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/379)

検証用の円

節 1.381

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/380)

検証用の円

節 1.382

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/381)

検証用の円

節 1.383

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/382)

検証用の円

節 1.384

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/383)

検証用の円

節 1.385

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/384)

検証用の円

節 1.386

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/385)

検証用の円

節 1.387

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/386)

検証用の円

節 1.388

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/387)

検証用の円

節 1.389

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/388)

検証用の円

節 1.390

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/389)

検証用の円

節 1.391

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/390)

検証用の円

節 1.392

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/391)

検証用の円

節 1.393

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/392)

検証用の円

節 1.394

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/393)

検証用の円

節 1.395

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/394)

検証用の円

節 1.396

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/395)

検証用の円

節 1.397

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/396)

検証用の円

節 1.398

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/397)

検証用の円

節 1.399

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/398)

検証用の円

節 1.400

日本語の読み取りとローカル資源を確認します。This is original synthetic reading content. (0/399)

検証用の円
\ No newline at end of file diff --git a/tests/fixtures/performance/html/style.css b/tests/fixtures/performance/html/style.css new file mode 100644 index 0000000..5d85d2e --- /dev/null +++ b/tests/fixtures/performance/html/style.css @@ -0,0 +1 @@ +body{font:18px sans-serif;line-height:1.6;margin:3em;max-width:55em}h1,h2{color:#165f69}img{width:240px} \ No newline at end of file diff --git a/tests/fixtures/performance/manifest.json b/tests/fixtures/performance/manifest.json new file mode 100644 index 0000000..501d097 --- /dev/null +++ b/tests/fixtures/performance/manifest.json @@ -0,0 +1,41 @@ +{ + "origin": "DocView original generated test content; PDF embeds the OFL font described in ../pdf/extended/manifest.json", + "limits": "500 PDF pages and 500 EPUB chapters, small reused images; not a 1 GiB scan corpus", + "files": { + "html/image.svg": { + "bytes": 167, + "sha256": "75a9e53bba15133453cd3c4fc643ab07b7496b7430cd3ed491105fc3754616cb" + }, + "html/index.html": { + "bytes": 88685, + "sha256": "759428ab7ba6f268964f7511f486cffee82490747128ec7a8b5d65b44a7f5c1d" + }, + "html/style.css": { + "bytes": 104, + "sha256": "95f79af1fb8a6ed283ddac428708405acb2f6aad86faf633bb45a44ad3a471ae" + }, + "standard-500.epub": { + "bytes": 300640, + "sha256": "c5816283075fe2915672cbb2daa33229d74eeffb11041ef4a1f927886cdd2aa6", + "purpose": "Timed navigation: 13 distinct heading destinations per chapter; final section minimum height is one viewport." + }, + "standard-500.pdf": { + "bytes": 187414, + "sha256": "cf0d33129971f0efa53434f2c827f70877c3d36b4146fdf1cf3ba0df6c9783e4" + }, + "standard-html.zip": { + "bytes": 32821, + "sha256": "40ac84446c8e684f4085653a7cc016ca4aa92fc18dcccef443266498b227ed9d" + }, + "standard-headings-500.pdf": { + "bytes": 293605, + "sha256": "55431198ea6b67e52c81eeef4f12c330f03558f78c01ac3a49768fc9076cac31", + "purpose": "Same 500 original generated pages with 500 authored page-start outline headings for fixed 100-command navigation sequences." + }, + "standard-500-clamped.epub": { + "bytes": 300618, + "sha256": "d4136e2c7e5e67bc10e640589c34fc629e80e66edd2ab2bd25dda238c7a2314d", + "purpose": "Original short-tail corpus, retained unchanged for end-clamping regressions and prior failed benchmark hashes." + } + } +} diff --git a/tests/fixtures/performance/standard-500-clamped.epub b/tests/fixtures/performance/standard-500-clamped.epub new file mode 100644 index 0000000..a8f0c38 Binary files /dev/null and b/tests/fixtures/performance/standard-500-clamped.epub differ diff --git a/tests/fixtures/performance/standard-500.epub b/tests/fixtures/performance/standard-500.epub new file mode 100644 index 0000000..3d6bc7e Binary files /dev/null and b/tests/fixtures/performance/standard-500.epub differ diff --git a/tests/fixtures/performance/standard-500.pdf b/tests/fixtures/performance/standard-500.pdf new file mode 100644 index 0000000..4fd55b1 Binary files /dev/null and b/tests/fixtures/performance/standard-500.pdf differ diff --git a/tests/fixtures/performance/standard-headings-500.pdf b/tests/fixtures/performance/standard-headings-500.pdf new file mode 100644 index 0000000..a0ad215 Binary files /dev/null and b/tests/fixtures/performance/standard-headings-500.pdf differ diff --git a/tests/fixtures/performance/standard-html.zip b/tests/fixtures/performance/standard-html.zip new file mode 100644 index 0000000..4059e7a Binary files /dev/null and b/tests/fixtures/performance/standard-html.zip differ diff --git a/tests/fixtures/web/README.md b/tests/fixtures/web/README.md new file mode 100644 index 0000000..7ec46a6 --- /dev/null +++ b/tests/fixtures/web/README.md @@ -0,0 +1,12 @@ +# Reflow fixtures + +`late-layout.ttf` is generated by `generate_late_font.py` with fontTools. The glyphs +are original rectangles with deliberately wide advance widths. It contains no +third-party font outlines. It is test data for a visible layout change after a +real font resource arrives, not a reading font. + +The WebEngine tests hold image/font replies through the production resource +handler's extraction completion, then release them after a logical location has +been captured. Native scroll anchoring is disabled in the fixture. The tests +check the same character and viewport fraction after image decoding and font +substitution, and navigation while a resource is pending. diff --git a/tests/fixtures/web/epub2-ncx.epub b/tests/fixtures/web/epub2-ncx.epub new file mode 100644 index 0000000..d838039 Binary files /dev/null and b/tests/fixtures/web/epub2-ncx.epub differ diff --git a/tests/fixtures/web/fixed-mixed.epub b/tests/fixtures/web/fixed-mixed.epub new file mode 100644 index 0000000..146c9bb Binary files /dev/null and b/tests/fixtures/web/fixed-mixed.epub differ diff --git a/tests/fixtures/web/generate_late_font.py b/tests/fixtures/web/generate_late_font.py new file mode 100644 index 0000000..1183bb7 --- /dev/null +++ b/tests/fixtures/web/generate_late_font.py @@ -0,0 +1,32 @@ +"""Generate original, deliberately wide glyphs for real font-swap layout tests.""" +from pathlib import Path +import hashlib +import json +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +font = FontBuilder(1000, isTTF=True) +chars = list(range(32, 127)) +glyphs = ['.notdef'] + [f'u{x:04X}' for x in chars] +font.setupGlyphOrder(glyphs) +font.setupCharacterMap(dict(zip(chars, glyphs[1:]))) +shapes = {} +for name in glyphs: + pen = TTGlyphPen(None) + if name != 'u0020': + pen.moveTo((80, 0)); pen.lineTo((80, 700)); pen.lineTo((1300, 700)); pen.lineTo((1300, 0)); pen.closePath() + shapes[name] = pen.glyph() +font.setupGlyf(shapes) +font.setupHorizontalMetrics({name: (1400, 0) for name in glyphs}) +font.setupHorizontalHeader(ascent=800, descent=-200) +font.setupNameTable({'familyName':'DocView Layout Test', 'styleName':'Regular', 'uniqueFontIdentifier':'DocView Layout Test 1', 'fullName':'DocView Layout Test', 'psName':'DocViewLayoutTest'}) +font.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200) +font.setupPost() +font.setupMaxp() +font.font['head'].created = font.font['head'].modified = 2082844800 +font.font.recalcTimestamp = False +path = Path(__file__).with_name('late-layout.ttf') +font.save(path) +path.with_suffix('.json').write_text(json.dumps({ + 'file': path.name, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), + 'generator': Path(__file__).name, 'purpose': 'Original wide rectangle glyphs for font-swap layout tests', +}, indent=2) + '\n') diff --git a/tests/fixtures/web/html-book.zip b/tests/fixtures/web/html-book.zip new file mode 100644 index 0000000..aaf8ea9 Binary files /dev/null and b/tests/fixtures/web/html-book.zip differ diff --git a/tests/fixtures/web/html-entry-choice.zip b/tests/fixtures/web/html-entry-choice.zip new file mode 100644 index 0000000..b4fb638 Binary files /dev/null and b/tests/fixtures/web/html-entry-choice.zip differ diff --git a/tests/fixtures/web/html/assets/local.svg b/tests/fixtures/web/html/assets/local.svg new file mode 100644 index 0000000..bd8b95f --- /dev/null +++ b/tests/fixtures/web/html/assets/local.svg @@ -0,0 +1 @@ +Local SVG \ No newline at end of file diff --git a/tests/fixtures/web/html/assets/style.css b/tests/fixtures/web/html/assets/style.css new file mode 100644 index 0000000..7918cd1 --- /dev/null +++ b/tests/fixtures/web/html/assets/style.css @@ -0,0 +1 @@ +body{font-family:serif;margin:32px;line-height:1.6;color:#16293a}h1{border-bottom:3px solid #147d79}section{min-height:720px}.hidden{display:none}nav{background:#eef8f8;padding:16px} \ No newline at end of file diff --git a/tests/fixtures/web/html/index.html b/tests/fixtures/web/html/index.html new file mode 100644 index 0000000..4037f2c --- /dev/null +++ b/tests/fixtures/web/html/index.html @@ -0,0 +1,4 @@ +DocView local document +

文書リーダー検証 / Local document

+Local asset

第一節

日本語、Latin, 漢字かんじ。This is locally authored acceptance text.

+

第二節

同じ文書内の資源とアンカーを検証します。

ARIA見出し

先頭へ戻る

\ No newline at end of file diff --git a/tests/fixtures/web/html/next.html b/tests/fixtures/web/html/next.html new file mode 100644 index 0000000..40ca670 --- /dev/null +++ b/tests/fixtures/web/html/next.html @@ -0,0 +1 @@ +Second resource

別HTML

第二節へ戻る \ No newline at end of file diff --git a/tests/fixtures/web/html/security.html b/tests/fixtures/web/html/security.html new file mode 100644 index 0000000..c85c623 --- /dev/null +++ b/tests/fixtures/web/html/security.html @@ -0,0 +1,8 @@ +Static security fixture + +

Static security fixture

SCRIPT DID NOT RUN

+ + +
+Blocked fileBlocked other session +Explicit external link \ No newline at end of file diff --git a/tests/fixtures/web/html/vertical.html b/tests/fixtures/web/html/vertical.html new file mode 100644 index 0000000..62367db --- /dev/null +++ b/tests/fixtures/web/html/vertical.html @@ -0,0 +1 @@ +

縦書き第一節

縦書きの読書方向と物理スクロールを分けて検証する。

縦書き第二節

章内進捗は右から左へ増加する。

\ No newline at end of file diff --git a/tests/fixtures/web/late-layout.json b/tests/fixtures/web/late-layout.json new file mode 100644 index 0000000..dd0d52c --- /dev/null +++ b/tests/fixtures/web/late-layout.json @@ -0,0 +1,6 @@ +{ + "file": "late-layout.ttf", + "sha256": "fc0bc6e1390e7af926721b5e8c44768154fc72988282e976df259c5b8785355d", + "generator": "generate_late_font.py", + "purpose": "Original wide rectangle glyphs for font-swap layout tests" +} diff --git a/tests/fixtures/web/late-layout.ttf b/tests/fixtures/web/late-layout.ttf new file mode 100644 index 0000000..d83124f Binary files /dev/null and b/tests/fixtures/web/late-layout.ttf differ diff --git a/tests/fixtures/web/manifest.json b/tests/fixtures/web/manifest.json new file mode 100644 index 0000000..a0105b3 --- /dev/null +++ b/tests/fixtures/web/manifest.json @@ -0,0 +1,18 @@ +{ + "origin": "Original project-authored fixtures; no external documents", + "license": "Same as the DocView source repository", + "sha256": { + "epub2-ncx.epub": "87b3e80e406210c0afa99021ab5fa441f5551813f6ded8b6fb41d8dda6085e84", + "fixed-mixed.epub": "ec2e13c4d942118de7a2875096cdaa0919d846ec80972700d2126494f6621d6d", + "html/assets/local.svg": "3c56a229973afc2920450570c9ef0c83e4f741e62f28a704e814a43456da8982", + "html/assets/style.css": "fd088b6dd813329f5175de02daeb02eaad54245426288d08acf37debb5d66157", + "html/index.html": "dea8f12f6b2569e6849325554171682009acd07e9040f1b22fe743067561b8b1", + "html/next.html": "e7819fc5a50e4b546422c49bd375f2de234f81a229c5f2d38e8ff113c78bfa91", + "html/security.html": "b695a45530dd6fa2a545e9356066db5da074e3dbbb57635250526365e67e8ae0", + "html/vertical.html": "a76fea2bde5ca00be4930aa8f9cb1eb5ea36ad56362021bcdab7686b7266aac9", + "html-book.zip": "23f3eac7886f79964e26990b1b3d5e90428f363f47109fc37fdaf570c8e31984", + "html-entry-choice.zip": "3e35722b80758a9132f08def24a10c8b99e90e39f0c18ae3a90537f2e7a7ae62", + "reflow-rtl.epub": "7ca4ae56b08912b50657547238c9f71367105c8604ba6a8c537c900996ef4746", + "unsafe-traversal.zip": "b80e7c6c46e9e51d91750a67672700e8112ae7e3e915a3dc36df1b8234c2f391" + } +} diff --git a/tests/fixtures/web/reflow-rtl.epub b/tests/fixtures/web/reflow-rtl.epub new file mode 100644 index 0000000..784532b Binary files /dev/null and b/tests/fixtures/web/reflow-rtl.epub differ diff --git a/tests/fixtures/web/unsafe-traversal.zip b/tests/fixtures/web/unsafe-traversal.zip new file mode 100644 index 0000000..bf22b91 Binary files /dev/null and b/tests/fixtures/web/unsafe-traversal.zip differ diff --git a/tests/font_test_fixture.h b/tests/font_test_fixture.h new file mode 100644 index 0000000..6460917 --- /dev/null +++ b/tests/font_test_fixture.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +// The only text/font is beyond the worker's initial 256-page metadata batch. +// This permits a real late font RPC, after a complete initial viewport exists. +inline QByteArray deferredFontPdf() { + QList objects{"<< /Type /Catalog /Pages 2 0 R >>", QByteArray{}, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"}; + QByteArray kids; + for (int page = 0; page < 257; ++page) { + const int pageId = objects.size() + 1, streamId = pageId + 1; + kids += QByteArray::number(pageId) + " 0 R "; + objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents " + + QByteArray::number(streamId) + " 0 R >>"; + const QByteArray content = page == 256 ? "BT /F1 24 Tf 40 700 Td (Late font) Tj ET\n" : "0.1 0.3 0.7 rg 40 40 500 740 re f\n"; + objects << "<< /Length " + QByteArray::number(content.size()) + " >>\nstream\n" + content + "endstream"; + } + objects[1] = "<< /Type /Pages /Count 257 /Kids [" + kids + "] >>"; + QByteArray result = "%PDF-1.7\n"; QList offsets{0}; + for (qsizetype i = 0; i < objects.size(); ++i) { + offsets.append(result.size()); result += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n"; + } + const auto xref = result.size(); + result += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n"; + for (qsizetype i = 1; i < offsets.size(); ++i) result += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n"; + result += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n" + QByteArray::number(xref) + "\n%%EOF\n"; + return result; +} diff --git a/tests/generate_performance_corpus.py b/tests/generate_performance_corpus.py new file mode 100644 index 0000000..fac4ce6 --- /dev/null +++ b/tests/generate_performance_corpus.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Deterministic medium-size fixtures, assembled only from DocView test content. + +These exercise page/chapter counts and local-resource navigation. They are not +1 GiB scan stress documents or a statistically representative book collection. +""" +from pathlib import Path +import hashlib +import json +import zipfile +from pypdf import PdfReader, PdfWriter + +root = Path(__file__).resolve().parent +out = root / "fixtures/performance" +out.mkdir(parents=True, exist_ok=True) +pdf_root = root / "fixtures/pdf/extended" +sources = [PdfReader(pdf_root / name) for name in ("japanese-layout.pdf", "graphics-compositing.pdf")] +writer = PdfWriter() +for index in range(500): + source = sources[index % len(sources)] + writer.add_page(source.pages[index % len(source.pages)]) +writer.add_metadata({"/Title": "DocView generated 500-page performance corpus", "/Author": "DocView tests"}) +with (out / "standard-500.pdf").open("wb") as handle: + writer.write(handle) + +# A separate navigation document keeps the established page/graphics corpus +# unchanged while providing 500 explicit, source-authored outline destinations. +heading_writer = PdfWriter(clone_from=out / "standard-500.pdf") +for index in range(500): + heading_writer.add_outline_item(f"Generated chapter {index + 1}", index) +with (out / "standard-headings-500.pdf").open("wb") as handle: + heading_writer.write(handle) + +css = "body{font:18px sans-serif;line-height:1.6;margin:3em;max-width:55em}h1,h2{color:#165f69}img{width:240px}" +svg = '' +paragraph = "日本語の読み取りとローカル資源を確認します。This is original synthetic reading content." + +def html(chapter, sections=12): + body = f'

第{chapter + 1}章

' + body += ''.join(f'

節 {chapter + 1}.{i + 1}

{paragraph} ({chapter}/{i})

検証用の円
' for i in range(sections)) + return f'章{chapter + 1}{body}' + +html_dir = out / "html" +html_dir.mkdir(exist_ok=True) +(html_dir / "index.html").write_text(html(0, 400), encoding="utf-8") +(html_dir / "style.css").write_text(css, encoding="utf-8") +(html_dir / "image.svg").write_text(svg, encoding="utf-8") + +def archive(name, entries): + with zipfile.ZipFile(out / name, "w") as handle: + for path, content in entries: + info = zipfile.ZipInfo(path, (2026, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100600 << 16 + info.compress_type = zipfile.ZIP_STORED if path == "mimetype" else zipfile.ZIP_DEFLATED + handle.writestr(info, content.encode("utf-8")) + +archive("standard-html.zip", [("index.html", html(0, 400)), ("style.css", css), ("image.svg", svg)] + + [(f"chapter-{i}.html", html(i)) for i in range(1, 50)]) +manifest = ''.join(f'' for i in range(500)) +spine = ''.join(f'' for i in range(500)) +nav = ''.join(f'
  • 第{i + 1}章
  • ' for i in range(500)) +package = f'urn:docview:generated:500生成した500章の資料ja{manifest}{spine}' +epub_entries = [("mimetype", "application/epub+zip"), + ("META-INF/container.xml", ''), + ("book.opf", package), ("nav.xhtml", f'目次'), + ("style.css", css), ("image.svg", svg)] + [(f"chapter-{i}.xhtml", html(i)) for i in range(500)] +# Preserve the original short-tail corpus for end-clamping regressions. The +# timed corpus makes all 13 heading destinations distinct at any viewport size, +# without shortening the fixed traversal or counting unchanged positions. +archive("standard-500-clamped.epub", epub_entries) +archive("standard-500.epub", [(path, content + "\nsection:last-child{min-height:100vh}" if path == "style.css" else content) + for path, content in epub_entries]) +files = {str(path.relative_to(out)): {"bytes": path.stat().st_size, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + for path in sorted(out.rglob("*")) if path.is_file() and path.name not in ("manifest.json", "README.md")} +files["standard-500.epub"]["purpose"] = "Timed navigation: 13 distinct heading destinations per chapter; final section minimum height is one viewport." +files["standard-500-clamped.epub"]["purpose"] = "Original short-tail corpus, retained unchanged for end-clamping regressions and prior failed benchmark hashes." +(out / "manifest.json").write_text(json.dumps({"origin": "DocView original generated test content; PDF embeds the OFL font described in ../pdf/extended/manifest.json", + "limits": "500 PDF pages and 500 EPUB chapters, small reused images; not a 1 GiB scan corpus", "files": files}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +print(json.dumps({name: info["bytes"] for name, info in files.items()}, ensure_ascii=False)) diff --git a/tests/generate_render_review.py b/tests/generate_render_review.py new file mode 100644 index 0000000..fbc57cf --- /dev/null +++ b/tests/generate_render_review.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Assemble existing rendering evidence for human review; never grant approval.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import struct +from urllib.parse import quote + +ROOT = Path(__file__).resolve().parents[1] +TESTS = ROOT / 'tests' +RESULTS = TESTS / 'results' +OUTPUT = RESULTS / 'render-review' + + +def sha(path): + with path.open('rb') as handle: + return hashlib.file_digest(handle, 'sha256').hexdigest() + + +def asset(path, expected=None, label=None): + path = path.resolve(strict=True) + if not path.is_relative_to(TESTS): + raise ValueError(f'Evidence outside tests directory: {path}') + digest = sha(path) + if expected is not None and expected != digest: + raise ValueError(f'Stale evidence: {path}') + value = {'url': quote(os.path.relpath(path, OUTPUT)), 'sha256': digest} + if label: + value['label'] = label + if path.suffix == '.png': + with path.open('rb') as handle: + header = handle.read(24) + if header[:8] != b'\x89PNG\r\n\x1a\n' or header[12:16] != b'IHDR': + raise ValueError(f'Invalid PNG header: {path}') + value['size'] = list(struct.unpack('>II', header[16:24])) + return value + + +def main(): + global RESULTS, OUTPUT + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--results-root', type=Path, default=RESULTS, + help='Parent of the six comparison directories; gallery is written to render-review below it.') + parser.add_argument('--build-dir', type=Path, default=ROOT / 'build', + help='Build whose PDF worker must match all six comparison reports.') + parser.add_argument('--output', type=Path, + help='Gallery directory; defaults to RESULTS_ROOT/render-review.') + args = parser.parse_args() + RESULTS = args.results_root.resolve() + OUTPUT = args.output.resolve() if args.output else RESULTS / 'render-review' + worker = sha(args.build_dir.resolve() / 'docview-pdf-worker') + comparisons = {} + inputs = [] + for name in ['pdf', 'pdf-extended', 'pdf-fonts', 'link-borders', 'pdf-icc', 'font-collection']: + path = RESULTS / name / 'comparison.json' + values = json.loads(path.read_text()) + if values['worker_sha256'] != worker: + raise ValueError(f'Comparison does not match current worker: {name}') + comparisons[name] = values + inputs.append(asset(path)) + cases = [] + + def add(id_, group, title, source, source_hash, left, right, diff, size, checks, + notes, measurement, independent=True): + entry = { + 'id': id_, 'group': group, 'title': title, + 'source': asset(source, source_hash), + 'left': asset(left, label='DocView / PDFium' if independent else 'PDFium:個別TTF'), + 'right': asset(right, label='Poppler(独立レンダラー)' if independent else 'PDFium:TTC'), + 'difference': asset(diff) if diff else None, + 'independentReference': independent, + 'checks': checks, 'notes': notes, + 'metrics': f'{size[0]} × {size[1]} px / RGB平均絶対差 {measurement:.4f} / 255(合否閾値ではありません)', + } + for key in ['left', 'right', 'difference']: + if entry[key] and entry[key]['size'] != size: + raise ValueError(f'Dimension mismatch: {id_}/{key}') + cases.append(entry) + + basic = comparisons['pdf'] + add('basic-page-1', '基本描画', '本文・リンク・保存済みフォーム外観', + TESTS / 'fixtures/pdf/navigation.pdf', basic['fixture_sha256'], + RESULTS / 'pdf/pdfium.png', RESULTS / 'pdf/poppler.png', RESULTS / 'pdf/difference.png', + basic['size'], ['Heading One、Needle、Second MCIDと黒い矩形が見える。', + '保存されたフォーム外観は赤い矩形。下部のリンク枠が見える。'], + '文字のアンチエイリアスと、保存外観のないText注釈のアイコンは描画器により異なります。' + 'フィールド値 Saved の文字表示はこの資料の期待値ではありません。第2ページはこの比較に含みません。', + basic['mean_absolute_channel_error']) + + extended_checks = { + 'japanese-layout': [], + 'graphics-compositing': [ + 'RGB/RGBA画像、円形クリップ、グラデーション、透明な重なり、CMYK色見本が表示される。', + '50%透明合成の3点は生成時の期待色に対して両描画器とも各チャンネル差2以内。'], + 'page-geometry': [ + '赤・緑・青・橙の4隅のマーカーがページの回転に応じた位置にある。', + 'CropBoxの外にあるピンク色の余白が表示されない。'], + 'unembedded-font': ['Standard-14のテキストが欠落せず、行と字形を確認できる。'], + 'icc-linear-rgb': [ + '上段ICCベクターと下段ICC画像は、中段DeviceRGBより明るい。', + '線形0.5の灰色は約188、中段DeviceRGBは128。'], + } + extended_notes = { + 'japanese-layout': '埋込BIZ UDPGothicと明示的な文字座標の検証です。この縦列はnative縦書きシェーピングの試験ではありません。', + 'graphics-compositing': '文字や画像の輪郭にはラスタライズの差があります。全ての合成・印刷色を検証する資料ではありません。', + 'page-geometry': '4ページ合計16個のマーカーを生成元の座標と照合済みです。', + 'unembedded-font': 'DocViewはLiberation、PopplerはNimbusへ代替します。書体の形状差と文字欠落を区別してください。', + 'icc-linear-rgb': '自作D65・sRGB原色・gamma 1のmatrix/TRCプロファイル。ICC LUT、校正済みCMYK、実モニターの発色は対象外です。', + } + extended_titles = {'japanese-layout': '埋込日本語・文字配置', 'graphics-compositing': '画像・透明・クリップ', + 'page-geometry': 'ページ寸法・回転・切り抜き', 'unembedded-font': '非埋込Standard-14', + 'icc-linear-rgb': 'ICC入力色(144 DPI)'} + for doc in comparisons['pdf-extended']['documents']: + stem = Path(doc['file']).stem + base = RESULTS / 'pdf-extended' / stem + for page in doc['pages']: + n = page['page'] + checks = extended_checks[stem] + if stem == 'japanese-layout': + checks = (['「日本語の文字配置と埋込みフォント」が読める。', + '横組の本文と明示配置されたルビが読め、文字が重ならない。'] if n == 1 else + ['縦列「日本語縦書き検証」「天地左右回転配置」の順序と配置を確認する。', + '0°・90°・180°・270°に回転した文字がそれぞれ表示される。']) + add(f'extended-{stem}-{n}', '追加PDF', f'{extended_titles[stem]}:{n}ページ', + TESTS / 'fixtures/pdf/extended' / doc['file'], doc['fixture_sha256'], + base / f'pdfium/page-{n}.png', base / f'poppler/page-{n}.png', base / f'difference-{n}.png', + page['expected_size_px'], checks, extended_notes[stem], + page['mean_absolute_channel_error']) + + fonts = comparisons['pdf-fonts'] + for page in fonts['pages']: + n = page['page'] + add(f'japanese-unembedded-{n}', '日本語フォント', '非埋込CID:' + ('横書き' if n == 1 else '縦書き'), + TESTS / 'fixtures/pdf/fonts/unembedded-japanese.pdf', fonts['fixture_sha256'], + RESULTS / f'pdf-fonts/worker/page-{n}.png', RESULTS / f'pdf-fonts/poppler-{n}.png', + RESULTS / f'pdf-fonts/difference-{n}.png', page['size'], + ['「日本語の文書を表示します。」「漢字とひらがな、カタカナ。」が読める。', + '「「縦書き」の句読点を確認。」の文字と句読点の位置、横組/縦組の順序を確認する。'], + 'UniJIS-UCS2-H/V。DocViewはIPAexMincho / BIZ UDPGothicを代替に選択し、Popplerとは書体が異なります。' + '元のHeisei書体そのものの再現、全CMapやWindowsの互換性を示すものではありません。', + page['mean_absolute_channel_error']) + + borders = comparisons['link-borders'] + border_manifest = TESTS / 'fixtures/pdf/link-borders/manifest.json' + inputs.append(asset(border_manifest)) + border_source = next(item for item in json.loads(border_manifest.read_text())['fixtures'] + if item['file'] == 'styles.pdf') + if border_source['sha256'] != borders['fixture_sha256']: + raise ValueError('Link border manifest identity mismatch') + for page in borders['pages']: + n = page['page'] + add(f'link-borders-{n}', 'リンク注釈', f'リンク枠:回転{page["rotation"]}°', + TESTS / 'fixtures/pdf/link-borders/styles.pdf', borders['fixture_sha256'], + RESULTS / f'link-borders/worker/page-{n}.png', RESULTS / f'link-borders/poppler-{n}.png', + RESULTS / f'link-borders/difference-{n}.png', page['whole_page']['size'], + ['実線・破線の間隔・下線・BS優先・丸角・色・半透明・bevel/insetを下の位置表に沿って確認する。', + 'zero-width、transparent、hidden、no-view、empty-appearance-preservedの枠は見えない。', + 'appearance-preservedは緑の保存外観だけが見え、余分な赤枠が追加されない。'], + 'Popplerの保存外観なしリンク補完はgray/CMYK、丸角、bevel/inset、alphaに制約があり、' + '保存AP/空APにも余分な枠を描きます。これらは右画像を正解にせず左の期待値を確認してください。' + 'NoZoom/NoRotateの試験は別記録です。', page['whole_page']['mean_absolute_channel_error']) + regions = [] + references = {item['name']: item['reference_supported'] for item in page['annotations']} + for annotation in border_source['annotations']: + x1, y1, x2, y2 = annotation['rect'] + points = [] + # Fixture CropBox [20,30,530,450], 144 DPI. Convert PDF bottom-left + # coordinates to the top-left of the actual rotated raster. + for x in (x1, x2): + for y in (y1, y2): + px, py = (x - 20) * 2, (450 - y) * 2 + points.append([(px, py), (840 - py, px), (1020 - px, 840 - py), + (py, 1020 - px)][page['rotation'] // 90]) + rect = [min(p[0] for p in points), min(p[1] for p in points), + max(p[0] for p in points), max(p[1] for p in points)] + name = annotation['name'] + expectation = '表示される' if annotation['has_border'] else '表示されない' + if name == 'appearance-preserved': + expectation = '緑の保存外観だけが表示される(赤い追加枠なし)' + if name == 'empty-appearance-preserved': + expectation = '空の保存外観を保つ(追加枠なし)' + regions.append({'name': name, 'rect': rect, 'expectation': expectation, + 'referenceSupported': references[name]}) + cases[-1]['regions'] = regions + + icc = comparisons['pdf-icc'] + if icc['fixture_sha256_before'] != icc['fixture_sha256_after'] or not icc['all_source_assertions_pass']: + raise ValueError('ICC source assertions are not current/passing') + for scale in icc['scales']: + value = scale['scale'] + base = RESULTS / 'pdf-icc' / ('scale-' + str(value).replace('.', '_')) + add(f'icc-scale-{value}', 'ICCと倍率', f'ICC:{value:g}倍({scale["dpi"]:g} DPI)', + TESTS / 'fixtures/pdf/extended/icc-linear-rgb.pdf', icc['fixture_sha256_before'], + base / 'pdfium/page-1.png', base / 'poppler.png', base / 'difference.png', scale['expected_size'], + extended_checks['icc-linear-rgb'] + ['この倍率の18色probeは理論値から各チャンネル差3以内。'], + extended_notes['icc-linear-rgb'], scale['mean_absolute_channel_error']) + + collection = comparisons['font-collection'] + for pair in collection['comparisons']: + name = pair['case'] + runs = [r for r in collection['runs'] if r['case'] == name] + if len(runs) != 2 or runs[0]['source_sha256'] != runs[1]['source_sha256']: + raise ValueError('TTC source identity mismatch') + source = (RESULTS / 'font-collection/regular-bold.pdf' if name == 'paired-styles' + else TESTS / 'fixtures/pdf/extended/unembedded-font.pdf') + left = RESULTS / f'font-collection/standalone/{name}/page-1.png' + right = RESULTS / f'font-collection/collection/{name}/page-1.png' + asset(left, pair['standalone_png_sha256']) + asset(right, pair['collection_png_sha256']) + add(f'ttc-{name}', 'TTC/TTF(同一描画器)', 'TTCとTTF:' + ('通常/太字' if name == 'paired-styles' else 'Standard-14'), + source, runs[0]['source_sha256'], left, right, None, pair['dimensions'], + ['左右が同じ画像で、TTCのface選択が個別TTFと一致する。'] + + (['同じ文「Collection face 0123456789 AVWA」の通常と太字が区別できる。'] if name == 'paired-styles' else []), + '同じPDFiumでフォントの格納形式を変えた比較です。独立レンダラーによる正解画像ではありません。' + 'Latinの静的TrueTypeを対象とし、CJK/CFF/Windowsはこの比較に含みません。試験用フォント本体は回収済みです。', + pair['mean_absolute_channel_error'], independent=False) + + if len(cases) != 21 or len({c['id'] for c in cases}) != 21: + raise ValueError('Expected exactly 21 distinct review cases') + template_path = TESTS / 'render_review/index.template.html' + template = template_path.read_text() + if template.count('__REVIEW_DATA__') != 1: + raise ValueError('Review template must contain one data placeholder') + data = {'schemaVersion': 1, 'workerSha256': worker, 'approvalStatus': 'pending-human-review', + 'templateSha256': sha(template_path), 'generatorSha256': sha(Path(__file__)), + 'comparisonInputs': inputs, 'cases': cases} + canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode() + data['bundleSha256'] = hashlib.sha256(canonical).hexdigest() + serialized = json.dumps(data, ensure_ascii=False, indent=2) + '\n' + escaped = serialized.replace('&', '\\u0026').replace('<', '\\u003c').replace('>', '\\u003e') + OUTPUT.mkdir(parents=True, exist_ok=True) + (OUTPUT / 'manifest.json').write_text(serialized) + (OUTPUT / 'index.html').write_text(template.replace('__REVIEW_DATA__', escaped)) + print(f'Generated {len(cases)} pairs: {data["bundleSha256"]}; approval remains pending.') + + +if __name__ == '__main__': + main() diff --git a/tests/generate_stress_pdf.py b/tests/generate_stress_pdf.py new file mode 100644 index 0000000..5c78020 --- /dev/null +++ b/tests/generate_stress_pdf.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Stream an original, deterministic scan-like PDF; no padding-only payload. + +The default is 5,000 pages referencing all 1,280 unique 512x512 RGB images. +Each image is an uncompressed 768 KiB stream used by at least one page. +Only one image (plus its reusable template) is buffered at a time. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import time + +ROOT = Path(__file__).resolve().parents[1] +WIDTH = HEIGHT = 512 +IMAGE_BYTES = WIDTH * HEIGHT * 3 + + +def raster_template(): + raster = bytearray(IMAGE_BYTES) + for y in range(HEIGHT): + for x in range(WIDTH): + value = 244 + ((x * 13 + y * 7) % 12) + # Authored scan-like text blocks, row rules and page margins. + if 36 <= x < 476 and 80 <= y < 465: + row, within = divmod(y - 80, 22) + if within < 8 and x < 470 - (row % 4) * 53 and (x - 36) % 14 < 10: + value = 38 + ((x + y) % 20) + i = (y * WIDTH + x) * 3 + raster[i:i + 3] = bytes((value, value, value)) + return raster + + +def raster_for(template, index): + raster = template.copy() + # A 16-bit, visually legible image-ID barcode makes each image distinct. + for bit in range(16): + color = bytes((24, 77, 113)) if index & (1 << bit) else bytes((210, 222, 230)) + stripe = color * 24 + x = 48 + bit * 26 + for y in range(28, 60): + i = (y * WIDTH + x) * 3 + raster[i:i + len(stripe)] = stripe + return raster + + +def generate(path, pages=5000, images=1280): + if pages < 1 or images < 1 or images > pages or pages > 100000 or images > 65536: + raise ValueError("Require 1 <= images <= pages <= 100000 and images <= 65536") + path = Path(path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + estimated_bytes = images * IMAGE_BYTES + pages * 768 + 2 * 1024 * 1024 + if estimated_bytes >= 10_000_000_000: + raise ValueError("The classic PDF cross-reference format is limited to ten-digit byte offsets") + free_before = shutil.disk_usage(path.parent).free + reserve = 512 * 1024 * 1024 + if free_before < estimated_bytes + reserve: + raise RuntimeError("Insufficient free disk for the PDF plus a 512 MiB reserve") + if path.exists(): + raise FileExistsError(f"Refusing to replace an existing file: {path}") + started = time.monotonic() + digest = hashlib.sha256() + image_first = 4 + content_first = image_first + images + page_first = content_first + pages + object_count = page_first + pages - 1 + offsets = [0] * (object_count + 1) + records = [] + sample_pages = sorted(set([0, min(97, pages - 1), min(images - 1, pages - 1), pages // 2, pages - 1])) + page_records = [] + template = raster_template() + try: + with path.open("xb", buffering=1024 * 1024) as out: + def write(data): + out.write(data) + digest.update(data) + + def begin(number): + offsets[number] = out.tell() + write(f"{number} 0 obj\n".encode()) + + def object_(number, body): + begin(number) + write(body + b"\nendobj\n") + + write(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + object_(1, b"<< /Type /Catalog /Pages 2 0 R >>") + kids = " ".join(f"{page_first + i} 0 R" for i in range(pages)) + object_(2, f"<< /Type /Pages /Count {pages} /Kids [{kids}] >>".encode()) + object_(3, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + for index in range(images): + raster = raster_for(template, index) + begin(image_first + index) + write((f"<< /Type /XObject /Subtype /Image /Width {WIDTH} /Height {HEIGHT} " + f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {IMAGE_BYTES} >>\nstream\n").encode()) + stream_offset = out.tell() + write(raster) + write(b"\nendstream\nendobj\n") + records.append({"index": index, "object": image_first + index, + "streamOffset": stream_offset, "bytes": len(raster), + "sha256": hashlib.sha256(raster).hexdigest()}) + del raster + for page in range(pages): + image = page % images + marker = f"DOCVIEW STRESS PAGE {page + 1:05d} OF {pages:05d} IMAGE {image:04d}" + stream = (f"q 512 0 0 512 44 180 cm /Scan Do Q\n" + f"0.1 0.3 0.6 rg 24 748 12 12 re f\n" + f"0.8 0.2 0.3 rg 564 24 12 12 re f\n" + f"0 g BT /F1 14 Tf 44 752 Td ({marker}) Tj ET\n" + f"BT /F1 11 Tf 44 142 Td (Original synthetic raster; no hidden padding.) Tj ET\n" + f"BT /F1 11 Tf 44 120 Td (Raster image {image:04d}; 512 x 512 RGB pixels.) Tj ET\n").encode() + object_(content_first + page, f"<< /Length {len(stream)} >>\nstream\n".encode() + stream + b"endstream") + if page in sample_pages: + page_records.append({"pageIndex": page, "physicalPage": page + 1, "imageIndex": image, + "pageObject": page_first + page, "contentObject": content_first + page, + "marker": marker, "textBaselinePt": [44, 752]}) + for page in range(pages): + object_(page_first + page, + (f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] " + f"/Resources << /Font << /F1 3 0 R >> /XObject << /Scan {image_first + page % images} 0 R >> >> " + f"/Contents {content_first + page} 0 R >>").encode()) + xref = out.tell() + write(f"xref\n0 {object_count + 1}\n0000000000 65535 f \n".encode()) + for offset in offsets[1:]: + write(f"{offset:010d} 00000 n \n".encode()) + write((f"trailer\n<< /Size {object_count + 1} /Root 1 0 R >>\n" + f"startxref\n{xref}\n%%EOF\n").encode()) + out.flush() + os.fsync(out.fileno()) + except BaseException: + path.unlink(missing_ok=True) + raise + return {"schemaVersion": 1, "generator": "generate_stress_pdf.py", "source": str(path), + "sha256": digest.hexdigest(), "sizeBytes": path.stat().st_size, + "pageCount": pages, "uniqueImageCount": images, "rasterPayloadBytes": images * IMAGE_BYTES, + "generationSeconds": time.monotonic() - started, "freeDiskBeforeBytes": free_before, + "maxRasterBufferBytes": IMAGE_BYTES * 2, "pageSizePt": [600, 800], + "imageRectanglePt": [44, 180, 556, 692], + "markerRectanglesPt": [[24, 748, 36, 760], [564, 24, 576, 36]], + "imageUse": "pageIndex % uniqueImageCount; every image is used, streams have no filter", + "images": records, "samplePages": page_records, "xrefOffset": xref, + "origin": "Self-authored raster texture, block glyphs, barcode and page markers; no external content"} + + +def validate_source(path, manifest): + path = Path(path) + if path.stat().st_size != manifest["sizeBytes"]: + raise AssertionError("File size mismatch") + images = manifest["images"] + if len({image["sha256"] for image in images}) != len(images): + raise AssertionError("Raster images are not unique") + checked = sorted(set([0, len(images) // 2, len(images) - 1])) + with path.open("rb") as source: + source.seek(manifest["xrefOffset"]) + if source.read(5) != b"xref\n": + raise AssertionError("Cross-reference offset mismatch") + for index in checked: + image = images[index] + source.seek(image["streamOffset"]) + if hashlib.sha256(source.read(image["bytes"])).hexdigest() != image["sha256"]: + raise AssertionError("Raster stream mismatch") + return {"sampledImageIndices": checked, "uniqueRasterDigests": len(images), + "sizeAndXrefValid": True} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=ROOT / "build/stress/scan-5000.pdf") + parser.add_argument("--pages", type=int, default=5000) + parser.add_argument("--images", type=int, default=1280) + args = parser.parse_args() + manifest = generate(args.output, args.pages, args.images) + manifest["validation"] = validate_source(args.output, manifest) + manifest_path = args.output.with_suffix(".json") + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(json.dumps({key: value for key, value in manifest.items() if key != "images"}, indent=2)) diff --git a/tests/generate_web_fixtures.py b/tests/generate_web_fixtures.py new file mode 100644 index 0000000..0cc4b05 --- /dev/null +++ b/tests/generate_web_fixtures.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Generate deterministic, project-authored HTML/ZIP/EPUB acceptance fixtures.""" +from pathlib import Path +import hashlib +import json +import zipfile + +ROOT = Path(__file__).resolve().parent / "fixtures" / "web" +ROOT.mkdir(parents=True, exist_ok=True) + + +def write(path, data): + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data.encode("utf-8") if isinstance(data, str) else data) + + +def archive(name, entries): + with zipfile.ZipFile(ROOT / name, "w") as output: + for path, content in entries.items(): + info = zipfile.ZipInfo(path, (2026, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100600 << 16 + output.writestr(info, content, compress_type=zipfile.ZIP_STORED if path == "mimetype" else zipfile.ZIP_DEFLATED) + + +style = "body{font-family:serif;margin:32px;line-height:1.6;color:#16293a}h1{border-bottom:3px solid #147d79}section{min-height:720px}.hidden{display:none}nav{background:#eef8f8;padding:16px}" +svg = 'Local SVG' +html = """DocView local document +

    文書リーダー検証 / Local document

    +Local asset

    第一節

    日本語、Latin, 漢字かんじ。This is locally authored acceptance text.

    +

    第二節

    同じ文書内の資源とアンカーを検証します。

    ARIA見出し

    先頭へ戻る

    """ +write("html/index.html", html) +write("html/assets/style.css", style) +write("html/assets/local.svg", svg) +write("html/next.html", 'Second resource

    別HTML

    第二節へ戻る') +write("html/vertical.html", '

    縦書き第一節

    縦書きの読書方向と物理スクロールを分けて検証する。

    縦書き第二節

    章内進捗は右から左へ増加する。

    ') +write("html/security.html", """Static security fixture + +

    Static security fixture

    SCRIPT DID NOT RUN

    + + +
    +Blocked fileBlocked other session +Explicit external link""") +entries = {"book/index.html": html, "book/assets/style.css": style, "book/assets/local.svg": svg, + "book/next.html": (ROOT / "html/next.html").read_text()} +archive("html-book.zip", entries) +archive("html-entry-choice.zip", {"one.html": "

    One

    ", "two.html": "

    Two

    "}) +archive("unsafe-traversal.zip", {"../outside.html": "

    Must not extract

    "}) + +container = '' +package = '''DocView EPUB verificationurn:uuid:docview-fixtureja''' +nav = '''目次''' +chapter = lambda title, anchor: f'{title}

    {title}

    著者指定の本文とルビを表示します。読書どくしょ。

    章内の見出し

    Static chapter content.

    ' +epub = {"mimetype": "application/epub+zip", "META-INF/container.xml": container, "OPS/package.opf": package, + "OPS/nav.xhtml": nav, "OPS/first.xhtml": chapter("第一章", "p1"), "OPS/second.xhtml": chapter("第二章", "p2"), + "OPS/extra.xhtml": chapter("補遺", "extra"), "OPS/style.css": style} +archive("reflow-rtl.epub", epub) +fixed = dict(epub) +fixed["OPS/package.opf"] = package.replace('', 'pre-paginated').replace('', '') +fixed["OPS/first.xhtml"] = 'Fixed page

    Fixed layout 900 × 650

    Second chapter switches to reflow.

    ' +archive("fixed-mixed.epub", fixed) +epub2 = dict(epub) +epub2["OPS/package.opf"] = package.replace('version="3.0"', 'version="2.0"').replace('', '') +epub2["OPS/nav.xhtml"] = '/${worker}") +endforeach() diff --git a/tests/heavy_pdf/README.md b/tests/heavy_pdf/README.md new file mode 100644 index 0000000..9b41c89 --- /dev/null +++ b/tests/heavy_pdf/README.md @@ -0,0 +1,21 @@ +# 高解像度・高密度PDFのGUI検証 + +本番Controller・QML・PdfCanvasと既存Releaseビルドのcore/common/font brokerライブラリを使う、Linux専用の追加試験。PDF/Archive workerは既存の実行ファイルへ接続し、模擬workerや保護無効化を使わない。通常のCTestや製品バイナリーを変更せず、別の実行ファイルとしてビルドする。 + +```sh +cmake -S tests/heavy_pdf -B build-heavy-pdf -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DDOCVIEW_CANONICAL_BUILD="$PWD/build" +cmake --build build-heavy-pdf --parallel 4 +python3 tests/heavy_pdf/run.py --output tests/results/heavy-pdf/new-run +``` + +既存ビルドと同じQt 6.11.2・ツールチェーン・依存ライブラリを使う。Qt SDKを別配置している環境では`CMAKE_PREFIX_PATH`、`PATH`、`LD_LIBRARY_PATH`、`PKG_CONFIG_PATH`も既存ビルドに合わせる。別のビルド先はrunnerの`--binary`と`--canonical-build`で明示する。既存の結果フォルダーは上書きしない。 + +資料は2ページ、595×842 pt。1ページ目は4,960×7,016 RGB画像(展開後104,398,080 bytes、A4の600dpi相当)、2ページ目はalpha 0.16・Multiplyで重ねる60,000個のベクターパス。生成元はオリジナルの決定的なアルゴリズムで、実スキャンや書籍ではない。Helveticaの標準フォント参照は非埋込であり、OSの代替を使用する。 + +qpdfによる画像streamの独立復号でサイズと全画素hashを照合し、Popplerの`pdfinfo`/`pdftotext`でページと本文markerを確認する。GUIでは画像の暗いブロックと明るい紙面、多重合成したベクター領域、各ページ固有の色markerを取得画素から検査する。その後、実open RPCが未完の状態でEscを送り、実render RPCが未完の状態で別PDFへ切り替える。旧sessionの破棄、旧文書の保持、切替後の安定、原本hash不変、文書close後のcache解放、通常解像度の維持を確認する。 + +専用Xvfb 1100×760・DPR1・Qt Quick software・専用D-Busを使う。XDG保存先は一時領域で、利用者の履歴を使用しない。10ms heartbeatの最大間隔はCI停止検出用の1,500ms未満で判定し、50msの視覚応答p95を代用しない。50msごとに採るRSS合計は共有ページの重複を含む。 + +出力には原本、manifest、復号照合、QtTestログ、3画面、各操作の記録、source/library/worker hashを保存する。`docview`本体のmain関数そのものを実行する試験ではなく、同じ本番GUI部品を使う専用test executableである。GPU、実IME、物理画面、Windows、長時間リークや広範な実書籍の互換性をこの結果だけで合格にしない。[保存済み結果](../results/heavy-pdf/README.md) diff --git a/tests/heavy_pdf/generate.py b/tests/heavy_pdf/generate.py new file mode 100644 index 0000000..6338421 --- /dev/null +++ b/tests/heavy_pdf/generate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Generate finite, original high-resolution raster and transparent-vector pages.""" +import argparse +import hashlib +import json +from pathlib import Path +import zlib + + +def generate(path): + path = Path(path) + if path.exists(): + raise FileExistsError(path) + width, height, curves = 4960, 7016, 60000 + compressor = zlib.compressobj(6) + chunks = [] + raw_hash = hashlib.sha256() + # Bounded row buffer; 104,398,080 decoded RGB bytes, about A4 at 600 dpi. + paper = bytes((248, 246, 240)) * width + text_row = b''.join(bytes((35, 40, 48)) if 260 < x < width - 260 and x % 83 < 61 + else bytes((248, 246, 240)) for x in range(width)) + for y in range(height): + row = text_row if 500 < y < height - 500 and y % 123 < 35 else paper + raw_hash.update(row) + part = compressor.compress(row) + if part: + chunks.append(part) + chunks.append(compressor.flush()) + image = b''.join(chunks) + vector = [b'q 20 20 555 802 re W n /GS gs\n'] + for index in range(curves): + x, y = 25 + (index * 37) % 490, 30 + (index * 53) % 730 + r, g, b = (index % 7) / 7, (index % 11) / 11, (index % 13) / 13 + vector.append((f'{r:.3f} {g:.3f} {b:.3f} rg {x} {y} m ' + f'{x+38} {y+67} {x+61} {y-19} {x+69} {y+22} c ' + f'{x+25} {y+48} l h f\n').encode()) + vector.append(b'Q\n0.85 0.1 0.65 rg 30 780 32 32 re f\n' + b'BT /F1 12 Tf 80 790 Td (DOCVIEW COMPLEX 60000 TRANSPARENT PATHS) Tj ET\n') + content = [b'q 555 0 0 782 20 20 cm /Scan Do Q\n' + b'0.1 0.3 0.6 rg 30 780 32 32 re f\n' + b'BT /F1 12 Tf 80 790 Td (DOCVIEW RASTER 4960 x 7016 RGB) Tj ET\n', + b''.join(vector)] + def stream(data, dictionary=b''): + return dictionary + b' /Length ' + str(len(data)).encode() + b' >>\nstream\n' + data + b'\nendstream' + objects = [b'<< /Type /Catalog /Pages 2 0 R >>', + b'<< /Type /Pages /Count 2 /Kids [7 0 R 9 0 R] >>', + b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + stream(image, f'<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode'.encode()), + b'<< /Type /ExtGState /ca 0.16 /CA 0.16 /BM /Multiply >>', + stream(zlib.compress(content[0]), b'<< /Filter /FlateDecode'), + b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /XObject << /Scan 4 0 R >> >> /Contents 6 0 R >>', + stream(zlib.compress(content[1]), b'<< /Filter /FlateDecode'), + b'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS 5 0 R >> >> /Contents 8 0 R >>'] + offsets = [0] + with path.open('xb') as out: + out.write(b'%PDF-1.7\n%\xe2\xe3\xcf\xd3\n') + for index, data in enumerate(objects, 1): + offsets.append(out.tell()) + out.write(f'{index} 0 obj\n'.encode() + data + b'\nendobj\n') + xref = out.tell() + out.write(f'xref\n0 {len(offsets)}\n0000000000 65535 f \n'.encode()) + for offset in offsets[1:]: + out.write(f'{offset:010d} 00000 n \n'.encode()) + out.write(f'trailer\n<< /Root 1 0 R /Size {len(offsets)} >>\nstartxref\n{xref}\n%%EOF\n'.encode()) + return {'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'sizeBytes': path.stat().st_size, + 'pages': 2, 'rasterWidth': width, 'rasterHeight': height, 'decodedRasterBytes': width * height * 3, + 'rasterSha256': raw_hash.hexdigest(), 'transparentPathCount': curves, + 'rasterFilter': 'FlateDecode', 'vectorAlpha': 0.16, 'vectorBlendMode': 'Multiply', + 'source': 'Original deterministic synthetic fixture; standard Helvetica is not embedded', + 'scope': 'One 600-dpi-equivalent image and one dense transparent-vector page, not a full book or real scan corpus'} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path) + args = parser.parse_args() + manifest = generate(args.output) + args.output.with_suffix('.json').write_text(json.dumps(manifest, indent=2) + '\n') + print(json.dumps(manifest)) diff --git a/tests/heavy_pdf/run.py b/tests/heavy_pdf/run.py new file mode 100644 index 0000000..dd6bcb2 --- /dev/null +++ b/tests/heavy_pdf/run.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Run the supplemental heavy-PDF GUI test on a private X11 software display.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shlex +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'tests')) +from run_stress_pdf import run_observed +from generate import generate + + +def sha(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, default=ROOT / 'build-heavy-pdf/test_heavy_pdf') + parser.add_argument('--canonical-build', type=Path, default=ROOT / 'build') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + if args.output.exists(): + parser.error('Choose a new output directory to preserve previous evidence') + flags = shlex.split(os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '')) + if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or any( + flag.split('=', 1)[0] in ('--no-sandbox', '--disable-setuid-sandbox', '--disable-seccomp-filter-sandbox') for flag in flags): + parser.error('Sandbox-disabling settings are not allowed') + if not args.private_session: + env = os.environ.copy() + for name in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'DBUS_SESSION_BUS_ADDRESS', + 'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', + 'QT_SCALE_FACTOR_ROUNDING_POLICY', 'QT_ENABLE_HIGHDPI_SCALING', 'QT_QPA_PLATFORMTHEME']: + env.pop(name, None) + env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1', + QT_FONT_DPI='96', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_FORCE_STDERR_LOGGING='1') + with tempfile.TemporaryDirectory(prefix='docview-heavy-session-') as private: + bus = Path(private) / 'bus.conf' + bus.write_text('sessionEXTERNAL' + f'unix:tmpdir={private}' + '' + '') + env['XDG_RUNTIME_DIR'] = private + return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', + 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp', + sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode + output = args.output.resolve(); output.mkdir(parents=True, exist_ok=False) + binary, build = args.binary.resolve(), args.canonical_build.resolve() + report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(), + 'scope': 'Linux supplemental heavy-PDF GUI validation; not native Windows, physical display, reference hardware or long-duration acceptance'} + watched = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker', + 'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']] + before = {os.path.relpath(p, ROOT): sha(p) for p in watched} + source_files = [ROOT / 'resources.qrc'] + for folder in ['src', 'qml', 'resources', 'tests/heavy_pdf']: + source_files += [p for p in (ROOT / folder).rglob('*') if p.is_file() and '__pycache__' not in p.parts] + sources = {str(p.relative_to(ROOT)): sha(p) for p in sorted(set(source_files))} + try: + # The executable resolves workers by application directory. Verify the + # linked paths refer to the canonical tested files before any launch. + for worker in ['docview-pdf-worker', 'docview-archive-worker']: + assert sha(binary.parent / worker) == sha(build / worker) + heavy = output / 'heavy.pdf' + manifest = generate(heavy) + (output / 'fixture.json').write_text(json.dumps(manifest, indent=2) + '\n') + info = subprocess.check_output(['pdfinfo', str(heavy)], text=True, timeout=15) + (output / 'pdfinfo.txt').write_text(info) + extracted = subprocess.check_output(['pdftotext', str(heavy), '-'], text=True, timeout=30) + (output / 'pdftotext.txt').write_text(extracted) + assert 'DOCVIEW RASTER 4960 x 7016 RGB' in extracted and 'DOCVIEW COMPLEX 60000 TRANSPARENT PATHS' in extracted + # Decode the actual image object with an independent parser. Store only + # its digest/size; the 100 MiB temporary decoded file is reclaimed. + with tempfile.TemporaryFile() as decoded: + subprocess.run(['qpdf', '--show-object=4', '--filtered-stream-data', str(heavy)], + stdout=decoded, stderr=subprocess.PIPE, check=True, timeout=30) + decoded.seek(0, 2); decoded_size = decoded.tell(); decoded.seek(0) + decoded_hash = hashlib.file_digest(decoded, 'sha256').hexdigest() + assert decoded_size == manifest['decodedRasterBytes'] and decoded_hash == manifest['rasterSha256'] + report['independentDecodedRaster'] = {'bytes': decoded_size, 'sha256': decoded_hash, + 'method': 'qpdf --show-object=4 --filtered-stream-data'} + env = os.environ.copy() + light = ROOT / 'tests/fixtures/pdf/navigation.pdf' + env.update(DOCVIEW_HEAVY_PDF=str(heavy), DOCVIEW_LIGHT_PDF=str(light), DOCVIEW_HEAVY_OUTPUT=str(output)) + report['process'] = run_observed([str(binary)], env, output, 'test', timeout=180) + measurement = json.loads((output / 'measurement.json').read_text()) if (output / 'measurement.json').exists() else {} + report['success'] = report['process']['exitCode'] == 0 and measurement.get('success') is True + report['independentFixtureMarkersVerified'] = True + report['fixtureSha256'] = sha(heavy) + report['measurementSha256'] = sha(output / 'measurement.json') if measurement else None + except Exception as error: + report['error'] = str(error) + finally: + report['binaries'] = before + report['sourceSha256'] = sources + report['binariesUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items()) + report['sourcesUnchanged'] = all(sha(ROOT / name) == value for name, value in sources.items()) + report['success'] = report['success'] and report['binariesUnchanged'] and report['sourcesUnchanged'] + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'success': report['success'], 'output': str(output), 'error': report.get('error')})) + return 0 if report['success'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/heavy_pdf/test_heavy_pdf.cpp b/tests/heavy_pdf/test_heavy_pdf.cpp new file mode 100644 index 0000000..903a603 --- /dev/null +++ b/tests/heavy_pdf/test_heavy_pdf.cpp @@ -0,0 +1,199 @@ +#include "app/controller.h" +#include "app/pdf_canvas.h" +#include "web/web_profile.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace docview; + +static QByteArray digest(const QString &path) { + QFile file(path); if (!file.open(QIODevice::ReadOnly)) return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + return hash.addData(&file) ? hash.result() : QByteArray{}; +} + +class HeavyPdfTest : public QObject { + Q_OBJECT +private slots: + void realHeavyPagesRemainResponsiveAndCancel() { + const auto heavy = qEnvironmentVariable("DOCVIEW_HEAVY_PDF"); + const auto light = qEnvironmentVariable("DOCVIEW_LIGHT_PDF"); + const auto output = qEnvironmentVariable("DOCVIEW_HEAVY_OUTPUT"); + QVERIFY(!heavy.isEmpty() && !light.isEmpty() && !output.isEmpty()); + const auto beforeHeavy = digest(heavy), beforeLight = digest(light); + QVERIFY(!beforeHeavy.isEmpty() && !beforeLight.isEmpty()); + QTemporaryDir isolated; QVERIFY(isolated.isValid()); + const auto config = isolated.filePath("config.toml"); + QFile configuration(config); QVERIFY(configuration.open(QIODevice::WriteOnly)); + configuration.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n[performance]\nprefetch_pages=0\n"); + configuration.close(); + Controller reader; + reader.initialize(config, false, {config, isolated.filePath("state.json"), + isolated.filePath("cache"), isolated.filePath("logs")}); + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("reader", &reader); + engine.load(QUrl("qrc:/qml/Main.qml")); + QVERIFY(!engine.rootObjects().isEmpty()); + auto *window = qobject_cast(engine.rootObjects().first()); + QVERIFY(window); reader.attachWindow(window); + window->resize(qMin(1100, window->screen()->availableGeometry().width()), + qMin(760, window->screen()->availableGeometry().height())); + auto *canvas = window->findChild(); QVERIFY(canvas); + QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); window->requestActivate(); + QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000); + QElapsedTimer clock; clock.start(); qint64 previous = 0, longestGap = 0; int ticks = 0, peakPressure = 0; + QTimer heartbeat; + connect(&heartbeat, &QTimer::timeout, this, [&] { + const auto now = clock.elapsed(); longestGap = qMax(longestGap, now - previous); + peakPressure = qMax(peakPressure, canvas->memoryPressureLevel()); + previous = now; ++ticks; + }); + heartbeat.start(10); + QJsonArray phases; bool success = false; + const auto save = qScopeGuard([&] { + heartbeat.stop(); + QJsonObject result{{"success", success}, {"phases", phases}, + {"elapsedMs", clock.elapsed()}, {"heartbeatTicks", ticks}, {"longestHeartbeatGapMs", longestGap}, + {"maximumMemoryPressureLevel", peakPressure}, + {"qtPlatform", QGuiApplication::platformName()}, {"qtVersion", qVersion()}, + {"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, + {"windowWidth", window->width()}, {"windowHeight", window->height()}, + {"devicePixelRatio", window->devicePixelRatio()}, + {"heavySha256", QString::fromLatin1(beforeHeavy.toHex())}, + {"lightSha256", QString::fromLatin1(beforeLight.toHex())}, + {"heavyUnchanged", digest(heavy) == beforeHeavy}, {"lightUnchanged", digest(light) == beforeLight}, + {"scope", "Production Controller/QML/Canvas with canonical sandboxed workers; GUI timer gaps and dispatched RPCs, not physical scanout or direct instrumentation inside PDFium"}}; + QSaveFile file(output + "/measurement.json"); + if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(result).toJson()); file.commit(); } + }); + const auto ready = [&] { return reader.state() == "Ready" && canvas->viewportReady(); }; + const auto sessionFor = [&](const QString &path) -> Session * { + for (auto *child : reader.children()) { + auto *session = dynamic_cast(child); + if (session && session->source == path) return session; + } + return nullptr; + }; + const auto sampleMarker = [&](int page) { + const QImage capture = window->grabWindow(); + const QPointF logical = canvas->mapToScene(canvas->pageToCanvas(page, {46, 796})); + const QPoint point(qRound(logical.x() * window->devicePixelRatio()), + qRound(logical.y() * window->devicePixelRatio())); + return capture.rect().contains(point) ? capture.pixelColor(point) : QColor(); + }; + const auto centralPixels = [&](int page) { + const QImage capture = window->grabWindow(); + const auto point = [&](QPointF pdf) { + return canvas->mapToScene(canvas->pageToCanvas(page, pdf)) * window->devicePixelRatio(); + }; + const QRect area = QRectF(point({80, 220}), point({480, 620})).normalized() + .toAlignedRect().intersected(capture.rect()); + int dark = 0, paper = 0; + for (int y = area.top(); y <= area.bottom(); y += 2) + for (int x = area.left(); x <= area.right(); x += 2) { + const auto color = capture.pixelColor(x, y); + dark += color.lightness() < 80; + paper += color.red() > 220 && color.green() > 220 && color.blue() > 220; + } + return QPair{dark, paper}; + }; + + // Complete both expensive pages before testing interruption: a rejected + // document or empty canvas cannot count as a successful heavy workload. + auto started = clock.elapsed(); reader.openPath(heavy); + QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000); + QCOMPARE(reader.pages().size(), 2); QCOMPARE(canvas->currentPage(), 0); + canvas->setPrefetchPages(0); canvas->fitPage(); + QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 30000); + const auto blue = sampleMarker(0); + QVERIFY2(blue.isValid() && blue.red() < 50 && blue.green() > 50 && blue.green() < 100 && blue.blue() > 130, + "High-resolution page did not show its original blue marker"); + const auto rasterPixels = centralPixels(0); + QVERIFY2(rasterPixels.first > 1000 && rasterPixels.second > 1000, + "Decoded scan pixels and paper were not both visible in the central image area"); + QVERIFY(window->grabWindow().save(output + "/raster.png")); + phases.append(QJsonObject{{"name", "complete-high-resolution-raster"}, {"elapsedMs", clock.elapsed() - started}, + {"pageIndex", 0}, {"originalMarkerVisible", true}, + {"darkRasterSamples", rasterPixels.first}, {"paperSamples", rasterPixels.second}}); + started = clock.elapsed(); reader.execute("nav.page", {{"page", 2}}); + QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000); + const auto magenta = sampleMarker(1); + QVERIFY2(magenta.isValid() && magenta.red() > 180 && magenta.green() < 50 && magenta.blue() > 120, + "Transparent-vector page did not show its original magenta marker"); + const auto vectorPixels = centralPixels(1); + QVERIFY2(vectorPixels.first > 1000 && vectorPixels.second == 0, + "Dense multiplied vector paths were absent from the central page area"); + QVERIFY(window->grabWindow().save(output + "/vector.png")); + phases.append(QJsonObject{{"name", "complete-transparent-vectors"}, {"elapsedMs", clock.elapsed() - started}, + {"pageIndex", 1}, {"originalMarkerVisible", true}, + {"darkVectorSamples", vectorPixels.first}}); + + // Cancel after the real protected worker has accepted an open RPC. + reader.openPath(light); QTRY_VERIFY_WITH_TIMEOUT(ready(), 15000); + const auto retainedToken = reader.webToken(); + reader.openPath(heavy); QCOMPARE(reader.state(), "Opening"); + QTRY_VERIFY_WITH_TIMEOUT(sessionFor(heavy) && sessionFor(heavy)->worker + && sessionFor(heavy)->worker->activeRequestCount("open") == 1, 10000); + QPointer cancelled = sessionFor(heavy); + started = clock.elapsed(); QTest::keyClick(window, Qt::Key_Escape); + QCOMPARE(reader.webToken(), retainedToken); QCOMPARE(reader.state(), "Ready"); + QTRY_VERIFY_WITH_TIMEOUT(cancelled.isNull(), 5000); + QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 15000); + phases.append(QJsonObject{{"name", "escape-during-open-rpc"}, {"elapsedMs", clock.elapsed() - started}, + {"activeOpenBeforeCancel", true}, {"retainedOldDocument", true}, {"oldSessionDestroyed", true}}); + + // Zoom invalidates old vector tiles. Replace while at least one new + // render RPC is outstanding; no fake sleep worker is substituted. + reader.openPath(heavy); QTRY_VERIFY_WITH_TIMEOUT(ready(), 30000); + reader.execute("nav.page", {{"page", 2}}); + QTRY_VERIFY_WITH_TIMEOUT(canvas->currentPage() == 1 && ready(), 30000); + QPointer replaced = sessionFor(heavy); QVERIFY(replaced); + canvas->setZoom(350); + QTRY_VERIFY_WITH_TIMEOUT(reader.benchmarkSnapshot().value("activeRenders").toInt() == 1 + && !canvas->viewportReady(), 10000); + const auto outstanding = canvas->pendingRenderCount(); + const auto replacedToken = replaced->token; + started = clock.elapsed(); reader.openPath(light); + QTRY_VERIFY_WITH_TIMEOUT(reader.webToken() != replacedToken && ready(), 15000); + QTRY_VERIFY_WITH_TIMEOUT(replaced.isNull(), 5000); + const auto lightToken = reader.webToken(); + QTest::qWait(300); // Allow any already-delivered obsolete Qt callbacks to run. + QCOMPARE(reader.webToken(), lightToken); QCOMPARE(canvas->currentPage(), 0); + QCOMPARE(reader.title(), QFileInfo(light).fileName()); QVERIFY(ready()); + QVERIFY(window->grabWindow().save(output + "/replacement.png")); + phases.append(QJsonObject{{"name", "replace-during-vector-render"}, {"elapsedMs", clock.elapsed() - started}, + {"activeRenderBeforeReplace", true}, {"pendingRendersBeforeReplace", qint64(outstanding)}, + {"oldSessionDestroyed", true}, {"replacementStableAfter300ms", true}}); + reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); QCOMPARE(canvas->cacheCostBytes(), 0); + QVERIFY2(ticks > 0 && longestGap < 1500, qPrintable(QString("GUI heartbeat gap: %1 ms").arg(longestGap))); + QCOMPARE(peakPressure, 0); + QCOMPARE(digest(heavy), beforeHeavy); QCOMPARE(digest(light), beforeLight); + success = true; + } +}; + +int main(int argc, char **argv) { + QTemporaryDir isolated; if (!isolated.isValid()) return 2; + qputenv("XDG_CONFIG_HOME", isolated.filePath("config").toUtf8()); + qputenv("XDG_STATE_HOME", isolated.filePath("state").toUtf8()); + qputenv("XDG_CACHE_HOME", isolated.filePath("cache").toUtf8()); + registerDocumentScheme(); QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); + app.setApplicationName("DocView heavy PDF validation"); app.setOrganizationName("DocView"); + qmlRegisterType("DocView", 1, 0, "PdfCanvas"); + HeavyPdfTest test; return QTest::qExec(&test, argc, argv); +} + +#include "test_heavy_pdf.moc" diff --git a/tests/ime/CMakeLists.txt b/tests/ime/CMakeLists.txt new file mode 100644 index 0000000..b75117f --- /dev/null +++ b/tests/ime/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.24) +project(DocViewImeValidation LANGUAGES CXX) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "This supplemental harness currently validates Linux only") +endif() +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +get_filename_component(DOCVIEW_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(DOCVIEW_CANONICAL_BUILD "${DOCVIEW_ROOT}/build" CACHE PATH "Existing tested Release build") +find_package(Qt6 6.11.2 EXACT REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2 WebEngineQuick Test) +find_package(PkgConfig REQUIRED) +pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig) +pkg_check_modules(SECCOMP REQUIRED IMPORTED_TARGET libseccomp) +pkg_check_modules(TOML REQUIRED IMPORTED_TARGET tomlplusplus) +foreach(name docview_core docview_common docview_font_broker) + if(NOT EXISTS "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a") + message(FATAL_ERROR "Build the canonical DocView targets first: ${name}") + endif() + add_library(${name} STATIC IMPORTED) + set_target_properties(${name} PROPERTIES IMPORTED_LOCATION "${DOCVIEW_CANONICAL_BUILD}/lib${name}.a") +endforeach() +qt_add_executable(test_ime test_ime.cpp + "${DOCVIEW_ROOT}/src/app/controller.cpp" + "${DOCVIEW_ROOT}/src/app/pdf_canvas.cpp" + "${DOCVIEW_ROOT}/src/web/web_profile.cpp" + "${DOCVIEW_ROOT}/resources.qrc") +target_include_directories(test_ime PRIVATE "${DOCVIEW_ROOT}/src") +target_link_libraries(test_ime PRIVATE docview_core docview_font_broker docview_common + PkgConfig::FONTCONFIG PkgConfig::SECCOMP PkgConfig::TOML Qt6::Core Qt6::Gui Qt6::Network Qt6::Quick + Qt6::Qml Qt6::QuickControls2 Qt6::WebEngineQuick Qt6::Test) +# Use exactly the recorded canonical workers; do not rebuild parser binaries. +foreach(worker docview-pdf-worker docview-archive-worker) + add_custom_command(TARGET test_ime POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E create_symlink + "${DOCVIEW_CANONICAL_BUILD}/${worker}" "$/${worker}") +endforeach() diff --git a/tests/ime/README.md b/tests/ime/README.md new file mode 100644 index 0000000..4eb7f20 --- /dev/null +++ b/tests/ime/README.md @@ -0,0 +1,32 @@ +# 実IME・XKBの補助GUI試験 + +Ubuntu 24.04のIBus/Mozcを通して、本番QML・Controllerの検索欄とコマンド欄を検査する。`QInputMethodEvent`を手作業で送らず、X11では専用XvfbへXTestのキー入力を送る。US pc105とJP jp106の配列を明示し、`:`・`/`・`+`・`-`はキー番号から入力する。WaylandではXWaylandを無効にした専用Swayへwtypeの仮想キーを送り、Qt Wayland+IBusのD-Bus接続で検査する。 + +必要なUbuntuパッケージは`ibus ibus-mozc xdotool x11-xkb-utils libxtst6 xvfb dbus-x11 sway wtype`。既存のDocView Releaseビルド、Qt 6.11.2 SDK、通常の開発依存を用意する。専用VMでは追加パッケージを導入したが、ホストのIME・ユーザー設定は変更していない。 + +```sh +export PATH=/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:$PATH +export LD_LIBRARY_PATH=/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib +export PKG_CONFIG_PATH=/opt/docview-deps/lib/pkgconfig +cmake -S docview-source/tests/ime -B docview-ime-build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DDOCVIEW_CANONICAL_BUILD=/home/docview/docview-build \ + '-DCMAKE_PREFIX_PATH=/opt/docview-qt/6.11.2/gcc_64;/opt/docview-deps' +cmake --build docview-ime-build --parallel 4 +python3 docview-source/tests/ime/run.py \ + --binary docview-ime-build/test_ime --canonical-build docview-build \ + --platform xcb --output validation/ime/new-x11-run +python3 docview-source/tests/ime/run.py \ + --binary docview-ime-build/test_ime --canonical-build docview-build \ + --platform wayland --output validation/ime/new-wayland-run +``` + +出力先は毎回新規にする。runnerはprivate D-Bus、選択したXvfb/Sway、IBusとMozc、専用のXDGディレクトリを作り、起動した子だけを識別して回収する。内部sandboxは有効。通常のCTest 22群には追加せず、既存core/common/font brokerと実ワーカーを使う別実行ファイルとして検証する。 + +「日本語」の変換確定Enterが検索を実行せず、次のEnterで検索すること、「確認」の変換確定がコマンドを実行しないことを確認する。`jikan123`の編集中・変換候補取消・未確定入力取消でも位置200を維持し、通常モードへ戻った後のj/kで200→248→200へ移動する。最初から上下移動できる表示寸法を要求する。 + +IBus 1.5.29のCLIは、エンジン切替後にlayoutが`default`だと終了コード1を返す。そのため`SetGlobalEngine`をD-Busで呼び、`ibus engine`で選択結果を照合する。[使用版の実装](https://github.com/ibus/ibus/blob/1.5.29/tools/main.vala#L222-L297)。IME開始はトグルではなく`Hiragana`を使用し、前ケースの入力モードに依存させない。 + +Waylandの仮想キーはwtypeが独自のキー配列を持つため、US/JP配列の試験として扱わない。Swayには試験中だけ仮想キーボードを常設し、短い入力ツールの終了で唯一のキーボードが抜けてQtの焦点を失うことを防ぐ。起動後のデバイス一覧を記録し、全子プロセスを回収する。 + +[実行結果](../results/ime/README.md)。物理キーボード、候補ウィンドウの外観、コンポジターのtext-inputプロトコル経由のIME連携、WindowsのIMEはこの試験の対象外。本番コンポーネントを使うが、製品のmain関数はこの補助試験から実行しない。 diff --git a/tests/ime/raw_key.py b/tests/ime/raw_key.py new file mode 100644 index 0000000..3a86a1c --- /dev/null +++ b/tests/ime/raw_key.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Send an XTest keycode to the runner's private X server, without remapping it.""" +import argparse +import ctypes as c +import os +from pathlib import Path + +p = argparse.ArgumentParser(description=__doc__) +p.add_argument('keycode', type=int) +p.add_argument('--shift', action='store_true') +a = p.parse_args() +runtime = Path(os.environ.get('XDG_RUNTIME_DIR', '/')) +if os.environ.get('DOCVIEW_PRIVATE_IME') != '1' or not runtime.name.startswith('docview-ime-'): + p.error('Only the dedicated private test session is permitted') +if not 8 <= a.keycode <= 255: + p.error('X11 keycode is out of range') +x = c.CDLL('libX11.so.6'); t = c.CDLL('libXtst.so.6') +x.XOpenDisplay.argtypes = [c.c_char_p]; x.XOpenDisplay.restype = c.c_void_p +x.XSync.argtypes = [c.c_void_p, c.c_int]; x.XCloseDisplay.argtypes = [c.c_void_p] +t.XTestFakeKeyEvent.argtypes = [c.c_void_p, c.c_uint, c.c_int, c.c_ulong] +d = x.XOpenDisplay(None) +if not d: + raise SystemExit('Private display is unavailable') +try: + if a.shift: + assert t.XTestFakeKeyEvent(d, 50, 1, 0) + assert t.XTestFakeKeyEvent(d, a.keycode, 1, 0) + assert t.XTestFakeKeyEvent(d, a.keycode, 0, 0) + if a.shift: + assert t.XTestFakeKeyEvent(d, 50, 0, 0) + x.XSync(d, 0) +finally: + x.XCloseDisplay(d) diff --git a/tests/ime/run.py b/tests/ime/run.py new file mode 100644 index 0000000..4ddd449 --- /dev/null +++ b/tests/ime/run.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Exercise production Qt input controls through IBus/Mozc on private X11 or Wayland.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with Path(path).open('rb') as f: + return hashlib.file_digest(f, 'sha256').hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--binary', type=Path, required=True) + p.add_argument('--canonical-build', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + p.add_argument('--platform', choices=['xcb', 'wayland'], default='xcb') + p.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) + a = p.parse_args() + if a.output.exists(): + p.error('Choose a new output directory') + if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or 'sandbox' in os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', ''): + p.error('Sandbox overrides are not permitted') + if not a.private_session: + with tempfile.TemporaryDirectory(prefix='docview-ime-') as private: + env = os.environ.copy() + for key in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'SWAYSOCK', 'I3SOCK', 'DBUS_SESSION_BUS_ADDRESS', 'IBUS_ADDRESS', + 'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', 'QT_QPA_PLATFORMTHEME']: + env.pop(key, None) + env.update(XDG_RUNTIME_DIR=private, XDG_CONFIG_HOME=private + '/config', XDG_STATE_HOME=private + '/state', + XDG_CACHE_HOME=private + '/cache', XDG_DATA_HOME=private + '/data', + QT_QPA_PLATFORM=a.platform, DOCVIEW_IME_PLATFORM=a.platform, + QT_QUICK_BACKEND='software', QT_SCALE_FACTOR='1', QT_FONT_DPI='96', + QT_IM_MODULE='ibus', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', DOCVIEW_PRIVATE_IME='1', + DOCVIEW_IME_RUN_ID=str(uuid.uuid4())) + bus = Path(private) / 'bus.conf' + bus.write_text('sessionEXTERNAL' + f'unix:tmpdir={private}' + '') + display = ['xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp'] if a.platform == 'xcb' else [] + return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', *display, sys.executable, str(Path(__file__).resolve()), + *sys.argv[1:], '--private-session'], env=env).returncode + out = a.output.resolve(); out.mkdir(parents=True, exist_ok=False) + binary, build = a.binary.resolve(), a.canonical_build.resolve() + paths = [binary] + [build / n for n in ['docview', 'docview-pdf-worker', 'docview-archive-worker', + 'libdocview_core.a', 'libdocview_common.a', 'libdocview_font_broker.a']] + paths += [ROOT / 'tests/fixtures/pdf/navigation.pdf', ROOT / 'resources.qrc'] + paths += [p for folder in ['src', 'qml', 'resources', 'tests/ime'] for p in (ROOT / folder).rglob('*') + if p.is_file() and '__pycache__' not in p.parts] + before = {os.path.relpath(path, ROOT): sha(path) for path in sorted(set(paths))} + report = {'success': False, 'os': platform.freedesktop_os_release(), 'kernel': platform.release(), + 'inputs': before, 'requestedPlatform': a.platform, + 'scope': ('Actual IBus/Mozc through virtual XTest keys and US/JP XKB on private X11' if a.platform == 'xcb' + else 'Actual Qt Wayland and IBus/Mozc over D-Bus; private Sway and wtype virtual-keyboard keysyms, not physical US/JP mappings or compositor text-input protocol') + + '; no physical keyboard, candidate-panel appearance or Windows acceptance'} + address = 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/ibus.sock' + env = os.environ.copy(); env.update(DOCVIEW_IME_CHILD_ID=str(uuid.uuid4()), IBUS_ADDRESS=address, DOCVIEW_IME_OUTPUT=str(out), + DOCVIEW_IME_PDF=str(ROOT / 'tests/fixtures/pdf/navigation.pdf'), DOCVIEW_IME_RAW_KEY=str(ROOT / 'tests/ime/raw_key.py')) + daemon = compositor = keyboard_keeper = None + tag = ('DOCVIEW_IME_CHILD_ID=' + env['DOCVIEW_IME_CHILD_ID']).encode() + + def owned(): + found = [] + for directory in Path('/proc').glob('[0-9]*'): + try: + pid = int(directory.name) + if pid != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'): + found.append(pid) + except (OSError, ValueError): + pass + return found + + try: + for name in ['docview-pdf-worker', 'docview-archive-worker']: + assert sha(binary.parent / name) == sha(build / name) + report['packages'] = subprocess.check_output(['dpkg-query', '-W', 'ibus', 'ibus-mozc', 'mozc-server', 'xdotool', 'libxtst6', 'xkb-data', 'sway', 'wtype'], text=True) + if a.platform == 'wayland': + runtime = Path(env['XDG_RUNTIME_DIR']) + config = runtime / 'sway.conf' + config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\n' + 'output HEADLESS-1 mode 1920x1080\nfor_window [app_id=".*"] floating enable\n' + 'default_border none\ndefault_floating_border none\nfocus_follows_mouse no\nmouse_warping none\n') + (out / 'sway.conf').write_bytes(config.read_bytes()) + env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1', + XDG_CURRENT_DESKTOP='sway', XDG_SESSION_TYPE='wayland', QT_USE_PORTAL='0') + with (out / 'compositor.log').open('w') as log: + compositor = subprocess.Popen(['sway', '-c', str(config)], env=env, stdout=log, stderr=subprocess.STDOUT) + for _ in range(100): + sockets = [p for p in runtime.glob('wayland-*') if p.is_socket()] + ipc = list(runtime.glob('sway-ipc.*.sock')) + if sockets and ipc or compositor.poll() is not None: + break + time.sleep(.1) + if not sockets or not ipc or compositor.poll() is not None: + raise RuntimeError('Private Sway did not become ready') + env.update(WAYLAND_DISPLAY=sockets[0].name, SWAYSOCK=str(ipc[0])) + report['waylandOutputs'] = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_outputs'], env=env)) + # Keep a virtual keyboard attached throughout the test. Otherwise + # each short wtype process disconnects the seat's only keyboard, + # clears Qt's active focus and cancels an in-progress composition. + # The deferred key is never reached: test timeout is 180 seconds, + # and cleanup terminates this uniquely tagged child first. + with (out / 'keyboard-keeper.log').open('w') as log: + keyboard_keeper = subprocess.Popen(['wtype', '-s', '300000', '-k', 'Shift_L'], env=env, + stdout=log, stderr=subprocess.STDOUT) + for _ in range(100): + inputs = json.loads(subprocess.check_output(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_inputs'], env=env)) + if any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None: + break + time.sleep(.05) + if not any(row.get('type') == 'keyboard' for row in inputs) or keyboard_keeper.poll() is not None: + raise RuntimeError('Persistent virtual keyboard did not attach') + report['waylandInputsBeforeApplication'] = inputs + with (out / 'ibus.log').open('w') as log: + daemon = subprocess.Popen(['ibus-daemon', '--address', address, '--panel', 'disable', '--config', 'disable', + '--emoji-extension', 'disable', '--cache', 'none'], env=env, stdout=log, stderr=subprocess.STDOUT) + ready = False + for _ in range(50): + query = subprocess.run(['ibus', 'list-engine'], env=env, capture_output=True, text=True, timeout=5) + if query.returncode == 0 and 'mozc-jp' in query.stdout: + (out / 'engines.txt').write_text(query.stdout); ready = True; break + if daemon.poll() is not None: + break + time.sleep(.1) + if not ready: + raise RuntimeError('IBus/Mozc did not become ready') + # Launch the installed engine explicitly: this session deliberately + # has no ambient D-Bus service activation. + with (out / 'mozc.log').open('w') as mozc_log: + mozc = subprocess.Popen(['/usr/lib/ibus-mozc/ibus-engine-mozc', '--ibus'], env=env, stdout=mozc_log, stderr=subprocess.STDOUT) + time.sleep(.5) + if mozc.poll() is not None: + raise RuntimeError(f'Mozc engine exited: {mozc.returncode}') + probe = subprocess.run(['gdbus', 'call', '--address', address, '--dest', 'org.freedesktop.IBus', '--object-path', '/org/freedesktop/IBus', '--method', 'org.freedesktop.IBus.SetGlobalEngine', 'mozc-jp'], env=env, capture_output=True, text=True, timeout=20) + (out / 'engine-probe.txt').write_text(probe.stdout + probe.stderr) + with (out / 'test.log').open('w') as testlog: + test = subprocess.run([str(binary)], env=env, stdout=testlog, stderr=subprocess.STDOUT, timeout=180) + report['testExitCode'] = test.returncode + layouts = ['us', 'jp'] if a.platform == 'xcb' else ['virtual-keysyms'] + report['cases'] = {layout: json.loads((out / f'{layout}.json').read_text()) for layout in layouts} + report['success'] = test.returncode == 0 and all(case['success'] and case['qtPlatform'] == a.platform for case in report['cases'].values()) + except Exception as error: + report['error'] = str(error) + finally: + if daemon: + try: + subprocess.run(['ibus', 'exit'], env=env, capture_output=True, timeout=5) + except subprocess.TimeoutExpired: + pass + for sig in [signal.SIGTERM, signal.SIGKILL]: + for pid in owned(): + try: + os.kill(pid, sig) + except ProcessLookupError: + pass + time.sleep(.2) + if daemon: + daemon.wait(timeout=5) + if compositor: + compositor.wait(timeout=5) + if keyboard_keeper: + keyboard_keeper.wait(timeout=5) + report['remainingOwnedProcesses'] = owned() + report['inputsUnchanged'] = all(sha(ROOT / name) == value for name, value in before.items()) + report['success'] &= report['inputsUnchanged'] and not report['remainingOwnedProcesses'] + (out / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({k: report.get(k) for k in ['success', 'testExitCode', 'error', 'inputsUnchanged', 'remainingOwnedProcesses']})) + return 0 if report['success'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/ime/test_ime.cpp b/tests/ime/test_ime.cpp new file mode 100644 index 0000000..b71dae3 --- /dev/null +++ b/tests/ime/test_ime.cpp @@ -0,0 +1,217 @@ +#include "app/controller.h" +#include "app/pdf_canvas.h" +#include "web/web_profile.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace docview; + +class ImeTest : public QObject { + Q_OBJECT + QJsonArray events_, commands_, phases_; + QString output_ = qEnvironmentVariable("DOCVIEW_IME_OUTPUT"); + bool wayland() const { return qEnvironmentVariable("DOCVIEW_IME_PLATFORM") == "wayland"; } + bool eventFilter(QObject *object, QEvent *event) override { + if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { + auto *key = static_cast(event); + events_.append(QJsonObject{{"object", object->metaObject()->className()}, + {"type", event->type() == QEvent::KeyPress ? "press" : "release"}, + {"key", key->key()}, {"text", key->text()}, {"scanCode", qint64(key->nativeScanCode())}, + {"virtualKey", qint64(key->nativeVirtualKey())}, {"modifiers", int(key->modifiers())}}); + } + if (event->type() == QEvent::InputMethod) { + auto *input = static_cast(event); + events_.append(QJsonObject{{"object", object->metaObject()->className()}, + {"preedit", input->preeditString()}, {"commit", input->commitString()}}); + } + return false; + } + bool run(const QString &program, const QStringList &arguments) { + QProcess process; process.start(program, arguments); + if (!process.waitForStarted(3000)) return false; + QElapsedTimer deadline; deadline.start(); + while (process.state() != QProcess::NotRunning && deadline.elapsed() < 10000) QTest::qWait(5); + if (process.state() != QProcess::NotRunning) { process.kill(); process.waitForFinished(1000); } + const auto out = QString::fromUtf8(process.readAllStandardOutput()); + const auto err = QString::fromUtf8(process.readAllStandardError()); + commands_.append(QJsonObject{{"program", program}, {"arguments", QJsonArray::fromStringList(arguments)}, + {"exitCode", process.exitCode()}, {"stdout", out}, {"stderr", err}}); + if (process.exitCode()) qWarning() << program << arguments << out << err; + QTest::qWait(60); + return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; + } + bool setEngine(const QString &name) { + if (!run("gdbus", {"call", "--address", qEnvironmentVariable("IBUS_ADDRESS"), + "--dest", "org.freedesktop.IBus", "--object-path", "/org/freedesktop/IBus", + "--method", "org.freedesktop.IBus.SetGlobalEngine", name})) return false; + return run("ibus", {"engine"}) && commands_.last().toObject().value("stdout").toString().trimmed() == name; + } + bool keys(const QStringList &keys) { + if (!wayland()) return run("xdotool", QStringList{"key", "--clearmodifiers", "--delay", "65"} + keys); + QStringList arguments; + for (const auto &key : keys) arguments << "-k" << key << "-s" << "65"; + return run("wtype", arguments); + } + bool text(const QString &value) { + return wayland() ? run("wtype", {"-d", "100", value}) + : run("xdotool", {"type", "--clearmodifiers", "--delay", "100", value}); + } + bool raw(int code, bool shift = false) { + // wtype supplies its own keysym map. Wayland checks IME transport and + // logical shortcuts; only the X11 cases test physical US/JP keycodes. + if (wayland()) { + if (code == 47 && shift) return keys({"colon"}); + if (code == 61 && !shift) return keys({"slash"}); + if (code == 21 && shift) return keys({"plus"}); + if (code == 20 && !shift) return keys({"minus"}); + return false; + } + QStringList args{qEnvironmentVariable("DOCVIEW_IME_RAW_KEY"), QString::number(code)}; + if (shift) args << "--shift"; + return run("python3", args); + } +private slots: + void initTestCase() { + QCOMPARE(QGuiApplication::platformName(), qEnvironmentVariable("DOCVIEW_IME_PLATFORM")); + QCOMPARE(qEnvironmentVariable("QT_IM_MODULE"), "ibus"); + QCOMPARE(qEnvironmentVariable("DOCVIEW_PRIVATE_IME"), "1"); + QVERIFY(!output_.isEmpty()); + qApp->installEventFilter(this); + } + void realImeAndLogicalKeys_data() { + QTest::addColumn("layout"); + if (wayland()) QTest::newRow("virtual-keysyms") << QString("virtual-keysyms"); + else { + QTest::newRow("us") << QString("us"); + QTest::newRow("jp") << QString("jp"); + } + } + void realImeAndLogicalKeys() { + QFETCH(QString, layout); + events_ = {}; commands_ = {}; phases_ = {}; bool success = false; + const auto save = qScopeGuard([&] { + QFile file(output_ + "/" + layout + ".json"); + if (file.open(QIODevice::WriteOnly)) file.write(QJsonDocument(QJsonObject{ + {"success", success}, {"layout", layout}, {"qtVersion", qVersion()}, + {"qtPlatform", QGuiApplication::platformName()}, + {"events", events_}, {"commands", commands_}, {"phases", phases_}}).toJson()); + }); + QTemporaryDir isolated; QVERIFY(isolated.isValid()); + const auto config = isolated.filePath("config.toml"); + QFile f(config); QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n"); f.close(); + Controller reader; + reader.initialize(config, false, {config, isolated.filePath("state.json"), isolated.filePath("cache"), isolated.filePath("logs")}); + QQmlApplicationEngine engine; engine.rootContext()->setContextProperty("reader", &reader); + engine.load(QUrl("qrc:/qml/Main.qml")); QVERIFY(!engine.rootObjects().isEmpty()); + auto *window = qobject_cast(engine.rootObjects().first()); QVERIFY(window); + reader.attachWindow(window); window->resize(1100, 760); + qApp->removeEventFilter(this); qApp->installEventFilter(this); + auto *canvas = window->findChild(); QVERIFY(canvas); + QVERIFY(QTest::qWaitForWindowExposed(window, 10000)); + if (wayland()) { + QVERIFY(run("swaymsg", {"-s", qEnvironmentVariable("SWAYSOCK"), + QString("[pid=%1] focus").arg(QCoreApplication::applicationPid())})); + } else QVERIFY(run("xdotool", {"windowfocus", "--sync", QString::number(window->winId())})); + QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window, 5000); + reader.openPath(qEnvironmentVariable("DOCVIEW_IME_PDF")); + QTRY_VERIFY_WITH_TIMEOUT(reader.state() == "Ready" && canvas->viewportReady(), 15000); + canvas->setZoom(300); + QVERIFY(canvas->totalHeight() > canvas->height() + 300); + canvas->scroll(0, 200); + QCOMPARE(canvas->offset(), 200.0); + QTRY_VERIFY_WITH_TIMEOUT(canvas->viewportReady(), 10000); + QVERIFY(setEngine("xkb:us::eng")); + if (!wayland()) { + QVERIFY(run("setxkbmap", {"-model", layout == "jp" ? "jp106" : "pc105", "-layout", layout, "-option", ""})); + QVERIFY(run("setxkbmap", {"-query"})); + QVERIFY(run("xkbcomp", {qEnvironmentVariable("DISPLAY"), output_ + "/" + layout + ".xkb"})); + } + const auto offset = canvas->offset(); + // Use physical X keycodes for symbols: ':' differs between US and JP. + QVERIFY(raw(layout == "jp" ? 48 : 47, layout != "jp")); + QTRY_COMPARE(reader.mode(), "command"); + QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); + QVERIFY(raw(61)); QTRY_COMPARE(reader.mode(), "search"); + auto *field = window->activeFocusItem(); QVERIFY(field); + QVERIFY(field->metaObject()->indexOfProperty("inputMethodComposing") >= 0); + QVERIFY(setEngine("mozc-jp")); + QVERIFY(run("ibus", {"engine"})); + QVERIFY(keys({"Hiragana"})); + QVERIFY(text("nihongo")); + QTRY_VERIFY_WITH_TIMEOUT(field->property("inputMethodComposing").toBool(), 5000); + QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); + QVERIFY(keys({"space"})); + QTRY_VERIFY(field->property("inputMethodComposing").toBool()); + QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-conversion.png")); + QVERIFY(keys({"Return"})); + QTRY_VERIFY(!field->property("inputMethodComposing").toBool()); + QTRY_COMPARE(field->property("text").toString(), QString::fromUtf8("日本語")); + QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); + phases_.append(QJsonObject{{"name", "conversion-enter-keeps-search"}, {"committed", field->property("text").toString()}}); + // A second Enter performs the application's search; conversion Enter did not. + QVERIFY(keys({"Return"})); QTRY_COMPARE(reader.mode(), "normal"); + QVERIFY(raw(61)); QTRY_COMPARE(reader.mode(), "search"); field = window->activeFocusItem(); + QVERIFY(text("jikan123")); + QTRY_VERIFY_WITH_TIMEOUT(field->property("inputMethodComposing").toBool(), 5000); + QCOMPARE(canvas->offset(), offset); QCOMPARE(reader.mode(), "search"); QVERIFY(reader.pending().isEmpty()); + QVERIFY(keys({"space"})); QTRY_VERIFY(field->property("inputMethodComposing").toBool()); + QVERIFY(keys({"Escape"})); + QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); + QTRY_VERIFY(field->property("inputMethodComposing").toBool()); + phases_.append(QJsonObject{{"name", "conversion-escape-keeps-reading"}, {"offset", offset}}); + QVERIFY(keys({"Escape"})); + QTRY_VERIFY(!field->property("inputMethodComposing").toBool()); + QCOMPARE(reader.mode(), "search"); QCOMPARE(canvas->offset(), offset); + QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); + phases_.append(QJsonObject{{"name", "letters-digits-escape-protected"}, {"offsetUnchanged", true}, {"offset", offset}}); + // The command field uses a different accepted action from search. + QVERIFY(raw(layout == "jp" ? 48 : 47, layout != "jp")); + QTRY_COMPARE(reader.mode(), "command"); field = window->activeFocusItem(); + QVERIFY(text("kakunin")); + QTRY_VERIFY(field->property("inputMethodComposing").toBool()); + QVERIFY(keys({"space"})); QVERIFY(keys({"Return"})); + QTRY_COMPARE(field->property("text").toString(), QString::fromUtf8("確認")); + QVERIFY(!field->property("inputMethodComposing").toBool()); + QCOMPARE(reader.mode(), "command"); QCOMPARE(canvas->offset(), offset); + QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-command.png")); + QVERIFY(keys({"Escape"})); QTRY_COMPARE(reader.mode(), "normal"); + phases_.append(QJsonObject{{"name", "conversion-enter-keeps-command"}, {"committed", QString::fromUtf8("確認")}}); + // Restore the engine and the actual map before checking logical zoom keys. + QVERIFY(setEngine("xkb:us::eng")); + if (!wayland()) QVERIFY(run("setxkbmap", {"-model", layout == "jp" ? "jp106" : "pc105", "-layout", layout, "-option", ""})); + const auto zoom = canvas->zoom(); + QVERIFY(raw(layout == "jp" ? 47 : 21, true)); QTRY_VERIFY(canvas->zoom() > zoom); + const auto enlarged = canvas->zoom(); QVERIFY(raw(20)); QTRY_VERIFY(canvas->zoom() < enlarged); + const auto beforeScroll = canvas->offset(); + QVERIFY(keys({"j"})); QTRY_VERIFY(canvas->offset() > beforeScroll); + const auto afterDown = canvas->offset(); + QVERIFY(keys({"k"})); QTRY_VERIFY(canvas->offset() < afterDown); + phases_.append(QJsonObject{{"name", wayland() ? "logical-symbols-and-navigation" : "physical-symbols-and-navigation"}, {"colon", true}, {"slash", true}, {"plus", true}, {"minus", true}, {"beforeScroll", beforeScroll}, {"afterDown", afterDown}, {"afterUp", canvas->offset()}}); + bool japaneseCommit = false, preedit = false; + for (const auto &value : events_) { + const auto event = value.toObject(); preedit |= !event.value("preedit").toString().isEmpty(); + japaneseCommit |= event.value("commit").toString() == QString::fromUtf8("日本語"); + } + QVERIFY(preedit); QVERIFY(japaneseCommit); + QVERIFY(window->grabWindow().save(output_ + "/" + layout + "-final.png")); + reader.closeDocument(); QCOMPARE(reader.state(), "Empty"); success = true; + } +}; + +int main(int argc, char **argv) { + registerDocumentScheme(); QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(false); + app.setApplicationName("DocView IME validation"); app.setOrganizationName("DocView"); + qmlRegisterType("DocView", 1, 0, "PdfCanvas"); + ImeTest test; return QTest::qExec(&test, argc, argv); +} +#include "test_ime.moc" diff --git a/tests/interaction_measurement.h b/tests/interaction_measurement.h new file mode 100644 index 0000000..ae43c6d --- /dev/null +++ b/tests/interaction_measurement.h @@ -0,0 +1,146 @@ +#pragma once + +#include "app/controller.h" +#include "app/pdf_canvas.h" +#include +#include +#include +#include +#include +#include +#include + +// Test-only instrumentation. It observes the production event loop, presentation +// notifications and worker counters without changing scheduling or deadlines. +class InteractionMeasurement : public QObject { +public: + static qint64 nowNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + } + InteractionMeasurement(docview::Controller *reader, docview::PdfCanvas *canvas, QQuickWindow *window) + : reader_(reader), canvas_(canvas), start_(nowNs()) { + connect(window, &QQuickWindow::frameSwapped, this, [this] { + const auto presented = nowNs(); + QMetaObject::invokeMethod(this, [this, presented] { + ++frameSerial; + lastFrameNs = presented; + if (phase_.isEmpty() || presented < phaseStart_) return; + frames.append(QJsonObject{{"phase", phase_}, {"atMs", ms(presented)}, + {"intervalMs", previousFrame_ ? (presented - previousFrame_) / 1e6 : 0.}}); + previousFrame_ = presented; + sample(); + }, Qt::QueuedConnection); + }, Qt::DirectConnection); + connect(reader, &docview::Controller::disposeWeb, this, [this] { sample(); }); + connect(canvas, &docview::PdfCanvas::tileRequested, this, [this] { sample(); }); + timer_.setInterval(10); + timer_.setTimerType(Qt::PreciseTimer); + connect(&timer_, &QTimer::timeout, this, [this] { + const auto now = nowNs(); + if (!phase_.isEmpty()) { + if (previousTick_) heartbeats.append(QJsonObject{{"phase", phase_}, {"atMs", ms(now)}, + {"gapMs", (now - previousTick_) / 1e6}}); + previousTick_ = now; + sample(); + } + }); + timer_.start(); + } + double ms(qint64 time) const { return (time - start_) / 1e6; } + void begin(const QString &phase) { + phase_ = phase; + previousFrame_ = previousTick_ = 0; + phaseStart_ = nowNs(); + phaseInput_ = phaseStart_; + sample(); + } + void input(QJsonObject event) { + phaseInput_ = nowNs(); + event["atMs"] = ms(phaseInput_); + event["phase"] = phase_; + inputs.append(event); + sample(); + } + void inputOutcome(const QJsonObject &outcome) { + auto row = inputs.last().toObject(); + for (auto it = outcome.begin(); it != outcome.end(); ++it) row[it.key()] = it.value(); + inputs[inputs.size() - 1] = row; + } + void finish(const QJsonObject &verified) { + QJsonObject row = verified; + row["phase"] = phase_; + row["startMs"] = ms(phaseStart_); + row["lastInputMs"] = ms(phaseInput_); + row["finalPresentedMs"] = ms(lastFrameNs); + row["lastInputToFinalFrameMs"] = (lastFrameNs - phaseInput_) / 1e6; + row["phaseDurationMs"] = (lastFrameNs - phaseStart_) / 1e6; + phases.append(row); + sample(); + phase_.clear(); + } + void sample() { + qint64 queued = 0, active = 0, queuedRenders = 0, activeRenders = 0; + for (auto *object : reader_->children()) { + auto *session = dynamic_cast(object); + if (!session || !session->worker) continue; + auto *worker = session->worker; + const auto discarded = qint64(worker->discardedQueuedRequestCount()); + discarded_ += qMax(0, discarded - observedDiscards_.value(session->token)); + observedDiscards_[session->token] = discarded; + queued += worker->queuedRequestCount(); active += worker->activeRequestCount(); + queuedRenders += worker->queuedRequestCount("render"); activeRenders += worker->activeRequestCount("render"); + } + const auto pending = qint64(canvas_->pendingRenderCount()); + pendingPeak_ = qMax(pendingPeak_, pending); + queuePeak_ = qMax(queuePeak_, queued); + queuedRenderPeak_ = qMax(queuedRenderPeak_, queuedRenders); + activeRenderPeak_ = qMax(activeRenderPeak_, activeRenders); + cachePeak_ = qMax(cachePeak_, canvas_->cacheCostBytes()); + if (!phase_.isEmpty() && samples.size() < 100000) + samples.append(QJsonObject{{"phase", phase_}, {"atMs", ms(nowNs())}, + {"queuedRequests", queued}, {"activeRequests", active}, {"queuedRenders", queuedRenders}, + {"activeRenders", activeRenders}, {"pendingCanvasRenders", pending}, + {"discardedQueuedRequestsTotal", discarded_}, {"staleRenderResponses", qint64(canvas_->staleRenderResponseCount())}, + {"discardedPrefetchResponses", qint64(canvas_->discardedPrefetchResponseCount())}, + {"cacheBytes", canvas_->cacheCostBytes()}}); + } + static QJsonObject statistics(QList values) { + if (values.isEmpty()) return {{"count", 0}}; + std::sort(values.begin(), values.end()); + auto percentile = [&](double p) { return values[qBound(0, qsizetype(std::ceil(p * values.size())) - 1, values.size() - 1)]; }; + return {{"count", values.size()}, {"minMs", values.first()}, {"p50Ms", percentile(.5)}, + {"p95Ms", percentile(.95)}, {"maxMs", values.last()}}; + } + QJsonObject result(bool success) { + sample(); + QList intervals, gaps; + for (const auto &row : frames) if (row.toObject().value("intervalMs").toDouble() > 0) intervals.append(row.toObject().value("intervalMs").toDouble()); + for (const auto &row : heartbeats) gaps.append(row.toObject().value("gapMs").toDouble()); + const auto frameStats = statistics(intervals), heartbeatStats = statistics(gaps); + return {{"schemaVersion", 1}, {"success", success}, {"designRequirement", "08-performance-tests §3.3(5), T20"}, + {"scope", "Synthetic bounded interaction workload; development-host reference, not reference-machine acceptance"}, + {"presentationBoundary", "second frameSwapped after target and viewport readiness; final PDF also pixel-checked. frameSwapped is presentation-queue notification, not physical scanout; Web frames are not correlated with Chromium frame IDs"}, + {"uiGapDefinition", "actual intervals of a 10 ms precise GUI timer; a proxy for event-loop starvation, not OS scheduling attribution"}, + {"discardDefinition", "actual WorkerProcess lifetime unsent-queue discards, including stop sampled before Session deletion; excludes active cancellation"}, + {"inputs", inputs}, {"phases", phases}, {"frames", frames}, {"uiHeartbeats", heartbeats}, {"workSamples", samples}, + {"frameIntervals", frameStats}, {"uiHeartbeatGaps", heartbeatStats}, + {"maxUiDelayBeyondNominalMs", qMax(0., heartbeatStats.value("maxMs").toDouble() - 10.)}, + {"discardedQueuedRequests", discarded_}, {"pendingRenderPeak", pendingPeak_}, {"queuedRequestPeak", queuePeak_}, + {"queuedRenderPeak", queuedRenderPeak_}, {"activeRenderPeak", activeRenderPeak_}, {"cachePeakBytes", cachePeak_}, + {"provisionalBudgetsUnchanged", QJsonObject{{"visualResponseP95Ms", 50}, {"cachedPdfP95Ms", 100}, + {"uncachedPdfP95Ms", 500}, {"continuousFrameP95MsAt60Hz", 33}}}, + {"budgetAssessment", "not evaluated as a product guarantee: operation burst/final-frame latencies differ from per-input visual-response metric"}}; + } + quint64 frameSerial = 0; + qint64 lastFrameNs = 0; + QJsonArray inputs, phases, frames, heartbeats, samples; +private: + docview::Controller *reader_; + docview::PdfCanvas *canvas_; + QTimer timer_; + QString phase_; + QHash observedDiscards_; + qint64 start_, phaseStart_ = 0, phaseInput_ = 0, previousFrame_ = 0, previousTick_ = 0; + qint64 discarded_ = 0, pendingPeak_ = 0, queuePeak_ = 0, queuedRenderPeak_ = 0, activeRenderPeak_ = 0, cachePeak_ = 0; +}; diff --git a/tests/measure_interaction.py b/tests/measure_interaction.py new file mode 100644 index 0000000..93dfebb --- /dev/null +++ b/tests/measure_interaction.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Run the opt-in production GUI T20 interaction workload and pin its evidence.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import time + + +def hashed(path, label): + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return {"path": label, "size": path.stat().st_size, "sha256": digest} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + source = Path(__file__).resolve().parents[1] + binary = args.binary.resolve() + output = args.output.resolve() + if not binary.is_file(): + parser.error("--binary must be an existing test_app executable") + if output.exists(): + parser.error("--output must be a new directory to preserve earlier evidence") + if os.environ.get("QTWEBENGINE_DISABLE_SANDBOX") or any(x in os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "") + for x in ("--no-sandbox", "--disable-setuid-sandbox")): + parser.error("Sandbox disabling is not allowed for this measurement") + output.mkdir(parents=True) + suffix = ".exe" if platform.system() == "Windows" else "" + binaries = [binary, binary.parent / ("docview-pdf-worker" + suffix), binary.parent / ("docview-archive-worker" + suffix)] + app = binary.parent / ("docview" + suffix) + if app.exists(): + binaries.append(app) # Recorded for comparison; the tested shell is test_app. + pdfium = source / ".deps/pdfium/lib/libpdfium.so" + if pdfium.exists(): + binaries.append(pdfium) + source_files = [source / "CMakeLists.txt", source / "resources.qrc", source / "tests/test_app.cpp", + source / "tests/interaction_measurement.h", Path(__file__).resolve()] + for directory in ("src", "qml", "resources"): + source_files.extend(p for p in (source / directory).rglob("*") if p.is_file() and p.suffix in (".cpp", ".h", ".qml", ".js", ".toml")) + source_files = sorted(set(source_files)) + before_binaries = [hashed(p, p.name) for p in binaries] + before_sources = [hashed(p, str(p.relative_to(source))) for p in source_files] + environment = os.environ.copy() + environment["DOCVIEW_INTERACTION_OUTPUT"] = str(output) + command = [str(binary), "measureInteractionWorkload"] + start = time.monotonic() + try: + run = subprocess.run(command, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=180, check=False) + log, code = run.stdout, run.returncode + except subprocess.TimeoutExpired as error: + log, code = error.stdout or b"", 124 + (output / "test.log").write_bytes(log) + after_binaries = [hashed(p, p.name) for p in binaries] + after_sources = [hashed(p, str(p.relative_to(source))) for p in source_files] + measurement_path = output / "measurement.json" + measurement = json.loads(measurement_path.read_text()) if measurement_path.exists() else {} + cpu = "" + if Path("/proc/cpuinfo").exists(): + cpu = next((line.partition(":")[2].strip() for line in Path("/proc/cpuinfo").read_text().splitlines() + if line.startswith("model name")), "") + mem = "" + if Path("/proc/meminfo").exists(): + mem = next((line.partition(":")[2].strip() for line in Path("/proc/meminfo").read_text().splitlines() + if line.startswith("MemTotal:")), "") + stable = before_binaries == after_binaries and before_sources == after_sources + record = {"schemaVersion": 1, "command": [binary.name, "measureInteractionWorkload"], + "testedExecutable": binary.name, "buildDirectory": binary.parent.name, + "exitCode": code, "elapsedSeconds": time.monotonic() - start, + "success": code == 0 and measurement.get("success") is True and stable, + "binaryAndSourceHashesStableDuringRun": stable, "binaries": before_binaries, + "sources": before_sources, "host": {"platform": platform.platform(), "cpu": cpu, + "logicalCpuCount": os.cpu_count(), "memTotal": mem}, + "measurement": hashed(measurement_path, "measurement.json") if measurement_path.exists() else None, + "limitations": ["Development-host synthetic workload, not the provisional reference machine.", + "Xvfb/software rendering when selected by the caller; no native GPU or physical scanout acceptance.", + "frameSwapped is a presentation-queue notification; Web is not correlated to Chromium frame IDs.", + "GUI timer gaps include host scheduling and instrumentation; they are not a direct measurement of exclusively application-caused stalls.", + "No OS cache flush. No Windows/Ubuntu acceptance inferred.", + "Normal app CTest deliberately skips this opt-in slot; this dedicated run executes it."]} + (output / "record.json").write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n") + print(json.dumps({"success": record["success"], "output": str(output), "exitCode": code}, ensure_ascii=False)) + return 0 if record["success"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/portal/record_build.py b/tests/portal/record_build.py new file mode 100644 index 0000000..a518075 --- /dev/null +++ b/tests/portal/record_build.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Freeze a complete passing build; keep earlier version evidence separate.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import platform +import re +import subprocess +import xml.etree.ElementTree as ET + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument('--build', type=Path, required=True) + parser.add_argument('--ctest', type=Path, required=True) + parser.add_argument('--installed', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + cases = ET.parse(args.ctest / 'tests.xml').findall('.//testcase') + assert len(cases) == 22 and all(c.find('failure') is None and c.find('skipped') is None for c in cases) + files = [args.source / name for name in ('CMakeLists.txt', 'resources.qrc')] + for folder in ('src', 'qml', 'cmake', 'resources', 'tools', 'tests'): + files += [path for path in (args.source / folder).rglob('*') + if path.is_file() and not path.is_symlink() and '__pycache__' not in path.parts + and 'results' not in path.parts and 'fixtures' not in path.parts] + names = ['docview', 'docview-pdf-worker', 'docview-archive-worker', 'test_app', + 'test_core', 'test_pdf', 'test_pdf_canvas', 'test_web_qml', 'test_translations'] + value = { + 'recordedAtUtc': datetime.now(timezone.utc).isoformat(), + 'scope': 'Full CTest after URL representation fix; Xvfb/xcb, software rendering, internal sandboxes enabled', + 'os': platform.freedesktop_os_release(), 'kernel': platform.release(), + 'compiler': subprocess.check_output(['c++', '--version'], text=True).splitlines()[0], + 'binaries': {name: sha(args.build / name) for name in names}, + 'sourceSha256': {str(path.relative_to(args.source)): sha(path) for path in sorted(set(files))}, + 'ctest': {'groups': len(cases), 'failures': 0, 'junitSha256': sha(args.ctest / 'tests.xml'), + 'logSha256': sha(args.ctest / 'LastTest.log'), + 'cases': [case.attrib for case in cases]}, + 'qtTestTotalsIncludingSetupAndCleanup': re.findall(r'Totals: .*', (args.ctest / 'LastTest.log').read_text()), + 'runnerSha256': sha(Path(__file__)), + } + if args.installed: + value['installedBinaries'] = {name: sha(args.installed / 'bin' / name) for name in names[:3]} + with args.output.open('x') as stream: + stream.write(json.dumps(value, ensure_ascii=False, indent=2) + '\n') + print(json.dumps({'ctestGroups': len(cases), 'docviewSha256': value['binaries']['docview']})) + + +if __name__ == '__main__': + main() diff --git a/tests/portal/record_validation.py b/tests/portal/record_validation.py new file mode 100644 index 0000000..9eefec5 --- /dev/null +++ b/tests/portal/record_validation.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Verify the URL-fix evidence and publish a current index with explicit history.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = ROOT / 'tests/results/portal' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def read(name): + return json.loads((RESULTS / name).read_text()) + + +def main(): + builds = {name: read(name + '-build-record.json') for name in ('arch', 'ubuntu')} + current_sources = {str(path.relative_to(ROOT)): sha(path) for directory in ('src', 'qml', 'cmake', 'resources') + for path in (ROOT / directory).rglob('*') if path.is_file() and '__pycache__' not in path.parts} + current_sources.update({name: sha(ROOT / name) for name in ('CMakeLists.txt', 'resources.qrc')}) + for name, value in builds.items(): + assert all(value['sourceSha256'].get(path) == digest for path, digest in current_sources.items()) + for path in ('tests/test_web_qml.cpp', 'tests/webqml/tst_reader.qml'): + assert value['sourceSha256'][path] == sha(ROOT / path) + ctest = RESULTS / (name + '-ctest') + assert sha(ctest / 'tests.xml') == value['ctest']['junitSha256'] + assert sha(ctest / 'LastTest.log') == value['ctest']['logSha256'] + cases = ET.parse(ctest / 'tests.xml').findall('.//testcase') + assert len(cases) == 22 and all(row.find('failure') is None and row.find('skipped') is None for row in cases) + assert all(sha(ROOT / 'build' / name) == digest for name, digest in builds['arch']['binaries'].items()) + package = read('ubuntu-package/report.json') + repeated = read('ubuntu-package/repeat-report.json') + verified = read('ubuntu-package/verification.json') + archive = ROOT / 'build-ubuntu-vm/packages' / package['archive'] + assert package['success'] and repeated['success'] and verified['success'] + assert package['archiveSha256'] == repeated['archiveSha256'] == sha(archive) + assert verified['archiveSha256'] == package['archiveSha256'] + assert verified['allSizesModesAndHashesMatch'] and verified['rootOwnership'] + assert verified['filesVerified'] == package['payloadFiles'] + assert package['packagerSha256'] == sha(ROOT / 'tools/package_ubuntu.py') + assert package['executableSha256'] == builds['ubuntu']['installedBinaries'] + assert package['manifestSha256'] == sha(RESULTS / 'ubuntu-package/package-manifest.json') + portal = {} + for platform, directory in {'x11': 'portal-x11-final', 'wayland': 'portal-wayland-fixed-keymap'}.items(): + report = read(directory + '/report.json') + assert report['success'] and report['executablesUnchanged'] and not report['remainingTaggedChildren'] + assert report['executables'] == package['executableSha256'] + assert report['packageManifestSha256'] == package['manifestSha256'] + assert report['runnerSha256'] == sha(RESULTS / directory / 'runner-at-start.py') + assert report['portalResponses'] == ['2', '0', '0', '0', '0'] + assert [row['case'] for row in report['cases']] == ['cancel', 'pdf', 'html', 'htmlzip', 'epub'] + assert all(row['focusRestored'] for row in report['cases']) + portal[platform] = {key: report[key] for key in ('success', 'platform', 'executables', 'portalResponses', 'cases', 'scope')} + lifecycle = {phase: read('package-vm/' + phase + '/report.json') for phase in ('install', 'smoke', 'remove')} + assert all(row['success'] and row['archiveSha256'] == package['archiveSha256'] for row in lifecycle.values()) + assert lifecycle['install']['executables'] == package['executableSha256'] + assert lifecycle['install']['installationMode'] == 'upgrade' + assert lifecycle['install']['runnerSha256'] == sha(ROOT / 'tests/ubuntu_vm/clean_package.py') + assert lifecycle['smoke']['smokeConditions'] == 12 + assert lifecycle['remove']['payloadRemoved'] and lifecycle['remove']['kernelRestrictionUnchanged'] + performance = read('performance-suite/summary.json') + assert performance['success'] and len(performance['cases']) == 10 + assert all(builds['arch']['binaries'][name] == digest for name, digest in performance['buildSha256'].items()) + for row in performance['cases']: + assert sha(RESULTS / 'performance-suite' / row['record']) == row['recordSha256'] + assert performance['interactionRecordSha256'] == sha(RESULTS / 'performance-suite/interaction/record.json') + assert performance['stressRecordSha256'] == sha(RESULTS / 'performance-suite/stress/report.json') + prior = read('prior-validation-record.json') + record = { + 'schemaVersion': 2, 'recordedAtUtc': datetime.now(timezone.utc).isoformat(), + 'scope': 'Current URL-fix build: Arch/Ubuntu full CTest, Ubuntu package and XDG/GTK file chooser integration; Arch ten-condition performance, interaction and 1 GiB PDF stress', + 'ctestSkipScope': 'All 22 CTest groups pass. Each App group explicitly skips its opt-in performance case; Ubuntu Python 3.12 skips 5 zstd provenance cases. These are not counted as executed acceptance tests.', + 'goalComplete': False, 'sourceSha256': current_sources, + 'validationToolsSha256': {name: sha(ROOT / name) for name in ( + 'tests/portal/record_build.py', 'tests/portal/record_validation.py', 'tests/portal/run.py', + 'tests/portal/run_performance.py', 'tests/portal/summarize_performance.py', 'tests/ubuntu_vm/clean_package.py')}, + 'executables': {name: builds['arch']['binaries'][name] for name in package['executableSha256']}, + 'builds': builds, 'ubuntuPackage': package, 'portal': portal, + 'packageLifecycle': lifecycle, + 'performance': performance, + 'historicalEvidence': { + 'record': 'tests/results/portal/prior-validation-record.json', + 'sha256': sha(RESULTS / 'prior-validation-record.json'), + 'changedCompiledSources': [name for name, digest in current_sources.items() if prior['sourceSha256'].get(name) != digest], + 'unchangedArchWorkers': [name for name in ('docview-pdf-worker', 'docview-archive-worker') + if prior['executables'][name] == builds['arch']['binaries'][name]], + 'scope': 'Prior full-app performance, GPU, IME, high-density PDF and package measurements are versioned history. Current ten-condition performance and 1 GiB stress are recorded separately above.', + }, + 'remainingAcceptance': [ + 'Windows 11 native MSVC/Qt build, LPAC/pipe/PE execution and installer', + 'Target-OS physical GPU/display/input, reference hardware and long-duration acceptance', + 'Human G-RENDER reference-image approval', + 'Complete notices/corresponding sources, public license and release distribution decision', + ], + 'evidenceSha256': {str(path.relative_to(ROOT)): sha(path) for path in sorted(RESULTS.rglob('*')) + if path.is_file() and path.suffix != '.pyc' and path != RESULTS / 'record.json'}, + } + encoded = json.dumps(record, ensure_ascii=False, indent=2) + '\n' + (RESULTS / 'record.json').write_text(encoded) + (ROOT / 'docs/validation-record.json').write_text(encoded) + print(json.dumps({'archCtestGroups': 22, 'ubuntuCtestGroups': 22, 'portalCases': 10, + 'packageSmokeConditions': 12, 'performanceConditions': 10, 'goalComplete': False})) + + +if __name__ == '__main__': + main() diff --git a/tests/portal/run.py b/tests/portal/run.py new file mode 100644 index 0000000..0c8adfa --- /dev/null +++ b/tests/portal/run.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Exercise the packaged viewer through real XDG/GTK file chooser portals. + +Only private Xvfb/Openbox or headless Sway desktops and generated files are +used. No mocked portal replies, Qt test hooks, or user desktop are involved. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import uuid + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def wait_for(probe, description, seconds=15): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + value = probe() + if value: return value + time.sleep(.1) + raise RuntimeError('Timed out: ' + description) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--platform', choices=['xcb', 'wayland'], required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + out = args.output.resolve() + if not args.private_session: + if out.exists(): parser.error('Choose a new evidence directory') + with tempfile.TemporaryDirectory(prefix='docview-portal-') as temporary: + private = Path(temporary) + env = {key: value for key, value in os.environ.items() if key in ['HOME', 'PATH', 'USER', 'LOGNAME', 'LANG']} + env.update(XDG_RUNTIME_DIR=str(private), XDG_CONFIG_HOME=str(private / 'config'), + XDG_STATE_HOME=str(private / 'state'), XDG_CACHE_HOME=str(private / 'cache'), + XDG_DATA_HOME=str(private / 'data'), XDG_CURRENT_DESKTOP='DocViewTest', + QT_QPA_PLATFORM=args.platform, QT_QPA_PLATFORMTHEME='xdgdesktopportal', + QT_USE_PORTAL='1', QT_QUICK_BACKEND='software', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', + QT_SCALE_FACTOR='1', QT_FONT_DPI='96', LANG='C.UTF-8', NO_AT_BRIDGE='1') + config = private / 'config/xdg-desktop-portal'; config.mkdir(parents=True) + (config / 'portals.conf').write_text('[preferred]\ndefault=gtk;\norg.freedesktop.impl.portal.FileChooser=gtk;\n') + bus = private / 'bus.conf' + bus.write_text('sessionEXTERNAL' + f'unix:tmpdir={private}' + '' + '') + display = ['xvfb-run', '-a', '-s', '-screen 0 1280x960x24 -dpi 96 -nolisten tcp'] if args.platform == 'xcb' else [] + return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', *display, + sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode + out.mkdir(parents=True, exist_ok=False) + (out / 'runner-at-start.py').write_bytes(Path(__file__).read_bytes()) + env = dict(os.environ, DOCVIEW_PORTAL_CHILD_ID=str(uuid.uuid4())) + tag = ('DOCVIEW_PORTAL_CHILD_ID=' + env['DOCVIEW_PORTAL_CHILD_ID']).encode() + processes, streams, commands, cases = [], [], [], [] + identities = {name: sha(Path('/opt/docview/bin') / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']} + report = {'success': False, 'platform': args.platform, 'executables': identities, + 'launcherSha256': sha('/usr/bin/docview'), 'runnerSha256': sha(Path(__file__)), + 'packageManifestSha256': sha('/opt/docview/share/doc/docview/package-manifest.json'), + 'scope': 'Actual packaged app and Ubuntu XDG/GTK file chooser on a private virtual desktop; no physical input or full desktop-session certification', + 'osRelease': Path('/etc/os-release').read_text(), 'cases': cases} + + def run(command, checked=True, timeout=8): + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=timeout) + commands.append({'command': command, 'exitCode': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) + if checked and result.returncode: + raise RuntimeError(str(command) + ': ' + result.stderr) + return result + + def start(name, command): + stream = (out / (name + '.log')).open('w'); streams.append(stream) + process = subprocess.Popen(command, env=env, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) + processes.append(process) + return process + + def wayland_keyboard(): + # wtype constructs its map from all arguments before sending input. + # Keep the same keysym-to-keycode mapping across the keeper and every + # invocation, including GTK's startup map. Release-only entries declare + # keys without typing them; actual input follows this prefix. + command = ['wtype'] + for symbol in ['Escape', 'Return', 'Shift_L', 'Tab'] + [ + 'U%04X' % ord(char) for char in ''.join(chr(n) for n in range(32, 127)) + '日本語選択資料']: + command += ['-p', symbol] + return command + ['-s', '150'] + + def keys(*values): + if args.platform == 'xcb': + run(['xdotool', 'key', '--clearmodifiers', '--delay', '90', *values]) + else: + command = wayland_keyboard() + for value in values: + parts = value.split('+'); modifiers = {'ctrl':'ctrl', 'shift':'shift', 'alt':'alt'} + for part in parts[:-1]: command += ['-M', modifiers[part.lower()]] + command += ['-s', '40', '-P', parts[-1], '-s', '40', '-p', parts[-1]] + for part in reversed(parts[:-1]): command += ['-m', modifiers[part.lower()]] + command += ['-s', '90'] + run(command) + + def text(value): + if args.platform == 'xcb': run(['xdotool', 'type', '--clearmodifiers', '--delay', '12', value]) + else: run(wayland_keyboard() + ['-d', '12', value]) + time.sleep(.3) # Wait for the other process to consume the final key. + + def windows(): + if args.platform == 'xcb': + found = run(['xdotool', 'search', '--onlyvisible', '--name', '.'], checked=False) + result = [] + for identifier in found.stdout.split(): + owner = run(['xdotool', 'getwindowpid', identifier], checked=False) + if owner.returncode: continue + title = run(['xdotool', 'getwindowname', identifier]).stdout.strip() + result.append({'id': identifier, 'pid': int(owner.stdout), 'name': title}) + return result + tree = json.loads(run(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_tree']).stdout) + result = [] + def visit(node): + if node.get('pid'): result.append({key: node.get(key) for key in ['id', 'pid', 'name', 'app_id', 'focused']}) + for child in node.get('nodes', []) + node.get('floating_nodes', []): visit(child) + visit(tree) + return result + + def focused(pid): + if args.platform == 'xcb': + value = run(['xdotool', 'getwindowfocus', 'getwindowpid'], checked=False) + return value.returncode == 0 and value.stdout.strip() == str(pid) + return any(row['pid'] == pid and row.get('focused') for row in windows()) + + def capture(name): + command = ['scrot', str(out / (name + '.png'))] if args.platform == 'xcb' else ['grim', '-o', 'HEADLESS-1', str(out / (name + '.png'))] + run(command) + + state_file = Path(env['XDG_STATE_HOME']) / 'docview/state.json' + def state_entry(path): + try: + value = json.loads(state_file.read_text()) + return next((row for row in value['documents'] if row['identity']['canonicalPath'] == str(path) and row['location']), None) + except (OSError, ValueError): return None + + try: + report['packages'] = run(['dpkg-query', '-W', 'docview', 'xdg-desktop-portal', 'xdg-desktop-portal-gtk', + 'openbox', 'xdotool', 'sway', 'wtype', 'grim', 'scrot']).stdout + if args.platform == 'xcb': + env['GDK_BACKEND'] = 'x11' + start('openbox', ['openbox', '--sm-disable']) + wait_for(lambda: run(['xprop', '-root', '_NET_SUPPORTING_WM_CHECK'], checked=False).stdout.find('window id') >= 0, 'X11 window manager') + else: + runtime = Path(env['XDG_RUNTIME_DIR']); config = runtime / 'sway.conf' + config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\noutput HEADLESS-1 mode 1280x960\n' + 'for_window [app_id=".*"] floating enable\ndefault_border none\ndefault_floating_border none\n' + 'focus_follows_mouse no\nmouse_warping none\n') + env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1', GDK_BACKEND='wayland', XDG_SESSION_TYPE='wayland') + start('sway', ['sway', '-c', str(config)]) + wait_for(lambda: list(runtime.glob('sway-ipc.*.sock')) and any(p.is_socket() for p in runtime.glob('wayland-*')), 'Wayland compositor') + env['WAYLAND_DISPLAY'] = next(p.name for p in runtime.glob('wayland-*') if p.is_socket()) + env['SWAYSOCK'] = str(next(runtime.glob('sway-ipc.*.sock'))) + start('keyboard', wayland_keyboard() + ['-s', '300000', '-k', 'Shift_L']) + wait_for(lambda: any(row.get('type') == 'keyboard' for row in json.loads(run(['swaymsg', '-t', 'get_inputs']).stdout)), 'persistent virtual keyboard') + start('dconf', ['/usr/libexec/dconf-service']) + start('permission-store', ['/usr/libexec/xdg-permission-store']) + start('documents', ['/usr/libexec/xdg-document-portal']) + backend = start('gtk-portal', ['/usr/libexec/xdg-desktop-portal-gtk']) + start('desktop-portal', ['/usr/libexec/xdg-desktop-portal', '--verbose']) + query = ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', '--object-path', '/org/freedesktop/portal/desktop', + '--method', 'org.freedesktop.DBus.Properties.Get', 'org.freedesktop.portal.FileChooser', 'version'] + wait_for(lambda: run(query, checked=False, timeout=3).returncode == 0, 'real file chooser portal', 30) + report['portalVersion'] = run(query).stdout.strip() + monitor = start('portal-messages', ['stdbuf', '-oL', '-eL', 'dbus-monitor', '--session', "interface='org.freedesktop.portal.FileChooser'", + "interface='org.freedesktop.portal.Request'"]) + documents = out / '選択 資料'; documents.mkdir() + shutil.copytree(ROOT / 'tests/fixtures/web/html', documents / 'web') + selections = [] + for kind, source, destination in [ + ('pdf', ROOT / 'tests/fixtures/pdf/extended/japanese-layout.pdf', documents / '日本語 j k 123 #%.pdf'), + ('html', documents / 'web/index.html', documents / 'web/日本語 j k 123 #%.html'), + ('htmlzip', ROOT / 'tests/fixtures/web/html-book.zip', documents / '日本語 j k 123 #%.zip'), + ('epub', ROOT / 'tests/fixtures/web/reflow-rtl.epub', documents / '日本語 j k 123 #%.epub')]: + shutil.copyfile(source, destination) + selections.append((kind, destination, sha(destination))) + report['documents'] = [{'format': kind, 'path': str(path), 'sha256': digest} for kind, path, digest in selections] + config = Path(env['XDG_CONFIG_HOME']) / 'docview/config.toml'; config.parent.mkdir(parents=True) + config.write_text('schema_version=1\n[privacy]\nremember_position=true\nrecent_documents=10\n') + app = start('docview', ['/usr/bin/docview', '--config', str(config)]) + wait_for(lambda: any(row['pid'] == app.pid for row in windows()) and focused(app.pid), 'initial viewer focus') + time.sleep(.5) # Qt queries the portal version asynchronously at startup. + # Exercise cancellation through the real GTK window before any document opens. + keys('ctrl+o') + popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK cancel dialog') + wait_for(lambda: focused(backend.pid), 'GTK dialog focus') + keys('ctrl+l'); text('j k 123 ?'); capture('cancel-dialog') + escape_count = 0 + # GTK can consume the first Escape to dismiss its completion popup. + for _ in range(3): + keys('Escape'); escape_count += 1; time.sleep(.3) + if not any(row['pid'] == backend.pid for row in windows()): break + wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'focus restored after cancel') + assert not state_file.exists() or not json.loads(state_file.read_text())['documents'] + cases.append({'case': 'cancel', 'backendWindow': popup, 'focusRestored': True, + 'documentOpened': False, 'escapeKeyCount': escape_count}) + for kind, path, digest in selections: + keys('ctrl+o') + popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK ' + kind + ' dialog') + wait_for(lambda: focused(backend.pid), 'GTK file entry focus') + keys('ctrl+l'); text(str(path)); capture(kind + '-dialog') + keys('Return') + wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'viewer focus after ' + kind) + entry = wait_for(lambda: state_entry(path), 'presented and persisted ' + kind, 20) + title = wait_for(lambda: next((row['name'] for row in windows() if row['pid'] == app.pid and path.name in row['name']), None), kind + ' window title') + if kind == 'pdf': + assert entry['location']['kind'] == 'pdf' + text('G') + entry = wait_for(lambda: (value if (value := state_entry(path)) and value['location'].get('pageIndex') == 1 else None), 'PDF keyboard navigation after dialog', 15) + else: + assert entry['location'].get('resourcePath') + if kind == 'epub': assert entry['location'].get('spineIdref') + capture(kind + '-opened') + assert sha(path) == digest + cases.append({'case': kind, 'backendWindow': popup, 'windowTitle': title, 'savedPresentedLocation': entry['location'], + 'focusRestored': True, 'originalUnchanged': True}) + text(':q'); keys('Return'); app.wait(timeout=10) + assert app.returncode == 0 + time.sleep(.2) + messages = (out / 'portal-messages.log').read_text() + responses = re.findall(r'interface=org.freedesktop.portal.Request; member=Response\n\s+uint32 (\d+)', messages) + assert len(re.findall(r'interface=org.freedesktop.portal.FileChooser; member=OpenFile', messages)) == 5 + # GTK 1.15.1 maps Escape's GTK_RESPONSE_DELETE_EVENT to portal code 2; + # code 1 is reserved for its explicit Cancel button. Both reject in Qt. + # See upstream src/filechooser.c:file_chooser_response. + assert responses == ['2', '0', '0', '0', '0'], responses + for _, path, _ in selections: assert path.as_uri() in messages, str(path) + report['portalResponses'] = responses + report['actualGtkBackendPid'] = backend.pid + report['applicationExitCode'] = app.returncode + report['success'] = True + except Exception as error: + report['error'] = str(error) + if 'app' in locals() and app.poll() is None: + try: capture('failure') + except Exception as capture_error: report['failureCaptureError'] = str(capture_error) + finally: + report['commands'] = commands + for process in reversed(processes): + if process.poll() is None: + try: os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: pass + for process in reversed(processes): + try: process.wait(timeout=3) + except subprocess.TimeoutExpired: + try: os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: pass + process.wait(timeout=3) + for stream in streams: stream.close() + remaining = [] + for directory in Path('/proc').glob('[0-9]*'): + try: + if int(directory.name) != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'): + remaining.append(int(directory.name)) + except (OSError, ValueError): pass + report['remainingTaggedChildren'] = remaining + for pid in remaining: + try: os.kill(pid, signal.SIGTERM) + except ProcessLookupError: pass + report['executablesUnchanged'] = all(sha(Path('/opt/docview/bin') / name) == digest for name, digest in identities.items()) + report['success'] = report['success'] and report['executablesUnchanged'] and not remaining + if state_file.exists(): shutil.copyfile(state_file, out / 'generated-document-state.json') + (out / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + print(json.dumps({key: report.get(key) for key in ['success', 'platform', 'error', 'portalResponses']})) + return 0 if report['success'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/portal/run_performance.py b/tests/portal/run_performance.py new file mode 100644 index 0000000..7c9f730 --- /dev/null +++ b/tests/portal/run_performance.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Repeat the existing ten-condition workload for the current production build.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + out = args.output.resolve(); out.mkdir(parents=True, exist_ok=False) + (out / 'runner-at-start.py').write_bytes(Path(__file__).read_bytes()) + env = dict(os.environ) + for name in ('DISPLAY', 'WAYLAND_DISPLAY', 'QT_SCREEN_SCALE_FACTORS', 'QT_STYLE_OVERRIDE', 'QT_SCALE_FACTOR_ROUNDING_POLICY'): + env.pop(name, None) + env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', + QT_SCALE_FACTOR='1', QT_FONT_DPI='96', QT_AUTO_SCREEN_SCALE_FACTOR='0') + record = {'success': False, 'scope': 'Arch Xvfb 1100x760 DPR1/software; OS file cache retained; development measurements, not reference hardware acceptance', + 'binaries': {name: sha(ROOT / 'build' / name) for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker', 'test_app')}, + 'runnerSha256': sha(Path(__file__)), 'commands': [], + 'displayEnvironment': {name: env[name] for name in ('QT_QPA_PLATFORM', 'QT_QUICK_BACKEND', 'QTWEBENGINE_CHROMIUM_FLAGS', 'QT_SCALE_FACTOR', 'QT_FONT_DPI', 'QT_AUTO_SCREEN_SCALE_FACTOR')}} + jobs = [(corpus, ['python3', 'tests/benchmark.py', '--binary', 'build/docview', '--corpus', corpus, + '--operations', '125', '--cold-process-runs', '30', '--output', str(out / corpus)]) + for corpus in ('smoke', 'standard', 'fonts')] + jobs += [('interaction', ['python3', 'tests/measure_interaction.py', '--binary', 'build/test_app', '--output', str(out / 'interaction')]), + ('stress', ['python3', 'tests/run_stress_pdf.py', '--binary', 'build/docview', '--output', str(out / 'stress')])] + try: + for name, command in jobs: + wrapper = ['dbus-run-session', '--', 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24 -dpi 96 -nolisten tcp'] + started = time.monotonic() + with (out / (name + '.log')).open('w') as log: + result = subprocess.run(wrapper + command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1500) + row = {'name': name, 'command': wrapper + command, 'exitCode': result.returncode, 'seconds': time.monotonic() - started, + 'measurementRunnerSha256': sha(ROOT / command[1])} + record['commands'].append(row) + (out / 'progress.json').write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps(row), flush=True) + if result.returncode: raise RuntimeError('Failed workload: ' + name) + assert all(sha(ROOT / 'build' / name) == digest for name, digest in record['binaries'].items()) + record['success'] = True + finally: + (out / 'report.json').write_text(json.dumps(record, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/tests/portal/summarize_performance.py b/tests/portal/summarize_performance.py new file mode 100644 index 0000000..4f8a074 --- /dev/null +++ b/tests/portal/summarize_performance.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Check and summarize all ten completed URL-fix measurements.""" +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / 'tests/results/portal/performance-suite' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + run = json.loads((OUT / 'report.json').read_text()) + assert run['success'] and len(run['commands']) == 5 and all(row['exitCode'] == 0 for row in run['commands']) + assert run['runnerSha256'] == sha(OUT / 'runner-at-start.py') + binaries = {name: sha(ROOT / 'build' / name) for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker')} + assert all(run['binaries'][name] == value for name, value in binaries.items()) + rows, operation_count = [], 0 + for corpus, formats in [('smoke', ['pdf', 'html', 'zip', 'epub']), + ('standard', ['pdf', 'pdf-headings', 'html', 'zip', 'epub']), ('fonts', ['pdf'])]: + for kind in formats: + relative = corpus + '/' + kind + '.json' + value = json.loads((OUT / relative).read_text()) + assert value['success'] and value['originalUnchanged'] and all(value['measurementChecks'].values()) + assert value['executableSha256'] == binaries and value['runnerSha256'] == sha(ROOT / 'tests/benchmark.py') + assert len(value['openFrameMs']) == 30 and value['coldProcessOpen']['requested'] == 30 and value['coldProcessOpen']['failed'] == 0 + assert value['summary']['uniform_open_close_cycles'] == 29 and value['summary']['all_closes_empty'] + display = value['display'] + assert display['screenLogicalWidth'] == 1100 and display['screenLogicalHeight'] == 760 and display['windowDevicePixelRatio'] == 1 + cold = list((OUT / corpus / 'cold' / kind).glob('[0-9][0-9][0-9].json')) + assert len(cold) == 30 + for path in cold: + sample = json.loads(path.read_text()) + assert sample['success'] and sample['documentSha256'] == value['documentSha256'] + assert sample['display'] == display and sample['maximumMemoryPressureLevel'] == 0 + operation_count += len(value['operations']) + summary = value['summary'] + exceeded = [name for name, observation in value['proposedBudgetObservations'].items() + if observation is False or isinstance(observation, dict) and observation.get('observed_met') is False] + rows.append({'corpus': corpus, 'format': kind, 'record': relative, 'recordSha256': sha(OUT / relative), + 'coldOpenP95Ms': summary['fresh_process_fresh_app_cache_open']['p95_ms'], + 'responseP95Ms': summary['response']['p95_ms'], 'finalQualityP95Ms': summary['final_quality']['p95_ms'], + 'scrollP95Ms': summary['continuous_scroll_frames']['p95_ms'], + 'processGroupPeakMiB': value['processGroupRssPeakBytes'] / 1024**2, + 'budgetObservationsExceeded': exceeded, 'navigationCoverage': value['navigationCoverage']}) + interaction = json.loads((OUT / 'interaction/record.json').read_text()) + stress = json.loads((OUT / 'stress/report.json').read_text()) + measured = {row['path']: row['sha256'] for row in interaction['binaries']} + assert interaction['success'] and measured['test_app'] == sha(ROOT / 'build/test_app') + assert all(measured[name] == digest for name, digest in binaries.items()) + assert stress['success'] and stress['originalUnchanged'] and stress['sourceRemoved'] + assert stress['executableSha256'] == binaries and stress['executablesUnchanged'] + result = {'success': True, 'buildSha256': binaries, 'cases': rows, 'coldOpens': 300, 'sameProcessOpens': 300, + 'summaryGeneratorSha256': sha(Path(__file__)), + 'navigationOperations': operation_count, 'scope': run['scope'], + 'interactionRecordSha256': sha(OUT / 'interaction/record.json'), 'stressRecordSha256': sha(OUT / 'stress/report.json'), + 'proposedBudgetExceedances': sum(len(row['budgetObservationsExceeded']) for row in rows)} + (OUT / 'summary.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n') + lines = ['# URL比較修正後の性能測定', '', + '10条件で新規プロセス・空のXDGアプリキャッシュ30回、同一プロセス30回、適用される移動系列125操作、連続scroll240入力を実施した。原本不変、全closeの状態解放、メモリ圧迫なしを確認した。', '', + 'ArchのXvfb 1100×760、DPR1、software描画。OSファイルキャッシュは保持。起動後のopenを起点とし、プロセス起動時間を含めない。Qt提示通知の測定で、Webの対象画素や物理scanoutの直接測定ではない。基準機・対象OSの性能受入とは区別する。', '', + '| 資料 | 形式 | 冷open p95 ms | 応答 p95 ms | 最終品質 p95 ms | scroll p95 ms | 群RSS最大 MiB |', + '|---|---|---:|---:|---:|---:|---:|'] + for row in rows: + lines.append(f"| {row['corpus']} | [{row['format']}]({row['record']}) | {row['coldOpenP95Ms']:.2f} | {row['responseP95Ms']:.2f} | {row['finalQualityP95Ms']:.2f} | {row['scrollP95Ms']:.2f} | {row['processGroupPeakMiB']:.2f} |") + lines += ['', f"新規open 300回・同一プロセスopen 300回・移動系列{operation_count}操作。暫定予算の参考超過は{result['proposedBudgetExceedances']}件。元の予算値は変更していない。未測定の系列は各JSONのnavigationCoverageで区別する。", '', + '[実行コマンド](report.json)、[機械集計](summary.json)、[操作負荷](interaction/record.json)、[約1GB・5,000ページPDF](stress/report.json)を保存した。重いPDFは3open・100操作の追加負荷であり、30回の性能受入標本ではない。', ''] + (OUT / 'README.md').write_text('\n'.join(lines)) + print(json.dumps({key: result[key] for key in ('success', 'coldOpens', 'sameProcessOpens', 'navigationOperations', 'proposedBudgetExceedances')})) + + +if __name__ == '__main__': + main() diff --git a/tests/record_installed_dependencies.py b/tests/record_installed_dependencies.py new file mode 100644 index 0000000..87e6b73 --- /dev/null +++ b/tests/record_installed_dependencies.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Verify and record dependencies of this project's installed Linux binaries.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--prefix', type=Path, default=ROOT / 'build/install') +parser.add_argument('--output', type=Path, default=ROOT / 'tests/results/installed-dependencies.json') +args = parser.parse_args() +if not sys.platform.startswith('linux'): + raise SystemExit('This records Linux development-host dependencies only.') +report = {'scope': 'Linux development host; installed binaries use system dependencies; not a clean-OS distribution test', + 'executables': {}} +for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']: + path = (args.prefix / 'bin' / name).resolve(strict=True) + dependencies = subprocess.check_output(['ldd', str(path)], text=True) + if 'not found' in dependencies: + raise SystemExit(f'{name}: unresolved runtime dependency') + dynamic = subprocess.check_output(['readelf', '-d', str(path)], text=True) + needed = sorted(re.findall(r'\(NEEDED\).*?\[(.*?)\]', dynamic)) + if name == 'docview' and any('pdfium' in value or 'qpdf' in value for value in needed): + raise SystemExit('Main must not link either independent PDF parser.') + if name == 'docview-pdf-worker' and not any('libqpdf' in value for value in needed): + raise SystemExit('PDFWorker libqpdf dependency missing.') + resolved = sorted({line.strip().split()[0] for line in dependencies.splitlines() + if line.strip() and not line.strip().startswith('linux-vdso')}) + report['executables'][name] = {'sha256': hashlib.file_digest(path.open('rb'), 'sha256').hexdigest(), + 'resolvedLibraries': resolved, 'directDependencies': needed, 'missing': []} +report['mainLinksPdfiumDirectly'] = False +report['mainLinksQpdfDirectly'] = False +report['qpdfVersion'] = subprocess.check_output(['pkg-config', '--modversion', 'libqpdf'], text=True).strip() +license_path = args.prefix / 'share/doc/docview/licenses/qpdf/LICENSE.txt' +report['installedQpdfLicenseSha256'] = hashlib.sha256(license_path.read_bytes()).hexdigest() +args.output.write_text(json.dumps(report, indent=2) + '\n') +print('Installed dependencies resolved; Main has no direct PDF parser linkage.') diff --git a/tests/record_linux_delivery.py b/tests/record_linux_delivery.py new file mode 100644 index 0000000..61b3c23 --- /dev/null +++ b/tests/record_linux_delivery.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Bind notice repair and fresh-OS delivery evidence to the unchanged v2 build.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PARENT = ROOT / 'tests/results/pdfium-intent-context/integration-record.json' +PARENT_SHA = 'd376de4f28b43c22b9073f07ca06c7e90edf2c9e271c12f6ebcdb364822d7b42' +OUTPUT = ROOT / 'tests/results/qpdf-notice-supplement/integration-record.json' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def ref(path): + return {'path': str(path.relative_to(ROOT)), 'sha256': sha(path)} + + +def main(): + current = ROOT / 'docs/validation-record.json' + assert not OUTPUT.exists(), 'The delivery record is immutable.' + assert sha(current) == sha(PARENT) == PARENT_SHA + parent = json.loads(PARENT.read_text()) + required = { + 'qpdfNoticeRepair': ROOT / 'tests/results/qpdf-notice-supplement/report.json', + 'ubuntuNoticePackage': ROOT / 'tests/results/qpdf-notice-supplement/ubuntu-package/report.json', + 'freshUbuntuInstall': ROOT / 'tests/results/pdfium-context-fresh/report.json', + 'currentInteraction': ROOT / 'tests/results/pdfium-context-interaction/report.json', + } + for key, path in required.items(): + assert json.loads(path.read_text())['success'] is True, key + source_join = ROOT / 'tests/results/qt-sdk-source-join/report.json' + assert json.loads(source_join.read_text())['analysisCompleted'] is True + for name, digest in parent['sourceSha256'].items(): + if name.startswith(('src/', 'qml/', 'resources/')) or name in ('CMakeLists.txt', 'resources.qrc'): + assert sha(ROOT / name) == digest, name + for name, digest in parent['executables'].items(): + assert sha(ROOT / 'build-context' / name) == digest, name + for key in ('selectedLibrary', 'buildInfo', 'reviewedLock'): + item = parent['candidateIntegration'][key] + assert sha(ROOT / item['path']) == item['sha256'], key + anchor = parent['candidateBuildAnchor'] + assert sha(ROOT / anchor['path']) == anchor['sha256'] + + sources = {name: sha(ROOT / name) for name in parent['sourceSha256']} + for folder in ('tools', 'tests', 'tests/ubuntu_vm'): + for path in sorted((ROOT / folder).glob('*.py')): + sources[str(path.relative_to(ROOT))] = sha(path) + sources[str(Path(__file__).resolve().relative_to(ROOT))] = sha(Path(__file__).resolve()) + evidence = {} + for folder in ('qpdf-notice-supplement', 'pdfium-context-fresh', + 'pdfium-context-interaction', 'qt-sdk-source-join'): + for path in sorted((ROOT / 'tests/results' / folder).rglob('*')): + if path.is_file() and path != OUTPUT and '__pycache__' not in path.parts: + evidence[str(path.relative_to(ROOT))] = sha(path) + documents = {name: sha(ROOT / name) for name in parent['documentationSha256']} + documents['docs/DEPENDENCY-PROVENANCE.md'] = sha(ROOT / 'docs/DEPENDENCY-PROVENANCE.md') + documents['docs/WINDOWS-BUILD.md'] = sha(ROOT / 'docs/WINDOWS-BUILD.md') + + record = dict(parent) + record.update( + schemaVersion=8, + recordedAtUtc=datetime.now(timezone.utc).isoformat(), + scope='Same v2 application and runtime bytes; repaired qpdf notice correspondence and validation5 packaging, first application install on a fresh Ubuntu cloud-image overlay, and the current T20 interaction workload. The full design acceptance remains incomplete.', + goalComplete=False, + previousValidation=ref(PARENT), + sourceSha256=sources, + documentationSha256=documents, + evidenceSha256=evidence, + changedSincePreviousRecord=[ + {'path': name, 'previousSha256': digest, 'currentSha256': sources[name]} + for name, digest in parent['sourceSha256'].items() if sources[name] != digest + ], + deliveryFollowup={key: ref(path) for key, path in required.items()}, + qtSdkSourceCorrespondenceInvestigation=ref(source_join), + designDocumentsSha256={str(path.relative_to(ROOT)): sha(path) + for path in sorted((ROOT / 'docs/design').glob('*.md'))}, + archApplicationAndCandidateBytesUnchanged=True, + ) + record['candidateIntegration'] = dict(parent['candidateIntegration']) + record['candidateIntegration']['ubuntuPackageRecord'] = ref(required['ubuntuNoticePackage']) + encoded = json.dumps(record, indent=2) + '\n' + OUTPUT.write_text(encoded) + current.write_text(encoded) + print(json.dumps({'schemaVersion': 8, 'sha256': sha(current), + 'sourceFiles': len(sources), 'evidenceFiles': len(evidence), + 'goalComplete': False})) + + +if __name__ == '__main__': + main() diff --git a/tests/record_validation.py b/tests/record_validation.py new file mode 100644 index 0000000..e64d49b --- /dev/null +++ b/tests/record_validation.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +"""Record the tested local build without collecting user documents or paths.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import xml.etree.ElementTree as ET + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser() +parser.add_argument('--build', type=Path, default=root / 'build') +parser.add_argument('--output', type=Path, default=root / 'docs/validation-record.json') +parser.add_argument('--runtime-results', type=Path, + help='Results from this build; older evidence remains hashed separately') +parser.add_argument('--ctest-results', type=Path, + help='Frozen full CTest directory containing tests.xml and LastTest.log') +args = parser.parse_args() +build = args.build.resolve() +results = root / 'tests/results' +runtime = args.runtime_results.resolve() if args.runtime_results else results +if not runtime.is_dir() or not runtime.is_relative_to(results): + raise SystemExit('--runtime-results must be an existing directory within tests/results') +ctest_directory = args.ctest_results.resolve() if args.ctest_results else None +if ctest_directory and (not ctest_directory.is_dir() or not ctest_directory.is_relative_to(results)): + raise SystemExit('--ctest-results must be an existing directory within tests/results') +junit_path = ctest_directory / 'tests.xml' if ctest_directory else build / 'tests.xml' +test_log_path = ctest_directory / 'LastTest.log' if ctest_directory else build / 'Testing/Temporary/LastTest.log' +tree = ET.parse(junit_path) +cases = tree.findall('.//testcase') +if not cases or any(case.find('failure') is not None or case.find('skipped') is not None for case in cases): + raise SystemExit('A complete passing CTest JUnit file is required.') + +def sha(path): + digest = hashlib.sha256() + with path.open('rb') as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + +def compiled_source(name): + # Debian desktop/profile/maintainer files are package inputs, separately + # validated against the deb manifest; they are not compiled into the GUI. + return (name.startswith(('src/', 'qml/', 'cmake/', 'resources/')) + and not name.startswith('resources/linux/')) or name in ('CMakeLists.txt', 'resources.qrc') + +def recorded_runner_matches(name, digest, frozen): + return (sha(root / name) == digest or + (name == 'tests/run_wayland_validation.py' and sha(frozen) == digest)) + +source_paths = sorted([p for folder in ['src', 'qml', 'resources', 'cmake', 'tools'] for p in (root / folder).rglob('*') + if p.is_file() and '__pycache__' not in p.parts] + [root / 'CMakeLists.txt', root / 'resources.qrc']) +sources = {str(p.relative_to(root)): sha(p) for p in source_paths} +hardware = {'logicalCpus': os.cpu_count(), 'architecture': platform.machine()} +if Path('/proc/cpuinfo').is_file(): + found = re.search(r'^model name\s*:\s*(.+)$', Path('/proc/cpuinfo').read_text(), re.M) + if found: + hardware['cpu'] = found.group(1) +if Path('/proc/meminfo').is_file(): + found = re.search(r'MemTotal:\s+(\d+)', Path('/proc/meminfo').read_text()) + if found: + hardware['physicalMemoryBytes'] = int(found.group(1)) * 1024 +record = {'recordedAtUtc': datetime.now(timezone.utc).isoformat(), 'os': platform.freedesktop_os_release(), + 'kernel': platform.release(), 'hardware': hardware, + 'compiler': subprocess.check_output(['c++', '--version'], text=True).splitlines()[0], + 'cmake': subprocess.check_output(['cmake', '--version'], text=True).splitlines()[0], + 'qt': subprocess.check_output(['pkg-config', '--modversion', 'Qt6Core'], text=True).strip(), + 'fontconfig': subprocess.check_output(['pkg-config', '--modversion', 'fontconfig'], text=True).strip(), + 'qpdf': subprocess.check_output(['pkg-config', '--modversion', 'libqpdf'], text=True).strip(), + 'pdfium': json.loads((root / 'cmake/pdfium.lock.json').read_text()), + 'display': 'Xvfb / xcb; Qt Quick software; WebEngine --disable-gpu; internal sandboxes enabled', + 'acceptance': 'Development evidence on Arch (including AMD OpenGL through private headless Wayland) and Ubuntu 24.04 KVM (X11 US/JP XKB and X11/Wayland IBus/Mozc over D-Bus); Windows native, target-OS GPU, physical input/display, reference hardware and release distribution gates incomplete', + 'windowsPort': {'status': 'Integrated source only; native MSVC/Qt build and execution unavailable', + 'unexecutedNativeSuites': ['windows_pipe', 'windows_resources', 'windows_worker'], + 'portableEvidence': ['runtime_manifest', 'runtime_staging'], + 'limits': 'Synthetic MZ and Wine-header syntax checks do not establish PE scanning, native ABI, LPAC protection or Windows rendering.'}, + 'executables': {name: sha(build / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']}, + 'runtimeResults': str(runtime.relative_to(root)), + 'sourceSha256': sources, + 'ctest': {'suites': len(cases), 'failures': 0, 'skipped': 0, + 'cases': [{key: case.attrib.get(key) for key in ['name', 'time', 'status']} for case in cases]}} +log = test_log_path.read_text() +record['ctest']['evidenceDirectory'] = str((ctest_directory or build).relative_to(root)) +record['qtTestTotalsIncludingSetupAndCleanup'] = re.findall(r'Totals: .*', log) +record['installedExecutableSha256'] = {name: sha(build / 'install/bin' / name) for name in + ['docview', 'docview-pdf-worker', 'docview-archive-worker'] + if (build / 'install/bin' / name).is_file()} +record['testSourceSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'tests').rglob('*')) + if p.is_file() and p.suffix in {'.cpp', '.h', '.py', '.qml', '.html'} + and 'results' not in p.parts and '__pycache__' not in p.parts} +record['fixtureSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'tests/fixtures').rglob('*')) + if p.is_file() and '__pycache__' not in p.parts} +record['designSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'docs/design').glob('*.md'))} +record['documentationSha256'] = {str(p.relative_to(root)): sha(p) for p in + sorted(list((root / 'docs').rglob('*.md')) + list((root / 'tests').glob('*VALIDATION.md')) + [root / 'README.md'])} +record['evidenceSha256'] = {str(p.relative_to(root)): sha(p) for p in + [junit_path, test_log_path]} +if args.runtime_results: + snapshot = runtime / 'build-record.json' + if snapshot.is_file(): + record['canonicalBuildRecord'] = json.loads(snapshot.read_text()) + recorded_binaries = record['canonicalBuildRecord'].get('binaries', {}) + if any(recorded_binaries.get(name) != value for name, value in record['executables'].items()): + raise SystemExit('Canonical CTest build differs from the current production executables') + if (record['canonicalBuildRecord'].get('junitSha256') != sha(junit_path) or + record['canonicalBuildRecord'].get('logSha256') != sha(test_log_path)): + raise SystemExit('Canonical CTest evidence changed after its build record was captured') + focused_scope = record.get('canonicalBuildRecord', {}).get('focusedTestScope') + if focused_scope: + record['focusedTestScope'] = focused_scope +canvas_snapshot = results / 'canvas-scene-graph/build-record.json' +if canvas_snapshot.is_file(): + value = json.loads(canvas_snapshot.read_text()) + frozen_runner = runtime / 'wayland/canvas-software/runner-at-start.py' + if (any(value['binaries'].get(name) != digest for name, digest in record['executables'].items()) + or value['binaries'].get('test_pdf_canvas') != sha(build / 'test_pdf_canvas') + or any(not recorded_runner_matches(name, digest, frozen_runner) for name, digest in value['sourceSha256'].items()) + or any(sha(root / name) != digest for name, digest in value['evidenceSha256'].items())): + raise SystemExit('Supplemental Canvas evidence differs from the recorded files/build') + value['currentSourceDifferences'] = [name for name, digest in value['sourceSha256'].items() if sha(root / name) != digest] + value['frozenWaylandRunner'] = str(frozen_runner.relative_to(root)) + record['canvasFollowup'] = value +for name in ['gui', 'gui-final', 'smoke', 'gpu-smoke', 'gpu-smoke-logged', 'gpu-gui', 'canvas-software']: + wayland = runtime / 'wayland' / name / 'report.json' + if wayland.is_file(): + value = json.loads(wayland.read_text()) + value['matchesRecordedBuild'] = all( + value.get('executables', {}).get(binary) == digest + for binary, digest in record['executables'].items()) + if value.get('mode') == 'gui': + value['testExecutablesMatchCurrent'] = all( + value.get('executables', {}).get(binary) == sha(build / binary) + for binary in ['test_app', 'test_web_qml', 'test_pdf_canvas']) + correction = wayland.parent / 'scope-correction.json' + if correction.is_file(): + value['scopeCorrection'] = json.loads(correction.read_text()) + graphics = wayland.parent / 'graphics-evidence.json' + if graphics.is_file(): + value['additionalGraphicsEvidence'] = json.loads(graphics.read_text()) + record.setdefault('wayland', {})[name] = value +component_evidence = runtime / 'unchanged-component-evidence.json' +if component_evidence.is_file(): + value = json.loads(component_evidence.read_text()) + for name in ['docview-pdf-worker', 'docview-archive-worker', 'test_pdf', 'test_core', + 'test_worker_deadlines', 'libdocview_common.a']: + row = value.get('binaries', {}).get(name, {}) + if row.get('currentSha256') != sha(build / name): + raise SystemExit('Unchanged-component record does not match the current build: ' + name) + record['unchangedComponentEvidence'] = value +ubuntu = runtime / 'ubuntu' +if (ubuntu / 'build-record.json').is_file(): + guest = json.loads((ubuntu / 'build-record.json').read_text()) + guest_cases = ET.parse(ubuntu / 'ctest-font-final/tests.xml').findall('.//testcase') + if (len(guest_cases) != guest['ctest']['groups'] or any( + c.find('failure') is not None or c.find('skipped') is not None for c in guest_cases) or + sha(ubuntu / 'ctest-font-final/tests.xml') != guest['ctest']['junitSha256'] or + sha(ubuntu / 'ctest-font-final/LastTest.log') != guest['ctest']['logSha256']): + raise SystemExit('Ubuntu CTest evidence does not match its build record') + guest['productionSourceDifferencesFromCurrent'] = [name for name, value in sources.items() + if compiled_source(name) + and guest['sourceSha256'].get(name) != value] + guest['productionSourceMatchesCurrent'] = not guest['productionSourceDifferencesFromCurrent'] + followup_path = ubuntu / 'canvas-build-record.json' + if followup_path.is_file(): + followup = json.loads(followup_path.read_text()) + followup_ctest = ubuntu / 'canvas-ctest' + selected = ET.parse(followup_ctest / 'tests.xml').findall('.//testcase') + if (len(selected) != 2 or followup['ctest']['groups'] != 2 or any( + c.find('failure') is not None or c.find('skipped') is not None for c in selected) + or sha(followup_ctest / 'tests.xml') != followup['ctest']['junitSha256'] + or sha(followup_ctest / 'LastTest.log') != followup['ctest']['logSha256']): + raise SystemExit('Ubuntu supplemental Canvas CTest evidence changed') + followup['productionBinariesMatchFullCTest'] = all( + followup['binaries'][n] == guest['binaries'][n] for n in record['executables']) + followup['productionSourceMatchesCurrent'] = all( + followup['sourceSha256'].get(name) == value for name, value in sources.items() + if compiled_source(name)) + followup['canvasTestSourcesMatchCurrent'] = all( + followup['sourceSha256'].get(name) == sha(root / name) + for name in ['tests/test_pdf_canvas.cpp', 'tests/run_wayland_validation.py', 'CMakeLists.txt']) + frozen_runner = ubuntu / 'canvas-wayland/runner-at-start.py' + followup['canvasTestSourcesMatchRecordedRun'] = all( + recorded_runner_matches(name, followup['sourceSha256'].get(name), frozen_runner) + for name in ['tests/test_pdf_canvas.cpp', 'tests/run_wayland_validation.py', 'CMakeLists.txt']) + followup['frozenWaylandRunner'] = str(frozen_runner.relative_to(root)) + wayland_followup = json.loads((ubuntu / 'canvas-wayland/report.json').read_text()) + followup['wayland'] = wayland_followup + followup['waylandMatchesFollowupBuild'] = all( + followup['binaries'].get(name) == digest for name, digest in wayland_followup['executables'].items()) + if not all(followup[name] for name in ['productionBinariesMatchFullCTest', + 'productionSourceMatchesCurrent', 'canvasTestSourcesMatchRecordedRun', 'waylandMatchesFollowupBuild']): + raise SystemExit('Ubuntu Canvas follow-up does not match the current sources or baseline production binaries') + guest['canvasFollowup'] = followup + guest['wayland'] = {} + for mode in ('gui', 'smoke'): + value = json.loads((ubuntu / 'wayland' / mode / 'report.json').read_text()) + value['matchesGuestBuild'] = all(guest['binaries'].get(n) == digest + for n, digest in value['executables'].items()) + correction = ubuntu / 'wayland' / mode / 'scope-correction.json' + if correction.is_file(): + value['scopeCorrection'] = json.loads(correction.read_text()) + guest['wayland'][mode] = value + installed = json.loads((ubuntu / 'installed-smoke/results.json').read_text()) + guest['installedSmoke'] = {'cases': len(installed), + 'allReady': all(x['state'] == 'Ready' for x in installed), + 'matchesInstalledBuild': all(x['binarySha256'] == guest['installedBinaries']['docview'] for x in installed)} + inventory = json.loads((ubuntu / 'installed-runtime-final/runtime.json').read_text()) + indexed = {x['path']: x['sha256'] for x in inventory['files']} + guest['installedRuntime'] = { + 'runtimeValidationSuccess': inventory['runtimeValidationSuccess'], + 'files': len(inventory['files']), 'systemPackages': len(inventory['systemPackages']), + 'noticesAndMetadata': len(inventory['notices']), 'noticeGaps': inventory['noticeGaps'], + 'matchesInstalledBuild': all(indexed.get('install:bin/' + n) == digest + for n, digest in guest['installedBinaries'].items()), + 'completeChromiumNotices': inventory['completeChromiumNotices'], + 'completeCorrespondingSources': inventory['completeCorrespondingSources'], + 'runtimeBinariesCopied': inventory['runtimeBinariesCopied']} + record['ubuntuValidation'] = guest +heavy_root = results / 'heavy-pdf' +for environment in ['final-arch', 'ubuntu']: + report_path = heavy_root / environment / 'report.json' + if not report_path.is_file(): + continue + value = json.loads(report_path.read_text()) + measurement = json.loads((report_path.parent / 'measurement.json').read_text()) + if (not value['success'] or not measurement['success'] + or sha(report_path.parent / 'measurement.json') != value['measurementSha256'] + or sha(report_path.parent / 'heavy.pdf') != value['fixtureSha256'] + or not all(sha(root / name) == digest for name, digest in value['sourceSha256'].items())): + raise SystemExit('Heavy-PDF supplemental source or evidence changed: ' + environment) + value['recordedSourcesMatchCurrent'] = True + if environment == 'final-arch': + value['binariesMatchCurrent'] = all(sha(root / name) == digest for name, digest in value['binaries'].items()) + if not value['binariesMatchCurrent']: + raise SystemExit('Heavy-PDF Arch libraries/workers/test executable changed') + else: + guest_binaries = record.get('ubuntuValidation', {}).get('binaries') + value['productionBinariesMatchUbuntuBaseline'] = (all( + value['binaries'].get('../docview-build/' + name) == guest_binaries[name] + for name in record['executables']) if guest_binaries else None) + if value['productionBinariesMatchUbuntuBaseline'] is False: + raise SystemExit('Heavy-PDF Ubuntu production binaries differ from the selected baseline') + value['measurement'] = measurement + record.setdefault('heavyPdfFollowup', {})[environment] = value +ime_root = results / 'ime' +def record_ime(relative, summary, current): + ime_path = ime_root / relative / 'report.json' + value = json.loads(ime_path.read_text()) + if (not value['success'] or not value['inputsUnchanged'] or value['remainingOwnedProcesses'] + or sha(ime_path) != summary['reportSha256'] + or not all(sha(ime_path.parent / name) == digest for name, digest in summary['evidenceSha256'].items())): + raise SystemExit('IME supplemental evidence is incomplete or changed') + value['recordedSourcesMatchCurrent'] = all(sha(root / name) == digest + for name, digest in value['inputs'].items() if not name.startswith('../')) + value['productionSourcesMatchCurrent'] = all(sha(root / name) == digest + for name, digest in value['inputs'].items() if name.startswith(('src/', 'qml/', 'resources/')) or name == 'resources.qrc') + if not current: + for name, digest in value['inputs'].items(): + if name.startswith('tests/ime/') and sha(ime_path.parent / 'harness-snapshot' / Path(name).name) != digest: + raise SystemExit('Historical IME harness snapshot changed: ' + name) + guest_binaries = record.get('ubuntuValidation', {}).get('binaries') + value['productionBinariesMatchUbuntuBaseline'] = (all( + value['inputs'].get('../docview-build/' + name) == guest_binaries[name] + for name in record['executables']) if guest_binaries else None) + if ((current and not value['recordedSourcesMatchCurrent']) or not value['productionSourcesMatchCurrent'] + or value['productionBinariesMatchUbuntuBaseline'] is False): + raise SystemExit('IME supplemental sources or Ubuntu production binaries changed') + platform = value.get('requestedPlatform', 'xcb') + expected = ['us', 'jp'] if platform == 'xcb' else ['virtual-keysyms'] + if set(value['cases']) != set(expected): + raise SystemExit('IME case set does not match the recorded platform') + for layout in expected: + case = json.loads((ime_path.parent / (layout + '.json')).read_text()) + if (not case['success'] or case != value['cases'][layout] or len(case['phases']) != 5 + or (current and case['qtPlatform'] != platform)): + raise SystemExit('IME case does not match the successful final report: ' + layout) + value['summary'] = summary + return value +if (ime_root / 'summary.json').is_file(): + record['imePriorX11'] = record_ime('ubuntu/final', json.loads((ime_root / 'summary.json').read_text()), False) +if (ime_root / 'current-summary.json').is_file(): + current = json.loads((ime_root / 'current-summary.json').read_text()) + if set(current) != {'xcb', 'wayland'}: + raise SystemExit('Current IME evidence must contain both tested platforms') + runtime_fingerprint = json.loads((ime_root / 'ubuntu/wayland-runtime-record.json').read_text()) + if runtime_fingerprint['remainingValidationProcesses']: + raise SystemExit('IME validation processes were not reclaimed') + record['imeFollowup'] = {platform: record_ime(value['directory'], value, True) for platform, value in current.items()} + record['imeRuntimeFingerprintAfterRun'] = runtime_fingerprint +source_archives = results / 'source-archives' +if (source_archives / 'qt-inspection/report.json').is_file(): + downloads = {name: json.loads((source_archives / 'download' / (name + '.json')).read_text()) + for name in ['qt-everywhere', 'tomlplusplus']} + for name, value in downloads.items(): + archive = root / value['archive'] + if not value['success'] or archive.stat().st_size != value['bytes'] or sha(archive) != value['hashes']['sha256']: + raise SystemExit('Collected source archive changed: ' + name) + qt_source = json.loads((source_archives / 'qt-inspection/report.json').read_text()) + toml_source = json.loads((source_archives / 'tomlplusplus/verification.json').read_text()) + patches = json.loads((source_archives / 'arch-patch-recipe-mode/report.json').read_text()) + if (not qt_source['success'] or not patches['success'] or not patches['pristineFilesUnchanged'] + or not toml_source['allInstalledHeadersMatch'] or not toml_source['licenseMatchesInstalled'] + or sha(source_archives / 'qt-inspection/files.jsonl') != qt_source['inventorySha256'] + or sha(source_archives / 'qt-inspection/notices-index.json') != qt_source['noticesIndexSha256'] + or qt_source['productionBinaries'] != record['executables']): + raise SystemExit('Source archive inspection evidence is incomplete or changed') + for step in patches['steps']: + if sha(root / step['patch']) != step['patchSha256']: + raise SystemExit('Recorded Arch source patch changed') + record['sourceArchiveCollection'] = {'status': 'partial', 'downloads': downloads, + 'qtInspection': qt_source, 'tomlVerification': toml_source, 'archPatchCheck': patches, + 'completeCorrespondingSources': False, 'completeChromiumNotices': False} +pdfium_collection_path = source_archives / 'pdfium-git/report.json' +if pdfium_collection_path.is_file(): + pdfium_collection = json.loads(pdfium_collection_path.read_text()) + pdfium_check_path = source_archives / 'pdfium-check/report.json' + pdfium_check = json.loads(pdfium_check_path.read_text()) + pdfium_gitlink = json.loads((source_archives / 'pdfium-gitlink/report.json').read_text()) + pdfium_plan = json.loads((source_archives / 'pdfium-git/plan.json').read_text()) + if (not pdfium_collection['success'] or not pdfium_check['success'] or not pdfium_gitlink['success'] + or pdfium_collection['pinSha256'] != sha(results / 'source-correspondence/pdfium/dependency-pins.json') + or pdfium_collection['planSha256'] != sha(source_archives / 'pdfium-git/plan.json') + or pdfium_collection['collectorSha256'] != sha(root / 'tests/source_archives/collect_pdfium.py') + or pdfium_check['checkerSha256'] != sha(root / 'tests/source_archives/check_pdfium.py') + or pdfium_gitlink['collectorSha256'] != sha(root / 'tests/source_archives/collect_pdfium_gitlink.py') + or pdfium_check['collectionReportSha256'] != sha(pdfium_collection_path) + or pdfium_gitlink['parentCollectionSha256'] != sha(pdfium_collection_path) + or pdfium_gitlink['gitmodulesSha256'] != sha(source_archives / 'pdfium-gitlink/freetype.gitmodules') + or pdfium_check['productionBinaries'] != record['executables'] + or not pdfium_check['productionBinariesUnchanged']): + raise SystemExit('PDFium source collection evidence is incomplete or changed') + expected_pins = {(item['path'], item['revision']) for item in pdfium_plan['selectedGit']} + actual_pins = {(item['path'], item['revision']) for item in pdfium_collection['repositories']} + if expected_pins != actual_pins or len(actual_pins) != 28: + raise SystemExit('Selected PDFium Git source inventory differs') + nested_pins = {(item['path'] + '/' + link['path'], link['gitObject']) + for item in pdfium_collection['repositories'] for link in item['gitlinks']} + if nested_pins != {(item['path'], item['revision']) for item in pdfium_gitlink['repositories']}: + raise SystemExit('Nested PDFium Git source inventory differs') + for item in pdfium_collection['repositories'] + pdfium_gitlink['repositories']: + if (not item['success'] or not item['gitFsckPassed'] or not item['archiveVerifiedAgainstGitBlobs'] + or sha(root / item['archive']) != item['archiveSha256'] + or (root / item['archive']).stat().st_size != item['archiveBytes'] + or sha(root / item['inventory']) != item['inventorySha256']): + raise SystemExit('Collected PDFium source archive changed: ' + item['path']) + for platform, checked in pdfium_check['platforms'].items(): + for step in checked['steps']: + if (sha(root / step['patch']) != step['sha256'] or step['apply']['exitCode'] + or step['dryRun']['exitCode']): + raise SystemExit('PDFium provider patch evidence changed') + if len(checked['linuxPackagedHeaderComparisons']) != 24: + raise SystemExit('PDFium public header inventory differs') + for item in checked['linuxPackagedHeaderComparisons']: + if (not item['match'] or item['sourceSha256'] != item['packagedSha256'] + or sha(root / '.deps/pdfium/include' / item['path']) != item['packagedSha256']): + raise SystemExit('PDFium packaged public header changed') + if len(pdfium_check['noticeComparisons']) != 15 or len(pdfium_check['gitilesSourceComparisons']) != 31: + raise SystemExit('PDFium source comparison inventory differs') + for item in pdfium_check['gitilesSourceComparisons']: + if sha(results / 'source-correspondence/pdfium' / item['saved']) != item['sha256']: + raise SystemExit('Previously retrieved PDFium source changed') + for item in pdfium_check['noticeComparisons']: + if not item['match'] or sha(root / '.deps/pdfium' / item['path']) != item['sha256']: + raise SystemExit('PDFium packaged notice changed') + record['sourceArchiveCollection']['pdfiumGit'] = { + 'collection': pdfium_collection, 'nestedGitlink': pdfium_gitlink, 'sourceCheck': pdfium_check, + 'completeCorrespondingSources': False, 'buildReproduced': False} +sdk_notices_path = source_archives / 'qt-sdk-notices-final/report.json' +if sdk_notices_path.is_file(): + sdk_notices = json.loads(sdk_notices_path.read_text()) + sdk_evidence = sdk_notices_path.parent + archive = root / sdk_notices['sdkArchive']['path'] + if (not sdk_notices['success'] + or sdk_notices['collectorSha256'] != sha(root / 'tests/source_archives/collect_qt_sdk_notices.py') + or sdk_notices['runtimeRecordSha256'] != sha(results / 'ui-final/ubuntu/installed-runtime-final/runtime.json') + or sdk_notices['sdkArchive']['sha256'] != sha(archive) + or sdk_notices['sdkArchive']['bytes'] != archive.stat().st_size + or sdk_notices['sdkArchiveInventorySha256'] != sha(sdk_evidence / 'sdk-archive-inventory.json') + or sdk_notices['noticeBundleSha256'] != sha(sdk_evidence / 'THIRD_PARTY_NOTICES.sdk-extract.txt') + or sdk_notices['completeChromiumNotices'] or sdk_notices['completeCorrespondingSources']): + raise SystemExit('Qt SDK notice evidence is incomplete or changed') + if (len(sdk_notices['notices']) != 129 or len(sdk_notices['sbomFileComparisons']) != 67 + or len(sdk_notices['testedRuntimeComparisons']) != 83 + or sdk_notices['externalPackageReferencesValid'] + or len(sdk_notices['packagesWithoutEmbeddedNoticeText']) != 2): + raise SystemExit('Qt SDK notice scope changed; reassess the recorded limitations') + for entry in sdk_notices['metadata'].values(): + if sha(root / entry['localPath']) != entry['sha256']: + raise SystemExit('Qt SDK SBOM input changed') + qt_tree = root / qt_source['sourceTree'] + for entry in sdk_notices['notices']: + if (sha(sdk_evidence / entry['file']) != entry['sha256'] + or sha(qt_tree / entry['source']) != entry['sha256']): + raise SystemExit('Qt SDK notice or corresponding source changed') + for name, digest in sdk_notices['generatorSources'].items(): + if sha(qt_tree / name) != digest: + raise SystemExit('Qt SDK SBOM generator source changed') + record['sourceArchiveCollection']['qtSdkNotices'] = sdk_notices +package_root = results / 'ubuntu-package' +if (package_root / 'lifecycle/report.json').is_file(): + package_build = json.loads((package_root / 'build/report.json').read_text()) + package_repeat = json.loads((package_root / 'repeat-report.json').read_text()) + package_verify = json.loads((package_root / 'build/verification.json').read_text()) + package_smoke = json.loads((package_root / 'smoke/report.json').read_text()) + package_lifecycle = json.loads((package_root / 'lifecycle/report.json').read_text()) + archive = root / 'build-ubuntu-vm/packages' / package_build['archive'] + if (not all(value['success'] for value in [package_build, package_repeat, package_verify, package_smoke, package_lifecycle]) + or package_build['archiveSha256'] != sha(archive) + or package_build['archiveSha256'] != package_repeat['archiveSha256'] + or package_build['archiveSha256'] != package_verify['archiveSha256'] + or package_build['archiveBytes'] != archive.stat().st_size + or package_build['manifestSha256'] != sha(package_root / 'build/package-manifest.json') + or package_verify['packageManifestSha256'] != package_build['manifestSha256'] + or package_build['runtimeRecordSha256'] != sha(package_root / 'build/runtime/runtime.json') + or package_build['packagerSha256'] != sha(root / 'tools/package_ubuntu.py') + or package_build['collectorSha256'] != sha(root / 'tools/collect_ubuntu_validation_runtime.py') + or package_build['executableSha256'] != record['ubuntuValidation']['installedBinaries'] + or package_build['executableSha256'] != package_smoke['executables'] + or package_smoke['runnerSha256'] != sha(root / 'tests/ubuntu_vm/package_smoke.py') + or package_smoke['smokeSourceSha256'] != sha(root / 'tests/smoke.py') + or package_smoke['waylandRunnerSha256'] != sha(root / 'tests/run_wayland_validation.py') + or package_smoke['launcherSha256'] != sha(root / 'resources/linux/docview-launcher') + or package_lifecycle['runnerSha256'] != sha(root / 'tests/ubuntu_vm/package_lifecycle.py') + or package_lifecycle['smokeReportSha256'] != sha(package_root / 'smoke/report.json') + or package_build['completeChromiumNotices'] or package_build['completeCorrespondingSources'] + or package_build['publicReleaseApproved']): + raise SystemExit('Ubuntu package evidence changed or does not match the tested installation') + package_manifest = json.loads((package_root / 'build/package-manifest.json').read_text()) + packaged_hashes = {row['path']: row['sha256'] for row in package_manifest['files']} + for source, destination in [('docview-launcher', 'usr/bin/docview'), + ('docview.desktop', 'usr/share/applications/docview.desktop'), + ('docview.apparmor', 'etc/apparmor.d/docview'), + ('deb-postinst', 'DEBIAN/postinst'), ('deb-prerm', 'DEBIAN/prerm')]: + if packaged_hashes[destination] != sha(root / 'resources/linux' / source): + raise SystemExit('Ubuntu package launcher or lifecycle resources changed') + for location in ['x11/results.json', 'wayland/smoke/results.json']: + smoke_cases = json.loads((package_root / 'smoke' / location).read_text()) + if len(smoke_cases) != 6 or any(row['state'] != 'Ready' or row['binarySha256'] != package_smoke['launcherSha256'] for row in smoke_cases): + raise SystemExit('Ubuntu installed launcher smoke failed or changed') + package_wayland = json.loads((package_root / 'smoke/wayland/report.json').read_text()) + if (not package_wayland['success'] or package_wayland['executables'] != package_build['executableSha256'] + or not all(package_smoke['hiddenOriginalPrefixes'].values()) + or not package_smoke['callerRuntimeVariablesAbsent'] or not package_smoke['executableBytesUnchanged'] + or package_smoke['smokeConditions'] != 12 + or not all(package_lifecycle['remove'].values()) or not all(package_lifecycle['purge'].values()) + or not package_lifecycle['kernelUserNamespaceRestrictionUnchanged']): + raise SystemExit('Ubuntu package isolation or remove/purge checks are incomplete') + record['ubuntuPackage'] = {'scope': 'Local validation deb in existing dedicated VM; clean OS/public release incomplete', + 'build': package_build, 'verification': package_verify, 'repeatedArchiveIdentical': True, + 'smoke': package_smoke, 'lifecycle': package_lifecycle, 'archivePath': str(archive.relative_to(root))} + clean_root = package_root / 'clean-vm' + if (clean_root / 'remove/report.json').is_file(): + phases = {name: json.loads((clean_root / name / 'report.json').read_text()) for name in ['install', 'smoke', 'remove']} + for name, phase in phases.items(): + if (not phase['success'] or phase['archiveSha256'] != package_build['archiveSha256'] + or phase['runnerSha256'] != sha(root / 'tests/ubuntu_vm/clean_package.py') + or phase['originalSdkOrBuildPresent'] or phase['uid'] == 0): + raise SystemExit('Fresh Ubuntu package phase failed or changed') + for command in phase['commands']: + if command['exitCode'] or sha(clean_root / name / (command['name'] + '.log')) != command['logSha256']: + raise SystemExit('Fresh Ubuntu command evidence changed') + for name in ['install', 'smoke']: + if phases[name]['executables'] != package_build['executableSha256']: + raise SystemExit('Fresh Ubuntu package uses different production binaries') + if (phases['install']['installedFilesVerified'] != 2004 + or len(phases['install']['elfDependenciesBeforeTestHelpers']) != 131 + or phases['smoke']['smokeConditions'] != 12 or not phases['smoke']['executableBytesUnchanged'] + or not phases['smoke']['callerRuntimeVariablesAbsent'] + or phases['smoke']['launcherSha256'] != package_smoke['launcherSha256'] + or phases['smoke']['smokeSourceSha256'] != sha(root / 'tests/smoke.py') + or phases['smoke']['waylandRunnerSha256'] != sha(root / 'tests/run_wayland_validation.py') + or not all(phases['remove'][key] for key in ['payloadRemoved', 'profileUnloaded', 'conffilePurged', 'kernelRestrictionUnchanged']) + or len(phases['remove']['userProbesPreserved']) != 4): + raise SystemExit('Fresh Ubuntu package tests are incomplete') + for location in ['x11/results.json', 'wayland/smoke/results.json']: + smoke_cases = json.loads((clean_root / 'smoke' / location).read_text()) + if len(smoke_cases) != 6 or any(row['state'] != 'Ready' or row['binarySha256'] != package_smoke['launcherSha256'] for row in smoke_cases): + raise SystemExit('Fresh Ubuntu smoke evidence changed') + fresh_wayland = json.loads((clean_root / 'smoke/wayland/report.json').read_text()) + if not fresh_wayland['success'] or fresh_wayland['executables'] != package_build['executableSha256']: + raise SystemExit('Fresh Ubuntu Wayland build changed') + transferred = json.loads((clean_root / 'transferred-inputs.json').read_text()) + for item in transferred['files']: + if sha(root / item['path']) != item['sha256']: + raise SystemExit('Fresh Ubuntu transferred test input changed') + base_provenance = json.loads((clean_root / 'base-provenance.json').read_text()) + original_image = root / 'build-ubuntu-vm/downloads/noble-server-cloudimg-amd64.img' + if (base_provenance['baseImageSha256'] != sha(original_image) or not base_provenance['newOverlay'] + or not base_provenance['existingVerification']['ubuntuImageSignatureValid'] + or base_provenance['runtimeSdkCopied']): + raise SystemExit('Fresh Ubuntu base image provenance changed') + record['ubuntuPackage']['scope'] = 'Local validation deb on existing and fresh Ubuntu 24.04 cloud-image VMs; Xvfb/headless Wayland/software; public release and physical desktop incomplete' + record['ubuntuPackage']['freshOs'] = {'base': base_provenance, 'phases': phases, + 'transferredTestInputs': len(transferred['files']), 'guiTestHelpersAddedAfterElfDependencyCheck': True} +prior_stress = results / 'stress-final/report.json' +if args.runtime_results and prior_stress.is_file(): + record['stressPdfPrior'] = json.loads(prior_stress.read_text()) +record['performance'] = {} +for corpus in ['performance', 'performance-standard', 'performance-fonts']: + record['performance'][corpus] = {} + for path in sorted((runtime / corpus).glob('*.json')): + values = json.loads(path.read_text()) + record['performance'][corpus][path.stem] = {key: values.get(key) for key in + ['success', 'summary', 'documentSha256', 'applicationRssPeakBytes', 'processGroupRssPeakBytes', + 'tileCachePeakChargedBytes', 'tileCacheBudgetBytes', 'staleRenderResponses', 'corpus', 'method', + 'executableSha256', 'schemaVersion', 'memoryStart', 'memoryAfterFinalClose', + 'operationSeries', 'pendingRenderPeak', 'queuedRenderPeak', 'activeRenderPeak', + 'discardedQueuedRequests', 'navigationCoverage', 'measurementChecks', 'proposedBudgetObservations', + 'coldProcessOpen', 'qtPlatform', 'sceneGraphBackend', 'timingSemantics', 'runnerSha256', + 'display', 'maximumMemoryPressureLevel']} + record['performance'][corpus][path.stem]['matchesRecordedBuild'] = ( + values.get('executableSha256') == record['executables']) + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) + for path in sorted((runtime / corpus / 'cold').rglob('*.json')): + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) + run_log = runtime / (corpus + '-canonical.log') + if run_log.is_file(): + record['evidenceSha256'][str(run_log.relative_to(root))] = sha(run_log) +for name, relative in [('installedSmoke', 'smoke/results.json'), + ('stressPdf', 'stress/report.json' if args.runtime_results else 'stress-final/report.json'), + ('workerDeadlines', 'worker-deadlines/report.json'), + ('installedDependencies', 'installed-dependencies.json')]: + path = runtime / relative + if path.is_file(): + record[name] = json.loads(path.read_text()) + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +for relative in ['worker-deadlines/junit.xml', 'worker-deadlines/test.txt']: + path = runtime / relative + if path.is_file(): + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +for folder in ['recent-documents', 'web-epub-boundaries', 'resource-warnings', 'zip-entry-choice', 'epub-spreads', + 'link-borders', 'pdf-annotation-flags', 'pdf-comments', 'resource-classification', 'pdf-structure', + 'pdf', 'pdf-extended', 'pdf-fonts', 'font-collection', 'pdf-icc', 'smoke', + 'renderer-response', 'navigation-reflow', 'archive-ratio', 'linux-development-package', + 'performance-interaction', 'performance-before-xhtml-fix', 'xhtml-headings', 'benchmark-xhtml', + 'stress', 'stress-final', 'worker-deadlines', 'render-review', 'dependency-provenance', + 'completion-regressions', 'canvas-scene-graph', 'heavy-pdf', 'ime', 'source-archives', 'ubuntu-package']: + for path in sorted((root / 'tests/results' / folder).rglob('*')): + if path.is_file() and (path.suffix in {'.png', '.txt', '.log', '.md', '.xml', '.json', '.html', '.py'} + or (folder == 'heavy-pdf' and path.suffix in {'.pdf', '.cpp'}) + or (folder == 'ime' and path.suffix in {'.cpp', '.xkb'}) or path.name.endswith('.tar.gz')): + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +if args.runtime_results: + for path in sorted(runtime.rglob('*')): + if path.is_file() and '__pycache__' not in path.parts: + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +interaction = runtime / ('performance-interaction/record.json' if args.runtime_results + else 'performance-interaction/canonical-xhtml/record.json') +for path in sorted((root / 'tests/results/source-correspondence').rglob('*')): + if path.is_file() and '__pycache__' not in path.parts: + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +if interaction.is_file(): + record['interactionMeasurement'] = json.loads(interaction.read_text()) + sampled = {entry['path']: entry['sha256'] for entry in record['interactionMeasurement'].get('binaries', [])} + record['interactionMeasurementBuildMatches'] = all( + sampled.get(name) == record['executables'][name] + for name in ['docview-pdf-worker', 'docview-archive-worker']) and sampled.get('test_app') == sha(build / 'test_app') + record['interactionMeasurementScope'] = 'test_app with production Controller/QML and worker executables; docview main() is exercised separately by the application benchmarks and installed smoke tests.' +package_folder = runtime / 'linux-development-package' +package_name = 'docview-0.1.0-arch-x86_64-development.json' +package = package_folder / ('package' if args.runtime_results else 'source-notices') / package_name +prior_package = results / 'linux-development-package/source-notices' / package_name +if prior_package.is_file(): + record['linuxDevelopmentPackagePrior'] = json.loads(prior_package.read_text()) +if package.is_file(): + record['linuxDevelopmentPackage'] = json.loads(package.read_text()) + packaged = record['linuxDevelopmentPackage'].get('executableSha256', {}) + record['linuxDevelopmentPackageInstalledBuildMatches'] = packaged == { + 'bin/' + name: digest for name, digest in record['installedExecutableSha256'].items()} + record['linuxDevelopmentPackageUninstalledBuildMatches'] = { + name: packaged.get('bin/' + name) == digest for name, digest in record['executables'].items()} + record['linuxDevelopmentPackageMatchScope'] = ('Compared to cmake-installed executables. The PDF worker RUNPATH ' + 'changes from its development PDFium path to $ORIGIN/../lib during installation, so its installed ' + 'file hash differs from build/docview-pdf-worker. Both identities are retained.') +package_smoke = package_folder / ('smoke' if args.runtime_results else 'source-notices-smoke') / 'package-smoke.json' +if package_smoke.is_file(): + record['linuxDevelopmentPackageSmoke'] = json.loads(package_smoke.read_text()) +prior_smoke = results / 'linux-development-package/source-notices-smoke/package-smoke.json' +if prior_smoke.is_file(): + record['linuxDevelopmentPackageSmokePrior'] = json.loads(prior_smoke.read_text()) +notice_record = results / 'linux-development-package/source-notices-record.json' +if notice_record.is_file(): + record['linuxDevelopmentPackageNoticeUpdate'] = json.loads(notice_record.read_text()) +record['pdfComparisons'] = {} +record['pdfComparisonBuildMatches'] = {} +for name in ['pdf', 'pdf-extended', 'pdf-fonts', 'font-collection', 'link-borders', 'pdf-icc']: + path = runtime / name / 'comparison.json' + if path.is_file(): + record['pdfComparisons'][name] = json.loads(path.read_text()) + record['pdfComparisonBuildMatches'][name] = ( + record['pdfComparisons'][name].get('worker_sha256') == record['executables']['docview-pdf-worker']) + record['evidenceSha256'][str(path.relative_to(root))] = sha(path) +if 'workerDeadlines' in record: + record['workerDeadlinesBuildMatches'] = { + 'testExecutable': record['workerDeadlines'].get('testExecutableSha256') == sha(build / 'test_worker_deadlines'), + 'commonLibrary': record['workerDeadlines'].get('commonLibrarySha256') == sha(build / 'libdocview_common.a')} +if 'stressPdf' in record: + record['stressPdfBuildMatches'] = record['stressPdf'].get('executableSha256') == record['executables'] +record['buildScope'] = ('Executable hashes identify the current production build; source hashes record the current tree. ' + 'The frozen full CTest report is bound to its canonical build record. Later test-only Canvas/HiDPI changes ' + 'and their focused results are identified separately; the full report is not relabeled as a rerun of those changes. ' + 'Runtime results are selected explicitly; missing new evidence is not filled with an older success. ' + 'Each performance record identifies its measured executables separately. ' + 'Build-match flags distinguish earlier performance and PDF-comparison evidence. ' + 'The stress record identifies all three measured executables. Historical reports and packages retain their own identities. ' + 'The standalone real-time watchdog record identifies its own test executable and common library.') +args.output.write_text(json.dumps(record, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') +print(f'{len(cases)} passing CTest suites recorded in {args.output}') diff --git a/tests/render_pdf_worker.cpp b/tests/render_pdf_worker.cpp new file mode 100644 index 0000000..ad5983b --- /dev/null +++ b/tests/render_pdf_worker.cpp @@ -0,0 +1,100 @@ +// Evidence exporter using the same sandbox, font broker and tile IPC as the GUI. +// This executable deliberately does not link PDFium. +#include "broker/font_broker.h" +#include "common/worker_process.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + if (app.arguments().size() != 3 && app.arguments().size() != 4) return 2; + bool validScale = true; + const double scale = app.arguments().size() == 4 ? app.arguments()[3].toDouble(&validScale) : 2.0; + if (!validScale || !std::isfinite(scale) || scale < .25 || scale > 4) return 2; + docview::FontBroker broker; + docview::WorkerProcess worker("pdf-render-evidence", 1); + QVariantList fontSelections; + qint64 transferred = 0; + worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function done) { + broker.request(op, payload, [&, op, payload, done = std::move(done)](QCborMap result) { + if (op == "font.map") { + auto item = result.toVariantMap(); + item.remove("fontId"); item.remove("sha256"); + item["requestedFace"] = QString::fromLocal8Bit(payload.value("faceLocal").toByteArray()); + item["requestedWeight"] = payload.value("weight").toInteger(); + item["requestedItalic"] = payload.value("italic").toBool(); + item["actualFace"] = QString::fromLocal8Bit(result.value("actualFaceLocal").toByteArray()); + item.remove("actualFaceLocal"); fontSelections.append(item); + } else if (op == "font.read") transferred += result.value("data").toByteArray().size(); + done(std::move(result)); + }); + }); + bool ready = false, failed = false; + QString failure; + QEventLoop bootstrap; + QObject::connect(&worker, &docview::WorkerProcess::ready, &bootstrap, [&] { ready = true; bootstrap.quit(); }); + QObject::connect(&worker, &docview::WorkerProcess::failed, &bootstrap, [&](QString code, QString message) { + failed = true; failure = code + ": " + message; bootstrap.quit(); + }); + QTimer::singleShot(10000, &bootstrap, &QEventLoop::quit); + if (!worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", app.arguments()[1]})) return 3; + bootstrap.exec(); + if (!ready || failed) { QTextStream(stderr) << failure; return 3; } + auto call = [&](const QString &op, const QCborMap &payload = QCborMap{}) { + QEventLoop wait; + QCborMap response; + bool complete = false; + QTimer::singleShot(125000, &wait, &QEventLoop::quit); + worker.request(op, payload, [&](const QCborMap &reply) { response = reply; complete = true; wait.quit(); }); + if (!complete) wait.exec(); + if (!complete || failed || response.contains(QStringLiteral("error"))) { + QTextStream(stderr) << op << ": " << failure << " " << QJsonDocument::fromVariant(response.toVariantMap()).toJson(); + if (!complete) worker.stop(); // Do not retain a callback to this stack frame. + return QCborMap{}; + } + return response.value("result").toMap(); + }; + const auto metadata = call("open"); + const auto pages = metadata.value("pages").toArray(); + if (pages.isEmpty() || pages.size() != metadata.value("pageCount").toInteger()) return 4; + if (!QDir().mkpath(app.arguments()[2])) return 5; + for (int page = 0; page < pages.size(); ++page) { + const auto info = pages[page].toMap(); + const int width = int(std::ceil(info.value("width").toDouble() * scale)); + const int height = int(std::ceil(info.value("height").toDouble() * scale)); + if (width <= 0 || height <= 0 || width > 8192 || height > 8192) return 6; + QImage image(width, height, QImage::Format_ARGB32); image.fill(Qt::white); + QPainter painter(&image); + for (int y = 0; y < height; y += 512) for (int x = 0; x < width; x += 512) { + const int tw = std::min(512, width - x), th = std::min(512, height - y); + const auto tile = call("render", {{"page", page}, {"scale", scale}, {"rotation", 0}, + {"x", x}, {"y", y}, {"width", tw}, {"height", th}, {"renderRevision", 1}}); + const auto bytes = tile.value("data").toByteArray(); + if (bytes.size() != tw * th * 4) return 7; + const QImage source(reinterpret_cast(bytes.constData()), tw, th, tw * 4, QImage::Format_ARGB32); + painter.drawImage(x, y, source); + } + painter.end(); + if (!image.save(app.arguments()[2] + QString("/page-%1.png").arg(page + 1))) return 8; + } + auto report = metadata.toVariantMap(); + report["japaneseSearch"] = call("search", {{"query", QString::fromUtf8("日本語")}, {"page", 0}, {"limit", 100}}).toVariantMap(); + report["fontSelections"] = fontSelections; + report["fontBytesTransferred"] = transferred; + report["renderScale"] = scale; + report["transport"] = "production WorkerProcess and FontBroker; sandbox enabled"; + QFile output(app.arguments()[2] + "/metadata.json"); + if (!output.open(QIODevice::WriteOnly)) return 9; + output.write(QJsonDocument::fromVariant(report).toJson()); + worker.stop(); broker.revoke(); + return failed ? 10 : 0; +} diff --git a/tests/render_review/index.template.html b/tests/render_review/index.template.html new file mode 100644 index 0000000..cad9d9e --- /dev/null +++ b/tests/render_review/index.template.html @@ -0,0 +1,335 @@ + + + + + + + DocView PDF 比較レビュー + + + +
    +

    DocView PDF 比較レビュー

    +

    人による確認前の比較資料です。JSON を保存するだけで、設計全体の合格や配布承認にはなりません。

    +

    画像と確認項目を見比べ、ケースごとに判定してください。入力内容はこの画面のメモリ内だけに保持されます。再読込・終了で消えるため、必要な結果は JSON 保存してください。

    +
    +
    +
    +
    +
    + + +
    +
    + + + +
    +
    +

    +
    +
    + +
    + +

    + +
    + +
    + +

    +
    +
    +
    + +
    +
    +
    +
    +
    + + +

    +
    + +
    +

    確認項目と比較条件

    +
      + +

      +

      +
      資料と画像の SHA-256
      +
      + +
      +

      このケースの判定

      +
      +
      初期値は未確認です。自動判定は行いません。
      +
      +
      +
      +

      Alt + ← / → でもケースを切り替えられます。保存 JSON の全体承認状態は「未承認」のままです。資料のハッシュは生成時の値で、この画面でファイルの再ハッシュは行いません。

      +
      + + + + + + diff --git a/tests/run_stress_pdf.py b/tests/run_stress_pdf.py new file mode 100644 index 0000000..8f98e78 --- /dev/null +++ b/tests/run_stress_pdf.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Generate, validate, benchmark and remove the temporary ~1 GiB PDF workload. + +Use --xvfb for a dedicated virtual X11 display. Chromium/worker sandboxes remain +enabled. The 3-open/100-operation run is a stress observation, not a 30-open +performance acceptance run or a representative scanned-book compatibility test. +""" +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import re +import select +import signal +import statistics +import subprocess +import tempfile +import time + +from generate_stress_pdf import ROOT, generate, validate_source + +RUNS, OPERATIONS, CLOSE_CYCLES = 3, 100, 2 + + +def file_hash(path): + with Path(path).open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def executable_hashes(binary): + return {name: file_hash(binary if name == "docview" else binary.parent / name) + for name in ("docview", "docview-pdf-worker", "docview-archive-worker")} + + +def clear_previous_evidence(output): + # Only this runner's known outputs are replaced. Historical runs elsewhere + # and unrelated files remain untouched, including on startup failure. + for name in ("report.json", "benchmark.json", "source-manifest.json", "pdfinfo.txt", "benchmark.log", "last-page.log", + "last-page/state.txt", "last-page/runtime.json", "last-page/screen.png"): + (output / name).unlink(missing_ok=True) + + +def summarize_benchmark(result, source_hash): + actions, opens = result.get("operations", []), result.get("openFrameMs", []) + cycles = result.get("openCloseCycles", []) + uniform = [v for v in cycles if v.get("workload") == "open-first-visible-only"] + final = [v for v in cycles if v.get("workload") == "navigation-and-scroll"] + page_actions = [v for v in actions if v.get("command") == "nav.page"] + noops = sum(v.get("positionChanged") is not True for v in page_actions) + finite = lambda values: all(type(v) in (int, float) and math.isfinite(v) and v >= 0 for v in values) + checks = { + "schema": result.get("schemaVersion") == 2, + "harness_success": result.get("success") is True and result.get("format") == "pdf", + "requested_workload": result.get("requestedOpenTimings") == RUNS and result.get("requestedCloseCycles") == CLOSE_CYCLES, + "actual_three_opens": result.get("openCount") == RUNS and len(opens) == RUNS and len(result.get("openRecords", [])) == RUNS, + "actual_two_uniform_closes_and_final_close": len(cycles) == RUNS and len(uniform) == CLOSE_CYCLES and len(final) == 1, + "close_releases_document": bool(cycles) and all(v.get("stateEmpty") is True and v.get("cacheBytesAfterClose") == 0 for v in cycles), + "hundred_page_scroll_operations": len(actions) == OPERATIONS and all(v.get("series") == "page-scroll" for v in actions), + "page_navigation_moved": len(page_actions) == 80 and noops == 0, + "operation_sequence": [v.get("index") for v in actions] == list(range(OPERATIONS)) + and all(v.get("command") == ("zoom.in" if i % 10 == 8 else "zoom.fit_width" if i % 10 == 9 else "nav.page") for i, v in enumerate(actions)), + "finite_timings": finite(opens) and all(finite([v.get("firstResponseFrameMs"), v.get("finalQualityFrameMs")]) + and v["finalQualityFrameMs"] >= v["firstResponseFrameMs"] for v in actions), + "full_resolution_conditions": type(result.get("maximumMemoryPressureLevel")) is int and result["maximumMemoryPressureLevel"] == 0, + "source_hash": result.get("documentSha256") == source_hash, + "continuous_scroll_disabled": result.get("continuousScroll", {}).get("requestedInputs") == 0 + and result.get("continuousScroll", {}).get("dispatchedInputs") == 0, + "tile_cache_within_configured_budget": type(result.get("tileCachePeakChargedBytes")) is int + and type(result.get("tileCacheBudgetBytes")) is int + and 0 <= result["tileCachePeakChargedBytes"] <= result["tileCacheBudgetBytes"], + } + summary = {"open": stats(opens) if finite(opens) else {"n": 0}, "excludedNoopPageNavigation": noops} + if checks["finite_timings"]: + summary.update({"firstResponse": stats([v["firstResponseFrameMs"] for v in actions]), + "finalQuality": stats([v["finalQualityFrameMs"] for v in actions])}) + for cached in (True, False): + summary["cachedNavigation" if cached else "uncachedNavigation"] = stats( + [v["finalQualityFrameMs"] for v in page_actions if v.get("cacheHit") is cached and v.get("positionChanged") is True]) + return checks, summary + + +def rss(pid): + try: + match = re.search(r"VmRSS:\s+(\d+)", Path(f"/proc/{pid}/status").read_text()) + return int(match[1]) * 1024 if match else 0 + except (OSError, ProcessLookupError): + return 0 + + +def group_rss(pid, seen=None): + seen = set() if seen is None else seen + if pid in seen: + return 0 + seen.add(pid) + value = rss(pid) + try: + children = set() + for task in Path(f"/proc/{pid}/task").iterdir(): + try: + children.update((task / "children").read_text().split()) + except OSError: + pass + except OSError: + children = [] + return value + sum(group_rss(int(child), seen) for child in children) + + +def run_observed(command, env, output, name, timeout=180): + started = time.monotonic() + app_peak = group_peak = samples = 0 + with (output / f"{name}.log").open("wb") as log: + process = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + while process.poll() is None: + app_peak = max(app_peak, rss(process.pid)) + group_peak = max(group_peak, group_rss(process.pid)) + samples += 1 + if time.monotonic() - started > timeout: + raise TimeoutError(f"{name} exceeded {timeout} seconds") + time.sleep(.05) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=3) + return {"command": command, "exitCode": process.returncode, "elapsedSeconds": time.monotonic() - started, + "sampleIntervalSeconds": .05, "samples": samples, + "sampledApplicationRssPeakBytes": app_peak, "sampledProcessGroupRssPeakBytes": group_peak, + "rssMethod": "Sum of /proc VmRSS for app and child tree; includes shared pages per process, excludes Xvfb"} + + +def stats(values): + if not values: + return {"n": 0} + ordered = sorted(values) + return {"n": len(values), "p50Ms": statistics.median(values), + "p95Ms": ordered[max(0, math.ceil(.95 * len(values)) - 1)], "maxMs": ordered[-1]} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, default=ROOT / "build/docview") + parser.add_argument("--output", type=Path, default=ROOT / "tests/results/stress-final") + parser.add_argument("--work-directory", type=Path, default=ROOT / "build/stress") + parser.add_argument("--xvfb", action="store_true") + args = parser.parse_args(argv) + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + clear_previous_evidence(output) + binary = args.binary.resolve() + report = {"schemaVersion": 2, "success": False, "runsRequested": RUNS, "operationsRequested": OPERATIONS, + "closeCyclesRequested": CLOSE_CYCLES, "continuousScrollInputsRequested": 0, + "sourceRemoved": False, "method": "OS file cache retained after generation; synthetic raster content"} + source = None + display = None + started = time.monotonic() + try: + args.work_directory.mkdir(parents=True, exist_ok=True) + report["executableSha256"] = executable_hashes(binary) + report["binarySha256"] = report["executableSha256"]["docview"] + report["runnerSha256"] = file_hash(__file__) + report["generatorSha256"] = file_hash(Path(__file__).with_name("generate_stress_pdf.py")) + if os.environ.get("QTWEBENGINE_DISABLE_SANDBOX", "0") != "0" or any( + flag in os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "") + for flag in ("--no-sandbox", "--disable-seccomp-filter-sandbox", "--disable-setuid-sandbox")): + raise RuntimeError("Sandbox-disabling environment is not permitted") + with tempfile.TemporaryDirectory(prefix="scan-workload-", dir=args.work_directory) as workload: + source = Path(workload) / "scan-5000.pdf" + print("Generating 5000 pages / 1280 uncompressed RGB images after free-disk check", flush=True) + manifest = generate(source) + report["source"] = {key: value for key, value in manifest.items() if key != "images"} + report["sourceValidation"] = validate_source(source, manifest) + (output / "source-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(f"Generated {manifest['sizeBytes']} bytes; SHA-256 {manifest['sha256']}", flush=True) + info = subprocess.run(["/usr/bin/pdfinfo", str(source)], check=True, capture_output=True, text=True, timeout=30) + (output / "pdfinfo.txt").write_text(info.stdout) + if not re.search(r"Pages:\s+5000\b", info.stdout): + raise AssertionError("Independent PDF page count is not 5000") + report["independentMarkerChecks"] = [] + for physical_page in (1, 1280, 5000): + text = subprocess.run(["/usr/bin/pdftotext", "-f", str(physical_page), "-l", str(physical_page), str(source), "-"], + check=True, capture_output=True, text=True, timeout=30).stdout + expected = f"DOCVIEW STRESS PAGE {physical_page:05d} OF 05000 IMAGE {(physical_page - 1) % 1280:04d}" + if expected not in text: + raise AssertionError(f"Source marker absent on page {physical_page}") + report["independentMarkerChecks"].append({"physicalPage": physical_page, "marker": expected, "found": True}) + with tempfile.TemporaryDirectory(prefix="docview-stress-state-") as temporary: + directory = Path(temporary) + config = directory / "config.toml" + config.write_text("schema_version=1\n[privacy]\nremember_position=false\nrecent_documents=0\n") + env = os.environ.copy() + for name, leaf in (("XDG_CONFIG_HOME", "config"), ("XDG_STATE_HOME", "state"), ("XDG_CACHE_HOME", "cache")): + env[name] = str(directory / leaf) + env["QT_QUICK_BACKEND"] = "software" + env["QT_QPA_PLATFORM"] = "xcb" + env["LIBGL_ALWAYS_SOFTWARE"] = "1" + if args.xvfb: + display = subprocess.Popen(["/usr/bin/Xvfb", "-displayfd", "1", "-screen", "0", "1400x900x24", "-nolisten", "tcp"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + if not select.select([display.stdout], [], [], 5)[0]: + raise RuntimeError("Xvfb did not allocate a display") + number = display.stdout.readline().strip() + if not number.isdigit(): + raise RuntimeError("Xvfb returned an invalid display") + env["DISPLAY"] = ":" + number + benchmark = output / "benchmark.json" + print("Running actual frame benchmark: 3 opens and 100 keyboard operations", flush=True) + report["benchmarkProcess"] = run_observed( + [str(binary), "--config", str(config), "--benchmark-output", str(benchmark), + "--benchmark-runs", str(RUNS), "--benchmark-operations", str(OPERATIONS), + "--benchmark-close-cycles", str(CLOSE_CYCLES), "--benchmark-scroll-steps", "0", str(source)], env, output, "benchmark") + if report["benchmarkProcess"]["exitCode"] != 0: + raise RuntimeError("DocView stress benchmark failed") + result = json.loads(benchmark.read_text()) + report["benchmarkEvidence"] = result + report["measurementChecks"], report["latency"] = summarize_benchmark(result, manifest["sha256"]) + if not all(report["measurementChecks"].values()): + raise AssertionError("Benchmark validation failed: " + ", ".join(k for k, v in report["measurementChecks"].items() if not v)) + smoke = output / "last-page" + print("Checking direct physical-page 5000 open and capturing the actual window", flush=True) + report["lastPageProcess"] = run_observed( + [str(binary), "--config", str(config), "--page", "5000", "--smoke-output", str(smoke), str(source)], + env, output, "last-page", timeout=30) + state = (smoke / "state.txt").read_text().splitlines() + if report["lastPageProcess"]["exitCode"] != 0 or state[:2] != ["Ready", "pdf"] or not (smoke / "screen.png").is_file(): + raise AssertionError("Last-page smoke did not reach Ready PDF and save its image") + report["lastPageSmoke"] = {"state": state, "screenshot": str(smoke / "screen.png"), + "runtime": json.loads((smoke / "runtime.json").read_text()), + "requestedPhysicalPage": 5000, "visualPageMarkerReview": "pending"} + report["originalSha256After"] = file_hash(source) + report["originalUnchanged"] = report["originalSha256After"] == manifest["sha256"] == result["documentSha256"] + report["measurementChecks"]["source_unchanged_after_smoke"] = report["originalUnchanged"] + if not report["originalUnchanged"]: + raise AssertionError("Source changed during benchmark or smoke") + report["success"] = True + except Exception as error: + report["error"] = str(error) + finally: + if display is not None: + display.terminate() + try: + display.wait(timeout=3) + except subprocess.TimeoutExpired: + display.kill() + display.wait(timeout=3) + report["sourceRemoved"] = source is not None and not source.exists() + if "executableSha256" in report: + try: + report["executableSha256After"] = executable_hashes(binary) + report["executablesUnchanged"] = report["executableSha256"] == report["executableSha256After"] + except OSError: + report["executablesUnchanged"] = False + report.setdefault("measurementChecks", {})["executables_unchanged"] = report["executablesUnchanged"] + report["success"] = report["success"] and report["executablesUnchanged"] + report["success"] = report["success"] and report["sourceRemoved"] + report["elapsedSeconds"] = time.monotonic() - started + (output / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + print(json.dumps({key: value for key, value in report.items() if key not in ("benchmarkEvidence", "source")}, ensure_ascii=False, indent=2), flush=True) + return 0 if report["success"] and report["sourceRemoved"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/run_wayland_validation.py b/tests/run_wayland_validation.py new file mode 100644 index 0000000..f4275ba --- /dev/null +++ b/tests/run_wayland_validation.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Run DocView on a private headless Wayland compositor with its sandboxes intact. + +Software and explicitly selected OpenGL paths use an offscreen compositor. +This is not a physical display, input-device, or reference-hardware acceptance. +No user desktop socket, DocView configuration/history, or D-Bus session is used. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shlex +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +RUNNER_SOURCE = Path(__file__).read_bytes() + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def graphics_evidence(output, runs): + compositor_log = (output / 'compositor.log').read_text(errors='replace') + compositor = sorted(set(re.findall(r'GL renderer: ([^\r\n]+)', compositor_log))) + groups = {} + for name in runs: + log = (output / (name + '.log')).read_text(errors='replace') + renderers = sorted(set(re.findall(r'OpenGL VENDOR: ([^\r\n]+)', log))) + groups[name] = {'rendererDescriptions': renderers, + 'openGLWindowCreations': len(re.findall(r'Creating QRhi with backend OpenGL for window', log))} + software = any(marker in text.lower() for text in compositor + [v for row in groups.values() for v in row['rendererDescriptions']] + for marker in ('llvmpipe', 'softpipe', 'swiftshader', 'software rasterizer')) + return {'compositorRenderers': compositor, 'groups': groups, + 'softwareRendererDetected': software, + 'nonSoftwareOpenGLObserved': bool(compositor) and all(row['rendererDescriptions'] for row in groups.values()) and not software, + 'scope': 'Renderer strings from this private compositor and tested Qt processes; no physical scanout or reference-machine evidence'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build-dir', type=Path, default=ROOT / 'build') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--mode', choices=['smoke', 'gui'], default='smoke') + parser.add_argument('--smoke-binary', type=Path, + help='Installed launcher to use for smoke; --build-dir still identifies the actual three binaries') + parser.add_argument('--renderer', choices=['software', 'opengl'], default='software') + parser.add_argument('--render-node', type=Path, + help='Explicit /dev/dri/renderD* device; required for OpenGL') + parser.add_argument('--output-size', default='1920x1080', + help='Headless output dimensions; default matches the X11 test desktop') + parser.add_argument('--app-test', action='append', default=[], + help='QtTest app function[:data-tag] to run alone; repeatable; requires --mode gui') + parser.add_argument('--suite', action='append', choices=['app', 'web', 'pdf_canvas'], default=[], + help='Run selected GUI suites; defaults to all three') + parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) + args = parser.parse_args() + if args.smoke_binary and args.mode != 'smoke': + parser.error('--smoke-binary requires --mode smoke') + if args.renderer == 'opengl': + if args.render_node is None or not re.fullmatch(r'/dev/dri/renderD[0-9]+', str(args.render_node)): + parser.error('--renderer opengl requires an explicit /dev/dri/renderD* device') + if (args.render_node.is_symlink() or not args.render_node.exists() or + not stat.S_ISCHR(args.render_node.stat().st_mode)): + parser.error('--render-node must be an existing render character device') + elif args.render_node is not None: + parser.error('--render-node requires --renderer opengl') + try: + dimensions = [int(value) for value in args.output_size.split('x')] + if len(dimensions) != 2 or any(value < 320 or value > 4096 for value in dimensions): + raise ValueError() + except ValueError: + parser.error('--output-size must be WIDTHxHEIGHT with each dimension between 320 and 4096') + if args.app_test and args.mode != 'gui': + parser.error('--app-test requires --mode gui') + if args.suite and (args.mode != 'gui' or args.app_test): + parser.error('--suite requires --mode gui and cannot be combined with --app-test') + for program in ['sway', 'swaymsg', 'dbus-run-session']: + if not shutil.which(program): + parser.error('Required program missing: ' + program) + flags = shlex.split(os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '')) + if os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or any( + flag.split('=', 1)[0] in ['--no-sandbox', '--disable-setuid-sandbox', + '--disable-seccomp-filter-sandbox'] for flag in flags): + parser.error('Sandbox-disabling settings are not allowed') + if not args.private_session: + if args.output.exists(): + parser.error('--output must be a new directory to preserve earlier evidence') + env = os.environ.copy() + for name in ['DISPLAY', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', 'SWAYSOCK', + 'I3SOCK', 'DBUS_SESSION_BUS_ADDRESS', 'QT_QPA_PLATFORMTHEME', + 'QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH']: + env.pop(name, None) + # A separate bus without activation directories cannot launch host + # portal services using the user's interactive-session configuration. + # Native portals and physical input are outside this headless test. + with tempfile.TemporaryDirectory(prefix='docview-wayland-bus-') as directory: + private = Path(directory) + private.chmod(0o700) + config = private / 'bus.conf' + config.write_text('sessionEXTERNAL' + f'unix:tmpdir={private}' + '' + '') + for name, leaf in [('XDG_CONFIG_HOME', 'config'), ('XDG_STATE_HOME', 'state'), + ('XDG_CACHE_HOME', 'cache'), ('XDG_DATA_HOME', 'data')]: + env[name] = str(private / leaf) + env['XDG_RUNTIME_DIR'] = str(private) + return subprocess.run(['dbus-run-session', '--config-file', str(config), '--', + sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], + '--private-session'], env=env).returncode + output, build = args.output.resolve(), args.build_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + (output / 'runner-at-start.py').write_bytes(RUNNER_SOURCE) + binaries = ['docview', 'docview-pdf-worker', 'docview-archive-worker'] + if args.mode == 'gui': + binaries += ['test_app', 'test_web_qml', 'test_pdf_canvas'] + identities = {name: sha(build / name) for name in binaries} + launcher = args.smoke_binary.resolve(strict=True) if args.smoke_binary else build / 'docview' + launcher_hash = sha(launcher) + record = {'success': False, 'mode': args.mode, 'platform': platform.platform(), + 'swayVersion': subprocess.check_output(['sway', '--version'], text=True).strip(), + 'executables': identities, 'runs': [], 'scope': + 'Private headless Sway/Wayland; no physical display/IME or reference-hardware acceptance', + 'requestedRenderer': args.renderer, + 'renderNode': str(args.render_node) if args.render_node else None, + 'applicationSandboxesEnabled': True, 'usesUserDesktop': False, + 'selectedAppTests': args.app_test, + 'selectedSuites': args.suite, + 'sessionBusServiceActivation': 'disabled; native desktop portals are not tested'} + record['smokeLauncher'] = {'path': str(launcher), 'sha256': launcher_hash} if args.mode == 'smoke' else None + compositor = None + runtime_directory = tempfile.TemporaryDirectory(prefix='docview-wayland-') + started = time.monotonic() + try: + # Keep the socket directory alive until the compositor has been reaped. + runtime = Path(runtime_directory.name) + runtime.chmod(0o700) + config = runtime / 'sway.conf' + config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\n' + f'output HEADLESS-1 mode {dimensions[0]}x{dimensions[1]}\n' + 'for_window [app_id=".*"] floating enable\n' + 'default_border none\ndefault_floating_border none\n' + 'focus_follows_mouse no\nmouse_warping none\n') + (output / 'sway.conf').write_bytes(config.read_bytes()) + env = os.environ.copy() + for name in ['WLR_DRM_DEVICES', 'WLR_RENDER_DRM_DEVICE', 'WLR_RENDERER_ALLOW_SOFTWARE', + 'QT_SCREEN_SCALE_FACTORS', 'QT_AUTO_SCREEN_SCALE_FACTOR', 'QT_DEVICE_PIXEL_RATIO', + 'QT_OPENGL', 'QSG_RHI_BACKEND', 'QSG_RHI_PREFER_SOFTWARE_RENDERER', + 'LIBGL_ALWAYS_SOFTWARE', 'MESA_LOADER_DRIVER_OVERRIDE', 'GALLIUM_DRIVER', 'DRI_PRIME']: + env.pop(name, None) + env.update({'XDG_RUNTIME_DIR': str(runtime), 'XDG_CONFIG_HOME': str(runtime / 'config'), + 'XDG_STATE_HOME': str(runtime / 'state'), 'XDG_CACHE_HOME': str(runtime / 'cache'), + 'XDG_CURRENT_DESKTOP': 'sway', 'XDG_SESSION_TYPE': 'wayland', + 'WLR_BACKENDS': 'headless', 'WLR_RENDERER': 'pixman', 'WLR_HEADLESS_OUTPUTS': '1', + 'DOCVIEW_PRIVATE_WAYLAND_TEST': '1', + 'QT_QPA_PLATFORM': 'wayland', 'QT_QUICK_BACKEND': 'software', + 'QTWEBENGINE_CHROMIUM_FLAGS': '--disable-gpu', 'QT_USE_PORTAL': '0', + 'QT_SCALE_FACTOR': '1', 'QT_FONT_DPI': '96', 'QT_FORCE_STDERR_LOGGING':'1'}) + if args.renderer == 'opengl': + env.pop('QT_QUICK_BACKEND', None) + env.update({'WLR_RENDERER':'gles2', 'WLR_RENDER_DRM_DEVICE':str(args.render_node), + 'QSG_RHI_BACKEND':'opengl', 'QSG_INFO':'1', 'QTWEBENGINE_CHROMIUM_FLAGS':'', + 'QT_LOGGING_RULES':'qt.scenegraph.general=true;qt.rhi.general=true;qt.webenginecontext.debug=true'}) + record['requestedGraphicsEnvironment'] = {name:env.get(name, '') for name in + ['WLR_BACKENDS','WLR_RENDERER','WLR_RENDER_DRM_DEVICE','QT_QUICK_BACKEND', + 'QSG_RHI_BACKEND','QTWEBENGINE_CHROMIUM_FLAGS','QT_SCALE_FACTOR']} + with (output / 'compositor.log').open('wb') as log: + compositor = subprocess.Popen(['sway', *(['-d'] if args.renderer == 'opengl' else []), '-c', str(config)], env=env, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + deadline = time.monotonic() + 10 + sockets = [] + while time.monotonic() < deadline and compositor.poll() is None: + sockets = [p for p in runtime.glob('wayland-*') if p.is_socket()] + ipc = list(runtime.glob('sway-ipc.*.sock')) + if sockets and ipc: + break + time.sleep(0.05) + if not sockets or compositor.poll() is not None: + raise RuntimeError('Private Wayland compositor did not start; see compositor.log') + env['WAYLAND_DISPLAY'] = sockets[0].name + env['SWAYSOCK'] = str(ipc[0]) + outputs = subprocess.check_output(['swaymsg', '-s', str(ipc[0]), '-t', 'get_outputs'], env=env) + record['outputs'] = json.loads(outputs) + commands = [('smoke', [sys.executable, str(ROOT / 'tests/smoke.py'), '--binary', str(launcher), + '--output', str(output / 'smoke')], 210)] if args.mode == 'smoke' else [ + ('app', [str(build / 'test_app')], 150), + ('web', [str(build / 'test_web_qml')], 150), + ('pdf_canvas', [str(build / 'test_pdf_canvas')], 90)] + if args.app_test: + commands = [('app', [str(build / 'test_app'), *args.app_test], 150)] + elif args.suite: + commands = [row for row in commands if row[0] in args.suite] + for name, command, timeout in commands: + print('Wayland: ' + name, flush=True) + before = time.monotonic() + with (output / (name + '.log')).open('wb') as test_log: + run = subprocess.Popen(command, cwd=ROOT, env=env, stdout=test_log, + stderr=subprocess.STDOUT, start_new_session=True) + timed_out = False + try: + run.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + finally: + # Only terminate this test's dedicated process group. + # This also collects children if a runner times out. + try: + os.killpg(run.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + run.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(run.pid, signal.SIGKILL) + run.wait(timeout=5) + record['runs'].append({'name': name, 'command': command, 'exitCode': run.returncode, + 'timedOut': timed_out, + 'seconds': time.monotonic() - before}) + failed = [row['name'] for row in record['runs'] if row['exitCode'] or row['timedOut']] + if failed: + raise RuntimeError(', '.join(failed) + ' failed; see preserved logs') + if args.mode == 'smoke': + cases = json.loads((output / 'smoke/results.json').read_text()) + if len(cases) != 6 or any(row['runtime']['qtPlatform'] != 'wayland' for row in cases): + raise RuntimeError('Smoke evidence is incomplete or did not use Wayland') + record['smokeCases'] = cases + if args.renderer == 'opengl': + record['graphicsEvidence'] = graphics_evidence(output, [r['name'] for r in record['runs']]) + if not record['graphicsEvidence']['nonSoftwareOpenGLObserved']: + raise RuntimeError('OpenGL hardware path was not established by the preserved renderer logs') + record['binariesUnchanged'] = identities == {name: sha(build / name) for name in binaries} + record['success'] = record['binariesUnchanged'] + except Exception as error: + record['failure'] = str(error) + finally: + if compositor is not None: + if compositor.poll() is None: + os.killpg(compositor.pid, signal.SIGTERM) + try: + compositor.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(compositor.pid, signal.SIGKILL) + compositor.wait(timeout=5) + record['compositorReaped'] = compositor.poll() is not None + runtime_directory.cleanup() + record['binariesUnchanged'] = identities == {name: sha(build / name) for name in binaries} + if not record['binariesUnchanged']: + record['success'] = False + if args.mode == 'smoke': + record['smokeLauncherUnchanged'] = launcher_hash == sha(launcher) + record['success'] = record['success'] and record['smokeLauncherUnchanged'] + record['elapsedSeconds'] = time.monotonic() - started + record['runnerSha256'] = hashlib.sha256(RUNNER_SOURCE).hexdigest() + record['runnerFileUnchanged'] = RUNNER_SOURCE == Path(__file__).read_bytes() + (output / 'report.json').write_text(json.dumps(record, ensure_ascii=False, indent=2) + '\n') + print(json.dumps({'success': record['success'], 'output': str(output), 'failure': record.get('failure')})) + return 0 if record['success'] else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..a61264b --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Capture installed-application smoke evidence on a dedicated display.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser() +parser.add_argument('--binary', type=Path, default=root / 'build/install/bin/docview') +parser.add_argument('--output', type=Path, default=root / 'tests/results/smoke') +args = parser.parse_args() +args.output.mkdir(parents=True, exist_ok=True) +# A failed launch must not leave a previous run's successful aggregate behind. +for name in ('results.json', 'package-smoke.json'): + (args.output / name).unlink(missing_ok=True) +with args.binary.open('rb') as binary: + binary_hash = hashlib.file_digest(binary, 'sha256').hexdigest() +cases = [ + ('pdf-dark-100', 'pdf/extended/japanese-layout.pdf', 'pdf', 'dark', 1), + ('pdf-fonts-dark-100', 'pdf/fonts/unembedded-japanese.pdf', 'pdf', 'dark', 1), + ('pdf-light-200', 'pdf/extended/japanese-layout.pdf', 'pdf', 'light', 2), + ('html-dark-100', 'web/html/index.html', 'html', 'dark', 1), + ('zip-dark-100', 'web/html-book.zip', 'htmlzip', 'dark', 1), + ('epub-dark-100', 'web/reflow-rtl.epub', 'epub', 'dark', 1), +] +results = [] +with tempfile.TemporaryDirectory(prefix='docview-smoke-') as temporary: + for name, fixture, kind, theme, scale in cases: + private = Path(temporary) / name + private.mkdir() + config = private / 'config.toml' + config.write_text(f'schema_version=1\n[ui]\ntheme="{theme}"\npages_visible=true\n[privacy]\nremember_position=false\nrecent_documents=0\n', encoding='utf-8') + destination = (args.output / name).resolve() + env = os.environ.copy() + env.update({key: str(private / leaf) for key, leaf in [('XDG_CONFIG_HOME','config'), ('XDG_STATE_HOME','state'), ('XDG_CACHE_HOME','cache')]}) + env['QT_SCALE_FACTOR'] = str(scale) + subprocess.run([str(args.binary.resolve()), '--config', str(config), '--smoke-output', str(destination), str(root / 'tests/fixtures' / fixture)], env=env, check=True, timeout=30) + state = (destination / 'state.txt').read_text(encoding='utf-8').splitlines() + runtime = json.loads((destination / 'runtime.json').read_text(encoding='utf-8')) + if (runtime['windowWidth'] > runtime['screenWidth'] or + runtime['windowHeight'] > runtime['screenHeight']): + raise RuntimeError(f'{name}: initial window exceeds the selected logical screen: {runtime}') + if state[:2] != ['Ready', kind] or not (destination / 'screen.png').is_file(): + raise RuntimeError(f'{name}: unexpected state {state}') + results.append({'case': name, 'state': state[0], 'format': kind, 'scale': scale, 'theme': theme, + 'image': name + '/screen.png', 'binarySha256': binary_hash, 'runtime': runtime}) +(args.output / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') +print(json.dumps(results, ensure_ascii=False)) diff --git a/tests/smoke_linux_package.py b/tests/smoke_linux_package.py new file mode 100644 index 0000000..b0741f2 --- /dev/null +++ b/tests/smoke_linux_package.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Verify, relocate and run a locally generated development package with sandboxing intact.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import shlex +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from collect_linux_dependencies import sha256 +from package_linux_development import verify_and_extract + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + # Invalidate earlier success even if environment/archive validation fails. + for name in ('results.json', 'package-smoke.json'): + (args.output / name).unlink(missing_ok=True) + flags = shlex.split(os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '')) + if (os.environ.get('QTWEBENGINE_DISABLE_SANDBOX', '') not in ('', '0') or + any(flag.split('=', 1)[0] in ('--no-sandbox', '--disable-setuid-sandbox', + '--disable-seccomp-filter-sandbox') for flag in flags)): + raise SystemExit('Refusing package smoke with Chromium sandbox disabled') + archive = args.archive.resolve(strict=True) + with tempfile.TemporaryDirectory(prefix='docview-relocated-') as directory: + prefix = verify_and_extract(archive, Path(directory)) + subprocess.run([sys.executable, str(ROOT / 'tests/smoke.py'), '--binary', str(prefix / 'bin/docview'), + '--output', str(args.output.resolve())], check=True, timeout=210) + report = {'archive': archive.name, 'archiveSha256': sha256(archive), + 'payloadVerified': True, 'relocatedExtraction': True, + 'scope': 'Same Arch development host; system Qt and other dependencies; not clean OS acceptance', + 'chromiumSandboxDisabledByTest': False, 'workerSandboxDisabledByTest': False, + 'cases': json.loads((args.output / 'results.json').read_text())} + report['temporaryExtractionRemoved'] = not Path(directory).exists() + (args.output / 'package-smoke.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + print('Verified relocated package: 6 GUI smoke conditions; temporary extraction removed') + + +if __name__ == '__main__': + main() diff --git a/tests/source_archives/README.md b/tests/source_archives/README.md new file mode 100644 index 0000000..c10bcad --- /dev/null +++ b/tests/source_archives/README.md @@ -0,0 +1,47 @@ +# 固定版ソースアーカイブの取得・照合 + +Qt 6.11.2とtoml++ 3.4.0の公開ソースを取得し、固定した公開hashと照合する。ダウンロードしたビルドスクリプトは実行しない。Qtでは通常ファイルを専用の新規treeへ保存し、path、件数、単体・総容量に上限を設ける。リンクを辿らず、元アーカイブのpath・mode・全通常ファイルのSHA-256をJSONLへ記録する。ファイルの実行属性は設定しないため、抽出treeをそのままビルド可能なcheckoutとは呼ばない。 + +```sh +python3 tests/source_archives/fetch.py \ + --output tests/results/source-archives/new-download \ + --cache .deps/new-source-archives +python3 tests/source_archives/inspect_qt.py \ + --archive .deps/new-source-archives/qt-everywhere-src-6.11.2.tar.xz \ + --tree .deps/new-qt-regular-files \ + --output tests/results/source-archives/new-qt-inspection +python3 tests/source_archives/verify_toml.py \ + --output tests/results/source-archives/new-toml-check +python3 tests/source_archives/check_arch_patches.py \ + --output tests/results/source-archives/new-patch-check +``` + +各出力先は新規にする。`verify_toml.py`は既定の`.deps/source-archives`を、patch確認は保存済み`qt-inspection/report.json`のtreeを読む。既存の資料を更新する場合は、対応するpinと参照箇所を明示的に変更する。 + +Qtのpinは[公式mirror metadata](https://download.qt.io/archive/qt/6.11/6.11.2/single/qt-everywhere-src-6.11.2.tar.xz.mirrorlist)、toml++は保存済みの使用版Arch recipeによる。Qtのmirrorページには要求元IPが含まれることがあるため、全文は保存せず、応答hashと一致した公開checksumを記録する。 + +[今回の実行結果](../results/source-archives/README.md)。ソース取得、使用版との部分的な照合、実ビルド構成に対する告知の完全性、具体的配布条件の判定は区別する。 + +## PDFiumの固定Gitソース + +保存済みDEPSの固定値からLinux / Windows x64 minimalのGit取得対象を選び、PDFium本体と27依存を取得する。3並列、fetch上限900秒、Git hookとcredential helper無効で、DEPSや取得ソースのコードは実行しない。Git objectを検査してから通常ファイル・リンクの全blobをアーカイブ化し、読み戻して照合する。アーカイブに保存したリンクはファイルシステムへ展開しない。 + +```sh +python3 tests/source_archives/collect_pdfium.py \ + --output tests/results/source-archives/new-pdfium-git \ + --cache .deps/new-pdfium-git +python3 tests/source_archives/check_pdfium.py \ + --collection tests/results/source-archives/new-pdfium-git \ + --output tests/results/source-archives/new-pdfium-check +python3 tests/source_archives/collect_pdfium_gitlink.py \ + --output tests/results/source-archives/new-pdfium-gitlink \ + --cache .deps/new-pdfium-gitlink +``` + +出力先とアーカイブのcacheは新規にする。rootのbare repositoryのみ、存在すれば最初の取得確認用cacheを再利用して固定commit・treeを再検査する。gitlink補足スクリプトは今回の既定`pdfium-git/report.json`と固定したFreeType参照を読み、未確認の追加参照があれば停止する。 + +checkスクリプトはproviderのパッチを出力先内の専用コピーへ適用する。Linux3件とWindows4件の差分、既存Linux配布物の全24公開ヘッダー・15告知を比較し、本体・ワーカーを変更しない。[PDFiumの実行結果と範囲](../results/source-archives/PDFIUM.md) + +## Ubuntu用Qt SDKの告知 + +`collect_qt_sdk_notices.py --output <新規directory>`は、保存済みの公式Qt SDK archive、Ubuntu実行時台帳とSDK SBOM、検証済みQt sourceからChromiumの告知本文を抽出する。全archiveをストリームで検査し、SBOMの67ファイル・既存実行時台帳の83ファイル・告知129entryを照合する。元SBOMの参照ID不整合や本文のない項目、生成器のskipを保持し、網羅性の判定とは分ける。[実行結果](../results/source-archives/QT-SDK-NOTICES.md) diff --git a/tests/source_archives/check_arch_patches.py b/tests/source_archives/check_arch_patches.py new file mode 100644 index 0000000..dbce1e0 --- /dev/null +++ b/tests/source_archives/check_arch_patches.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Check recorded Arch patches on selected pristine release sources, without building.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--output', type=Path, required=True) + a = p.parse_args(); output = a.output.resolve() + if not output.is_relative_to(ROOT): + p.error('Use an output directory inside the workspace') + inspection = ROOT / 'tests/results/source-archives/qt-inspection' + record = json.loads((inspection / 'report.json').read_text()) + if not record['success'] or sha(inspection / 'files.jsonl') != record['inventorySha256']: + raise SystemExit('A verified source inventory is required') + inventory = {row['path']: row for line in (inspection / 'files.jsonl').open() if (row := json.loads(line))} + source = ROOT / record['sourceTree'] + evidence = ROOT / 'tests/results/source-correspondence/arch' + patches = [ + ('qtbase', evidence / 'packages/qt6-base/qt6-base-cflags.patch'), + ('qtbase', evidence / 'packages/qt6-base/qt6-base-nostrip.patch'), + ('qtbase', evidence / 'upstream-metadata/qtbase-e80e3f0.patch'), + ('qtdeclarative', evidence / 'upstream-metadata/qtdeclarative-2efb7c6.patch')] + output.mkdir(parents=True, exist_ok=False) + inputs, steps = {}, [] + for module, patch in patches: + for line in patch.read_text().splitlines(): + if not line.startswith('+++ b/'): + continue + name = line[len('+++ b/'):].split('\t', 1)[0] + path = PurePosixPath(name) + if path.is_absolute() or '..' in path.parts: + raise ValueError('Unsafe patch target') + relative = module + '/' + name + target = output / 'tree' / relative + target.parent.mkdir(parents=True, exist_ok=True) + if relative in inputs: + continue + before = None + if (source / relative).exists(): + before = sha(source / relative) + if before != inventory[relative]['sha256']: + raise ValueError('Selected release file changed') + shutil.copyfile(source / relative, target) + inputs[relative] = before + success = True + for module, patch in patches: + # The two local Arch patches use GNU patch's default fuzz tolerance. + # Keep exact context for the separately recorded upstream changes. + fuzz = 2 if patch.name.startswith('qt6-base-') else 0 + step = {'module': module, 'patch': str(patch.relative_to(ROOT)), 'patchSha256': sha(patch), 'fuzzLimit': fuzz} + for label, extra in [('dryRun', ['--dry-run']), ('apply', [])]: + command = ['patch', '--batch', '--forward', '--fuzz=' + str(fuzz), '-p1', '-d', str(output / 'tree' / module), '-i', str(patch), *extra] + run = subprocess.run(command, capture_output=True, text=True, timeout=20) + step[label] = {'exitCode': run.returncode, 'stdout': run.stdout, 'stderr': run.stderr} + if run.returncode: + success = False; break + steps.append(step) + if not success: + break + after = {name: sha(output / 'tree' / name) if (output / 'tree' / name).is_file() else None for name in inputs} + report = {'success': success, 'sourceArchiveSha256': record['archiveSha256'], + 'beforeSha256': inputs, 'afterSha256': after, 'steps': steps, + 'pristineFilesUnchanged': all(sha(source / name) == digest for name, digest in inputs.items() if digest), + 'scope': 'Recorded Arch patches checked on a private selected-file copy with explicit tolerances; no upstream build scripts run and no resulting binaries rebuilt'} + report['installedMkspecComparisons'] = { + name: sha(output / 'tree/qtbase/mkspecs/common' / name) == sha(Path('/usr/lib/qt6/mkspecs/common') / name) + for name in ['g++-unix.conf', 'gcc-base.conf']} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'success': success, 'patches': len(steps), 'targetFiles': len(inputs), 'pristineFilesUnchanged': report['pristineFilesUnchanged']})) + return 0 if success else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/source_archives/check_pdfium.py b/tests/source_archives/check_pdfium.py new file mode 100644 index 0000000..d021399 --- /dev/null +++ b/tests/source_archives/check_pdfium.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Compare collected PDFium source, provider patches and installed public headers.""" +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path, PurePosixPath +import subprocess + +from collect_pdfium import ROOT, EVIDENCE, sha, run, save + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--collection', type=Path, default=ROOT / 'tests/results/source-archives/pdfium-git') + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if not output.is_relative_to(ROOT): + parser.error('Use an output directory inside the workspace') + collection = args.collection.resolve() + report_path = collection / 'report.json' + collected = json.loads(report_path.read_text()) + if not collected['success']: + raise ValueError('Complete successful selected-Git collection required') + repositories, inventories = {}, {} + for item in collected['repositories']: + if sha(ROOT / item['archive']) != item['archiveSha256'] or sha(ROOT / item['inventory']) != item['inventorySha256']: + raise ValueError('Source archive or inventory changed') + repositories[item['path']] = item + inventories[item['path']] = {e['path']: e for line in (ROOT / item['inventory']).open() if (e := json.loads(line))} + + def blob(repository, path): + item = repositories[repository] + expected = inventories[repository][path] + if expected['mode'] not in {'100644', '100755'}: + raise ValueError('Expected a regular source file') + result = run(ROOT / item['repository'], ['cat-file', 'blob', expected['gitObject']]) + result.check_returncode() + if hashlib.sha256(result.stdout).hexdigest() != expected['sha256']: + raise ValueError('Source blob differs from inventory') + return result.stdout + + def locate(saved): + if saved.startswith('upstream/'): + return '.', saved.removeprefix('upstream/') + if saved.startswith('dependencies/'): + relative = saved.removeprefix('dependencies/') + key = next(k for k in sorted(repositories, key=len, reverse=True) if relative.startswith(k + '/')) + return key, relative[len(key) + 1:] + raise ValueError('Unexpected saved source') + + output.mkdir(parents=True, exist_ok=False) + # Tie earlier Gitiles bytes to the full fetched snapshots, not just filenames. + correspondence = [] + for folder in ('upstream', 'dependencies'): + for sidecar in sorted((EVIDENCE / folder).rglob('*.retrieval.json')): + retrieval = json.loads(sidecar.read_text()) + if retrieval['transform'] != 'base64 decoded Gitiles response': + continue + saved = retrieval['file'] + repository, path = locate(saved) + content = blob(repository, path) + digest = hashlib.sha256(content).hexdigest() + if digest != retrieval['sha256'] or content != (EVIDENCE / saved).read_bytes(): + raise ValueError('Saved Gitiles source differs: ' + saved) + correspondence.append({'saved': saved, 'repository': repository, 'path': path, + 'revision': repositories[repository]['revision'], 'sha256': digest}) + provider = EVIDENCE / 'provider/recipe' + provider_report = json.loads((EVIDENCE / 'report.json').read_text()) + recipe_hashes = {e['file']: e['sha256'] for e in provider_report['recipeFilesVerifiedAgainstGitTree']} + for name in ('steps/03-patch.sh', 'steps/07-stage.sh', 'steps/08-licenses.sh'): + if sha(provider / name) != recipe_hashes[name]: + raise ValueError('Provider recipe changed') + selections = [('patches/shared_library.patch', '.'), ('patches/public_headers.patch', '.'), + ('patches/clang_rt.patch', 'build'), ('patches/win/build.patch', 'build')] + binaries = {name: sha(ROOT / 'build' / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']} + platforms = {} + for platform, count in [('linux', 3), ('win', 4)]: + tree = output / platform / 'tree' + before = {} + # Include the entire public directory so the package header inventory is + # compared in both directions after applying the provider's text patch. + for path, entry in inventories['.'].items(): + if path.startswith('public/') and entry['mode'] in {'100644', '100755'}: + target = tree / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(blob('.', path)) + before[path] = entry['sha256'] + steps = [] + for name, repository in selections[:count]: + patch = provider / name + if sha(patch) != recipe_hashes[name]: + raise ValueError('Provider patch changed') + work = tree if repository == '.' else tree / repository + for line in patch.read_text().splitlines(): + if not line.startswith('+++ b/'): + continue + path = line[len('+++ b/'):].split('\t')[0] + safe = PurePosixPath(path) + if safe.is_absolute() or '..' in safe.parts: + raise ValueError('Unsafe patch path') + target = work / path + target.parent.mkdir(parents=True, exist_ok=True) + full_path = str(target.relative_to(tree)) + if full_path not in before: + target.write_bytes(blob(repository, path)) + before[full_path] = sha(target) + step = {'patch': str(patch.relative_to(ROOT)), 'sha256': sha(patch), 'directory': repository, 'fuzzLimit': 2} + for phase, extra in [('dryRun', ['--dry-run']), ('apply', [])]: + applied = subprocess.run(['patch', '--batch', '--forward', '--verbose', '--fuzz=2', '-p1', + '-d', str(work), '-i', str(patch), *extra], + capture_output=True, text=True, timeout=20) + step[phase] = {'exitCode': applied.returncode, 'stdout': applied.stdout, 'stderr': applied.stderr} + if applied.returncode: + save(output / 'failure.json', step) + raise ValueError('Recorded provider patch failed: ' + name) + steps.append(step) + expected_headers = {str(p.relative_to(tree / 'public')): p for p in (tree / 'public').rglob('*.h')} + packaged_headers = {str(p.relative_to(ROOT / '.deps/pdfium/include')): p + for p in (ROOT / '.deps/pdfium/include').rglob('*.h')} + if expected_headers.keys() != packaged_headers.keys(): + raise ValueError('Public header inventory differs') + header_checks = [{'path': name, 'sourceSha256': sha(path), 'packagedSha256': sha(packaged_headers[name]), + 'match': path.read_bytes() == packaged_headers[name].read_bytes()} + for name, path in sorted(expected_headers.items())] + if not all(e['match'] for e in header_checks): + raise ValueError('Patched public headers differ') + # Provider GNU patch can retain an original file after an offset/fuzzy + # application. Keep the observed .orig file separate from API headers. + extras = [] + for path in (ROOT / '.deps/pdfium/include').rglob('*'): + if not path.is_file() or path.suffix == '.h': + continue + relative = str(path.relative_to(ROOT / '.deps/pdfium/include')) + matches = relative.endswith('.orig') and path.read_bytes() == blob('.', 'public/' + relative[:-5]) + extras.append({'path': relative, 'sha256': sha(path), 'matchesPristineSource': matches}) + if not matches: + raise ValueError('Unexplained packaged extra header file') + platforms[platform] = {'steps': steps, 'beforeSha256': before, + 'afterSha256': {p: sha(tree / p) for p in before}, + 'linuxPackagedHeaderComparisons': header_checks, 'extraPackagedFiles': extras, + 'windowsResourceGenerated': False, 'compiled': False} + spec = importlib.util.spec_from_file_location('notice_correspondence', EVIDENCE / 'check_correspondence.py') + notices = importlib.util.module_from_spec(spec); spec.loader.exec_module(notices) + notice_checks = [] + for packaged, (source, mode) in notices.MAPPING.items(): + if source.startswith('provider/'): + content = (EVIDENCE / source).read_bytes() + if sha(EVIDENCE / source) != recipe_hashes['LICENSE']: + raise ValueError('Provider license changed') + else: + content = blob(*locate(source)) + transformed = notices.transform(content, mode) + actual = ROOT / '.deps/pdfium' / packaged + match = transformed == actual.read_bytes() + notice_checks.append({'path': packaged, 'sha256': sha(actual), 'match': match, 'transform': mode}) + if not match: + raise ValueError('Notice content differs') + after_binaries = {name: sha(ROOT / 'build' / name) for name in binaries} + if binaries != after_binaries: + raise ValueError('Production binaries changed') + report = {'success': True, 'collectionReportSha256': sha(report_path), + 'checkerSha256': sha(Path(__file__)), 'gitilesSourceComparisons': correspondence, + 'platforms': platforms, 'noticeComparisons': notice_checks, + 'productionBinaries': binaries, 'productionBinariesUnchanged': True, + 'scope': 'Source text, selected Linux/Windows provider patches and installed Linux public headers/notices only. ' + 'No build scripts, Windows compiler/resource generation or reproducible binary build executed.'} + save(output / 'report.json', report) + print(json.dumps({'success': True, 'gitilesComparisons': len(correspondence), + 'publicHeaders': len(expected_headers), 'notices': len(notice_checks), + 'patchApplications': sum(len(p['steps']) for p in platforms.values())})) + + +if __name__ == '__main__': + main() diff --git a/tests/source_archives/collect_pdfium.py b/tests/source_archives/collect_pdfium.py new file mode 100644 index 0000000..5d2c9b0 --- /dev/null +++ b/tests/source_archives/collect_pdfium.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Collect fixed PDFium Git snapshots without executing checkout/build scripts.""" +import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import tarfile +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[2] +EVIDENCE = ROOT / 'tests/results/source-correspondence/pdfium' +PINS = EVIDENCE / 'dependency-pins.json' +ENV = dict(os.environ, GIT_CONFIG_GLOBAL='/dev/null', GIT_CONFIG_NOSYSTEM='1', + GIT_TERMINAL_PROMPT='0', GIT_CONFIG_COUNT='0', LC_ALL='C') +GIT = ['git', '-c', 'core.hooksPath=/dev/null', '-c', 'credential.helper=', + '-c', 'protocol.file.allow=never', '-c', 'transfer.fsckObjects=true', + '-c', 'fetch.fsckObjects=true', '-c', 'gc.auto=0'] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def save(path, value): + path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + '\n') + + +def run(repo, arguments, timeout=120): + command = GIT + ['--git-dir=' + str(repo), *arguments] + return subprocess.run(command, env=ENV, capture_output=True, timeout=timeout) + + +def plan(): + data = json.loads(PINS.read_text()) + root = json.loads((EVIDENCE / 'upstream/commit.json').read_text().removeprefix(")]}'\n")) + if root['commit'] != data['rootRevision']: + raise ValueError('Root commit evidence differs') + selected = [{'path': '.', 'url': 'https://pdfium.googlesource.com/pdfium.git', + 'revision': data['rootRevision'], 'expectedTree': root['tree'], 'condition': None}] + excluded, packages = [], [] + # Explicit union of Linux x64 and Windows x64 minimal, V8/Rust/Skia-off + # checkouts. Refuse new predicates instead of executing any DEPS code. + conditions = {'checkout_libpng': True, 'checkout_win': True, + 'checkout_testing_corpus': False, 'checkout_rust': False, + 'checkout_android': False, 'checkout_v8': False, 'checkout_skia': False} + for name, value in data['root']['deps'].items(): + if isinstance(value, str): + url, condition = value, None + elif 'url' in value: + url, condition = value['url'], value.get('condition') + else: + packages.append({'path': name, 'pin': value}) + continue + if condition is not None and condition not in conditions: + raise ValueError('Unreviewed Git condition: ' + condition) + url, revision = url.rsplit('@', 1) + parsed = urlsplit(url) + if (parsed.scheme != 'https' or parsed.netloc not in { + 'pdfium.googlesource.com', 'chromium.googlesource.com', 'skia.googlesource.com'} + or not re.fullmatch('[0-9a-f]{40}', revision)): + raise ValueError('Unexpected source URL or revision') + item = {'path': name, 'url': url, 'revision': revision, 'condition': condition} + (selected if condition is None or conditions[condition] else excluded).append(item) + for name, recursive in data['recursive'].items(): + for subpath, value in recursive['deps'].items(): + if isinstance(value, str) or 'url' in value: + raise ValueError('Unreviewed recursive Git source') + packages.append({'path': name + '/' + subpath, 'pin': value}) + return {'selectedGit': selected, 'excludedGit': excluded, 'uncollectedPackages': packages, + 'conditionValues': conditions, + 'scope': 'Union of Linux/Windows x64 minimal Git snapshots; not a linked-target list. ' + 'No hooks, CIPD/GCS packages, compiler/sysroot downloads or build executed.'} + + +def export(repo, item, archive, inventory): + listing = run(repo, ['ls-tree', '-rlz', item['revision']]) + listing.check_returncode() + entries, total = [], 0 + for raw in listing.stdout.split(b'\0'): + if not raw: + continue + metadata, name = raw.split(b'\t', 1) + mode, kind, oid, size = metadata.decode('ascii').split() + name = name.decode('utf-8') + path = PurePosixPath(name) + if path.is_absolute() or '..' in path.parts or '\\' in name: + raise ValueError('Unsafe Git path') + size = int(size) if size != '-' else 0 + if size > 512 * 1024**2 or mode not in {'100644', '100755', '120000', '160000'}: + raise ValueError('Unexpected source entry') + total += size + entries.append({'path': name, 'mode': mode, 'type': kind, 'gitObject': oid, 'bytes': size}) + if len(entries) > 300000 or total > 4 * 1024**3: + raise ValueError('Source snapshot exceeds collection limits') + prefix = 'pdfium/' + (item['path'] + '/' if item['path'] != '.' else '') + child = subprocess.Popen(GIT + ['--git-dir=' + str(repo), 'cat-file', '--batch'], + env=ENV, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + try: + with archive.open('xb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as zipped: + with tarfile.open(fileobj=zipped, mode='w|', format=tarfile.PAX_FORMAT) as tar: + for entry in entries: + if entry['type'] == 'commit': + entry['archived'] = False + continue + child.stdin.write((entry['gitObject'] + '\n').encode()); child.stdin.flush() + header = child.stdout.readline().decode().split() + if header != [entry['gitObject'], 'blob', str(entry['bytes'])]: + raise ValueError('Git blob header differs') + blob = child.stdout.read(entry['bytes']) + if len(blob) != entry['bytes'] or child.stdout.read(1) != b'\n': + raise ValueError('Short Git blob read') + object_hash = hashlib.sha1(b'blob ' + str(len(blob)).encode() + b'\0' + blob).hexdigest() + if object_hash != entry['gitObject']: + raise ValueError('Git object content differs') + entry['sha256'] = hashlib.sha256(blob).hexdigest() + entry['archived'] = True + info = tarfile.TarInfo(prefix + entry['path']) + info.mtime = 0 + info.mode = int(entry['mode'][-3:], 8) + if entry['mode'] == '120000': + info.type = tarfile.SYMTYPE + info.linkname = blob.decode('utf-8') + entry['linkTarget'] = info.linkname + tar.addfile(info) + else: + info.size = len(blob) + tar.addfile(info, io.BytesIO(blob)) + child.stdin.close() + if child.wait(timeout=60) != 0: + raise ValueError('Git cat-file failed') + finally: + if child.poll() is None: + child.kill(); child.wait() + with inventory.open('x') as stream: + for entry in entries: + stream.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + '\n') + # Read the entire resulting archive and independently compare payloads. + by_name = {prefix + e['path']: e for e in entries if e['archived']} + with tarfile.open(archive, 'r:gz') as tar: + for member in tar: + entry = by_name.pop(member.name) + if member.issym(): + if member.linkname != entry['linkTarget']: + raise ValueError('Archived symlink differs') + else: + with tar.extractfile(member) as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + if digest != entry['sha256'] or member.size != entry['bytes']: + raise ValueError('Archived blob differs') + if by_name: + raise ValueError('Archive omitted entries') + return {'entries': len(entries), 'sourceBytes': total, + 'regularFiles': sum(e['type'] == 'blob' and e['mode'] != '120000' for e in entries), + 'symlinks': sum(e['mode'] == '120000' for e in entries), + 'gitlinks': [e for e in entries if e['type'] == 'commit'], + 'archiveVerifiedAgainstGitBlobs': True} + + +def collect(item, cache, output): + key = 'root' if item['path'] == '.' else item['path'].replace('/', '--') + directory = output / key + directory.mkdir() + repo = cache / (key + '.git') + # Reuse the previously fetched, verified root; no source checkout is made. + probe = ROOT / '.deps/source-archives/pdfium-probe/repository.git' + if key == 'root' and probe.is_dir(): + repo = probe + report = dict(item, repository=str(repo.relative_to(ROOT)), success=False) + try: + if not repo.exists(): + subprocess.run(GIT + ['init', '--bare', '--template=', str(repo)], env=ENV, + capture_output=True, check=True, timeout=20) + exists = run(repo, ['cat-file', '-e', item['revision'] + '^{commit}']) + if exists.returncode: + fetched = run(repo, ['fetch', '--depth=1', '--no-tags', item['url'], item['revision']], timeout=900) + (directory / 'fetch.log').write_bytes(fetched.stdout + fetched.stderr) + fetched.check_returncode() + report['fetched'] = True + else: + report['fetched'] = False + commit = run(repo, ['rev-parse', item['revision'] + '^{commit}']) + commit.check_returncode() + if commit.stdout.decode().strip() != item['revision']: + raise ValueError('Commit mismatch') + tree = run(repo, ['rev-parse', item['revision'] + '^{tree}']) + tree.check_returncode() + report['tree'] = tree.stdout.decode().strip() + if item.get('expectedTree') and report['tree'] != item['expectedTree']: + raise ValueError('Root tree differs from saved Gitiles metadata') + checked = run(repo, ['fsck', '--full', '--strict', '--no-reflogs', item['revision']], timeout=300) + (directory / 'fsck.log').write_bytes(checked.stdout + checked.stderr) + checked.check_returncode() + report['gitFsckPassed'] = True + archive = cache / (key + '-' + item['revision'] + '.tar.gz') + inventory = directory / 'files.jsonl' + report.update(export(repo, item, archive, inventory)) + report.update(archive=str(archive.relative_to(ROOT)), archiveBytes=archive.stat().st_size, + archiveSha256=sha(archive), inventory=str(inventory.relative_to(ROOT)), + inventorySha256=sha(inventory), success=True) + except Exception as error: + report['error'] = type(error).__name__ + ': ' + str(error) + save(directory / 'report.json', report) + print(json.dumps({'path': item['path'], 'success': report['success'], + 'error': report.get('error'), 'bytes': report.get('archiveBytes')}), flush=True) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--cache', type=Path, default=ROOT / '.deps/source-archives/pdfium') + args = parser.parse_args() + output, cache = args.output.resolve(), args.cache.resolve() + if not output.is_relative_to(ROOT) or not cache.is_relative_to(ROOT): + parser.error('Output/cache must be inside the workspace') + selected = plan() + output.mkdir(parents=True, exist_ok=False); cache.mkdir(parents=True, exist_ok=True) + save(output / 'plan.json', selected) + report = {'startedAt': datetime.now(timezone.utc).isoformat(), 'pinSha256': sha(PINS), + 'collectorSha256': sha(Path(__file__)), 'planSha256': sha(output / 'plan.json'), + 'gitVersion': subprocess.check_output(['git', '--version'], env=ENV, text=True).strip()} + values = [] + with ThreadPoolExecutor(max_workers=3) as pool: + futures = [pool.submit(collect, item, cache, output) for item in selected['selectedGit']] + for future in as_completed(futures): + values.append(future.result()) + report.update(success=all(x['success'] for x in values), repositories=sorted(values, key=lambda x: x['path']), + finishedAt=datetime.now(timezone.utc).isoformat(), + completeCorrespondingSources=False, buildReproduced=False, + scope=selected['scope']) + save(output / 'report.json', report) + print(json.dumps({'success': report['success'], 'repositories': len(values)})) + return 0 if report['success'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/source_archives/collect_pdfium_gitlink.py b/tests/source_archives/collect_pdfium_gitlink.py new file mode 100644 index 0000000..09f5da8 --- /dev/null +++ b/tests/source_archives/collect_pdfium_gitlink.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Preserve the single nested Git source referenced by the collected FreeType tree.""" +import argparse +import configparser +import json +from pathlib import Path + +from collect_pdfium import ROOT, collect, run, save, sha + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--cache', type=Path, default=ROOT / '.deps/source-archives/pdfium-gitlink') + args = parser.parse_args() + output, cache = args.output.resolve(), args.cache.resolve() + if not output.is_relative_to(ROOT) or not cache.is_relative_to(ROOT): + parser.error('Output/cache must be inside the workspace') + collection = ROOT / 'tests/results/source-archives/pdfium-git/report.json' + report = json.loads(collection.read_text()) + if not report['success']: + raise ValueError('Verified parent collection required') + parent = next(item for item in report['repositories'] if item['path'] == 'third_party/freetype/src') + links = [(item['path'], link) for item in report['repositories'] for link in item['gitlinks']] + if (len(links) != 1 or links[0][0] != parent['path'] or links[0][1]['path'] != 'subprojects/dlg' + or links[0][1]['gitObject'] != '395ccad2c1e0daae535c4d20bb0a3f2424648e17'): + raise ValueError('Unreviewed nested Git source') + config = run(ROOT / parent['repository'], ['show', parent['revision'] + ':.gitmodules']) + config.check_returncode() + modules = configparser.ConfigParser(interpolation=None) + modules.read_string(config.stdout.decode()) + source = modules['submodule "dlg"'] + if source['path'] != 'subprojects/dlg' or source['url'] != 'https://github.com/nyorain/dlg.git': + raise ValueError('Unexpected pinned .gitmodules mapping') + output.mkdir(parents=True, exist_ok=False); cache.mkdir(parents=True, exist_ok=True) + (output / 'freetype.gitmodules').write_bytes(config.stdout) + item = {'path': parent['path'] + '/' + source['path'], 'url': source['url'], + 'revision': links[0][1]['gitObject'], 'condition': 'Referenced by collected FreeType gitlink'} + result = collect(item, cache, output) + success = result['success'] and not result.get('gitlinks') + save(output / 'report.json', {'success': success, 'collectorSha256': sha(Path(__file__)), + 'parentCollectionSha256': sha(collection), 'parentRevision': parent['revision'], + 'gitmodulesSha256': sha(output / 'freetype.gitmodules'), 'repositories': [result], + 'scope': 'Nested source preserved, not evidence that dlg is linked into the distributed PDFium. ' + 'No source/build scripts or hooks executed.'}) + return 0 if success else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/source_archives/collect_qt_sdk_notices.py b/tests/source_archives/collect_qt_sdk_notices.py new file mode 100644 index 0000000..3dfdd3b --- /dev/null +++ b/tests/source_archives/collect_qt_sdk_notices.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Extract the tested Ubuntu Qt SDK's embedded Chromium SBOM notice texts.""" +import argparse +import ast +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import tarfile +import threading + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME = ROOT / 'tests/results/ui-final/ubuntu/installed-runtime-final' +QT_SOURCE = ROOT / '.deps/source-archives/qt-6.11.2-regular-files' +ARCHIVE_NAME = '6.11.2-0-202608131118qtwebengine-Linux-RHEL_9_6-GCC-Linux-RHEL_9_6-X86_64.7z' +ARCHIVE_SHA256 = '9cbfd85900e85b95fa11926b41818addfc2a96361f62af567be430607ba6b67e' +METADATA_NAMES = ['qtwebengine-6.11.2.spdx.json', 'qtwebengine-chromium-webengine-6.11.2.spdx.json', + 'qtwebengine-chromium-webengine-6.11.2.spdx'] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def write(path, data): + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n') + + +def relative(name): + path = PurePosixPath(name) + if path.is_absolute() or '..' in path.parts or '\\' in name: + raise ValueError('Unsafe input path') + return str(path) + + +def inspect_sdk_archive(archive, output): + """Stream archive conversion, never extracting links or executing payloads.""" + rows, names, total = [], set(), 0 + with (output / 'archive-reader.log').open('wb') as error_log: + process = subprocess.Popen(['bsdtar', '-cf', '-', '--format=pax', '@' + str(archive)], + stdout=subprocess.PIPE, stderr=error_log) + timer = threading.Timer(240, process.kill) + timer.start() + try: + with tarfile.open(fileobj=process.stdout, mode='r|') as tar: + for member in tar: + name = relative(member.name) + if name in names or len(names) >= 10000: + raise ValueError('Duplicate/excessive SDK entries') + names.add(name) + if member.isdir(): + continue + item = {'path': name, 'mode': member.mode} + if member.issym() or member.islnk(): + item.update(type='symlink' if member.issym() else 'hardlink', target=member.linkname) + elif member.isfile(): + total += member.size + if member.size > 512 * 1024**2 or total > 4 * 1024**3: + raise ValueError('SDK exceeds inspection size limits') + hashes = {key: hashlib.new(key) for key in ('sha1', 'sha256')} + size = 0 + with tar.extractfile(member) as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + size += len(chunk) + for digest in hashes.values(): + digest.update(chunk) + if size != member.size: + raise ValueError('Short archive member') + item.update(type='file', bytes=size, **{key: value.hexdigest() for key, value in hashes.items()}) + else: + raise ValueError('Unexpected SDK archive entry type') + rows.append(item) + if process.wait(timeout=30): + raise ValueError('SDK archive inspection failed') + finally: + timer.cancel() + if process.poll() is None: + process.kill(); process.wait() + rows.sort(key=lambda x: x['path']) + write(output / 'sdk-archive-inventory.json', rows) + return {row['path']: row for row in rows}, total + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if not output.is_relative_to(ROOT): + parser.error('Output must be inside the workspace') + output.mkdir(parents=True, exist_ok=False) + (output / 'notices').mkdir() + runtime_path = RUNTIME / 'runtime.json' + runtime = json.loads(runtime_path.read_text()) + metadata = {} + for name in METADATA_NAMES: + entry = next(e for e in runtime['notices'] if e['path'] == 'qt-sdk:sbom/' + name) + path = RUNTIME / entry['copiedPath'] + if path.stat().st_size != entry['size'] or sha(path) != entry['sha256']: + raise ValueError('Saved SDK metadata changed') + metadata[name] = dict(entry, localPath=str(path.relative_to(ROOT))) + main_sbom = json.loads((ROOT / metadata[METADATA_NAMES[0]]['localPath']).read_text()) + chromium = json.loads((ROOT / metadata[METADATA_NAMES[1]]['localPath']).read_text()) + external = next(e for e in main_sbom['externalDocumentRefs'] if 'qtwebengine-chromium-webengine' in e['externalDocumentId']) + external_bytes = (ROOT / metadata[METADATA_NAMES[2]]['localPath']).read_bytes() + if (external['checksum']['algorithm'] != 'SHA1' + or hashlib.sha1(external_bytes).hexdigest() != external['checksum']['checksumValue'] + or external['spdxDocument'] != chromium['documentNamespace']): + raise ValueError('Qt module SBOM does not bind the Chromium SBOM') + archive = ROOT / 'build-ubuntu-vm/sdk-downloads' / ARCHIVE_NAME + if sha(archive) != ARCHIVE_SHA256: + raise ValueError('Qt SDK archive differs from fixed downloaded input') + inventory, total_bytes = inspect_sdk_archive(archive, output) + for name, entry in metadata.items(): + if inventory['sbom/' + name]['sha256'] != entry['sha256']: + raise ValueError('SDK metadata differs from original archive') + file_checks = [] + for file in main_sbom['files']: + name = relative(file['fileName']) + entry = inventory.get(name) + if not entry or entry['type'] != 'file': + raise ValueError('SBOM file absent from original archive: ' + name) + for checksum in file['checksums']: + algorithm = checksum['algorithm'].lower() + if algorithm not in {'sha1', 'sha256'} or entry[algorithm] != checksum['checksumValue']: + raise ValueError('SBOM file checksum differs: ' + name) + file_checks.append({'path': name, 'spdxId': file['SPDXID'], 'sha1': entry['sha1'], 'sha256': entry['sha256']}) + runtime_checks = [] + for entry in runtime['files']: + resolved = entry['resolvedPath'] + if not resolved.startswith('qt-sdk:'): + continue + name = relative(resolved.removeprefix('qt-sdk:')) + if name not in inventory: + continue + original = inventory[name] + if original.get('sha256') != entry['sha256'] or original.get('bytes') != entry['size']: + raise ValueError('SDK archive differs from tested Ubuntu runtime: ' + name) + runtime_checks.append({'path': entry['path'], 'resolvedPath': resolved, 'sha256': entry['sha256']}) + if not any(e['resolvedPath'] == 'qt-sdk:lib/libQt6WebEngineCore.so.6.11.2' for e in runtime_checks): + raise ValueError('QtWebEngineCore runtime binding is required') + packages = {p['SPDXID']: p for p in chromium['packages']} + licenses = {p['licenseId']: p for p in chromium['hasExtractedLicensingInfos']} + if len(packages) != len(chromium['packages']) or len(licenses) != len(chromium['hasExtractedLicensingInfos']): + raise ValueError('Duplicate SPDX IDs') + # The SDK's document checksum/namespace can match even when its package + # relationship names a nonexistent ID. Preserve that upstream inconsistency. + external_package_refs = [] + for relationship in main_sbom['relationships']: + prefix = external['externalDocumentId'] + ':' + target = relationship['relatedSpdxElement'] + if target.startswith(prefix): + target_id = target[len(prefix):] + external_package_refs.append(dict(relationship, targetIdExists=target_id in packages)) + for relationship in chromium['relationships']: + if (relationship['spdxElementId'] not in packages + or relationship['relatedSpdxElement'] not in packages + or relationship['relationshipType'] != 'CONTAINS'): + raise ValueError('Unreviewed SPDX relationship') + without_text = [] + for package in packages.values(): + license_id = package['licenseConcluded'] + if license_id not in licenses: + if license_id not in {'BSD-3-Clause', 'MIT'}: + raise ValueError('Unresolved SPDX license reference') + without_text.append(package) + source_report = json.loads((ROOT / 'tests/results/source-archives/qt-inspection/report.json').read_text()) + source_inventory = ROOT / 'tests/results/source-archives/qt-inspection/files.jsonl' + if sha(source_inventory) != source_report['inventorySha256']: + raise ValueError('Qt source inventory changed') + helpers = ['qtwebengine/src/3rdparty/chromium/tools/licenses/sbom.py', + 'qtwebengine/cmake/QtWebEngineSbomHelpers.cmake'] + source_names = set(helpers) + for license in licenses.values(): + refs = license['crossRefs'] + if len(refs) != 1 or not refs[0]['url'].startswith(('/chromium/', '/gn/')): + raise ValueError('Unexpected source license reference') + source_names.add('qtwebengine/src/3rdparty/' + relative(refs[0]['url'][1:])) + sources = {} + for line in source_inventory.open(): + entry = json.loads(line) + if entry['path'] in source_names: + if sha(QT_SOURCE / entry['path']) != entry['sha256']: + raise ValueError('Qt source notice/helper changed') + sources[entry['path']] = entry + if sources.keys() != source_names: + raise ValueError('Notice source missing from verified source archive') + rows, combined = [], ['Qt WebEngine 6.11.2 — Chromium notices contained in the Linux Qt SDK SBOM\n'] + for license_id, license in sorted(licenses.items()): + text = license['extractedText'].encode('utf-8') + source = 'qtwebengine/src/3rdparty/' + license['crossRefs'][0]['url'][1:] + if not text or text != (QT_SOURCE / source).read_bytes(): + raise ValueError('SBOM license text differs from verified source archive') + digest = hashlib.sha256(text).hexdigest() + target = output / 'notices' / (digest + '.txt') + if not target.exists(): + target.write_bytes(text) + consumers = [p['SPDXID'] for p in packages.values() if p['licenseConcluded'] == license_id] + rows.append({'licenseId': license_id, 'source': source, 'sha256': digest, 'bytes': len(text), + 'file': str(target.relative_to(output)), 'packageIds': consumers}) + combined.extend(['\n' + '=' * 72 + '\n' + license['name'] + '\n', + 'Source: ' + license['crossRefs'][0]['url'] + '\n\n', license['extractedText']]) + bundle = output / 'THIRD_PARTY_NOTICES.sdk-extract.txt' + bundle.write_text(''.join(combined), encoding='utf-8') + # Preserve the generator's explicit limitations as data without importing it. + syntax = ast.parse((QT_SOURCE / helpers[0]).read_text()) + skip_names = {'DIRECTORIES_TO_SKIP_BECAUSE_THEY_HAVE_VARIOUS_PARSING_ISSUES', + 'PACKAGES_TO_OVERRIDE_LICENSE_FILE_WITH_ID'} + generator_limits = {} + for node in syntax.body: + if not isinstance(node, ast.Assign) or not isinstance(node.targets[0], ast.Name): + continue + name = node.targets[0].id + if name not in skip_names: + continue + values = [] + for value in node.value.elts: + if isinstance(value, ast.Constant): + values.append(value.value) + elif isinstance(value, ast.Call) and ast.unparse(value.func) == 'os.path.join': + values.append('/'.join(ast.literal_eval(v) for v in value.args)) + else: + raise ValueError('Unexpected generator limitation declaration') + generator_limits[name] = values + arch_sbom = Path('/usr/lib/qt6/sbom/qtwebengine-6.11.2.spdx') + arch_incomplete = 'not listing all of its consumed 3rd party dependencies' in arch_sbom.read_text() + if not arch_incomplete: + raise ValueError('Arch SBOM scope changed; reassess separately') + (output / 'arch-qtwebengine.spdx').write_bytes(arch_sbom.read_bytes()) + report = {'success': True, 'collectorSha256': sha(Path(__file__)), 'runtimeRecordSha256': sha(runtime_path), + 'sdkArchive': {'path': str(archive.relative_to(ROOT)), 'sha256': ARCHIVE_SHA256, + 'bytes': archive.stat().st_size, 'regularPayloadBytes': total_bytes, + 'entries': len(inventory)}, + 'sdkArchiveInventorySha256': sha(output / 'sdk-archive-inventory.json'), + 'metadata': metadata, 'externalSbomReference': external, 'sbomFileComparisons': file_checks, + 'externalPackageReferences': external_package_refs, + 'externalPackageReferencesValid': bool(external_package_refs) and all( + e['targetIdExists'] for e in external_package_refs), + 'testedRuntimeComparisons': runtime_checks, 'packages': chromium['packages'], + 'relationships': chromium['relationships'], 'notices': rows, + 'noticeBundleSha256': sha(bundle), 'uniqueNoticeFiles': len(list((output / 'notices').glob('*.txt'))), + 'packagesWithoutEmbeddedNoticeText': without_text, 'generatorLimits': generator_limits, + 'generatorSources': {name: sources[name]['sha256'] for name in helpers}, + 'qtSourceArchiveSha256': source_report['archiveSha256'], + 'archSbom': {'path': str(arch_sbom), 'sha256': sha(arch_sbom), 'explicitIncompleteNotice': True}, + 'completeChromiumNotices': False, 'completeCorrespondingSources': False, + 'scope': 'All embedded notice texts in the tested Ubuntu Qt SDK Chromium WebEngine SBOM, ' + 'bound to its original archive, runtime fingerprint and Qt source archive. ' + 'Not the Arch configuration or a complete license/linked-component verdict. ' + 'Unresolved external package IDs, generator skips, identifier-only entries ' + 'and omitted patent files remain distinct.'} + write(output / 'report.json', report) + print(json.dumps({'success': True, 'noticeEntries': len(rows), 'uniqueNoticeFiles': report['uniqueNoticeFiles'], + 'sbomFilesMatched': len(file_checks), 'testedRuntimeFilesMatched': len(runtime_checks), + 'packagesWithoutEmbeddedText': len(without_text)})) + + +if __name__ == '__main__': + main() diff --git a/tests/source_archives/collect_qt_sdk_supplement.py b/tests/source_archives/collect_qt_sdk_supplement.py new file mode 100644 index 0000000..bbb5619 --- /dev/null +++ b/tests/source_archives/collect_qt_sdk_supplement.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Recover two explicitly declared Patent files omitted by the fixed Qt SDK SBOM.""" +import argparse +import hashlib +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +BASE = ROOT / 'tests/results/source-archives/qt-sdk-notices-final/report.json' +BASE_SHA = 'cea0a2f80ad50b5519fbad94c1dd2eed55f5ffa34e83d1360ce8da6902a845a0' +INVENTORY_SHA = '43dc4aa3cf0d970fc97a9db2555f0025429e9bd31823cb794fd5526496d1a9e0' +SOURCE = ROOT / '.deps/source-archives/qt-6.11.2-regular-files' +PREFIX = 'qtwebengine/src/3rdparty/chromium/' +PATENTS = { + 'libwebm': ('source/LICENSE.TXT', 'source/PATENTS.TXT'), + 'libwebp': ('src/COPYING', 'src/PATENTS'), +} +ID_ONLY = { + 'mini-chromium': ('third_party/crashpad/crashpad/third_party/mini_chromium/README.crashpad', 'Shipped in Chromium'), + 'libdrm': ('third_party/libdrm/README.chromium', 'Shipped'), +} + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if not output.is_relative_to(ROOT): + parser.error('Output must be inside the workspace') + assert sha(BASE) == BASE_SHA + base = json.loads(BASE.read_text()) + inventory = ROOT / 'tests/results/source-archives/qt-inspection/files.jsonl' + assert sha(inventory) == INVENTORY_SHA + names = {PREFIX + 'tools/licenses/sbom.py'} + for name, files in PATENTS.items(): + names.update(PREFIX + 'third_party/' + name + '/' + tail for tail in (*files, 'README.chromium')) + names.update(PREFIX + row[0] for row in ID_ONLY.values()) + sources = {} + for line in inventory.open(): + row = json.loads(line) + if row['path'] in names: + assert row['path'] not in sources + source = SOURCE / row['path'] + assert source.is_file() and not source.is_symlink() + assert source.stat().st_size == row['bytes'] and sha(source) == row['sha256'] + sources[row['path']] = row + assert sources.keys() == names + assert sources[PREFIX + 'tools/licenses/sbom.py']['sha256'] == base['generatorSources'][PREFIX + 'tools/licenses/sbom.py'] + packages = {p['SPDXID']: p for p in base['packages']} + output.mkdir(parents=True, exist_ok=False) + copied, notices, id_only = [], [], [] + + def copy(name, kind): + source = sources[name] + target = 'sources/' + name.removeprefix(PREFIX) + path = output / target + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes((SOURCE / name).read_bytes()) + assert sha(path) == source['sha256'] + copied.append(dict(file=target, source=name, sha256=source['sha256'], bytes=source['bytes'], kind=kind)) + return target + + def fields(name): + return dict(re.findall(r'^([^:\n]+): (.*)$', (SOURCE / name).read_text(), re.M)) + + for name, (license_file, patent_file) in PATENTS.items(): + directory = PREFIX + 'third_party/' + name + '/' + metadata = directory + 'README.chromium' + data = fields(metadata) + assert data['License'] == 'BSD-3-Clause, Patent' and data['Shipped'] == 'yes' + assert data['License File'] == license_file + ', ' + patent_file + package_id = 'SPDXRef-Package-QtWebEngine-Chromium-WebEngine-' + name + package = packages[package_id] + assert package['comment'].splitlines()[0] == 'Location within source: third_party/' + name + embedded = next(n for n in base['notices'] if package_id in n['packageIds']) + assert embedded['source'] == directory + license_file + assert embedded['sha256'] == sources[directory + license_file]['sha256'] + assert not any(n['source'] == directory + patent_file for n in base['notices']) + metadata_file = copy(metadata, 'upstream-package-metadata') + notice_file = copy(directory + patent_file, 'declared-patent-notice') + notices.append(dict(packageId=package_id, revision=data['Revision'], licenseDeclared=data['License'], + metadataFile=metadata_file, noticeFile=notice_file, + existingLicenseSha256=embedded['sha256'], reason='Second declared file omitted by SDK generator Patent branch')) + for name, (relative, shipped_field) in ID_ONLY.items(): + metadata = PREFIX + relative + data = fields(metadata) + assert data[shipped_field] == 'no' + package_id = 'SPDXRef-Package-QtWebEngine-Chromium-WebEngine-' + name + package = next(p for p in base['packagesWithoutEmbeddedNoticeText'] if p['SPDXID'] == package_id) + assert data['License'] == package['licenseConcluded'] + id_only.append(dict(packageId=package_id, metadataFile=copy(metadata, 'upstream-package-metadata'), + declaredShippedField=shipped_field, declaredShippedValue='no', + licenseId=package['licenseConcluded'], noticeAdded=False, + linkedInSdk='unverified; upstream metadata is not a build-graph proof')) + report = dict(schemaVersion=1, success=True, collectorSha256=sha(Path(__file__)), + baseNoticeReportSha256=BASE_SHA, sourceInventorySha256=INVENTORY_SHA, + qtSourceArchiveSha256=base['qtSourceArchiveSha256'], sdkArchiveSha256=base['sdkArchive']['sha256'], + runtimeComparisons=base['testedRuntimeComparisons'], files=sorted(copied, key=lambda x:x['file']), + sourceChecks=sorted(sources.values(), key=lambda x:x['path']), notices=notices, idOnlyPackages=id_only, + sourceSdkExternalPackageReferencesValid=base['externalPackageReferencesValid'], + completeChromiumNotices=False, completeCorrespondingSources=False, publicReleaseApproved=False, + scope='Two upstream-declared Patent files recovered from the fixed Qt 6.11.2 source archive. ' + 'Four upstream metadata files retained. Original SBOM is unchanged. ' + 'Does not resolve the SDK build graph, generator skips or external package-ID inconsistency.') + (output / 'report.json').write_text(json.dumps(report, indent=2, sort_keys=True) + '\n') + print(json.dumps({'success': True, 'additionalNotices': len(notices), 'metadataFiles': len(copied)-len(notices)})) + + +if __name__ == '__main__': + main() diff --git a/tests/source_archives/fetch.py b/tests/source_archives/fetch.py new file mode 100644 index 0000000..e08d179 --- /dev/null +++ b/tests/source_archives/fetch.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Collect pinned public source archives; never execute downloaded code.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import time +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +PINS = Path(__file__).with_name('pins.json') +HOSTS = {'download.qt.io', 'ftp.jaist.ac.jp', 'github.com', 'codeload.github.com'} + + +class Redirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + parsed = urllib.parse.urlparse(newurl) + if parsed.scheme != 'https' or parsed.hostname not in HOSTS: + raise ValueError('Source archive redirect left the pinned HTTPS providers') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def fetch(name, pin, output, cache): + result = {'name': name, 'pin': pin, 'success': False, + 'retrievedAtUtc': datetime.now(timezone.utc).isoformat()} + target = cache / pin['name']; partial = target.with_name(target.name + '.part') + before = time.monotonic() + try: + if target.exists() or partial.exists(): + raise ValueError('Refusing to replace an existing archive or partial download') + opener = urllib.request.build_opener(Redirects()) + if 'metadataUrl' in pin: + with opener.open(pin['metadataUrl'], timeout=30) as source: + metadata = source.read(128 * 1024 + 1) + if len(metadata) > 128 * 1024: + raise ValueError('Metadata exceeded its finite limit') + if not all(value.encode() in metadata for value in pin['hashes'].values()): + raise ValueError('Published Qt checksum differs from the pin') + result['metadataSha256'] = hashlib.sha256(metadata).hexdigest() + # Mirror pages may contain the requesting IP. Keep the digest and + # matched public fields rather than copying that incidental data. + result['publishedHashesMatched'] = True + else: + metadata = (ROOT / pin['metadataFile']).read_bytes() + if not all(value.encode() in metadata for value in pin['hashes'].values()): + raise ValueError('Stored recipe differs from the pinned checksums') + result['metadataSha256'] = hashlib.sha256(metadata).hexdigest() + limit = pin.get('bytes', pin.get('maximumBytes')) + hashes = {algorithm: hashlib.new(algorithm) for algorithm in {*pin['hashes'], 'sha256'}} + count, last = 0, before + with opener.open(pin['url'], timeout=30) as source, partial.open('xb') as file: + result['resolvedUrl'] = source.url + while block := source.read(1024 * 1024): + count += len(block) + if count > limit or time.monotonic() - before > 900: + raise ValueError('Download exceeded size or time limit') + file.write(block) + for digest in hashes.values(): + digest.update(block) + if time.monotonic() - last > 10: + print(f'{name}: {count / 1048576:.1f} MiB received', flush=True) + last = time.monotonic() + result.update(bytes=count, hashes={algorithm: digest.hexdigest() for algorithm, digest in hashes.items()}) + if pin.get('bytes', count) != count or any(result['hashes'][algorithm] != digest for algorithm, digest in pin['hashes'].items()): + raise ValueError('Archive size or checksum mismatch; partial retained as unverified') + partial.rename(target) + result.update(success=True, archive=str(target.relative_to(ROOT))) + except Exception as error: + result['error'] = str(error) + result['elapsedSeconds'] = time.monotonic() - before + (output / (name + '.json')).write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps({'name': name, 'success': result['success'], 'error': result.get('error')}), flush=True) + return result['success'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--cache', type=Path, required=True) + parser.add_argument('--artifact', action='append', choices=['qt-everywhere', 'tomlplusplus']) + args = parser.parse_args() + output, cache = args.output.resolve(), args.cache.resolve() + if not output.is_relative_to(ROOT) or not cache.is_relative_to(ROOT): + parser.error('Keep this task’s evidence and archives inside the workspace') + output.mkdir(parents=True, exist_ok=False); cache.mkdir(parents=True, exist_ok=True) + pins = json.loads(PINS.read_text()) + names = args.artifact or list(pins) + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(fetch, name, pins[name], output, cache) for name in names] + successes = [future.result() for future in futures] + return 0 if all(successes) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/source_archives/inspect_qt.py b/tests/source_archives/inspect_qt.py new file mode 100644 index 0000000..ed2e617 --- /dev/null +++ b/tests/source_archives/inspect_qt.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Inspect the verified Qt release archive and retain regular source files safely.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import tarfile +import time +import urllib.parse + +ROOT = Path(__file__).resolve().parents[2] + + +def sha(path): + with path.open('rb') as f: + return hashlib.file_digest(f, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--tree', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + archive, tree, out = args.archive.resolve(), args.tree.resolve(), args.output.resolve() + if any(not path.is_relative_to(ROOT) for path in [archive, tree, out]): + parser.error('All paths must remain in this workspace') + pin = json.loads(Path(__file__).with_name('pins.json').read_text())['qt-everywhere'] + if archive.stat().st_size != pin['bytes'] or sha(archive) != pin['hashes']['sha256']: + parser.error('Archive does not match the verified release pin') + tree.mkdir(parents=True, exist_ok=False); out.mkdir(parents=True, exist_ok=False) + comparison_root = ROOT / 'tests/results/source-correspondence/arch/upstream-metadata' + expected = {} + for row in json.loads((comparison_root / 'version-sources.json').read_text()): + url = urllib.parse.urlparse(row['url']).path + if '/qtwebengine.git/plain/' in url: + relative = 'qtwebengine/' + url.split('/plain/', 1)[1] + elif '/qtwebengine-chromium.git/plain/' in url: + relative = 'qtwebengine/src/3rdparty/' + url.split('/plain/', 1)[1] + else: + continue + if sha(comparison_root / row['file']) != row['sha256']: + raise ValueError('Stored fixed-revision comparison input changed') + expected[relative] = row + binaries = {name: sha(ROOT / 'build' / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']} + report = {'success': False, 'archive': str(archive.relative_to(ROOT)), 'archiveSha256': pin['hashes']['sha256'], + 'archiveBytes': pin['bytes'], 'sourceTree': str(tree.relative_to(ROOT)), + 'scope': 'Official Qt 6.11.2 release sources and selected metadata/notices; not complete linked-component attribution, a rebuild, license acceptance or legal compliance certification'} + files = size = members = notice_bytes = 0 + notices, links, comparisons, module_counts, versions = [], [], {}, {}, {} + seen = set(); last = time.monotonic() + try: + with tarfile.open(archive, 'r|xz') as stream, (out / 'files.jsonl').open('w') as inventory: + for member in stream: + members += 1 + if members > 600000 or member.size > 1024**3 or member.size < 0: + raise ValueError('Archive exceeded finite member limits') + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] != 'qt-everywhere-src-6.11.2' or '\\' in member.name: + raise ValueError('Unexpected source member path') + relative = PurePosixPath(*path.parts[1:]) + if member.isdir(): + continue + name = str(relative) + if name in seen or name == '.': + raise ValueError('Duplicate or empty source file path') + seen.add(name) + if member.issym() or member.islnk(): + links.append({'path': name, 'target': member.linkname, 'kind': 'symlink' if member.issym() else 'hardlink'}) + continue # Keep links in the original archive; never follow them. + if not member.isfile(): + raise ValueError('Non-file source entry') + size += member.size + if size > 20 * 1024**3: + raise ValueError('Source tree exceeded 20 GiB limit') + destination = tree / name + destination.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(); count = 0 + with stream.extractfile(member) as source, destination.open('xb') as target: + while block := source.read(1024 * 1024): + target.write(block); digest.update(block); count += len(block) + if count != member.size: + raise ValueError('Source member was truncated') + row = {'path': name, 'bytes': count, 'sha256': digest.hexdigest(), 'archiveMode': member.mode} + inventory.write(json.dumps(row) + '\n'); files += 1 + module = relative.parts[0] if len(relative.parts) > 1 else '(root)' + module_counts[module] = module_counts.get(module, 0) + 1 + if name in expected: + comparisons[name] = {**row, 'expectedSha256': expected[name]['sha256'], + 'matches': row['sha256'] == expected[name]['sha256'], 'sourceUrl': expected[name]['url']} + if relative.name == '.cmake.conf' and len(relative.parts) == 2: + text = destination.read_text(errors='replace') + versions[module] = re.findall(r'QT_REPO_MODULE_VERSION\s+"([^"]+)"', text) + # Keep a named-file subset; README.chromium and attribution + # metadata may refer to further texts. Do not call this complete. + if (re.fullmatch(r'(licen[cs]e|copying|copyright|notice)([._-].*)?', relative.name, re.I) + or relative.name in ('README.chromium', 'qt_attributions.json', 'REUSE.toml') + or 'LICENSES' in relative.parts): + if count > 8 * 1024**2 or notice_bytes + count > 128 * 1024**2: + raise ValueError('Notice metadata exceeded finite limits') + notice_bytes += count; notices.append(row) + if time.monotonic() - last > 15: + print(f'Qt source: {files} files / {size / 1048576:.1f} MiB inspected', flush=True) + last = time.monotonic() + report['fixedSourceComparisons'] = comparisons + missing = sorted(set(expected) - set(comparisons)) + report['missingComparisons'] = missing + if missing or not all(row['matches'] for row in comparisons.values()): + raise ValueError('Release source differs from a fixed-revision comparison input') + report['success'] = True + except Exception as error: + report['error'] = str(error) + finally: + (out / 'notices-index.json').write_text(json.dumps(notices, indent=2) + '\n') + report.update(regularFiles=files, regularBytes=size, members=members, archivedLinksNotMaterialized=links, + moduleFileCounts=module_counts, moduleVersionDeclarations=versions, + noticeAndMetadataFiles=len(notices), noticeAndMetadataBytes=notice_bytes, + completeChromiumNotices=False, completeCorrespondingSources=False, + inventorySha256=sha(out / 'files.jsonl'), noticesIndexSha256=sha(out / 'notices-index.json'), + productionBinaries=binaries, + productionBinariesUnchanged=all(sha(ROOT / 'build' / name) == digest for name, digest in binaries.items())) + report['success'] &= report['productionBinariesUnchanged'] + (out / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({key: report.get(key) for key in ['success', 'regularFiles', 'regularBytes', 'noticeAndMetadataFiles', 'error']})) + return 0 if report['success'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/source_archives/pins.json b/tests/source_archives/pins.json new file mode 100644 index 0000000..d171b0f --- /dev/null +++ b/tests/source_archives/pins.json @@ -0,0 +1,21 @@ +{ + "qt-everywhere": { + "name": "qt-everywhere-src-6.11.2.tar.xz", + "url": "https://ftp.jaist.ac.jp/pub/qtproject/archive/qt/6.11/6.11.2/single/qt-everywhere-src-6.11.2.tar.xz", + "metadataUrl": "https://download.qt.io/archive/qt/6.11/6.11.2/single/qt-everywhere-src-6.11.2.tar.xz.mirrorlist", + "bytes": 1019661552, + "hashes": { + "sha256": "6dcfbca271d76a6502741a2c0dc6fc98ef7dd0b7b4cfd0abcebb285a86a26f33" + } + }, + "tomlplusplus": { + "name": "tomlplusplus-3.4.0.tar.gz", + "url": "https://github.com/marzer/tomlplusplus/archive/refs/tags/v3.4.0.tar.gz", + "metadataFile": "tests/results/source-correspondence/arch/packages/tomlplusplus/PKGBUILD", + "maximumBytes": 16777216, + "hashes": { + "sha512": "c227fc8147c9459b29ad24002aaf6ab2c42fac22ea04c1c52b283a0172581ccd4527b33c1931e0ef0d1db6b6a53f9e9882c6d4231c7f3494cf070d0220741aa5", + "blake2b": "9495ccd78707ced11744eab7c1c0bf0c0c28e283d186195bb48d1059bae7eb1a874bc964b0fc45210fd73ffd7485ecf3e1159da227d0e1c8ff249e79c08eecf0" + } + } +} diff --git a/tests/source_archives/record_qt_supplement.py b/tests/source_archives/record_qt_supplement.py new file mode 100644 index 0000000..041e82f --- /dev/null +++ b/tests/source_archives/record_qt_supplement.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Bind a notice-only Ubuntu package update to the existing tested executables.""" +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +RESULTS = ROOT / 'tests/results/qt-notice-supplement' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def read(name): + return json.loads((RESULTS / name).read_text()) + + +def main(): + prior = read('prior-validation-record.json') + assert sha(RESULTS / 'prior-validation-record.json') == '25bc879e35e02b70f7bd1cf2afca293896afb247163fa5b1f81020ec5bd3359b' + for name, digest in prior['evidenceSha256'].items(): + assert sha(ROOT / name) == digest, name + assert sha(RESULTS / 'prior-packager.py') == prior['ubuntuPackage']['packagerSha256'] + assert sha(RESULTS / 'prior-record-validation.py') == prior['validationToolsSha256']['tests/portal/record_validation.py'] + current_sources = {str(path.relative_to(ROOT)): sha(path) for directory in ('src', 'qml', 'cmake', 'resources') + for path in (ROOT / directory).rglob('*') if path.is_file() and '__pycache__' not in path.parts} + current_sources.update({name: sha(ROOT / name) for name in ('CMakeLists.txt', 'resources.qrc')}) + assert current_sources == prior['sourceSha256'] + for name, digest in prior['builds']['arch']['binaries'].items(): + assert sha(ROOT / 'build' / name) == digest + supplement = read('collection/report.json') + repeat = read('collection-repeat/report.json') + assert supplement == repeat and supplement['success'] + assert len(supplement['files']) == 6 and len(supplement['notices']) == 2 + assert supplement['collectorSha256'] == sha(ROOT / 'tests/source_archives/collect_qt_sdk_supplement.py') + assert supplement['baseNoticeReportSha256'] == sha(ROOT / 'tests/results/source-archives/qt-sdk-notices-final/report.json') + for row in supplement['files']: + assert sha(RESULTS / 'collection' / row['file']) == row['sha256'] + assert sha(RESULTS / 'collection-repeat' / row['file']) == row['sha256'] + package = read('package/report.json') + repeated = read('package/repeat-report.json') + verified = read('package/verification.json') + manifest = read('package/package-manifest.json') + assert package['success'] and repeated['success'] and verified['success'] + assert package['archiveSha256'] == repeated['archiveSha256'] == verified['archiveSha256'] + assert package['archiveSha256'] == sha(ROOT / 'build-ubuntu-vm/packages' / package['archive']) + assert package['manifestSha256'] == verified['packageManifestSha256'] == sha(RESULTS / 'package/package-manifest.json') + assert package['packagerSha256'] == sha(ROOT / 'tools/package_ubuntu.py') + assert package['collectorSha256'] == sha(ROOT / 'tools/collect_ubuntu_validation_runtime.py') + assert package['runtimeRecordSha256'] == sha(RESULTS / 'package/runtime/runtime.json') + assert package['executableSha256'] == prior['ubuntuPackage']['executableSha256'] + assert package['executableSha256'] == prior['builds']['ubuntu']['installedBinaries'] + assert verified['filesVerified'] == package['payloadFiles'] == len(manifest['files']) + 1 + assert verified['allSizesModesAndHashesMatch'] and verified['rootOwnership'] + assert package['sdkSupplementReportSha256'] == sha(RESULTS / 'collection/report.json') + assert manifest['sdkSupplementReportSha256'] == package['sdkSupplementReportSha256'] + old_manifest = json.loads((ROOT / 'tests/results/portal/ubuntu-package/package-manifest.json').read_text()) + old_rows = {r['path']: r for r in old_manifest['files']} + new_rows = {r['path']: r for r in manifest['files']} + added = sorted(new_rows.keys() - old_rows.keys()) + removed = sorted(old_rows.keys() - new_rows.keys()) + changed = sorted(name for name in old_rows.keys() & new_rows.keys() if old_rows[name] != new_rows[name]) + assert not removed and len(added) == 7 + assert all(name.startswith('opt/docview/share/doc/docview/third-party/qt-sdk-supplement/') for name in added) + assert changed == ['DEBIAN/control', 'opt/docview/share/doc/docview/UBUNTU-PACKAGE.txt'] + for row in supplement['files']: + name = 'opt/docview/share/doc/docview/third-party/qt-sdk-supplement/' + row['file'] + assert new_rows[name]['sha256'] == row['sha256'] and new_rows[name]['size'] == row['bytes'] + lifecycle = {phase: read('package-vm/' + phase + '/report.json') for phase in ('install', 'smoke', 'remove')} + assert all(row['success'] and row['archiveSha256'] == package['archiveSha256'] for row in lifecycle.values()) + for phase, report in lifecycle.items(): + assert report['runnerSha256'] == sha(ROOT / 'tests/ubuntu_vm/clean_package.py') + for command in report['commands']: + assert command['exitCode'] == 0 + assert sha(RESULTS / 'package-vm' / phase / (command['name'] + '.log')) == command['logSha256'] + assert lifecycle['install']['executables'] == package['executableSha256'] + assert lifecycle['install']['installationMode'] == 'fresh-install' + assert lifecycle['install']['installedFilesVerified'] == len(manifest['files']) - 4 + assert lifecycle['smoke']['smokeConditions'] == 12 and lifecycle['smoke']['executableBytesUnchanged'] + assert lifecycle['remove']['payloadRemoved'] and lifecycle['remove']['kernelRestrictionUnchanged'] + assert len(lifecycle['remove']['userProbesPreserved']) == 4 + for os_name in ('arch', 'ubuntu'): + log = (RESULTS / ('package-tests-' + os_name + '.log')).read_text() + assert 'Ran 8 tests' in log and log.rstrip().endswith('OK') + assert read('shutdown.json')['allGuestsStopped'] + record = {**prior, 'schemaVersion': 3, 'recordedAtUtc': datetime.now(timezone.utc).isoformat(), + 'scope': 'Notice-only Ubuntu validation3 package; all compiled product sources and executables unchanged ' + 'from the URL-fix record. Eight packaging tests on Arch and Ubuntu, two identical deb builds, ' + 'archive verification and 12 X11/Wayland launches plus install/remove/purge on the existing SDK-free VM.', + 'ubuntuPackage': package, 'packageLifecycle': lifecycle, + 'noticeSupplement': supplement, + 'packageChanges': {'added': added, 'removed': removed, 'changed': changed, + 'selfManifestAlsoChanged': True, 'allOtherPayloadIdentical': True}, + 'previousValidation': {'record': 'tests/results/qt-notice-supplement/prior-validation-record.json', + 'sha256': sha(RESULTS / 'prior-validation-record.json'), + 'scope': 'All 22 CTest groups per OS, native portal cases and performance stay bound ' + 'to their original runs. Product bytes are unchanged. Portal ran on package ' + 'validation2; its OS integration was not rerun for notice-only validation3.'}, + 'packageVmScope': 'Existing dedicated Ubuntu 24.04 VM with no SDK/build tree. Test helpers already ' + 'installed; this is not a newly created OS. Prior validation1 fresh-OS evidence is historical.', + 'validationToolsSha256': {**prior['validationToolsSha256'], **{name: sha(ROOT / name) for name in ( + 'tools/package_ubuntu.py', 'tools/verify_ubuntu_package.py', 'tests/test_ubuntu_package.py', + 'tests/source_archives/collect_qt_sdk_supplement.py', 'tests/source_archives/record_qt_supplement.py')}}, + 'evidenceSha256': {**prior['evidenceSha256'], **{str(path.relative_to(ROOT)): sha(path) + for path in sorted(RESULTS.rglob('*')) if path.is_file() and path.suffix != '.pyc' + and path != RESULTS / 'record.json'}}, 'goalComplete': False} + encoded = json.dumps(record, ensure_ascii=False, indent=2) + '\n' + (RESULTS / 'record.json').write_text(encoded) + (ROOT / 'docs/validation-record.json').write_text(encoded) + print(json.dumps({'additionalNoticeFiles': 2, 'packageFiles': package['payloadFiles'], + 'smokeConditions': 12, 'compiledSourcesUnchanged': True, 'goalComplete': False})) + + +if __name__ == '__main__': + main() diff --git a/tests/source_archives/verify_toml.py b/tests/source_archives/verify_toml.py new file mode 100644 index 0000000..39afa6f --- /dev/null +++ b/tests/source_archives/verify_toml.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Compare the pinned toml++ source archive with installed development inputs.""" +import argparse +import hashlib +import json +from pathlib import Path +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--output', type=Path, required=True) +args = parser.parse_args() +pin = json.loads(Path(__file__).with_name('pins.json').read_text())['tomlplusplus'] +archive = ROOT / '.deps/source-archives' / pin['name'] +data = archive.read_bytes() +if len(data) > pin['maximumBytes'] or any(hashlib.new(name, data).hexdigest() != expected for name, expected in pin['hashes'].items()): + raise SystemExit('Archive does not match the recipe checksums') +headers, license_text = [], None +with tarfile.open(archive) as stream: + for member in stream: + if not member.isfile(): + continue + prefix = 'tomlplusplus-3.4.0/' + if not member.name.startswith(prefix) or '..' in Path(member.name).parts: + raise SystemExit('Unexpected source member path') + relative = member.name[len(prefix):] + if not (relative.startswith('include/toml++/') or relative == 'LICENSE'): + continue + if member.size > 8 * 1024 * 1024: + raise SystemExit('Selected source file exceeds its limit') + content = stream.extractfile(member).read() + if relative == 'LICENSE': + license_text = content + else: + installed = Path('/usr') / relative + headers.append({'path': relative, 'bytes': len(content), 'sha256': hashlib.sha256(content).hexdigest(), + 'installedMatches': installed.is_file() and installed.read_bytes() == content}) +if license_text is None or len(headers) != 51 or not all(row['installedMatches'] for row in headers): + raise SystemExit('Installed header set differs from the pinned source archive') +record = {'archive': str(archive.relative_to(ROOT)), 'sha256': hashlib.sha256(data).hexdigest(), + 'headers': headers, 'allInstalledHeadersMatch': True, 'headerCount': len(headers), + 'licenseSha256': hashlib.sha256(license_text).hexdigest(), + 'licenseMatchesInstalled': license_text == Path('/usr/share/licenses/tomlplusplus/LICENSE').read_bytes(), + 'scope': 'Source archive and current installed headers; not a rebuild or proof of every historical compiler input'} +if not record['licenseMatchesInstalled']: + raise SystemExit('Installed license differs from source archive') +args.output.mkdir(parents=True, exist_ok=False) +(args.output / 'LICENSE').write_bytes(license_text) +(args.output / 'verification.json').write_text(json.dumps(record, indent=2) + '\n') +print('Verified 51 toml++ headers and the source license') diff --git a/tests/test_app.cpp b/tests/test_app.cpp new file mode 100644 index 0000000..cb1e421 --- /dev/null +++ b/tests/test_app.cpp @@ -0,0 +1,3269 @@ +#include "app/controller.h" +#include "app/pdf_canvas.h" +#include "common/contracts.h" +#include "common/memory_pressure.h" +#include "core/state.h" +#include "web/web_profile.h" +#include "font_test_fixture.h" +#include "interaction_measurement.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#include +#include +#endif + +using namespace docview; + +class ScriptProbe : public QObject { + Q_OBJECT + Q_PROPERTY(QVariant value MEMBER value NOTIFY updated) +public: + QVariant value; +signals: + void updated(); +}; + +#ifndef Q_MOC_RUN +namespace { + +bool writeBytes(const QString &path, const QByteArray &bytes) +{ + QFile file(path); + return file.open(QIODevice::WriteOnly | QIODevice::Truncate) && file.write(bytes) == bytes.size(); +} + +QByteArray fileHash(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + return hash.addData(&file) ? hash.result() : QByteArray{}; +} + +QByteArray pagedPdf(int pageCount, int outlineCount = 0, int outlinePageStride = 0, int externalOutlines = 0) +{ + QList objects; + objects << "<< /Type /Catalog /Pages 2 0 R >>" << QByteArray{}; + QByteArray kids; + for (int page = 0; page < pageCount; ++page) { + const auto pageObject = QByteArray::number(3 + page * 2); + const auto streamObject = QByteArray::number(4 + page * 2); + kids += pageObject + " 0 R "; + objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents " + + streamObject + " 0 R /Resources << >> >>"; + const QByteArray color = pageCount >= 5000 && page == pageCount - 1 ? "0.1 0.7 0.3" : "0.1 0.3 0.7"; + const QByteArray stream = color + " rg 50 50 495 742 re f\n1 1 1 rg 80 100 435 " + + QByteArray::number(20 + (page % 50) * 8) + " re f\n"; + objects << "<< /Length " + QByteArray::number(stream.size()) + " >>\nstream\n" + stream + "endstream"; + } + objects[1] = "<< /Type /Pages /Count " + QByteArray::number(pageCount) + " /Kids [" + kids + "] >>"; + if (outlineCount) { + const int root = objects.size() + 1; + objects[0] = "<< /Type /Catalog /Pages 2 0 R /Outlines " + QByteArray::number(root) + " 0 R >>"; + objects << "<< /Type /Outlines /First " + QByteArray::number(root + 1) + " 0 R /Last " + + QByteArray::number(root + outlineCount) + " 0 R /Count " + QByteArray::number(outlineCount) + " >>"; + for (int index = 0; index < outlineCount; ++index) { + const int id = objects.size() + 1; + QByteArray item = "<< /Title (Chapter " + QByteArray::number(index) + ") /Parent " + + QByteArray::number(root) + " 0 R"; + if (index < externalOutlines) + item += index % 2 ? " /A << /S /Launch /F (blocked-program) >>" + : " /A << /S /URI /URI (https://example.invalid/outline) >>"; + else + item += " /Dest [" + QByteArray::number(3 + 2 * qMin(pageCount - 1, index * outlinePageStride)) + " 0 R /Fit]"; + if (index) item += " /Prev " + QByteArray::number(id - 1) + " 0 R"; + if (index + 1 < outlineCount) item += " /Next " + QByteArray::number(id + 1) + " 0 R"; + objects << item + " >>"; + } + } + QByteArray document = "%PDF-1.7\n"; + QList offsets{0}; + for (qsizetype i = 0; i < objects.size(); ++i) { + offsets << document.size(); + document += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n"; + } + const auto xref = document.size(); + document += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n"; + for (qsizetype i = 1; i < offsets.size(); ++i) + document += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n"; + document += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n" + + QByteArray::number(xref) + "\n%%EOF\n"; + return document; +} + +bool makeZip(const QString &path, const QList> &files) +{ + int error = 0; + zip_t *archive = zip_open(QFile::encodeName(path).constData(), ZIP_CREATE | ZIP_TRUNCATE, &error); + if (!archive) + return false; + for (const auto &[name, bytes] : files) { + auto *source = zip_source_buffer(archive, bytes.constData(), static_cast(bytes.size()), 0); + if (!source) { zip_discard(archive); return false; } + const auto index = zip_file_add(archive, name.constData(), source, ZIP_FL_ENC_UTF_8); + if (index < 0) { zip_source_free(source); zip_discard(archive); return false; } + if (zip_set_file_compression(archive, static_cast(index), ZIP_CM_STORE, 0) < 0 + || zip_file_set_external_attributes(archive, static_cast(index), 0, ZIP_OPSYS_UNIX, 0100600u << 16) < 0) { + zip_discard(archive); + return false; + } + } + if (zip_close(archive) < 0) { zip_discard(archive); return false; } + return true; +} + +int greenPagePixels(const QImage &image) +{ + int count = 0; + for (int y = 0; y < image.height(); y += 2) { + for (int x = 0; x < image.width(); x += 2) { + const auto color = image.pixelColor(x, y); + if (color.red() < 45 && color.green() >= 160 && color.green() <= 200 + && color.blue() >= 60 && color.blue() <= 95) + ++count; + } + } + return count; +} + +QByteArray htmlFixture() +{ + return R"(Fixture + +

      First heading

      Readable document

      +

      Second heading

      Local target +
      + +)"; +} + +QList> epubFixture() +{ + return { + {"mimetype", "application/epub+zip"}, + {"META-INF/container.xml", R"()"}, + {"OPS/book.opf", R"(urn:docview:integrationIntegration booken)"}, + {"OPS/nav.xhtml", R"(Contents)"}, + {"OPS/one.xhtml", R"(One

      Chapter One

      First spine chapter.

      )"}, + {"OPS/aside.xhtml", R"(Aside

      Nonlinear aside

      )"}, + {"OPS/two.xhtml", R"(Two

      Chapter Two

      Second linear chapter.

      )"} + }; +} + +} // namespace +#endif + +class AppTest : public QObject { + Q_OBJECT + QTemporaryDir documents_; + std::unique_ptr reader_; + std::unique_ptr engine_; + QQuickWindow *window_ = nullptr; + PdfCanvas *canvas_ = nullptr; + ScriptProbe probe_; + QString manyPages_, stressPages_, html_, zip_, epub_, configuration_; + + StoragePaths testStorage() const + { + // XDG variables do not redirect Windows Known Folders. Inject all + // writable locations so history-clear tests never touch real state. + return {configuration_, documents_.filePath("state/state.json"), + documents_.filePath("cache"), documents_.filePath("logs")}; + } + + Session *activeSession() const + { + for (auto *object : reader_->children()) { + if (auto *session = dynamic_cast(object); session && session->token == reader_->webToken()) + return session; + } + return nullptr; + } + + QObject *activeWeb() const + { + if (!window_) + return nullptr; + for (auto *object : window_->findChildren()) { + if (object->property("documentToken").toString() == reader_->webToken() + && object->property("isCurrent").toBool()) + return object; + } + return nullptr; + } + + bool evaluateWeb(const QString &javascript, QVariant *result) + { + auto *view = activeWeb(); + if (!view) + return false; + const auto json = QJsonDocument(QJsonArray{javascript}).toJson(QJsonDocument::Compact); + const auto encoded = QString::fromUtf8(json.mid(1, json.size() - 2)); + QSignalSpy completed(&probe_, &ScriptProbe::updated); + probe_.value = {}; + QQmlExpression expression(engine_->rootContext(), view, + "runJavaScript(" + encoded + ", 1, function(value) { testProbe.value = value; })"); + expression.evaluate(); + if (expression.hasError()) { + qWarning() << expression.error(); + return false; + } + QElapsedTimer timer; + timer.start(); + while (completed.isEmpty() && timer.elapsed() < 10000) + QTest::qWait(5); + if (completed.isEmpty()) + return false; + *result = probe_.value; + return true; + } + + void key(Qt::Key key, Qt::KeyboardModifiers modifiers = {}) + { + // QtTest's Qt::Key overload leaves the event text lowercase even with + // Shift; the character overload models the layout-produced text. + if (key >= Qt::Key_A && key <= Qt::Key_Z && modifiers.testFlag(Qt::ShiftModifier)) + QTest::keyClick(window_, static_cast(key), modifiers); + else + QTest::keyClick(window_, key, modifiers); + QCoreApplication::processEvents(); + } + + void startUi(bool readOnly = false) + { + reader_ = std::make_unique(); + reader_->initialize(configuration_, readOnly, testStorage()); + engine_ = std::make_unique(); + engine_->rootContext()->setContextProperty("reader", reader_.get()); + engine_->rootContext()->setContextProperty("testProbe", &probe_); + engine_->load(QUrl("qrc:/qml/Main.qml")); + QVERIFY2(!engine_->rootObjects().isEmpty(), "Main.qml failed to instantiate"); + window_ = qobject_cast(engine_->rootObjects().first()); + QVERIFY(window_); + reader_->attachWindow(window_); + canvas_ = window_->findChild(); + QVERIFY(canvas_); + QVERIFY(QTest::qWaitForWindowExposed(window_, 10000)); + window_->requestActivate(); + QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000); + } + + bool resizeTestWindow(const QSize &size) + { + if (QGuiApplication::platformName() != "wayland" || qEnvironmentVariable("DOCVIEW_PRIVATE_WAYLAND_TEST") != "1") { + window_->resize(size); + return true; + } + // This opt-in belongs exclusively to run_wayland_validation.py's + // private compositor. Select its socket and only this process's window. + const auto socket = qEnvironmentVariable("SWAYSOCK"); + const auto runtime = qEnvironmentVariable("XDG_RUNTIME_DIR"); + if (socket.isEmpty() || !QDir::isAbsolutePath(socket) || !QFileInfo::exists(socket) + || QFileInfo(socket).absolutePath() != runtime + || !QFileInfo(runtime).fileName().startsWith("docview-wayland-")) return false; + QProcess command; + const auto resize = QString("[pid=%1] resize set width %2 px height %3 px") + .arg(QCoreApplication::applicationPid()).arg(size.width()).arg(size.height()); + command.start("swaymsg", {"-s", socket, "-r", "-t", "command", resize}); + if (!command.waitForStarted(2000) || !command.waitForFinished(3000)) { + command.kill(); command.waitForFinished(1000); return false; + } + const auto bytes = command.readAllStandardOutput(); + if (command.exitStatus() != QProcess::NormalExit || command.exitCode() != 0 || bytes.size() > 65536) return false; + QJsonParseError error; + const auto response = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !response.isArray() || response.array().isEmpty()) return false; + for (const auto &value : response.array()) + if (!value.isObject() || !value.toObject().value("success").isBool() || !value.toObject().value("success").toBool()) return false; + // The client configure alone can precede Sway's transaction commit. + // Wait for both client and compositor geometry before another resize. + QElapsedTimer deadline; deadline.start(); + while (deadline.elapsed() < 5000) { + QTest::qWait(10); + QProcess treeCommand; + treeCommand.start("swaymsg", {"-s", socket, "-r", "-t", "get_tree"}); + if (!treeCommand.waitForStarted(1000) || !treeCommand.waitForFinished(1000)) { + treeCommand.kill(); treeCommand.waitForFinished(1000); return false; + } + const auto treeBytes = treeCommand.readAllStandardOutput(); + if (treeCommand.exitCode() != 0 || treeBytes.size() > 1024 * 1024) return false; + const auto tree = QJsonDocument::fromJson(treeBytes, &error); + if (error.error != QJsonParseError::NoError || !tree.isObject()) return false; + int visited = 0; + std::function matches = [&](const QJsonObject &node, int depth) { + if (++visited > 4096 || depth > 32) return false; + if (node.value("pid").toDouble() == double(QCoreApplication::applicationPid())) { + const auto rect = node.value("rect").toObject(); + return rect.value("width").toInt() == size.width() && rect.value("height").toInt() == size.height(); + } + for (const auto *key : {"nodes", "floating_nodes"}) + for (const auto &child : node.value(key).toArray()) + if (matches(child.toObject(), depth + 1)) return true; + return false; + }; + if (window_->size() == size && matches(tree.object(), 0)) return true; + } + return false; + } + + bool captureRecentScreen(const QString &name) + { + const auto directory = qEnvironmentVariable("DOCVIEW_RECENT_SCREENSHOTS"); + if (directory.isEmpty()) + return true; + if (!QDir().mkpath(directory)) + return false; + window_->update(); + QTest::qWait(100); + const auto image = window_->grabWindow(); + return !image.isNull() && image.save(QDir(directory).filePath(name + ".png")); + } + +private slots: + void initTestCase() + { + QVERIFY(documents_.isValid()); + manyPages_ = documents_.filePath("50 pages 日本語.pdf"); + stressPages_ = documents_.filePath("5000 pages.pdf"); + html_ = documents_.filePath("index.html"); + zip_ = documents_.filePath("book.zip"); + epub_ = documents_.filePath("book.epub"); + configuration_ = documents_.filePath("config.toml"); + QVERIFY(writeBytes(manyPages_, pagedPdf(50))); + QVERIFY(writeBytes(stressPages_, pagedPdf(5000))); + QVERIFY(writeBytes(html_, htmlFixture())); + QVERIFY(writeBytes(documents_.filePath("style.css"), "body{background:rgb(240,245,250)}h1{color:rgb(10,20,30)}")); + QVERIFY(writeBytes(documents_.filePath("other.html"), "

      Linked destination

      ")); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n")); + QVERIFY(makeZip(zip_, {{"index.html", htmlFixture()}, {"style.css", "h1{color:rgb(40,50,60)}"}, + {"other.html", "

      Archive linked destination

      "}})); + QVERIFY(makeZip(epub_, epubFixture())); +#ifdef Q_OS_WIN + const QString suffix = ".exe"; +#else + const QString suffix; +#endif + QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-pdf-worker" + suffix)); + QVERIFY(QFileInfo::exists(QCoreApplication::applicationDirPath() + "/docview-archive-worker" + suffix)); + } + + void init() + { + QByteArray settings = "[privacy]\nremember_position=false\nrecent_documents=0\n"; + if (QByteArray(QTest::currentTestFunction()) == "visibleSidebarKeepsDocumentUsable") + settings += "[ui]\npages_visible=true\n"; + QVERIFY(writeBytes(configuration_, settings)); + startUi(); + } + + void cleanup() + { + if (reader_) + reader_->cancel(); + engine_.reset(); + window_ = nullptr; + canvas_ = nullptr; + reader_.reset(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + QCoreApplication::processEvents(); + } + + void recentDocumentsRespectPrivacyCapAndClear() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=20\n")); + QVERIFY(reader_->command("reload")); + auto *store = reader_->findChild(); + QVERIFY(store); + QList> originals; + for (int i = 0; i < 8; ++i) { + const auto path = documents_.filePath(QString("recent %1 日本語.pdf").arg(i)); + QVERIFY(writeBytes(path, pagedPdf(1))); + originals.append({path, fileHash(path)}); + QVERIFY(store->remember(path, {})); + } + QVERIFY(store->flush()); + cleanup(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); + startUi(); + auto *list = window_->findChild("recentDocumentsList"); + auto *clear = window_->findChild("clearRecentDocuments"); + QVERIFY(list && clear); + QCOMPARE(reader_->historyCount(), 8); + QCOMPARE(reader_->recentDocuments().size(), 3); + QCOMPARE(reader_->recentDocuments().first().toMap().value("title").toString(), QStringLiteral("recent 7 日本語.pdf")); + QCOMPARE(list->property("count").toInt(), 3); + QVERIFY(list->isVisible()); + QTRY_VERIFY(list->hasActiveFocus()); + window_->resize(1100, 760); + QVERIFY(captureRecentScreen("recent-dark-1100x760")); + const auto oldId = reader_->recentDocuments().first().toMap().value("documentId").toString(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=0\n")); + QVERIFY(reader_->command("reload")); + QVERIFY(reader_->recentDocuments().isEmpty()); + QVERIFY(!list->isVisible()); + QVERIFY(!clear->isVisible()); + reader_->openRecent(oldId); + QCOMPARE(reader_->state(), "Empty"); + QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n")); + QVERIFY(reader_->command("reload")); + QCOMPARE(list->property("count").toInt(), 8); + window_->resize(480, 360); + QTRY_VERIFY(list->height() > 0 && list->height() <= 150); + auto *screen = window_->findChild("initialScreen"); + QVERIFY(screen && screen->height() < window_->height()); + auto *notice = window_->findChild("noticeBanner"); + QVERIFY(notice && notice->isVisible()); + QTRY_VERIFY(clear->mapToScene(QPointF(0, clear->height())).y() <= notice->mapToScene(QPointF()).y()); + QVERIFY(captureRecentScreen("recent-dark-480x360")); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=100\n[ui]\ntheme=\"light\"\n")); + QVERIFY(reader_->command("reload")); + window_->resize(1100, 760); + QVERIFY(captureRecentScreen("recent-light-1100x760")); + QTRY_VERIFY(list->hasActiveFocus()); + key(Qt::Key_G, Qt::ShiftModifier); + QCOMPARE(list->property("currentIndex").toInt(), 7); + key(Qt::Key_Tab); + QVERIFY(clear->hasActiveFocus()); + key(Qt::Key_Return); + QCOMPARE(reader_->mode(), "dialog"); + auto *dialog = window_->findChild("historyDialog"); + auto *confirmation = window_->findChild("historyConfirmation"); + QVERIFY(dialog && confirmation); + QVERIFY(confirmation->property("text").toString().startsWith("8 ")); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + QCOMPARE(reader_->historyCount(), 8); + key(Qt::Key_Tab); + key(Qt::Key_Return); + QCOMPARE(reader_->mode(), "dialog"); + QVERIFY(QMetaObject::invokeMethod(dialog, "accept")); + QTRY_COMPARE(reader_->historyCount(), 0); + QCOMPARE(list->property("count").toInt(), 0); + QVERIFY(!list->isVisible()); + for (const auto &[path, hash] : originals) + QCOMPARE(fileHash(path), hash); + cleanup(); + startUi(); + QVERIFY(reader_->recentDocuments().isEmpty()); + QCOMPARE(reader_->historyCount(), 0); + } + + void recentDocumentKeyboardReopenRestoresMatchingOriginal() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); + QVERIFY(reader_->command("reload")); + auto *store = reader_->findChild(); + QVERIFY(store); + QVERIFY(store->remember(manyPages_, {{"pageIndex", 41}, {"zoom", 100.0}})); + QVERIFY(store->remember(html_, {})); + QVERIFY(store->flush()); + const auto pdfHash = fileHash(manyPages_), htmlHash = fileHash(html_); + cleanup(); + startUi(); + auto *list = window_->findChild("recentDocumentsList"); + QVERIFY(list); + QCOMPARE(list->property("count").toInt(), 2); + QTRY_VERIFY(list->hasActiveFocus()); + key(Qt::Key_J); + QCOMPARE(list->property("currentIndex").toInt(), 1); + key(Qt::Key_Up); + QCOMPARE(list->property("currentIndex").toInt(), 0); + key(Qt::Key_Down); + QCOMPARE(list->property("currentIndex").toInt(), 1); + key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(reader_->format(), "pdf"); + QCOMPARE(reader_->title(), QFileInfo(manyPages_).fileName()); + QCOMPARE(canvas_->currentPage(), 41); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(!list->isVisible()); + QCOMPARE(fileHash(manyPages_), pdfHash); + QCOMPARE(fileHash(html_), htmlHash); + } + + void recentDocumentMissingOrReplaced_data() + { + QTest::addColumn("replaced"); + QTest::newRow("missing") << false; + QTest::newRow("replaced-with-identical-bytes-and-mtime") << true; + } + void recentDocumentMissingOrReplaced() + { + QFETCH(bool, replaced); + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); + QVERIFY(reader_->command("reload")); + const auto path = documents_.filePath(replaced ? "replaced recent.pdf" : "missing recent.pdf"); + const auto bytes = pagedPdf(50); + QVERIFY(writeBytes(path, bytes)); + const auto identity = StateStore::fileIdentity(path); + auto *store = reader_->findChild(); + QVERIFY(store); + QVERIFY(store->remember(path, {{"pageIndex", 41}, {"zoom", 100.0}})); + const auto hash = fileHash(path); + QVERIFY(QFile::rename(path, path + ".original")); + if (replaced) { + QVERIFY(writeBytes(path, bytes)); + QFile newFile(path); + QVERIFY(newFile.open(QIODevice::ReadWrite)); + QVERIFY(newFile.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); + newFile.close(); + const auto current = StateStore::fileIdentity(path); + QCOMPARE(current.value("size"), identity.value("size")); + QCOMPARE(current.value("mtime"), identity.value("mtime")); + QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity")); + } + QTRY_COMPARE(reader_->recentDocuments().size(), 1); + key(Qt::Key_Return); + QCOMPARE(reader_->state(), "Empty"); + QVERIFY(reader_->webToken().isEmpty()); + QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); + QVERIFY(reader_->notice().contains("Ctrl+O")); + QVERIFY(reader_->notice().contains(replaced ? QStringLiteral("変更または置換") : QStringLiteral("見つからない"))); + QCOMPARE(fileHash(path + ".original"), hash); + if (replaced) { + QCOMPARE(fileHash(path), hash); + reader_->openPath(path); // Explicit file selection may open the new original, without the old position. + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(canvas_->currentPage(), 0); + QCOMPARE(fileHash(path), hash); + } + } + + void initialUiAndCommandEntry() + { + QCOMPARE(reader_->state(), "Empty"); + QCOMPARE(reader_->title(), "DocView"); + QCOMPARE(window_->property("tocVisible").toBool(), false); + QCOMPARE(window_->property("pagesVisible").toBool(), false); + QCOMPARE(window_->property("statusVisible").toBool(), true); + key(Qt::Key_Colon); + QCOMPARE(reader_->mode(), "command"); + key(Qt::Key_J); + key(Qt::Key_4); + QCOMPARE(reader_->mode(), "command"); + QVERIFY(reader_->pending().isEmpty()); + key(Qt::Key_Escape); + QCOMPARE(reader_->mode(), "normal"); + key(Qt::Key_Colon); + QVERIFY(QGuiApplication::focusObject()); + QVERIFY(QGuiApplication::focusObject()->setProperty("text", "help")); + key(Qt::Key_Return); + QCOMPARE(reader_->mode(), "dialog"); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + key(Qt::Key_Z); key(Qt::Key_S); + QCOMPARE(window_->property("statusVisible").toBool(), false); + key(Qt::Key_Z); key(Qt::Key_S); + QCOMPARE(window_->property("statusVisible").toBool(), true); + QVERIFY(!reader_->command("page -1")); + QCOMPARE(reader_->state(), "Empty"); + } + + void pdfNumericNavigationRenderingAndSourceIntegrity() + { + const auto originalHash = fileHash(manyPages_); + QSignalSpy presented(reader_.get(), &Controller::framePresented); + reader_->openPath(manyPages_); + QCOMPARE(reader_->state(), "Opening"); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(reader_->format(), "pdf"); + QCOMPARE(reader_->pages().size(), 50); + QTRY_VERIFY_WITH_TIMEOUT(!presented.isEmpty(), 15000); + key(Qt::Key_4); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000); + QVERIFY(reader_->position().startsWith("42 / 50")); + const auto before = canvas_->currentPage(); + key(Qt::Key_9); key(Qt::Key_9); key(Qt::Key_G, Qt::ShiftModifier); + QCOMPARE(canvas_->currentPage(), before); + QVERIFY(reader_->notice().contains("範囲外")); + key(Qt::Key_G); key(Qt::Key_G); + QTRY_COMPARE(canvas_->currentPage(), 0); + const auto zoom = canvas_->zoom(); + reader_->execute("zoom.in"); + QVERIFY(canvas_->zoom() > zoom); + reader_->execute("view.rotate", {{"degrees", 90}}); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + const QImage frame = window_->grabWindow(); + QVERIFY(!frame.isNull()); + int bluePixels = 0; + for (int y = 0; y < frame.height(); y += 2) { + for (int x = 0; x < frame.width(); x += 2) { + const auto color = frame.pixelColor(x, y); + if (color.red() < 45 && color.green() >= 60 && color.green() <= 95 + && color.blue() >= 160 && color.blue() <= 200) + ++bluePixels; + } + } + QVERIFY2(bluePixels > 500, "Rendered frame did not contain the fixture's original blue page content"); + QCOMPARE(fileHash(manyPages_), originalHash); + } + + void initialPageAppliesOnlyToRequestedDocument() + { + reader_->openPath(manyPages_, {}, 42); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 0, 10000); + const auto previousTitle = reader_->title(); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"), {}, 2); + QCOMPARE(reader_->state(), "Opening"); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(reader_->title(), previousTitle); + QCOMPARE(canvas_->currentPage(), 0); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(canvas_->currentPage(), 0); + } + + void largePdfFarJumpsRemainResponsiveAndCancel() + { + const auto originalHash = fileHash(stressPages_); + QElapsedTimer elapsed; + elapsed.start(); + qint64 previousTick = 0, longestGap = 0; + int ticks = 0; + QTimer heartbeat; + connect(&heartbeat, &QTimer::timeout, this, [&] { + const auto now = elapsed.elapsed(); + longestGap = qMax(longestGap, now - previousTick); + previousTick = now; + ++ticks; + }); + heartbeat.start(10); + QSignalSpy requested(canvas_, &PdfCanvas::tileRequested); + QSignalSpy metadata(canvas_, &PdfCanvas::pageNeeded); + reader_->openPath(stressPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QCOMPARE(reader_->pages().size(), 5000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); + QVERIFY(ticks > 0); + const int firstPageTicks = ticks; + requested.clear(); + reader_->execute("nav.page", {{"page", 5000}}); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); + QVERIFY(reader_->position().startsWith("5000 / 5000")); + QVERIFY(ticks > firstPageTicks); + QVERIFY(!metadata.isEmpty()); + QVERIFY(!requested.isEmpty()); + // A far jump must request the destination rather than render all + // intervening pages. Both the visible page and nearby prefetch count. + QSet renderedPages; + for (const auto &request : requested) + renderedPages.insert(request[0].toInt()); + QVERIFY(renderedPages.contains(4999)); + QVERIFY2(renderedPages.size() < 10, "Far jump rendered intervening PDF pages"); + + requested.clear(); + // Let each jump dispatch before changing direction, so the test + // exercises worker replies from obsolete render revisions. + for (const int page : {2500, 42, 4999, 1250}) { + const auto previousRequests = requested.size(); + reader_->execute("nav.page", {{"page", page}}); + QTRY_VERIFY_WITH_TIMEOUT(requested.size() > previousRequests, 10000); + } + reader_->execute("zoom.in"); + reader_->execute("zoom.out"); + reader_->execute("nav.page", {{"page", 5000}}); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 4999, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); + QSet revisions; + for (const auto &request : requested) + revisions.insert(request[2].toLongLong()); + QVERIFY2(revisions.size() >= 2, "Rapid jumps did not dispatch different render revisions"); + QTest::qWait(100); + QCOMPARE(canvas_->currentPage(), 4999); + QVERIFY2(greenPagePixels(window_->grabWindow()) > 500, + "The final viewport did not show the distinct green last page"); + + const auto stressTitle = reader_->title(); + reader_->openPath(epub_); + QCOMPARE(reader_->state(), "Opening"); + reader_->cancel(); + QCOMPARE(reader_->state(), "Ready"); + QCOMPARE(reader_->title(), stressTitle); + QCOMPARE(canvas_->currentPage(), 4999); + // Replace while far-away work is queued and ensure no old frame or + // callback can reinstate the large document after the commit. + reader_->execute("nav.page", {{"page", 1234}}); + QTest::qWait(22); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 20000); + QCOMPARE(reader_->pages().size(), 50); + QCOMPARE(canvas_->currentPage(), 0); + QTest::qWait(200); + QCOMPARE(reader_->pages().size(), 50); + QCOMPARE(canvas_->currentPage(), 0); + QCOMPARE(greenPagePixels(window_->grabWindow()), 0); + heartbeat.stop(); + // Detect event-loop starvation with a generous CI bound. The + // separate performance harness measures the specified p95 targets. + QVERIFY2(longestGap < 1500, qPrintable(QString("GUI stalled for %1 ms").arg(longestGap))); + qInfo().nospace() << "T03 synthetic 5000 pages: " << elapsed.elapsed() << " ms, " + << ticks << " event-loop ticks, longest gap " << longestGap << " ms"; + QCOMPARE(fileHash(stressPages_), originalHash); + } + + void configuredNontextLogicalKeys() + { + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QVERIFY(writeBytes(configuration_, R"([privacy] +remember_position=false +recent_documents=0 +[[keybindings]] +mode='normal' +context='global' +keys=['F2'] +command='panel.status.toggle' +[[keybindings]] +mode='normal' +context='content' +keys=['Home'] +command='nav.document_start' +[[keybindings]] +mode='normal' +context='content' +keys=['Ctrl+Enter'] +command='nav.page' +args={page=42} +[[keybindings]] +mode='normal' +context='content' +keys=['Shift+Tab'] +command='nav.page' +args={page=2} +)")); + QVERIFY(reader_->command("reload")); + QVERIFY2(!reader_->notice().startsWith("E_CONFIG"), qPrintable(reader_->notice())); + key(Qt::Key_F2); + QCOMPARE(window_->property("statusVisible").toBool(), false); + key(Qt::Key_Return, Qt::ControlModifier); + QTRY_COMPARE(canvas_->currentPage(), 41); + key(Qt::Key_Home); + QTRY_COMPARE(canvas_->currentPage(), 0); + key(Qt::Key_Tab, Qt::ShiftModifier); + QTRY_COMPARE(canvas_->currentPage(), 1); + key(Qt::Key_Home); + key(Qt::Key_Backtab); + QTRY_COMPARE(canvas_->currentPage(), 1); + key(Qt::Key_Colon); + QCOMPARE(reader_->mode(), "command"); + key(Qt::Key_F2); + key(Qt::Key_Home); + QCOMPARE(window_->property("statusVisible").toBool(), false); + QCOMPARE(canvas_->currentPage(), 1); + key(Qt::Key_Escape); + key(Qt::Key_F2); + QCOMPARE(window_->property("statusVisible").toBool(), true); + } + + void reloadingHiddenFocusedPanelsReturnsToContent_data() + { + QTest::addColumn("panel"); + QTest::newRow("toc") << QString("toc"); + QTest::newRow("pages") << QString("pages"); + } + + void visibleSidebarKeepsDocumentUsable() + { + QVERIFY(window_->property("pagesVisible").toBool()); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QVERIFY(!reader_->outline().isEmpty()); + auto *sidebar = window_->findChild("navigationSidebar"); + auto *content = window_->findChild("documentContent"); + QVERIFY(sidebar && content); + for (const int width : {1100, 480, 1100}) { + window_->resize(width, 800); + QTRY_COMPARE(window_->width(), width); + const QStringList transitions{{}, "panel.pages.toggle", "panel.toc.toggle", + "panel.pages.toggle", "panel.toc.toggle", + "panel.pages.toggle", "panel.pages.toggle"}; + for (const auto &command : transitions) { + if (!command.isEmpty()) + reader_->execute(command); + QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() * .6 - 2, 5000); + if (sidebar->isVisible()) { + QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() > 0, 5000); + QTRY_VERIFY_WITH_TIMEOUT(sidebar->width() <= window_->width() * .4 + 1, 5000); + } else { + QTRY_VERIFY_WITH_TIMEOUT(content->width() >= window_->width() - 2, 5000); + } + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(canvas_->width() >= window_->width() * .6 - 2); + } + } + } + + void invalidCapabilitiesCannotDispatchCommands_data() + { + QTest::addColumn("fault"); + for (const auto *fault : {"missing", "unknown-state", "state-type", "unknown-name", "missing-reason"}) + QTest::newRow(fault) << QString::fromLatin1(fault); + } + + void invalidCapabilitiesCannotDispatchCommands() + { + QFETCH(QString, fault); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + auto *session = activeSession(); + QVERIFY(session); + const auto original = session->meta.value("capabilities").toMap(); + const auto originalReasons = session->meta.value("capabilityReasons").toMap(); + QVERIFY(validateCapabilities(original, originalReasons)); + auto altered = original; + if (fault == "missing") + altered.remove("pageNavigation"); + else if (fault == "unknown-state") + altered["pageNavigation"] = "enabled"; + else if (fault == "state-type") + altered["pageNavigation"] = true; + else if (fault == "unknown-name") + altered["futureCapability"] = "supported"; + else if (fault == "missing-reason") + session->meta["capabilityReasons"] = QVariantMap{}; + session->meta["capabilities"] = altered; + QSignalSpy ui(reader_.get(), &Controller::uiCommand); + reader_->execute("nav.page", {{"page", 2}}); + reader_->execute("search.open"); + reader_->execute("panel.toc.toggle"); + QCOMPARE(canvas_->currentPage(), 0); + QCOMPARE(ui.size(), 0); + QCOMPARE(reader_->mode(), "normal"); + QVERIFY(reader_->notice().startsWith("E_WORKER_CRASH")); + session->meta["capabilities"] = original; + session->meta["capabilityReasons"] = originalReasons; + reader_->execute("nav.chapter_next"); + QVERIFY(reader_->notice().startsWith("E_CHAPTER_NAVIGATION_UNSUPPORTED")); + reader_->execute("nav.page", {{"page", 2}}); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + } + + void reloadingHiddenFocusedPanelsReturnsToContent() + { + QFETCH(QString, panel); + const QByteArray binding = "\n[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['F5']\ncommand='config.reload'\n"; + const QByteArray privacy = "[privacy]\nremember_position=false\nrecent_documents=0\n"; + QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=true\n" + binding)); + reader_->execute("config.reload"); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); + QCOMPARE(reader_->context(), panel); + QVERIFY(window_->property(qPrintable(panel + "Visible")).toBool()); + QVERIFY(writeBytes(configuration_, privacy + "[ui]\n" + panel.toUtf8() + "_visible=false\n" + binding)); + // Invoke the configured normal-mode binding; command entry's focus + // restoration would hide this regression. + key(Qt::Key_F5); + QVERIFY(!window_->property(qPrintable(panel + "Visible")).toBool()); + QCOMPARE(reader_->context(), "content"); + QVERIFY(canvas_->hasActiveFocus()); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + reader_->execute("nav.document_start"); + const auto before = canvas_->offset(); + key(Qt::Key_J); + QVERIFY(canvas_->offset() > before); + } + + void explicitCloseClearsResourcesAndRejectsPendingOpen() + { + const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000); + QPointer previous = activeSession(); QVERIFY(previous); + const auto token = previous->token; + reader_->openPath(html_); // Closing also cancels a staged replacement. + reader_->closeDocument(); + QCOMPARE(reader_->state(), "Empty"); QCOMPARE(reader_->format(), QString{}); + QVERIFY(reader_->outline().isEmpty()); QVERIFY(reader_->pages().isEmpty()); + QVERIFY(reader_->benchmarkSnapshot().isEmpty()); + QCOMPARE(canvas_->cacheCostBytes(), qint64(0)); QCOMPARE(canvas_->pendingRenderCount(), qsizetype(0)); + reader_->webLoaded(token, true); reader_->webIndex(token, {{"headings", QVariantList{}}}); + QTest::qWait(200); QCOMPARE(reader_->state(), "Empty"); + QTRY_VERIFY(previous.isNull()); + reader_->closeDocument(); // Idempotent while already empty. + reader_->openPath(path); + QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && canvas_->viewportReady(), 15000); + QVERIFY(activeSession()->token != token); QCOMPARE(fileHash(path), original); + reader_->openPath(html_); + QTRY_VERIFY_WITH_TIMEOUT(reader_->format() == "html" && reader_->state() == "Ready", 15000); + QPointer web = activeSession(); + reader_->closeDocument(); + QTRY_VERIFY(web.isNull()); + QCOMPARE(reader_->format(), QString{}); QCOMPARE(reader_->state(), "Empty"); + } + + void formOnlyStructureProvidesExactHeadingNavigation() + { + const auto path = QFINDTESTDATA("fixtures/pdf/headings/form-only-structure.pdf"); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session); + QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); + QVERIFY(!session->meta.value("capabilityReasons").toMap().contains("headings")); + QVERIFY(!session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_STRUCTURE_LIMITED")); + canvas_->setZoom(800); canvas_->goTo(0); + reader_->execute("nav.heading_next"); + const auto location = canvas_->location(); + QCOMPARE(location.value("pageIndex").toInt(), 0); + // Authored baseline is 130 pt, with a 16 pt glyph height after the Form translation. + QVERIFY(location.value("yPt").toDouble() > 130.0 && location.value("yPt").toDouble() < 147.0); + QVERIFY(!reader_->notice().contains("E_PDF_STRUCTURE_LIMITED")); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(reader_->state(), "Ready"); + QCOMPARE(fileHash(path), original); + } + + void pagePrecisionHeadingDoesNotReselectItsOwnStart() + { + const auto path = QFINDTESTDATA("fixtures/pdf/headings/vector-heading.pdf"); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session); + QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); + canvas_->setZoom(800); canvas_->goTo(0); canvas_->scroll(0, 300); + QVERIFY(canvas_->location().value("progression").toDouble() > .01); + reader_->execute("nav.heading_previous"); + QCOMPARE(canvas_->currentPage(), 0); + QVERIFY(canvas_->location().value("progression").toDouble() < .0001); + const auto atStart = canvas_->offset(); + reader_->execute("nav.heading_previous"); + QCOMPARE(canvas_->offset(), atStart); + QVERIFY(reader_->notice().contains("見出しの端")); + reader_->execute("nav.heading_next"); + QCOMPARE(canvas_->offset(), atStart); + QVERIFY(reader_->notice().contains("見出しの端")); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(fileHash(path), original); + } + + void rotatedPdfHeadingsFollowDisplayedReadingPosition() + { + const auto path = QFINDTESTDATA("fixtures/pdf/headings/rotated-tags.pdf"); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session); + QTRY_COMPARE_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString(), "supported", 10000); + canvas_->setZoom(800); canvas_->goTo(0); + const double start = canvas_->offset(); + reader_->execute("nav.heading_next"); + const double first = canvas_->offset(); QVERIFY(first > start + 100); + // Intrinsic Rotate 90 makes the heading's PDF x the reading-axis + // coordinate. Its original y must not be compared to the top's y. + auto location = canvas_->location(); + QVERIFY(location.value("xPt").toDouble() >= 50 && location.value("xPt").toDouble() <= 54); + reader_->execute("nav.heading_next"); + const double second = canvas_->offset(); QVERIFY(second > first + 40); + reader_->execute("nav.heading_previous"); + QVERIFY(std::abs(canvas_->offset() - first) < 1); + reader_->execute("nav.heading_previous"); + QVERIFY(reader_->notice().contains("見出しの端")); + QVERIFY(std::abs(canvas_->offset() - first) < 1); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(fileHash(path), original); + } + + void pdfIndexBudgetKeepsDocumentReady_data() + { + QTest::addColumn("outline"); + QTest::newRow("outline-pagination") << true; + QTest::newRow("tagged-headings") << false; + } + + void pdfIndexBudgetKeepsDocumentReady() + { + QFETCH(bool, outline); + const QString path = outline ? documents_.filePath("bounded-outline.pdf") + : QFINDTESTDATA("fixtures/pdf/navigation.pdf"); + if (outline) + QVERIFY(writeBytes(path, pagedPdf(4, 160))); + bool injected = false; + QObject injectionLifetime; + connect(reader_.get(), &Controller::documentChanged, &injectionLifetime, [&] { + if (auto *session = activeSession()) { + // Exercise the real next worker reply at the budget boundary + // without allocating a 32 MiB synthetic index in every run. + session->navigationBytes = MaxNavigationBytes - 1; + injected = true; + } + }); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QVERIFY(injected); + auto *session = activeSession(); + QVERIFY(session && session->worker); + QSignalSpy failures(session->worker, &WorkerProcess::failed); + QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains("E_PDF_INDEX_PARTIAL"), 10000); + QCOMPARE(reader_->state(), "Ready"); + QVERIFY(!session->resourcesStopped); + if (outline) { + QCOMPARE(session->meta.value("nextOutlineCursor").toInt(), -1); + QCOMPARE(reader_->outline().size(), 64); + } + bool pong = false; + session->worker->request("ping", {}, [&](const QCborMap &reply) { + pong = reply.value("result").toMap().value("ready").toBool(); + }); + QTRY_VERIFY_WITH_TIMEOUT(pong, 5000); + reader_->execute("nav.page", {{"page", reader_->pages().size()}}); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), reader_->pages().size() - 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(reader_->state(), "Ready"); + QCOMPARE(failures.size(), 0); + } + + void archiveFailureCompletesPendingResourceRequests() + { + reader_->openPath(zip_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + auto *session = activeSession(); + QVERIFY(session && session->worker && session->handler); + QList completions; + session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; }; + session->extractWaiters["pending-a"] << [&](ResourceFailure failure) { completions << failure; }; + session->extractWaiters["pending-b"] << [&](ResourceFailure failure) { completions << failure; }; + QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection, + Q_ARG(QString, "E_WORKER_CRASH"), Q_ARG(QString, "Injected active-worker failure"))); + QCOMPARE(reader_->state(), "Recovering"); + QCOMPARE(completions, QList({ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed, ResourceFailure::WorkerFailed})); + QVERIFY(session->extractWaiters.isEmpty()); + QVERIFY(session->resourcesStopped); + QSignalSpy blocked(session->handler, &ResourceHandler::blocked); + QVariant result; + QVERIFY(evaluateWeb("(()=>{const image=document.createElement('img');image.id='after-revoke';image.src='other.html';document.body.append(image);return true;})()", &result)); + QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.getElementById('after-revoke').complete && document.getElementById('after-revoke').naturalWidth===0", &result) && result.toBool(), 5000); + QCOMPARE(blocked.size(), 0); // Revoked sessions must not issue new warnings. + QVERIFY(session->extractWaiters.isEmpty()); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTest::qWait(100); + QCOMPARE(completions.size(), 3); // Disposal must not complete them twice. + } + void latePdfFontFailureCanReopenFreshWorker_data() + { + QTest::addColumn("operation"); + QTest::newRow("prefetch") << 0; + QTest::newRow("search") << 1; + QTest::newRow("cancelled-search") << 2; + QTest::newRow("page-metadata") << 3; + } + void latePdfFontFailureCanReopenFreshWorker() + { + QFETCH(int, operation); + const auto path = documents_.filePath("late-font-recovery.pdf"); + QVERIFY(writeBytes(path, deferredFontPdf())); const auto original = fileHash(path); + reader_->openPath(path); canvas_->setPrefetchPages(0); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session && session->worker); + const auto previousToken = session->token; const auto previousGeneration = session->generation; + if (operation == 0) canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); + canvas_->setZoom(300); canvas_->goTo(255); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); + const auto cached = canvas_->cacheCostBytes(); QVERIFY(cached > 0); + int maps = 0; + session->worker->setFontRequestHandler([&](const QString &fontOperation, const QCborMap &, std::function done) { + QCOMPARE(fontOperation, "font.map"); ++maps; + if (operation == 2) reader_->cancel(); // Obsolete search still poisoned this worker's font cache. + done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected late font failure"}}}}); + }); + if (operation == 0) canvas_->setPrefetchPages(1); + else if (operation == 3) canvas_->goTo(256); + else reader_->search("Late"); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000); + QVERIFY(maps > 0); QVERIFY(session->resourcesStopped); + if (operation == 1 || operation == 2) + QCOMPARE(session->meta.value("capabilities").toMap().value("textSearch").toString(), "loading"); + QVERIFY(reader_->notice().contains("E_FONT_FAILED")); QVERIFY(reader_->notice().contains(QStringLiteral("開き直"))); + QVERIFY(canvas_->cacheCostBytes() >= cached); + if (operation != 3) QVERIFY(canvas_->viewportReady()); + reader_->openPath(path); canvas_->setPrefetchPages(0); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + session = activeSession(); QVERIFY(session); + QVERIFY(session->token != previousToken); QVERIFY(session->generation > previousGeneration); + canvas_->goTo(256); QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped); + QCOMPARE(fileHash(path), original); + } + void latePdfFontWarningPersistsInDocumentInfo() + { + const auto path = documents_.filePath("late-font-warning.pdf"); + QVERIFY(writeBytes(path, deferredFontPdf())); + reader_->openPath(path); canvas_->setPrefetchPages(0); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session && session->worker); + canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); + canvas_->setZoom(300); canvas_->goTo(255); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); + session->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function done) { + done({{"found", false}}); // A genuine absence permits PDFium's built-in alternative. + }); + canvas_->setPrefetchPages(1); + QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("warnings").toStringList().join('\n').contains(QStringLiteral("内蔵")), 15000); + QCOMPARE(reader_->state(), "Ready"); QVERIFY(!session->resourcesStopped); + QSignalSpy commands(reader_.get(), &Controller::uiCommand); + reader_->execute("document.info"); QCOMPARE(commands.size(), 1); + QVERIFY(commands[0][1].toMap().value("text").toString().contains(QStringLiteral("内蔵"))); + const auto warnings = session->meta.value("warnings").toStringList(); + QVERIFY(warnings.size() <= 32); for (const auto &warning : warnings) QVERIFY(warning.size() <= 1024); + } + void initialPdfFontErrorPreservesCodeAndOldDocument() + { + reader_->openPath(manyPages_); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + const auto previous = reader_->webToken(); + const auto path = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); + reader_->openPath(path); + Session *pending = nullptr; + for (auto *object : reader_->children()) + if (auto *candidate = dynamic_cast(object); candidate && candidate->token != previous && !candidate->resourcesStopped) + pending = candidate; + QVERIFY(pending && pending->worker); + pending->worker->setFontRequestHandler([](const QString &, const QCborMap &, std::function done) { + done({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Injected initial font failure"}}}}); + }); + QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains("E_FONT_FAILED"), 15000); + QCOMPARE(reader_->webToken(), previous); QCOMPARE(reader_->state(), "Ready"); + QVERIFY(canvas_->viewportReady()); QVERIFY(!reader_->notice().contains("E_PDF_CORRUPT")); + } + void activeFontFailureSurvivesStagingRollback_data() + { + QTest::addColumn("cancelStaging"); + QTest::newRow("staging-fails") << false; + QTest::newRow("staging-cancelled") << true; + } + void activeFontFailureSurvivesStagingRollback() + { + QFETCH(bool, cancelStaging); + const auto path = documents_.filePath("active-font-rollback.pdf"); + QVERIFY(writeBytes(path, deferredFontPdf())); + reader_->openPath(path); canvas_->setPrefetchPages(0); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *active = activeSession(); QVERIFY(active && active->worker); + const auto token = active->token; + canvas_->updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); + canvas_->setZoom(300); canvas_->goTo(255); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && active->worker->idle(), 15000); + std::function oldReply, newReply; + active->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function done) { oldReply = std::move(done); }); + canvas_->setPrefetchPages(1); QTRY_VERIFY_WITH_TIMEOUT(bool(oldReply), 15000); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QCOMPARE(reader_->state(), "Opening"); + Session *staging = nullptr; + for (auto *object : reader_->children()) + if (auto *candidate = dynamic_cast(object); candidate && candidate != active && !candidate->resourcesStopped) staging = candidate; + QVERIFY(staging && staging->worker); + staging->worker->setFontRequestHandler([&](const QString &, const QCborMap &, std::function done) { newReply = std::move(done); }); + QTRY_VERIFY_WITH_TIMEOUT(bool(newReply), 15000); + QCOMPARE(reader_->state(), "Opening"); // The old worker is still allowed to report a fatal error. + oldReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Active font failure"}}}}); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 15000); + QVERIFY(active->resourcesStopped); QCOMPARE(reader_->webToken(), token); + if (cancelStaging) reader_->cancel(); + else { + QPointer failedAttempt = staging; + newReply({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "Staging font failure"}}}}); + QTRY_VERIFY_WITH_TIMEOUT(failedAttempt.isNull(), 15000); + QVERIFY(reader_->notice().contains("E_FONT_FAILED")); + QVERIFY(reader_->notice().contains("navigation.pdf")); + QVERIFY(reader_->notice().contains(QStringLiteral("開き直してください"))); + QVERIFY(!reader_->notice().contains("Staging font failure")); + } + QCOMPARE(reader_->state(), "Recovering"); QCOMPARE(reader_->webToken(), token); + QVERIFY(active->resourcesStopped); QVERIFY(canvas_->viewportReady()); + } + + void clearedHistorySurvivesCloseAndReopen() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); + QVERIFY(reader_->command("reload")); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000); + auto *store = reader_->findChild(); + QVERIFY(store); + QVERIFY(!store->readOnly()); + const auto stateFile = store->filePath(); + // Exercise the actual debounce and on-disk state, not only count(). + auto diskCount = [&] { + QFile file(stateFile); + if (!file.open(QIODevice::ReadOnly)) + return -1; + return int(QJsonDocument::fromJson(file.readAll()).object().value("documents").toArray().size()); + }; + QTRY_COMPARE_WITH_TIMEOUT(diskCount(), 1, 5000); + reader_->clearHistory(); + QCOMPARE(reader_->historyCount(), 0); + QCOMPARE(diskCount(), 0); + // A repaint at the same location must not repopulate cleared state. + window_->update(); + QTest::qWait(100); + QCOMPARE(reader_->historyCount(), 0); + cleanup(); + QCOMPARE(diskCount(), 0); + Controller reopened; + reopened.initialize(configuration_, false, testStorage()); + QCOMPARE(reopened.historyCount(), 0); + } + + void unpresentedJumpDoesNotReplaceSavedPosition() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); + QVERIFY(reader_->command("reload")); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QTRY_COMPARE_WITH_TIMEOUT(reader_->historyCount(), 1, 5000); + auto *store = reader_->findChild(); + QVERIFY(store); + QCOMPARE(store->restore(manyPages_).value("location").toMap().value("pageIndex").toInt(), 0); + const auto stateFile = store->filePath(); + reader_->execute("nav.page", {{"page", 42}}); + QCOMPARE(canvas_->currentPage(), 41); + QVERIFY(!canvas_->viewportReady()); + // Close synchronously before the newly requested tiles can arrive. + cleanup(); + StateStore reopened(stateFile); + const auto restored = reopened.restore(manyPages_).value("location").toMap(); + QVERIFY(!restored.isEmpty()); + QCOMPARE(restored.value("pageIndex").toInt(), 0); + QVERIFY(reopened.clearHistory()); + } + + void panelFocusAndInputProtection() + { + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QVERIFY(!reader_->outline().isEmpty()); + const auto page = canvas_->currentPage(); + key(Qt::Key_T); + QCOMPARE(window_->property("tocVisible").toBool(), true); + QCOMPARE(reader_->context(), "content"); + QCOMPARE(canvas_->currentPage(), page); + key(Qt::Key_P); + QCOMPARE(window_->property("pagesVisible").toBool(), true); + key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); + QCOMPARE(reader_->context(), "toc"); + key(Qt::Key_J); + QCOMPARE(canvas_->currentPage(), page); + key(Qt::Key_W, Qt::ControlModifier); key(Qt::Key_W); + QCOMPARE(reader_->context(), "pages"); + key(Qt::Key_P); + QCOMPARE(reader_->context(), "content"); + QCOMPARE(window_->property("pagesVisible").toBool(), false); + reader_->execute("nav.page", {{"page", 1}}); + const auto offset = canvas_->offset(); + reader_->setMode("command"); + key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); + QCOMPARE(canvas_->offset(), offset); + reader_->setMode("normal"); + QInputMethodEvent composition(QString::fromUtf8("変換中"), {}); + QCoreApplication::sendEvent(window_, &composition); + key(Qt::Key_J); key(Qt::Key_2); key(Qt::Key_G, Qt::ShiftModifier); + QCOMPARE(canvas_->offset(), offset); + QVERIFY(reader_->pending().isEmpty()); + QInputMethodEvent finished; + QCoreApplication::sendEvent(window_, &finished); + } + + void pdfSearchCapabilityFollowsCompletedEvidence_data() + { + QTest::addColumn("hasText"); + QTest::addColumn("query"); + QTest::newRow("text-with-match") << true << QStringLiteral("Needle"); + QTest::newRow("text-without-match") << true << QStringLiteral("DefinitelyAbsentSearchTerm"); + QTest::newRow("no-text") << false << QStringLiteral("Needle"); + } + + void pdfSearchCapabilityFollowsCompletedEvidence() + { + QFETCH(bool, hasText); + QFETCH(QString, query); + const auto empty = documents_.filePath("search-without-text.pdf"); + QVERIFY(writeBytes(empty, pagedPdf(3))); + reader_->openPath(hasText ? QFINDTESTDATA("fixtures/pdf/navigation.pdf") : empty); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session && session->worker); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); + const auto capability = [&] { return session->meta.value("capabilities").toMap().value("textSearch").toString(); }; + QCOMPARE(capability(), "loading"); + + // The request is sent, but no event loop can consume its response + // before cancel changes the revision. Even an empty document stays + // unclassified when that in-flight response arrives. + reader_->search(query); + QVERIFY(!session->worker->idle()); + reader_->cancel(); + QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000); + QCOMPARE(capability(), "loading"); + + reader_->search(query); + QTRY_COMPARE_WITH_TIMEOUT(capability(), hasText ? "supported" : "unavailable", 15000); + QTRY_VERIFY_WITH_TIMEOUT(session->worker->idle(), 15000); + if (!hasText) QVERIFY(reader_->notice().contains(QStringLiteral("検索できる文字情報がありません"))); + if (hasText && query == "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("一致する箇所"))); + if (hasText && query != "Needle") QVERIFY(reader_->notice().contains(QStringLiteral("検索結果がありません"))); + + // A new document must start with its own evidence, even after a + // supported or unavailable capability was established previously. + const auto token = session->token; + reader_->openPath(manyPages_); + QTRY_VERIFY_WITH_TIMEOUT(reader_->state() == "Ready" && reader_->webToken() != token, 15000); + session = activeSession(); QVERIFY(session && session->worker); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady() && session->worker->idle(), 15000); + QCOMPARE(capability(), "loading"); + } + + void tinyPdfCommentRemainsKeyboardAccessible() + { + reader_->openPath(QFINDTESTDATA("fixtures/pdf/tiny-comments.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + canvas_->setZoom(25); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested); + key(Qt::Key_Tab); key(Qt::Key_Return); + QTRY_COMPARE(comments.size(), 1); + QCOMPARE(comments.first().first().toString(), "Literal comment"); + QVERIFY(std::isfinite(canvas_->offset())); + QCOMPARE(canvas_->currentPage(), 0); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + } + + void pdfCommentsAreReadableWithKeyboard_data() + { + QTest::addColumn("outside"); + QTest::newRow("links-and-comment") << false; + QTest::newRow("comments-only-outside-crop") << true; + } + + void pdfCommentsAreReadableWithKeyboard() + { + QFETCH(bool, outside); + const auto path = outside ? QFINDTESTDATA("fixtures/pdf/comments.pdf") : QFINDTESTDATA("fixtures/pdf/navigation.pdf"); + const auto original = fileHash(path); QVERIFY(!original.isEmpty()); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QSignalSpy comments(canvas_, &PdfCanvas::annotationRequested); + auto *dialog = window_->findChild("infoDialog"); + auto *body = window_->findChild("infoText"); + QVERIFY(dialog); QVERIFY(body); + // Reverse tab starts at the last comment, after the unchanged link order. + key(Qt::Key_Tab, Qt::ShiftModifier); key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(comments.size(), 1, 3000); + QCOMPARE(body->property("text").toString(), outside ? "Outside CropBox comment" : "Read-only comment"); + QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("注釈")); + QCOMPARE(body->property("textFormat").toInt(), 0); // Qt.PlainText; contents never become markup + QCOMPARE(reader_->mode(), "dialog"); + QVERIFY(dialog->property("visible").toBool()); + const auto screenshots = qEnvironmentVariable("DOCVIEW_COMMENT_SCREENSHOTS"); + if (!screenshots.isEmpty()) { + QVERIFY(QDir().mkpath(screenshots)); + QTest::qWait(100); + QVERIFY(window_->grabWindow().save(QDir(screenshots).filePath(outside ? "outside-crop.png" : "comment.png"))); + } + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + QCOMPARE(canvas_->currentPage(), 0); + if (outside) { + key(Qt::Key_Tab); key(Qt::Key_Return); + QTRY_COMPARE(comments.size(), 2); + QCOMPARE(body->property("text").toString(), "Literal comment"); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + } else { + // Forward tab wraps to the URI link and then to the blocked Launch + // link, before arriving back at the comment. + key(Qt::Key_Tab); key(Qt::Key_Tab); key(Qt::Key_Return); + QVERIFY(reader_->notice().contains("E_LINK_BLOCKED")); + QCOMPARE(comments.size(), 1); + key(Qt::Key_Tab); key(Qt::Key_Return); + QTRY_COMPARE(comments.size(), 2); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + } + // Rotations and zoom preserve the selected comment and its read-only body. + for (int rotation : {90, 180, 270, 0}) { + canvas_->goToLocation({{"pageIndex", 0}, {"viewRotation", rotation}, {"zoom", 200.0}, {"fitMode", "percent"}}); + const int before = comments.size(); + canvas_->activateLink(); + QTRY_COMPARE(comments.size(), before + 1); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + } + key(Qt::Key_Escape); // In normal mode Esc clears the PDF target selection. + const int beforeClear = comments.size(); key(Qt::Key_Return); + QCOMPARE(comments.size(), beforeClear); + QVERIFY(reader_->notice().contains(QString::fromUtf8("選択してください"))); + QCOMPARE(fileHash(path), original); + reader_->execute("document.info"); + QCOMPARE(dialog->property("title").toString(), QString::fromUtf8("文書情報")); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + const int before = comments.size(); + canvas_->activateLink(); + QCOMPARE(comments.size(), before); // old PDF selection was retired + } + + void htmlStyleHeadingsAndScriptIsolation() + { + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QCOMPARE(reader_->format(), "html"); + QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000); + QVERIFY(reader_->pages().isEmpty()); + QVariant result; + QVERIFY(evaluateWeb("({title:document.querySelector('h1').textContent,color:getComputedStyle(document.querySelector('h1')).color,executed:document.body.hasAttribute('data-document-script')})", &result)); + QCOMPARE(result.toMap().value("title").toString(), "First heading"); + QCOMPARE(result.toMap().value("color").toString(), "rgb(10, 20, 30)"); + QCOMPARE(result.toMap().value("executed").toBool(), false); + reader_->execute("nav.heading_next"); + QTest::qWait(150); + QVERIFY(evaluateWeb("document.querySelector('h1').getBoundingClientRect().top", &result)); + QVERIFY(qAbs(result.toDouble()) < 3); + reader_->execute("nav.heading_next"); + QTest::qWait(150); + QVERIFY(evaluateWeb("({scroll:window.scrollY,total:document.documentElement.scrollHeight,viewport:innerHeight,first:document.querySelector('h1').getBoundingClientRect().top,second:document.querySelector('h2').getBoundingClientRect().top,writing:getComputedStyle(document.body).writingMode})", &result)); + QVERIFY2(result.toMap().value("scroll").toDouble() > 500 + && qAbs(result.toMap().value("second").toDouble()) < 3, + QJsonDocument::fromVariant(result).toJson(QJsonDocument::Compact).constData()); + const auto token = reader_->webToken(); + QVERIFY(!reader_->navigationAllowed(QUrl("https://example.org/"), 5, token)); + QCOMPARE(reader_->webToken(), token); + reader_->execute("nav.page", {{"page", 42}}); + QVERIFY2(reader_->notice().contains("利用できません"), qPrintable(reader_->notice())); + QCOMPARE(reader_->webToken(), token); + } + + void zipRelativeStylesAndInternalNavigation() + { + const auto originalHash = fileHash(zip_); + reader_->openPath(zip_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + QCOMPARE(reader_->format(), "htmlzip"); + QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() >= 2, 10000); + QVariant result; + QVERIFY(evaluateWeb("getComputedStyle(document.querySelector('h1')).color", &result)); + QCOMPARE(result.toString(), "rgb(40, 50, 60)"); + const auto token = reader_->webToken(); + reader_->activateItem({{"href", "other.html#destination"}}); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html"), 15000); + QTRY_VERIFY_WITH_TIMEOUT(reader_->outline().size() == 1, 10000); + QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); + QCOMPARE(result.toString(), "Archive linked destination"); + QCOMPARE(reader_->webToken(), token); + QCOMPARE(fileHash(zip_), originalHash); + } + + void epubSpineOrderAndNonlinearSkipping() + { + reader_->openPath(epub_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + QCOMPARE(reader_->format(), "epub"); + QCOMPARE(reader_->outline().first().toMap().value("title").toString(), "Second in contents"); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QVariant result; + QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); + QCOMPARE(result.toString(), "Chapter One"); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml"), 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb()->property("documentReady").toBool(), 10000); + QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &result)); + QCOMPARE(result.toString(), "Chapter Two"); + QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("3 / 3"), 10000); + reader_->execute("nav.chapter_previous"); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("one.xhtml"), 15000); + } + + void resourceWarningsAggregateWithoutRetainingReferences_data() + { + QTest::addColumn("kind"); + QTest::newRow("html") << QString("html"); + QTest::newRow("zip") << QString("zip"); + QTest::newRow("epub") << QString("epub"); + } + + void resourceWarningsAggregateWithoutRetainingReferences() + { + QFETCH(QString, kind); + const auto path = documents_.filePath("resource-warnings." + kind); + const QByteArray html = "Resource checks" + "" + "" + "

      Readable despite missing resources

      Local font check

      " + ""; + if (kind == "html") QVERIFY(writeBytes(path, html)); + else if (kind == "zip") QVERIFY(makeZip(path, {{"index.html", html}})); + else { + auto files = epubFixture(); + for (auto &file : files) if (file.first == "OPS/one.xhtml") file.second = html; + QVERIFY(makeZip(path, files)); + } + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + auto *session = activeSession(); + QVERIFY(session); + QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("broker.missing") >= 3, 10000); + QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.style") >= 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(session->resourceIssues.value("csp.image") >= 1, 10000); + const auto firstWarnings = session->meta.value("warnings").toStringList(); + QCOMPARE(firstWarnings.filter(":info").size(), 1); + const int missing = session->resourceIssues.value("broker.missing"); + QVariant result; + QVERIFY(evaluateWeb("(()=>{const i=document.createElementNS('http://www.w3.org/1999/xhtml','img');i.src='absent-late-image.png';document.body.append(i);return document.querySelector('h1').textContent})()", &result)); + QCOMPARE(result.toString(), "Readable despite missing resources"); + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), missing + 1, 10000); + QCOMPARE(session->meta.value("warnings").toStringList(), firstWarnings); + reader_->notify(""); // Details outlive the transient notification. + QSignalSpy information(reader_.get(), &Controller::uiCommand); + reader_->execute("document.info"); + QVERIFY(!information.isEmpty()); + const auto text = information.last().at(1).toMap().value("text").toString(); + QVERIFY(text.contains("E_RESOURCE_MISSING")); + QVERIFY(text.contains("E_RESOURCE_BLOCKED")); + QVERIFY(text.contains(QString::number(missing + 1) + "件")); + QVERIFY(text.contains("スタイルシート")); + QVERIFY(!text.contains("secret")); + QVERIFY(!text.contains("invalid.example")); + QVERIFY(!text.contains(session->token)); + QVERIFY(!text.contains(documents_.path())); + QCOMPARE(fileHash(path), original); + const auto output = qEnvironmentVariable("DOCVIEW_RESOURCE_SCREENSHOT"); + if (!output.isEmpty() && kind == "html") { + QTest::qWait(200); + QVERIFY(window_->grabWindow().save(output)); + } + } + + void resourceFailureReasonsStayDistinct() + { + const auto root = documents_.filePath("resource-classification"); + QVERIFY(QDir().mkpath(root)); + const auto page = root + "/index.html"; + QVERIFY(writeBytes(page, "

      Readable resource classes

      ")); + QVERIFY(writeBytes(root + "/unsupported.bin", "body{}")); + QVERIFY(writeBytes(root + "/denied.css", "body{}")); + QFile large(root + "/large.css"); QVERIFY(large.open(QIODevice::WriteOnly)); + QVERIFY(large.resize(256ll * 1024 * 1024 + 1)); large.close(); +#ifdef Q_OS_LINUX + QVERIFY(::symlink("denied.css", QFile::encodeName(root + "/symbolic.css").constData()) == 0); + QVERIFY(::link(QFile::encodeName(root + "/denied.css").constData(), QFile::encodeName(root + "/hard.css").constData()) == 0); + QVERIFY(writeBytes(root + "/private.css", "body{}")); + QVERIFY(QFile::setPermissions(root + "/private.css", {})); +#endif + reader_->openPath(page); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); + auto *session = activeSession(); QVERIFY(session); + QStringList files{"absent.css", "unsupported.bin", "large.css"}; +#ifdef Q_OS_LINUX + files << "symbolic.css" << "hard.css"; + if (::geteuid() != 0) files << "private.css"; +#endif + for (const auto &name : files) { + QVariant result; + QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='" + name + "';document.head.append(e);return true})()", &result)); + } + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.missing"), 1, 10000); + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.type"), 1, 10000); + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.limit"), 1, 10000); +#ifdef Q_OS_LINUX + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.blocked"), 2, 10000); + if (::geteuid() != 0) QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value("broker.denied"), 1, 10000); + QVERIFY(QFile::setPermissions(root + "/private.css", QFile::ReadOwner | QFile::WriteOwner)); +#endif + QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); + const auto text = info.last().at(1).toMap().value("text").toString(); + QVERIFY(text.contains("E_RESOURCE_MISSING")); QVERIFY(text.contains("E_RESOURCE_BLOCKED")); + QVERIFY(text.contains("E_RESOURCE_LIMIT")); QVERIFY(!text.contains(root)); + QVERIFY(!text.contains("symbolic.css")); QVERIFY(!text.contains(session->token)); + QCOMPARE(reader_->state(), "Ready"); + } + + void archiveResourceFailureKeepsItsReason_data() + { + QTest::addColumn("kind"); QTest::addColumn("reason"); QTest::addColumn("code"); + QTest::newRow("actual-crc") << QString("crc") << QString("broker.corrupt") << QString("E_ARCHIVE_CORRUPT"); + QTest::newRow("expanded-budget") << QString("limit") << QString("broker.archive_limit") << QString("E_ARCHIVE_LIMIT"); + QTest::newRow("unavailable-storage") << QString("storage") << QString("broker.storage_failed") << QString("E_STORAGE_FAILED"); + } + void archiveResourceFailureKeepsItsReason() + { + QFETCH(QString, kind); QFETCH(QString, reason); QFETCH(QString, code); + const auto archive = documents_.filePath("classified-" + kind + ".zip"); + const QByteArray asset = "body{color:rgb(12,34,56)}/*unique-resource-crc*/"; + QVERIFY(makeZip(archive, {{"index.html", "

      Still readable

      "}, {"late.css", asset}})); + if (kind == "crc") { + QFile file(archive); QVERIFY(file.open(QIODevice::ReadWrite)); auto bytes = file.readAll(); + const auto offset = bytes.indexOf(asset); QVERIFY(offset >= 0); bytes[offset] = 'X'; + QVERIFY(file.seek(0)); QCOMPARE(file.write(bytes), bytes.size()); file.close(); + } + const auto original = fileHash(archive); + reader_->openPath(archive); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); + auto *session = activeSession(); QVERIFY(session && session->worker); + const auto resourceRoot = session->root; + if (kind == "limit") session->expandedBytes = 2ll * 1024 * 1024 * 1024 - 1; + if (kind == "storage") session->root += "/unavailable-storage"; + QVariant value; + QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value)); + QTRY_COMPARE_WITH_TIMEOUT(session->resourceIssues.value(reason), 1, 10000); + QCOMPARE(session->resourceIssues.value("broker.missing"), 0); + QVERIFY(!QFile::exists(resourceRoot + "/late.css")); + if (kind != "storage") { QCOMPARE(reader_->state(), "Recovering"); QVERIFY(session->resourcesStopped); } + QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Still readable"); + QSignalSpy info(reader_.get(), &Controller::uiCommand); reader_->execute("document.info"); + const auto text = info.last().at(1).toMap().value("text").toString(); + QVERIFY(text.contains(code)); QVERIFY(!text.contains("E_RESOURCE_MISSING")); + QVERIFY(!text.contains("late.css")); QVERIFY(!text.contains(resourceRoot)); + QCOMPARE(fileHash(archive), original); + session->root = resourceRoot; + } + +#ifdef Q_OS_LINUX + void pendingArchiveWorkerFailure_data() + { + QTest::addColumn("kind"); + QTest::newRow("real-killed-worker") << QString("crash"); + QTest::newRow("timeout-signal") << QString("timeout"); + QTest::newRow("explicit-document-replacement") << QString("replace"); + } + void pendingArchiveWorkerFailure() + { + QFETCH(QString, kind); + const auto path = documents_.filePath("pending-worker-" + kind + ".zip"); + QVERIFY(makeZip(path, {{"index.html", "

      Preserved content

      "}, {"late.css", "body{color:black}"}})); + reader_->openPath(path); QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY(activeWeb() && activeWeb()->property("documentReady").toBool()); + QPointer session = activeSession(); QVERIFY(session && session->worker); + auto *socket = session->worker->findChild(); QVERIFY(socket); + struct ucred peer{}; socklen_t length = sizeof(peer); + QCOMPARE(::getsockopt(int(socket->socketDescriptor()), SOL_SOCKET, SO_PEERCRED, &peer, &length), 0); + QVERIFY(peer.pid > 1 && peer.pid != ::getpid()); QCOMPARE(peer.uid, ::geteuid()); + QCOMPARE(::kill(peer.pid, SIGSTOP), 0); + bool completed = false; + const auto cleanup = qScopeGuard([&] { if (!completed) ::kill(peer.pid, SIGKILL); }); + QVariant value; + QVERIFY(evaluateWeb("(()=>{const e=document.createElement('link');e.rel='stylesheet';e.href='late.css';document.head.append(e);return true})()", &value)); + QTRY_VERIFY_WITH_TIMEOUT(session->extractWaiters.contains("late.css"), 5000); + auto outcomes = std::make_shared>(); + session->extractWaiters["late.css"].append([outcomes](ResourceFailure failure) { *outcomes << failure; }); + QSignalSpy issues(session->handler, &ResourceHandler::blocked); + if (kind == "replace") { + reader_->openPath(html_); QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 10000); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); + QCOMPARE(*outcomes, QList{ResourceFailure::Cancelled}); + QCOMPARE(issues.size(), 0); + } else { + if (kind == "crash") QCOMPARE(::kill(peer.pid, SIGKILL), 0); + else QVERIFY(QMetaObject::invokeMethod(session->worker, "failed", Qt::DirectConnection, + Q_ARG(QString, "E_WORKER_TIMEOUT"), Q_ARG(QString, "Injected deadline notification"))); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Recovering", 10000); + const auto failure = kind == "crash" ? ResourceFailure::WorkerFailed : ResourceFailure::WorkerTimeout; + QCOMPARE(*outcomes, QList{failure}); + QCOMPARE(session->resourceIssues.value(resourceFailureKey(failure)), 1); + QCOMPARE(session->resourceIssues.value("broker.missing"), 0); + QVERIFY(session->extractWaiters.isEmpty()); + QVERIFY(evaluateWeb("document.querySelector('h1').textContent", &value)); QCOMPARE(value.toString(), "Preserved content"); + QTest::qWait(50); QCOMPARE(outcomes->size(), 1); QCOMPARE(issues.size(), 1); + } + completed = true; + } +#endif + + void resourceWarningReportsValidateWholeBatchAndSession() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); + reader_->execute("config.reload"); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + auto *session = activeSession(); + QVERIFY(session); + const auto token = session->token; + reader_->webResourceIssues(token, {{"csp.image", 3}, {"csp.font", 2.0}}); + const auto counts = session->resourceIssues; + const QList invalid{ + {{"csp.image", 4}, {"csp.url", "https://secret.invalid/"}}, + {{"csp.image", true}}, {{"csp.image", "3"}}, {{"csp.image", 0}}, + {{"csp.image", -1}}, {{"csp.image", 1.5}}, {{"csp.image", 1000000}}, + {{"csp.image", std::numeric_limits::infinity()}}, + {{"csp.image", std::numeric_limits::quiet_NaN()}}, + {{"broker.missing", 10}}, {{"network.blocked", 10}}}; + for (const auto &report : invalid) { + reader_->webResourceIssues(token, report); + QCOMPARE(session->resourceIssues, counts); + } + reader_->webResourceIssues("stale-document", {{"csp.image", 7}}); + QCOMPARE(session->resourceIssues, counts); + reader_->webResourceIssues(token, {{"csp.image", 999999}}); + QCOMPARE(session->resourceIssues.value("csp.image"), 999999); + reader_->webResourceIssues(token, {{"csp.image", 999999}}); + QCOMPARE(session->resourceIssues.value("csp.image"), 999999); + for (const auto &kind : {"style", "font", "script", "frame", "connect", "form", "base", "object", "media", "other"}) + reader_->webResourceIssues(token, {{"csp." + QString::fromLatin1(kind), 1}}); + window_->resize(480, 360); + reader_->execute("document.info"); + auto *dialog = window_->findChild("infoDialog"); + auto *scroll = window_->findChild("infoScroll"); + QVERIFY(dialog && scroll); + QTRY_VERIFY(dialog->property("visible").toBool()); + QVERIFY(dialog->property("height").toDouble() <= 300); + QVERIFY(scroll->property("contentHeight").toDouble() > scroll->property("availableHeight").toDouble()); + key(Qt::Key_Escape); + QTRY_VERIFY(!dialog->property("visible").toBool()); + QCOMPARE(reader_->mode(), "normal"); + auto *store = reader_->findChild(); + QVERIFY(store && store->flush()); + QFile state(testStorage().stateFile); + QVERIFY(state.open(QIODevice::ReadOnly)); + const auto saved = state.readAll(); + QVERIFY(!saved.contains("csp.image")); + QVERIFY(!saved.contains("resourceIssues")); + QVERIFY(!saved.contains("secret.invalid")); + reader_->openPath(documents_.filePath("other.html")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != token, 10000); + auto *next = activeSession(); + QVERIFY(next); + const auto nextCounts = next->resourceIssues; + reader_->webResourceIssues(token, {{"csp.image", 100}}); + QCOMPARE(next->resourceIssues, nextCounts); + } + + void webLocationPersistsOnlyValidatedLogicalFields() + { + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\nstore_text_anchor=false\n")); + reader_->execute("config.reload"); + reader_->clearHistory(); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + auto *session = activeSession(); + QVERIFY(session); + const auto token = reader_->webToken(); + QVariantMap valid{{"url", "doc://" + token + "/index.html"}, {"cfi", "epubcfi(/4/2/1:0)"}, + {"fragment", "first"}, {"progression", .25}, {"offsetX", .1}, {"offsetY", .2}, + {"textQuote", "short visible text"}, {"arbitrary", QVariantMap{{"token", token}}}}; + QSignalSpy positioned(reader_.get(), &Controller::positionChanged); + reader_->webLocation(token, valid); + QCOMPARE(positioned.size(), 1); + QVERIFY(QMetaObject::invokeMethod(window_, "frameSwapped", Qt::DirectConnection)); + QCoreApplication::processEvents(); + QCOMPARE(session->lastPresented.value("resourcePath").toString(), "index.html"); + QVERIFY(!session->lastPresented.contains("url")); + QVERIFY(!session->lastPresented.contains("arbitrary")); + auto *store = reader_->findChild(); + QVERIFY(store); + QVERIFY(store->flush()); + const auto saved = store->restore(html_).value("location").toMap(); + QCOMPARE(saved.value("resourcePath").toString(), "index.html"); + QVERIFY(!saved.contains("textQuote")); + QFile state(testStorage().stateFile); + QVERIFY(state.open(QIODevice::ReadOnly)); + const auto bytes = state.readAll(); + QVERIFY(!bytes.contains(token.toUtf8())); + QVERIFY(!bytes.contains("doc://")); + + // Wrong types, limits, origin and old generations cannot replace the + // last validated position; unknown data is never serialized. + QList bad; + auto changed = [&](const QString &key, const QVariant &value) { + auto next = valid; next[key] = value; bad.append(next); + }; + changed("progression", "0.5"); + changed("progression", true); + changed("progression", -0.1); + changed("progression", 1.1); + changed("progression", std::numeric_limits::quiet_NaN()); + changed("cfi", QVariantList{1, 2}); + changed("cfi", QString(4097, 'a')); + changed("fragment", QString(1025, 'a')); + changed("textQuote", QString(161, 'a')); + changed("offsetX", std::numeric_limits::infinity()); + changed("url", "doc://different-session/index.html"); + changed("url", "file:///etc/passwd"); + changed("url", "doc://" + token + "/%2e%2e/secret.html"); + for (const auto &value : bad) { + positioned.clear(); + reader_->webLocation(token, value); + QCOMPARE(positioned.size(), 0); + QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED")); + } + positioned.clear(); + reader_->webLocation("old-session", valid); + QCOMPARE(positioned.size(), 0); + } + + void epubRestoresStableSpineIdentityAndRejectsForeignPackage() + { + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); + reader_->execute("config.reload"); + reader_->clearHistory(); + auto *store = reader_->findChild(); + QVERIFY(store); + // The stable ID wins over an outdated href. CFI stays local to the + // resolved spine resource; a legacy transient URL is discarded. + const QVariantMap location{{"packagePath", "OPS/book.opf"}, {"spineIdref", "two"}, + {"href", "OPS/one.xhtml"}, {"url", "doc://expired/OPS/one.xhtml"}, + {"fragment", "two"}, {"progression", 0.0}}; + QVERIFY(store->remember(epub_, location)); + QSignalSpy commands(reader_.get(), &Controller::webCommand); + reader_->openPath(epub_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("two.xhtml") + && activeWeb()->property("documentReady").toBool(), 15000); + QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("spineIdref").toString(), "two", 10000); + QCOMPARE(activeSession()->lastPresented.value("packagePath").toString(), "OPS/book.opf"); + QCOMPARE(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/two.xhtml"); + QVERIFY(!activeSession()->lastPresented.contains("url")); + bool restored = false; + for (const auto &call : commands) if (call[0].toString() == "location.restore") { + restored = true; + const auto applied = call[1].toMap(); + QCOMPARE(applied.value("spineIdref").toString(), "two"); + QVERIFY(applied.value("href").toString().startsWith("OPS/two.xhtml")); + QVERIFY(!applied.contains("url")); + } + QVERIFY(restored); + // A different package or unresolved spine must not silently use href. + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 20000); + auto invalid = location; + invalid["packagePath"] = "OPS/foreign.opf"; + QVERIFY(store->remember(epub_, invalid)); + commands.clear(); + reader_->openPath(epub_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "epub", 20000); + QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().startsWith("E_LOCATOR_UNRESOLVED"), 10000); + QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/one.xhtml"); + for (const auto &call : commands) QVERIFY(call[0].toString() != "location.restore"); + } + + void legacyHtmlLocatorRestoresWithoutSessionUrl_data() + { + QTest::addColumn("resourceOnly"); + QTest::newRow("legacy-href") << false; + QTest::newRow("logical-resource-path") << true; + } + void legacyHtmlLocatorRestoresWithoutSessionUrl() + { + QFETCH(bool, resourceOnly); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=5\n")); + reader_->execute("config.reload"); + reader_->clearHistory(); + auto *store = reader_->findChild(); + QVERIFY(store); + QVariantMap savedLocation{{"href", "other.html#destination"}, + {"url", "doc://expired-capability/other.html#destination"}, + {"fragment", "destination"}, {"progression", 0.0}}; + if (resourceOnly) { + savedLocation.remove("href"); savedLocation.remove("url"); + savedLocation["resourcePath"] = "other.html"; + } + QVERIFY(store->remember(html_, savedLocation)); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("url").toUrl().path().endsWith("other.html") + && activeWeb()->property("documentReady").toBool(), 15000); + QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "other.html", 10000); + QVERIFY(store->flush()); + const auto saved = store->restore(html_).value("location").toMap(); + QVERIFY(!saved.contains("url")); + QCOMPARE(saved.value("resourcePath").toString(), "other.html"); + } + + void failedReplacementCancelAndStaleGeneration() + { + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + reader_->execute("nav.page", {{"page", 42}}); + QTRY_COMPARE(canvas_->currentPage(), 41); + const auto title = reader_->title(); + reader_->openPath(QFINDTESTDATA("fixtures/pdf/corrupt.pdf")); + QCOMPARE(reader_->state(), "Opening"); + QCOMPARE(reader_->title(), title); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(reader_->title(), title); + QCOMPARE(canvas_->currentPage(), 41); + QVERIFY(reader_->notice().startsWith("E_")); + reader_->openPath(epub_); + QCOMPARE(reader_->state(), "Opening"); + reader_->cancel(); + QCOMPARE(reader_->state(), "Ready"); + QVERIFY(reader_->notice().contains("取り消しました")); + QTest::qWait(200); + QCOMPARE(reader_->title(), title); + QCOMPARE(canvas_->currentPage(), 41); + reader_->openPath(epub_); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QCOMPARE(reader_->format(), "html"); + const auto token = reader_->webToken(); + reader_->webLoaded("obsolete-generation", true); + reader_->webIndex("obsolete-generation", {{"headings", QVariantList{QVariantMap{{"title", "stale"}}}}}); + QCOMPARE(reader_->webToken(), token); + QTest::qWait(200); + QCOMPARE(reader_->format(), "html"); + } + void epubDocumentEndpoints_data() + { + QTest::addColumn("writingMode"); + QTest::addColumn("direction"); + QTest::newRow("horizontal-ltr") << "horizontal-tb" << "ltr"; + QTest::newRow("horizontal-rtl") << "horizontal-tb" << "rtl"; + QTest::newRow("vertical-rl") << "vertical-rl" << "rtl"; + } + + void epubDocumentEndpoints() + { + QFETCH(QString, writingMode); + QFETCH(QString, direction); + auto files = epubFixture(); + files[2].second.replace("", ""); + files[2].second.replace("", ""); + files[2].second.replace("", ""); + const auto style = QString("") + .arg(writingMode, direction, writingMode == "vertical-rl" ? "min-width:4000px" : "min-height:4000px").toUtf8(); + files[4].second.replace("", style); + files[6].second.replace("", style); + files.append(QPair{"OPS/pre.xhtml", "

      Nonlinear front

      "}); + files.append(QPair{"OPS/post.xhtml", "

      Nonlinear end

      "}); + const auto path = documents_.filePath("endpoints-" + writingMode + "-" + direction + ".epub"); + QVERIFY(makeZip(path, files)); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + const auto at = [&](const QString &name, double expected) { + if (!activeWeb() || !activeWeb()->property("documentReady").toBool() + || !activeWeb()->property("url").toUrl().path().endsWith(name)) return false; + QVariant result; + return evaluateWeb("__docviewReader.location().progression", &result) + && std::abs(result.toDouble() - expected) < .002; + }; + // The first/last spine rows are nonlinear; document endpoints belong + // to normal reading order and include the final chapter's actual end. + QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000); + reader_->execute("nav.document_end"); + QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000); + reader_->execute("nav.document_start"); + QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 10000); + + // No load event is required when already in the endpoint resource. + QVariant result; + QVERIFY(evaluateWeb("__docviewReader.command('nav.document_end',{})", &result)); + QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 1), 5000); + reader_->execute("nav.document_start"); + QTRY_VERIFY_WITH_TIMEOUT(at("one.xhtml", 0), 5000); + reader_->execute("nav.document_end"); + QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 10000); + QVERIFY(evaluateWeb("__docviewReader.command('nav.document_start',{})", &result)); + QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 0), 5000); + reader_->execute("nav.document_end"); + QTRY_VERIFY_WITH_TIMEOUT(at("two.xhtml", 1), 5000); + } + void zipEntryChoiceSurvivesRestart_data() + { + QTest::addColumn("rememberPosition"); + QTest::newRow("recent-history-without-reading-position") << false; + QTest::newRow("reading-position-without-recent-list") << true; + } + void zipEntryChoiceSurvivesRestart() + { + QFETCH(bool, rememberPosition); + reader_->clearHistory(); + const auto privacy = rememberPosition + ? QByteArray("[privacy]\nremember_position=true\nrecent_documents=0\n") + : QByteArray("[privacy]\nremember_position=false\nrecent_documents=3\n"); + QVERIFY(writeBytes(configuration_, privacy)); + reader_->execute("config.reload"); + const auto path = documents_.filePath("remember-entry.zip"); + QVERIFY(makeZip(path, {{"a/first.html", "

      First entry

      "}, + {"b/second.html", "

      Chosen entry

      "}})); + const auto original = fileHash(path); + QSignalSpy choices(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); + QCOMPARE(reader_->mode(), "dialog"); + auto *list = window_->findChild("entryCandidates"); + QVERIFY(list); + QCOMPARE(list->property("currentIndex").toInt(), 0); + key(Qt::Key_Down); + QCOMPARE(list->property("currentIndex").toInt(), 1); + key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); + auto *store = reader_->findChild(); + QVERIFY(store); + const QStringList candidates{"a/first.html", "b/second.html"}; + QCOMPARE(store->archiveEntry(path, candidates), "b/second.html"); + if (!rememberPosition) QVERIFY(store->restore(path).isEmpty()); + QVERIFY(store->flush()); + cleanup(); + QProcess freshProcess; + freshProcess.setProcessChannelMode(QProcess::MergedChannels); + freshProcess.start(QCoreApplication::applicationFilePath(), {"--zip-entry-probe", documents_.path()}); + QVERIFY(freshProcess.waitForStarted(5000)); + QVERIFY(freshProcess.waitForFinished(30000)); + QVERIFY2(freshProcess.exitStatus() == QProcess::NormalExit && freshProcess.exitCode() == 0, + freshProcess.readAll().constData()); + startUi(); + QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QCOMPARE(reopened.size(), 0); + QCOMPARE(reader_->mode(), "normal"); + QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); + cleanup(); + const auto savedHash = fileHash(testStorage().stateFile); + startUi(true); + QVERIFY(reader_->findChild()->readOnly()); + QSignalSpy readOnlyChoices(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QCOMPARE(readOnlyChoices.size(), 0); + QVERIFY(activeWeb()->property("url").toUrl().path().endsWith("b/second.html")); + cleanup(); + QCOMPARE(fileHash(testStorage().stateFile), savedHash); + QCOMPARE(fileHash(path), original); + } + + void zipEntryChoiceDisabledOrCleared_data() + { + QTest::addColumn("disabled"); + QTest::newRow("fully-disabled-privacy") << true; + QTest::newRow("history-cleared") << false; + } + void zipEntryChoiceDisabledOrCleared() + { + QFETCH(bool, disabled); + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, disabled + ? "[privacy]\nremember_position=false\nrecent_documents=0\n" + : "[privacy]\nremember_position=false\nrecent_documents=3\n")); + reader_->execute("config.reload"); + const auto path = documents_.filePath("private-entry.zip"); + QVERIFY(makeZip(path, {{"one.html", "

      One

      "}, {"two.html", "

      Two

      "}})); + QSignalSpy choices(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); + key(Qt::Key_Down); + key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QTRY_VERIFY_WITH_TIMEOUT(activeSession() && !activeSession()->lastPresented.isEmpty(), 10000); + if (!disabled) { + QCOMPARE(reader_->findChild()->archiveEntry(path, {"one.html", "two.html"}), "two.html"); + reader_->clearHistory(); + } + QCOMPARE(reader_->historyCount(), 0); + cleanup(); + startUi(); + QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); + QCOMPARE(reader_->state(), "Opening"); + QVERIFY(reader_->webToken().isEmpty()); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->state(), "Empty"); + QCOMPARE(reader_->historyCount(), 0); + } + + void zipEntryChoiceInvalidated_data() + { + QTest::addColumn("change"); + QTest::newRow("replacement-same-bytes-size-mtime") << "replacement"; + QTest::newRow("archive-candidate-removed") << "changed"; + QTest::newRow("stored-candidate-no-longer-present") << "missing"; + QTest::newRow("unsafe-saved-candidate") << "unsafe"; + } + void zipEntryChoiceInvalidated() + { + QFETCH(QString, change); + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); + reader_->execute("config.reload"); + const auto path = documents_.filePath("changed-entry-" + change + ".zip"); + QVERIFY(makeZip(path, {{"first.html", "

      First

      "}, {"second.html", "

      Second

      "}})); + const auto identity = StateStore::fileIdentity(path); + const auto originalHash = fileHash(path); + QSignalSpy choices(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); + key(Qt::Key_Down); + key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 20000); + QCOMPARE(reader_->findChild()->archiveEntry(path, {"first.html", "second.html"}), "second.html"); + cleanup(); + if (change == "replacement") { + QVERIFY(QFile::rename(path, path + ".original")); + QVERIFY(QFile::copy(path + ".original", path)); + QFile replaced(path); + QVERIFY(replaced.open(QIODevice::ReadWrite)); + QVERIFY(replaced.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); + replaced.close(); + const auto current = StateStore::fileIdentity(path); + QCOMPARE(current.value("size"), identity.value("size")); + QCOMPARE(current.value("mtime"), identity.value("mtime")); + QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity")); + QCOMPARE(fileHash(path), originalHash); + } else if (change == "changed") { + QVERIFY(QFile::remove(path)); + QVERIFY(makeZip(path, {{"first.html", "

      Changed original

      "}, {"third.html", "

      New candidate

      "}})); + } else { + QFile state(testStorage().stateFile); + QVERIFY(state.open(QIODevice::ReadOnly)); + auto json = QJsonDocument::fromJson(state.readAll()).object(); + state.close(); + auto entries = json.value("documents").toArray(); + QCOMPARE(entries.size(), 1); + auto record = entries.first().toObject(); + record["archiveEntry"] = change == "unsafe" ? "../second.html" : "absent.html"; + entries[0] = record; + json["documents"] = entries; + QVERIFY(writeBytes(testStorage().stateFile, QJsonDocument(json).toJson())); + } + startUi(); + QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); + QCOMPARE(reader_->state(), "Opening"); + QVERIFY(reader_->webToken().isEmpty()); + const auto candidates = reopened.first().first().toStringList(); + QVERIFY(candidates.contains("first.html")); + QVERIFY(!candidates.contains("../second.html")); + key(Qt::Key_Escape); + QTRY_COMPARE(reader_->state(), "Empty"); + if (change != "changed") QCOMPARE(fileHash(path), originalHash); + } + + void cancelledZipEntryChoiceIsNotSaved() + { + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=false\nrecent_documents=3\n")); + reader_->execute("config.reload"); + const auto path = documents_.filePath("cancelled-choice.zip"); + QVERIFY(makeZip(path, {{"first.html", "

      First

      "}, {"second.html", "

      Second

      "}})); + QSignalSpy choices(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(choices.size(), 1, 10000); + auto *dialog = window_->findChild("entryDialog"); + QVERIFY(dialog); + QVERIFY(QMetaObject::invokeMethod(dialog, "accept")); + reader_->cancel(); // No event loop turn: the selected document has not committed. + QCOMPARE(reader_->state(), "Empty"); + QCOMPARE(reader_->historyCount(), 0); + QVERIFY(reader_->findChild()->archiveEntry(path, {"first.html", "second.html"}).isEmpty()); + QTest::qWait(100); + QCOMPARE(reader_->historyCount(), 0); + cleanup(); + startUi(); + QSignalSpy reopened(reader_.get(), &Controller::entryNeeded); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reopened.size(), 1, 10000); + key(Qt::Key_Escape); + } + void epubFixedSpreads_data() + { + QTest::addColumn("policy"); + QTest::addColumn("rtl"); + QTest::newRow("both-ltr") << "both" << false; + QTest::newRow("both-rtl") << "both" << true; + QTest::newRow("landscape") << "landscape" << false; + QTest::newRow("auto") << "auto" << false; + QTest::newRow("none") << "none" << false; + } + void epubFixedSpreads() + { + QFETCH(QString, policy); QFETCH(bool, rtl); + auto files = epubFixture(); + files = files.mid(0, 2); + const QStringList sides = rtl ? QStringList{"right", "left"} : QStringList{"left", "right"}; + QByteArray package = "Spread proofpre-paginated" + policy.toUtf8() + ""; + QByteArray spine; + for (int i = 1; i <= 7; ++i) { + const auto number = QByteArray::number(i); + package += ""; + QByteArray properties; + if (i <= 2) properties = "page-spread-" + sides[i-1].toUtf8(); + if (i == 3) properties = "rendition:page-spread-center rendition:spread-both"; + if (i == 4) properties = "rendition:spread-none"; + if (i == 5) properties = "rendition:layout-reflowable"; + if (i >= 6) properties = "rendition:spread-both page-spread-" + sides[i-6].toUtf8(); + spine += ""; + const QByteArray color = i % 2 ? "#e7edff" : "#fff0d4"; + const QByteArray label = i == 3 ? "CENTER" : i == 4 ? "SPREAD NONE" : i == 5 ? "REFLOW" : "PAGE " + number; + files.append({"OPS/p" + number + ".xhtml", ""}); + } + package += "" + spine + ""; + files.append({"OPS/book.opf", package}); + const auto path = documents_.filePath("spread-" + policy + (rtl ? "-rtl" : "-ltr") + ".epub"); + QVERIFY(makeZip(path, files)); + QVERIFY(resizeTestWindow(QSize(1100, 760))); + QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 25000); + const auto companion = [&]() -> QQuickItem * { + for (auto *object : window_->findChildren()) + if (object->property("documentToken").toString() == reader_->webToken() + && object->property("isCompanion").toBool() && object->property("alive").toBool()) return object; + return nullptr; + }; + const auto loadedPage = [&](int number) { + return activeWeb() && activeWeb()->property("documentReady").toBool() + && activeWeb()->property("url").toUrl().path().endsWith(QString("p%1.xhtml").arg(number)); + }; + const auto readyPair = [&]() { + return companion() && companion()->property("documentReady").toBool() + && companion()->property("fixedViewport").toMap().value("width").toInt() == 400; + }; + const auto spreadDiagnostic = [&](const char *phase) { + if (!qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS") || !activeWeb()) return; + auto *pane = activeWeb()->parent(); + QQmlExpression expression(engine_->rootContext(), pane, + "JSON.stringify({width:width,height:height,selection:spreadSelection,failed:failedCompanionUrl," + "pending:!!pendingView,activeReady:activeView.documentReady,fixed:activeView.fixedLayout," + "active:resourcePath(activeView.url),companion:companionView?resourcePath(companionView.url):null," + "companionReady:companionView?companionView.documentReady:false,options:optionsFor(activeView)})"); + qInfo().noquote() << "Spread diagnostic" << phase << "window" << window_->size() << expression.evaluate().toString(); + }; + const auto diagnosticAtExit = qScopeGuard([&] { spreadDiagnostic("exit"); }); + const auto verifyGeometry = [&]() { + auto *primary = qobject_cast(activeWeb()); auto *other = companion(); + if (!primary || !other) return false; + auto *left = primary->x() < other->x() ? primary : other; + auto *right = left == primary ? other : primary; + return std::abs(left->x() + left->width() * left->scale() - right->x()) < 1.1 + && std::abs(primary->height() * primary->scale() - other->height() * other->scale()) < 1.1; + }; + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(1), 10000); + if (policy != "none") { + QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 15000); + QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); + QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p2.xhtml"); + QCOMPARE(qobject_cast(activeWeb())->x() > companion()->x(), rtl); + QVariant viewport; QVERIFY(evaluateWeb("[innerWidth,innerHeight]", &viewport)); + QCOMPARE(viewport.toList().at(0).toInt(), 400); QCOMPARE(viewport.toList().at(1).toInt(), 600); + } else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 3000); + const auto capture = [&](const QString &suffix) { + const auto directory = qEnvironmentVariable("DOCVIEW_SPREAD_SCREENSHOTS"); + if (directory.isEmpty()) return true; + if (!QDir().mkpath(directory)) return false; + QTest::qWait(150); + return window_->grabWindow().save(QDir(directory).filePath(policy + (rtl ? "-rtl-" : "-ltr-") + suffix + ".png")); + }; + QVERIFY(capture("landscape")); + if (policy == "both") { + // Zoom scales the complete pair and physical keys can pan it. + reader_->execute("zoom.in"); reader_->execute("zoom.in"); reader_->execute("zoom.in"); + QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); + auto *primary = qobject_cast(activeWeb()); + const auto beforePan = primary->x(); + reader_->execute("scroll.right"); + QTRY_VERIFY_WITH_TIMEOUT(primary->x() < beforePan, 5000); + reader_->execute("zoom.fit_width"); + QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); + // Cancelling a staged replacement reconstructs the prior spread. + const auto token = reader_->webToken(); + QSignalSpy staged(reader_.get(), &Controller::stageWeb); + connect(reader_.get(), &Controller::stageWeb, reader_.get(), &Controller::cancel, + Qt::ConnectionType(Qt::QueuedConnection | Qt::SingleShotConnection)); + reader_->openPath(epub_); + QTRY_COMPARE_WITH_TIMEOUT(staged.count(), 1, 10000); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); + QCOMPARE(reader_->webToken(), token); + QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); + QVERIFY(!reader_->notice().contains("読込中")); + } + QVERIFY(resizeTestWindow(QSize(600, 960))); + QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(600, 960), 5000); + QTRY_VERIFY(qobject_cast(activeWeb())->parentItem()->width() < qobject_cast(activeWeb())->parentItem()->height()); + if (policy == "both") { QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); } + else QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); + QVERIFY(capture("portrait")); + QVERIFY(resizeTestWindow(QSize(1100, 760))); + QTRY_COMPARE_WITH_TIMEOUT(window_->size(), QSize(1100, 760), 5000); + QTRY_VERIFY(qobject_cast(activeWeb())->parentItem()->width() > qobject_cast(activeWeb())->parentItem()->height()); + if (qEnvironmentVariableIsSet("DOCVIEW_SPREAD_DIAGNOSTICS")) { + spreadDiagnostic("landscape-immediate"); + } + if (policy != "none") QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(2), 10000); + if (policy != "none") { + QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); + QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p1.xhtml"); + } + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(3), 10000); + QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); + QVERIFY(capture("center")); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(4), 10000); + QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(5), 10000); + QTRY_COMPARE_WITH_TIMEOUT(activeWeb()->property("fixedLayout").toBool(), false, 5000); + QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); + auto *reflow = qobject_cast(activeWeb()); + QTRY_VERIFY(std::abs(reflow->width() - reflow->parentItem()->width()) < 1); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(loadedPage(6), 10000); + QTRY_VERIFY_WITH_TIMEOUT(readyPair(), 10000); + QTRY_VERIFY_WITH_TIMEOUT(verifyGeometry(), 5000); + QCOMPARE(companion()->property("url").toUrl().path(), "/OPS/p7.xhtml"); + // Only the commanded spine item becomes the logical position. + QTRY_COMPARE_WITH_TIMEOUT(activeSession()->lastPresented.value("resourcePath").toString(), "OPS/p6.xhtml", 10000); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->format(), "html", 15000); + QTRY_VERIFY_WITH_TIMEOUT(!companion(), 5000); + } + + void outlineCurrentPositionIsSeparateFromKeyboardSelection() + { + const auto capture = [&](const QString &name) { + const auto directory = qEnvironmentVariable("DOCVIEW_NAVIGATION_SCREENSHOTS"); + if (directory.isEmpty()) return true; + if (!QDir().mkpath(directory)) return false; + QTest::qWait(60); + return window_->grabWindow().save(QDir(directory).filePath(name + ".png")); + }; + const auto path = documents_.filePath("current-outline.html"); + QVERIFY(writeBytes(path, R"( + +

      One

      First section

      Two

      Second section

      +

      Three

      Third section

      )")); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->outline().size(), 3, 10000); + QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("目次")); + reader_->activateItem(reader_->outline()[0].toMap()); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); + reader_->execute("panel.toc.toggle"); + reader_->setContext("toc"); + auto *list = window_->findChild("tocList"); QVERIFY(list); + list->setProperty("currentIndex", 0); + QVariant before; QVERIFY(evaluateWeb("scrollY", &before)); + key(Qt::Key_J); + QCOMPARE(list->property("currentIndex").toInt(), 1); + QVERIFY(QMetaObject::invokeMethod(reader_.get(), "navigationChanged")); + QCOMPARE(list->property("currentIndex").toInt(), 1); + QTRY_COMPARE(reader_->currentOutline(), 0); + QCOMPARE(reader_->currentOutline(), 0); + QVariant after; QVERIFY(evaluateWeb("scrollY", &after)); QCOMPARE(after, before); + QVERIFY(capture("current-and-selected")); + key(Qt::Key_Return); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); + reader_->setContext("toc"); list->setProperty("currentIndex", 0); key(Qt::Key_H); + QTRY_COMPARE(window_->property("visibleCurrentOutline").toInt(), 0); + QCOMPARE(reader_->currentOutline(), 1); // Collapsed parent represents its current child. + QTRY_VERIFY(list->property("currentItem").value()); + QTRY_VERIFY(list->property("currentItem").value()->property("readingCurrent").toBool()); + QVERIFY(list->property("currentItem").value()->property("keyboardSelected").toBool()); + QVERIFY(capture("collapsed-current")); + const auto staleToken = reader_->webToken(); + const auto staleTargets = reader_->webNavigationTargets(staleToken, activeWeb()->property("url").toUrl()); + const auto noToc = documents_.filePath("headings-only.html"); + QVERIFY(writeBytes(noToc, "

      First

      Body

      Second

      End

      ")); + reader_->openPath(noToc); + QTRY_VERIFY_WITH_TIMEOUT(reader_->webToken() != staleToken && reader_->outline().size() == 2, 10000); + QCOMPARE(reader_->outlineTitle(), QString::fromUtf8("見出し一覧")); + reader_->activateItem(reader_->outline()[0].toMap()); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); + reader_->activateItem(reader_->outline()[1].toMap()); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); + key(Qt::Key_Left, Qt::AltModifier); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 0, 10000); + key(Qt::Key_Right, Qt::AltModifier); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); + reader_->webLocation(staleToken, {{"url", "doc://" + staleToken + "/current-outline.html"}, + {"progression", 0.}, {"currentOutline", 0}, {"navigationRevision", staleTargets.value("revision")}}); + QCOMPARE(reader_->currentOutline(), 1); + QVERIFY(!activeSession()->lastPresented.contains("currentOutline")); + QVERIFY(!activeSession()->lastPresented.contains("navigationRevision")); + QCOMPARE(fileHash(path), original); + } + void pdfStatusShowsLabelAndPhysicalPage() + { + reader_->openPath(QFINDTESTDATA("fixtures/pdf/navigation.pdf")); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); + canvas_->setZoom(400); canvas_->goTo(0); + QTRY_VERIFY(reader_->position().contains(QString::fromUtf8("i(実ページ 1)"))); + QVERIFY(reader_->position().contains(" / 2")); + const auto outline = reader_->outline(); QVERIFY(!outline.isEmpty()); + reader_->activateItem(outline.first().toMap()); + QTRY_VERIFY(reader_->currentOutline() >= 0); + } + void outlineCurrentPageDoesNotRequireEarlierPageGeometry() + { + const auto path = documents_.filePath("lazy-outline.pdf"); + QVERIFY(writeBytes(path, pagedPdf(500, 2, 300))); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 10000); + QTRY_COMPARE(reader_->outline().size(), 2); + canvas_->goTo(450); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 450, 10000); + QTRY_COMPARE_WITH_TIMEOUT(reader_->currentOutline(), 1, 10000); + } + void measureInteractionWorkload() + { + const auto output = qEnvironmentVariable("DOCVIEW_INTERACTION_OUTPUT"); + if (output.isEmpty()) QSKIP("Opt-in T20 workload: run tests/measure_interaction.py; this is measured separately from normal app CTest."); + QDir directory(output); + QVERIFY(QDir().mkpath(directory.filePath("corpus"))); + QJsonArray corpus; + const QStringList sources{stressPages_, manyPages_, html_, documents_.filePath("style.css"), + documents_.filePath("other.html"), epub_}; + QHash originalHashes; + for (const auto &source : sources) { + const auto name = QFileInfo(source).fileName(); const auto hash = fileHash(source); + QVERIFY(!hash.isEmpty()); originalHashes[source] = hash; + QVERIFY(QFile::copy(source, directory.filePath("corpus/" + name))); + corpus.append(QJsonObject{{"path", name}, {"sha256", QString::fromLatin1(hash.toHex())}, + {"size", QFileInfo(source).size()}}); + } + InteractionMeasurement measurement(reader_.get(), canvas_, window_); + bool success = false; + const auto save = qScopeGuard([&] { + auto result = measurement.result(success); + result["corpus"] = corpus; + result["corpusDescription"] = "Deterministic generated 5000-page and 50-page vector PDFs; 5000th page green; simple HTML/CSS and two-linear-chapter EPUB. No large scan/complex-vector/font corpus claim."; + result["qtVersion"] = qVersion(); result["os"] = QSysInfo::prettyProductName(); + result["kernel"] = QSysInfo::kernelVersion(); result["architecture"] = QSysInfo::currentCpuArchitecture(); + result["display"] = QJsonObject{{"width", window_->screen()->size().width()}, {"height", window_->screen()->size().height()}, + {"reportedRefreshHz", window_->screen()->refreshRate()}, {"devicePixelRatio", window_->devicePixelRatio()}, + {"qtPlatform", QGuiApplication::platformName()}, + {"quickBackend", qEnvironmentVariable("QT_QUICK_BACKEND")}, {"chromiumFlags", qEnvironmentVariable("QTWEBENGINE_CHROMIUM_FLAGS")}}; + result["cacheCondition"] = "Application starts fresh; OS file cache is not cleared. Later phases reuse the application cache."; + result["standardCtest"] = "Intentionally QSKIP without DOCVIEW_INTERACTION_OUTPUT; dedicated runner executes this slot and preserves its result."; + result["sourceUnchanged"] = std::all_of(sources.begin(), sources.end(), [&](const auto &path) { return fileHash(path) == originalHashes[path]; }); + result["finalState"] = QJsonObject{{"state", reader_->state()}, {"format", reader_->format()}, {"notice", reader_->notice()}, + {"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_}, + {"currentPage", canvas_->currentPage()}, {"viewportReady", canvas_->viewportReady()}}; + QSaveFile file(directory.filePath("measurement.json")); + if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(result).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) + qWarning() << "Failed to write interaction measurement"; + }); + auto waitUntil = [](const std::function &predicate, int timeout = 30000) { + QElapsedTimer timer; timer.start(); + while (!predicate() && timer.elapsed() < timeout) QTest::qWait(2); + return predicate(); + }; + auto presented = [&](const std::function &ready) { + if (!waitUntil(ready)) return false; + // Two requested presentations after validated readiness avoid + // accepting an old queued frame notification as the target frame. + for (int frame = 0; frame < 2; ++frame) { + const auto before = measurement.frameSerial; + const auto requiredNs = InteractionMeasurement::nowNs(); + window_->update(); + if (!waitUntil([&] { return measurement.frameSerial > before && measurement.lastFrameNs >= requiredNs && ready(); })) return false; + } + measurement.sample(); return true; + }; + auto pdfReady = [&](int count, int page) { return reader_->state() == "Ready" && reader_->format() == "pdf" + && reader_->pages().size() == count && canvas_->currentPage() == page && canvas_->viewportReady(); }; + bool inputFocusMaintained = true; + auto press = [&](Qt::Key code, const QString &text, Qt::KeyboardModifiers modifiers = {}, bool repeat = false, bool release = true) { + inputFocusMaintained = inputFocusMaintained && QGuiApplication::focusWindow() == window_; + QKeyEvent down(QEvent::KeyPress, code, modifiers, text, repeat); + QCoreApplication::sendEvent(window_, &down); + if (release) { QKeyEvent up(QEvent::KeyRelease, code, modifiers, text); QCoreApplication::sendEvent(window_, &up); } + measurement.sample(); + }; + auto jump = [&](int physicalPage) { + measurement.input({{"kind", "numeric-page-keys"}, {"text", QString::number(physicalPage) + "G"}, {"physicalPage", physicalPage}}); + for (const auto digit : QString::number(physicalPage)) press(Qt::Key(Qt::Key_0 + digit.digitValue()), QString(digit)); + press(Qt::Key_G, "G", Qt::ShiftModifier); + }; + window_->resize(1100, 760); + measurement.begin("initial-open"); + measurement.input({{"kind", "open-path"}, {"source", QFileInfo(stressPages_).fileName()}}); + reader_->openPath(stressPages_); + QVERIFY2(presented([&] { return pdfReady(5000, 0); }), qPrintable(reader_->notice())); + measurement.finish({{"targetPage", 0}, {"viewportReady", true}}); + + measurement.begin("held-j-key"); + int noOps = 0; + for (int index = 0; index < 120; ++index) { + const auto before = canvas_->offset(); + measurement.input({{"kind", "key-press"}, {"key", "j"}, {"autoRepeat", index > 0}, {"index", index}, {"scheduledIntervalMs", 16}}); + press(Qt::Key_J, "j", {}, index > 0, false); + const bool moved = std::abs(canvas_->offset() - before) > .01; + if (!moved) ++noOps; + measurement.inputOutcome({{"moved", moved}, {"offsetAfter", canvas_->offset()}, {"pageAfter", canvas_->currentPage()}, + {"context", reader_->context()}, {"mode", reader_->mode()}, {"windowFocused", QGuiApplication::focusWindow() == window_}}); + QTest::qWait(16); + } + QKeyEvent release(QEvent::KeyRelease, Qt::Key_J, {}, "j"); QCoreApplication::sendEvent(window_, &release); + const int heldPage = canvas_->currentPage(); const double heldOffset = canvas_->offset(); + QVERIFY(presented([&] { return pdfReady(5000, heldPage) && std::abs(canvas_->offset() - heldOffset) < 1.; })); + measurement.finish({{"keyPressCount", 120}, {"boundaryOrRejectedNoOpCount", noOps}, {"movedCount", 120 - noOps}, {"targetPage", heldPage}}); + + measurement.begin("rapid-far-jumps"); + const QList jumps{5000, 42, 3750, 150, 2500, 1, 4900, 1250, 4999, 20, 3200, 750, + 4700, 81, 2100, 300, 4100, 1000, 4800, 10, 3500, 500, 2500, 5000}; + for (const auto page : jumps) { jump(page); QTest::qWait(8); } + QVERIFY(presented([&] { return pdfReady(5000, 4999); })); + QVERIFY(greenPagePixels(window_->grabWindow()) > 500); + measurement.finish({{"jumpCount", jumps.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}}); + + measurement.begin("resize-burst"); + const QList sizes{{480,360},{1100,760},{760,540},{900,600},{480,760}, + {1100,360},{640,480},{1000,700},{480,360},{1100,760}}; + for (const auto size : sizes) { + measurement.input({{"kind", "window-resize"}, {"width", size.width()}, {"height", size.height()}, {"scheduledIntervalMs", 40}}); + window_->resize(size); QTest::qWait(40); + } + QVERIFY(presented([&] { return pdfReady(5000, 4999); })); + QVERIFY(greenPagePixels(window_->grabWindow()) > 500); + measurement.finish({{"resizeCount", sizes.size()}, {"targetPage", 4999}, {"greenEndpointVerified", true}}); + + measurement.begin("cancel-staged-document"); + const auto retainedToken = reader_->webToken(); + measurement.input({{"kind", "open-path"}, {"source", QFileInfo(epub_).fileName()}}); + reader_->openPath(epub_); QCOMPARE(reader_->state(), "Opening"); + measurement.input({{"kind", "key-press"}, {"key", "Escape"}}); + press(Qt::Key_Escape, {}); + QVERIFY(presented([&] { return reader_->webToken() == retainedToken && pdfReady(5000, 4999); })); + measurement.finish({{"cancelledBeforeCommit", true}, {"oldDocumentRetained", true}, {"targetPage", 4999}}); + + const QList> replacements{{html_, "html"}, {epub_, "epub"}, {manyPages_, "pdf"}, {stressPages_, "pdf"}}; + for (int index = 0; index < replacements.size(); ++index) { + const auto source = replacements[index].first, format = replacements[index].second; + measurement.begin("document-switch-" + QString::number(index + 1)); + if (reader_->format() == "pdf") { jump(reader_->pages().size() > 50 ? 1234 : 42); QTest::qWait(20); } + const auto oldToken = reader_->webToken(); + measurement.input({{"kind", "open-path"}, {"source", QFileInfo(source).fileName()}}); + reader_->openPath(source); + QVERIFY2(presented([&] { + if (reader_->state() != "Ready" || reader_->webToken() == oldToken || reader_->format() != format) return false; + if (format == "pdf") return pdfReady(source == stressPages_ ? 5000 : 50, 0); + auto *view = activeWeb(); + return view && view->property("documentReady").toBool() + && !reader_->benchmarkSnapshot().value("location").toMap().value("resourcePath").toString().isEmpty(); + }), qPrintable(reader_->notice())); + if (format != "pdf") { + QVariant body; QVERIFY(evaluateWeb("document.body.innerText", &body)); + QVERIFY(body.toString().contains(format == "html" ? "Readable document" : "Chapter One")); + } + measurement.finish({{"format", format}, {"source", QFileInfo(source).fileName()}, {"newSessionVerified", true}}); + } + measurement.begin("final-pdf-endpoint"); jump(5000); + QVERIFY(presented([&] { return pdfReady(5000, 4999); })); + const auto screenshot = window_->grabWindow(); + QVERIFY(greenPagePixels(screenshot) > 500); QVERIFY(screenshot.save(directory.filePath("final-target.png"))); + measurement.finish({{"targetPage", 4999}, {"greenEndpointVerified", true}}); + for (const auto &source : sources) QCOMPARE(fileHash(source), originalHashes[source]); + QVERIFY2(inputFocusMaintained, "The host changed window focus during measured input; this run is not a valid interaction measurement"); + success = true; + } + void memoryPressurePdfStagesPreserveLogicalPosition() + { + auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); + QVERIFY(monitor); monitor->stop(); QVERIFY(!monitor->running()); + const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; + const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; + for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); + QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); + const auto original = fileHash(manyPages_); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + canvas_->setZoom(150); + canvas_->goTo(20, .25); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 20, 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + const auto initial = canvas_->location(); + auto samePosition = [&](const QVariantMap &before) { + const auto after = canvas_->location(); + return after.value("pageIndex") == before.value("pageIndex") + && std::abs(after.value("xPt").toDouble() - before.value("xPt").toDouble()) < .01 + && std::abs(after.value("yPt").toDouble() - before.value("yPt").toDouble()) < .01 + && after.value("zoom") == before.value("zoom") && after.value("fitMode") == before.value("fitMode"); + }; + monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::NoPrefetch); + QCOMPARE(canvas_->memoryPressureLevel(), 1); + QCOMPARE(canvas_->rasterResolutionScale(), 1.); + QVERIFY(samePosition(initial)); + QSignalSpy requests(canvas_, &PdfCanvas::tileRequested); + canvas_->goTo(40, .25); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 40, 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(!requests.isEmpty()); + for (const auto &request : requests) QVERIFY2(!request[1].toBool(), "Memory pressure dispatched a new speculative render"); + const auto location = canvas_->location(); + const auto populatedCache = canvas_->cacheCostBytes(); + const auto configuredBudget = canvas_->cacheBudgetBytes(); + QVERIFY(populatedCache > 0); + monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::Trim); + QCOMPARE(canvas_->memoryPressureLevel(), 2); + QVERIFY2(canvas_->cacheCostBytes() < populatedCache, "Trim did not release the previously visited, now invisible PDF pages"); + QCOMPARE(canvas_->cacheBudgetBytes(), configuredBudget); + QVERIFY(samePosition(location)); + monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced); + QCOMPARE(canvas_->memoryPressureLevel(), 3); + QCOMPARE(canvas_->rasterResolutionScale(), .5); + QVERIFY(reader_->notice().contains(QString::fromUtf8("描画解像度"))); + QVERIFY(activeSession()); + QVERIFY(activeSession()->meta.value("warnings").toStringList().join('\n').contains(QString::fromUtf8("描画解像度"))); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(samePosition(location)); + QVERIFY(canvas_->cacheCostBytes() <= configuredBudget); + for (int i = 0; i < 4; ++i) monitor->observeSample(healthy); + QCOMPARE(monitor->level(), MemoryPressureLevel::Reduced); + monitor->observeSample(healthy); + QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); + QCOMPARE(canvas_->memoryPressureLevel(), 0); + QCOMPARE(canvas_->rasterResolutionScale(), 1.); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(samePosition(location)); + QCOMPARE(reader_->state(), "Ready"); + QCOMPARE(fileHash(manyPages_), original); + } + void memoryPressureStopCancelsPendingDocumentAndRequiresReopen() + { + auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); + QVERIFY(monitor); monitor->stop(); + const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; + const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; + for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); + const auto pdfHash = fileHash(manyPages_), epubHash = fileHash(epub_); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + canvas_->goTo(41); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 41, 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QTRY_VERIFY(activeSession() && activeSession()->lastPresented.value("pageIndex").toInt() == 41); + const auto previousToken = reader_->webToken(); + QPointer active = activeSession(); QVERIFY(active); + QPointer worker = active->worker; QVERIFY(worker); + const auto presentedLocation = active->lastPresented; + reader_->openPath(epub_); + QCOMPARE(reader_->state(), "Opening"); + QPointer pending; + for (auto *object : reader_->children()) + if (auto *session = dynamic_cast(object); session && session != active) pending = session; + QVERIFY(pending); + for (int i = 0; i < 4; ++i) monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); + QCOMPARE(canvas_->memoryPressureLevel(), 4); + QCOMPARE(reader_->state(), "Recovering"); + QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); + QVERIFY(active && active->resourcesStopped); + QVERIFY(!worker->isConnected()); QVERIFY(worker->idle()); + QCOMPARE(canvas_->cacheCostBytes(), 0); QCOMPARE(canvas_->pendingRenderCount(), 0); + QVERIFY(!canvas_->viewportReady()); + QCOMPARE(active->lastPresented, presentedLocation); + QSignalSpy newTiles(canvas_, &PdfCanvas::tileRequested), newPageRequests(canvas_, &PdfCanvas::pageNeeded); + const auto stoppedRenders = canvas_->visibleRenderRequestCount(); + reader_->activateItem({{"page", 41}, {"precision", "page"}}); + reader_->search("request after memory stop"); + QTest::qWait(30); + QCOMPARE(reader_->state(), "Recovering"); + QCOMPARE(worker->queuedRequestCount(), 0); QCOMPARE(worker->activeRequestCount(), 0); + QCOMPARE(canvas_->visibleRenderRequestCount(), stoppedRenders); + QVERIFY(newTiles.isEmpty()); QVERIFY(newPageRequests.isEmpty()); + QTRY_VERIFY(!pending); + reader_->openPath(epub_); + QCOMPARE(reader_->state(), "Recovering"); + QCOMPARE(reader_->webToken(), previousToken); + QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); + QCOMPARE(reader_->findChildren().size(), 1); + for (int i = 0; i < 4; ++i) monitor->observeSample(healthy); + QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); + monitor->observeSample(healthy); + QCOMPARE(monitor->level(), MemoryPressureLevel::Normal); + QTest::qWait(100); + QCOMPARE(reader_->state(), "Recovering"); + QCOMPARE(reader_->webToken(), previousToken); + QVERIFY(!worker->isConnected()); QVERIFY(!canvas_->viewportReady()); + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + QVERIFY(reader_->webToken() != previousToken); + QTRY_VERIFY(!worker); + QCOMPARE(canvas_->memoryPressureLevel(), 0); + QCOMPARE(fileHash(manyPages_), pdfHash); QCOMPARE(fileHash(epub_), epubHash); + } + void memoryPressureStopDisposesWebViewAndRecoversExplicitly() + { + auto *monitor = reader_->findChild("docviewMemoryPressureMonitor"); + QVERIFY(monitor); monitor->stop(); + const MemorySample low{8ull * 1024 * 1024 * 1024, 100ull * 1024 * 1024, true}; + const MemorySample healthy{8ull * 1024 * 1024 * 1024, 4ull * 1024 * 1024 * 1024, true}; + for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); + const auto epubHash = fileHash(epub_), htmlHash = fileHash(html_); + reader_->openPath(epub_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000); + QPointer oldView = activeWeb(); + const auto oldToken = reader_->webToken(); + QPointer session = activeSession(); QVERIFY(session); + QPointer oldProfile = session->profile; QVERIFY(oldProfile); + QPointer worker = session->worker; QVERIFY(worker); + QSignalSpy disposed(reader_.get(), &Controller::disposeWeb); + for (int i = 0; i < 4; ++i) monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); + QCOMPARE(reader_->state(), "Recovering"); + QVERIFY(session->resourcesStopped); QVERIFY(!worker->isConnected()); QVERIFY(worker->idle()); + QVERIFY(!disposed.isEmpty()); QCOMPARE(disposed.last()[0].toString(), oldToken); + QTRY_VERIFY(!oldView); QVERIFY(!activeWeb()); + QCOMPARE(reader_->beginRendererProbe(oldToken, 0), 0u); + const auto stoppedOutline = reader_->outline(); const auto stoppedMeta = session->meta; + const QVariantMap lateHeading{{"title", "Late stale heading"}, {"index", 0}, {"href", "OPS/two.xhtml#two"}, + {"source", "html-heading"}, {"precision", "exact"}, {"level", 1}, {"depth", 0}}; + reader_->webIndex(oldToken, {{"title", "Late stale index"}, {"headings", QVariantList{lateHeading}}, + {"outline", QVariantList{lateHeading}}}); + QCOMPARE(reader_->outline(), stoppedOutline); QCOMPARE(session->meta, stoppedMeta); + for (int i = 0; i < 5; ++i) monitor->observeSample(healthy); + QTest::qWait(100); + QCOMPARE(reader_->state(), "Recovering"); QVERIFY(!activeWeb()); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 15000); + QVERIFY(reader_->webToken() != oldToken); + QTRY_VERIFY(!session); QTRY_VERIFY(!oldProfile); QTRY_VERIFY(!worker); + QVariant text; QVERIFY(evaluateWeb("document.body.innerText", &text)); + QVERIFY(text.toString().contains("Readable document")); + const auto htmlToken = reader_->webToken(); + QPointer htmlView = activeWeb(); + QSignalSpy staged(reader_.get(), &Controller::stageWeb); + for (int i = 0; i < 4; ++i) monitor->observeSample(low); + QCOMPARE(monitor->level(), MemoryPressureLevel::Stop); + QCOMPARE(reader_->state(), "Recovering"); + QTRY_VERIFY(!htmlView); + // A folder dialog may return after the memory-pressure stop. + reader_->setHtmlRoot(QUrl::fromLocalFile(QFileInfo(html_).absolutePath())); + QCOMPARE(reader_->state(), "Recovering"); + QCOMPARE(reader_->webToken(), htmlToken); + QVERIFY(reader_->notice().startsWith("E_RESOURCE_LIMIT:")); + QVERIFY(staged.isEmpty()); QVERIFY(!activeWeb()); + QCOMPARE(fileHash(epub_), epubHash); QCOMPARE(fileHash(html_), htmlHash); + } + + void xhtmlEpubHeadingNavigationStaysWithinChapter() + { + const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/reflow-rtl.epub"); + const auto original = fileHash(path); + QVERIFY(!original.isEmpty()); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QCOMPARE(reader_->format(), "epub"); + const auto token = reader_->webToken(); + QCOMPARE(activeWeb()->property("url").toUrl().path(), "/OPS/first.xhtml"); + QVERIFY(!reader_->outline().isEmpty()); + QVERIFY(reader_->outline().first().toMap().value("href").toString().startsWith("OPS/second.xhtml")); + QVariant result; + QVERIFY(evaluateWeb("({mime:document.contentType,tag:document.querySelector('h1').tagName,headings:__docviewReader.index().headings})", &result)); + const auto index = result.toMap(); + QCOMPARE(index.value("mime").toString(), "application/xhtml+xml"); + QCOMPARE(index.value("tag").toString(), "h1"); + const auto headings = index.value("headings").toList(); + QCOMPARE(headings.size(), 2); + QCOMPARE(headings[0].toMap().value("level").toInt(), 1); + QCOMPARE(headings[1].toMap().value("level").toInt(), 2); + QVERIFY(headings[1].toMap().value("href").toString().isEmpty()); // The h2 has no author ID. + QVariant position; + auto atHeading = [this, &position](const QString &tag) { + if (!activeWeb() || !evaluateWeb("({top:document.querySelector('" + tag + "').getBoundingClientRect().top,scroll:scrollY,height:innerHeight,total:document.documentElement.scrollHeight,path:location.pathname})", &position)) + return false; + const auto values = position.toMap(); + const double top = values.value("top").toDouble(), height = values.value("height").toDouble(); + // This short chapter may clamp its final heading above the bottom + // scroll limit, so require the closest attainable alignment. + const double expectedTop = qMax(0.0, values.value("scroll").toDouble() + top + - qMax(0.0, values.value("total").toDouble() - height)); + return values.value("path").toString() == "/OPS/first.xhtml" + && top < height && qAbs(top - expectedTop) < 3; + }; + reader_->activateItem({{"index", 0}}); + QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000); + for (int i = 0; i < 2; ++i) { + reader_->execute("nav.heading_next"); + QTRY_VERIFY2_WITH_TIMEOUT(atHeading("h2"), QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000); + QCOMPARE(reader_->webToken(), token); + reader_->execute("nav.heading_previous"); + QTRY_VERIFY_WITH_TIMEOUT(atHeading("h1"), 5000); + QCOMPARE(reader_->webToken(), token); + } + QCOMPARE(fileHash(path), original); + } + void clampedEpubHeadingsReachChapterBoundary() + { + const auto path = QStringLiteral(DOCVIEW_SOURCE_DIR "/tests/fixtures/performance/standard-500-clamped.epub"); + const auto original = fileHash(path); + QCOMPARE(original.toHex(), QByteArray("d4136e2c7e5e67bc10e640589c34fc629e80e66edd2ab2bd25dda238c7a2314d")); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + const auto token = reader_->webToken(); + QVariant position; + auto sample = [this, &position] { + return activeWeb() && activeWeb()->property("documentReady").toBool() + && evaluateWeb("({path:location.pathname,x:scrollX,y:scrollY,progression:__docviewReader.location().progression,first:document.querySelector('h1').getBoundingClientRect().top})", &position); + }; + reader_->activateItem({{"index", 0}}); + QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000); + QCOMPARE(position.toMap().value("path").toString(), "/chapter-0.xhtml"); + int moves = 0; + while (position.toMap().value("path").toString() == "/chapter-0.xhtml" && moves < 13) { + const auto before = position.toMap(); + reader_->execute("nav.heading_next"); + QTRY_VERIFY2_WITH_TIMEOUT(sample() && (position.toMap().value("path") != before.value("path") + || position.toMap().value("x") != before.value("x") || position.toMap().value("y") != before.value("y")), + QJsonDocument::fromVariant(position).toJson(QJsonDocument::Compact).constData(), 5000); + ++moves; + } + QCOMPARE(position.toMap().value("path").toString(), "/chapter-1.xhtml"); + QCOMPARE(reader_->webToken(), token); + QTRY_VERIFY_WITH_TIMEOUT(sample() && qAbs(position.toMap().value("first").toDouble()) < 3, 5000); + reader_->execute("nav.heading_previous"); + QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml" + && position.toMap().value("progression").toDouble() > .999, 5000); + reader_->execute("nav.heading_previous"); + QTRY_VERIFY_WITH_TIMEOUT(sample() && position.toMap().value("path").toString() == "/chapter-0.xhtml" + && position.toMap().value("progression").toDouble() < .999, 5000); + QCOMPARE(reader_->webToken(), token); + QCOMPARE(fileHash(path), original); + } + void replacingOpenDocumentDoesNotTransferSavedPosition_data() + { + QTest::addColumn("kind"); + for (const auto &kind : {"pdf", "html", "zip", "epub"}) QTest::newRow(kind) << QString(kind); + } + void replacingOpenDocumentDoesNotTransferSavedPosition() + { + QFETCH(QString, kind); + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=20\n")); + QVERIFY(reader_->command("reload")); + const QString original = kind == "pdf" ? manyPages_ : kind == "html" ? html_ : kind == "zip" ? zip_ : epub_; + const QString path = documents_.filePath("changed-during-reading." + kind); + QVERIFY(QFile::copy(original, path)); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session); + auto *store = reader_->findChild(); QVERIFY(store); + if (kind == "pdf") { + reader_->execute("nav.page", {{"page", 42}}); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + } + QTRY_VERIFY_WITH_TIMEOUT(!session->lastPresented.isEmpty(), 10000); + QVERIFY(store->flush()); + const auto prior = store->restore(path); QVERIFY(!prior.isEmpty()); + const auto identity = StateStore::fileIdentity(path); + QVERIFY(QFile::rename(path, path + ".original")); + QVERIFY(QFile::copy(original, path)); + QFile replacement(path); QVERIFY(replacement.open(QIODevice::ReadWrite)); + QVERIFY(replacement.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); + replacement.close(); + QVERIFY(StateStore::fileIdentity(path).value("fileIdentity") != identity.value("fileIdentity")); + // Closing is an unconditional save point while the renderer still owns A. + reader_->closeDocument(); + QVERIFY(store->flush()); + QVERIFY2(store->restore(path).isEmpty(), "The old document's location was rebound to the replacement's identity"); + const auto disk = QJsonDocument::fromJson([&] { QFile file(store->filePath()); if (!file.open(QIODevice::ReadOnly)) return QByteArray{}; return file.readAll(); }()).object(); + QCOMPARE(disk.value("documents").toArray().first().toObject().value("identity").toObject().toVariantMap(), identity); + QStringList notices; + const auto connection = connect(reader_.get(), &Controller::noticeChanged, reader_.get(), [&] { notices << reader_->notice(); }); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(notices.join('\n').contains("E_LOCATOR_UNRESOLVED"), 5000); + if (kind == "pdf") QCOMPARE(canvas_->currentPage(), 0); + disconnect(connection); + QCOMPARE(fileHash(path), fileHash(original)); + QCOMPARE(fileHash(path + ".original"), fileHash(original)); + } + void htmlOutlineLoadingIsNotAbsence() + { + bool observed = false, loading = false, toggled = false, placeholderVisible = false; + QString notice; + const auto connection = connect(reader_.get(), &Controller::stateChanged, reader_.get(), [&] { + if (reader_->state() != "Ready" || reader_->format() != "html" || observed) return; + observed = true; + loading = activeSession()->meta.value("capabilities").toMap().value("outline").toString() == "loading"; + reader_->execute("panel.toc.toggle"); + toggled = window_->property("tocVisible").toBool(); + notice = reader_->notice(); + const auto *placeholder = window_->findChild("outlineLoading"); + placeholderVisible = placeholder && placeholder->isVisible() + && placeholder->property("text").toString().contains(QStringLiteral("読み込")); + }); + reader_->openPath(html_); + QTRY_VERIFY_WITH_TIMEOUT(observed, 15000); + QVERIFY(loading); + QVERIFY2(toggled, "An outline request during DOM indexing must retain the panel request"); + QVERIFY(placeholderVisible); + QVERIFY(!notice.contains(QStringLiteral("目次がありません"))); + QTRY_VERIFY_WITH_TIMEOUT(!reader_->outline().isEmpty(), 10000); + QVERIFY(window_->property("tocVisible").toBool()); + QVERIFY(!window_->findChild("outlineLoading")->isVisible()); + disconnect(connection); + } + void pdfHeadingFallbackUsesOnlyInternalOutline_data() + { + QTest::addColumn("mixed"); + QTest::newRow("external-and-blocked-only") << false; + QTest::newRow("mixed-internal-external-blocked") << true; + } + void pdfHeadingFallbackUsesOnlyInternalOutline() + { + QFETCH(bool, mixed); + const auto path = documents_.filePath(mixed ? "mixed-outline-actions.pdf" : "external-outline-actions.pdf"); + QVERIFY(writeBytes(path, pagedPdf(4, mixed ? 4 : 2, 1, 2))); + const auto original = fileHash(path); + QSignalSpy external(reader_.get(), &Controller::externalLinkRequested); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + auto *session = activeSession(); QVERIFY(session); + QTRY_VERIFY_WITH_TIMEOUT(session->meta.value("capabilities").toMap().value("headings").toString() != "loading", 15000); + QCOMPARE(session->meta.value("capabilities").toMap().value("headings").toString(), mixed ? "supported" : "unavailable"); + QCOMPARE(reader_->outline().size(), mixed ? 4 : 2); // Preserve all original TOC rows. + QCOMPARE(reader_->benchmarkSnapshot().value("headingCount").toInt(), mixed ? 2 : 0); + reader_->execute("nav.heading_next"); + if (mixed) { + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + reader_->execute("nav.heading_next"); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 3, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas_->viewportReady(), 15000); + reader_->execute("nav.heading_previous"); + QTRY_COMPARE_WITH_TIMEOUT(canvas_->currentPage(), 2, 10000); + } else { + QCOMPARE(canvas_->currentPage(), 0); + QVERIFY(reader_->notice().contains(QStringLiteral("対象の情報がありません"))); + } + QVERIFY(external.isEmpty()); + QCOMPARE(fileHash(path), original); + } + void epubFailedHeadingBoundaryDoesNotLeakIntoChapterNavigation() + { + auto files = epubFixture(); + for (auto &file : files) if (file.first == "OPS/one.xhtml" || file.first == "OPS/two.xhtml") + file.second = "Boundary" + "

      First heading

      " + "

      Last heading

      "; + const auto path = documents_.filePath("failed-heading-boundary.epub"); + QVERIFY(makeZip(path, files)); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QVariant location; + auto position = [&] { return evaluateWeb("({path:location.pathname,y:scrollY})", &location); }; + QTRY_VERIFY(position() && location.toMap().value("path").toString() == "/OPS/one.xhtml"); + reader_->execute("nav.heading_previous"); + QTRY_VERIFY_WITH_TIMEOUT(reader_->notice().contains(QStringLiteral("章の端")), 5000); + reader_->execute("nav.chapter_next"); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool() + && position() && location.toMap().value("path").toString() == "/OPS/two.xhtml", 10000); + QTest::qWait(200); QVERIFY(position()); + QVERIFY2(location.toMap().value("y").toDouble() < 1, "A failed previous-heading request must not select the next chapter's last heading"); + // A successful heading boundary still deliberately resolves the previous chapter's last heading. + reader_->execute("nav.heading_previous"); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool() + && position() && location.toMap().value("path").toString() == "/OPS/one.xhtml" + && location.toMap().value("y").toDouble() > 1000, 10000); + QCOMPARE(fileHash(path), original); + } + void htmlEscapeClearsSelectedLink() + { + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QVariant result; + key(Qt::Key_Tab); + QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("document.activeElement.id", &result) && result.toString() == "local", 5000); + key(Qt::Key_Escape); + QTRY_VERIFY_WITH_TIMEOUT(evaluateWeb("({active:document.activeElement.id,outline:document.getElementById('local').style.outline})", &result) + && result.toMap().value("active").toString() != "local" + && result.toMap().value("outline").toString().isEmpty(), 5000); + QSignalSpy applied(reader_.get(), &Controller::webActionApplied); + const auto url = activeWeb()->property("url").toUrl(); + key(Qt::Key_Return); + QTRY_VERIFY_WITH_TIMEOUT(!applied.isEmpty(), 5000); + QCOMPARE(activeWeb()->property("url").toUrl(), url); + } + void temporaryUiRestoresPanelFocus_data() + { + QTest::addColumn("panel"); QTest::addColumn("temporary"); + for (const auto &panel : {QString("toc"), QString("pages")}) + for (const auto &temporary : {QString("command"), QString("help"), QString("info"), QString("file")}) + QTest::newRow(qPrintable(panel + "-" + temporary)) << panel << temporary; + } + void temporaryUiRestoresPanelFocus() + { + QFETCH(QString, panel); QFETCH(QString, temporary); + const auto path = documents_.filePath("focus-outline.pdf"); + QVERIFY(writeBytes(path, pagedPdf(8, 4))); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + reader_->execute("panel." + panel + ".toggle"); + reader_->execute("focus.next"); + QCOMPARE(reader_->context(), panel); + if (temporary == "command") key(Qt::Key_Colon); + else if (temporary == "file") reader_->execute("file.open"); + else reader_->execute(temporary == "help" ? "help.toggle" : "document.info"); + QTRY_VERIFY(reader_->mode() != "normal"); + QTest::qWait(100); + if (temporary == "file") { + QObject *dialog = nullptr; + for (auto *object : window_->findChildren()) + if (object->property("title").toString() == QStringLiteral("文書を開く") && object->metaObject()->indexOfMethod("reject()") >= 0) { dialog = object; break; } + QVERIFY(dialog); QVERIFY(QMetaObject::invokeMethod(dialog, "reject")); + } else key(Qt::Key_Escape); + QTRY_COMPARE(reader_->mode(), "normal"); + QCOMPARE(reader_->context(), panel); + auto *focus = qobject_cast(QGuiApplication::focusObject()); + QVERIFY(focus && focus->hasActiveFocus()); + // A bare Xvfb server has no window manager to reactivate the parent + // after a separate platform file-dialog window closes. + if (temporary == "file") window_->requestActivate(); + QTRY_COMPARE_WITH_TIMEOUT(QGuiApplication::focusWindow(), window_, 5000); + // An explicit successful command takes precedence over the saved origin. + key(Qt::Key_Colon); + QVERIFY(QGuiApplication::focusObject()->setProperty("text", "cancel")); + key(Qt::Key_Return); + QTRY_COMPARE(reader_->mode(), "normal"); + QCOMPARE(reader_->context(), "content"); + QVERIFY(canvas_->hasActiveFocus()); + reader_->execute("focus.next"); + QCOMPARE(reader_->context(), panel); + // A normal command may deliberately hide its origin. Fall back to content. + key(Qt::Key_Colon); + QVERIFY(QGuiApplication::focusObject()->setProperty("text", panel == "toc" ? "toctoggle" : "pagestoggle")); + key(Qt::Key_Return); + QCOMPARE(reader_->mode(), "normal"); + QCOMPARE(reader_->context(), "content"); + QVERIFY(canvas_->hasActiveFocus()); + } + void webZoomPersistsAcrossReopen_data() + { + QTest::addColumn("kind"); + for (const auto &kind : {QString("html"), QString("zip"), QString("epub"), QString("fixed")}) + QTest::newRow(qPrintable(kind)) << kind; + } + void webZoomPersistsAcrossReopen() + { + QFETCH(QString, kind); + reader_->clearHistory(); + QVERIFY(writeBytes(configuration_, "[privacy]\nremember_position=true\nrecent_documents=0\n")); + QVERIFY(reader_->command("reload")); + const auto path = kind == "html" ? html_ : kind == "zip" ? zip_ : kind == "epub" ? epub_ + : QString(DOCVIEW_SOURCE_DIR "/tests/fixtures/web/fixed-mixed.epub"); + const auto original = fileHash(path); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QTest::qWait(250); + reader_->execute("zoom.in"); reader_->execute("zoom.in"); + QTest::qWait(700); + auto *store = reader_->findChild(); QVERIFY(store); + QVERIFY(store->flush()); + const auto saved = store->restore(path); + QVERIFY2(std::abs(saved.value("zoom").toDouble() - 132.25) < .1, + qPrintable("Persisted zoom: " + saved.value("zoom").toString())); + QVERIFY(reader_->position().contains("132%")); + cleanup(); startUi(); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains("132%"), 10000); + const auto actualZoom = kind == "fixed" ? activeWeb()->parent()->property("spreadZoom").toDouble() + : activeWeb()->property("zoomFactor").toDouble(); + QVERIFY(std::abs(actualZoom - 1.3225) < .01); + if (kind == "fixed") { + QVERIFY(captureRecentScreen("fixed-epub-restored-zoom")); + reader_->execute("zoom.fit_width"); + QTRY_VERIFY(reader_->position().contains(QStringLiteral("100%(幅合わせ)"))); + QTRY_VERIFY(activeSession()->lastPresented.value("fitWidth").toBool()); + QVERIFY(reader_->findChild()->flush()); + cleanup(); startUi(); + reader_->openPath(path); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(reader_->position().contains(QStringLiteral("100%(幅合わせ)")), 10000); + QVERIFY(activeWeb()->parent()->property("spreadFitWidth").toBool()); + } + QCOMPARE(fileHash(path), original); + } + void webZoomLocationSchemaRejectsInvalidDisplayState() + { + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QTRY_VERIFY_WITH_TIMEOUT(activeWeb() && activeWeb()->property("documentReady").toBool(), 10000); + QVariant raw; + QVERIFY(evaluateWeb("globalThis.__docviewReader.command('location',{})", &raw)); + const auto token = reader_->webToken(); + const QList invalid{{{"zoom", 24.99}}, {{"zoom", 500.01}}, {{"zoom", "125"}}, + {{"zoom", true}}, {{"zoom", std::numeric_limits::quiet_NaN()}}, + {{"fitWidth", "true"}}, {{"panX", -0.1}}, {{"panY", 1.1}}}; + for (const auto &fields : invalid) { + auto location = raw.toMap(); + for (auto it = fields.begin(); it != fields.end(); ++it) location[it.key()] = it.value(); + const auto before = reader_->benchmarkSnapshot().value("location"); + reader_->webLocation(token, location); + QVERIFY(reader_->notice().startsWith("E_RESOURCE_BLOCKED")); + QCOMPARE(reader_->benchmarkSnapshot().value("location"), before); + } + for (const auto zoom : {25., 500.}) { + auto location = raw.toMap(); location["zoom"] = zoom; + reader_->webLocation(token, location); + QCOMPARE(reader_->benchmarkSnapshot().value("location").toMap().value("zoom").toDouble(), zoom); + } + } + void failedOpenIdentifiesAttemptWithoutChangingActiveWarnings() + { + reader_->openPath(manyPages_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + const auto token = reader_->webToken(), title = reader_->title(); + const auto warnings = activeSession()->meta.value("warnings"); + const auto failed = documents_.filePath("broken-target-B.pdf"); + QVERIFY(writeBytes(failed, "%PDF-1.7\ninvalid")); + reader_->openPath(failed); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + QCOMPARE(reader_->webToken(), token); QCOMPARE(reader_->title(), title); + QCOMPARE(activeSession()->meta.value("warnings"), warnings); + QVERIFY(reader_->notice().contains("broken-target-B.pdf")); + QVERIFY(reader_->notice().contains(QStringLiteral("確認")) || reader_->notice().contains(QStringLiteral("開き直"))); + reader_->notify({}); + QSignalSpy info(reader_.get(), &Controller::uiCommand); + reader_->execute("document.info"); + QVERIFY(!info.isEmpty()); + const auto text = info.last()[1].toMap().value("text").toString(); + QVERIFY(text.contains("broken-target-B.pdf")); + QVERIFY(text.contains("E_PDF_CORRUPT")); + QVERIFY(text.contains(QStringLiteral("最後に開こうとした文書"))); + QVERIFY(captureRecentScreen("open-failure-with-active-document")); + QCOMPARE(activeSession()->meta.value("warnings"), warnings); + key(Qt::Key_Escape); + const auto missing = documents_.filePath("missing-target-C.html"); + reader_->openPath(missing); + QCOMPARE(reader_->webToken(), token); + QVERIFY(reader_->notice().startsWith("E_OPEN_FAILED")); + QVERIFY(reader_->notice().contains("missing-target-C.html")); + QVERIFY(reader_->notice().contains("Ctrl+O")); + reader_->openPath(html_); + QTRY_COMPARE_WITH_TIMEOUT(reader_->state(), "Ready", 15000); + info.clear(); reader_->execute("document.info"); + QVERIFY(!info.last()[1].toMap().value("text").toString().contains("missing-target-C.html")); + } + +}; + +// A separate GUI process reads the persisted choice using the production +// Controller, workers and Main.qml. No in-memory cache survives this boundary. +static int probePersistedZipEntry(const QString &root) +{ + const QDir directory(root); + const StoragePaths paths{directory.filePath("config.toml"), directory.filePath("state/state.json"), + directory.filePath("cache"), directory.filePath("logs")}; + Controller reader; + reader.initialize(paths.configFile, false, paths); + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty("reader", &reader); + engine.load(QUrl("qrc:/qml/Main.qml")); + if (engine.rootObjects().isEmpty()) return 21; + auto *window = qobject_cast(engine.rootObjects().first()); + if (!window) return 22; + reader.attachWindow(window); + int choices = 0; + QObject::connect(&reader, &Controller::entryNeeded, &reader, [&] { ++choices; }); + reader.openPath(directory.filePath("remember-entry.zip")); + QElapsedTimer elapsed; + elapsed.start(); + while (elapsed.elapsed() < 15000) { + QCoreApplication::processEvents(); + if (choices) return 23; + if (reader.state() == "Ready") { + for (auto *object : window->findChildren()) { + if (object->property("documentToken").toString() == reader.webToken() + && object->property("isCurrent").toBool() && object->property("documentReady").toBool()) + return object->property("url").toUrl().path().endsWith("b/second.html") ? 0 : 24; + } + } + QTest::qWait(5); + } + qWarning() << "ZIP entry restart probe failed:" << reader.state() << reader.notice(); + return 25; +} + +int main(int argc, char **argv) +{ + QTemporaryDir isolatedState; + if (!isolatedState.isValid()) + return 2; + qputenv("XDG_CONFIG_HOME", isolatedState.filePath("config").toUtf8()); + qputenv("XDG_STATE_HOME", isolatedState.filePath("state").toUtf8()); + qputenv("XDG_CACHE_HOME", isolatedState.filePath("cache").toUtf8()); + registerDocumentScheme(); + QtWebEngineQuick::initialize(); + QGuiApplication application(argc, argv); + application.setQuitOnLastWindowClosed(false); + application.setApplicationName("DocView integration tests"); + application.setOrganizationName("DocView"); + qmlRegisterType("DocView", 1, 0, "PdfCanvas"); + if (argc == 3 && QByteArray(argv[1]) == "--zip-entry-probe") + return probePersistedZipEntry(QString::fromLocal8Bit(argv[2])); + AppTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_app.moc" diff --git a/tests/test_archive.cpp b/tests/test_archive.cpp new file mode 100644 index 0000000..faba7f4 --- /dev/null +++ b/tests/test_archive.cpp @@ -0,0 +1,582 @@ +#include "archive/archive.h" +#include "common/worker_process.h" +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#endif + +using namespace docview; +#ifndef Q_MOC_RUN +namespace { +struct File { QByteArray path; QByteArray data; quint32 mode = 0100600; bool compress = false; zip_int32_t method = ZIP_CM_DEFAULT; }; +QString createZip(QTemporaryDir &dir, const QList &files, const QString &name = "book.zip") { + const auto path = dir.filePath(name); + int error; + zip_t *archive = zip_open(QFile::encodeName(path).constData(), ZIP_CREATE | ZIP_TRUNCATE, &error); + if (!archive) return {}; + for (const auto &file : files) { + auto *source = zip_source_buffer(archive, file.data.constData(), zip_uint64_t(file.data.size()), 0); + const auto index = zip_file_add(archive, file.path.constData(), source, ZIP_FL_ENC_UTF_8); + if (index < 0) { zip_source_free(source); zip_discard(archive); return {}; } + if (zip_set_file_compression(archive, zip_uint64_t(index), file.method == ZIP_CM_DEFAULT ? (file.compress ? ZIP_CM_DEFLATE : ZIP_CM_STORE) : file.method, 9) < 0) { + zip_discard(archive); return {}; + } + zip_file_set_external_attributes(archive, zip_uint64_t(index), 0, ZIP_OPSYS_UNIX, file.mode << 16); + } + if (zip_close(archive) < 0) { zip_discard(archive); return {}; } + return path; +} +bool padCompressedEntry(const QString &path, quint32 padded, quint32 *compressed = nullptr) { + QFile archive(path); if (!archive.open(QIODevice::ReadWrite)) return false; + auto raw = archive.readAll(); + const auto end = raw.lastIndexOf(QByteArray("PK\5\6", 4)); + if (end < 22) return false; + const auto central = qFromLittleEndian(reinterpret_cast(raw.constData() + end + 16)); + if (central < 30 || quint64(central) + 46 > quint64(end) || raw.mid(central, 4) != QByteArray("PK\1\2", 4)) return false; + const auto size = qFromLittleEndian(reinterpret_cast(raw.constData() + central + 20)); + if (size > padded) return false; + if (compressed) *compressed = size; + qToLittleEndian(padded, raw.data() + 18); + qToLittleEndian(padded, raw.data() + central + 20); + qToLittleEndian(central + padded - size, raw.data() + end + 16); + raw.insert(central, QByteArray(padded - size, '\0')); + return archive.resize(0) && archive.write(raw) == raw.size(); +} +QList epubFiles(QByteArray package = {}, QByteArray nav = {}) { + if (package.isEmpty()) package = R"(試験書籍urn:uuid:1234pre-paginated)"; + if (nav.isEmpty()) nav = R"()"; + return {{"mimetype", "application/epub+zip"}, {"META-INF/container.xml", R"()"}, + {"OPS/package.opf", package}, {"OPS/one.xhtml", "one"}, {"OPS/two.xhtml", "two"}, {"OPS/nav.xhtml", nav}}; +} +} +#endif +class ArchiveTests : public QObject { + Q_OBJECT +private slots: + void paths_data(); + void paths(); + void unsafeArchives_data(); + void unsafeArchives(); + void htmlEntryPriority(); + void htmlCommonDirectoryAndCandidates(); + void limits(); + void paddedCompressedInputRatio_data(); + void paddedCompressedInputRatio(); + void legalCompressionInputAccounting_data(); + void legalCompressionInputAccounting(); + void smallTrailingGarbageRemainsCorrupt(); + void libzipPaddingConsistencyBaseline(); + void ratioThresholdBoundary(); + void sandboxedWorkerRejectsPaddedInput(); + void generatedRatioCorpus_data(); + void generatedRatioCorpus(); + void crcAndPartialCleanup(); + void embeddedNul(); + void nofollowExtraction(); + void epub3(); + void epubSpreadMetadata(); + void epub2Ncx(); + void xmlEntitiesAndDepth(); + void missingSpineAndFallback(); + void forbiddenNavTargets(); + void fontObfuscationAndDrm(); + void encryptedZip(); + void forgedSize(); + void metadataLimits(); + void extensionlessManifestResources(); + void streamingExtraction(); + void borrowedReadOnlySource(); + void sourceLifetimeAndAccess(); + void sandboxedWorkerStreamsOpenedSource(); +}; +void ArchiveTests::paths_data() { + QTest::addColumn("path"); QTest::addColumn("valid"); + const QStringList bad{"../x", "/x", "C:/x", "//host/x", "a\\b", "a/../b", "a//b", "a/./b", "a:b", "CON", "aux.txt", "LPT1.txt", "COM¹.dat", "foo.", "foo ", "a/NUL/x", "a?b", "a*", "a|b", "a<", QString("a") + QChar::Null + "b", QString(1025, 'a')}; + for (int i = 0; i < bad.size(); ++i) QTest::newRow(qPrintable(QString::number(i))) << bad[i] << false; + QTest::newRow("relative") << QString("assets/font.woff2") << true; + QTest::newRow("unicode") << QString("本/本文.xhtml") << true; + QTest::newRow("literal percent") << QString("100%25.html") << true; + } + +void ArchiveTests::paths() { QFETCH(QString, path); QFETCH(bool, valid); QCOMPARE(ArchiveReader::validPath(path, false), valid); } + +void ArchiveTests::unsafeArchives_data() { + QTest::addColumn("first"); QTest::addColumn("second"); QTest::addColumn("mode"); + QTest::newRow("traversal") << QByteArray("../index.html") << QByteArray() << quint32(0100600); + QTest::newRow("absolute") << QByteArray("/index.html") << QByteArray() << quint32(0100600); + QTest::newRow("symlink") << QByteArray("index.html") << QByteArray() << quint32(0120777); + QTest::newRow("fifo") << QByteArray("index.html") << QByteArray() << quint32(0010600); + QTest::newRow("device") << QByteArray("index.html") << QByteArray() << quint32(0020600); + QTest::newRow("case") << QByteArray("index.html") << QByteArray("INDEX.html") << quint32(0100600); + QTest::newRow("normalization") << QString("é.html").toUtf8() << QString("e\u0301.html").toUtf8() << quint32(0100600); + QTest::newRow("directory case") << QByteArray("Assets/a.css") << QByteArray("assets/b.css") << quint32(0100600); + QTest::newRow("file-dir forward") << QByteArray("a") << QByteArray("a/index.html") << quint32(0100600); + QTest::newRow("file-dir reverse") << QByteArray("a/index.html") << QByteArray("a") << quint32(0100600); + } + +void ArchiveTests::unsafeArchives() { + QFETCH(QByteArray, first); QFETCH(QByteArray, second); QFETCH(quint32, mode); + QTemporaryDir dir; QList files{{first, "x", mode}}; if (!second.isEmpty()) files.append(File{second, "x"}); + const auto path = createZip(dir, files); QVERIFY(!path.isEmpty()); + ArchiveReader reader; ArchiveError error; + QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); + } + +void ArchiveTests::htmlEntryPriority() { + QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "root"}, {"folder/index.html", "inner"}, {"style.css", "body{}"}}); + ArchiveReader reader; ArchiveError error; ArchiveDocument document; + QVERIFY(reader.open(path, &error)); QVERIFY(reader.describe(false, &document, &error)); + QCOMPARE(document.entry, "index.html"); QCOMPARE(document.format, "htmlzip"); QCOMPARE(document.candidates.size(), 2); + QTemporaryDir out; QVERIFY(reader.extract("index.html", out.path(), &error)); + QFile extracted(out.filePath("index.html")); QVERIFY(extracted.open(QIODevice::ReadOnly)); QCOMPARE(extracted.readAll(), "root"); + QVERIFY(!QFile::exists(out.filePath("style.css"))); // lazy extraction + } + +void ArchiveTests::htmlCommonDirectoryAndCandidates() { + QTemporaryDir dir; ArchiveError error; ArchiveDocument doc; ArchiveReader reader; + QVERIFY(reader.open(createZip(dir, {{"book/index.html", "book"}, {"book/css/style.css", "s"}}), &error)); + QVERIFY(reader.describe(false, &doc, &error)); QCOMPARE(doc.entry, "book/index.html"); + QVERIFY(reader.open(createZip(dir, {{"book/index.htm", "book"}, {"book/INDEX.html", "b"}}, "ambiguous.zip"), &error)); + QVERIFY(reader.describe(false, &doc, &error)); QVERIFY(doc.entry.isEmpty()); QCOMPARE(doc.candidates.size(), 2); + QVERIFY(reader.open(createZip(dir, {{"style.css", "x"}}, "empty.zip"), &error)); + QVERIFY(!reader.describe(false, &doc, &error)); QCOMPARE(error.code, "E_HTML_ENTRY_MISSING"); + } + +void ArchiveTests::limits() { + QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", QByteArray(101, 'a')}, {"two.html", "b"}}); + ArchiveError error; + ArchiveLimits limit; limit.maxEntryBytes = 100; ArchiveReader a(limit); QVERIFY(!a.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + limit = {}; limit.maxEntries = 1; ArchiveReader b(limit); QVERIFY(!b.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + limit = {}; limit.maxTotalBytes = 101; ArchiveReader c(limit); QVERIFY(!c.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + limit = {}; limit.maxDepth = 2; QVERIFY(!ArchiveReader::validPath("a/b/c", false, limit)); + limit = {}; limit.ratioThreshold = 32; limit.maxRatio = 2; + const auto compressed = createZip(dir, {{"index.html", QByteArray(10000, 'x'), 0100600, true}}, "bomb.zip"); + ArchiveReader d(limit); QVERIFY(!d.open(compressed, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + } + +void ArchiveTests::paddedCompressedInputRatio_data() { + QTest::addColumn("method"); + for (const auto &entry : QList>{{"deflate", ZIP_CM_DEFLATE}, {"bzip2", ZIP_CM_BZIP2}, {"lzma", ZIP_CM_LZMA}, {"xz", ZIP_CM_XZ}, {"zstd", ZIP_CM_ZSTD}}) + if (zip_compression_method_supported(entry.second, 0) && zip_compression_method_supported(entry.second, 1)) QTest::newRow(entry.first) << entry.second; +} +void ArchiveTests::paddedCompressedInputRatio() { + QFETCH(int, method); + QTemporaryDir dir; + const QByteArray plain(33 * 1024 * 1024, 'x'); + const auto path = createZip(dir, {{"index.html", plain, 0100600, true, method}}, "padded.zip"); + QVERIFY(!path.isEmpty()); + quint32 compressed = 0; + const quint32 padded = 256 * 1024; + QVERIFY(padCompressedEntry(path, padded, &compressed)); QVERIFY(compressed < padded); + ArchiveReader reader; ArchiveError error; + QVERIFY2(reader.open(path, &error), qPrintable(error.message)); + quint64 output = 0; + const bool accepted = reader.extractTo("index.html", [&](const char *, qsizetype n) { output += quint64(n); return true; }, &error); + const auto statistics = reader.lastStreamStatistics(); + qInfo() << "compressed stream bytes" << compressed << "ZIP declared bytes" << padded << "actual source input" << statistics.inputBytesRead + << "open input" << statistics.inputBytesDuringOpen << "max source read" << statistics.largestInputRead + << "provisional output" << output << "accepted" << accepted; + QVERIFY(!accepted); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + QVERIFY(output <= 32ull * 1024 * 1024); + QVERIFY(statistics.inputBytesRead < padded); + QVERIFY(statistics.largestInputRead <= 64 * 1024); + QVERIFY(statistics.outputBytes > 32ull * 1024 * 1024); +#ifdef Q_OS_UNIX + QTemporaryDir out; + QVERIFY(!reader.extract("index.html", out.path(), &error)); + QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + QVERIFY(!QFile::exists(out.filePath("index.html"))); +#endif +} + +void ArchiveTests::legalCompressionInputAccounting_data() { + paddedCompressedInputRatio_data(); + QTest::newRow("store") << int(ZIP_CM_STORE); +} +void ArchiveTests::legalCompressionInputAccounting() { + QFETCH(int, method); + // Incompressible early input must remain charged after later output crosses + // the threshold. Resetting the input count there would reject this stream. + QByteArray plain(1024 * 1024, Qt::Uninitialized); + quint32 random = 0x5a123456; + for (auto &byte : plain) { random ^= random << 13; random ^= random >> 17; random ^= random << 5; byte = char(random & 255); } + plain += QByteArray(32768, 'x'); + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", plain, 0100600, true, method}, {"other.html", "other"}}); + ArchiveLimits limits; limits.ratioThreshold = 1024 * 1024; limits.maxRatio = 2; + ArchiveReader reader(limits); ArchiveError error; + QVERIFY2(reader.open(path, &error), qPrintable(error.message)); + QByteArray actual; + QVERIFY2(reader.extractTo("index.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error), qPrintable(error.message)); + QCOMPARE(actual, plain); + const auto statistics = reader.lastStreamStatistics(); + qInfo() << "source input" << statistics.inputBytesRead << "open input" << statistics.inputBytesDuringOpen + << "max source read" << statistics.largestInputRead << "output" << statistics.outputBytes; + QVERIFY(statistics.inputBytesRead >= 1024 * 1024); + QVERIFY(statistics.largestInputRead <= 64 * 1024); + QCOMPARE(statistics.outputBytes, quint64(plain.size())); + actual.clear(); + QVERIFY(reader.extractTo("other.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error)); + QCOMPARE(actual, "other"); + QCOMPARE(reader.lastStreamStatistics().outputBytes, 5u); + QVERIFY(reader.lastStreamStatistics().inputBytesRead < 65536); // No prior entry's denominator survives. +} + +void ArchiveTests::smallTrailingGarbageRemainsCorrupt() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", QByteArray(1024, 'x'), 0100600, true}}); + QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto raw = file.readAll(); + const auto central = raw.indexOf(QByteArray("PK\1\2", 4)); const auto end = raw.lastIndexOf(QByteArray("PK\5\6", 4)); + QVERIFY(central > 30 && end > central); + const auto size = qFromLittleEndian(reinterpret_cast(raw.constData() + central + 20)); + qToLittleEndian(size + 5, raw.data() + 18); qToLittleEndian(size + 5, raw.data() + central + 20); + qToLittleEndian(quint32(central) + 5, raw.data() + end + 16); raw.insert(central, "extra"); + QVERIFY(file.resize(0)); QCOMPARE(file.write(raw), raw.size()); file.close(); + ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(path, &error)); + QByteArray actual; + QVERIFY(!reader.read("index.html", 4096, &actual, &error)); + QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); QVERIFY(actual.isEmpty()); +} + +void ArchiveTests::libzipPaddingConsistencyBaseline() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", QByteArray(33 * 1024 * 1024, 'x'), 0100600, true}}); + QVERIFY(padCompressedEntry(path, 256 * 1024)); + int code = 0; + auto *archive = zip_open(QFile::encodeName(path).constData(), ZIP_RDONLY | ZIP_CHECKCONS, &code); + QVERIFY(archive); + zip_stat_t stat; zip_stat_init(&stat); QCOMPARE(zip_stat_index(archive, 0, 0, &stat), 0); + QVERIFY(stat.size / stat.comp_size < 200); // The original metadata-only guard passes. + auto *entry = zip_fopen_index(archive, 0, 0); QVERIFY(entry); + QByteArray buffer(65536, Qt::Uninitialized); quint64 output = 0; zip_int64_t n = 0; + while ((n = zip_fread(entry, buffer.data(), zip_uint64_t(buffer.size()))) > 0) output += quint64(n); + QCOMPARE(n, -1); + const int zipError = zip_error_code_zip(zip_file_get_error(entry)); + qInfo() << "libzip-only baseline provisional output" << output << "final zip error" << zipError; + QCOMPARE(output, 33ull * 1024 * 1024); QCOMPARE(zipError, ZIP_ER_INCONS); + zip_fclose(entry); zip_discard(archive); +} + +void ArchiveTests::ratioThresholdBoundary() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", QByteArray(32 * 1024 * 1024, 'x'), 0100600, true}}); + ArchiveReader reader; ArchiveError error; + QVERIFY(reader.open(path, &error)); + quint64 total = 0; + QVERIFY2(reader.extractTo("index.html", [&](const char *, qsizetype n) { total += quint64(n); return true; }, &error), qPrintable(error.message)); + QCOMPARE(total, 32ull * 1024 * 1024); // The design applies only above 32 MiB. +} + +void ArchiveTests::sandboxedWorkerRejectsPaddedInput() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", QByteArray(33 * 1024 * 1024, 'x'), 0100600, true}}); + QVERIFY(padCompressedEntry(path, 256 * 1024)); + WorkerProcess worker("archive-ratio-test", 1); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-archive-worker", {"--source", path})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); + QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); + bool opened = false; + worker.request("open", {}, [&](const QCborMap &reply) { opened = reply.contains(QStringLiteral("result")); }); + QTRY_VERIFY_WITH_TIMEOUT(opened || !failed.isEmpty(), 5000); QVERIFY(opened); + quint64 bytes = 0; bool success = false; QString code; + worker.request("extract", {{QStringLiteral("path"), QStringLiteral("index.html")}}, [&](const QCborMap &reply) { + if (reply.contains(QStringLiteral("error"))) code = reply.value(QStringLiteral("error")).toMap().value(QStringLiteral("code")).toString(); + else { + const auto result = reply.value(QStringLiteral("result")).toMap(); + if (result.value(QStringLiteral("more")).toBool()) bytes += quint64(result.value(QStringLiteral("data")).toByteArray().size()); + else success = true; + } + }); + QTRY_VERIFY_WITH_TIMEOUT(!code.isEmpty() || success || !failed.isEmpty(), 10000); + QVERIFY(!success); QVERIFY(failed.isEmpty()); QCOMPARE(code, "E_ARCHIVE_LIMIT"); + QCOMPARE(bytes, 32ull * 1024 * 1024); + bool pinged = false; + worker.request("ping", {}, [&](const QCborMap &reply) { pinged = reply.value(QStringLiteral("result")).toMap().value(QStringLiteral("ready")).toBool(); }); + QTRY_VERIFY_WITH_TIMEOUT(pinged, 5000); + worker.stop(); +} + +void ArchiveTests::generatedRatioCorpus_data() { + QTest::addColumn("name"); QTest::addColumn("opens"); QTest::addColumn("extracts"); + QTest::newRow("padded") << QString("padded-33mib.zip") << true << false; + QTest::newRow("threshold") << QString("threshold-32mib.zip") << true << true; + QTest::newRow("unpadded") << QString("unpadded-33mib.zip") << false << false; +} +void ArchiveTests::generatedRatioCorpus() { + QFETCH(QString, name); QFETCH(bool, opens); QFETCH(bool, extracts); + const auto path = QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/fixtures/archive-ratio/" + name; + ArchiveReader reader; ArchiveError error; + QCOMPARE(reader.open(path, &error), opens); + if (!opens) { QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); return; } + quint64 bytes = 0; + QCOMPARE(reader.extractTo("index.html", [&](const char *, qsizetype size) { bytes += quint64(size); return true; }, &error), extracts); + if (!extracts) QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + QCOMPARE(bytes, 32ull * 1024 * 1024); +} + +void ArchiveTests::crcAndPartialCleanup() { + QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "correct contents"}}); + QFile archive(path); QVERIFY(archive.open(QIODevice::ReadWrite)); QByteArray raw = archive.readAll(); + const auto offset = raw.indexOf("correct contents"); QVERIFY(offset >= 0); raw[offset] = 'X'; archive.resize(0); QCOMPARE(archive.write(raw), raw.size()); archive.close(); + ArchiveReader reader; ArchiveError error; QVERIFY(reader.open(path, &error)); + QTemporaryDir out; QVERIFY(!reader.extract("index.html", out.path(), &error)); QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); + QVERIFY(!QFile::exists(out.filePath("index.html"))); + } + +void ArchiveTests::embeddedNul() { + QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "hello"}}); + QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto raw = file.readAll(); + const int central = raw.indexOf(QByteArray("PK\1\2", 4)); QVERIFY(central >= 0); raw[central + 46 + 2] = '\0'; file.resize(0); file.write(raw); file.close(); + ArchiveReader reader; ArchiveError error; QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); + } + +void ArchiveTests::nofollowExtraction() { +#ifdef Q_OS_UNIX + QTemporaryDir dir, out, victim; ArchiveReader reader; ArchiveError error; + QVERIFY(reader.open(createZip(dir, {{"assets/a.css", "s"}}), &error)); + QVERIFY(::symlink(QFile::encodeName(victim.path()).constData(), QFile::encodeName(out.filePath("assets")).constData()) == 0); + QVERIFY(!reader.extract("assets/a.css", out.path(), &error)); QCOMPARE(error.code, "E_ARCHIVE_UNSAFE_PATH"); + QVERIFY(!QFile::exists(victim.filePath("a.css"))); +#endif + } + +void ArchiveTests::epub3() { + QTemporaryDir dir; ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, epubFiles()), &error)); QVERIFY2(reader.describe(true, &doc, &error), qPrintable(error.message)); + QCOMPARE(doc.format, "epub"); QCOMPARE(doc.title, "試験書籍"); QVERIFY(doc.rtl); QVERIFY(doc.fixed); + QCOMPARE(doc.entry, "OPS/one.xhtml"); QCOMPARE(doc.spine.size(), 2); + QCOMPARE(doc.spine[0].toMap()["href"].toString(), "OPS/one.xhtml"); + QCOMPARE(doc.spine[1].toMap()["layout"].toString(), "reflowable"); QVERIFY(!doc.spine[1].toMap()["linear"].toBool()); + QCOMPARE(doc.outline[0].toMap()["href"].toString(), "OPS/two.xhtml#end"); QCOMPARE(doc.outline[1].toMap()["depth"].toInt(), 1); + QCOMPARE(doc.pageList.size(), 1); QCOMPARE(doc.landmarks.size(), 1); + } + +void ArchiveTests::epubSpreadMetadata() { + for (const auto &policy : QStringList{"auto", "both", "none", "landscape"}) { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("", QByteArray("") + policy.toUtf8() + ""); + files[2].data.replace("", ""); + files[2].data.replace("rendition:layout-reflowable", "rendition:layout-reflowable rendition:spread-none rendition:page-spread-center"); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QCOMPARE(doc.spread, policy); QCOMPARE(doc.toVariant().value("spread").toString(), policy); + QCOMPARE(doc.spine[0].toMap().value("renditionSpread").toString(), policy); + QCOMPARE(doc.spine[0].toMap().value("spread").toString(), "right"); + QCOMPARE(doc.spine[1].toMap().value("renditionSpread").toString(), "none"); + QCOMPARE(doc.spine[1].toMap().value("spread").toString(), "center"); + QCOMPARE(doc.spine[1].toMap().value("layout").toString(), "reflowable"); + } + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("", ""); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QCOMPARE(doc.spread, "auto"); + QCOMPARE(doc.spine[0].toMap().value("spread").toString(), "left"); + QCOMPARE(doc.spine[0].toMap().value("renditionSpread").toString(), "both"); +} + +void ArchiveTests::epub2Ncx() { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("version=\"3.0\"", "version=\"2.0\""); + files[2].data.replace("", ""); + files[5].data = "NCX chapter)"}); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QCOMPARE(doc.outline[0].toMap()["title"].toString(), "NCX chapter"); QVERIFY(doc.warnings.contains("E_EPUB_NAV_INVALID")); + } + +void ArchiveTests::xmlEntitiesAndDepth() { + QTemporaryDir dir; auto files = epubFiles(); + files[1].data = R"(]>)"; + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_EPUB_PACKAGE_INVALID"); + files = epubFiles(); files[1].data = QByteArray("") + QByteArray("").repeated(129) + QByteArray("").repeated(129) + ""; + QVERIFY(reader.open(createZip(dir, files, "deep.zip"), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); + } + +void ArchiveTests::missingSpineAndFallback() { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("href=\"two.xhtml\"", "href=\"missing.xhtml\""); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QVERIFY(doc.spine[1].toMap()["missing"].toBool()); QVERIFY(doc.warnings.contains("E_EPUB_SPINE_MISSING")); + files = epubFiles(); + files[2].data.replace("", ""); + QVERIFY(reader.open(createZip(dir, files, "fallback.zip"), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QCOMPARE(doc.spine[0].toMap()["href"].toString(), "OPS/one.xhtml"); + files[2].data.replace("fallback=\"one\"", "fallback=\"foreign\""); + QVERIFY(reader.open(createZip(dir, files, "cycle.zip"), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QVERIFY(doc.spine[0].toMap()["missing"].toBool()); + } + +void ArchiveTests::forbiddenNavTargets() { + QTemporaryDir dir; auto files = epubFiles(); + files[5].data.replace("two.xhtml#end", "../../outside.xhtml"); + files[5].data.replace("href=\"one.xhtml\"", "href=\"file:///etc/passwd\""); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QVERIFY(doc.outline[0].toMap()["missing"].toBool()); QVERIFY(doc.outline[1].toMap()["missing"].toBool()); + } + +void ArchiveTests::fontObfuscationAndDrm() { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("", ""); + QByteArray plain(2000, Qt::Uninitialized); for (int i = 0; i < plain.size(); ++i) plain[i] = char(i % 251); + auto encrypted = plain; const auto key = QCryptographicHash::hash("urn:uuid:1234", QCryptographicHash::Sha1); + for (int i = 0; i < 1040; ++i) encrypted[i] = char(uchar(encrypted[i]) ^ uchar(key[i % 20])); + files.append(File{"OPS/font.otf", encrypted}); + files.append(File{"META-INF/encryption.xml", R"()"}); + ArchiveReader reader; ArchiveError error; ArchiveDocument doc; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &doc, &error)); + QByteArray decoded; QVERIFY(reader.read("OPS/font.otf", 3000, &decoded, &error)); QCOMPARE(decoded, plain); + files.last().data.replace("http://www.idpf.org/2008/embedding", "urn:unsupported-drm"); + QVERIFY(reader.open(createZip(dir, files, "drm.zip"), &error)); QVERIFY(!reader.describe(true, &doc, &error)); QCOMPARE(error.code, "E_DRM_UNSUPPORTED"); + } + +void ArchiveTests::encryptedZip() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", "protected contents"}}); + int zipError = 0; + auto *archive = zip_open(QFile::encodeName(path).constData(), 0, &zipError); + QVERIFY(archive); + QCOMPARE(zip_file_set_encryption(archive, 0, ZIP_EM_AES_256, "fixture-password"), 0); + QCOMPARE(zip_close(archive), 0); + ArchiveReader reader; ArchiveError error; + QVERIFY(!reader.open(path, &error)); QCOMPARE(error.code, "E_ARCHIVE_ENCRYPTED"); +} + +void ArchiveTests::forgedSize() { + QTemporaryDir dir; + const auto path = createZip(dir, {{"index.html", "declared contents"}}); + QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); auto bytes = file.readAll(); + const int central = bytes.indexOf(QByteArray("PK\1\2", 4)); QVERIFY(central >= 0); + qToLittleEndian(quint32(1024 * 1024 * 1024), bytes.data() + central + 24); + file.resize(0); file.write(bytes); file.close(); + ArchiveReader reader; ArchiveError error; + QVERIFY(!reader.open(path, &error)); + QVERIFY(error.code == "E_ARCHIVE_LIMIT" || error.code == "E_ARCHIVE_CORRUPT"); +} + +void ArchiveTests::metadataLimits() { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("idref=\"one\"", "idref=\"" + QByteArray(1025, 'a') + "\""); + ArchiveReader reader; ArchiveError error; ArchiveDocument document; + QVERIFY(reader.open(createZip(dir, files), &error)); + QVERIFY(!reader.describe(true, &document, &error)); QCOMPARE(error.code, "E_EPUB_PACKAGE_INVALID"); + files = epubFiles(); + files[1].data.replace("", QByteArray("").repeated(20000) + ""); + QVERIFY(reader.open(createZip(dir, files, "renditions.epub"), &error)); + QVERIFY(!reader.describe(true, &document, &error)); QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); +} +void ArchiveTests::extensionlessManifestResources() { + QTemporaryDir dir; auto files = epubFiles(); + files[2].data.replace("one.xhtml", "chapter"); files[3].path = "OPS/chapter"; + files[2].data.replace("", ""); + files.append(File{"OPS/picture", "image fixture"}); files.append(File{"OPS/font", "font fixture"}); + ArchiveReader reader; ArchiveError error; ArchiveDocument document; + QVERIFY(reader.open(createZip(dir, files), &error)); QVERIFY(reader.describe(true, &document, &error)); + QCOMPARE(document.entry, "OPS/chapter"); + QHash types; for (const auto &entry : reader.entries()) types[entry.path] = entry.mime; + QCOMPARE(types["OPS/chapter"], "application/xhtml+xml"); QCOMPARE(types["OPS/picture"], "image/png"); QCOMPARE(types["OPS/font"], "font/otf"); +} +void ArchiveTests::streamingExtraction() { + QTemporaryDir dir; + QByteArray original(170000, 'x'); original[12345] = 'y'; + ArchiveReader reader; ArchiveError error; + QVERIFY(reader.open(createZip(dir, {{"index.html", original}}), &error)); + QByteArray result; qsizetype largest = 0; int chunks = 0; + QVERIFY(reader.extractTo("index.html", [&](const char *data, qsizetype size) { largest = std::max(largest, size); result.append(data, size); ++chunks; return true; }, &error)); + QCOMPARE(result, original); QVERIFY(largest <= 65536); QVERIFY(chunks > 1); + QVERIFY(!reader.extractTo("index.html", [](const char *, qsizetype) { return false; }, &error)); + QCOMPARE(error.code, "E_STORAGE_FULL"); + ArchiveLimits limits; limits.maxTotalBytes = quint64(original.size()); + ArchiveReader limited(limits); + QVERIFY(limited.open(dir.filePath("book.zip"), &error)); + QVERIFY(limited.extractTo("index.html", [](const char *, qsizetype) { return true; }, &error)); + QVERIFY(!limited.extractTo("index.html", [](const char *, qsizetype) { return true; }, &error)); + QCOMPARE(error.code, "E_ARCHIVE_LIMIT"); +} +void ArchiveTests::borrowedReadOnlySource() { + QTemporaryDir dir; + const QByteArray contents = "opened handle"; + const auto path = createZip(dir, {{"index.html", contents}}); + QFile source(path); + QVERIFY(source.open(QIODevice::ReadOnly)); + const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256); +#ifdef Q_OS_UNIX + // An unlinked source can only be parsed through the still-open descriptor. + QVERIFY(QFile::remove(path)); +#endif + { + ArchiveReader reader; ArchiveError error; ArchiveDocument document; + QVERIFY2(reader.openFile(&source, &error), qPrintable(error.message)); + QVERIFY(reader.describe(false, &document, &error)); + QCOMPARE(document.entry, "index.html"); + QByteArray actual; + QVERIFY(reader.extractTo("index.html", [&](const char *data, qsizetype size) { actual.append(data, size); return true; }, &error)); + QCOMPARE(actual, contents); + } + QVERIFY(source.isOpen()); + QVERIFY(source.seek(0)); + QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before); +} +void ArchiveTests::sourceLifetimeAndAccess() { + QTemporaryDir dir; const auto path = createZip(dir, {{"index.html", "handle"}}); + ArchiveReader reader; ArchiveError error; + QVERIFY(!reader.openFile(nullptr, &error)); + QFile writable(path); QVERIFY(writable.open(QIODevice::ReadWrite)); + QVERIFY(!reader.openFile(&writable, &error)); QCOMPARE(error.code, "E_OPEN_FAILED"); + writable.close(); + auto source = std::make_unique(path); QVERIFY(source->open(QIODevice::ReadOnly)); + QVERIFY(reader.openFile(source.get(), &error)); + source.reset(); + QByteArray actual; + QVERIFY(!reader.read("index.html", 1024, &actual, &error)); + QCOMPARE(error.code, "E_ARCHIVE_CORRUPT"); +} +void ArchiveTests::sandboxedWorkerStreamsOpenedSource() { + QTemporaryDir dir; + QByteArray contents(170000, 'x'); contents.replace(0, 6, ""); + const auto path = createZip(dir, {{"index.html", contents}}); + WorkerProcess worker("archive-handle-test", 1); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-archive-worker", {"--source", path})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); + QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); + QVERIFY(!ready.isEmpty()); + QList responses; + worker.request("ping", {}, [&](const QCborMap &reply) { responses.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 5000); + QVERIFY(responses.takeFirst().value("result").toMap().value("ready").toBool()); + worker.request("open", {}, [&](const QCborMap &reply) { responses.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 5000); + const auto opened = responses.takeFirst(); + QVERIFY2(!opened.contains(QStringLiteral("error")), qPrintable(opened.value("error").toMap().value("message").toString())); +#ifdef Q_OS_UNIX + QVERIFY(QFile::remove(path)); +#endif + QByteArray actual; bool finished = false; int chunks = 0; + worker.request("extract", {{QStringLiteral("path"), QStringLiteral("index.html")}}, [&](const QCborMap &reply) { + const auto result = reply.value("result").toMap(); + if (result.value("more").toBool()) { actual += result.value("data").toByteArray(); ++chunks; } + else { responses.append(reply); finished = true; } + }); + QTRY_VERIFY_WITH_TIMEOUT(finished || !failed.isEmpty(), 5000); + QVERIFY(failed.isEmpty()); QVERIFY(finished); QCOMPARE(chunks, 3); + QVERIFY(!responses.last().contains(QStringLiteral("error"))); + QCOMPARE(responses.last().value("result").toMap().value("size").toInteger(), contents.size()); + QCOMPARE(actual, contents); + worker.stop(); +} +QTEST_GUILESS_MAIN(ArchiveTests) +#include "test_archive.moc" diff --git a/tests/test_benchmark_runner.py b/tests/test_benchmark_runner.py new file mode 100644 index 0000000..13ce2c9 --- /dev/null +++ b/tests/test_benchmark_runner.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Runner failure-evidence regressions; these are not performance measurements.""" +import json +import hashlib +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +RUNNER = Path(__file__).with_name('benchmark.py') + +class BenchmarkRunnerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.binary = self.root/'docview' + self.binary.write_text('#!/bin/sh\nexit 7\n') + self.binary.chmod(0o700) + for name in ['docview-pdf-worker', 'docview-archive-worker']: + (self.root/name).write_bytes(b'fixture') + self.output = self.root/'results' + self.output.mkdir() + self.result = self.output/'pdf.json' + self.result.write_text(json.dumps({'success': True, 'executableSha256': {'docview': 'old'}})) + self.arguments = [str(RUNNER), '--binary', str(self.binary), '--formats', 'pdf', '--runs', '1', + '--operations', '0', '--close-cycles', '0', '--scroll-steps', '0', '--output', str(self.output)] + + @unittest.skipUnless(os.name == 'posix', 'POSIX fixture executable') + def test_startup_failure_cannot_relabel_previous_success(self): + result = subprocess.run([sys.executable, *self.arguments], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('no measurement record', result.stderr) + self.assertFalse(self.result.exists()) + + def test_timeout_cannot_leave_previous_success(self): + with patch.object(sys, 'argv', self.arguments), patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['fixture'], 1)): + with self.assertRaises(SystemExit) as failure: + runpy.run_path(str(RUNNER), run_name='__main__') + self.assertIn('process timeout', str(failure.exception)) + self.assertFalse(self.result.exists()) + + def test_os_startup_error_clears_all_selected_previous_evidence(self): + other = self.output/'html.json' + other.write_text('{"success":true}') + cold = self.output/'cold/pdf' + cold.mkdir(parents=True) + (cold/'030.json').write_text('{"success":true}') + arguments = self.arguments + ['--formats', 'pdf', 'html'] + with patch.object(sys, 'argv', arguments), patch('subprocess.run', side_effect=OSError('fixture')): + with self.assertRaises(SystemExit) as failure: + runpy.run_path(str(RUNNER), run_name='__main__') + self.assertIn('executable startup failed', str(failure.exception)) + self.assertFalse(self.result.exists()) + self.assertFalse(other.exists()) + self.assertFalse((cold/'030.json').exists()) + + def test_aggregate_success_requires_cold_success_and_valid_measurements(self): + source = RUNNER.parent/'fixtures/pdf/navigation.pdf' + source_hash = hashlib.sha256(source.read_bytes()).hexdigest() + for scenario in ['cold failure', 'measurement failure', 'memory pressure', 'cold memory pressure']: + with self.subTest(scenario=scenario): + values = {'success': True, 'operations': [], 'openFrameMs': [1], + 'processGroupRssPeakBytes': 1, 'tileCachePeakChargedBytes': 0, 'tileCacheBudgetBytes': 1, + 'maximumMemoryPressureLevel': 2 if scenario == 'memory pressure' else 0, + 'documentSha256': 'wrong' if scenario == 'measurement failure' else source_hash, + 'continuousScroll': {'dispatchedInputs': 0}, + 'openCloseCycles': [{'stateEmpty': True, 'cacheBytesAfterClose': 0, + 'workload': 'navigation-and-scroll', 'afterClose': {'available': False}}]} + def execute(command, **_): + output = Path(command[command.index('--benchmark-output')+1]) + if output == self.result: + output.write_text(json.dumps(values)) + return subprocess.CompletedProcess(command, 0) + if scenario == 'cold memory pressure': + output.write_text(json.dumps(dict(values, maximumMemoryPressureLevel=3))) + return subprocess.CompletedProcess(command, 0) + return subprocess.CompletedProcess(command, 7) + arguments = self.arguments + ['--cold-process-runs', '1' if scenario.startswith('cold ') else '0'] + with patch.object(sys, 'argv', arguments), patch('subprocess.run', side_effect=execute), patch('builtins.print'): + with self.assertRaises(SystemExit): + runpy.run_path(str(RUNNER), run_name='__main__') + result = json.loads(self.result.read_text()) + self.assertTrue(result['harnessReportedSuccess']) + self.assertFalse(result['success']) + if scenario == 'cold failure': + self.assertEqual(result['coldProcessOpen']['failed'], 1) + self.assertTrue(all(result['measurementChecks'].values())) + elif scenario == 'measurement failure': + self.assertFalse(result['measurementChecks']['source_unchanged']) + else: + self.assertFalse(result['measurementChecks']['full_resolution_conditions']) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_core.cpp b/tests/test_core.cpp new file mode 100644 index 0000000..9ae84fb --- /dev/null +++ b/tests/test_core.cpp @@ -0,0 +1,857 @@ +#include "core/config.h" +#include "core/input.h" +#include "core/state.h" +#include "core/types.h" +#include "common/contracts.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; + +class CoreTest : public QObject { + Q_OBJECT +private slots: + void defaultConfiguration(); + void atomicReload(); + void reloadKeepsSelectedSource(); + void invalidConfiguration_data(); + void invalidConfiguration(); + void bindingPrecedenceAndUnbind(); + void canonicalLogicalBindings(); + void navigationBudgetHasDistinctRecoverableError(); + void capabilityBoundary_data(); + void capabilityBoundary(); + void commandGrammar(); + void commandFailures_data(); + void commandFailures(); + void numericPageSequence(); + void prefixMismatchAndCancellation(); + void timeoutAndRepeat(); + void textInputAndFocus(); + void physicalPageBoundaryAndGeneration(); + void stateRoundTripAndPrivacy(); + void stateChangedDocumentAndLock(); + void stateOpenedIdentityRejectsReplacement(); + void stateRestoreDistinguishesChangedIdentity(); + void stateSameMetadataReplacement(); + void stateLegacyMissingPhysicalIdentity(); + void stateBackupRecovery(); + void stateLegacySessionUrlMigration_data(); + void stateLegacySessionUrlMigration(); + void stateRememberCannotReintroduceSessionUrls(); + void statePrivacySwitches(); + void stateArchiveEntryPrivacyAndRoundTrip(); + void stateArchiveEntryIdentityAndCandidates(); + void stateUnsafeArchiveEntry_data(); + void stateUnsafeArchiveEntry(); + void xdgPaths(); +}; + +void CoreTest::defaultConfiguration() +{ + ConfigManager config; + QCOMPARE(config.value("ui.theme").toString(), "dark"); + QCOMPARE(config.value("ui.toc_visible").toBool(), false); + QCOMPARE(config.value("ui.pages_visible").toBool(), false); + QCOMPARE(config.value("keys.sequence_timeout_ms").toInt(), 800); + QCOMPARE(config.value("privacy.store_text_anchor").toBool(), false); + QVERIFY(config.loadData("")); + QVERIFY(config.snapshot().value("keybindings").toList().size() > 30); +} + +void CoreTest::atomicReload() +{ + ConfigManager config; + QSignalSpy changes(&config, &ConfigManager::changed); + QVERIFY(config.loadData("[ui]\ntheme='light'\nfont_size=16.5\n[keys]\nsequence_timeout_ms=0\n")); + QCOMPARE(changes.size(), 1); + QCOMPARE(config.value("ui.font_size").toDouble(), 16.5); + const auto valid = config.snapshot(); + QVERIFY(!config.loadData("[ui]\ntheme='dark'\nfont_size=500\n")); + QCOMPARE(config.snapshot(), valid); + QCOMPARE(changes.size(), 1); + QVERIFY(config.lastError().contains("3行")); + QVERIFY(!config.loadData("[ui]\nfont_size = \n")); + QCOMPARE(config.snapshot(), valid); + QVERIFY(config.loadData("[ui]\nstatus_bar=false")); + QCOMPARE(config.value("ui.theme").toString(), "dark"); // Each reload is defaults plus one file. + QVERIFY(config.lastError().isEmpty()); +} + +void CoreTest::reloadKeepsSelectedSource() +{ + QTemporaryDir directory; + const auto path = directory.filePath("config.toml"); + ConfigManager config; + QVERIFY(!config.reload(path)); + QCOMPARE(config.path(), path); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("[ui]\ntheme='light'"); + file.close(); + QVERIFY(config.reload()); + QCOMPARE(config.value("ui.theme").toString(), "light"); + QVERIFY(file.remove()); + QVERIFY(!config.reload()); + QCOMPARE(config.value("ui.theme").toString(), "light"); +} + +void CoreTest::invalidConfiguration_data() +{ + QTest::addColumn("toml"); + QTest::newRow("future-schema") << QByteArray("schema_version=2"); + QTest::newRow("schema-type") << QByteArray("schema_version='1'"); + QTest::newRow("unknown-key") << QByteArray("[ui]\nstatusbar=true"); + QTest::newRow("unknown-section") << QByteArray("[shell]\ncommand='echo'"); + QTest::newRow("integer-type") << QByteArray("[ui]\npanel_width=280.5"); + QTest::newRow("boolean-type") << QByteArray("[ui]\nstatus_bar=1"); + QTest::newRow("unsupported-filter") << QByteArray("[pdf]\npreserve_colors=false"); + QTest::newRow("timeout-lower-bound") << QByteArray("[keys]\nsequence_timeout_ms=100"); + QTest::newRow("timeout-upper-bound") << QByteArray("[keys]\nsequence_timeout_ms=3001"); + QTest::newRow("non-finite") << QByteArray("[reading]\nfont_size=nan"); + QTest::newRow("unknown-command") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='shell.exec'"); + QTest::newRow("reserved-esc") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Esc']\ncommand='file.open'"); + QTest::newRow("reserved-digits") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['4']\ncommand='file.open'"); + QTest::newRow("prefix-same-context") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g']\ncommand='file.open'"); + QTest::newRow("prefix-global-context") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['g']\ncommand='file.open'"); + QTest::newRow("command-arguments") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='nav.page'"); + QTest::newRow("input-mode-protected") << QByteArray("[[keybindings]]\nmode='command'\ncontext='global'\nkeys=['j']\ncommand='file.open'"); + QTest::newRow("duplicate") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='file.open'\n[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='app.quit'"); + QTest::newRow("duplicate-key-alias") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+j']\ncommand='file.open'\n[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['J']\ncommand='app.quit'"); + QTest::newRow("duplicate-modifier") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Ctrl+Ctrl+A']\ncommand='file.open'"); + QTest::newRow("ambiguous-shift-symbol") << QByteArray("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+1']\ncommand='file.open'"); + QTest::newRow("oversize") << QByteArray(1024 * 1024 + 1, ' '); + QTest::newRow("deep-table") << QByteArray("[a.b.c.d.e.f.g.h.i]\nx=1"); + QTest::newRow("invalid-utf8") << QByteArray("[ui]\ntheme='\xff'"); +} + +void CoreTest::invalidConfiguration() +{ + QFETCH(QByteArray, toml); + ConfigManager config; + const auto original = config.snapshot(); + QVERIFY(!config.loadData(toml)); + QVERIFY(!config.lastError().isEmpty()); + QCOMPARE(config.snapshot(), original); +} + +void CoreTest::bindingPrecedenceAndUnbind() +{ + ConfigManager config; + QVERIFY(config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['t']\ncommand='document.info'\n" + "[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g','g']\ncommand='unbound'\n" + "[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['g']\ncommand='file.open'\n" + "[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['x']\ncommand='nav.page'\nargs={page=42}")); + InputRouter router; + router.setConfig(config); + QCOMPARE(router.feed("t").value("command").toString(), "document.info"); + QCOMPARE(router.feed("t", "toc").value("command").toString(), "panel.toc.toggle"); + QCOMPARE(router.feed("g").value("command").toString(), "file.open"); + QCOMPARE(router.feed("x").value("args").toMap().value("page").toInt(), 42); +} + +void CoreTest::canonicalLogicalBindings() +{ + QCOMPARE(CommandRegistry::canonicalKey("Meta+Shift+Alt+Ctrl+a"), "Ctrl+Alt+Shift+Meta+A"); + QCOMPARE(CommandRegistry::canonicalKey("Shift+j"), "J"); + QCOMPARE(CommandRegistry::canonicalKey("Ctrl++"), "Ctrl++"); + QCOMPARE(CommandRegistry::canonicalKey("Ctrl+Insert"), "Ctrl+Insert"); + QCOMPARE(CommandRegistry::canonicalKey("Shift+Tab"), "Shift+Tab"); + QCOMPARE(CommandRegistry::canonicalKey("j"), "j"); + QCOMPARE(CommandRegistry::canonicalKey("J"), "J"); + QVERIFY(CommandRegistry::canonicalKey("Shift+1").isEmpty()); + QVERIFY(CommandRegistry::canonicalKey("Ctrl+Ctrl+A").isEmpty()); + ConfigManager config; + QVERIFY(config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Meta+Shift+Alt+Ctrl+a']\ncommand='document.info'\n" + "[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+j']\ncommand='panel.status.toggle'\n" + "[[keybindings]]\nmode='normal'\ncontext='global'\nkeys=['Ctrl+o']\ncommand='help.toggle'")); + InputRouter router; + router.setConfig(config); + QCOMPARE(router.feed("Ctrl+Alt+Shift+Meta+A").value("command").toString(), "document.info"); + QCOMPARE(router.feed("J").value("command").toString(), "panel.status.toggle"); + QCOMPARE(router.feed("j").value("command").toString(), "scroll.down"); + QCOMPARE(router.feed("Ctrl+O").value("command").toString(), "help.toggle"); + const auto good = config.snapshot(); + QVERIFY(!config.loadData("[[keybindings]]\nmode='normal'\ncontext='content'\nkeys=['Shift+1']\ncommand='file.open'")); + QVERIFY(config.lastError().contains(QStringLiteral("文字"))); + QCOMPARE(config.snapshot(), good); +} + +void CoreTest::navigationBudgetHasDistinctRecoverableError() +{ + qint64 used = MaxNavigationBytes - 1; + const QVariantList original{QVariantMap{{"title", "previous index"}}}; + QVariantList out = original; + QString error; + QVERIFY(!sanitizeNavigation({QVariantMap{{"title", "valid next heading"}, {"page", 2}}}, &out, &used, &error)); + QCOMPARE(error, "E_NAVIGATION_LIMIT"); + QCOMPARE(used, MaxNavigationBytes - 1); + QCOMPARE(out, original); + QVERIFY(!sanitizeNavigation({QVariantMap{{"title", QString(4097, 'x')}}}, &out, &used, &error)); + QVERIFY(error.startsWith("E_WORKER_CRASH")); + QCOMPARE(out, original); +} + +void CoreTest::capabilityBoundary_data() +{ + QTest::addColumn("capabilities"); + QTest::addColumn("reasons"); + QTest::addColumn("valid"); + const QVariantMap baseline{{"pageNavigation", "supported"}, {"chapterNavigation", "unsupported"}, + {"outline", "unavailable"}, {"headings", "loading"}, {"textSearch", "loading"}, + {"textSelection", "unsupported"}, {"reflow", "unsupported"}, {"password", "unavailable"}}; + const QVariantMap explanation{{"chapterNavigation", "E_CHAPTER_NAVIGATION_UNSUPPORTED"}, + {"textSelection", "E_TEXT_SELECTION_UNSUPPORTED"}, {"reflow", "E_REFLOW_UNSUPPORTED"}}; + auto row = [&](const QByteArray &name, const QVariant &caps, const QVariant &why, bool valid = false) { + QTest::newRow(name.constData()) << caps << why << valid; + }; + row("all-four-states", baseline, explanation, true); + QVariantMap supported = baseline; + for (auto it = supported.begin(); it != supported.end(); ++it) + it.value() = "supported"; + row("supported-needs-no-reason", supported, QVariantMap{}, true); + row("capabilities-map-required", QVariantList{baseline}, explanation); + row("reasons-map-required", baseline, QVariantList{explanation}); + for (auto it = baseline.begin(); it != baseline.end(); ++it) { + auto missing = baseline; + missing.remove(it.key()); + row("missing-" + it.key().toUtf8(), missing, explanation); + } + auto altered = baseline; + altered["futureCapability"] = "supported"; + row("unknown-capability", altered, explanation); + altered = baseline; + altered.remove("headings"); + altered["futureCapability"] = "supported"; + row("eight-fields-with-unknown-name", altered, explanation); + altered = baseline; + altered["pageNavigation"] = "enabled"; + row("unknown-state", altered, explanation); + altered["pageNavigation"] = true; + row("state-type", altered, explanation); + for (auto it = explanation.begin(); it != explanation.end(); ++it) { + auto missing = explanation; + missing.remove(it.key()); + row("unsupported-without-" + it.key().toUtf8(), baseline, missing); + } + auto why = explanation; + why["futureCapability"] = "E_UNSUPPORTED"; + row("unknown-reason-owner", baseline, why); + for (const auto &invalid : QVariantList{QString{}, QString("E_WITH\nNEWLINE"), QString("explanation"), + QString("E_") + QString(127, 'A'), 42}) { + why = explanation; + why["reflow"] = invalid; + row("invalid-reason-" + QByteArray::number(invalid.toString().size()), baseline, why); + } +} + +void CoreTest::capabilityBoundary() +{ + QFETCH(QVariant, capabilities); + QFETCH(QVariant, reasons); + QFETCH(bool, valid); + QString error = "previous error"; + QCOMPARE(validateCapabilities(capabilities, reasons, &error), valid); + if (valid) + QVERIFY(error.isEmpty()); + else + QVERIFY(error.startsWith("E_WORKER_CRASH")); +} + +void CoreTest::commandGrammar() +{ + const auto open = CommandRegistry::parse(":open \"C:\\Users\\日本語 folder\\book.pdf\""); + QVERIFY2(open.valid(), qPrintable(open.error)); + QCOMPARE(open.id, "file.open"); + QCOMPARE(open.arguments.value("path").toString(), "C:\\Users\\日本語 folder\\book.pdf"); + QCOMPARE(CommandRegistry::parse(":open").arguments.size(), 0); + QCOMPARE(CommandRegistry::parse(":page 42").arguments.value("page").toInt(), 42); + QCOMPARE(CommandRegistry::parse(":rotate -90").arguments.value("degrees").toInt(), -90); + QCOMPARE(CommandRegistry::parse(":history clear").id, "history.clear"); + const auto literal = CommandRegistry::parse(":open \"$HOME/$(touch nope)/*.pdf\""); + QVERIFY(literal.valid()); + QCOMPARE(literal.arguments.value("path").toString(), "$HOME/$(touch nope)/*.pdf"); +} + +void CoreTest::commandFailures_data() +{ + QTest::addColumn("command"); + for (const auto &text : {"", ":!ls", ":page", ":page 0", ":page -1", ":page 2147483648", ":page 2 3", + ":page 2.5", ":rotate 45", ":rotate 0", ":q extra", ":history", ":open \"missing", ":open \"\""}) + QTest::newRow(text) << QString::fromUtf8(text); +} + +void CoreTest::commandFailures() +{ + QFETCH(QString, command); + const auto result = CommandRegistry::parse(command); + QVERIFY(!result.valid()); + QVERIFY(!result.error.isEmpty()); +} + +void CoreTest::numericPageSequence() +{ + InputRouter router; + QVERIFY(router.feed("4").isEmpty()); + QVERIFY(router.feed("2").isEmpty()); + QCOMPARE(router.pending(), "42"); + const auto result = router.feed("G"); + QCOMPARE(result.value("command").toString(), "nav.page"); + QCOMPARE(result.value("args").toMap().value("page").toInt(), 42); + QVERIFY(router.pending().isEmpty()); + router.feed("4"); + QVERIFY(router.feed("j").isEmpty()); + QCOMPARE(router.feed("j").value("command").toString(), "scroll.down"); + router.feed("0"); + QVERIFY(router.feed("G").isEmpty()); + for (int i = 0; i < 15; ++i) + router.feed("9"); + QVERIFY(router.feed("G").isEmpty()); + QCOMPARE(router.feed("G").value("command").toString(), "nav.document_end"); +} + +void CoreTest::prefixMismatchAndCancellation() +{ + InputRouter router; + QVERIFY(router.feed("g").isEmpty()); + QCOMPARE(router.feed("j").value("command").toString(), "scroll.down"); + router.feed("g"); + QVERIFY(router.feed("Esc").isEmpty()); + QVERIFY(router.pending().isEmpty()); + router.feed("g"); + QCOMPARE(router.feed("g").value("command").toString(), "nav.document_start"); + router.feed("Ctrl+W"); + QCOMPARE(router.feed("w").value("command").toString(), "focus.next"); + router.feed("g"); + QVERIFY(router.feed("4").isEmpty()); + QCOMPARE(router.pending(), "4"); + QCOMPARE(router.feed("G").value("args").toMap().value("page").toInt(), 4); + QCOMPARE(router.feed("Esc", "toc").value("command").toString(), "focus.content"); +} + +void CoreTest::timeoutAndRepeat() +{ + InputRouter router; + router.setBindings(CommandRegistry::defaultBindings(), 20); + router.feed("g"); + QTRY_VERIFY_WITH_TIMEOUT(router.pending().isEmpty(), 250); + QVERIFY(router.feed("g").isEmpty()); + QVERIFY(router.feed("g", "content", false, true).isEmpty()); + QCOMPARE(router.pending(), "g"); + router.reset(); + QVERIFY(router.feed("t", "content", false, true).isEmpty()); + QCOMPARE(router.feed("j", "content", false, true).value("command").toString(), "scroll.down"); + router.setBindings(CommandRegistry::defaultBindings(), 0); + router.feed("g"); + QTest::qWait(30); + QCOMPARE(router.pending(), "g"); +} + +void CoreTest::textInputAndFocus() +{ + InputRouter router; + for (const auto &key : {"j", "4", "Enter", "Esc", "Ctrl+O"}) + QVERIFY(router.feed(key, "content", true).isEmpty()); + router.setMode("command"); + QVERIFY(router.feed("j").isEmpty()); + QVERIFY(router.feed("Ctrl+O").isEmpty()); + router.setMode("normal"); + router.feed("g"); + QVERIFY(router.feed("g", "toc").isEmpty()); // Focus change resets the previous prefix. + QCOMPARE(router.pending(), "g"); + QCOMPARE(router.feed("g", "toc").value("command").toString(), "panel.first"); + router.feed("4"); + QVERIFY(router.feed("Enter", "dialog").isEmpty()); + QVERIFY(router.pending().isEmpty()); +} + +void CoreTest::physicalPageBoundaryAndGeneration() +{ + QCOMPARE(physicalPageToIndex(1, 10), std::optional{0}); + QCOMPARE(physicalPageToIndex(10, 10), std::optional{9}); + QVERIFY(!physicalPageToIndex(11, 10)); + QVERIFY(!physicalPageToIndex(0, 10)); + QVERIFY(!physicalPageToIndex(std::numeric_limits::max(), 10)); + QVERIFY(validLocation(PdfLocation{})); + HtmlLocation bad; + bad.progression = std::numeric_limits::quiet_NaN(); + QVERIFY(!validLocation(bad)); + const ResponseStamp stamp{"document-a", 2, 7}; + QVERIFY(stamp.matches({"document-a", 2, 7})); + QVERIFY(!stamp.matches({"document-a", 1, 7})); + QVERIFY(!stamp.matches({"document-b", 2, 7})); +} + +static void writeFile(const QString &path, const QByteArray &bytes) +{ + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(file.write(bytes), bytes.size()); +} + +void CoreTest::stateRoundTripAndPrivacy() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto document = directory.filePath("日本語 file.html"); + const auto statePath = directory.filePath("state/state.json"); + writeFile(document, "

      Document

      "); + { + StateStore state(statePath); + QVERIFY2(!state.readOnly(), qPrintable(state.lastError())); + QVERIFY(state.remember(document, {{"resourcePath", "book.html"}, {"progression", 0.4}, {"textQuote", "private text"}}, 125)); + QVERIFY(state.flush()); + QCOMPARE(state.recentDocuments().size(), 1); + } + { + StateStore state(statePath); + const auto result = state.restore(document); + QVERIFY(!result.isEmpty()); + QCOMPARE(result.value("zoom").toDouble(), 125.0); + QCOMPARE(result.value("location").toMap().value("progression").toDouble(), 0.4); + QVERIFY(!result.value("location").toMap().contains("textQuote")); + QVERIFY(state.clearHistory()); + QCOMPARE(state.count(), 0); + QVERIFY(state.restore(document).isEmpty()); + QVERIFY(QFileInfo::exists(document)); + QVERIFY(!QFileInfo::exists(statePath + ".bak")); + } +} + +void CoreTest::stateChangedDocumentAndLock() +{ + QTemporaryDir directory; + const auto document = directory.filePath("file.pdf"); + const auto statePath = directory.filePath("state.json"); + writeFile(document, "old"); + StateStore writer(statePath); + QVERIFY(writer.remember(document, {{"pageIndex", 4}})); + QVERIFY(writer.flush()); + StateStore reader(statePath); + QVERIFY(reader.readOnly()); + QVERIFY(!reader.remember(document, {{"pageIndex", 10}})); + QVERIFY(!reader.clearHistory()); + QCOMPARE(reader.restore(document).value("location").toMap().value("pageIndex").toInt(), 4); + writeFile(document, "new content"); + QVERIFY(writer.restore(document).isEmpty()); + QVERIFY(reader.restore(document).isEmpty()); +} + +void CoreTest::stateOpenedIdentityRejectsReplacement() +{ + QTemporaryDir directory; + const auto path = directory.filePath("book.pdf"); + writeFile(path, "original"); + const auto opened = StateStore::fileIdentity(path); + StateStore state(directory.filePath("state.json")); + QVERIFY(state.rememberOpened(path, opened, {{"pageIndex", 4}})); + QVERIFY(state.flush()); + const auto before = state.recentDocuments().first().toMap(); + QVERIFY(QFile::rename(path, path + ".old")); + writeFile(path, "original"); + QVERIFY(!state.rememberOpened(path, opened, {{"pageIndex", 42}})); + QVERIFY(state.lastError().startsWith("E_STATE_SAVE:")); + QCOMPARE(state.recentDocuments().first().toMap(), before); + QVERIFY(state.restore(path).isEmpty()); + QVERIFY(!state.rememberOpened(path, {}, {{"pageIndex", 42}})); + QVERIFY(state.flush()); + StateStore reloaded(state.filePath()); + QCOMPARE(reloaded.recentDocuments().first().toMap(), before); + QVERIFY(reloaded.restore(path).isEmpty()); + // A fresh explicit open may create new state for B, without A's location. + const auto fresh = StateStore::fileIdentity(path); + QVERIFY(state.rememberOpened(path, fresh, {{"pageIndex", 0}})); + QCOMPARE(state.restore(path).value("location").toMap().value("pageIndex").toInt(), 0); +} + +void CoreTest::stateRestoreDistinguishesChangedIdentity() +{ + QTemporaryDir directory; + const auto path = directory.filePath("book.html"); + writeFile(path, "original"); + StateStore state(directory.filePath("state.json")); + const auto opened = StateStore::fileIdentity(path); + QVERIFY(!state.restoreOpened(path, opened).identityMismatch); + QVERIFY(state.rememberOpened(path, opened, {{"progression", .7}})); + QVERIFY(!state.restoreOpened(path, opened).identityMismatch); + writeFile(path, "changed source length"); + const auto changed = StateStore::fileIdentity(path); + QVERIFY(state.restoreOpened(path, opened).identityMismatch); + QVERIFY(state.restoreOpened(path, changed).identityMismatch); + QVERIFY(state.restoreOpened(path, changed).record.isEmpty()); + ConfigManager config; + QVERIFY(config.loadData("[privacy]\nremember_position=false\n")); + state.setConfig(config); + QVERIFY(!state.restoreOpened(path, changed).identityMismatch); + QVERIFY(state.restoreOpened(path, changed).record.isEmpty()); +} + +void CoreTest::stateSameMetadataReplacement() +{ + QTemporaryDir directory; + const auto document = directory.filePath("same metadata.pdf"); + const auto replacement = directory.filePath("replacement.pdf"); + writeFile(document, "original"); + writeFile(replacement, "replaced"); + const auto timestamp = QDateTime::fromMSecsSinceEpoch(1700000000000LL); + for (const auto &path : {document, replacement}) { + QFile file(path); QVERIFY(file.open(QIODevice::ReadWrite)); + QVERIFY(file.setFileTime(timestamp, QFileDevice::FileModificationTime)); + } + const auto before = StateStore::fileIdentity(document); + QVERIFY(!before.value("fileIdentity").toString().isEmpty()); + StateStore state(directory.filePath("state.json")); + QVERIFY(state.remember(document, {{"pageIndex", 41}})); + QVERIFY(state.flush()); + const auto oldDocumentId = state.restore(document).value("documentId").toString(); + QVERIFY(!oldDocumentId.isEmpty()); + // Preserve the old object so filesystems cannot recycle its identifier. + QVERIFY(QFile::rename(document, directory.filePath("previous.pdf"))); + QVERIFY(QFile::rename(replacement, document)); + const auto after = StateStore::fileIdentity(document); + QCOMPARE(after.value("canonicalPath"), before.value("canonicalPath")); + QCOMPARE(after.value("size"), before.value("size")); + QCOMPARE(after.value("mtime"), before.value("mtime")); + QVERIFY(after.value("fileIdentity") != before.value("fileIdentity")); + QVERIFY(state.restore(document).isEmpty()); + QVERIFY(state.remember(document, {{"pageIndex", 0}})); + const auto current = state.restore(document); + QVERIFY(current.value("documentId").toString() != oldDocumentId); + QCOMPARE(current.value("location").toMap().value("pageIndex").toInt(), 0); + QVERIFY(StateStore::fileIdentity(directory.path()).isEmpty()); + QVERIFY(StateStore::fileIdentity(directory.filePath("absent.pdf")).isEmpty()); +} + +void CoreTest::stateLegacyMissingPhysicalIdentity() +{ + QTemporaryDir directory; + const auto document = directory.filePath("legacy.pdf"); + const auto statePath = directory.filePath("state.json"); + writeFile(document, "legacy content"); + { + StateStore state(statePath); + QVERIFY(state.remember(document, {{"pageIndex", 8}})); + QVERIFY(state.flush()); + } + QFile saved(statePath); QVERIFY(saved.open(QIODevice::ReadOnly)); + auto json = QJsonDocument::fromJson(saved.readAll()).object(); saved.close(); + auto documents = json.value("documents").toArray(); + auto record = documents[0].toObject(); + auto identity = record.value("identity").toObject(); + identity.insert("fileIdentity", ""); + record.insert("identity", identity); documents[0] = record; json.insert("documents", documents); + writeFile(statePath, QJsonDocument(json).toJson(QJsonDocument::Compact)); + StateStore state(statePath); + QCOMPARE(state.count(), 1); // Valid schema-v1 history remains readable. + QCOMPARE(state.recentDocuments().size(), 1); + QVERIFY(state.lastError().isEmpty()); + QVERIFY(state.restore(document).isEmpty()); + QVERIFY(state.remember(document, {{"pageIndex", 0}})); + QCOMPARE(state.restore(document).value("location").toMap().value("pageIndex").toInt(), 0); + QVERIFY(state.restore(document).value("documentId").toString() != record.value("documentId").toString()); +} + +void CoreTest::stateBackupRecovery() +{ + QTemporaryDir directory; + const auto document = directory.filePath("book.pdf"); + const auto statePath = directory.filePath("state.json"); + writeFile(document, "contents"); + { + StateStore state(statePath); + QVERIFY(state.remember(document, {{"pageIndex", 1}})); + QVERIFY(state.flush()); + QVERIFY(state.remember(document, {{"pageIndex", 2}})); + QVERIFY(state.flush()); + } + QVERIFY(QFileInfo::exists(statePath + ".bak")); + writeFile(statePath, "invalid json"); + StateStore recovered(statePath); + QCOMPARE(recovered.restore(document).value("location").toMap().value("pageIndex").toInt(), 1); + QVERIFY(recovered.lastError().contains(QStringLiteral("復元"))); + QVERIFY(recovered.clearHistory()); + QVERIFY(!QFileInfo::exists(statePath + ".bak")); +} + +void CoreTest::statePrivacySwitches() +{ + QTemporaryDir directory; + const auto document = directory.filePath("file.html"); + writeFile(document, "contents"); + StateStore state(directory.filePath("state.json")); + ConfigManager config; + QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=0")); + state.setConfig(config); + QVERIFY(state.remember(document, {{"textQuote", "secret"}})); + QCOMPARE(state.count(), 0); + QVERIFY(config.loadData("[privacy]\nstore_text_anchor=true\nrecent_documents=0")); + state.setConfig(config); + QVERIFY(state.remember(document, {{"textQuote", "opted in"}, {"progression", 0.2}})); + QVERIFY(state.recentDocuments().isEmpty()); + QCOMPARE(state.restore(document).value("location").toMap().value("textQuote").toString(), "opted in"); +} + +void CoreTest::stateLegacySessionUrlMigration_data() +{ + QTest::addColumn("damagedCurrent"); + QTest::addColumn("currentUrl"); + QTest::addColumn("readOnly"); + QTest::addColumn("forcedReadOnly"); + QTest::newRow("current-and-backup") << false << true << false << false; + QTest::newRow("backup-only-url") << false << false << false << false; + QTest::newRow("backup-fallback") << true << true << false << false; + QTest::newRow("read-only-current") << false << true << true << false; + QTest::newRow("read-only-fallback") << true << true << true << false; + QTest::newRow("forced-read-only-current") << false << true << false << true; + QTest::newRow("forced-read-only-fallback") << true << true << false << true; +} + +void CoreTest::stateLegacySessionUrlMigration() +{ + QFETCH(bool, damagedCurrent); + QFETCH(bool, currentUrl); + QFETCH(bool, readOnly); + QFETCH(bool, forcedReadOnly); + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto document = directory.filePath("book.epub"); + const auto statePath = directory.filePath("state.json"); + writeFile(document, "test document identity"); + const auto identity = QJsonObject::fromVariantMap(StateStore::fileIdentity(document)); + QVERIFY(!identity.isEmpty()); + const QString authored = "A printed example: doc://author-provided-url/chapter"; + auto record = [&](double progress, bool transient) { + QJsonObject location{{"href", "OPS/chapter.xhtml"}, {"packagePath", "OPS/book.opf"}, + {"spineIdref", "chapter"}, {"cfi", "epubcfi(/4/2:0)"}, + {"progression", progress}, {"textQuote", authored}}; + if (transient) location.insert("url", "doc://legacy-session-secret/OPS/chapter.xhtml"); + const QJsonObject entry{{"documentId", "saved-document-id"}, {"identity", identity}, + {"location", location}, {"recent", true}, {"zoom", 100}}; + return QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", QJsonArray{entry}}}) + .toJson(QJsonDocument::Compact); + }; + const QByteArray beforeCurrent = damagedCurrent ? QByteArray("damaged JSON") : record(.7, currentUrl); + const QByteArray beforeBackup = record(.2, true); + writeFile(statePath, beforeCurrent); + writeFile(statePath + ".bak", beforeBackup); + auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; }; + QLockFile otherWriter(statePath + ".lock"); + if (readOnly) QVERIFY(otherWriter.tryLock(0)); + { + StateStore state(statePath); + // load() must not write before the caller can request read-only mode. + QCOMPARE(bytes(statePath), beforeCurrent); + QCOMPARE(bytes(statePath + ".bak"), beforeBackup); + if (forcedReadOnly) state.forceReadOnly(); + QCOMPARE(state.readOnly(), readOnly || forcedReadOnly); + const auto location = state.restore(document).value("location").toMap(); + QVERIFY(!location.contains("url")); + QCOMPARE(location.value("progression").toDouble(), damagedCurrent ? .2 : .7); + QCOMPARE(location.value("cfi").toString(), "epubcfi(/4/2:0)"); + QCOMPARE(location.value("spineIdref").toString(), "chapter"); + QCOMPARE(location.value("textQuote").toString(), authored); + QVERIFY(!state.recentDocuments().first().toMap().value("location").toMap().contains("url")); + QVERIFY(state.flush()); + if (damagedCurrent) QVERIFY(state.lastError().contains(QStringLiteral("復元"))); + } + if (readOnly || forcedReadOnly) { + QCOMPARE(bytes(statePath), beforeCurrent); + QCOMPARE(bytes(statePath + ".bak"), beforeBackup); + } else { + for (const auto &path : {statePath, statePath + ".bak"}) { + const auto saved = QJsonDocument::fromJson(bytes(path)).object().value("documents").toArray(); + QCOMPARE(saved.size(), 1); + const auto location = saved.first().toObject().value("location").toObject(); + QVERIFY(!location.contains("url")); + QVERIFY(!bytes(path).contains("legacy-session-secret")); + QCOMPARE(location.value("href").toString(), "OPS/chapter.xhtml"); + QCOMPARE(location.value("textQuote").toString(), authored); + QCOMPARE(location.value("progression").toDouble(), + path.endsWith(".bak") || damagedCurrent ? .2 : .7); + } + } +} + +void CoreTest::stateRememberCannotReintroduceSessionUrls() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto document = directory.filePath("book.html"); + const auto statePath = directory.filePath("state.json"); + writeFile(document, "test document identity"); + StateStore state(statePath); + ConfigManager config; + QVERIFY(config.loadData("[privacy]\nstore_text_anchor=true\n")); + state.setConfig(config); + const QString authored = "Literal doc://printed-example/page in the document"; + QVariantMap position{{"href", "book.html"}, {"progression", .3}, {"textQuote", authored}, + {"url", "doc://new-session-secret/book.html"}}; + QVERIFY(state.remember(document, position)); + QVERIFY(!state.restore(document).value("location").toMap().contains("url")); + QVERIFY(state.flush()); + auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; }; + QVERIFY(!bytes(statePath).contains("new-session-secret")); + // Simulate an old on-disk version at backup rotation. flush must encode + // validated, sanitized state rather than copying those bytes verbatim. + auto root = QJsonDocument::fromJson(bytes(statePath)).object(); + auto documents = root.value("documents").toArray(); + auto entry = documents.first().toObject(); + auto legacy = entry.value("location").toObject(); + legacy.insert("url", "doc://old-session-secret/book.html"); + entry.insert("location", legacy); documents[0] = entry; root.insert("documents", documents); + writeFile(statePath, QJsonDocument(root).toJson(QJsonDocument::Compact)); + position["progression"] = .8; + QVERIFY(state.remember(document, position)); + QVERIFY(state.flush()); + for (const auto &path : {statePath, statePath + ".bak"}) { + const auto contents = bytes(path); + QVERIFY(!contents.contains("new-session-secret")); + QVERIFY(!contents.contains("old-session-secret")); + const auto location = QJsonDocument::fromJson(contents).object().value("documents").toArray() + .first().toObject().value("location").toObject(); + QVERIFY(!location.contains("url")); + QCOMPARE(location.value("textQuote").toString(), authored); + QCOMPARE(location.value("progression").toDouble(), path.endsWith(".bak") ? .3 : .8); + } +} + +void CoreTest::stateArchiveEntryPrivacyAndRoundTrip() +{ + QTemporaryDir directory; + const auto document = directory.filePath("book.zip"), statePath = directory.filePath("state.json"); + writeFile(document, "unchanged archive"); + const QString entry = "chapters/第二章.xhtml"; + const QStringList candidates{"first.html", entry}; + const auto identity = StateStore::fileIdentity(document); + ConfigManager config; + QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=3")); + { + StateStore state(statePath); + state.setConfig(config); + QVERIFY(state.rememberArchiveEntry(document, entry, identity)); + QCOMPARE(state.count(), 1); + QCOMPARE(state.archiveEntry(document, candidates), entry); + QVERIFY(state.restore(document).isEmpty()); + QVERIFY(state.recentDocuments().first().toMap().value("location").toMap().isEmpty()); + QVERIFY(state.flush()); + } + StateStore state(statePath); + state.setConfig(config); + QCOMPARE(state.archiveEntry(document, candidates), entry); + QVERIFY(state.remember(document, {{"resourcePath", entry}, {"progression", .6}})); + QCOMPARE(state.archiveEntry(document, candidates), entry); + QVERIFY(state.recentDocuments().first().toMap().value("location").toMap().isEmpty()); + QVERIFY(state.flush()); + const auto bytes = [](const QString &path) { QFile file(path); return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; }; + const auto before = bytes(statePath); + { + StateStore readOnly(statePath); + readOnly.setConfig(config); + QVERIFY(readOnly.readOnly()); + QCOMPARE(readOnly.archiveEntry(document, candidates), entry); + QVERIFY(!readOnly.rememberArchiveEntry(document, "first.html", identity)); + QVERIFY(!readOnly.clearHistory()); + } + QCOMPARE(bytes(statePath), before); + QVERIFY(config.loadData("[privacy]\nremember_position=false\nrecent_documents=0")); + state.setConfig(config); + QVERIFY(state.archiveEntry(document, candidates).isEmpty()); + QVERIFY(state.rememberArchiveEntry(document, "first.html", identity)); + QVERIFY(state.flush()); + QCOMPARE(bytes(statePath), before); + QVERIFY(config.loadData("[privacy]\nremember_position=true\nrecent_documents=0")); + state.setConfig(config); + QCOMPARE(state.archiveEntry(document, candidates), entry); + QVERIFY(state.recentDocuments().isEmpty()); + QVERIFY(state.remember(document, {{"resourcePath", entry}, {"progression", .4}})); + QVERIFY(state.rememberArchiveEntry(document, "first.html", identity)); + QCOMPARE(state.restore(document).value("location").toMap().value("progression").toDouble(), .4); + QVERIFY(state.flush()); + QVERIFY(QFileInfo::exists(statePath + ".bak")); + QVERIFY(state.clearHistory()); + QVERIFY(state.archiveEntry(document, candidates).isEmpty()); + QCOMPARE(state.count(), 0); + QVERIFY(!QFileInfo::exists(statePath + ".bak")); +} + +void CoreTest::stateArchiveEntryIdentityAndCandidates() +{ + QTemporaryDir directory; + const auto document = directory.filePath("book.zip"); + writeFile(document, "same archive bytes"); + const auto identity = StateStore::fileIdentity(document); + StateStore state(directory.filePath("state.json")); + QVERIFY(state.rememberArchiveEntry(document, "second.html", identity)); + QVERIFY(state.archiveEntry(document, {"first.html"}).isEmpty()); + QCOMPARE(state.archiveEntry(document, {"first.html", "second.html"}), "second.html"); + QVERIFY(QFile::rename(document, document + ".original")); + writeFile(document, "same archive bytes"); + QFile replacement(document); + QVERIFY(replacement.open(QIODevice::ReadWrite)); + QVERIFY(replacement.setFileTime(QDateTime::fromMSecsSinceEpoch(identity.value("mtime").toLongLong()), QFileDevice::FileModificationTime)); + replacement.close(); + const auto current = StateStore::fileIdentity(document); + QCOMPARE(current.value("size"), identity.value("size")); + QCOMPARE(current.value("mtime"), identity.value("mtime")); + QVERIFY(current.value("fileIdentity") != identity.value("fileIdentity")); + QVERIFY(state.archiveEntry(document, {"first.html", "second.html"}).isEmpty()); + QVERIFY(!state.rememberArchiveEntry(document, "first.html", identity)); + QVERIFY(state.rememberArchiveEntry(document, "first.html", current)); + QCOMPARE(state.archiveEntry(document, {"first.html", "second.html"}), "first.html"); +} + +void CoreTest::stateUnsafeArchiveEntry_data() +{ + QTest::addColumn("entry"); + for (const auto &entry : QStringList{"../outside.html", "/absolute.html", "C:/drive.html", "a\\b.html", + "a//b.html", "a/./b.html", "CON.html", "a/COM¹.htm", "script.js", + "https://example.com/a.html", "a?b.html", "a|b.html", "a/../b.html", + QString::fromLatin1("nul\0byte.html", 13), QString(1024, 'x') + ".html"}) + QTest::newRow(entry.toUtf8().toHex().left(60).constData()) << entry; +} + +void CoreTest::stateUnsafeArchiveEntry() +{ + QFETCH(QString, entry); + QTemporaryDir directory; + const auto document = directory.filePath("book.zip"), statePath = directory.filePath("state.json"); + writeFile(document, "archive"); + const auto identity = StateStore::fileIdentity(document); + { + StateStore state(statePath); + QVERIFY(!state.rememberArchiveEntry(document, entry, identity)); + QCOMPARE(state.count(), 0); + } + // A modified state file cannot turn a saved string into resource authority. + const QJsonObject record{{"documentId", "entry-test"}, {"identity", QJsonObject::fromVariantMap(identity)}, + {"location", QJsonObject{}}, {"recent", true}, {"archiveEntry", entry}}; + writeFile(statePath, QJsonDocument(QJsonObject{{"state_version", 1}, {"documents", QJsonArray{record}}}).toJson()); + StateStore state(statePath); + QCOMPARE(state.count(), 1); + QVERIFY(state.archiveEntry(document, {entry, "safe.html"}).isEmpty()); +} + +void CoreTest::xdgPaths() +{ +#ifdef Q_OS_UNIX + const auto previous = qgetenv("XDG_CONFIG_HOME"); + const bool hadValue = qEnvironmentVariableIsSet("XDG_CONFIG_HOME"); + qputenv("XDG_CONFIG_HOME", "relative/path"); + QCOMPARE(StoragePaths::forCurrentUser().configFile, QDir::homePath() + "/.config/docview/config.toml"); + qputenv("XDG_CONFIG_HOME", "/tmp/docview-test-config"); + QCOMPARE(StoragePaths::forCurrentUser().configFile, "/tmp/docview-test-config/docview/config.toml"); + if (hadValue) + qputenv("XDG_CONFIG_HOME", previous); + else + qunsetenv("XDG_CONFIG_HOME"); +#endif +} + +QTEST_GUILESS_MAIN(CoreTest) +#include "test_core.moc" diff --git a/tests/test_dependency_provenance.py b/tests/test_dependency_provenance.py new file mode 100644 index 0000000..2e2b821 --- /dev/null +++ b/tests/test_dependency_provenance.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Focused offline regressions for the provenance evidence collector.""" +import io +import json +from pathlib import Path +import shutil +import sys +import tarfile +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import record_dependency_provenance as provenance + + +class ProvenanceTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='docview-provenance-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def package(self, version='2:1.2.3-1', build_version=None, with_build=True): + # The Arch cache collector deliberately uses Python's native zstd tar + # support (3.14+). Only these five real .pkg.tar.zst fixture cases need + # that capability; all package/notice boundaries still run on 3.11/3.12. + try: + with tarfile.open(fileobj=io.BytesIO(), mode='w:zst'): + pass + except (tarfile.CompressionError, ModuleNotFoundError): + self.skipTest('Arch .pkg.tar.zst metadata tests require Python tarfile zstd support (3.14+)') + package = ('pkgname = fixture\npkgbase = fixture\npkgver = ' + version + + '\narch = x86_64\nlicense = MIT\nlicense = BSD-3-Clause\n' + 'builddate = 12345\npackager = Private Person \n').encode() + build = ('format = 2\npkgname = fixture\npkgbase = fixture\npkgver = ' + + (build_version or version) + '\npkgarch = x86_64\npkgbuild_sha256sum = ' + + 'a' * 64 + '\nbuildtool = devtools\nbuildtoolver = 1:1.2.3-1-any\n' + 'builddate = 12345\nbuilddir = /home/private/work\n' + 'packager = Private Person \ninstalled = private-tool-1.0\n').encode() + path = self.root / f'fixture-{version}-x86_64.pkg.tar.zst' + with tarfile.open(path, 'w:zst') as archive: + entries = [('.PKGINFO', package)] + if with_build: + entries.insert(0, ('.BUILDINFO', build)) + for name, data in entries: + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return path, package, build + + def pdfium_fixture(self, supplemental=False): + """Small physical package with the legacy 15 notices, optionally plus two.""" + project, local, payload = [self.root / name for name in ('project', 'pdfium', 'payload')] + (project / 'cmake').mkdir(parents=True) + (local / 'lib').mkdir(parents=True) + (payload / 'lib').mkdir(parents=True) + lock = json.loads((ROOT / 'cmake/pdfium.lock.json').read_text()) + (project / 'cmake/pdfium.lock.json').write_bytes(provenance.canonical(lock)) + (local / 'VERSION').write_text(''.join(f'{key}={value}\n' for key, value in + zip(('MAJOR', 'MINOR', 'BUILD', 'PATCH'), lock['version'].split('.')))) + (local / 'args.gn').write_text(''.join(f'{key} = {json.dumps(value)}\n' + for key, value in lock['buildOptions'].items())) + library = b'fixture library whose bytes are bound to every metadata layer' + for path in (local / 'lib/libpdfium.so', payload / 'lib/libpdfium.so'): + path.write_bytes(library) + component = {'version': lock['version'], 'path': 'lib/libpdfium.so', + 'sha256': provenance.digest(library), 'size': len(library), + 'source': {'upstreamCommit': lock['upstreamCommit'], + 'archiveSha256': lock['linuxX64Sha256']}, 'licenseTexts': []} + for index in range(15): + suffix = 'LICENSE' if index == 0 else f'licenses/dependency-{index}.txt' + data = f'Provider notice fixture {index}\n'.encode() + target = payload / 'share/doc/docview/pdfium' / suffix + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + original = local / suffix + original.parent.mkdir(parents=True, exist_ok=True) + original.write_bytes(data) + component['licenseTexts'].append({'path': target.relative_to(payload).as_posix(), + 'size': len(data), 'sha256': provenance.digest(data)}) + if supplemental: + source = project / 'resources/licenses/pdfium-supplemental' + shutil.copytree(ROOT / 'resources/licenses/pdfium-supplemental', source) + metadata = json.loads((source / 'sources.json').read_text()) + metadata['pdfiumLibrarySha256'] = provenance.digest(library) + metadata_bytes = provenance.canonical(metadata) + (source / 'sources.json').write_bytes(metadata_bytes) + relative = 'share/doc/docview/pdfium/supplemental' + shutil.copytree(source, payload / relative) + rows = [] + for row in metadata['files']: + path = relative + '/' + row['name'] + rows.append({**row, 'path': path}) + component['licenseTexts'].append({'path': path, 'sha256': row['sha256'], 'size': row['size']}) + component['supplementalNotices'] = {'metadataPath': relative + '/sources.json', + 'metadataSha256': provenance.digest(metadata_bytes), 'scope': metadata['scope'], + 'providerRecipeCommit': metadata['providerRecipeCommit'], + 'pdfiumLibrarySha256': provenance.digest(library), 'files': rows} + return payload, {'components': {'PDFium-independent-worker': component}}, local, project + + def test_legacy_pdfium_fifteen_notices_without_supplemental_are_accepted(self): + result = provenance.pdfium_record(*self.pdfium_fixture()) + self.assertEqual(len(result['licenseTexts']), 15) + self.assertTrue(result['localLibraryMatchesPayload']) + self.assertEqual(result['supplementalNotices']['status'], 'not-in-this-package') + self.assertTrue(any('see separate source-correspondence' in row for row in result['remaining'])) + self.assertFalse(any('not collected' in row for row in result['remaining'])) + + def test_pdfium_supplemental_binds_original_metadata_pin_library_and_two_notices(self): + result = provenance.pdfium_record(*self.pdfium_fixture(supplemental=True)) + supplements = result['supplementalNotices'] + self.assertEqual(len(result['licenseTexts']), 17) + self.assertEqual(supplements['status'], 'repository-original-and-package-matched') + self.assertEqual(supplements['pdfiumLibrarySha256'], result['library']['sha256']) + self.assertEqual(supplements['metadataSha256'], supplements['repositoryMetadata']['sha256']) + self.assertEqual({row['component'] for row in supplements['files']}, {'libc++', 'libc++abi'}) + self.assertTrue(all(len(row['sourceRevision']) == 40 for row in supplements['files'])) + + def test_supplemental_packaged_metadata_change_rejected(self): + args = self.pdfium_fixture(supplemental=True) + metadata = args[0] / 'share/doc/docview/pdfium/supplemental/sources.json' + metadata.write_bytes(metadata.read_bytes() + b'\n') + with self.assertRaisesRegex(ValueError, 'metadata differs from repository'): + provenance.pdfium_record(*args) + + def test_supplemental_changed_original_notice_rejected(self): + args = self.pdfium_fixture(supplemental=True) + notice = args[3] / 'resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt' + notice.write_bytes(b'changed upstream notice') + with self.assertRaisesRegex(ValueError, 'notice differs from original'): + provenance.pdfium_record(*args) + + def test_supplemental_changed_packaged_notice_and_inventory_cannot_hide_mismatch(self): + args = self.pdfium_fixture(supplemental=True) + component = args[1]['components']['PDFium-independent-worker'] + row = component['licenseTexts'][-1] + data = b'changed notice with a matching outer inventory hash' + (args[0] / row['path']).write_bytes(data) + row.update(size=len(data), sha256=provenance.digest(data)) + with self.assertRaisesRegex(ValueError, 'notice differs from original'): + provenance.pdfium_record(*args) + + def test_supplemental_library_substitution_with_matching_outer_hash_rejected(self): + args = self.pdfium_fixture(supplemental=True) + library = b'a different library with copied notice files' + for directory in (args[0], args[2]): + (directory / 'lib/libpdfium.so').write_bytes(library) + args[1]['components']['PDFium-independent-worker']['sha256'] = provenance.digest(library) + with self.assertRaisesRegex(ValueError, 'source pin or library hash mismatch'): + provenance.pdfium_record(*args) + + def test_supplemental_source_pin_substitution_rejected(self): + args = self.pdfium_fixture(supplemental=True) + for path in (args[0] / 'share/doc/docview/pdfium/supplemental/sources.json', + args[3] / 'resources/licenses/pdfium-supplemental/sources.json'): + metadata = json.loads(path.read_text()) + metadata['pdfiumUpstreamCommit'] = '0' * 40 + path.write_bytes(provenance.canonical(metadata)) + with self.assertRaisesRegex(ValueError, 'source pin or library hash mismatch'): + provenance.pdfium_record(*args) + + def test_supplemental_manifest_cannot_claim_a_different_component_revision(self): + args = self.pdfium_fixture(supplemental=True) + args[1]['components']['PDFium-independent-worker']['supplementalNotices']['files'][0]['sourceRevision'] = '0' * 40 + with self.assertRaisesRegex(ValueError, 'manifest metadata mismatch'): + provenance.pdfium_record(*args) + + def test_supplemental_payload_without_manifest_metadata_is_not_legacy(self): + args = self.pdfium_fixture(supplemental=True) + del args[1]['components']['PDFium-independent-worker']['supplementalNotices'] + with self.assertRaisesRegex(ValueError, 'lacks manifest metadata'): + provenance.pdfium_record(*args) + + def test_supplemental_metadata_cannot_omit_notice_from_license_inventory(self): + args = self.pdfium_fixture(supplemental=True) + args[1]['components']['PDFium-independent-worker']['licenseTexts'].pop() + with self.assertRaisesRegex(ValueError, 'license inventory differs'): + provenance.pdfium_record(*args) + + def test_supplemental_duplicate_license_row_rejected(self): + args = self.pdfium_fixture(supplemental=True) + rows = args[1]['components']['PDFium-independent-worker']['licenseTexts'] + rows.append(dict(rows[-1])) + with self.assertRaisesRegex(ValueError, 'Duplicate PDFium license'): + provenance.pdfium_record(*args) + + def test_supplemental_unknown_file_rejected(self): + args = self.pdfium_fixture(supplemental=True) + (args[0] / 'share/doc/docview/pdfium/supplemental/extra.txt').write_bytes(b'unknown') + with self.assertRaisesRegex(ValueError, 'payload contents'): + provenance.pdfium_record(*args) + + def test_identity_matched_metadata_omits_personal_fields(self): + path, package, build = self.package() + result = provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture')) + encoded = provenance.canonical(result) + for token in (b'/home/', b'private', b'packager', b'builddir', b'installed ='): + self.assertNotIn(token, encoded) + self.assertEqual(result['packageInfo']['rawSha256'], provenance.digest(package)) + self.assertEqual(result['buildInfo']['rawSha256'], provenance.digest(build)) + self.assertEqual(result['buildInfo']['selectedFields']['pkgbuild_sha256sum'], 'a' * 64) + self.assertEqual(result['packageInfo']['selectedFields']['license'], ['BSD-3-Clause', 'MIT']) + self.assertEqual(result['signatureVerification'], 'not-performed') + self.assertEqual(result['installedFileToCachedPayloadComparison'], 'not-performed') + self.assertIsNone(result['recipeCommit']) + + def test_epoch_is_part_of_cache_identity(self): + self.package() + item = {'version': '2:1.2.3-1', 'architecture': 'x86_64', + 'source': {'packageBase': 'fixture'}} + result = provenance.cache_record(self.root, 'fixture', item) + self.assertEqual(result['status'], 'identity-matched-metadata') + item['version'] = '1.2.3-1' + self.assertEqual(provenance.cache_record(self.root, 'fixture', item), + {'status': 'not-present-in-selected-cache'}) + + def test_build_version_mismatch_rejected(self): + path, _, _ = self.package(build_version='2:1.2.4-1') + with self.assertRaisesRegex(ValueError, 'identity'): + provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture')) + + def test_package_architecture_mismatch_rejected(self): + path, _, _ = self.package() + with self.assertRaisesRegex(ValueError, 'identity'): + provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'aarch64', 'fixture')) + + def test_missing_build_metadata_not_success(self): + path, _, _ = self.package(with_build=False) + with self.assertRaisesRegex(ValueError, 'lacks'): + provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture')) + + def test_duplicate_scalar_rejected(self): + with self.assertRaisesRegex(ValueError, 'Duplicate'): + provenance.parse_metadata(b'pkgver = 1\npkgver = 2\n', provenance.PKG_FIELDS) + + def test_sensitive_selected_value_rejected(self): + with self.assertRaisesRegex(ValueError, 'path or address'): + provenance.parse_metadata(b'pkgbase = /home/private\n', provenance.PKG_FIELDS) + + def test_metadata_bounds_and_nul(self): + for data in (b'a' * (provenance.MAX_METADATA + 1), b'pkgname = bad\0name\n'): + with self.assertRaises(ValueError): + provenance.parse_metadata(data, provenance.PKG_FIELDS) + + def test_license_hash_mismatch_rejected(self): + (self.root / 'LICENSE').write_bytes(b'changed license') + with self.assertRaisesRegex(ValueError, 'disagrees'): + provenance.license_evidence(self.root, + {'path': 'LICENSE', 'sha256': '0' * 64, 'size': 15}, bundled=True) + + def test_license_path_traversal_rejected(self): + with self.assertRaisesRegex(ValueError, 'Unsafe'): + provenance.license_evidence(self.root, + {'path': '../LICENSE', 'sha256': '0' * 64, 'size': 1}, bundled=True) + + def test_canonical_bytes_ignore_dictionary_insertion_order(self): + first = {'z': 1, 'a': {'b': 3, 'a': 2}} + second = {'a': {'a': 2, 'b': 3}, 'z': 1} + self.assertEqual(provenance.canonical(first), provenance.canonical(second)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_dependency_provenance_qt.py b/tests/test_dependency_provenance_qt.py new file mode 100644 index 0000000..ca87885 --- /dev/null +++ b/tests/test_dependency_provenance_qt.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Offline provenance boundary tests for the partial Qt resource notice set.""" +import json +import importlib.util +from pathlib import Path +import shutil +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import record_dependency_provenance as provenance + + +class QtNoticeProvenanceTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='docview-qt-provenance-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.project, self.payload = self.root / 'project', self.root / 'payload' + self.original = self.project / 'resources/licenses/qt-embedded-notices' + shutil.copytree(ROOT / 'resources/licenses/qt-embedded-notices', self.original) + self.relative = 'licenses/qt6-webengine/embedded-resource-notices' + self.directory = self.payload / 'share/doc/docview/third-party' / self.relative + shutil.copytree(self.original, self.directory) + raw = (self.original / 'sources.json').read_bytes() + self.metadata = json.loads(raw) + rows = [{'path': self.relative + '/' + row['name'], 'size': row['size'], 'sha256': row['sha256'], + 'origin': 'reviewed-installed-DataPack-resource-comment', + 'sourceResources': row['sourceResources'], 'collectionScope': self.metadata['scope']} + for row in self.metadata['files']] + self.component = {'version': '6.11.2-1', 'source': {'status': 'not-collected'}, 'licenseTexts': rows, + 'embeddedResourceNotices': {'status': 'collected-reviewed-resource-subset', + 'metadataPath': self.relative + '/sources.json', 'metadataSha256': provenance.digest(raw), + 'noticeCount': 7, 'sourceResourceCount': 13, 'runtimeDistribution': 'system-not-bundled', + 'completeChromiumNotices': False, 'scope': self.metadata['scope']}} + self.manifest = {'host': {'qtVersion': '6.11.2'}, 'components': {'qt6-webengine': self.component}, + 'systemFiles': self.metadata['dataPacks']} + + def record(self): + licenses = [provenance.license_evidence(self.payload, row) for row in self.component['licenseTexts']] + return provenance.qt_embedded_notice_record(self.payload, self.manifest, licenses, self.project) + + def test_new_notices_metadata_inventory_and_repo_are_bound(self): + result = self.record() + self.assertEqual(result['status'], 'repository-original-and-package-matched') + self.assertEqual((result['noticeCount'], result['sourceResourceCount']), (7, 13)) + self.assertIs(result['completeChromiumNotices'], False) + self.assertEqual(result['sourceCollectionStatus'], 'not-collected') + self.assertEqual(result['runtimeDistribution'], 'system-not-bundled') + self.assertEqual(result['metadata']['sha256'], result['repositoryMetadata']['sha256']) + self.assertEqual(len(result['files']), 7) + self.assertEqual(len(result['dataPacks']), 2) + + def test_legacy_without_any_embedded_payload_remains_supported(self): + shutil.rmtree(self.directory) + self.component['licenseTexts'] = [] + del self.component['embeddedResourceNotices'] + result = self.record() + self.assertEqual(result['status'], 'not-in-this-package') + self.assertIs(result['completeChromiumNotices'], False) + + def test_payload_without_manifest_metadata_is_not_legacy(self): + del self.component['embeddedResourceNotices'] + with self.assertRaisesRegex(ValueError, 'lacks manifest metadata'): + self.record() + + def test_changed_packaged_metadata_rejected(self): + path = self.directory / 'sources.json' + path.write_bytes(path.read_bytes() + b'\n') + with self.assertRaisesRegex(ValueError, 'metadata differs from repository'): + self.record() + + def test_changed_notice_with_updated_outer_hash_still_rejected(self): + row = self.component['licenseTexts'][0] + data = b'replacement with self-consistent package inventory' + (self.payload / 'share/doc/docview/third-party' / row['path']).write_bytes(data) + row.update(size=len(data), sha256=provenance.digest(data)) + with self.assertRaisesRegex(ValueError, 'differs from original'): + self.record() + + def test_changed_original_notice_rejected(self): + (self.original / self.metadata['files'][0]['name']).write_bytes(b'changed repository original') + with self.assertRaisesRegex(ValueError, 'digest/size mismatch'): + self.record() + + def test_changed_datapack_identity_rejected(self): + self.manifest['systemFiles'][0]['sha256'] = '0' * 64 + with self.assertRaisesRegex(ValueError, 'DataPack inventory mismatch'): + self.record() + + def test_omitted_or_duplicate_license_inventory_rejected(self): + original = self.component['licenseTexts'][:] + for rows in (original[:-1], original + [original[0]]): + self.component['licenseTexts'] = rows + with self.subTest(count=len(rows)), self.assertRaisesRegex(ValueError, 'license inventory differs'): + self.record() + + def test_forged_resource_source_not_lost_in_generic_license_hash(self): + self.component['licenseTexts'][0]['sourceResources'][0]['resourceId'] += 1 + with self.assertRaisesRegex(ValueError, 'resource provenance differs'): + self.record() + + def test_manifest_cannot_claim_full_chromium_or_other_pin(self): + original = dict(self.component['embeddedResourceNotices']) + for key, value in [('completeChromiumNotices', True), ('metadataSha256', '0' * 64), + ('noticeCount', 8), ('runtimeDistribution', 'bundled')]: + self.component['embeddedResourceNotices'] = {**original, key: value} + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'manifest metadata mismatch'): + self.record() + + def test_partial_notices_cannot_change_source_collection_status(self): + self.component['source']['status'] = 'complete' + with self.assertRaisesRegex(ValueError, 'cannot claim complete source'): + self.record() + + def test_unlisted_file_rejected(self): + (self.directory / 'extra.txt').write_bytes(b'unreviewed notice') + with self.assertRaisesRegex(ValueError, 'payload contents'): + self.record() + + +def load_tests(loader, tests, pattern): + # Keep the historical fixture tests intact while exposing one portable + # suite for current Qt notices and the existing provenance boundaries. + path = ROOT / 'tests/test_dependency_provenance.py' + spec = importlib.util.spec_from_file_location('docview_legacy_provenance_tests', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + tests.addTests(loader.loadTestsFromModule(module)) + return tests + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_font_broker.cpp b/tests/test_font_broker.cpp new file mode 100644 index 0000000..344b78e --- /dev/null +++ b/tests/test_font_broker.cpp @@ -0,0 +1,252 @@ +#include "broker/font_broker.h" +#include "common/font_contract.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#include +#endif + +using namespace docview; +namespace { +QCborMap mapRequest(QByteArray family = "Helvetica", int weight = 400) { + return {{"faceLocal", family}, {"weight", weight}, {"italic", false}, {"charset", 0}, {"pitchFamily", 0}}; +} +QCborMap call(FontBroker &broker, const QString &operation, const QCborMap &payload) { + struct Result { QCborMap map; bool complete = false; QPointer loop; }; + auto result = std::make_shared(); + QEventLoop loop; result->loop = &loop; + QTimer timer; timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + broker.request(operation, payload, [result](QCborMap map) { + result->map = std::move(map); result->complete = true; + if (result->loop) result->loop->quit(); + }); + timer.start(10000); + if (!result->complete) loop.exec(); + if (!result->complete) return {{"testTimeout", true}}; + return result->map; +} +QString code(const QCborMap &result) { return result.value("error").toMap().value("code").toString(); } +} +class FontBrokerTest : public QObject { + Q_OBJECT +private slots: + void cleanup() { QTest::qWait(10); } // Let revoked bounded OS threads release their permits. + void asynchronousMappingAndSelectedBytes() { + FontBroker broker; + bool callback = false, returned = false; QCborMap mapped; + broker.request("font.map", mapRequest(), [&](QCborMap result) { + QVERIFY(returned); QCOMPARE(QThread::currentThread(), broker.thread()); + callback = true; mapped = std::move(result); + }); + returned = true; QVERIFY(!callback); + QTRY_VERIFY_WITH_TIMEOUT(callback, 10000); + QVERIFY2(mapped.value("found").toBool(), qPrintable(QCborValue(mapped).toDiagnosticNotation())); + QVERIFY(validFontId(mapped.value("fontId"))); + QVERIFY(!mapped.contains(QStringLiteral("path"))); QVERIFY(!mapped.contains(QStringLiteral("fontRequestId"))); + const auto id = mapped.value("fontId"); const auto size = mapped.value("size").toInteger(); + QVERIFY(size > 0 && size <= MaxFontSnapshotBytes); + QCryptographicHash hash(QCryptographicHash::Sha256); + for (qint64 offset = 0; offset < size; offset += MaxFontReadBytes) { + const auto length = qMin(MaxFontReadBytes, size - offset); + const auto result = call(broker, "font.read", {{"fontId", id}, {"offset", offset}, {"length", length}}); + QCOMPARE(result.value("data").toByteArray().size(), length); QCOMPARE(result.value("offset").toInteger(), offset); + hash.addData(result.value("data").toByteArray()); + } + QCOMPARE(hash.result(), mapped.value("sha256").toByteArray()); + QVERIFY(call(broker, "font.close", {{"fontId", id}}).value("released").toBool()); + QCOMPARE(code(call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_FAILED"); + QCOMPARE(code(call(broker, "font.close", {{"fontId", id}})), "E_FONT_FAILED"); + } + void namesAreValuesAndReferencesArePrivate() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + QFile secret(directory.filePath("private.ttf")); QVERIFY(secret.open(QIODevice::WriteOnly)); + const QByteArray canary("NOT AN INSTALLED FONT: PRIVATE FIXTURE"); secret.write(canary); secret.close(); + FontBroker first, second; + for (const auto &name : {secret.fileName().toLocal8Bit(), QByteArray("sans:file=") + secret.fileName().toLocal8Bit()}) { + const auto result = call(first, "font.map", mapRequest(name)); + QVERIFY(!result.contains(QStringLiteral("error"))); + if (result.value("found").toBool()) { + QVERIFY(result.value("sha256").toByteArray() != QCryptographicHash::hash(canary, QCryptographicHash::Sha256)); + const auto id = result.value("fontId"); + QCOMPARE(code(call(second, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_FAILED"); + QVERIFY(call(first, "font.close", {{"fontId", id}}).value("released").toBool()); + } + } + } + void malformedInputAndReadBounds() { + FontBroker broker; + auto invalid = mapRequest(); invalid.insert(QStringLiteral("path"), "/etc/passwd"); + QCOMPARE(code(call(broker, "font.map", invalid)), "E_FONT_FAILED"); + QCOMPARE(code(call(broker, "font.map", mapRequest(QByteArray(257, 'a')))), "E_FONT_FAILED"); + QCOMPARE(code(call(broker, "font.map", mapRequest(QByteArray("a\0b", 3)))), "E_FONT_FAILED"); + invalid = mapRequest(); invalid.insert(QStringLiteral("weight"), 400.5); + QCOMPARE(code(call(broker, "font.map", invalid)), "E_FONT_FAILED"); + const auto font = call(broker, "font.map", mapRequest()); QVERIFY(font.value("found").toBool()); + const auto id = font.value("fontId"); const auto size = font.value("size").toInteger(); + for (const auto &request : {QCborMap{{"fontId", id}, {"offset", -1}, {"length", 1}}, + QCborMap{{"fontId", id}, {"offset", 0}, {"length", 65537}}, + QCborMap{{"fontId", id}, {"offset", size}, {"length", 1}}}) + QCOMPARE(code(call(broker, "font.read", request)), "E_FONT_FAILED"); + } + void standardAliasesPreserveResolvedFamilyAndStyle() { + FontBroker broker; + for (const auto &family : {QByteArray("Helvetica"), QByteArray("Times-Roman"), QByteArray("Courier")}) { + const auto regular = call(broker, "font.map", mapRequest(family)); + QVERIFY(regular.value("found").toBool()); + const auto actual = regular.value("actualFaceLocal").toByteArray(); + QVERIFY(!actual.isEmpty()); + const auto exact = call(broker, "font.map", mapRequest(actual)); + QVERIFY(exact.value("found").toBool()); QVERIFY(!exact.value("substituted").toBool()); + QCOMPARE(exact.value("sha256"), regular.value("sha256")); + auto boldRequest = mapRequest(family, 700); boldRequest.insert(QStringLiteral("italic"), true); + const auto bold = call(broker, "font.map", boldRequest); + QVERIFY(bold.value("found").toBool()); + QCOMPARE(bold.value("actualFaceLocal"), regular.value("actualFaceLocal")); + // Standard OS alias families in the supported test environments + // provide a distinct bold-italic face, rather than fake metadata. + QVERIFY(bold.value("sha256") != regular.value("sha256")); + for (const auto &font : {regular, exact, bold}) + QVERIFY(call(broker, "font.close", {{"fontId", font.value("fontId")}}).value("released").toBool()); + } + } + void opaqueHandleQuotaAndReuse() { + FontBroker broker; QList ids; + for (int i = 0; i < 16; ++i) { + const auto result = call(broker, "font.map", mapRequest()); QVERIFY(result.value("found").toBool()); + const auto id = result.value("fontId"); QVERIFY(!ids.contains(id)); ids << id; + } + QCOMPARE(code(call(broker, "font.map", mapRequest())), "E_FONT_LIMIT"); + QVERIFY(call(broker, "font.close", {{"fontId", ids.takeLast()}}).value("released").toBool()); + QVERIFY(call(broker, "font.map", mapRequest()).value("found").toBool()); + } + void distinctSelectionQuotaIncludesAbsentFonts() { + FontBroker broker; + auto request = mapRequest(); request.insert(QStringLiteral("charset"), 2); + for (int i = 0; i < 256; ++i) { + request.insert(QStringLiteral("faceLocal"), QByteArray("DocViewAbsentSymbol-") + QByteArray::number(i)); + const auto result = call(broker, "font.map", request); + QVERIFY2(!result.contains(QStringLiteral("error")), qPrintable(code(result))); + if (result.value("found").toBool()) call(broker, "font.close", {{"fontId", result.value("fontId")}}); + } + request.insert(QStringLiteral("faceLocal"), QByteArray("DocViewAbsentSymbol-over-limit")); + QCOMPARE(code(call(broker, "font.map", request)), "E_FONT_LIMIT"); + } + void transferredBytesAreChargedEvenForRepeatedReads() { + FontBroker broker; + const auto font = call(broker, "font.map", mapRequest()); QVERIFY(font.value("found").toBool()); + const auto id = font.value("fontId"); + const auto length = qMin(MaxFontReadBytes, font.value("size").toInteger()); + qint64 read = 0; + while (read < MaxFontTransferBytes) { + const auto count = qMin(length, MaxFontTransferBytes - read); + const auto result = call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", count}}); + QCOMPARE(result.value("data").toByteArray().size(), count); read += count; + } + QCOMPARE(code(call(broker, "font.read", {{"fontId", id}, {"offset", 0}, {"length", 1}})), "E_FONT_LIMIT"); + } + void revokeDropsQueuedCompletionsWithoutWaiting() { + bool called = false; + auto broker = std::make_unique(); + broker->request("font.map", mapRequest(), [&](QCborMap) { called = true; }); + QElapsedTimer elapsed; elapsed.start(); broker->revoke(); broker.reset(); + QVERIFY(elapsed.elapsed() < 500); + QTest::qWait(100); QVERIFY(!called); + } + void processWideOsThreadLimit() { + FontBroker first, second, third; + QCOMPARE(code(call(third, "font.map", mapRequest())), "E_FONT_LIMIT"); + QVERIFY(call(first, "font.map", mapRequest()).value("found").toBool()); + QVERIFY(call(second, "font.map", mapRequest()).value("found").toBool()); + } + void globalCacheEvictsOtherServiceButProtectsIssuedHandles() { + qint64 firstSize = 0, secondSize = 0; + { + FontBroker discovery; + const auto regular = call(discovery, "font.map", mapRequest()); + const auto bold = call(discovery, "font.map", mapRequest("Helvetica", 700)); + QVERIFY(regular.value("found").toBool()); QVERIFY(bold.value("found").toBool()); + firstSize = regular.value("size").toInteger(); secondSize = bold.value("size").toInteger(); + } + QTest::qWait(10); + const auto limit = qMax(firstSize, secondSize); + QVERIFY(limit > 0 && limit < MaxBrokerFontCacheBytes); + // Both real OS fonts fit individually, but cannot fit together. The + // production cap is only lowered; no fake backend or larger limit. + FontBroker active(nullptr, limit), staging(nullptr, limit); + const auto first = call(active, "font.map", mapRequest()); QVERIFY(first.value("found").toBool()); + const auto firstId = first.value("fontId"); + QCOMPARE(code(call(staging, "font.map", mapRequest("Helvetica", 700))), "E_FONT_LIMIT"); + const auto original = call(active, "font.read", {{"fontId", firstId}, {"offset", 0}, {"length", 16}}); + QCOMPARE(original.value("data").toByteArray().size(), 16); + QVERIFY(call(active, "font.close", {{"fontId", firstId}}).value("released").toBool()); + // This is the original bug: staging has no local cache victim, and + // must reclaim the unused snapshot still cached by active. + const auto second = call(staging, "font.map", mapRequest("Helvetica", 700)); + QVERIFY2(second.value("found").toBool(), qPrintable(QCborValue(second).toDiagnosticNotation())); + const auto secondId = second.value("fontId"); + QCOMPARE(code(call(active, "font.map", mapRequest())), "E_FONT_LIMIT"); + QCOMPARE(call(staging, "font.read", {{"fontId", secondId}, {"offset", 0}, {"length", 16}}).value("data").toByteArray().size(), 16); + QVERIFY(call(staging, "font.close", {{"fontId", secondId}}).value("released").toBool()); + const auto restored = call(active, "font.map", mapRequest()); QVERIFY(restored.value("found").toBool()); + QCOMPARE(restored.value("sha256"), first.value("sha256")); + } +#ifdef Q_OS_LINUX + void destroyingFacadeDoesNotWaitForBlockedFontconfig() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const auto fifo = directory.filePath("fontconfig.xml"); + const auto path = QFile::encodeName(fifo); + QVERIFY(::mkfifo(path.constData(), 0600) == 0); + QProcess child; + auto environment = QProcessEnvironment::systemEnvironment(); + environment.insert("FONTCONFIG_FILE", fifo); + child.setProcessEnvironment(environment); + child.start(QCoreApplication::applicationFilePath(), {"--blocked-fontconfig"}); + QVERIFY(child.waitForStarted(5000)); + // A writer succeeds only after the backend opens the fixture FIFO for + // reading. Leave it open without bytes so the actual OS font call waits. + int writer = -1; QElapsedTimer wait; wait.start(); + while (writer < 0 && wait.elapsed() < 2000 && child.state() != QProcess::NotRunning) { + writer = ::open(path.constData(), O_WRONLY | O_NONBLOCK | O_CLOEXEC); + if (writer < 0) QTest::qWait(2); + } + if (writer < 0) { child.kill(); child.waitForFinished(); } + QVERIFY2(writer >= 0, "The real fontconfig call did not enter the controlled FIFO"); + const bool finished = child.waitForFinished(5000); + ::close(writer); + if (!finished) { child.kill(); child.waitForFinished(); } + QVERIFY(finished); QCOMPARE(child.exitStatus(), QProcess::NormalExit); QCOMPARE(child.exitCode(), 0); + QCOMPARE(child.readAllStandardOutput().trimmed(), QByteArray("revoked-without-wait")); + } +#endif +}; +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); +#ifdef Q_OS_LINUX + if (app.arguments().contains("--blocked-fontconfig")) { + auto broker = std::make_unique(); + bool completed = false; + broker->request("font.map", mapRequest(), [&](QCborMap) { completed = true; }); + QTimer::singleShot(500, &app, [&] { + QElapsedTimer elapsed; elapsed.start(); broker.reset(); + if (elapsed.elapsed() >= 100 || completed) { app.exit(91); return; } + std::puts("revoked-without-wait"); std::fflush(stdout); app.quit(); + }); + return app.exec(); + } +#endif + FontBrokerTest test; return QTest::qExec(&test, argc, argv); +} +#include "test_font_broker.moc" diff --git a/tests/test_font_contract.cpp b/tests/test_font_contract.cpp new file mode 100644 index 0000000..83cb4f7 --- /dev/null +++ b/tests/test_font_contract.cpp @@ -0,0 +1,258 @@ +#include "common/font_contract.h" +#include "common/ipc.h" +#include "common/worker_font_client.h" +#include "common/worker_runtime.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; +namespace { +const QString fontId(32, 'a'); +QCborMap mapPayload(qint64 id = 1) { + return {{"fontRequestId", id}, {"faceLocal", QByteArray("Helvetica")}, + {"weight", 400}, {"italic", false}, + {"charset", 0}, {"pitchFamily", 0}}; +} +QCborMap selected(qint64 id = 1) { + return {{"fontRequestId", id}, + {"found", true}, + {"fontId", fontId}, + {"actualFaceLocal", QByteArray("Actual Face")}, + {"charset", 0}, + {"size", 4}, + {"sha256", QByteArray(32, 'x')}, + {"faceIndex", 0}, + {"substituted", true}}; +} +QCborMap parentRequest() { + return {{"protocolVersion", 1}, {"sessionId", "font-session"}, {"generation", 3}, + {"requestId", 7}, {"operation", "render"}, {"payload", QCborMap{{"page", 1}}}}; +} +QCborMap resultReply(QCborMap request, QCborMap result) { + request.remove(QStringLiteral("payload")); + request.insert(QStringLiteral("result"), result); + return request; +} +struct ChannelPair { + QLocalServer server; + WorkerRuntime runtime; + QLocalSocket *peer = nullptr; + bool open() { + server.setSocketOptions(QLocalServer::UserAccessOption); + if (!server.listen("font-contract-" + QUuid::createUuid().toString(QUuid::Id128))) + return false; + auto socket = std::make_unique(); + socket->connectToServer(server.fullServerName()); + if (!socket->waitForConnected(1000) || !server.waitForNewConnection(1000)) + return false; + peer = server.nextPendingConnection(); + runtime.transport = std::move(socket); + return peer; + } +}; +} // namespace + +class FontContractTest : public QObject { + Q_OBJECT + private slots: + void strictMapSchema() { + const auto request = mapPayload(); + QVERIFY(validateFontPayload("font.map", request)); + auto emptyName = request; + emptyName.insert(QStringLiteral("faceLocal"), QByteArray{}); + QVERIFY(validateFontPayload("font.map", emptyName)); + const QList> bad{{"faceLocal", "string coercion"}, + {"faceLocal", QByteArray(257, 'a')}, + {"faceLocal", QByteArray("a\0b", 3)}, + {"weight", -1}, + {"weight", 1001}, + {"weight", 400.0}, + {"italic", 1}, + {"charset", 130}, + {"charset", "0"}, + {"pitchFamily", 3}, + {"pitchFamily", 0x60}, + {"fontRequestId", 0}, + {"fontRequestId", 1.0}, + {"path", "/secret/font.ttf"}}; + for (const auto &[field, value] : bad) { + auto invalid = request; + invalid.insert(field, value); + QVERIFY2(!validateFontPayload("font.map", invalid), qPrintable(field)); + } + auto missing = request; + missing.remove(QStringLiteral("charset")); + QVERIFY(!validateFontPayload("font.map", missing)); + QVERIFY(!validateFontPayload("font.list", request)); + } + void boundedReadSchema() { + QCborMap request{ + {"fontRequestId", 2}, {"fontId", fontId}, {"offset", 0}, {"length", MaxFontReadBytes}}; + QVERIFY(validateFontPayload("font.read", request)); + for (const auto badLength : + {qint64(-1), qint64(0), qint64(MaxFontReadBytes + 1), std::numeric_limits::max()}) { + auto invalid = request; + invalid.insert(QStringLiteral("length"), badLength); + QVERIFY(!validateFontPayload("font.read", invalid)); + } + for (const auto badOffset : + {qint64(-1), MaxFontSnapshotBytes - 1, std::numeric_limits::max()}) { + auto invalid = request; + invalid.insert(QStringLiteral("offset"), badOffset); + QVERIFY(!validateFontPayload("font.read", invalid)); + } + request.insert(QStringLiteral("offset"), MaxFontSnapshotBytes - MaxFontReadBytes); + QVERIFY(validateFontPayload("font.read", request)); + request.insert(QStringLiteral("fontId"), fontId.toUpper()); + QVERIFY(!validateFontPayload("font.read", request)); + QVERIFY(validateFontPayload("font.close", {{"fontRequestId", 3}, {"fontId", fontId}})); + } + void selectedMetadataAndReadRepliesAreExact() { + const auto request = mapPayload(); + QVERIFY(validateFontResult("font.map", request, selected())); + auto offset = selected(); + offset.remove(QStringLiteral("faceIndex")); + offset.insert(QStringLiteral("faceOffset"), 3); + QVERIFY(validateFontResult("font.map", request, offset)); + offset.insert(QStringLiteral("faceOffset"), 4); + QVERIFY(!validateFontResult("font.map", request, offset)); + const QList> bad{{"fontRequestId", 2}, + {"fontId", QByteArray(32, 'a')}, + {"fontId", QString(31, 'a')}, + {"actualFaceLocal", QByteArray{}}, + {"charset", 255}, + {"size", 0}, + {"size", MaxFontSnapshotBytes + 1}, + {"sha256", QByteArray(31, 'x')}, + {"sha256", QString(64, 'a')}, + {"faceIndex", -1}, + {"faceIndex", 65536}, + {"faceOffset", 0}, + {"substituted", 1}, + {"more", false}}; + for (const auto &[field, value] : bad) { + auto invalid = selected(); + invalid.insert(field, value); + QVERIFY2(!validateFontResult("font.map", request, invalid), qPrintable(field)); + } + QVERIFY(validateFontResult("font.map", request, {{"fontRequestId", 1}, {"found", false}})); + QVERIFY(!validateFontResult("font.map", request, + {{"fontRequestId", 1}, {"found", false}, {"fontId", fontId}})); + const QCborMap read{{"fontRequestId", 2}, {"fontId", fontId}, {"offset", 1}, {"length", 3}}; + const QCborMap good{ + {"fontRequestId", 2}, {"fontId", fontId}, {"offset", 1}, {"data", QByteArray("abc")}}; + QVERIFY(validateFontResult("font.read", read, good)); + for (const auto &[field, value] : QList>{{"fontId", QString(32, 'b')}, + {"offset", 0}, + {"offset", 1.0}, + {"data", QByteArray("ab")}, + {"data", "abc"}, + {"more", true}}) { + auto invalid = good; + invalid.insert(field, value); + QVERIFY(!validateFontResult("font.read", read, invalid)); + } + } + void errorAndEnvelopeRoleValidation() { + QCborMap error{{"fontRequestId", 1}, {"code", "E_FONT_LIMIT"}, {"message", "Font quota reached"}}; + QVERIFY(validateFontError(error)); + error.insert(QStringLiteral("code"), "E_UNREGISTERED"); + QVERIFY(!validateFontError(error)); + auto envelope = parentRequest(); + envelope.insert(QStringLiteral("operation"), "font.map"); + envelope.insert(QStringLiteral("payload"), mapPayload()); + QVERIFY(validateEnvelope(envelope)); + envelope.insert(QStringLiteral("unexpected"), true); + QVERIFY(!validateEnvelope(envelope)); + } + void synchronousClientRoutesMultipleQueuedResponses() { + ChannelPair pair; + QVERIFY(pair.open()); + IpcChannel incoming(pair.runtime.transport.get()), broker(pair.peer); + WorkerFontClient client(&incoming, &pair.runtime); + QSignalSpy errors(&client, &WorkerFontClient::protocolError); + connect(&incoming, &IpcChannel::messageReceived, &client, + [&](const QCborMap &reply) { QVERIFY(client.routeReply(reply)); }); + QList ids; + connect(&broker, &IpcChannel::messageReceived, &broker, [&](const QCborMap &request) { + QCOMPARE(request.value("requestId").toInteger(), 7); + QCOMPARE(request.value("generation").toInteger(), 3); + const auto id = request.value("payload").toMap().value("fontRequestId").toInteger(); + ids << id; + QTimer::singleShot(0, &broker, [&, request, id] { + QVERIFY(broker.send(resultReply(request, {{"fontRequestId", id}, {"found", false}}))); + }); + }); + QVERIFY(client.setParentRequest(parentRequest())); + auto payload = mapPayload(); + payload.remove(QStringLiteral("fontRequestId")); + QCOMPARE(client.call("font.map", payload), QCborMap({{"found", false}})); + QCOMPARE(client.call("font.map", payload), QCborMap({{"found", false}})); + QCOMPARE(ids, QList({1, 2})); + QVERIFY(!client.failed()); + QCOMPARE(errors.size(), 0); + client.clearParentRequest(); + } + void clientRejectsMismatchedReply() { + ChannelPair pair; + QVERIFY(pair.open()); + IpcChannel incoming(pair.runtime.transport.get()), broker(pair.peer); + WorkerFontClient client(&incoming, &pair.runtime); + QSignalSpy errors(&client, &WorkerFontClient::protocolError); + connect(&incoming, &IpcChannel::messageReceived, &client, + [&](const QCborMap &reply) { client.routeReply(reply); }); + connect(&broker, &IpcChannel::messageReceived, &broker, [&](const QCborMap &request) { + QVERIFY(broker.send(resultReply(request, {{"fontRequestId", 9}, {"found", false}}))); + }); + QVERIFY(client.setParentRequest(parentRequest())); + auto payload = mapPayload(); + payload.remove(QStringLiteral("fontRequestId")); + QCOMPARE(client.call("font.map", payload).value("error").toMap().value("code").toString(), + "E_WORKER_CRASH"); + QCOMPARE(errors.size(), 1); + QVERIFY(client.failed()); + client.clearParentRequest(); + } + void clientTimeoutIsStickyAndDoesNotSendAnotherRequest() { + ChannelPair pair; + QVERIFY(pair.open()); + IpcChannel incoming(pair.runtime.transport.get()), broker(pair.peer); + WorkerFontClient client(&incoming, &pair.runtime); + QSignalSpy requests(&broker, &IpcChannel::messageReceived); + QVERIFY(client.setParentRequest(parentRequest())); + auto payload = mapPayload(); + payload.remove(QStringLiteral("fontRequestId")); + QElapsedTimer elapsed; + elapsed.start(); + const auto failure = client.call("font.map", payload, 20); + QCOMPARE(failure.value("error").toMap().value("code").toString(), "E_WORKER_TIMEOUT"); + QVERIFY(elapsed.elapsed() < 1000); + QCOMPARE(client.call("font.map", payload), failure); + QCOMPARE(requests.size(), 1); + client.clearParentRequest(); + } + void clientTransportDisconnectEndsWaitImmediately() { + ChannelPair pair; + QVERIFY(pair.open()); + IpcChannel incoming(pair.runtime.transport.get()), broker(pair.peer); + WorkerFontClient client(&incoming, &pair.runtime); + QVERIFY(client.setParentRequest(parentRequest())); + auto payload = mapPayload(); + payload.remove(QStringLiteral("fontRequestId")); + QTimer::singleShot(10, pair.peer, &QLocalSocket::close); + QElapsedTimer elapsed; + elapsed.start(); + QCOMPARE(client.call("font.map", payload).value("error").toMap().value("code").toString(), + "E_WORKER_CRASH"); + QVERIFY(elapsed.elapsed() < 1000); + client.clearParentRequest(); + } +}; +QTEST_GUILESS_MAIN(FontContractTest) +#include "test_font_contract.moc" diff --git a/tests/test_instance.cpp b/tests/test_instance.cpp new file mode 100644 index 0000000..b049a78 --- /dev/null +++ b/tests/test_instance.cpp @@ -0,0 +1,339 @@ +#include "common/single_instance.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#include +#include +#endif + +using namespace docview; + +static QByteArray framed(const QCborMap &map) +{ + const auto value = QCborValue(map).toCbor(); + QByteArray bytes(4, '\0'); + qToBigEndian(static_cast(value.size()), bytes.data()); + return bytes + value; +} + +class InstanceTest : public QObject { + Q_OBJECT +private slots: + void forwardsToPrimary() + { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + QVERIFY(primary.isPrimary()); + QSignalSpy opens(&primary, &SingleInstance::openRequested); + const auto path = directory.filePath("日本語 document.pdf"); + QProcess second; + second.start(QCoreApplication::applicationFilePath(), {"--instance-forward", directory.path(), path}); + QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000); + QCOMPARE(second.exitStatus(), QProcess::NormalExit); + QCOMPARE(second.exitCode(), 0); + QCOMPARE(opens.size(), 1); + QCOMPARE(opens.first().first().toStringList(), QStringList{path}); + QVERIFY(primary.isPrimary()); + } + + void activatesWithoutDocument() + { + QTemporaryDir directory; + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + QSignalSpy opens(&primary, &SingleInstance::openRequested); + QProcess second; + second.start(QCoreApplication::applicationFilePath(), {"--instance-forward", directory.path()}); + QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000); + QCOMPARE(second.exitCode(), 0); + QCOMPARE(opens.size(), 1); + QVERIFY(opens.first().first().toStringList().isEmpty()); + } + + void forwardsInitialPage_data() + { + QTest::addColumn("page"); + QTest::newRow("first") << 1; + QTest::newRow("page-42") << 42; + QTest::newRow("maximum") << 100000; + } + + void forwardsInitialPage() + { + QFETCH(int, page); + QTemporaryDir directory; + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + QSignalSpy ordinaryOpens(&primary, &SingleInstance::openRequested); + QSignalSpy pageOpens(&primary, &SingleInstance::openAtPageRequested); + const auto path = directory.filePath("page 日本語.pdf"); + QProcess second; + second.start(QCoreApplication::applicationFilePath(), + {"--instance-forward-page", directory.path(), QString::number(page), path}); + QTRY_COMPARE_WITH_TIMEOUT(second.state(), QProcess::NotRunning, 5000); + QCOMPARE(second.exitStatus(), QProcess::NormalExit); + QCOMPARE(second.exitCode(), 0); + QCOMPARE(ordinaryOpens.size(), 0); + QCOMPARE(pageOpens.size(), 1); + QCOMPARE(pageOpens.front()[0].toString(), path); + QCOMPARE(pageOpens.front()[1].toInt(), page); + } + + void lockedButUnavailableIsReadOnly() + { + QTemporaryDir directory; + QLockFile owner(directory.filePath("instance.lock")); + QVERIFY(owner.tryLock(0)); + SingleInstance second; + QCOMPARE(second.start(directory.path(), {}, 100), SingleInstance::StartResult::ReadOnly); + QVERIFY(!second.isPrimary()); + QVERIFY(!second.lastError().isEmpty()); + QVERIFY(owner.isLocked()); + } + + void rejectsMalformedRequests_data() + { + QTest::addColumn("bytes"); + QTest::newRow("relative") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"relative.pdf"}}}); + QTest::newRow("url") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"https://example.org/document.pdf"}}}); + QTest::newRow("unc") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{"//server/share/file.pdf"}}}); + QTest::newRow("resource") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{":/secret"}}}); + QTest::newRow("version") << framed({{"version", 2}, {"operation", "open"}, {"paths", QCborArray{}}}); + QTest::newRow("type") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{42}}}); + QTest::newRow("operation") << framed({{"version", 1}, {"operation", "execute"}, {"paths", QCborArray{}}}); + QTest::newRow("unknown-key") << framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{}}, {"shell", "touch"}}); + const QCborArray path{QDir::temp().absoluteFilePath("one.pdf")}; + const auto pageRequest = [&](const QCborValue &page, const QCborArray &paths) { + return framed({{"version", 1}, {"operation", "open"}, {"paths", paths}, {"initialPage", page}}); + }; + QTest::newRow("page-string") << pageRequest("42", path); + QTest::newRow("page-double") << pageRequest(42.0, path); + QTest::newRow("page-bool") << pageRequest(true, path); + QTest::newRow("page-zero") << pageRequest(0, path); + QTest::newRow("page-negative") << pageRequest(-1, path); + QTest::newRow("page-too-large") << pageRequest(100001, path); + QTest::newRow("page-no-path") << pageRequest(42, {}); + QTest::newRow("page-two-paths") << pageRequest(42, {path.at(0), QDir::temp().absoluteFilePath("two.pdf")}); + QByteArray tooLarge(4, '\0'); + qToBigEndian(8193, tooLarge.data()); + QTest::newRow("oversize-header") << tooLarge; + QTest::newRow("empty-frame") << QByteArray(4, '\0'); + QByteArray invalid(4, '\0'); + qToBigEndian(1, invalid.data()); + invalid.append(char(0xff)); + QTest::newRow("invalid-cbor") << invalid; + } + + void rejectsMalformedRequests() + { + QFETCH(QByteArray, bytes); + QTemporaryDir directory; + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + QSignalSpy opens(&primary, &SingleInstance::openRequested); + QSignalSpy pageOpens(&primary, &SingleInstance::openAtPageRequested); + QLocalSocket socket; + socket.connectToServer(primary.endpoint()); + QTRY_COMPARE(socket.state(), QLocalSocket::ConnectedState); + QCOMPARE(socket.write(bytes), bytes.size()); + socket.flush(); + QTRY_COMPARE_WITH_TIMEOUT(socket.state(), QLocalSocket::UnconnectedState, 3000); + QCOMPARE(opens.size(), 0); + QCOMPARE(pageOpens.size(), 0); + QVERIFY(primary.isPrimary()); + } + + void partialFrameTimesOut() + { + QTemporaryDir directory; + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + QSignalSpy opens(&primary, &SingleInstance::openRequested); + QLocalSocket socket; + socket.connectToServer(primary.endpoint()); + QTRY_COMPARE(socket.state(), QLocalSocket::ConnectedState); + const auto frame = framed({{"version", 1}, {"operation", "open"}, {"paths", QCborArray{}}}); + socket.write(frame.left(5)); + socket.flush(); + QTRY_COMPARE_WITH_TIMEOUT(socket.state(), QLocalSocket::UnconnectedState, 2500); + QCOMPARE(opens.size(), 0); + } + + void rejectsInvalidOutboundPaths() + { + QTemporaryDir directory; + for (const auto &path : {QString("relative.pdf"), QString("https://example.org/a"), QString(":/resource"), QString("//server/file")}) { + SingleInstance instance; + QCOMPARE(instance.start(directory.path(), {path}), SingleInstance::StartResult::ReadOnly); + QVERIFY(!instance.isPrimary()); + } + } + + void rejectsInvalidOutboundPages_data() + { + QTest::addColumn("page"); + QTest::addColumn("pathCount"); + QTest::newRow("negative") << -1 << 1; + QTest::newRow("too-large") << 100001 << 1; + QTest::newRow("no-path") << 42 << 0; + QTest::newRow("two-paths") << 42 << 2; + } + + void rejectsInvalidOutboundPages() + { + QFETCH(int, page); + QFETCH(int, pathCount); + QTemporaryDir directory; + QStringList paths; + for (int i = 0; i < pathCount; ++i) + paths << directory.filePath(QString::number(i) + ".pdf"); + SingleInstance second; + QCOMPARE(second.start(directory.path(), paths, 100, page), SingleInstance::StartResult::ReadOnly); + QVERIFY(!second.lastError().isEmpty()); + QVERIFY(!second.isPrimary()); + // Validation happens before creating a lock or listening endpoint. + SingleInstance primary; + QCOMPARE(primary.start(directory.path()), SingleInstance::StartResult::Primary); + } + + void safeRestartAndLongDirectory() + { + QTemporaryDir base; + const auto directory = base.filePath(QString(100, 'x') + "/" + QString(100, 'y')); + { + SingleInstance primary; + QCOMPARE(primary.start(directory), SingleInstance::StartResult::Primary); + QVERIFY(primary.isPrimary()); + } + SingleInstance replacement; + QCOMPARE(replacement.start(directory), SingleInstance::StartResult::Primary); + } + +#ifdef Q_OS_LINUX + void rejectsPublicDirectoryAndLinks() + { + QTemporaryDir base; + const auto publicDirectory = base.filePath("public"); + QVERIFY(QDir().mkpath(publicDirectory)); + QVERIFY(QFile::setPermissions(publicDirectory, QFileDevice::ReadOwner | QFileDevice::WriteOwner + | QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::ExeGroup)); + SingleInstance publicInstance; + QCOMPARE(publicInstance.start(publicDirectory), SingleInstance::StartResult::ReadOnly); + const auto link = base.filePath("link"); + QVERIFY(QFile::link(base.path(), link)); + SingleInstance linked; + QCOMPARE(linked.start(link), SingleInstance::StartResult::ReadOnly); + const auto lockTarget = base.filePath("target"); + QFile file(lockTarget); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("must survive"); file.close(); + QVERIFY(QFile::link(lockTarget, base.filePath("instance.lock"))); + SingleInstance locked; + QCOMPARE(locked.start(base.path()), SingleInstance::StartResult::ReadOnly); + QVERIFY(QFileInfo(base.filePath("instance.lock")).isSymLink()); + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(file.readAll(), QByteArray("must survive")); + } + + void neverDeletesUnrelatedEndpoint() + { + QTemporaryDir directory; + const auto endpoint = directory.filePath("i"); + QFile file(endpoint); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("unrelated"); file.close(); + { + SingleInstance instance; + QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::ReadOnly); + } + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(file.readAll(), QByteArray("unrelated")); + file.close(); + QVERIFY(file.remove()); + { + SingleInstance instance; + QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::Primary); + QVERIFY(QFile::remove(endpoint)); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("replacement"); file.close(); + } + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(file.readAll(), QByteArray("replacement")); + } + + void recoversOwnedStaleSocket() + { + QTemporaryDir directory; + const int descriptor = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + QVERIFY(descriptor >= 0); + sockaddr_un address{}; + address.sun_family = AF_UNIX; + const auto path = QFile::encodeName(directory.filePath("i")); + QVERIFY(path.size() < static_cast(sizeof(address.sun_path))); + std::copy(path.cbegin(), path.cend(), address.sun_path); + QCOMPARE(::bind(descriptor, reinterpret_cast(&address), sizeof(address)), 0); + ::close(descriptor); + SingleInstance instance; + QCOMPARE(instance.start(directory.path()), SingleInstance::StartResult::Primary); + struct stat details{}; + QCOMPARE(::lstat(path.constData(), &details), 0); + QVERIFY(S_ISSOCK(details.st_mode)); + QCOMPARE(details.st_mode & 0077, 0u); + } + + void cleanupUsesPinnedDirectoryInode() + { + QTemporaryDir base; + const auto original = base.filePath("state"); + const auto renamed = base.filePath("renamed"); + SingleInstance instance; + QCOMPARE(instance.start(original), SingleInstance::StartResult::Primary); + QVERIFY(QDir().rename(original, renamed)); + QVERIFY(QDir().mkpath(original)); + QFile replacement(original + "/i"); + QVERIFY(replacement.open(QIODevice::WriteOnly)); + replacement.write("different directory"); replacement.close(); + instance.stop(); + QVERIFY(!QFileInfo::exists(renamed + "/i")); + QVERIFY(replacement.open(QIODevice::ReadOnly)); + QCOMPARE(replacement.readAll(), QByteArray("different directory")); + } +#endif +}; + +int main(int argc, char **argv) +{ + QCoreApplication application(argc, argv); + const auto arguments = application.arguments(); + if (arguments.value(1) == "--instance-forward") { + SingleInstance instance; + const auto result = instance.start(arguments.value(2), arguments.mid(3), 1500); + return result == SingleInstance::StartResult::Forwarded ? 0 : 3; + } + if (arguments.value(1) == "--instance-forward-page") { + bool ok = false; + const int page = arguments.value(3).toInt(&ok); + if (!ok) + return 3; + SingleInstance instance; + const auto result = instance.start(arguments.value(2), arguments.mid(4), 1500, page); + return result == SingleInstance::StartResult::Forwarded ? 0 : 3; + } + InstanceTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_instance.moc" diff --git a/tests/test_linux_package.py b/tests/test_linux_package.py new file mode 100644 index 0000000..486c05d --- /dev/null +++ b/tests/test_linux_package.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Portable packaging-boundary tests; no Qt, GUI or production sandbox bypass.""" +import io +import json +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from collect_linux_dependencies import (ldd_paths, license_candidates, license_supplements, + inventory, PackageDatabase, pacman_fields, + pdfium_supplemental_notices, qt_embedded_notices, + qt_runtime_files, sha256) +from package_linux_development import (MANIFEST, PACKAGE_NAME, copy_install, describe_payload, + payload_files, verify_and_extract, write_archive) + + +class LinuxPackageTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='docview-package-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.payload = self.root / 'payload' + (self.payload / 'bin').mkdir(parents=True) + (self.payload / 'bin/docview').write_bytes(b'fixture executable\n') + (self.payload / 'bin/docview').chmod(0o755) + (self.payload / 'share/doc/docview').mkdir(parents=True) + (self.payload / 'share/doc/docview/NOTICE.txt').write_text('Synthetic test material.\n') + self.manifest() + + def manifest(self): + (self.payload / MANIFEST).write_text(json.dumps(describe_payload(self.payload, 0), sort_keys=True)) + + def archive(self, name='valid.tar.gz'): + path = self.root / name + write_archive(self.payload, path, 0) + return path + + def test_failed_smoke_removes_previous_success_aggregates(self): + archive = self.archive() + invalid_archive = self.root / 'invalid.tar.gz' + invalid_archive.write_bytes(b'not a tar archive') + cases = [ + ('direct-missing', 'smoke.py', '--binary', self.root / 'missing', FileNotFoundError), + ('direct-launch', 'smoke.py', '--binary', self.payload / 'bin/docview', subprocess.CalledProcessError), + ('package-missing', 'smoke_linux_package.py', '--archive', self.root / 'missing', FileNotFoundError), + ('package-invalid', 'smoke_linux_package.py', '--archive', invalid_archive, tarfile.ReadError), + ('package-launch', 'smoke_linux_package.py', '--archive', archive, subprocess.CalledProcessError), + ('package-sandbox', 'smoke_linux_package.py', '--archive', archive, SystemExit), + ] + for name, script, option, source, exception in cases: + with self.subTest(name=name): + output = self.root / name + output.mkdir() + for aggregate in ('results.json', 'package-smoke.json'): + (output / aggregate).write_text('{"success":true}') + screenshot = output / 'previous-image.png' + screenshot.write_bytes(b'previous image is not success evidence') + arguments = [str(ROOT / 'tests' / script), option, str(source), '--output', str(output)] + environment = {'QTWEBENGINE_DISABLE_SANDBOX': '1' if name == 'package-sandbox' else '0', + 'QTWEBENGINE_CHROMIUM_FLAGS': ''} + with patch.object(sys, 'argv', arguments), patch.dict(os.environ, environment), \ + patch('subprocess.run', side_effect=subprocess.CalledProcessError(7, 'synthetic-failed-launch')) as launch: + with self.assertRaises(exception): + runpy.run_path(arguments[0], run_name='__main__') + self.assertEqual(launch.call_count, 1 if name.endswith('-launch') else 0) + self.assertFalse((output / 'results.json').exists()) + self.assertFalse((output / 'package-smoke.json').exists()) + self.assertEqual(screenshot.read_bytes(), b'previous image is not success evidence') + + def test_reproducible_bytes_ignore_mtime_and_creation_order(self): + first = self.archive() + for path in payload_files(self.payload): + os.utime(path, (123456, 654321)) + second = self.archive('second.tar.gz') + self.assertEqual(sha256(first), sha256(second)) + relocated = verify_and_extract(first, self.root / 'relocated') + third = self.root / 'third.tar.gz' + write_archive(relocated, third, 0) + self.assertEqual(sha256(first), sha256(third)) + self.assertEqual((relocated / 'bin/docview').stat().st_mode & 0o777, 0o755) + + def test_modified_payload_rejected_against_manifest(self): + (self.payload / 'bin/docview').write_bytes(b'modified executable\n') + with self.assertRaisesRegex(ValueError, 'integrity'): + verify_and_extract(self.archive(), self.root / 'extract') + + def test_unlisted_file_rejected(self): + (self.payload / 'unexpected').write_text('not listed') + with self.assertRaisesRegex(ValueError, 'entries differ'): + verify_and_extract(self.archive(), self.root / 'extract') + + def test_duplicate_manifest_entry_rejected(self): + path = self.payload / MANIFEST + value = json.loads(path.read_text()) + value['files'].append(value['files'][0]) + path.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'manifest file entry'): + verify_and_extract(self.archive(), self.root / 'extract') + + def test_archive_path_link_and_duplicate_rejected(self): + for kind in ('traversal', 'symlink', 'duplicate'): + with self.subTest(kind=kind): + archive = self.root / (kind + '.tar.gz') + with tarfile.open(archive, 'w:gz') as tar: + info = tarfile.TarInfo(PACKAGE_NAME + ('/../escape' if kind == 'traversal' else '/entry')) + info.mode = 0o644 + if kind == 'symlink': + info.type = tarfile.SYMTYPE + info.linkname = '/tmp' + tar.addfile(info, io.BytesIO()) + if kind == 'duplicate': + tar.addfile(info, io.BytesIO()) + with self.assertRaisesRegex(ValueError, 'Unsafe or duplicate'): + verify_and_extract(archive, self.root / (kind + '-out')) + + def test_staging_rejects_symlink_and_hardlink(self): + target = self.payload / 'bin/docview' + link = self.payload / 'alias' + link.symlink_to(target) + with self.assertRaisesRegex(ValueError, 'link or special'): + payload_files(self.payload) + link.unlink() + os.link(target, link) + with self.assertRaisesRegex(ValueError, 'multiply-linked'): + payload_files(self.payload) + + def test_install_whitelist_and_missing_binary(self): + with self.assertRaisesRegex(ValueError, 'missing'): + copy_install(self.payload, self.root / 'copy') + for name in ('docview-pdf-worker', 'docview-archive-worker'): + (self.payload / 'bin' / name).write_bytes(b'test worker') + (self.payload / 'lib').mkdir() + (self.payload / 'lib/libpdfium.so').write_bytes(b'test library') + (self.payload / 'user-state.json').write_text('must not be packaged') + with self.assertRaisesRegex(ValueError, 'Unexpected file'): + copy_install(self.payload, self.root / 'copy') + + def test_ldd_missing_and_unknown_output_fail_closed(self): + output = 'linux-vdso.so.1 (0x123)\nlibx.so => /usr/lib/libx.so (0x456)\n/lib64/ld.so (0x789)\n' + self.assertEqual(ldd_paths(output), {Path('/usr/lib/libx.so'), Path('/lib64/ld.so')}) + for invalid in ('libx.so => not found\n', 'unexpected output\n'): + with self.assertRaises(ValueError): + ldd_paths(invalid) + + def test_package_license_labels_preserved_and_shared_text_collected(self): + desc = pacman_fields('%NAME%\nexample\n\n%LICENSE%\nLGPL-2.1-only\nMIT OR BSD-3-Clause\n') + self.assertEqual(desc['LICENSE'], ['LGPL-2.1-only', 'MIT OR BSD-3-Clause']) + licenses = self.root / 'licenses' + (licenses / 'spdx').mkdir(parents=True) + (licenses / 'spdx/LGPL-2.1-only.txt').write_text('shared license fixture') + (licenses / 'example').mkdir() + (licenses / 'example/COPYING').write_text('specific copyright fixture') + self.assertEqual(len(license_candidates('example', desc, licenses)), 2) + + def qt_fixture(self): + query = {'QT_VERSION': '6.11.2'} + for name in ('LIBEXECS', 'QML', 'PLUGINS', 'DATA', 'TRANSLATIONS'): + path = self.root / 'qt' / name.lower() + path.mkdir(parents=True, exist_ok=True) + query['QT_INSTALL_' + name] = str(path) + (Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess').write_bytes(b'fixture helper') + for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm'): + (Path(query['QT_INSTALL_TRANSLATIONS']) / name).write_bytes(b'fixture ' + name.encode()) + return query + + def test_japanese_qt_catalogs_required_independently(self): + query = self.qt_fixture() + directory = Path(query['QT_INSTALL_TRANSLATIONS']) + required = {directory / name for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')} + self.assertTrue(required <= qt_runtime_files(query)) + # Unrelated languages or a merged deployment catalog must not hide a + # missing required catalog in this Arch system-runtime package. + (directory / 'qt_ja.qm').write_bytes(b'fixture merged catalog') + (directory / 'qtbase_en.qm').write_bytes(b'fixture other language') + for path in sorted(required): + with self.subTest(catalog=path.name): + data = path.read_bytes() + path.unlink() + with self.assertRaisesRegex(ValueError, 'Required Japanese Qt translation is missing: ' + path.name): + qt_runtime_files(query) + path.write_bytes(data) + + def test_translation_owner_license_and_source_are_in_inventory(self): + query = self.qt_fixture() + database_root = self.root / 'pacman' + database_root.mkdir() + catalogs = [Path(query['QT_INSTALL_TRANSLATIONS']) / name + for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')] + helper = Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess' + for name in ('qt6-base', 'qt6-declarative', 'qt6-webengine', 'qt6-translations', 'tomlplusplus', 'glibc'): + directory = database_root / name + directory.mkdir() + owned = catalogs if name == 'qt6-translations' else [helper] if name == 'qt6-webengine' else [] + directory.joinpath('desc').write_text( + f'%NAME%\n{name}\n\n%BASE%\n{name}\n\n%VERSION%\n6.11.2-1\n\n' + '%ARCH%\nany\n\n%URL%\nhttps://example.invalid/qt\n\n%LICENSE%\nLGPL-3.0-only\n') + directory.joinpath('files').write_text('%FILES%\n' + ''.join(str(path)[1:] + '\n' for path in owned)) + database = PackageDatabase(database_root) + self.assertEqual([database.owner(path) for path in catalogs], ['qt6-translations'] * 2) + project = self.root / 'project' + for directory in (project / '.deps/pdfium/lib', project / 'cmake', self.payload / 'lib', + self.payload / 'share/doc/docview/pdfium'): + directory.mkdir(parents=True, exist_ok=True) + for name in ('docview-pdf-worker', 'docview-archive-worker'): + (self.payload / 'bin' / name).write_bytes(b'fixture worker') + for path in (project / '.deps/pdfium/lib/libpdfium.so', self.payload / 'lib/libpdfium.so'): + path.write_bytes(b'fixture PDFium') + (self.payload / 'share/doc/docview/pdfium/LICENSE').write_text('fixture PDFium notice') + lock = {name: 'fixture' for name in ('version', 'upstream', 'upstreamCommit', 'binaryProvider', + 'linuxX64Archive', 'linuxX64Sha256', 'buildOptions')} + (project / 'cmake/pdfium.lock.json').write_text(json.dumps(lock)) + notice = self.root / 'LGPL-3.0-only.txt' + notice.write_text('synthetic translation license notice') + notice_source = {'url': 'https://example.invalid/qttranslations/COPYING', + 'version': '6.11.2', 'downloadSha256': sha256(notice)} + def supplements(name, version): + return [(notice, notice_source)] if name == 'qt6-translations' else [] + def command(args): + return '\n'.join(key + ':' + value for key, value in query.items()) if args[-1] == '--query' else '' + output = self.root / 'inventory' + embedded_metadata = ROOT / 'resources/licenses/qt-embedded-notices/sources.json' + with patch('collect_linux_dependencies.ROOT', project), \ + patch('collect_linux_dependencies.PackageDatabase', return_value=database), \ + patch('collect_linux_dependencies.run', side_effect=command), \ + patch('collect_linux_dependencies.license_candidates', return_value=[]), \ + patch('collect_linux_dependencies.license_supplements', side_effect=supplements), \ + patch('collect_linux_dependencies.qt_embedded_notices', + return_value=(embedded_metadata, json.loads(embedded_metadata.read_text()))), \ + patch('collect_linux_dependencies.pdfium_supplemental_notices', return_value={}), \ + patch('collect_linux_dependencies.sys.platform', 'linux'), \ + patch('collect_linux_dependencies.platform.machine', return_value='x86_64'), \ + patch('collect_linux_dependencies.platform.freedesktop_os_release', return_value={'ID': 'arch'}): + result = inventory(self.payload, output) + component = result['components']['qt6-translations'] + self.assertEqual(component['distribution'], 'system-not-bundled') + self.assertEqual(component['version'], '6.11.2-1') + self.assertEqual(component['architecture'], 'any') + self.assertEqual(component['licenseLabelsFromPackage'], ['LGPL-3.0-only']) + self.assertEqual(component['source']['packageBase'], 'qt6-translations') + self.assertEqual(component['source']['recipeRepository'], + 'https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-translations') + self.assertEqual(component['source']['status'], 'not-collected') + self.assertEqual(component['noticeStatus'], 'collected-package-or-upstream-texts-not-a-compliance-verdict') + self.assertEqual(len(component['licenseTexts']), 1) + collected = component['licenseTexts'][0] + self.assertEqual((output / collected['path']).read_bytes(), notice.read_bytes()) + self.assertEqual(collected['sha256'], sha256(notice)) + rows = [row for row in result['systemFiles'] if row['package'] == 'qt6-translations'] + self.assertEqual({row['path'] for row in rows}, {str(path) for path in catalogs}) + for row in rows: + self.assertEqual(row['sha256'], sha256(Path(row['path']))) + self.assertEqual(row['size'], Path(row['path']).stat().st_size) + self.assertIn('qt6-translations 6.11.2-1 [system-not-bundled]', (output / 'NOTICE.txt').read_text()) + self.assertFalse(any(path.suffix == '.qm' for path in output.rglob('*'))) + embedded = result['components']['qt6-webengine']['embeddedResourceNotices'] + self.assertEqual(embedded['noticeCount'], 7) + self.assertEqual(embedded['sourceResourceCount'], 13) + self.assertIs(embedded['completeChromiumNotices'], False) + self.assertEqual(embedded['runtimeDistribution'], 'system-not-bundled') + self.assertEqual(sha256(output / embedded['metadataPath']), sha256(embedded_metadata)) + for row in result['components']['qt6-webengine']['licenseTexts']: + self.assertEqual(sha256(output / row['path']), row['sha256']) + + def qt_notice_fixture(self): + destination = self.root / 'qt-notices' + shutil.copytree(ROOT / 'resources/licenses/qt-embedded-notices', destination) + metadata = json.loads((destination / 'sources.json').read_text()) + return destination, metadata, [dict(row) for row in metadata['dataPacks']] + + def test_qt_embedded_notices_bind_exact_target_and_seven_texts(self): + base, metadata, inventory_rows = self.qt_notice_fixture() + source, result = qt_embedded_notices('6.11.2', '6.11.2-1', inventory_rows, base) + self.assertEqual(source, base / 'sources.json') + self.assertEqual(result, metadata) + self.assertEqual(len(result['files']), 7) + self.assertEqual(sum(len(row['sourceResources']) for row in result['files']), 13) + self.assertIs(result['completeChromiumNotices'], False) + + def test_qt_embedded_notices_reject_different_versions(self): + base, _, inventory_rows = self.qt_notice_fixture() + for qt, package in [('6.11.3', '6.11.2-1'), ('6.11.2', '6.11.2-2')]: + with self.subTest(qt=qt, package=package), self.assertRaisesRegex(ValueError, 'target version'): + qt_embedded_notices(qt, package, inventory_rows, base) + + def test_qt_embedded_notices_reject_missing_duplicate_or_other_datapacks(self): + base, _, rows = self.qt_notice_fixture() + cases = [rows[1:], rows + [rows[0]]] + for key, wrong in [('path', '/usr/share/qt6/resources/other.pak'), ('package', 'other-package'), + ('sha256', '0' * 64), ('size', rows[0]['size'] + 1)]: + cases.append([{**rows[0], key: wrong}, rows[1]]) + for changed in cases: + with self.subTest(rows=changed), self.assertRaisesRegex(ValueError, 'DataPack inventory mismatch'): + qt_embedded_notices('6.11.2', '6.11.2-1', changed, base) + + def test_qt_embedded_notices_reject_missing_or_modified_text(self): + base, metadata, rows = self.qt_notice_fixture() + path = base / metadata['files'][0]['name'] + original = path.read_bytes() + path.unlink() + with self.assertRaisesRegex(ValueError, 'text missing'): + qt_embedded_notices('6.11.2', '6.11.2-1', rows, base) + for data in (b'X' + original[1:], original + b'\n'): + path.write_bytes(data) + with self.assertRaisesRegex(ValueError, 'digest/size mismatch'): + qt_embedded_notices('6.11.2', '6.11.2-1', rows, base) + + def test_qt_embedded_notices_reject_unreviewed_metadata_changes(self): + base, metadata, rows = self.qt_notice_fixture() + path = base / 'sources.json' + for changed in ({**metadata, 'completeChromiumNotices': True}, + {**metadata, 'files': metadata['files'][:-1]}, + {**metadata, 'files': metadata['files'] + [metadata['files'][0]]}, + {**metadata, 'files': [{**metadata['files'][0], 'name': '../outside'}] + metadata['files'][1:]}, + {**metadata, 'dataPacks': [{**rows[0], 'sha256': '0' * 64}, rows[1]]}): + path.write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, 'metadata changed'): + qt_embedded_notices('6.11.2', '6.11.2-1', rows, base) + path.unlink() + with self.assertRaisesRegex(ValueError, 'metadata missing'): + qt_embedded_notices('6.11.2', '6.11.2-1', rows, base) + path.write_bytes(b'x' * 65537) + with self.assertRaisesRegex(ValueError, 'oversized'): + qt_embedded_notices('6.11.2', '6.11.2-1', rows, base) + + def test_upstream_license_supplement_requires_version_and_hash(self): + root = self.root / 'supplement' + (root / 'example').mkdir(parents=True) + path = root / 'example/COPYING' + path.write_text('upstream text fixture') + (root / 'sources.json').write_text(json.dumps([{'package': 'example', 'version': '1.2.3', + 'files': {'COPYING': sha256(path)}}])) + self.assertEqual(len(license_supplements('example', '2:1.2.3-4', root)), 1) + with self.assertRaisesRegex(ValueError, 'version needs review'): + license_supplements('example', '1.2.4-1', root) + path.write_text('altered') + with self.assertRaisesRegex(ValueError, 'digest mismatch'): + license_supplements('example', '1.2.3-4', root) + + def test_pdfium_notices_are_bound_to_binary_source_and_exact_files(self): + folder = self.payload / 'share/doc/docview/pdfium/supplemental' + folder.mkdir(parents=True) + (self.payload / 'lib').mkdir() + binary = self.payload / 'lib/libpdfium.so' + binary.write_bytes(b'synthetic PDFium') + lock = {'version': '1.2.3.4', 'upstreamCommit': 'a' * 40} + rows = [] + for name in ['libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt']: + path = folder / name + path.write_bytes(b'synthetic notice\n') + rows.append({'name': name, 'sha256': sha256(path), 'size': path.stat().st_size}) + metadata = {'schemaVersion': 1, 'pdfiumVersion': lock['version'], + 'pdfiumUpstreamCommit': lock['upstreamCommit'], 'pdfiumLibrarySha256': sha256(binary), + 'files': rows, 'scope': 'fixture', 'providerRecipeCommit': 'b' * 40} + manifest = folder / 'sources.json' + manifest.write_text(json.dumps(metadata)) + self.assertEqual(len(pdfium_supplemental_notices(self.payload, lock)['files']), 2) + for key, wrong in [('version', '9.9.9.9'), ('upstreamCommit', 'c' * 40)]: + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'needs review'): + pdfium_supplemental_notices(self.payload, {**lock, key: wrong}) + binary.write_bytes(b'different PDFium') + with self.assertRaisesRegex(ValueError, 'needs review'): + pdfium_supplemental_notices(self.payload, lock) + binary.write_bytes(b'synthetic PDFium') + (folder / rows[0]['name']).write_bytes(b'changed notice') + with self.assertRaisesRegex(ValueError, 'digest or size mismatch'): + pdfium_supplemental_notices(self.payload, lock) + for replacement in [[rows[0]], [rows[0], rows[0]], [{**rows[0], 'name': '../outside'}, rows[1]]]: + manifest.write_text(json.dumps({**metadata, 'files': replacement})) + with self.subTest(files=replacement), self.assertRaisesRegex(ValueError, 'notice set'): + pdfium_supplemental_notices(self.payload, lock) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_memory_pressure.cpp b/tests/test_memory_pressure.cpp new file mode 100644 index 0000000..020c653 --- /dev/null +++ b/tests/test_memory_pressure.cpp @@ -0,0 +1,200 @@ +#include "common/memory_pressure.h" + +#include +#include +#include + +using namespace docview; +namespace { +constexpr quint64 MiB = 1024ull * 1024; +constexpr quint64 GiB = 1024 * MiB; +constexpr MemorySample Low{8 * GiB, 512 * MiB, true}; +constexpr MemorySample Critical{8 * GiB, 128 * MiB, true}; +constexpr MemorySample Healthy{8 * GiB, 2 * GiB, true}; +} // namespace + +class MemoryPressureTests : public QObject { + Q_OBJECT + private slots: + void parsesAvailableRatherThanFree(); + void rejectsMalformedSamples_data(); + void rejectsMalformedSamples(); + void sustainedPressureAdvancesInOrder(); + void criticalPressureStillAdvancesOneStage(); + void thresholdBoundaries(); + void recoveryRequiresHealthyConsecutiveSamples(); + void invalidSamplePreservesStateAndBreaksStreaks(); + void monitorInjectionAndOneSecondSource(); + void nativeSampleIsConsistent(); +}; + +void MemoryPressureTests::parsesAvailableRatherThanFree() { + const auto sample = parseLinuxMemoryInfo("MemFree: 1 kB\nMemAvailable:\t2048 kB\n" + "Other: ignored\nMemTotal: 8192 kB\n"); + QVERIFY(sample.valid); + QCOMPARE(sample.totalBytes, 8 * MiB); + QCOMPARE(sample.availableBytes, 2 * MiB); + const auto empty = parseLinuxMemoryInfo("MemAvailable: 0 kB\nMemTotal: 1 kB"); + QVERIFY(empty.valid); + QCOMPARE(empty.availableBytes, 0u); + const auto largest = std::numeric_limits::max() / 1024; + const auto big = + parseLinuxMemoryInfo("MemTotal: " + QByteArray::number(largest) + " kB\nMemAvailable: 0 kB\n"); + QVERIFY(big.valid); + QCOMPARE(big.totalBytes, largest * 1024); +} + +void MemoryPressureTests::rejectsMalformedSamples_data() { + QTest::addColumn("input"); + QTest::newRow("empty") << QByteArray{}; + QTest::newRow("no-total") << QByteArray("MemAvailable: 1 kB\n"); + QTest::newRow("free-is-not-available") << QByteArray("MemTotal: 10 kB\nMemFree: 9 kB\n"); + QTest::newRow("zero-total") << QByteArray("MemTotal: 0 kB\nMemAvailable: 0 kB\n"); + QTest::newRow("available-exceeds-total") << QByteArray("MemTotal: 1 kB\nMemAvailable: 2 kB\n"); + QTest::newRow("duplicate-total") << QByteArray("MemTotal: 2 kB\nMemTotal: 2 kB\nMemAvailable: 1 kB\n"); + QTest::newRow("duplicate-available") + << QByteArray("MemTotal: 2 kB\nMemAvailable: 1 kB\nMemAvailable: 1 kB\n"); + QTest::newRow("byte-unit") << QByteArray("MemTotal: 2 B\nMemAvailable: 1 kB\n"); + QTest::newRow("missing-unit") << QByteArray("MemTotal: 2\nMemAvailable: 1 kB\n"); + QTest::newRow("negative") << QByteArray("MemTotal: 2 kB\nMemAvailable: -1 kB\n"); + QTest::newRow("plus-sign") << QByteArray("MemTotal: +2 kB\nMemAvailable: 1 kB\n"); + QTest::newRow("decimal") << QByteArray("MemTotal: 2.0 kB\nMemAvailable: 1 kB\n"); + QTest::newRow("extra-field") << QByteArray("MemTotal: 2 kB extra\nMemAvailable: 1 kB\n"); + QTest::newRow("integer-overflow") + << QByteArray("MemTotal: 18446744073709551616 kB\nMemAvailable: 1 kB\n"); + QTest::newRow("byte-overflow") << ("MemTotal: " + + QByteArray::number(std::numeric_limits::max() / 1024 + 1) + + " kB\nMemAvailable: 1 kB\n"); + QTest::newRow("nul") << (QByteArray("MemTotal: 2 kB\nMemAvailable: 1 kB\n") + '\0'); + QTest::newRow("oversized") << QByteArray(64 * 1024 + 1, 'x'); +} +void MemoryPressureTests::rejectsMalformedSamples() { + QFETCH(QByteArray, input); + QVERIFY(!parseLinuxMemoryInfo(input).valid); +} + +void MemoryPressureTests::sustainedPressureAdvancesInOrder() { + MemoryPressurePolicy policy; + QCOMPARE(policy.observe(Low), MemoryPressureLevel::NoPrefetch); + for (const auto next : + {MemoryPressureLevel::Trim, MemoryPressureLevel::Reduced, MemoryPressureLevel::Stop}) { + const auto previous = policy.level(); + for (int n = 1; n < MemoryPressurePolicy::SustainedSamplesPerStage; ++n) + QCOMPARE(policy.observe(Low), previous); + QCOMPARE(policy.observe(Low), next); + } + for (int n = 0; n < 30; ++n) + QCOMPARE(policy.observe(Low), MemoryPressureLevel::Stop); +} +void MemoryPressureTests::criticalPressureStillAdvancesOneStage() { + MemoryPressurePolicy policy; + for (const auto expected : {MemoryPressureLevel::NoPrefetch, MemoryPressureLevel::Trim, + MemoryPressureLevel::Reduced, MemoryPressureLevel::Stop}) + QCOMPARE(policy.observe(Critical), expected); + QCOMPARE(policy.observe(Critical), MemoryPressureLevel::Stop); +} +void MemoryPressureTests::thresholdBoundaries() { + MemoryPressurePolicy absolute; + QCOMPARE(absolute.observe({64 * GiB, GiB, true}), MemoryPressureLevel::Normal); + QCOMPARE(absolute.observe({64 * GiB, GiB - 1, true}), MemoryPressureLevel::NoPrefetch); + QCOMPARE(absolute.observe({64 * GiB, 256 * MiB, true}), MemoryPressureLevel::NoPrefetch); + QCOMPARE(absolute.observe({64 * GiB, 256 * MiB - 1, true}), MemoryPressureLevel::Trim); + + const quint64 tenPercent = (4 * GiB + 9) / 10; + MemoryPressurePolicy ratio; + QCOMPARE(ratio.observe({4 * GiB, tenPercent, true}), MemoryPressureLevel::Normal); + QCOMPARE(ratio.observe({4 * GiB, tenPercent - 1, true}), MemoryPressureLevel::NoPrefetch); + const quint64 threePercent = (4 * GiB * 3 + 99) / 100; + QCOMPARE(ratio.observe({4 * GiB, threePercent, true}), MemoryPressureLevel::NoPrefetch); + QCOMPARE(ratio.observe({4 * GiB, threePercent - 1, true}), MemoryPressureLevel::Trim); + + MemoryPressurePolicy maximum; + const auto max = std::numeric_limits::max(); + QCOMPARE(maximum.observe({max, max, true}), MemoryPressureLevel::Normal); + QCOMPARE(maximum.observe({max, 0, true}), MemoryPressureLevel::NoPrefetch); + QCOMPARE(maximum.observe({max, 0, true}), MemoryPressureLevel::Trim); +} +void MemoryPressureTests::recoveryRequiresHealthyConsecutiveSamples() { + MemoryPressurePolicy policy; + for (int n = 0; n < 4; ++n) + policy.observe(Critical); + const MemorySample hysteresisBand{8 * GiB, GiB, true}; + for (int n = 0; n < 20; ++n) + QCOMPARE(policy.observe(hysteresisBand), MemoryPressureLevel::Stop); + for (int n = 1; n < MemoryPressurePolicy::RecoverySamples; ++n) + QCOMPARE(policy.observe(Healthy), MemoryPressureLevel::Stop); + QCOMPARE(policy.observe(hysteresisBand), MemoryPressureLevel::Stop); + for (int n = 1; n < MemoryPressurePolicy::RecoverySamples; ++n) + QCOMPARE(policy.observe(Healthy), MemoryPressureLevel::Stop); + QCOMPARE(policy.observe(Healthy), MemoryPressureLevel::Normal); + QCOMPARE(policy.observe(Low), MemoryPressureLevel::NoPrefetch); + + // Either margin can establish recovery on small and large memory systems. + MemoryPressurePolicy large; + large.observe({64 * GiB, 128 * MiB, true}); + for (int n = 0; n < MemoryPressurePolicy::RecoverySamples; ++n) + large.observe({64 * GiB, 1536 * MiB, true}); + QCOMPARE(large.level(), MemoryPressureLevel::Normal); + MemoryPressurePolicy small; + small.observe({4 * GiB, 128 * MiB, true}); + for (int n = 0; n < MemoryPressurePolicy::RecoverySamples; ++n) + small.observe({4 * GiB, (4 * GiB * 15 + 99) / 100, true}); + QCOMPARE(small.level(), MemoryPressureLevel::Normal); +} +void MemoryPressureTests::invalidSamplePreservesStateAndBreaksStreaks() { + MemoryPressurePolicy policy; + policy.observe(Low); + for (int n = 1; n < MemoryPressurePolicy::SustainedSamplesPerStage; ++n) + policy.observe(Low); + for (const MemorySample invalid : {MemorySample{}, MemorySample{0, 0, true}, MemorySample{1, 2, true}, + MemorySample{8 * GiB, 2 * GiB, false}}) + QCOMPARE(policy.observe(invalid), MemoryPressureLevel::NoPrefetch); + QCOMPARE(policy.observe(Low), MemoryPressureLevel::NoPrefetch); + for (int n = 1; n < MemoryPressurePolicy::RecoverySamples; ++n) + policy.observe(Healthy); + QCOMPARE(policy.observe({}), MemoryPressureLevel::NoPrefetch); + QCOMPARE(policy.observe(Healthy), MemoryPressureLevel::NoPrefetch); +} +void MemoryPressureTests::monitorInjectionAndOneSecondSource() { + int calls = 0; + MemoryPressureMonitor monitor(nullptr, [&] { + ++calls; + return Critical; + }); + QSignalSpy changed(&monitor, &MemoryPressureMonitor::levelChanged); + QVERIFY(!monitor.running()); + QCOMPARE(calls, 0); + monitor.start(); + QCOMPARE(calls, 1); + QCOMPARE(changed.size(), 1); + QCOMPARE(qvariant_cast(changed.last().first()), MemoryPressureLevel::NoPrefetch); + monitor.start(); + QCOMPARE(calls, 1); // Idempotent start must not accelerate escalation. + QTRY_VERIFY_WITH_TIMEOUT(calls >= 2, 1500); + monitor.stop(); + QVERIFY(!monitor.running()); + QCOMPARE(monitor.level(), MemoryPressureLevel::Trim); + monitor.observeSample(Critical); + monitor.observeSample(Critical); + QCOMPARE(monitor.level(), MemoryPressureLevel::Stop); + QCOMPARE(changed.size(), 4); + monitor.observeSample({}); + QCOMPARE(changed.size(), 4); + for (int n = 0; n < MemoryPressurePolicy::RecoverySamples; ++n) + monitor.observeSample(Healthy); + QCOMPARE(changed.size(), 5); + QCOMPARE(qvariant_cast(changed.last().first()), MemoryPressureLevel::Normal); +} +void MemoryPressureTests::nativeSampleIsConsistent() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + const auto sample = readSystemMemory(); + QVERIFY(sample.valid); + QVERIFY(sample.totalBytes > 0); + QVERIFY(sample.availableBytes <= sample.totalBytes); +#else + QVERIFY(!readSystemMemory().valid); +#endif +} + +QTEST_GUILESS_MAIN(MemoryPressureTests) +#include "test_memory_pressure.moc" diff --git a/tests/test_pdf.cpp b/tests/test_pdf.cpp new file mode 100644 index 0000000..255b92c --- /dev/null +++ b/tests/test_pdf.cpp @@ -0,0 +1,1069 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "pdf/pdf_document.h" +#include "pdf/link_border_reader.h" +#include "pdf/pdf_metadata_context.h" +#include "common/worker_process.h" +#include "broker/font_broker.h" +using namespace docview; +#ifndef Q_MOC_RUN +namespace { +QByteArray pdfString(const QString &text) { + QByteArray bytes(text.size() * 2, '\0'); + for (qsizetype i = 0; i < text.size(); ++i) qToBigEndian(text[i].unicode(), bytes.data() + i * 2); + return ""; +} +QString largeMetadataFixture(QTemporaryDir &directory, int pageCount, int outlineCount, + const QString &titlePrefix = {}, const QString &label = {}) { + QList objects{QByteArray{}, QByteArray{}}; + QByteArray kids; + for (int page = 0; page < pageCount; ++page) { + const int id = objects.size() + 1; + kids += QByteArray::number(id) + " 0 R "; + objects << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << >> >>"; + } + const int root = outlineCount ? objects.size() + 1 : 0; + if (outlineCount) { + objects << "<< /Type /Outlines /First " + QByteArray::number(root + 1) + " 0 R /Last " + + QByteArray::number(root + outlineCount) + " 0 R /Count " + QByteArray::number(outlineCount) + " >>"; + for (int index = 0; index < outlineCount; ++index) { + const int id = objects.size() + 1; + const auto title = titlePrefix.isEmpty() ? QString("Heading %1").arg(index) : titlePrefix + QString::number(index); + QByteArray item = "<< /Title " + pdfString(title) + " /Parent " + QByteArray::number(root) + + " 0 R /Dest [3 0 R /Fit]"; + if (index) item += " /Prev " + QByteArray::number(id - 1) + " 0 R"; + if (index + 1 < outlineCount) item += " /Next " + QByteArray::number(id + 1) + " 0 R"; + objects << item + " >>"; + } + } + objects[0] = "<< /Type /Catalog /Pages 2 0 R"; + if (root) objects[0] += " /Outlines " + QByteArray::number(root) + " 0 R"; + if (!label.isEmpty()) objects[0] += " /PageLabels << /Nums [0 << /P " + pdfString(label) + " >>] >>"; + objects[0] += " >>"; + objects[1] = "<< /Type /Pages /Count " + QByteArray::number(pageCount) + " /Kids [" + kids + "] >>"; + QByteArray result = "%PDF-1.7\n"; + QList offsets{0}; + for (qsizetype i = 0; i < objects.size(); ++i) { + offsets << result.size(); + result += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n"; + } + const auto xref = result.size(); + result += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n"; + for (qsizetype i = 1; i < offsets.size(); ++i) result += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n"; + result += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n" + + QByteArray::number(xref) + "\n%%EOF\n"; + const auto path = directory.filePath("metadata.pdf"); + QFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(result) != result.size()) return {}; + return path; +} +QString annotationMetadataFixture(QTemporaryDir &directory, int count, const QString &contents, bool links = false) { + QList objects{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", {}}; + QByteArray refs; + for (int i = 0; i < count; ++i) { + refs += QByteArray::number(objects.size() + 1) + " 0 R "; + objects << "<< /Type /Annot /Subtype /" + QByteArray(links ? "Link" : "Square") + + " /Rect [10 10 30 30] " + + (links ? QByteArray("/Dest [3 0 R /Fit] ") : "/Contents " + pdfString(contents)) + " >>"; + } + objects[2] = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Annots [" + refs + "] >>"; + QByteArray bytes = "%PDF-1.7\n"; + QList offsets{0}; + for (qsizetype i = 0; i < objects.size(); ++i) { + offsets << bytes.size(); + bytes += QByteArray::number(i + 1) + " 0 obj\n" + objects[i] + "\nendobj\n"; + } + const auto xref = bytes.size(); + bytes += "xref\n0 " + QByteArray::number(offsets.size()) + "\n0000000000 65535 f \n"; + for (qsizetype i = 1; i < offsets.size(); ++i) + bytes += QByteArray::number(offsets[i]).rightJustified(10, '0') + " 00000 n \n"; + bytes += "trailer\n<< /Size " + QByteArray::number(offsets.size()) + " /Root 1 0 R >>\nstartxref\n" + + QByteArray::number(xref) + "\n%%EOF\n"; + const auto path = directory.filePath("annotation-metadata.pdf"); + QFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) return {}; + return path; +} +} +#endif +class PdfTest : public QObject { + Q_OBJECT + QString fixture() { return QFINDTESTDATA("fixtures/pdf/navigation.pdf"); } + static QCborMap request(int rotation = 0, int x = 0, int y = 0, int w = 200, int h = 200) { + return {{QStringLiteral("page"), 0}, {QStringLiteral("scale"), 1.0}, {QStringLiteral("rotation"), rotation}, + {QStringLiteral("x"), x}, {QStringLiteral("y"), y}, {QStringLiteral("width"), w}, {QStringLiteral("height"), h}, {QStringLiteral("renderRevision"), 7}}; + } +private slots: + void generatedAppearancesDoNotAccumulateAcrossTiles() { + const auto path = QFINDTESTDATA("fixtures/pdf/link-borders/styles.pdf"); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); + const QByteArray bytes = source.readAll(); + FPDF_InitLibrary(); const auto libraryGuard = qScopeGuard([] { FPDF_DestroyLibrary(); }); + auto document = FPDF_LoadMemDocument64(bytes.constData(), size_t(bytes.size()), nullptr); QVERIFY(document); + const auto documentGuard = qScopeGuard([&] { FPDF_CloseDocument(document); }); + auto context = std::make_shared(); QString code, error; + QVERIFY2(context->open(&source, {}, 4, &code, &error), qPrintable(error)); + LinkBorderReader reader(context); + const auto prepare = [&](int index) { + auto page = FPDF_LoadPage(document, index); + if (!page) return false; + { auto scope = reader.prepare(index, page, &code, &error); if (!scope) { FPDF_ClosePage(page); return false; } } + FPDF_ClosePage(page); return true; + }; + // This test-only serializer detects retained orphan indirect AP streams + // in the PDFium document. It never writes or replaces the source file. + struct Writer : FPDF_FILEWRITE { QByteArray bytes; Writer() { version = 1; WriteBlock = [](FPDF_FILEWRITE *self, const void *data, unsigned long size) { + static_cast(self)->bytes.append(static_cast(data), qsizetype(size)); return 1; }; } }; + QVERIFY2(prepare(0), qPrintable(error)); + Writer first; QVERIFY(FPDF_SaveAsCopy(document, &first, FPDF_NO_INCREMENTAL)); + for (int index = 0; index < 200; ++index) QVERIFY2(prepare(index % 4), qPrintable(error)); + Writer repeated; QVERIFY(FPDF_SaveAsCopy(document, &repeated, FPDF_NO_INCREMENTAL)); + QCOMPARE(repeated.bytes.size(), first.bytes.size()); + QCOMPARE(repeated.bytes.count("/Subtype/Form"), first.bytes.count("/Subtype/Form")); + QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes); + } + void linkBorderStylesAreRenderedAndSourceIsUnchanged() { + const auto path = QFINDTESTDATA("fixtures/pdf/link-borders/styles.pdf"); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); + const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256); + PdfDocument doc; QString code, error; QVERIFY2(doc.openFile(&source, {}, &code, &error), qPrintable(error)); + const auto originalLinks = doc.links(0).value("links").toArray(); QCOMPARE(originalLinks.size(), 18); // Hidden/NoView are not interactive. + for (int page = 0; page < 4; ++page) { + const auto metadata = doc.pages(page, 1).value("pages").toArray().first().toMap(); + for (int rotation : {0, 90, 180, 270}) { + int width = int(metadata.value("width").toDouble()), height = int(metadata.value("height").toDouble()); + if (rotation % 180) std::swap(width, height); + auto render = request(rotation, 0, 0, width, height); render.insert(QStringLiteral("page"), page); + const auto image = doc.render(render, &code, &error).value("data").toByteArray(); + QVERIFY2(code.isEmpty(), qPrintable(code + ": " + error)); QCOMPARE(image.size(), width * height * 4); + const auto pixel = [&](double x, double y, int component) { + auto point = request(rotation); point.insert(QStringLiteral("page"), page); + point.insert(QStringLiteral("x"), x); point.insert(QStringLiteral("y"), y); + const auto device = doc.mapPoint(point, &code, &error); + const int px = std::clamp(int(device.value("x").toDouble()), 0, width - 1); + const int py = std::clamp(int(device.value("y").toDouble()), 0, height - 1); + return int(quint8(image[(py * width + px) * 4 + component])); + }; + for (int index : {7, 8, 9, 10, 18}) { // Zero, transparent, Hidden, NoView, saved empty AP. + const int x = 40 + (index % 4) * 120, y = 50 + (index / 4) * 75; + for (int c = 0; c < 3; ++c) QCOMPARE(pixel(x + 50, y, c), 255); + } + QVERIFY(pixel(90, 50, 0) < 150); // Absent /Border means one point black. + QVERIFY(pixel(210, 50, 2) > 150); QVERIFY(pixel(210, 50, 0) < 100); // RGB. + QVERIFY(pixel(210, 125, 0) < 230); // Underline bottom. + QCOMPARE(pixel(210, 155, 0), 255); // Underline has no top edge. + QVERIFY(pixel(210, 350 + 15, 1) > 180); // Supplied green AP. + QVERIFY(pixel(210, 350 + 15, 2) < 10); + if (page == 0 && rotation == 0) { + QVERIFY(pixel(90, 275, 0) >= 120 && pixel(90, 275, 0) <= 135); // Gray. + QVERIFY(pixel(210, 275, 0) > 200 && pixel(210, 275, 2) < 40); // Device CMYK cyan. + QCOMPARE(pixel(280, 275, 0), 255); // Rounded corner is open. + QVERIFY(pixel(330, 275, 0) < 100); // Rounded bottom edge remains colored. + QVERIFY(pixel(450, 350, 0) > 100 && pixel(450, 350, 2) > 100); // Alpha over white. + // Bevel/inset expose opposite light and dark facets. + QVERIFY(pixel(450, 305, 0) > pixel(450, 275, 0)); + QVERIFY(pixel(90, 380, 0) < pixel(90, 350, 0)); + int ink = 0, gaps = 0; + for (int x = 290; x < 370; ++x) { if (pixel(x, 50, 2) < 150) ++ink; else ++gaps; } + QVERIFY(ink > 20); QVERIFY(gaps > 10); // /Border dash is not flattened. + } + // Same annotation's original rectangle survives rendering and reopening pages. + QCOMPARE(doc.links(page).value("links").toArray(), originalLinks); + // Independently rendered tiles must reconstruct the same pixels, + // including the centered half-width outside the annotation rect. + for (int y = 0; y < height; y += 127) for (int x = 0; x < width; x += 173) { + auto tileRequest = request(rotation, x, y, std::min(173, width - x), std::min(127, height - y)); + tileRequest.insert(QStringLiteral("page"), page); + const auto tile = doc.render(tileRequest, &code, &error).value("data").toByteArray(); + QVERIFY2(code.isEmpty(), qPrintable(error)); + const int w = int(tileRequest.value("width").toInteger()), h = int(tileRequest.value("height").toInteger()); + for (int row = 0; row < h; ++row) QCOMPARE(tile.mid(row * w * 4, w * 4), image.mid(((y + row) * width + x) * 4, w * 4)); + } + } + } + QVERIFY(source.seek(0)); QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before); + doc.close(); + QVERIFY(source.isOpen()); // Borrowed inherited input retains caller ownership. + } + void annotationFlagsPreserveGeometryAndAppearance() { + const auto path = QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf"); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); + const auto bytes = source.readAll(); + PdfDocument doc; QString code, error; QVERIFY2(doc.openFile(&source, {}, &code, &error), qPrintable(error)); + QCOMPARE(doc.pageCount(), 38); + const QString evidence = qEnvironmentVariable("DOCVIEW_ANNOTATION_FLAGS_EVIDENCE"); + if (!evidence.isEmpty()) QVERIFY(QDir().mkpath(evidence)); + for (int page = 0; page < 36; ++page) { + const int kind = page / 12, flags = (page % 12) / 4 == 0 ? 8 : (page % 12) / 4 == 1 ? 16 : 24; + const int intrinsic = page % 4; + const auto originalMetadata = doc.links(page); + const auto rows = originalMetadata.value(kind == 2 ? QStringLiteral("annotations") : QStringLiteral("links")).toArray(); + QCOMPARE(rows.size(), 1); QCOMPARE(rows.first().toMap().value("flags").toInteger(), flags); + const auto originalPages = doc.pages(page, 1); + for (int rotation : {0, 90, 180, 270}) for (double zoom : {.5, 1., 2.}) for (double dpr : {1., 2.}) { + const double scale = zoom * dpr; + const int total = (intrinsic + rotation / 90) % 4; + const int width = int((total % 2 ? 104 : 112) * scale), height = int((total % 2 ? 112 : 104) * scale); + auto render = request(rotation, 0, 0, width, height); + render.insert(QStringLiteral("page"), page); render.insert(QStringLiteral("scale"), scale); + render.insert(QStringLiteral("devicePixelRatio"), dpr); + const auto image = doc.render(render, &code, &error).value("data").toByteArray(); + const QByteArray context = QString("page=%1 flags=%2 total=%3 zoom=%4 dpr=%5: %6").arg(page).arg(flags).arg(total).arg(zoom).arg(dpr).arg(error).toUtf8(); + QVERIFY2(code.isEmpty(), context.constData()); QCOMPARE(image.size(), width * height * 4); + const auto map = [&](double x, double y) { + if (total == 0) return QPointF((x - 8) * scale, (116 - y) * scale); + if (total == 1) return QPointF((y - 12) * scale, (x - 8) * scale); + if (total == 2) return QPointF((120 - x) * scale, (y - 12) * scale); + return QPointF((116 - y) * scale, (120 - x) * scale); + }; + const QPointF anchor = map(42, 64); + const auto annotationPoint = [&](double x, double y) { + if (flags & 16) return anchor + QPointF(x - 42, 64 - y) * (flags & 8 ? dpr : scale); + return anchor + (map(x, y) - anchor) * (flags & 8 ? 1 / zoom : 1); + }; + const double margin = kind == 0 ? 1 : 0; + const QPointF p = annotationPoint(42 - margin, 48 - margin), q = annotationPoint(74 + margin, 64 + margin); + const QRectF expected = QRectF(p, q).normalized().intersected(QRectF(0, 0, width, height)); + int left = width, top = height, right = -1, bottom = -1; + for (int y = 0; y < height; ++y) for (int x = 0; x < width; ++x) { + const auto *pixel = reinterpret_cast(image.constData() + (y * width + x) * 4); + if (pixel[1] < 80 && ((pixel[2] > 180 && pixel[0] < 80) || (pixel[0] > 180 && pixel[2] < 80))) { + left = std::min(left, x); top = std::min(top, y); right = std::max(right, x); bottom = std::max(bottom, y); + } + } + QVERIFY2(right >= left && bottom >= top, context.constData()); + QVERIFY2(std::abs(left - expected.left()) <= 1.1 && std::abs(top - expected.top()) <= 1.1 && + std::abs(right + 1 - expected.right()) <= 1.1 && std::abs(bottom + 1 - expected.bottom()) <= 1.1, + (context + " actual=" + QByteArray::number(left) + ',' + QByteArray::number(top) + ',' + QByteArray::number(right + 1) + ',' + QByteArray::number(bottom + 1) + + " expected=" + QByteArray::number(expected.left()) + ',' + QByteArray::number(expected.top()) + ',' + QByteArray::number(expected.right()) + ',' + QByteArray::number(expected.bottom())).constData()); + if (kind != 0) for (int half : {0, 1}) { + const auto point = annotationPoint(50 + 16 * half, 56); + const int x = int(point.x()), y = int(point.y()); + if (x >= 0 && x < width && y >= 0 && y < height) { + const auto *pixel = reinterpret_cast(image.constData() + (y * width + x) * 4); + QVERIFY2(pixel[half ? 0 : 2] > 220 && pixel[half ? 2 : 0] < 30 && pixel[1] < 30, context.constData()); + } + } + QCOMPARE(doc.links(page), originalMetadata); QCOMPARE(doc.pages(page, 1), originalPages); + if (zoom == 2 && dpr == 2 && rotation == 90) { + for (int y = 0; y < height; y += 127) for (int x = 0; x < width; x += 173) { + auto tileRequest = render; const int w = std::min(173, width - x), h = std::min(127, height - y); + tileRequest.insert(QStringLiteral("x"), x); tileRequest.insert(QStringLiteral("y"), y); + tileRequest.insert(QStringLiteral("width"), w); tileRequest.insert(QStringLiteral("height"), h); + const auto tile = doc.render(tileRequest, &code, &error).value("data").toByteArray(); + QVERIFY2(code.isEmpty(), context.constData()); + for (int row = 0; row < h; ++row) { + const auto actual = tile.mid(row * w * 4, w * 4), expectedRow = image.mid(((y + row) * width + x) * 4, w * 4); + // PDFium's rotated, scaled AP matrix uses floats: + // translating the same edge between tile origins may + // differ by two 8-bit antialiasing steps, never geometry. + for (qsizetype at = 0; at < actual.size(); ++at) + QVERIFY2(std::abs(int(quint8(actual[at])) - int(quint8(expectedRow[at]))) <= 2, + (context + " tile=" + QByteArray::number(x) + ',' + QByteArray::number(y) + " difference=" + QByteArray::number(x + at / 4) + ',' + QByteArray::number(y + row) + + " actual=" + QByteArray::number(quint8(actual[at])) + " expected=" + QByteArray::number(quint8(expectedRow[at]))).constData()); + } + } + } + if (!evidence.isEmpty() && page % 4 == 1 && rotation == 90 && dpr == 2) { + QFile output(evidence + QString("/kind%1-flags%2-intrinsic90-user90-zoom%3-dpr2.ppm").arg(kind).arg(flags).arg(zoom)); + QVERIFY(output.open(QIODevice::WriteOnly)); + QByteArray ppm = "P6\n" + QByteArray::number(width) + ' ' + QByteArray::number(height) + "\n255\n"; + for (qsizetype i = 0; i < image.size(); i += 4) { ppm += image[i + 2]; ppm += image[i + 1]; ppm += image[i]; } + QCOMPARE(output.write(ppm), ppm.size()); + } + } + } + QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes); + } + void annotationFlagsKeepVisibilityZOrderAndCropClipping() { + PdfDocument doc; QString code, error; QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf"), {}, &code, &error)); + const auto visible = doc.links(36); + QCOMPARE(visible.value("links").toArray().size(), 2); + QCOMPARE(visible.value("annotations").toArray().size(), 0); + for (double scale : {.5, 1., 2.}) { + auto render = request(0, 0, 0, int(112 * scale), int(104 * scale)); + render.insert(QStringLiteral("page"), 36); render.insert(QStringLiteral("scale"), scale); + const auto image = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error)); + int green = 0; + for (qsizetype i = 0; i < image.size(); i += 4) { + const auto *pixel = reinterpret_cast(image.constData() + i); + QVERIFY(!(pixel[1] < 50 && pixel[0] > 200 && pixel[2] < 50)); // No blue from either hidden AP. + if (pixel[1] > 200 && pixel[0] < 50 && pixel[2] < 50) ++green; + } + QVERIFY(green > 200); + const int center = (int(52 * scale + 8) * int(112 * scale) + int(34 * scale + 16)) * 4; + QVERIFY(quint8(image[center + 1]) > 200 && quint8(image[center]) < 50 && quint8(image[center + 2]) < 50); + render.insert(QStringLiteral("page"), 37); + const auto clipped = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error)); + const int w = int(112 * scale), h = int(104 * scale); int ink = 0; + for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { + const auto *pixel = reinterpret_cast(clipped.constData() + (y * w + x) * 4); + if (pixel[1] < 50) { QVERIFY(x >= int(100 * scale)); ++ink; } + } + QVERIFY(ink > 0); + } + } + void generatedTextIconsUseOneCapturedAppearance() { + const auto path = QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf"); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto bytes = source.readAll(); + FPDF_InitLibrary(); const auto libraryGuard = qScopeGuard([] { FPDF_DestroyLibrary(); }); + auto document = FPDF_LoadMemDocument64(bytes.constData(), size_t(bytes.size()), nullptr); QVERIFY(document); + const auto documentGuard = qScopeGuard([&] { FPDF_CloseDocument(document); }); + auto context = std::make_shared(); QString code, error; + QVERIFY(context->open(&source, {}, 12, &code, &error)); LinkBorderReader reader(context); + const auto prepare = [&](int index, double scale) { + auto page = FPDF_LoadPage(document, index); if (!page) return false; + { auto scope = reader.prepare(index, page, &code, &error, scale, 2); if (!scope) { FPDF_ClosePage(page); return false; } } + auto annotation = FPDFPage_GetAnnot(page, 0); FS_RECTF rect{}; + const bool original = FPDFAnnot_GetRect(annotation, &rect) && rect.left == 42 && rect.bottom == 48 && rect.right == 74 && rect.top == 64; + FPDFPage_CloseAnnot(annotation); + auto ink = FPDFPage_GetAnnot(page, 1); FS_RECTF inkRect{}; + const bool inkUnchanged = FPDFAnnot_GetRect(ink, &inkRect) && inkRect.left == 80 && inkRect.bottom == 24 && inkRect.right == 100 && inkRect.top == 34 && + FPDFAnnot_GetFlags(ink) == 8 && FPDFAnnot_GetAP(ink, FPDF_ANNOT_APPEARANCEMODE_NORMAL, nullptr, 0) <= 2; + FPDFPage_CloseAnnot(ink); FPDF_ClosePage(page); return original && inkUnchanged; + }; + for (int page = 0; page < 12; ++page) QVERIFY2(prepare(page, 1), qPrintable(error)); + struct Writer : FPDF_FILEWRITE { QByteArray bytes; Writer() { version = 1; WriteBlock = [](FPDF_FILEWRITE *self, const void *data, unsigned long size) { + static_cast(self)->bytes.append(static_cast(data), qsizetype(size)); return 1; }; } }; + Writer first; QVERIFY(FPDF_SaveAsCopy(document, &first, FPDF_NO_INCREMENTAL)); + for (int i = 0; i < 200; ++i) QVERIFY2(prepare(i % 12, i % 2 ? .5 : 2), qPrintable(error)); + Writer repeated; QVERIFY(FPDF_SaveAsCopy(document, &repeated, FPDF_NO_INCREMENTAL)); + QCOMPARE(repeated.bytes.size(), first.bytes.size()); + QCOMPARE(repeated.bytes.count("/Subtype/Form"), first.bytes.count("/Subtype/Form")); + QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), bytes); + } + void generatedTextIconGeometryMatchesFlags() { + PdfDocument doc; QString code, error; QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf"), {}, &code, &error)); + for (int page = 0; page < 12; ++page) { + const int flags = page / 4 == 0 ? 8 : page / 4 == 1 ? 16 : 24; + const auto original = doc.links(page); const auto icon = original.value("annotations").toArray().first().toMap(); + QCOMPARE(icon.value("flags").toInteger(), flags); QCOMPARE(icon.value("rect").toArray(), QCborArray({42.,48.,62.,68.})); + QCOMPARE(original.value("annotations").toArray()[1].toMap().value("flags").toInteger(),0); + QVERIFY(!original.value("warnings").toArray().isEmpty()); + for (int rotation : {0,90,180,270}) for (double zoom : {.5,2.}) for (double dpr : {1.,2.}) { + const int total = (page % 4 + rotation / 90) % 4; const double scale = zoom * dpr; + const int w = int((total % 2 ? 104 : 112) * scale), h = int((total % 2 ? 112 : 104) * scale); + auto render = request(rotation, 0, 0, w, h); render.insert(QStringLiteral("page"), page); + render.insert(QStringLiteral("scale"), scale); render.insert(QStringLiteral("devicePixelRatio"), dpr); + const auto data = doc.render(render, &code, &error).value("data").toByteArray(); QVERIFY2(code.isEmpty(), qPrintable(error)); + const auto map = [&](double x, double y) { + if (total == 0) return QPointF((x - 8) * scale, (116 - y) * scale); + if (total == 1) return QPointF((y - 12) * scale, (x - 8) * scale); + if (total == 2) return QPointF((120 - x) * scale, (y - 12) * scale); + return QPointF((116 - y) * scale, (120 - x) * scale); + }; + const auto anchor = map(42,68); const double sizeScale = flags & 8 ? dpr : scale; + const auto opposite = flags & 16 ? anchor + QPointF(20,20) * sizeScale : anchor + (map(62,48) - anchor) * (flags & 8 ? 1/zoom : 1); + const auto expected = QRectF(anchor,opposite).normalized().intersected(QRectF(0,0,w,h)); + QRect red; + for (int y=0;y(data.constData()+(y*w+x)*4); + if (p[2]>150 && p[1]>150 && p[0]<100) red = red.isNull() ? QRect(x,y,1,1) : red.united(QRect(x,y,1,1)); + } + QVERIFY2(!red.isNull(),qPrintable(QString("page=%1 rotation=%2 zoom=%3 dpr=%4").arg(page).arg(rotation).arg(zoom).arg(dpr))); + QVERIFY(expected.adjusted(-1,-1,1,1).contains(red)); + QVERIFY2(red.width() >= expected.width() - 4 * sizeScale - 2 && red.height() >= expected.height() - 4 * sizeScale - 2, + qPrintable(QString("page=%1 rotation=%2 zoom=%3 dpr=%4 actual=%5x%6 expected=%7x%8").arg(page).arg(rotation).arg(zoom).arg(dpr).arg(red.width()).arg(red.height()).arg(expected.width()).arg(expected.height()))); + const auto after = doc.links(page); + QCOMPARE(after.value("annotations").toArray().first().toMap(),icon); + QCOMPARE(after.value("annotations").toArray()[1].toMap().value("flags").toInteger(),0); + } + } + } + void nativeIconFailureRemainsStickyAcrossSharedPages() { + QFile source(QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf")); QVERIFY(source.open(QIODevice::ReadOnly)); + const auto bytes=source.readAll(); FPDF_InitLibrary(); const auto libraryGuard=qScopeGuard([]{FPDF_DestroyLibrary();}); + auto document=FPDF_LoadMemDocument64(bytes.constData(),size_t(bytes.size()),nullptr); QVERIFY(document); + const auto documentGuard=qScopeGuard([&]{FPDF_CloseDocument(document);}); + auto context=std::make_shared(); QString code,error; QVERIFY(context->open(&source,{},12,&code,&error)); + LinkBorderReader reader(context,1); // Lower-only quota reaches a failure after AP creation. + auto first=FPDF_LoadPage(document,0); QVERIFY(reader.prepareNativeIcons(0,first,&code,&error)); FPDF_ClosePage(first); + for(int pageIndex:{4,5,0}) { + auto page=FPDF_LoadPage(document,pageIndex); QVERIFY(!reader.prepareNativeIcons(pageIndex,page,&code,&error)); + QCOMPARE(code,"E_LIMIT_EXCEEDED"); auto annotation=FPDFPage_GetAnnot(page,0); FS_RECTF rect{}; QVERIFY(FPDFAnnot_GetRect(annotation,&rect)); + QCOMPARE(rect.left,42.f); QCOMPARE(rect.bottom,48.f); QCOMPARE(rect.right,74.f); QCOMPARE(rect.top,64.f); + FPDFPage_CloseAnnot(annotation); FPDF_ClosePage(page); + } + } + void nativeIconMetadataCanBrokerFontsInLinksRequest() { + FontBroker fonts; WorkerProcess worker("native-text-icon-fonts", 1); + int maps = 0; QString operation; + worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function done) { + if (op == "font.map" && operation == "links") ++maps; + fonts.request(op, payload, std::move(done)); + }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker", {"--source",QFINDTESTDATA("fixtures/pdf/annotation-flags/text-icons.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(),10000); QVERIFY(failed.isEmpty()); + QList replies; + operation="open"; worker.request(operation,{},[&](QCborMap r){replies<0); worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty()); + } + void invalidLinkBordersAreExplicit_data() { + QTest::addColumn("name"); QTest::addColumn("expected"); + QTest::newRow("unknown-style") << "unknown-style" << "E_PDF_ANNOT_UNSUPPORTED"; + QTest::newRow("zero-dash") << "invalid-dash" << "E_PDF_CORRUPT"; + QTest::newRow("bounded-dash") << "oversized-dash" << "E_LIMIT_EXCEEDED"; + QTest::newRow("color") << "invalid-color" << "E_PDF_CORRUPT"; + QTest::newRow("appearance") << "invalid-ap" << "E_PDF_CORRUPT"; + } + void invalidLinkBordersAreExplicit() { + QFETCH(QString, name); QFETCH(QString, expected); + const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/link-borders/manifest.json")).absoluteDir(); + PdfDocument document; QString code, message; + QVERIFY2(document.open(directory.filePath(name + ".pdf"), {}, &code, &message), qPrintable(message)); + QVERIFY(document.render(request(), &code, &message).isEmpty()); QCOMPARE(code, expected); + QVERIFY(!message.contains(directory.path())); + } + void failedBorderPagesDoNotConsumeTheAppearanceBudget() { + PdfDocument document; QString code, message; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/link-borders/retry-budget.pdf"), {}, &code, &message)); + for (int retry = 0; retry < 40; ++retry) { + QVERIFY(document.render(request(), &code, &message).isEmpty()); + QCOMPARE(code, "E_PDF_ANNOT_UNSUPPORTED"); + } + auto validPage = request(); validPage.insert(QStringLiteral("page"), 1); + const auto result = document.render(validPage, &code, &message); + QVERIFY2(code.isEmpty(), qPrintable(code + ": " + message)); + QCOMPARE(result.value("data").toByteArray().size(), 200 * 200 * 4); + } + void outlinePaginationBeyondOneThousand() { + QTemporaryDir directory; PdfDocument doc; QString code, error; + QVERIFY(doc.open(largeMetadataFixture(directory, 1, 1200), {}, &code, &error)); + const auto metadata = doc.metadata(); + QCOMPARE(metadata.value("outlineCount").toInteger(), 1200); + QVERIFY(!metadata.value("isTagged").toBool()); + QCOMPARE(metadata.value("outline").toArray().size(), 64); + QCOMPARE(metadata.value("nextOutlineCursor").toInteger(), 64); + QVERIFY(!metadata.value("outlineTruncated").toBool()); + int cursor = 0, seen = 0; + while (cursor >= 0) { + const auto result = doc.outline(cursor, 128); + QVERIFY(!result.contains(QStringLiteral("error"))); + QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024); + QCOMPARE(result.value("outlineCount").toInteger(), 1200); + QVERIFY(!result.value("truncated").toBool()); + const auto rows = result.value("outline").toArray(); + QVERIFY(!rows.isEmpty()); + for (const auto &value : rows) { + const auto row = value.toMap(); + QCOMPARE(row.value("id").toString(), QString("outline-%1").arg(seen)); + QCOMPARE(row.value("title").toString(), QString("Heading %1").arg(seen)); + QCOMPARE(row.value("page").toInteger(), 0); + ++seen; + } + cursor = int(result.value("nextOutlineCursor").toInteger()); + QVERIFY(cursor < 0 || cursor == seen); + } + QCOMPARE(seen, 1200); + QCOMPARE(doc.outline(1024, 1).value("outline").toArray()[0].toMap().value("id").toString(), "outline-1024"); + QVERIFY(doc.outline(1201, 1).contains(QStringLiteral("error"))); + QVERIFY(doc.outline(0, 0).contains(QStringLiteral("error"))); + } + void outlineAndPageBatchesRespectEncodedByteLimits() { + QTemporaryDir directory; PdfDocument doc; QString code, error; + const QString longText(4050, QChar(0x3042)); + QVERIFY(doc.open(largeMetadataFixture(directory, 300, 180, longText, longText), {}, &code, &error)); + const auto metadata = doc.metadata(); + QVERIFY(!metadata.contains(QStringLiteral("error"))); + QVERIFY(QCborValue(metadata).toCbor().size() <= 512 * 1024); + QVERIFY(metadata.value("outline").toArray().size() < 64); + QVERIFY(metadata.value("pages").toArray().size() < 256); + int cursor = 0, pages = 0; + while (cursor >= 0) { + const auto batch = doc.pages(cursor, 256); + QVERIFY(QCborValue(batch).toCbor().size() <= 256 * 1024); + const auto values = batch.value("pages").toArray(); QVERIFY(!values.isEmpty()); + for (const auto &value : values) { + QCOMPARE(value.toMap().value("pageIndex").toInteger(), pages++); + QCOMPARE(value.toMap().value("label").toString(), longText); + } + cursor = int(batch.value("nextPageCursor").toInteger()); + } + QCOMPARE(pages, 300); + cursor = 0; int outlines = 0; + while (cursor >= 0) { + const auto batch = doc.outline(cursor, 256); + QVERIFY(QCborValue(batch).toCbor().size() <= 512 * 1024); + const auto values = batch.value("outline").toArray(); QVERIFY(!values.isEmpty()); + for (const auto &value : values) QCOMPARE(value.toMap().value("title").toString(), longText + QString::number(outlines++)); + cursor = int(batch.value("nextOutlineCursor").toInteger()); + } + QCOMPARE(outlines, 180); + } + void outlineSafetyLimitIsExplicit() { + QTemporaryDir directory; PdfDocument doc; QString code, error; + QVERIFY(doc.open(largeMetadataFixture(directory, 1, 20002), {}, &code, &error)); + const auto metadata = doc.metadata(); + QCOMPARE(metadata.value("outlineCount").toInteger(), 20000); + QVERIFY(metadata.value("outlineTruncated").toBool()); + QVERIFY(metadata.value("warnings").toArray().toVariantList().first().toString().contains("20000")); + const auto last = doc.outline(19999, 128); + QCOMPARE(last.value("outline").toArray().size(), 1); + QCOMPARE(last.value("nextOutlineCursor").toInteger(), -1); + QVERIFY(last.value("truncated").toBool()); + QVERIFY(!last.value("warnings").toArray().isEmpty()); + } + void oversizedStringsProduceWarnings() { + QTemporaryDir directory; PdfDocument doc; QString code, error; + const QString tooLong(5000, 'x'); + QVERIFY(doc.open(largeMetadataFixture(directory, 1, 2, tooLong, tooLong), {}, &code, &error)); + const auto metadata = doc.metadata(); + QVERIFY(!metadata.contains(QStringLiteral("error"))); + QCOMPARE(metadata.value("warnings").toArray().size(), 2); + QVERIFY(metadata.value("pages").toArray()[0].toMap().value("label").toString().isEmpty()); + QVERIFY(metadata.value("outline").toArray()[0].toMap().value("title").toString().contains("長さ制限")); + QCOMPARE(metadata.value("outlineCount").toInteger(), 2); + } + void metadataAndDestinations() { + PdfDocument doc; QString code, error; QVERIFY2(doc.open(fixture(), {}, &code, &error), qPrintable(error)); + auto meta = doc.metadata(); QCOMPARE(meta.value("pageCount").toInteger(), 2); + QVERIFY(meta.value("isTagged").toBool()); + const auto capabilities = meta.value("capabilities").toMap(); + QCOMPARE(capabilities.size(), 8); + QCOMPARE(capabilities.value("pageNavigation").toString(), "supported"); + QCOMPARE(capabilities.value("headings").toString(), "loading"); + QCOMPARE(capabilities.value("textSearch").toString(), "loading"); + QCOMPARE(capabilities.value("reflow").toString(), "unsupported"); + QCOMPARE(meta.value("capabilityReasons").toMap().value("reflow").toString(), "E_REFLOW_UNSUPPORTED"); + QCOMPARE(capabilities.value("password").toString(), "unavailable"); + QCOMPARE(meta.value("title").toString(), "DocView PDF fixture"); + auto pages = meta.value("pages").toArray(); QCOMPARE(pages.size(), 2); + QCOMPARE(pages[0].toMap().value("width").toDouble(), 200.0); + QCOMPARE(pages[0].toMap().value("label").toString(), "i"); + QCOMPARE(pages[1].toMap().value("label").toString(), "1"); + QCOMPARE(pages[1].toMap().value("rotation").toInteger(), 90); + QCOMPARE(pages[1].toMap().value("width").toDouble(), 200.0); + auto outline = meta.value("outline").toArray(); QCOMPARE(outline.size(), 2); + QCOMPARE(outline[0].toMap().value("page").toInteger(), 0); + QCOMPARE(outline[0].toMap().value("y").toDouble(), 180.0); + QCOMPARE(outline[1].toMap().value("page").toInteger(), 1); + QCOMPARE(doc.metadata(1, 1).value("nextPageCursor").toInteger(), -1); + } + void tagsRoleMapAndMultipleMcid() { + PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error)); + auto headings = doc.headings(0).value("headings").toArray(); QCOMPARE(headings.size(), 2); + QCOMPARE(headings[0].toMap().value("level").toInteger(), 1); + QCOMPARE(headings[0].toMap().value("title").toString(), "Heading One"); + QCOMPARE(headings[0].toMap().value("precision").toString(), "exact"); + QVERIFY(headings[0].toMap().value("y").toDouble() > 180); + QCOMPARE(headings[1].toMap().value("title").toString(), "NeedleSecond MCID"); + QCOMPARE(doc.headings(1).value("headings").toArray().size(), 0); + } + void taggedBoundaryCorpus_data() { + QTest::addColumn("fileName"); + QFile manifest(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")); QVERIFY(manifest.open(QIODevice::ReadOnly)); + for (const auto &value : QJsonDocument::fromJson(manifest.readAll()).object().value("documents").toArray()) { + const auto name = value.toObject().value("file").toString(); QTest::newRow(qPrintable(name)) << name; + } + } + void taggedBoundaryCorpus() { + QFETCH(QString, fileName); + const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")).absoluteDir(); + QFile manifest(directory.filePath("manifest.json")); QVERIFY(manifest.open(QIODevice::ReadOnly)); + const auto records = QJsonDocument::fromJson(manifest.readAll()).object().value("documents").toArray(); + QJsonObject record; + for (const auto &entry : records) if (entry.toObject().value("file").toString() == fileName) record = entry.toObject(); + QVERIFY(!record.isEmpty()); + QFile bytes(directory.filePath(fileName)); QVERIFY(bytes.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromLatin1(QCryptographicHash::hash(bytes.readAll(), QCryptographicHash::Sha256).toHex()), record.value("sha256").toString()); + PdfDocument document; QString code, error; + QVERIFY2(document.open(directory.filePath(fileName), {}, &code, &error), qPrintable(error)); + const auto expected = record.value("expected").toObject(); + QCOMPARE(document.pageCount(), expected.value("pages").toInt()); + const auto metadata = document.metadata(); + QVERIFY(metadata.value("isTagged").toBool()); + QVERIFY(metadata.value("outline").toArray().isEmpty()); // Tags must work without bookmarks. + QCborArray allHeadings; + for (int page = 0; page < document.pageCount(); ++page) { + const auto result = document.headings(page); + if (expected.value("response_limited").toBool()) { + QVERIFY(result.value("structureLimited").toBool()); QVERIFY(result.value("truncated").toBool()); + QVERIFY(!result.value("complete").toBool()); QVERIFY(!result.value("headings").toArray().isEmpty()); + QVERIFY(result.value("headings").toArray().size() < 70); QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024); + continue; + } + if (expected.value("limited").toBool()) { + QVERIFY2(result.value("structureLimited").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation())); + QVERIFY(!result.value("unavailableReason").toString().isEmpty()); + QCOMPARE(result.value("truncated").toBool(), expected.value("truncated").toBool()); + QVERIFY(result.value("headings").toArray().isEmpty()); continue; + } + QVERIFY2(result.value("complete").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation())); QVERIFY(!result.value("truncated").toBool()); + QCOMPARE(result.value("page").toInteger(), page); + if (expected.value("unavailable_form_structure").toBool()) { + QVERIFY(result.value("structureLimited").toBool()); + QVERIFY(result.value("unavailableReason").toString().contains("Form XObject")); + QVERIFY(result.value("hasText").toBool()); + } else { + QVERIFY2(!result.value("structureLimited").toBool(), qPrintable(QCborValue(result).toDiagnosticNotation())); + } + for (const auto &heading : result.value("headings").toArray()) allHeadings.append(heading); + } + const auto targets = expected.value("headings").toArray(); + QCOMPARE(allHeadings.size(), targets.size()); + for (qsizetype index = 0; index < targets.size(); ++index) { + const auto actual = allHeadings[index].toMap(); const auto target = targets[index].toObject(); + QCOMPARE(actual.value("title").toString(), target.value("title").toString()); + QCOMPARE(actual.value("page").toInteger(), target.value("page").toInt()); + QCOMPARE(actual.value("level").toInteger(), target.value("level").toInt()); + QCOMPARE(actual.value("source").toString(), "pdf-tag"); + QCOMPARE(actual.value("precision").toString(), target.value("precision").toString()); + if (target.value("precision").toString() == "page") { + QVERIFY(actual.value("reason").toString().contains(target.value("reason_contains").toString())); + QVERIFY(!actual.contains(QStringLiteral("x"))); QVERIFY(!actual.contains(QStringLiteral("y"))); + QVERIFY(!actual.contains(QStringLiteral("rect"))); + continue; + } + const auto region = target.value("text_region_pt").toArray(); + const auto rect = actual.value("rect").toArray(); QCOMPARE(rect.size(), 4); + QVERIFY(rect[0].toDouble() >= region[0].toDouble()); QVERIFY(rect[1].toDouble() >= region[1].toDouble()); + QVERIFY(rect[2].toDouble() <= region[2].toDouble()); QVERIFY(rect[3].toDouble() <= region[3].toDouble()); + QVERIFY(rect[2].toDouble() > rect[0].toDouble()); QVERIFY(rect[3].toDouble() > rect[1].toDouble()); + QCOMPARE(actual.value("x").toDouble(), rect[0].toDouble()); + QCOMPARE(actual.value("y").toDouble(), rect[3].toDouble()); + // The returned PDF point must survive document CropBox/Rotate and + // each user rotation. A correct title alone does not pass this test. + for (int rotation : {0, 90, 180, 270}) { + QCborMap point{{QStringLiteral("page"), target.value("page").toInt()}, {QStringLiteral("scale"), 2.0}, + {QStringLiteral("rotation"), rotation}, {QStringLiteral("x"), actual.value("x")}, {QStringLiteral("y"), actual.value("y")}}; + code.clear(); const auto mapped = document.mapPoint(point, &code, &error); QVERIFY2(code.isEmpty(), qPrintable(error)); + QVERIFY(mapped.value("x").toDouble() >= 0); QVERIFY(mapped.value("y").toDouble() >= 0); + const auto pageMeta = metadata.value("pages").toArray()[target.value("page").toInt()].toMap(); + double width = pageMeta.value("width").toDouble(), height = pageMeta.value("height").toDouble(); + if (rotation % 180) std::swap(width, height); + QVERIFY(mapped.value("x").toDouble() <= width * 2); + QVERIFY(mapped.value("y").toDouble() <= height * 2); + point.insert(QStringLiteral("direction"), "deviceToPage"); + point.insert(QStringLiteral("x"), mapped.value("x")); point.insert(QStringLiteral("y"), mapped.value("y")); + const auto restored = document.mapPoint(point, &code, &error); + QVERIFY(qAbs(restored.value("x").toDouble() - actual.value("x").toDouble()) <= 0.5); + QVERIFY(qAbs(restored.value("y").toDouble() - actual.value("y").toDouble()) <= 0.5); + } + } + } + void linksCommentsAndSearch() { + PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error)); + auto result = doc.links(0); auto links = result.value("links").toArray(); QCOMPARE(links.size(), 2); + QCOMPARE(links[0].toMap().value("kind").toString(), "external"); + QCOMPARE(links[1].toMap().value("kind").toString(), "blocked"); + QCOMPARE(result.value("annotations").toArray()[0].toMap().value("text").toString(), "Read-only comment"); + auto matches = doc.search("Needle", 0).value("matches").toArray(); QCOMPARE(matches.size(), 1); + QVERIFY(!matches[0].toMap().value("rects").toArray().isEmpty()); + QCOMPARE(doc.search("needle", 1).value("matches").toArray().size(), 1); + QCOMPARE(doc.search("missing", 0).value("matches").toArray().size(), 0); + } + void annotationContentsLengthBoundary() { + QTemporaryDir directory; PdfDocument doc; QString code, error; + // Keep a complete surrogate pair at the finite UTF-16 boundary. + const QString atLimit = QString(4093, QChar('x')) + QString::fromUcs4(U"\U0001f4d6"); + QCOMPARE(atLimit.size(), 4095); + auto path = annotationMetadataFixture(directory, 1, atLimit); + QVERIFY(!path.isEmpty()); QVERIFY(doc.open(path, {}, &code, &error)); + auto result = doc.links(0); + QCOMPARE(result.value("annotations").toArray().size(), 1); + QCOMPARE(result.value("annotations").toArray()[0].toMap().value("text").toString(), atLimit); + QVERIFY(result.value("warnings").toArray().isEmpty()); + doc.close(); + const QString overLimit = QString(4094, QChar('x')) + QString::fromUcs4(U"\U0001f4d6"); + path = annotationMetadataFixture(directory, 1, overLimit); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto original = source.readAll(); source.close(); + QVERIFY(doc.open(path, {}, &code, &error)); result = doc.links(0); + QCOMPARE(result.value("annotations").toArray().size(), 1); + const auto note = result.value("annotations").toArray()[0].toMap(); + QVERIFY(note.value("text").toString().contains(QStringLiteral("長さ制限"))); + QCOMPARE(note.value("rect").toArray(), QCborArray({10., 10., 30., 30.})); + const auto warnings = result.value("warnings").toArray(); QCOMPARE(warnings.size(), 1); + QVERIFY(warnings[0].toString().startsWith("E_PDF_METADATA_LIMIT:")); + QVERIFY(warnings[0].toString().contains(QStringLiteral("注釈本文"))); + doc.close(); QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), original); + } + void annotationAndLinkCountLimitsAreExplicit_data() { + QTest::addColumn("links"); QTest::addColumn("count"); + for (bool links : {false, true}) for (int count : {1000, 1001}) + QTest::newRow(qPrintable(QString("%1-%2").arg(links ? "links" : "comments").arg(count))) << links << count; + } + void annotationAndLinkCountLimitsAreExplicit() { + QFETCH(bool, links); QFETCH(int, count); + QTemporaryDir directory; PdfDocument doc; QString code, error; + QVERIFY(doc.open(annotationMetadataFixture(directory, count, QStringLiteral("Comment"), links), {}, &code, &error)); + const auto result = doc.links(0); + QVERIFY(!result.contains(QStringLiteral("error"))); + QCOMPARE(result.value(links ? QStringLiteral("links") : QStringLiteral("annotations")).toArray().size(), 1000); + const auto warnings = result.value("warnings").toArray(); + QCOMPARE(warnings.isEmpty(), count == 1000); + if (count > 1000) { + bool found = false; + for (const auto &warning : warnings) { + QVERIFY(warning.toString().startsWith("E_PDF_METADATA_LIMIT:")); + if (warning.toString().contains(links ? QStringLiteral("リンク") : QStringLiteral("コメント"))) found = true; + } + QVERIFY(found); + } + QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024); + } + void annotationResponsePreservesBoundedPrefixThroughWorker() { + QTemporaryDir directory; + const QString contents(4095, QChar(0x65e5)); // UTF-8 exceeds its UTF-16 code-unit count. + const auto path = annotationMetadataFixture(directory, 100, contents); + QVERIFY(!path.isEmpty()); + QFile source(path); QVERIFY(source.open(QIODevice::ReadOnly)); const auto original = source.readAll(); source.close(); + WorkerProcess worker("annotation-budget", 1); + FontBroker broker; + worker.setFontRequestHandler([&broker](const QString &op, const QCborMap &payload, auto completion) { + broker.request(op, payload, std::move(completion)); + }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QList replies; + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", path})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + worker.request("open", {}, [&](const QCborMap &reply) { replies.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); QVERIFY(!replies.takeFirst().contains(QStringLiteral("error"))); + worker.request("links", {{"page", 0}}, [&](const QCborMap &reply) { replies.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); const auto reply = replies.takeFirst(); + QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(QCborValue(reply).toDiagnosticNotation())); + const auto result = reply.value("result").toMap(); const auto annotations = result.value("annotations").toArray(); + QVERIFY(!annotations.isEmpty()); QVERIFY(annotations.size() < 100); + for (const auto &annotation : annotations) QCOMPARE(annotation.toMap().value("text").toString(), contents); + bool warningFound = false; + for (const auto &warning : result.value("warnings").toArray()) + if (warning.toString().contains(QStringLiteral("応答サイズ")) && warning.toString().startsWith("E_PDF_METADATA_LIMIT:")) warningFound = true; + QVERIFY(warningFound); QVERIFY(QCborValue(result).toCbor().size() <= 512 * 1024); + worker.stop(); QVERIFY(failed.isEmpty()); + QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), original); + } + void cropRotationTilesAndFormAppearance() { + PdfDocument doc; QString code, error; QVERIFY(doc.open(fixture(), {}, &code, &error)); + auto full = doc.render(request(), &code, &error); QVERIFY2(code.isEmpty(), qPrintable(error)); + auto data = full.value("data").toByteArray(); QCOMPARE(data.size(), 200 * 200 * 4); + const auto pixel = [&](int x, int y) { return reinterpret_cast(data.constData() + (y * 200 + x) * 4); }; + QVERIFY(pixel(30, 160)[0] < 5); // black square in cropped device space + QVERIFY(pixel(110, 150)[0] < 5); QVERIFY(pixel(110, 150)[2] > 250); // saved widget AP is red + auto tile = doc.render(request(0, 100, 100, 100, 100), &code, &error).value("data").toByteArray(); + // A 16-pixel render margin preserves clipped glyph antialiasing at + // tile boundaries. Every pixel must equal the full-page crop. + for (int row = 0; row < 100; ++row) QCOMPARE(tile.mid(row * 400, 400), data.mid(((row + 100) * 200 + 100) * 4, 400)); + auto rotated = doc.render(request(180), &code, &error).value("data").toByteArray(); + auto red = reinterpret_cast(rotated.constData() + (49 * 200 + 89) * 4); + QVERIFY(red[0] < 5 && red[2] > 250); + auto pointRequest = request(); pointRequest.insert(QStringLiteral("x"), 60.0); pointRequest.insert(QStringLiteral("y"), 180.0); + auto mapped = doc.mapPoint(pointRequest, &code, &error); QCOMPARE(mapped.value("x").toDouble(), 40.0); QCOMPARE(mapped.value("y").toDouble(), 50.0); + pointRequest.insert(QStringLiteral("direction"), "deviceToPage"); pointRequest.insert(QStringLiteral("x"), mapped.value("x")); pointRequest.insert(QStringLiteral("y"), mapped.value("y")); + auto inverse = doc.mapPoint(pointRequest, &code, &error); QCOMPARE(inverse.value("x").toDouble(), 60.0); QCOMPARE(inverse.value("y").toDouble(), 180.0); + } + void passwordCorruptionAndLimits() { + PdfDocument doc; QString code, error; const auto path = QFINDTESTDATA("fixtures/pdf/password.pdf"); + QVERIFY(!doc.open(path, {}, &code, &error)); QCOMPARE(code, "E_PASSWORD_REQUIRED"); + QVERIFY(!doc.open(path, "wrong", &code, &error)); QCOMPARE(code, "E_PASSWORD_INVALID"); + QVERIFY(doc.open(path, "reader", &code, &error)); QCOMPARE(doc.pageCount(), 2); + const auto encrypted = doc.render(request(), &code, &error).value("data").toByteArray(); + QVERIFY2(code.isEmpty(), qPrintable(error)); QCOMPARE(encrypted.size(), 200 * 200 * 4); + QVERIFY(doc.open(fixture(), {}, &code, &error)); + QCOMPARE(doc.render(request(), &code, &error).value("data").toByteArray(), encrypted); + QVERIFY(!doc.open(QFINDTESTDATA("fixtures/pdf/corrupt.pdf"), {}, &code, &error)); QCOMPARE(code, "E_PDF_CORRUPT"); + QVERIFY(doc.open(fixture(), {}, &code, &error)); + auto invalid = request(); invalid.insert(QStringLiteral("width"), 515); code.clear(); + QVERIFY(doc.render(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID"); + invalid = request(); invalid.insert(QStringLiteral("scale"), std::numeric_limits::infinity()); + QVERIFY(doc.render(invalid, &code, &error).isEmpty()); + invalid = request(); invalid.insert(QStringLiteral("page"), qint64(1) << 32); + QVERIFY(doc.render(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID"); + invalid = request(); invalid.insert(QStringLiteral("rotation"), qint64(1) << 32); + QVERIFY(doc.mapPoint(invalid, &code, &error).isEmpty()); QCOMPARE(code, "E_PROTOCOL_INVALID"); + doc.close(); doc.close(); QCOMPARE(doc.pageCount(), 0); + } + void missingAppearanceAndAmbiguousFormMcid() { + PdfDocument doc; QString code, error; + QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/missing-appearance.pdf"), {}, &code, &error)); + const auto warnings = doc.links(0).value("warnings").toArray(); + bool missing = false; + for (const auto &warning : warnings) if (warning.toString().contains(QStringLiteral("フォーム外観"))) missing = true; + QVERIFY(missing); + QVERIFY(doc.open(QFINDTESTDATA("fixtures/pdf/reused-form-mcid.pdf"), {}, &code, &error)); + const auto headings = doc.headings(0).value("headings").toArray(); + QVERIFY(!headings.isEmpty()); + QCOMPARE(headings[0].toMap().value("title").toString(), "Heading One"); + QCOMPARE(headings[0].toMap().value("precision").toString(), "exact"); + QVERIFY(!headings[0].toMap().value("title").toString().contains("Different Form")); + } + void missingFirstPageRejectsMetadata() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf"), {}, &code, &error)); + const auto result = document.metadata(); + QVERIFY(result.contains(QStringLiteral("error"))); + QCOMPARE(result.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT"); + QVERIFY(!result.contains(QStringLiteral("pages"))); + const auto pageResult = document.metadata(0, 1); + QCOMPARE(pageResult.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT"); + } + void sandboxedWorker_data() { + QTest::addColumn("input"); QTest::addColumn("broken"); + QTest::newRow("valid") << fixture() << false; + QTest::newRow("missing-first-page") << QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf") << true; + } + void sandboxedWorker() { + QFETCH(QString, input); QFETCH(bool, broken); + FontBroker fonts; + WorkerProcess worker("pdf-test-session", 1); + int fontMaps = 0, fontReads = 0; + worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, std::function done) { + if (operation == "font.map") ++fontMaps; + if (operation == "font.read") ++fontReads; + fonts.request(operation, payload, std::move(done)); + }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", input})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); + QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); + QVERIFY(!ready.isEmpty()); + QList responses; + const auto send = [&](const QString &operation, const QCborMap &payload = QCborMap{}) { + worker.request(operation, payload, [&](const QCborMap &response) { responses.append(response); }); + }; + send("open"); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000); + auto response = responses.takeFirst(); + if (broken) { + QCOMPARE(response.value("error").toMap().value("code").toString(), "E_PDF_CORRUPT"); + send("pages"); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000); + response = responses.takeFirst(); + QCOMPARE(response.value("error").toMap().value("code").toString(), "E_NOT_OPEN"); + } else { + QVERIFY2(!response.contains(QStringLiteral("error")), qPrintable(response.value("error").toMap().value("message").toString())); + QCOMPARE(response.value("result").toMap().value("pageCount").toInteger(), 2); + send("render", request()); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000); + response = responses.takeFirst(); QCOMPARE(response.value("result").toMap().value("data").toByteArray().size(), 160000); + QVERIFY(fontMaps > 0); QVERIFY(fontReads > 0); // Real reverse RPC, with no worker font-directory grants. + send("outline", {{QStringLiteral("cursor"), 1}, {QStringLiteral("limit"), 1}}); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000); + response = responses.takeFirst(); + auto outline = response.value("result").toMap(); + QCOMPARE(outline.value("outlineCount").toInteger(), 2); + QCOMPARE(outline.value("outline").toArray().size(), 1); + QCOMPARE(outline.value("outline").toArray()[0].toMap().value("id").toString(), "outline-1"); + QCOMPARE(outline.value("nextOutlineCursor").toInteger(), -1); + QVERIFY(!outline.contains(QStringLiteral("pages"))); + send("pages", {{QStringLiteral("cursor"), 1}, {QStringLiteral("limit"), 1}}); QTRY_COMPARE_WITH_TIMEOUT(responses.size(), 1, 10000); + response = responses.takeFirst(); + const auto pages = response.value("result").toMap(); + QCOMPARE(pages.value("pages").toArray().size(), 1); + QCOMPARE(pages.value("pages").toArray()[0].toMap().value("pageIndex").toInteger(), 1); + QCOMPARE(pages.value("nextPageCursor").toInteger(), -1); + QVERIFY(!pages.contains(QStringLiteral("outline"))); + } + worker.stop(); + fonts.revoke(); + QVERIFY(failed.isEmpty()); + } + void sandboxedJapaneseFontBroker() { + FontBroker fonts; + WorkerProcess worker("pdf-japanese-font-test", 1); + int maps = 0, reads = 0, closes = 0; QSet charsets; + worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, std::function done) { + if (operation == "font.map") { ++maps; charsets.insert(int(payload.value("charset").toInteger())); } + if (operation == "font.read") { ++reads; QVERIFY(payload.value("length").toInteger() <= 65536); } + if (operation == "font.close") ++closes; + fonts.request(operation, payload, std::move(done)); + }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", + {"--source", QFINDTESTDATA("fixtures/pdf/fonts/unembedded-japanese.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); + QVERIFY2(failed.isEmpty(), failed.isEmpty() ? "" : qPrintable(failed.first()[1].toString())); + QList replies; + const auto send = [&](const QString &operation, const QCborMap &payload = QCborMap{}) { + // Large CJK collections require many 64 KiB reverse-RPC reads. + // Keep the real event loop running instead of adding QTRY's sleep + // between chunks. The operation still has the same 15 s deadline. + QEventLoop loop; + QPointer waiting(&loop); + QTimer deadline; + deadline.setSingleShot(true); + connect(&deadline, &QTimer::timeout, &loop, &QEventLoop::quit); + worker.request(operation, payload, [&, waiting](const QCborMap &reply) { + replies.append(reply); + if (waiting) waiting->quit(); + }); + if (replies.isEmpty()) { + deadline.start(15000); + loop.exec(); + } + return replies.size() == 1; + }; + QVERIFY(send("open")); + auto reply = replies.takeFirst(); QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(reply.value("error").toMap().value("message").toString())); + QCOMPARE(reply.value("result").toMap().value("pageCount").toInteger(), 2); + for (int page = 0; page < 2; ++page) { + auto render = request(0, 0, 80, 514, 514); render.insert(QStringLiteral("page"), page); + QVERIFY(send("render", render)); + reply = replies.takeFirst(); QVERIFY2(!reply.contains(QStringLiteral("error")), qPrintable(reply.value("error").toMap().value("message").toString())); + const auto pixels = reply.value("result").toMap().value("data").toByteArray(); QCOMPARE(pixels.size(), 514 * 514 * 4); + QVERIFY(std::any_of(pixels.cbegin(), pixels.cend(), [](char c) { return static_cast(c) < 128; })); + QVERIFY(send("search", {{QStringLiteral("page"), page}, {QStringLiteral("query"), QStringLiteral("日本語")}})); + reply = replies.takeFirst(); + QVERIFY(!reply.contains(QStringLiteral("error"))); + const auto matches = reply.value("result").toMap().value("matches").toArray(); + QCOMPARE(matches.size(), 2); // Mincho and Gothic, horizontal then native vertical page. + for (const auto &match : matches) QVERIFY(!match.toMap().value("rects").toArray().empty()); + } + QVERIFY(maps > 0); QVERIFY(reads > 1); QVERIFY(closes > 0); QVERIFY(charsets.contains(128)); + worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty()); + } + void fontFailureCannotSucceedThroughBuiltInFallback_data() { + QTest::addColumn("errorCode"); + QTest::newRow("broker-timeout") << QString("E_WORKER_TIMEOUT"); + QTest::newRow("invalid-font") << QString("E_FONT_FAILED"); + } + void fontFailureCannotSucceedThroughBuiltInFallback() { + QFETCH(QString, errorCode); int calls = 0; + PdfDocument document([&](const QString &, const QCborMap &, int) -> QCborMap { + ++calls; return {{QStringLiteral("error"), QCborMap{{QStringLiteral("code"), errorCode}, {QStringLiteral("message"), QStringLiteral("fixture font failure")}}}}; + }); + QString code, error; QVERIFY(document.open(fixture(), {}, &code, &error)); + const auto result = document.render(request(), &code, &error); + QVERIFY(calls > 0); QVERIFY(result.empty()); QCOMPARE(code, errorCode); + QCOMPARE(document.fontError().value("code").toString(), errorCode); + const int previous = calls; + QVERIFY(document.render(request(), &code, &error).empty()); QCOMPARE(code, errorCode); QCOMPARE(calls, previous); + } + void encryptedFormStructureUsesTheBorrowedSource() { + QTemporaryDir directory; QVERIFY(directory.isValid()); + const auto qpdf = QStandardPaths::findExecutable("qpdf"); + if (qpdf.isEmpty()) QSKIP("The qpdf CLI is required to generate this temporary encrypted fixture."); + const auto path = directory.filePath("encrypted-form.pdf"); + QCOMPARE(QProcess::execute(qpdf, {"--encrypt", "reader", "owner-test", "256", "--", + QFINDTESTDATA("fixtures/pdf/headings/form-only-structure.pdf"), path}), 0); + QFile file(path); QVERIFY(file.open(QIODevice::ReadOnly)); + const auto source = file.readAll(); PdfDocument document; QString code, error; + QVERIFY(!document.openFile(&file, "wrong", &code, &error)); QCOMPARE(code, "E_PASSWORD_INVALID"); + QVERIFY2(document.openFile(&file, "reader", &code, &error), qPrintable(error)); + const auto result = document.headings(0); QVERIFY(!result.value("structureLimited").toBool()); + const auto items = result.value("headings").toArray(); QCOMPARE(items.size(), 1); + QCOMPARE(items.first().toMap().value("title").toString(), "Form only heading"); + QCOMPARE(items.first().toMap().value("precision").toString(), "exact"); + QVERIFY(items.first().toMap().value("y").toDouble() > 130); + document.close(); QVERIFY(file.isOpen()); QVERIFY(file.seek(0)); QCOMPARE(file.readAll(), source); + } + void failedMetadataPageWalkCannotShiftPageIndices() { + QFile source(QFINDTESTDATA("fixtures/pdf/headings/context-deep-tree.pdf")); QVERIFY(source.open(QIODevice::ReadOnly)); + PdfMetadataContext context; QString code, error; QVERIFY(context.open(&source, {}, 2, &code, &error)); + for (int retry=0;retry<3;++retry) { + context.beginOperation(); bool rejected=false; + try { auto page=context.page(0); Q_UNUSED(page); } catch(const PdfMetadataLimit &) { rejected=true; } + QVERIFY(rejected); // The valid second page must never become page zero. + } + } + void malformedMetadataPageTreeIsNotRepairedBeforeValidation() { + QFile source(QFINDTESTDATA("fixtures/pdf/missing-first-page.pdf")); QVERIFY(source.open(QIODevice::ReadOnly)); + const auto original = source.readAll(); + PdfMetadataContext context; QString code, error; QVERIFY(context.open(&source, {}, 2, &code, &error)); + // Setting qpdf's warning limit before processInputSource implicitly + // repairs this tree in qpdf 12.4.1. The raw null must remain visible. + auto kids = context.root().getKey("/Pages").getKey("/Kids"); + QCOMPARE(kids.getArrayNItems(), 2); QVERIFY(kids.getArrayItem(0).isNull()); + for (int retry=0;retry<3;++retry) { + context.beginOperation(); bool rejected=false; + try { auto page=context.page(0); Q_UNUSED(page); } catch(const PdfMetadataInvalid &) { rejected=true; } + QVERIFY(rejected); + } + QVERIFY(source.seek(0)); QCOMPARE(source.readAll(), original); + } + void metadataWarningsRemainBounded() { + for (int count : {32, 33}) { + QFile source(QFINDTESTDATA(qPrintable(QString("fixtures/pdf/headings/context-warnings-%1.pdf").arg(count)))); + QVERIFY(source.open(QIODevice::ReadOnly)); + PdfMetadataContext context; QString code, error; + QVERIFY(context.open(&source, {}, 1, &code, &error)); + auto probes = context.root().getKey("/Probe"); + bool limited = false; + try { + for (int i=0; i("name"); QTest::addColumn("precision"); + QTest::newRow("form-only") << "form-only-structure.pdf" << "exact"; + QTest::newRow("nested") << "nested-form-matrix.pdf" << "exact"; + QTest::newRow("ambiguous") << "ambiguous-form-matrix.pdf" << "page"; + QTest::newRow("repeated") << "repeated-form.pdf" << "page"; + QTest::newRow("quota") << "stream-quota-over-limit.pdf" << "limited"; + QTest::newRow("partial-response") << "bounded-heading-response.pdf" << "partial"; + } + void sandboxedFormStructure() { + QFETCH(QString, name); QFETCH(QString, precision); + const auto directory = QFileInfo(QFINDTESTDATA("fixtures/pdf/headings/manifest.json")).absoluteDir(); + QFile source(directory.filePath(name)); QVERIFY(source.open(QIODevice::ReadOnly)); + const auto before = QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256); + FontBroker fonts; WorkerProcess worker("structure-worker-test", 1); + worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function done) { fonts.request(op, payload, std::move(done)); }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker", {"--source", source.fileName()})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + QList replies; + worker.request("open", {}, [&](const QCborMap &reply) { replies.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); QVERIFY2(!replies.takeFirst().contains(QStringLiteral("error")), "open failed"); + worker.request("headings", {{QStringLiteral("page"), 0}}, [&](const QCborMap &reply) { replies.append(reply); }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 10000); + const auto reply = replies.takeFirst(); QVERIFY(!reply.contains(QStringLiteral("error"))); + const auto result = reply.value("result").toMap(); + if (precision == "limited" || precision == "partial") { + QVERIFY(result.value("structureLimited").toBool()); QVERIFY(result.value("truncated").toBool()); + QVERIFY(!result.value("complete").toBool()); QVERIFY(QCborValue(result).toCbor().size()<=512*1024); + if (precision == "partial") QVERIFY(!result.value("headings").toArray().isEmpty()); + } + else { + QVERIFY(!result.value("structureLimited").toBool()); const auto headings = result.value("headings").toArray(); QVERIFY(!headings.isEmpty()); + for (const auto &value : headings) { const auto heading=value.toMap(); QCOMPARE(heading.value("precision").toString(), precision); QCOMPARE(heading.value("page").toInteger(), 0); } + } + worker.stop(); fonts.revoke(); QVERIFY(failed.isEmpty()); + QVERIFY(source.seek(0)); QCOMPARE(QCryptographicHash::hash(source.readAll(), QCryptographicHash::Sha256), before); + } + void cancellingStructureWorkDiscardsItsCallback() { + FontBroker fonts; WorkerProcess worker("structure-cancel-test", 1); + worker.setFontRequestHandler([&](const QString &op, const QCborMap &payload, std::function done) { fonts.request(op, payload, std::move(done)); }); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath()+"/docview-pdf-worker", + {"--source", QFINDTESTDATA("fixtures/pdf/headings/stream-quota-at-limit.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + bool opened=false; + worker.request("open", {}, [&](const QCborMap &reply) { opened=!reply.contains(QStringLiteral("error")); }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); + bool delivered=false; QElapsedTimer elapsed; elapsed.start(); + worker.request("headings", {{QStringLiteral("page"), 0}}, [&](const QCborMap &) { delivered=true; }); + QVERIFY(!worker.idle()); + QTimer::singleShot(1, &worker, [&] { worker.stop(); fonts.revoke(); }); + QTRY_VERIFY_WITH_TIMEOUT(!worker.isConnected(), 1000); + QVERIFY(elapsed.elapsed()<1000); QTest::qWait(100); QVERIFY(!delivered); QVERIFY(failed.isEmpty()); + } +}; +QTEST_GUILESS_MAIN(PdfTest) +#include "test_pdf.moc" diff --git a/tests/test_pdf_canvas.cpp b/tests/test_pdf_canvas.cpp new file mode 100644 index 0000000..55af9dc --- /dev/null +++ b/tests/test_pdf_canvas.cpp @@ -0,0 +1,509 @@ +#include +#include +#include +#include "app/pdf_canvas.h" +#include "broker/font_broker.h" +#include +#include +#include +#include +#include "pdf/pdf_document.h" +#include "font_test_fixture.h" +#include +#include +using namespace docview; +static void provideFonts(WorkerProcess &worker, FontBroker &broker) { + worker.setFontRequestHandler([&broker](const QString &operation, const QCborMap &payload, + std::function completion) { + broker.request(operation, payload, std::move(completion)); + }); +} +class PdfCanvasTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { + qInfo().noquote() << "Runtime platform:" << QGuiApplication::platformName(); + QCOMPARE(QGuiApplication::platformName(), qEnvironmentVariable("QT_QPA_PLATFORM").section(':', 0, 0)); + } + void sceneGraphPresentsPdfPixelsAndReplacesOldPage() { + FontBroker broker; WorkerProcess worker("canvas-scene-graph", 1); provideFonts(worker, broker); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", + {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !failed.empty(), 10000); QVERIFY(failed.empty()); + QCborMap metadata; bool opened = false; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); + QCOMPARE(metadata.value("pageCount").toInteger(), 2); + QQuickWindow window; window.resize(512, 512); + PdfCanvas canvas(window.contentItem()); canvas.setWidth(512); canvas.setHeight(512); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); + canvas.setZoom(200); canvas.setPrefetchPages(0); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + qInfo() << "Scene graph graphics API:" << window.rendererInterface()->graphicsApi(); + const auto pixel = [&](const QImage &image, QPointF pdfPoint) { + const auto point = canvas.pageToCanvas(0, pdfPoint) * window.devicePixelRatio(); + const QPoint physical(qRound(point.x()), qRound(point.y())); + return image.rect().contains(physical) ? image.pixelColor(physical) : QColor(); + }; + const auto red = [](QColor color) { return color.red() > 220 && color.green() < 40 && color.blue() < 40; }; + QImage capture; + const auto firstPageVisible = [&] { + capture = window.grabWindow(); + return !capture.isNull() && red(pixel(capture, {140, 75})) && + pixel(capture, {60, 70}).lightness() < 40 && pixel(capture, {90, 210}).lightness() > 220; + }; + // The fixture has a black rectangle and a saved red widget appearance. + // Read pixels from the actual window, rather than calling paint() directly. + QTRY_VERIFY_WITH_TIMEOUT(firstPageVisible(), 5000); + QCOMPARE(capture.size(), QSize(qRound(window.width() * window.devicePixelRatio()), + qRound(window.height() * window.devicePixelRatio()))); + canvas.goTo(1); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QCOMPARE(canvas.currentPage(), 1); + const auto secondPageVisible = [&] { + capture = window.grabWindow(); + if (capture.isNull()) return false; + int dark = 0; + for (int y = 0; y < capture.height(); ++y) for (int x = 0; x < capture.width(); ++x) { + const auto color = capture.pixelColor(x, y); + if (red(color)) return false; + if (color.red() < 12 && color.green() < 12 && color.blue() < 12) ++dark; + } + return dark > 20; + }; + QTRY_VERIFY_WITH_TIMEOUT(secondPageVisible(), 5000); + canvas.goTo(0); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QTRY_VERIFY_WITH_TIMEOUT(firstPageVisible(), 5000); + canvas.setDocument(nullptr, {}, 0); + const auto closedVisible = [&] { + capture = window.grabWindow(); + return !capture.isNull() && capture.pixelColor(256, 256) == canvas.backgroundColor(); + }; + QTRY_VERIFY_WITH_TIMEOUT(closedVisible(), 5000); + worker.stop(); QVERIFY(failed.empty()); + } + void memoryPressureStagesRetainLocationAndReleaseInvisiblePixels() { + const QString source = QFINDTESTDATA("fixtures/pdf/navigation.pdf"); + const auto hash = [&] { QFile file(source); if (!file.open(QIODevice::ReadOnly)) return QByteArray(); + return QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); }; + const auto originalHash = hash(); QVERIFY(!originalHash.isEmpty()); + FontBroker broker; WorkerProcess worker("canvas-memory-pressure", 1); provideFonts(worker, broker); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", source})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !failed.empty(), 10000); QVERIFY(failed.empty()); + QCborMap metadata; bool opened = false; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + const auto location = canvas.location(); const auto normalCost = canvas.cacheCostBytes(); + const auto paint = [&] { QImage image(250, 180, QImage::Format_ARGB32); image.fill(Qt::transparent); + QPainter painter(&image); canvas.paint(&painter); return image; }; + const auto normalPixels = paint(); + bool interrupted = false; + auto interrupt = connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool prefetch, qint64) { + if (prefetch && !interrupted) { + interrupted = true; + QTimer::singleShot(0, &canvas, [&] { canvas.setMemoryPressureLevel(1); }); + } + }); + canvas.setPrefetchPages(1); + QTRY_VERIFY_WITH_TIMEOUT(interrupted && canvas.memoryPressureLevel() == 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + disconnect(interrupt); + QVERIFY(canvas.staleRenderResponseCount() > 0); + const auto prefetchCount = canvas.prefetchRenderRequestCount(); QTest::qWait(80); + QCOMPARE(canvas.prefetchRenderRequestCount(), prefetchCount); + QCOMPARE(canvas.location(), location); QCOMPARE(paint(), normalPixels); + canvas.setMemoryPressureLevel(0); + QTRY_VERIFY_WITH_TIMEOUT(canvas.cacheCostBytes() > normalCost && worker.idle(), 10000); + canvas.setMemoryPressureLevel(2); + QCOMPARE(canvas.cacheCostBytes(), normalCost); QVERIFY(canvas.viewportReady()); + QCOMPARE(canvas.location(), location); QCOMPARE(paint(), normalPixels); + canvas.setMemoryPressureLevel(3); + QCOMPARE(canvas.rasterResolutionScale(), .5); QCOMPARE(canvas.zoom(), 300.); + QCOMPARE(canvas.location(), location); QVERIFY(!canvas.viewportReady()); + QCOMPARE(paint(), normalPixels); // Old visible pixels survive until replacement is ready. + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QVERIFY(canvas.cacheCostBytes() < normalCost); QCOMPARE(canvas.location(), location); + canvas.setPrefetchPages(0); canvas.setMemoryPressureLevel(0); + QCOMPARE(canvas.rasterResolutionScale(), 1.); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QCOMPARE(paint(), normalPixels); QCOMPARE(canvas.location(), location); + + // Recovery while reduced work is active cannot admit its stale pixels. + canvas.setZoom(400); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + const auto restoredPixels = paint(); + const auto staleBefore = canvas.staleRenderResponseCount(); bool recovered = false; + auto recover = connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool, qint64) { + if (canvas.memoryPressureLevel() == 3 && !recovered) { + recovered = true; QTimer::singleShot(0, &canvas, [&] { canvas.setMemoryPressureLevel(0); }); + } + }); + canvas.setMemoryPressureLevel(3); + QTRY_VERIFY_WITH_TIMEOUT(recovered && canvas.memoryPressureLevel() == 0 && worker.idle(), 10000); + disconnect(recover); + QVERIFY(canvas.staleRenderResponseCount() > staleBefore); QVERIFY(canvas.viewportReady()); + QCOMPARE(paint(), restoredPixels); + + canvas.setMemoryPressureLevel(4); const auto renders = canvas.visibleRenderRequestCount(); + canvas.goTo(1); QTest::qWait(80); QCOMPARE(canvas.visibleRenderRequestCount(), renders); + QVERIFY(!canvas.viewportReady()); QCOMPARE(canvas.pendingRenderCount(), 0); + canvas.setDocument(nullptr, {}, 0); QCOMPARE(canvas.cacheCostBytes(), 0); + QCOMPARE(canvas.memoryPressureLevel(), 4); // Global pressure survives document replacement. + canvas.setMemoryPressureLevel(0); + QCOMPARE(hash(), originalHash); QVERIFY(failed.empty()); worker.stop(); + } + void reducedRasterHonorsWorkerMinimumWithoutMovingGeometry() { + PdfCanvas canvas; canvas.setWidth(100); canvas.setHeight(100); + canvas.setDocument(nullptr, {QVariantMap{{"pageIndex", 0}, {"width", 100000.}, {"height", 100000.}}}, 1); + canvas.fitWidth(); QCOMPARE(canvas.zoom(), 5.); + const auto location = canvas.location(); const auto point = canvas.pageToCanvas(0, {50., 70.}); + canvas.setMemoryPressureLevel(3); + QCOMPARE(canvas.rasterResolutionScale(), 1.); // Physical scale must remain at least .05. + QCOMPARE(canvas.location(), location); QCOMPARE(canvas.pageToCanvas(0, {50., 70.}), point); + canvas.setMemoryPressureLevel(-1); QCOMPARE(canvas.memoryPressureLevel(), 0); + canvas.setMemoryPressureLevel(10); QCOMPARE(canvas.memoryPressureLevel(), 4); + } + void currentPageUsesCentreAndReadingLocationUsesLeadingEdge() { + QVariantList pages; + for (int i = 0; i < 5; ++i) + pages.append(QVariantMap{{"pageIndex", i}, {"width", 100.}, {"height", 100.}, + {"cropBox", QVariantList{0., 0., 100., 100.}}, {"rotation", 0}, {"label", QString::number(i + 1)}}); + PdfCanvas canvas; canvas.setWidth(132); canvas.setHeight(180); + canvas.setDocument(nullptr, pages, 5); canvas.setZoom(100); + canvas.goTo(0); + QCOMPARE(canvas.currentPage(), 0); + canvas.scroll(0, 34); // Centre 124: equidistant from page 0 bottom 116 and page 1 top 132. + QCOMPARE(canvas.currentPage(), 0); + QCOMPARE(canvas.location().value("pageIndex").toInt(), 0); + canvas.scroll(0, .25); + QCOMPARE(canvas.currentPage(), 1); + QCOMPARE(canvas.location().value("pageIndex").toInt(), 0); + canvas.scroll(0, 82); + QCOMPARE(canvas.currentPage(), 1); + QCOMPARE(canvas.location().value("pageIndex").toInt(), 1); + } + void zoomPreservesViewportCentre() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + PdfCanvas canvas; canvas.setWidth(280); canvas.setHeight(200); + canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); + canvas.setZoom(300); canvas.goTo(0); canvas.scroll(80, 150); + const int page = canvas.currentPage(); + const QPointF centre(canvas.width() / 2, canvas.height() / 2); + const auto point = canvas.canvasToPage(page, centre); + canvas.setZoom(400); + const auto after = canvas.canvasToPage(page, centre); + QVERIFY(QLineF(point, after).length() < .001); + } + void transformsAgreeWithPdfium() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + PdfCanvas canvas; canvas.setWidth(600); canvas.setHeight(400); + canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); + canvas.setZoom(200); + for (int rotation : {0, 90, 180, 270}) { + for (int page : {0, 1}) { + canvas.goToLocation({{"pageIndex", page}, {"rotation", rotation}, {"fitMode", "percent"}, {"zoom", 200.0}}); + const QRectF crop = page == 0 ? QRectF(20, 30, 200, 200) : QRectF(0, 0, 300, 200); + const auto corners = {crop.topLeft(), crop.topRight(), crop.bottomLeft(), crop.bottomRight()}; + double left = 1e9, top = 1e9; + for (auto corner : corners) { auto p = canvas.pageToCanvas(page, corner); left = std::min(left, p.x()); top = std::min(top, p.y()); } + for (const QPointF point : {crop.topLeft(), crop.center(), crop.bottomRight(), QPointF(55, 85)}) { + auto actual = canvas.pageToCanvas(page, point); + QCborMap request{{"page", page}, {"scale", 2.0}, {"rotation", rotation}, {"x", point.x()}, {"y", point.y()}}; + auto expected = document.mapPoint(request, &code, &error); + QVERIFY(code.isEmpty()); + QVERIFY(std::abs(actual.x() - left - expected.value("x").toDouble()) < 1.01); + QVERIFY(std::abs(actual.y() - top - expected.value("y").toDouble()) < 1.01); + auto original = canvas.canvasToPage(page, actual); + QVERIFY(std::abs(original.x() - point.x()) < .0001); + QVERIFY(std::abs(original.y() - point.y()) < .0001); + } + } + } + } + void pointLocationRestoresAfterZoomAndPan() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); + canvas.setZoom(300); canvas.scroll(80, 90); + const auto saved = canvas.location(); + QVERIFY(saved.contains("xPt")); QVERIFY(saved.contains("yPt")); QCOMPARE(saved.value("pageLabel").toString(), "i"); + canvas.goTo(1); canvas.setZoom(100); canvas.goToLocation(saved); + const auto restored = canvas.location(); + QCOMPARE(restored.value("pageIndex"), saved.value("pageIndex")); + QVERIFY(std::abs(restored.value("xPt").toDouble() - saved.value("xPt").toDouble()) < .001); + QVERIFY(std::abs(restored.value("yPt").toDouble() - saved.value("yPt").toDouble()) < .001); + QCOMPARE(canvas.zoom(), 300.0); + } + void headingTargetsAndInvalidCoordinates() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); + canvas.setZoom(300); + const auto headings = document.headings(0).value("headings").toArray(); + auto first = headings[0].toMap().toVariantMap(), second = headings[1].toMap().toVariantMap(); + canvas.goToLocation(first); const double firstOffset = canvas.offset(); + auto target = canvas.pageToCanvas(0, QPointF(first.value("x").toDouble(), first.value("y").toDouble())); + QVERIFY(std::abs(target.y() - 16) < .001); + canvas.goToLocation(second); QVERIFY(canvas.offset() > firstOffset + 100); + QSignalSpy errors(&canvas, &PdfCanvas::renderFailed); + canvas.goToLocation({{"pageIndex", -1}}); QCOMPARE(errors.size(), 1); + canvas.goToLocation({{"pageIndex", 0}, {"xPt", 20.0}, {"yPt", 10000.0}}); QCOMPARE(errors.size(), 2); + auto clipped = canvas.location(); QVERIFY(clipped.value("yPt").toDouble() <= 230.0); + } + void searchJumpsToTextAndZoomIsBounded() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(nullptr, document.metadata().value("pages").toArray().toVariantList(), 2); + canvas.setZoom(100000); QCOMPARE(canvas.zoom(), 800.0); + canvas.setZoom(-10); QCOMPARE(canvas.zoom(), 25.0); + canvas.setZoom(300); canvas.setZoom(std::numeric_limits::infinity()); QCOMPARE(canvas.zoom(), 300.0); + auto match = document.search("Needle", 0).value("matches").toArray()[0].toMap().toVariantMap(); + canvas.showSearchResult(match); + auto rect = match.value("rects").toList()[0].toList(); + const auto target = canvas.pageToCanvas(0, QPointF(rect[0].toDouble(), rect[3].toDouble())); + QVERIFY(std::abs(target.y() - 16) < .001); + QVERIFY(!canvas.viewportReady()); // no worker and no raster pixels yet + canvas.showSearchResult({}); + } + void prefetchIsOptionalAndWarmsOnlyAdjacentViewport() { + FontBroker broker; + WorkerProcess worker("canvas-prefetch-test", 1); + provideFonts(worker, broker); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + bool opened = false; QCborMap metadata; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 2); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); + QCOMPARE(canvas.cacheBudgetBytes(), 256ll * 1024 * 1024); + canvas.setCacheBudget(-1); QCOMPARE(canvas.cacheBudgetBytes(), 64ll * 1024 * 1024); + canvas.setCacheBudget(1024); QCOMPARE(canvas.cacheBudgetBytes(), 512ll * 1024 * 1024); + canvas.setCacheBudget(64); + QCOMPARE(canvas.cacheCostBytes(), 0); QCOMPARE(canvas.cacheEntryCount(), 0); + QSignalSpy requests(&canvas, &PdfCanvas::tileRequested); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QVERIFY(canvas.cacheCostBytes() > 0); QVERIFY(canvas.cacheEntryCount() > 0); + QVERIFY(canvas.cacheCostBytes() <= canvas.cacheBudgetBytes()); + QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(0)); + QCOMPARE(canvas.visibleRenderRequestCount(), quint64(requests.size())); + const auto visibleRequests = canvas.visibleRenderRequestCount(); + for (int check = 0; check < 100; ++check) QVERIFY(canvas.viewportReady()); + QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); + for (const auto &request : requests) { QCOMPARE(request[0].toInt(), 0); QVERIFY(!request[1].toBool()); } + const int disabledCount = requests.size(); QTest::qWait(80); QCOMPARE(requests.size(), disabledCount); + canvas.setPrefetchPages(1); + QTRY_VERIFY_WITH_TIMEOUT(requests.size() > disabledCount, 10000); + QTRY_VERIFY_WITH_TIMEOUT(worker.idle(), 10000); QTest::qWait(80); + int prefetched = 0; + for (const auto &request : requests) if (request[1].toBool()) { QCOMPARE(request[0].toInt(), 1); ++prefetched; } + QVERIFY(prefetched > 0 && prefetched <= 8); QVERIFY(canvas.viewportReady()); + QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(prefetched)); + QVERIFY(canvas.cacheCostBytes() <= canvas.cacheBudgetBytes()); + canvas.setPrefetchPages(0); const int beforeJump = requests.size(); canvas.goTo(1); + QVERIFY(canvas.viewportReady()); // adjacent visible strip was already cached + QTest::qWait(80); + for (int i = beforeJump; i < requests.size(); ++i) QVERIFY2(requests[i][1].toBool(), "A visible render was needed despite the prefetched page strip"); + QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); + QCOMPARE(canvas.staleRenderResponseCount(), quint64(0)); + canvas.setDocument(nullptr, {}, 0); + QCOMPARE(canvas.cacheCostBytes(), 0); QCOMPARE(canvas.cacheEntryCount(), 0); + QCOMPARE(canvas.visibleRenderRequestCount(), visibleRequests); // counters are lifetime samples + worker.stop(); + } + void zoomDiscardsInFlightPrefetchAndKeepsOldVisiblePixels() { + FontBroker broker; + WorkerProcess worker("canvas-prefetch-cancel", 1); + provideFonts(worker, broker); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", QFINDTESTDATA("fixtures/pdf/navigation.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + bool opened = false; QCborMap metadata; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 2); canvas.setZoom(300); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + bool changed = false; qint64 prefetchRevision = -1; QSignalSpy requests(&canvas, &PdfCanvas::tileRequested); + connect(&canvas, &PdfCanvas::tileRequested, &canvas, [&](int, bool prefetch, qint64 revision) { + if (!prefetch || changed) return; + prefetchRevision = revision; + QTimer::singleShot(0, &canvas, [&] { changed = true; canvas.setPrefetchPages(0); canvas.setZoom(200); }); + }); + canvas.setPrefetchPages(1); + QTRY_VERIFY_WITH_TIMEOUT(changed, 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QCOMPARE(canvas.zoom(), 200.0); QVERIFY(prefetchRevision >= 0); + QCOMPARE(canvas.prefetchRenderRequestCount(), quint64(1)); + QCOMPARE(canvas.staleRenderResponseCount(), quint64(1)); + bool currentRender = false; + for (const auto &request : requests) if (!request[1].toBool() && request[2].toLongLong() > prefetchRevision) currentRender = true; + QVERIFY(currentRender); + canvas.setZoom(300); QVERIFY(canvas.viewportReady()); // earlier visible resolution retained + canvas.goTo(1); QVERIFY(!canvas.viewportReady()); // stale prefetch was not admitted + worker.stop(); + } + void lateFontReplyRespectsDocumentAndWorkerLifetime_data() { + QTest::addColumn("mode"); + QTest::newRow("visible-fatal") << 0; + QTest::newRow("prefetch-fatal") << 1; + QTest::newRow("old-revision-fatal") << 2; + QTest::newRow("old-document-ignored") << 3; + QTest::newRow("prefetch-warning") << 4; + } + void lateFontReplyRespectsDocumentAndWorkerLifetime() { + QFETCH(int, mode); + QTemporaryDir directory; QFile file(directory.filePath("late-font.pdf")); + QVERIFY(file.open(QIODevice::WriteOnly)); QVERIFY(file.write(deferredFontPdf()) > 0); file.close(); + WorkerProcess worker("canvas-late-font", 1); + std::function held; int maps = 0; + worker.setFontRequestHandler([&](const QString &operation, const QCborMap &, std::function done) { + QCOMPARE(operation, "font.map"); ++maps; + if (mode == 4) done({{"found", false}}); else held = std::move(done); + }); + QSignalSpy ready(&worker, &WorkerProcess::ready), crashed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", {"--source", file.fileName()})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.empty() || !crashed.empty(), 10000); QVERIFY(crashed.empty()); + QCborMap metadata; bool opened = false; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 257); QCOMPARE(maps, 0); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); canvas.setPrefetchPages(0); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 257); + canvas.updatePages({QVariantMap{{"pageIndex", 256}, {"width", 595.0}, {"height", 842.0}}}); + canvas.setZoom(100); canvas.goTo(255); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + const auto cached = canvas.cacheCostBytes(); QVERIFY(cached > 0); + QSignalSpy fatal(&canvas, &PdfCanvas::fatalWorkerError), warnings(&canvas, &PdfCanvas::documentWarning); + connect(&canvas, &PdfCanvas::fatalWorkerError, &worker, [&](const QString &, const QString &) { worker.stop(); }); + if (mode == 0) canvas.goTo(256); else canvas.setPrefetchPages(1); + if (mode == 4) { + QTRY_VERIFY_WITH_TIMEOUT(!warnings.empty(), 10000); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + QVERIFY(fatal.empty()); QVERIFY(maps > 0); + } else { + QTRY_VERIFY_WITH_TIMEOUT(bool(held), 10000); + if (mode == 2) canvas.setZoom(200); + if (mode == 3) canvas.setDocument(nullptr, {}, 0); + held({{"error", QCborMap{{"code", "E_FONT_FAILED"}, {"message", "late font fixture failure"}}}}); + if (mode == 3) { + QTRY_VERIFY_WITH_TIMEOUT(worker.idle(), 10000); QVERIFY(fatal.empty()); QVERIFY(canvas.staleRenderResponseCount() > 0); + } else { + QTRY_COMPARE_WITH_TIMEOUT(fatal.size(), 1, 10000); + QCOMPARE(fatal[0][0].toString(), "E_FONT_FAILED"); + QVERIFY(canvas.cacheCostBytes() >= cached); // Existing pixels survive a fatal fetch. + if (mode == 1) QVERIFY(canvas.viewportReady()); + } + } + worker.stop(); QVERIFY(crashed.empty()); + } + void latePageMetadataRetainsDestination() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + auto pages = document.metadata().value("pages").toArray().toVariantList(); + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(nullptr, {pages[0]}, 2); canvas.setZoom(300); + QSignalSpy needed(&canvas, &PdfCanvas::pageNeeded); + canvas.goToLocation({{"pageIndex", 1}, {"xPt", 50.0}, {"yPt", 100.0}}); + QVERIFY(!needed.isEmpty()); canvas.updatePages({pages[1]}); + QCOMPARE(canvas.currentPage(), 1); + auto point = canvas.pageToCanvas(1, QPointF(50, 100)); QVERIFY(std::abs(point.y() - 16) < .001); + } + void latePageMetadataCannotRestoreCancelledJump() { + PdfDocument document; QString code, error; + QVERIFY(document.open(QFINDTESTDATA("fixtures/pdf/navigation.pdf"), {}, &code, &error)); + const auto pages = document.metadata().value("pages").toArray().toVariantList(); + for (int action = 0; action < 4; ++action) { + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + canvas.setDocument(nullptr, {pages[0]}, 2); canvas.setZoom(300); + canvas.goToLocation({{"pageIndex", 1}, {"xPt", 50.0}, {"yPt", 100.0}, {"zoom", 400.0}}); + if (action == 0) canvas.goTo(0); + if (action == 1) canvas.goToLocation({{"pageIndex", 0}, {"xPt", 40.0}, {"yPt", 180.0}}); + if (action == 2) canvas.setZoom(200); + if (action == 3) canvas.cancelPendingNavigation(); + canvas.updatePages({pages[1]}); + QCOMPARE(canvas.zoom(), action == 2 ? 200.0 : 300.0); + if (action < 2) QCOMPARE(canvas.currentPage(), 0); + } + } + void annotationFlagsHitTargetsMatchRenderedPixels() { + FontBroker broker; WorkerProcess worker("canvas-annotation-flags", 1); provideFonts(worker, broker); + QSignalSpy ready(&worker, &WorkerProcess::ready), failed(&worker, &WorkerProcess::failed); + QVERIFY(worker.start(QCoreApplication::applicationDirPath() + "/docview-pdf-worker", + {"--source", QFINDTESTDATA("fixtures/pdf/annotation-flags/flags.pdf")})); + QTRY_VERIFY_WITH_TIMEOUT(!ready.isEmpty() || !failed.isEmpty(), 10000); QVERIFY(failed.isEmpty()); + bool opened = false; QCborMap metadata; + worker.request("open", {}, [&](const QCborMap &reply) { metadata = reply.value("result").toMap(); opened = true; }); + QTRY_VERIFY_WITH_TIMEOUT(opened, 10000); QCOMPARE(metadata.value("pageCount").toInteger(), 38); + QQuickWindow window; window.resize(512, 240); + PdfCanvas canvas(window.contentItem()); canvas.setWidth(512); canvas.setHeight(240); + canvas.setDocument(&worker, metadata.value("pages").toArray().toVariantList(), 38); + QSignalSpy links(&canvas, &PdfCanvas::externalLinkRequested), comments(&canvas, &PdfCanvas::annotationRequested); + for (int page : {12, 15, 17, 18, 20, 23, 24, 27, 29, 30, 32, 35}) { + QCborMap details; bool received = false; + worker.request("links", {{"page", page}}, [&](const QCborMap &reply) { details = reply.value("result").toMap(); received = true; }); + QTRY_VERIFY_WITH_TIMEOUT(received, 10000); + const auto targets = details.value(page < 24 ? QStringLiteral("links") : QStringLiteral("annotations")).toArray(); + QVERIFY(!targets.isEmpty()); const auto target = targets.first().toMap().toVariantMap(); + for (int rotation : {0, 90, 180, 270}) for (double zoom : {200., 400.}) for (int pressure : {0, 3}) { + canvas.setMemoryPressureLevel(pressure); + canvas.goToLocation({{"pageIndex", page}, {"viewRotation", rotation}, {"fitMode", "percent"}, {"zoom", zoom}}); + // Keep the annotation in the viewport even at 400%. + const auto center = canvas.annotationRectToCanvas(page, target).center(); + canvas.scroll(center.x() - 256, center.y() - 120); + QTRY_VERIFY_WITH_TIMEOUT(canvas.viewportReady() && worker.idle(), 10000); + const auto expected = canvas.annotationRectToCanvas(page, target); + QImage image(512, 240, QImage::Format_ARGB32); image.fill(Qt::transparent); + QPainter painter(&image); canvas.paint(&painter); painter.end(); + QRect colored; QPoint click; int count = 0; + const auto clip = expected.adjusted(-3, -3, 3, 3).toAlignedRect().intersected(image.rect()); + for (int y = clip.top(); y <= clip.bottom(); ++y) for (int x = clip.left(); x <= clip.right(); ++x) { + const auto color = image.pixelColor(x, y); + if ((color.red() > 220 && color.green() < 50 && color.blue() < 50) || + (color.blue() > 220 && color.green() < 50 && color.red() < 50)) { + colored = colored.united(QRect(x, y, 1, 1)); click = {x, y}; ++count; + } + } + QVERIFY2(count > 30, qPrintable(QString("No saved appearance at page %1 rotation %2 zoom %3 DPR %4").arg(page).arg(rotation).arg(zoom).arg(window.devicePixelRatio()))); + QVERIFY(std::abs(colored.left() - expected.left()) <= 2); + QVERIFY(std::abs(colored.top() - expected.top()) <= 2); + QVERIFY(std::abs(colored.right() + 1 - expected.right()) <= 2); + QVERIFY(std::abs(colored.bottom() + 1 - expected.bottom()) <= 2); + const int beforeLinks = links.size(), beforeComments = comments.size(); + QMouseEvent event(QEvent::MouseButtonRelease, QPointF(click), QPointF(click), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(&canvas, &event); + QCOMPARE(links.size(), beforeLinks + (page < 24)); + QCOMPARE(comments.size(), beforeComments + (page >= 24)); + } + } + worker.stop(); + } + void backgroundThemePreservesWhitePage() { + PdfCanvas canvas; canvas.setWidth(250); canvas.setHeight(180); + const QColor background("#e0e5e8"); canvas.setBackgroundColor(background); + canvas.setDocument(nullptr, {QVariantMap{{"pageIndex", 0}, {"width", 100.0}, {"height", 100.0}}}, 1); + QImage image(250, 180, QImage::Format_ARGB32); image.fill(Qt::transparent); + QPainter painter(&image); canvas.paint(&painter); painter.end(); + QCOMPARE(image.pixelColor(0, 0), background); + QCOMPARE(image.pixelColor(200, 150), QColor(Qt::white)); + canvas.setBackgroundColor({}); QCOMPARE(canvas.backgroundColor(), background); + } +}; +int main(int argc, char **argv) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) qputenv("QT_QPA_PLATFORM", "offscreen"); + QGuiApplication application(argc, argv); PdfCanvasTest test; return QTest::qExec(&test, argc, argv); +} +#include "test_pdf_canvas.moc" diff --git a/tests/test_pdfium_candidate_integration.py b/tests/test_pdfium_candidate_integration.py new file mode 100644 index 0000000..1bb7e21 --- /dev/null +++ b/tests/test_pdfium_candidate_integration.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Synthetic trust-boundary/CMake/deb tests; never adopts a real PDFium candidate.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import verify_pdfium_candidate as verifier +import verify_ubuntu_package as deb +from package_ubuntu import MANIFEST + + +def write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return {'sha256': verifier.digest(data), 'size': len(data)} + + +def encoded(data): + return (json.dumps(data, sort_keys=True, indent=2) + '\n').encode() + + +class CandidateIntegrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='docview candidate integration ') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.repo, self.prefix = self.base / 'repo', self.base / 'prefix' + self.original, self.library = b'provider-library', b'reviewed-candidate-library' + self.source = {'schemaVersion': 1, 'pdfiumVersion': '155.0.8057.0', + 'pdfiumUpstreamCommit': 'a' * 40, 'pdfiumLibrarySha256': verifier.digest(self.original), + 'scope': 'Synthetic original provider', 'files': []} + for name in ('libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'): + text = ('License ' + name).encode() + row = write(self.repo / 'resources/licenses/pdfium-supplemental' / name, text) + self.source['files'].append({'name': name, **row}) + self.source_raw = encoded(self.source) + write(self.repo / 'resources/licenses/pdfium-supplemental/sources.json', self.source_raw) + write(self.repo / 'cmake/pdfium.lock.json', encoded({'version': '155.0.8057.0', 'upstreamCommit': 'a' * 40})) + self.files = { + 'lib/libpdfium.so': self.library, 'include/fpdfview.h': b'fixed public header', + 'LICENSE': b'provider license', 'VERSION': b'fixed VERSION', 'args.gn': b'pdf_enable_v8=false\n', + 'provenance/master6.json': b'fixed immutable record', + 'provenance/rendering-intent.patch': b'fixed v2 patch', + 'provenance/supplemental-notices.json': self.source_raw} + for row in self.source['files']: + self.files['licenses/' + row['name']] = (self.repo / 'resources/licenses/pdfium-supplemental' / row['name']).read_bytes() + self.info = {'schemaVersion': 1, 'candidateOnly': True, 'sourceRevision': 'a' * 40, + 'librarySha256': verifier.digest(self.library), + 'patchSha256': verifier.digest(self.files['provenance/rendering-intent.patch']), + 'gnArgsSha256': verifier.digest(self.files['args.gn']), + 'files': {name: {'bytes': len(data), 'sha256': verifier.digest(data)} for name, data in self.files.items()}} + self.raw = encoded(self.info) + self.lock = {'schemaVersion': 1, 'candidateId': 'reviewed-v2', 'sourceRevision': 'a' * 40, + 'librarySha256': self.info['librarySha256'], 'buildInfoSha256': verifier.digest(self.raw), + 'anchorRecordPath': 'provenance/master6.json', + 'anchorRecordSha256': verifier.digest(self.files['provenance/master6.json']), + 'patchSha256': self.info['patchSha256'], 'gnArgsSha256': self.info['gnArgsSha256'], + 'supplementalSourceSha256': verifier.digest(self.source_raw)} + write(self.repo / verifier.LOCK, encoded(self.lock)) + for name, data in {**self.files, 'BUILDINFO.json': self.raw}.items(): write(self.prefix / name, data) + + def verify(self): + return verifier.verify_prefix(self.prefix, self.prefix / 'lib/libpdfium.so', self.prefix / 'include', self.repo) + + def install(self, provider=False): + path = self.base / ('provider-install' if provider else 'install') + if provider: + write(path / 'lib/libpdfium.so', self.original) + write(path / verifier.DOC / 'LICENSE', b'provider license') + raw = self.source_raw + else: + _, raw = self.verify() + for name, data in {**self.files, 'BUILDINFO.json': self.raw}.items(): + write(path / verifier.installed_path(name), data) + write(path / verifier.SUPPLEMENT / 'sources.json', raw) + for row in self.source['files']: + write(path / verifier.SUPPLEMENT / row['name'], self.files['licenses/' + row['name']]) + return path + + def test_candidate_prefix_and_installed_correspondence(self): + report, supplemental = self.verify() + self.assertEqual(report['candidateId'], 'reviewed-v2') + self.assertEqual(json.loads(supplemental)['pdfiumLibrarySha256'], verifier.digest(self.library)) + result = verifier.verify_installed(self.install(), self.repo) + self.assertEqual(result['kind'], 'reviewed-candidate') + self.assertEqual(result['anchorRecordSha256'], self.lock['anchorRecordSha256']) + + def test_absent_lock_rejects_candidate_but_preserves_original_provider(self): + (self.repo / verifier.LOCK).unlink() + with self.assertRaisesRegex(ValueError, 'not available'): self.verify() + self.assertEqual(verifier.verify_installed(self.install(provider=True), self.repo)['kind'], 'original-provider') + + def test_wrong_library_header_patch_record_notice_args_and_buildinfo(self): + for name in self.files.keys() | {'BUILDINFO.json'}: + with self.subTest(name=name): + path = self.prefix / name; original = path.read_bytes(); path.write_bytes(original + b'changed') + with self.assertRaises(ValueError): self.verify() + path.write_bytes(original) + + def test_self_consistent_forged_buildinfo_does_not_change_trust_anchor(self): + info = json.loads(self.raw) + replacement = b'replacement-library' + row = write(self.prefix / 'lib/libpdfium.so', replacement) + info['librarySha256'] = row['sha256'] + info['files']['lib/libpdfium.so'] = {'sha256': row['sha256'], 'bytes': row['size']} + write(self.prefix / 'BUILDINFO.json', encoded(info)) + with self.assertRaisesRegex(ValueError, 'BUILDINFO'): self.verify() + + def test_extra_missing_link_and_special_prefix_files(self): + extra = self.prefix / 'include/extra.h'; extra.write_text('unregistered') + with self.assertRaisesRegex(ValueError, 'Unregistered'): self.verify() + extra.unlink() + header = self.prefix / 'include/fpdfview.h'; header.unlink() + with self.assertRaises(ValueError): self.verify() + outside = self.base / 'outside'; outside.write_bytes(self.files['include/fpdfview.h']) + header.symlink_to(outside) + with self.assertRaises(ValueError): self.verify() + header.unlink(); os.mkfifo(header) + with self.assertRaisesRegex(ValueError, 'special'): self.verify() + + def test_cmake_cache_cannot_select_library_or_headers_outside_prefix(self): + for library, includes in ((self.base / 'old/libpdfium.so', self.prefix / 'include'), + (self.prefix / 'lib/libpdfium.so', self.base / 'old/include')): + with self.subTest(library=library, includes=includes), self.assertRaisesRegex(ValueError, 'selected candidate'): + verifier.verify_prefix(self.prefix, library, includes, self.repo) + + def test_missing_or_changed_installed_provenance_and_notice_rejected(self): + installed = self.install() + for name in (verifier.CANDIDATE + 'BUILDINFO.json', verifier.CANDIDATE + 'provenance/master6.json', + verifier.SUPPLEMENT + 'sources.json', verifier.SUPPLEMENT + 'libcxx-LICENSE.txt'): + with self.subTest(name=name): + path = installed / name; raw = path.read_bytes(); path.write_bytes(raw + b'changed') + with self.assertRaises(ValueError): verifier.verify_installed(installed, self.repo) + path.write_bytes(raw) + shutil.rmtree(installed / verifier.CANDIDATE) + with self.assertRaisesRegex(ValueError, 'missing candidate'): verifier.verify_installed(installed, self.repo) + + def test_archive_hash_inventory_cannot_rebind_candidate_or_use_provider_notice(self): + installed = self.install() + rows = {path.relative_to(installed).as_posix(): {'sha256': verifier.digest(path.read_bytes()), + 'size': path.stat().st_size} for path in installed.rglob('*') if path.is_file()} + contents = {path: (installed / path).read_bytes() for path in verifier.METADATA_PATHS} + self.assertEqual(verifier.verify_payload(rows, contents, self.repo)['kind'], 'reviewed-candidate') + wrong_rows = dict(rows) + wrong_rows['lib/libpdfium.so'] = {'sha256': '0' * 64, 'size': 12} + with self.assertRaisesRegex(ValueError, 'Candidate file'): + verifier.verify_payload(wrong_rows, contents, self.repo) + wrong_metadata = dict(contents) + wrong_metadata[verifier.SUPPLEMENT + 'sources.json'] = self.source_raw + with self.assertRaisesRegex(ValueError, 'correspondence'): + verifier.verify_payload(rows, wrong_metadata, self.repo) + + def test_original_provider_rejects_unknown_library_and_altered_notice(self): + installed = self.install(provider=True) + (installed / 'lib/libpdfium.so').write_bytes(b'unknown binary') + with self.assertRaisesRegex(ValueError, 'Unknown PDFium'): + verifier.verify_installed(installed, self.repo) + (installed / 'lib/libpdfium.so').write_bytes(self.original) + (installed / verifier.SUPPLEMENT / 'libcxx-LICENSE.txt').write_bytes(b'wrong notice') + with self.assertRaisesRegex(ValueError, 'notice text'): + verifier.verify_installed(installed, self.repo) + + def cmake_project(self, provider=False): + for name in ('verify_pdfium_candidate.py', 'stage_pdfium_candidate.py'): + write(self.repo / 'tools' / name, (ROOT / 'tools' / name).read_bytes()) + write(self.repo / 'cmake/PdfiumSupplementalNotices.cmake', (ROOT / 'cmake/PdfiumSupplementalNotices.cmake').read_bytes()) + if provider: + (self.prefix / 'BUILDINFO.json').unlink() + (self.prefix / 'lib/libpdfium.so').write_bytes(self.original) + (self.repo / verifier.LOCK).unlink() + cmake = '''cmake_minimum_required(VERSION 3.24) +project(candidate_fixture NONE) +set(DOCVIEW_PDFIUM_ROOT "@PREFIX@") +set(DOCVIEW_PDFIUM_LIBRARY "@PREFIX@/lib/libpdfium.so") +set(DOCVIEW_PDFIUM_INCLUDE_DIR "@PREFIX@/include") +include(cmake/PdfiumSupplementalNotices.cmake) +install(FILES "${DOCVIEW_PDFIUM_LIBRARY}" DESTINATION lib) +install(FILES "${DOCVIEW_PDFIUM_ROOT}/LICENSE" DESTINATION share/doc/docview/pdfium) +install(DIRECTORY "${DOCVIEW_PDFIUM_ROOT}/licenses/" DESTINATION share/doc/docview/pdfium/licenses) +'''.replace('@PREFIX@', str(self.prefix)) + write(self.repo / 'CMakeLists.txt', cmake.encode()) + build = self.base / 'cmake-build' + result = subprocess.run(['cmake', '-S', str(self.repo), '-B', str(build)], capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return build + + @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') + def test_cmake_candidate_install_and_revalidation(self): + build = self.cmake_project() + target = self.base / 'cmake-install' + command = ['cmake', '--install', str(build), '--prefix', str(target)] + result = subprocess.run(command, capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(verifier.verify_installed(target, self.repo)['kind'], 'reviewed-candidate') + (self.prefix / 'include/fpdfview.h').write_bytes(b'changed after configure') + result = subprocess.run(command, capture_output=True, text=True, timeout=45) + self.assertNotEqual(result.returncode, 0) + self.assertIn('changed before install', result.stdout + result.stderr) + + @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') + def test_cmake_original_provider_configures_without_candidate_lock(self): + self.cmake_project(provider=True) + + @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') + def test_install_rejects_changed_library_before_touching_existing_destination(self): + # Keep the small executable fixture's install ordering tied to the + # product: its verification must precede every product install rule. + product = (ROOT / 'CMakeLists.txt').read_text() + guard = product.index('include(cmake/PdfiumSupplementalNotices.cmake)') + first_install = product.index('install(') + self.assertLess(guard, first_install, 'Candidate guard must run before product copies') + build = self.cmake_project() + target = self.base / 'existing-valid-install' + command = ['cmake', '--install', str(build), '--prefix', str(target)] + first = subprocess.run(command, capture_output=True, text=True, timeout=45) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + self.assertEqual(verifier.verify_installed(target, self.repo)['kind'], 'reviewed-candidate') + + def snapshot(): + return {path.relative_to(target).as_posix(): + (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns) + for path in target.rglob('*') if path.is_file()} + + before = snapshot() + source = self.prefix / 'lib/libpdfium.so' + source.write_bytes(b'changed candidate after successful configure and install') + # CMake may skip a same-second copy. Make the dependency observably + # newer so this test catches publishing corrupt bytes before rejection. + newer = (target / 'lib/libpdfium.so').stat().st_mtime_ns + 2_000_000_000 + os.utime(source, ns=(newer, newer)) + rejected = subprocess.run(command, capture_output=True, text=True, timeout=45) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn('changed before install', rejected.stdout + rejected.stderr) + self.assertEqual(snapshot(), before, 'Rejected candidate modified the installed payload') + + def make_deb(self, install, summary): + stage = self.base / 'deb-stage' + shutil.copytree(install, stage / 'opt/docview') + write(stage / 'DEBIAN/control', b'Package: docview\nVersion: 0.1\nArchitecture: amd64\nMaintainer: Fixture\nDescription: synthetic trust test\n') + for path in stage.rglob('*'): path.chmod(0o755 if path.is_dir() else 0o644) + rows = [{'path': path.relative_to(stage).as_posix(), 'sha256': verifier.digest(path.read_bytes()), + 'size': path.stat().st_size, 'mode': '0o644'} for path in sorted(stage.rglob('*')) if path.is_file()] + manifest = {'schemaVersion': 1, 'package': 'docview', 'version': '0.1', 'files': rows, 'pdfiumCorrespondence': summary} + write(stage / MANIFEST, encoded(manifest)) + (stage / MANIFEST).chmod(0o644) + archive = self.base / 'fixture.deb' + subprocess.run(['dpkg-deb', '--root-owner-group', '-Znone', '--build', str(stage), str(archive)], + capture_output=True, check=True, timeout=30) + return archive, stage, manifest + + @unittest.skipUnless(shutil.which('dpkg-deb'), 'dpkg-deb is required') + def test_deb_verifies_candidate_and_rejects_forged_self_consistent_manifest(self): + installed = self.install() + summary = verifier.verify_installed(installed, self.repo) + archive, stage, manifest = self.make_deb(installed, summary) + with patch.object(verifier, 'ROOT', self.repo), patch.object(deb, 'verify_qpdf_payload', return_value={'status': 'synthetic-qpdf-out-of-scope'}): + self.assertEqual(deb.verify(archive)['pdfiumCorrespondence'], summary) + path = stage / 'opt/docview/lib/libpdfium.so' + path.write_bytes(b'changed packaged library') + row = next(row for row in manifest['files'] if row['path'] == 'opt/docview/lib/libpdfium.so') + row.update(sha256=verifier.digest(path.read_bytes()), size=path.stat().st_size) + write(stage / MANIFEST, encoded(manifest)) + subprocess.run(['dpkg-deb', '--root-owner-group', '-Znone', '--build', str(stage), str(archive)], + capture_output=True, check=True, timeout=30) + with self.assertRaisesRegex(ValueError, 'Candidate file'): deb.verify(archive) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_qpdf_notice_package.py b/tests/test_qpdf_notice_package.py new file mode 100644 index 0000000..85dcd9a --- /dev/null +++ b/tests/test_qpdf_notice_package.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""qpdf notice/package boundaries with a synthetic pin; real Ubuntu tests use dpkg-deb.""" +import copy +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import collect_ubuntu_validation_runtime as collector +import package_ubuntu as package +import verify_ubuntu_package as verifier + + +def digest(data): + return {'sha256': hashlib.sha256(data).hexdigest(), 'size': len(data)} + + +class QpdfNoticePackageTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.library = b'synthetic reviewed qpdf runtime' + self.texts = {'LICENSE.txt': b'synthetic license', 'NOTICE.md': b'synthetic notice'} + self.pin = {'version': '12.4.1', 'archive': {'name': 'fixed.tar.gz', **digest(b'source archive')}, + 'sourceFiles': 2, 'sourceBytes': 27, 'sourceInventorySha256': 'a' * 64, + 'library': digest(self.library), 'notices': {k: digest(v) for k, v in self.texts.items()}} + self.summary = {k: copy.deepcopy(self.pin[k]) for k in ('version', 'archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256')} + self.summary.update(status='verified', sourceRoot='source-0:', + library={'path': 'dependencies:lib/libqpdf.so.30', **self.pin['library']}, + notices=[{'source': k, **v, 'copiedPath': 'notices/' + v['sha256'] + '.txt'} for k, v in self.pin['notices'].items()]) + self.payload = {'opt/docview/lib/libqpdf.so.30': self.library} + for row in self.summary['notices']: + self.payload['opt/docview/share/doc/docview/third-party/runtime/' + row['copiedPath']] = self.texts[row['source']] + self.patch = patch.object(collector, 'QPDF_NOTICE_PIN', self.pin); self.patch.start(); self.addCleanup(self.patch.stop) + + def inputs(self, summary=None): + summary = copy.deepcopy(self.summary if summary is None else summary) + return ({name: digest(data) for name, data in self.payload.items()}, + {verifier.RUNTIME_RECORD: json.dumps({'qpdfNoticeCorrespondence': summary}).encode()}, + {'version': '0.1.0~validation5', 'qpdfNoticeCorrespondence': summary}) + + def test_correspondence_binds_runtime_and_both_notice_texts(self): + self.assertEqual(verifier.verify_qpdf_payload(*self.inputs()), self.summary) + + def test_missing_or_changed_notice_and_library_rejected_even_with_new_payload_hashes(self): + original = copy.deepcopy(self.payload) + for name in original: + for mode in ('missing', 'changed'): + with self.subTest(name=name, mode=mode): + self.payload = dict(original) + if mode == 'missing': del self.payload[name] + else: self.payload[name] += b'changed' + with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs()) + self.payload = original + + def test_forged_source_identity_and_notice_claims_rejected(self): + for key in ('archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256', 'version'): + with self.subTest(key=key): + summary = copy.deepcopy(self.summary); summary[key] = 'forged' + with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs(summary)) + for mutate in ('duplicate', 'escape', 'name'): + summary = copy.deepcopy(self.summary) + if mutate == 'duplicate': summary['notices'][1] = summary['notices'][0] + elif mutate == 'escape': summary['notices'][0]['copiedPath'] = '../../NOTICE.md' + else: summary['notices'][0]['source'] = 'OTHER' + with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs(summary)) + + def test_correct_unused_alias_does_not_authorize_changed_loaded_soname(self): + self.payload['opt/docview/lib/libqpdf.so.30.4.1'] = self.library + summary = copy.deepcopy(self.summary) + summary['library']['path'] = 'dependencies:lib/libqpdf.so.30.4.1' + self.payload['opt/docview/lib/libqpdf.so.30'] = b'changed actual loader dependency' + with self.assertRaisesRegex(ValueError, 'SONAME'): verifier.verify_qpdf_payload(*self.inputs(summary)) + + def test_runtime_record_cannot_disagree_with_summary(self): + data, metadata, manifest = self.inputs(); metadata[verifier.RUNTIME_RECORD] = b'{}' + with self.assertRaisesRegex(ValueError, 'differs'): verifier.verify_qpdf_payload(data, metadata, manifest) + + def test_all_versions_require_correspondence_for_current_verification(self): + for version in ('0.1.0~validation5', '0.1.0~validation4', 'forged'): + with self.subTest(version=version), self.assertRaises(ValueError): + verifier.verify_qpdf_payload({}, {}, {'version': version}) + + def test_package_passes_archive_to_collector(self): + class ReachedCollector(Exception): pass + args = SimpleNamespace(version='0.1.0~validation5', output=self.root/'new-package', + prefix=self.root/'install', qtpaths=self.root/'qtpaths', dependency_prefix=self.root/'deps', + source_root=[], qpdf_source_archive=self.root/'fixed.tar.gz') + with patch.object(package.platform, 'freedesktop_os_release', return_value={'ID':'ubuntu','VERSION_ID':'24.04'}), \ + patch.object(package.subprocess, 'check_output', return_value='amd64\n'), \ + patch.object(package, 'Collector', side_effect=ReachedCollector) as construct: + with self.assertRaises(ReachedCollector): package.create(args) + self.assertEqual(construct.call_args.kwargs['qpdf_source_archive'], args.qpdf_source_archive) + args.version = '0.1.0~unreviewed' + with self.assertRaisesRegex(ValueError, 'Unsupported'): package.create(args) + + def test_cli_accepts_fixed_new_version_and_archive(self): + args = ['package_ubuntu.py', '--prefix','p','--qtpaths','q','--dependency-prefix','d', '--input-manifest','i', + '--sdk-notices','n','--sdk-supplement','s','--qt-licenses','l','--output','o', + '--version','0.1.0~validation5','--qpdf-source-archive','fixed.tar.gz'] + with patch.object(sys, 'argv', args), patch.object(package, 'create') as create: + package.main() + self.assertEqual(create.call_args.args[0].qpdf_source_archive, Path('fixed.tar.gz')) + self.assertEqual(create.call_args.args[0].version, '0.1.0~validation5') + + @unittest.skipUnless(shutil.which('dpkg-deb'), 'dpkg-deb is required') + def test_real_deb_rejects_omitted_notice_with_self_consistent_manifest(self): + stage = self.root/'stage'; stage.mkdir() + def make(manifest_version='0.1.0~validation5', control_version='0.1.0~validation5', summary=True): + for name, data in self.payload.items(): + p=stage/name; p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o644) + record=stage/'opt/docview'/verifier.RUNTIME_RECORD; record.parent.mkdir(parents=True,exist_ok=True) + record.write_text(json.dumps({'qpdfNoticeCorrespondence': self.summary}));record.chmod(0o644) + control=stage/'DEBIAN/control';control.parent.mkdir(exist_ok=True) + control.write_text('Package: docview\nVersion: ' + control_version + '\nArchitecture: amd64\nMaintainer: Fixture\nDescription: qpdf notice boundary\n');control.chmod(0o644) + rows=[{'path':p.relative_to(stage).as_posix(), **digest(p.read_bytes()), 'mode':'0o644'} for p in sorted(stage.rglob('*')) if p.is_file() and p.relative_to(stage).as_posix()!=package.MANIFEST] + manifest=stage/package.MANIFEST;manifest.write_text(json.dumps({'schemaVersion':1,'package':'docview','version':manifest_version,'files':rows, **({'qpdfNoticeCorrespondence':self.summary} if summary else {})}));manifest.chmod(0o644) + archive=self.root/'fixture.deb';subprocess.run(['dpkg-deb','--root-owner-group','-Znone','--build',str(stage),str(archive)],capture_output=True,check=True,timeout=30) + return archive + with patch.object(verifier, 'verify_pdfium_payload', return_value={'kind':'original-provider'}): + self.assertEqual(verifier.verify(make())['qpdfNoticeCorrespondence'],self.summary) + with self.assertRaisesRegex(ValueError, 'control identity'): + verifier.verify(make(manifest_version='0.1.0~validation4')) + with self.assertRaisesRegex(ValueError, 'Missing verified'): + verifier.verify(make(manifest_version='0.1.0~validation4', control_version='0.1.0~validation4', summary=False)) + missing=next(name for name in self.payload if digest(self.texts['NOTICE.md'])['sha256'] in name) + del self.payload[missing];(stage/missing).unlink() + with self.assertRaisesRegex(ValueError,'absent or changed'):verifier.verify(make()) + + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_renderer_watchdog.cpp b/tests/test_renderer_watchdog.cpp new file mode 100644 index 0000000..b9d151b --- /dev/null +++ b/tests/test_renderer_watchdog.cpp @@ -0,0 +1,238 @@ +#include "web/renderer_watchdog.h" +#include +#include +#include +#include +#include +#include + +class RendererWatchdogTests : public QObject { + Q_OBJECT +private slots: + void procStatParsing(); + void rendererArgumentParsing(); + void rejectsForeignAndNonRenderer(); + void terminatesOnlyRegisteredRenderer(); + void forgetsExitedRenderer(); + void boundedSpreadSlots(); + void responseProbeLifetime(); + void responseTimeoutStopsOnlyStalledSlot(); + void suspensionRestartsResponseBudget(); +}; + +void RendererWatchdogTests::procStatParsing() { + QList fields; + for (int i = 0; i < 22; ++i) fields << "0"; + fields[0] = "S"; fields[1] = "123"; fields[19] = "456"; fields[21] = "789"; + auto data = QByteArray("999 (name with ) parens) ") + fields.join(' '); + docview::RendererProcessInfo info; + QVERIFY(docview::parseRendererProcStat(data, 4096, &info)); + QCOMPARE(info.parentPid, 123); QCOMPARE(info.startTime, 456u); QCOMPARE(info.residentBytes, 789ull * 4096); + QVERIFY(!docview::parseRendererProcStat(data, 0, &info)); + QVERIFY(!docview::parseRendererProcStat(data, std::numeric_limits::max(), &info)); + QVERIFY(!docview::parseRendererProcStat("invalid", 4096, &info)); + fields[21] = "-1"; + QVERIFY(!docview::parseRendererProcStat(QByteArray("999 (name) ") + fields.join(' '), 4096, &info)); +} + +void RendererWatchdogTests::rejectsForeignAndNonRenderer() { + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath()); + QString error; + QVERIFY(!watchdog.registerRenderer("foreign", 1, &error)); + QVERIFY(!watchdog.registerRenderer("self", QCoreApplication::applicationPid(), &error)); + QVERIFY(!watchdog.registerRenderer("overflow", std::numeric_limits::max(), &error)); + QProcess child; + child.start(QCoreApplication::applicationFilePath(), {"--watchdog-child", "--allocate", "1"}); + QVERIFY(child.waitForStarted()); QVERIFY(child.waitForReadyRead()); + QVERIFY(!watchdog.registerRenderer("wrong-type", child.processId(), &error)); + QCOMPARE(watchdog.monitoredCount(), 0); + QCOMPARE(child.state(), QProcess::Running); + child.kill(); QVERIFY(child.waitForFinished()); +} +void RendererWatchdogTests::rendererArgumentParsing() { + const QByteArray executable("/trusted path/QtWebEngineProcess"); + QVERIFY(docview::isRendererCommandLine(executable + '\0' + "--type=renderer" + '\0' + "--other=x" + '\0', executable)); + QVERIFY(docview::isRendererCommandLine(executable + " --type=renderer --other=x" + '\0', executable)); + QVERIFY(!docview::isRendererCommandLine(executable + " --type=renderer-other" + '\0', executable)); + QVERIFY(!docview::isRendererCommandLine(executable + " --url=space --type=renderer" + '\0', executable)); + QVERIFY(!docview::isRendererCommandLine(executable + "-other --type=renderer" + '\0', executable)); + QVERIFY(!docview::isRendererCommandLine(executable + '\0' + "--url= --type=renderer" + '\0', executable)); + QVERIFY(!docview::isRendererCommandLine(QByteArray(65537, 'x'), executable)); +} + +void RendererWatchdogTests::terminatesOnlyRegisteredRenderer() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess renderer, sibling; + renderer.start(QCoreApplication::applicationFilePath(), {"--watchdog-child", "--type=renderer", "--allocate", "64"}); + sibling.start(QCoreApplication::applicationFilePath(), {"--watchdog-child", "--allocate", "1"}); + QVERIFY(renderer.waitForStarted()); QVERIFY(renderer.waitForReadyRead()); + QVERIFY(sibling.waitForStarted()); QVERIFY(sibling.waitForReadyRead()); + docview::RendererWatchdog watchdog(nullptr, 32ull * 1024 * 1024, QCoreApplication::applicationFilePath()); + QString error; + QVERIFY2(watchdog.registerRenderer("test-session", renderer.processId(), &error), qPrintable(error)); + QSignalSpy exceeded(&watchdog, &docview::RendererWatchdog::limitExceeded); + watchdog.checkNow(); + QTRY_COMPARE(exceeded.count(), 1); + QCOMPARE(exceeded.first().first().toString(), "test-session"); + QVERIFY(renderer.waitForFinished()); +#ifdef Q_OS_WIN + QCOMPARE(renderer.exitCode(), 137); +#else + QCOMPARE(renderer.exitStatus(), QProcess::CrashExit); +#endif + QCOMPARE(sibling.state(), QProcess::Running); + sibling.kill(); QVERIFY(sibling.waitForFinished()); + QCOMPARE(watchdog.monitoredCount(), 0); +#else + docview::RendererWatchdog watchdog; QString error; + QVERIFY(!watchdog.registerRenderer("session", 123, &error)); QCOMPARE(error, "E_SANDBOX_UNAVAILABLE"); +#endif +} + +void RendererWatchdogTests::forgetsExitedRenderer() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess renderer; + renderer.start(QCoreApplication::applicationFilePath(), {"--watchdog-child", "--type=renderer", "--allocate", "1"}); + QVERIFY(renderer.waitForStarted()); QVERIFY(renderer.waitForReadyRead()); + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath()); + QString error; + QVERIFY2(watchdog.registerRenderer("session", renderer.processId(), &error), qPrintable(error)); + QSignalSpy exceeded(&watchdog, &docview::RendererWatchdog::limitExceeded); + renderer.kill(); QVERIFY(renderer.waitForFinished()); + watchdog.checkNow(); + QCOMPARE(watchdog.monitoredCount(), 0); QCOMPARE(exceeded.count(), 0); +#endif +} + +void RendererWatchdogTests::boundedSpreadSlots() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess first, second; + const QStringList args{"--watchdog-child", "--type=renderer", "--allocate", "1"}; + first.start(QCoreApplication::applicationFilePath(), args); + second.start(QCoreApplication::applicationFilePath(), args); + QVERIFY(first.waitForStarted()); QVERIFY(first.waitForReadyRead()); + QVERIFY(second.waitForStarted()); QVERIFY(second.waitForReadyRead()); + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath()); + QString error; + QVERIFY(watchdog.registerRenderer("spread", first.processId(), &error, 0)); + QVERIFY(watchdog.registerRenderer("spread", second.processId(), &error, 1)); + QCOMPARE(watchdog.monitoredCount(), 2); + QVERIFY(!watchdog.registerRenderer("spread", first.processId(), &error, 2)); + QVERIFY(!watchdog.registerRenderer("spread", 0, &error, -1)); + QCOMPARE(watchdog.monitoredCount(), 2); + QVERIFY(watchdog.registerRenderer("spread", first.processId(), &error, 1)); + QCOMPARE(watchdog.monitoredCount(), 2); // Shared renderer, independently owned slots. + watchdog.removeSlot("spread", 1); + QCOMPARE(watchdog.monitoredCount(), 1); + watchdog.checkNow(); + QCOMPARE(first.state(), QProcess::Running); QCOMPARE(second.state(), QProcess::Running); + QVERIFY(watchdog.registerRenderer("spread", second.processId(), &error, 1)); + first.kill(); QVERIFY(first.waitForFinished()); watchdog.checkNow(); + QCOMPARE(watchdog.monitoredCount(), 1); // Exited primary must not unmonitor companion. + watchdog.removeSession("spread"); QCOMPARE(watchdog.monitoredCount(), 0); + second.kill(); QVERIFY(second.waitForFinished()); +#endif +} + +void RendererWatchdogTests::responseProbeLifetime() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess first, replacement; + const QStringList args{"--watchdog-child", "--type=renderer", "--allocate", "1"}; + first.start(QCoreApplication::applicationFilePath(), args); + replacement.start(QCoreApplication::applicationFilePath(), args); + QVERIFY(first.waitForStarted()); QVERIFY(first.waitForReadyRead()); + QVERIFY(replacement.waitForStarted()); QVERIFY(replacement.waitForReadyRead()); + docview::RendererWatchdog productionExecutable; + QVERIFY(!productionExecutable.registerRenderer("impostor", first.processId())); + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath(), 1000); + QVERIFY(!watchdog.beginProbe("absent", 0)); + QVERIFY(watchdog.registerRenderer("session", first.processId())); + const auto firstProbe = watchdog.beginProbe("session", 0); + QVERIFY(firstProbe); QCOMPARE(watchdog.beginProbe("session", 0), 0u); + QVERIFY(!watchdog.acknowledgeProbe("session", 1, firstProbe)); + QVERIFY(watchdog.acknowledgeProbe("session", 0, firstProbe)); + const auto oldDocumentProbe = watchdog.beginProbe("session", 0); + QVERIFY(oldDocumentProbe != firstProbe); + // Navigation cancellation invalidates only the exact outstanding probe. + QVERIFY(watchdog.acknowledgeProbe("session", 0, oldDocumentProbe)); + const auto oldProcessProbe = watchdog.beginProbe("session", 0); + QVERIFY(!watchdog.acknowledgeProbe("session", 0, oldDocumentProbe)); + QVERIFY(watchdog.registerRenderer("session", replacement.processId())); + const auto current = watchdog.beginProbe("session", 0); + QVERIFY(current && current != oldProcessProbe); + QVERIFY(!watchdog.acknowledgeProbe("session", 0, oldProcessProbe)); + // Two visible views can share a renderer but retain independent deadlines. + QVERIFY(watchdog.registerRenderer("session", replacement.processId(), nullptr, 1)); + const auto companion = watchdog.beginProbe("session", 1); + QVERIFY(companion && companion != current); + QVERIFY(watchdog.acknowledgeProbe("session", 0, current)); + QCOMPARE(watchdog.beginProbe("session", 1), 0u); + watchdog.removeSession("session"); + QVERIFY(!watchdog.acknowledgeProbe("session", 1, companion)); + watchdog.checkNow(); + QCOMPARE(first.state(), QProcess::Running); QCOMPARE(replacement.state(), QProcess::Running); + first.kill(); replacement.kill(); + QVERIFY(first.waitForFinished()); QVERIFY(replacement.waitForFinished()); +#endif +} + +void RendererWatchdogTests::responseTimeoutStopsOnlyStalledSlot() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess primary, companion; + const QStringList args{"--watchdog-child", "--type=renderer", "--allocate", "1"}; + primary.start(QCoreApplication::applicationFilePath(), args); + companion.start(QCoreApplication::applicationFilePath(), args); + QVERIFY(primary.waitForStarted()); QVERIFY(primary.waitForReadyRead()); + QVERIFY(companion.waitForStarted()); QVERIFY(companion.waitForReadyRead()); + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath(), 100); + QVERIFY(watchdog.registerRenderer("spread", primary.processId())); + QVERIFY(watchdog.registerRenderer("spread", companion.processId(), nullptr, 1)); + const auto successful = watchdog.beginProbe("spread", 0); + QVERIFY(watchdog.acknowledgeProbe("spread", 0, successful)); + QVERIFY(watchdog.beginProbe("spread", 1)); + QSignalSpy timedOut(&watchdog, &docview::RendererWatchdog::responseTimedOut); + QSignalSpy memory(&watchdog, &docview::RendererWatchdog::limitExceeded); + QTest::qSleep(150); watchdog.checkNow(); + QCOMPARE(timedOut.count(), 1); QCOMPARE(memory.count(), 0); + QCOMPARE(timedOut.first(), QVariantList({"spread", "E_RENDERER_TIMEOUT"})); + QVERIFY(companion.waitForFinished()); + QCOMPARE(primary.state(), QProcess::Running); + QCOMPARE(watchdog.monitoredCount(), 0); // Controller disposes the whole failed session. + primary.kill(); QVERIFY(primary.waitForFinished()); +#endif +} + +void RendererWatchdogTests::suspensionRestartsResponseBudget() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) + QProcess renderer; + renderer.start(QCoreApplication::applicationFilePath(), {"--watchdog-child", "--type=renderer", "--allocate", "1"}); + QVERIFY(renderer.waitForStarted()); QVERIFY(renderer.waitForReadyRead()); + docview::RendererWatchdog watchdog(nullptr, docview::RendererWatchdog::DefaultLimitBytes, QCoreApplication::applicationFilePath(), 100); + QVERIFY(watchdog.registerRenderer("session", renderer.processId())); + const auto probe = watchdog.beginProbe("session", 0); + QSignalSpy timedOut(&watchdog, &docview::RendererWatchdog::responseTimedOut); + QTest::qSleep(5100); watchdog.checkNow(); // No GUI callbacks during a host pause. + QCOMPARE(timedOut.count(), 0); QCOMPARE(renderer.state(), QProcess::Running); + QVERIFY(watchdog.acknowledgeProbe("session", 0, probe)); + QVERIFY(watchdog.beginProbe("session", 0)); + QTest::qSleep(150); watchdog.checkNow(); + QCOMPARE(timedOut.count(), 1); QVERIFY(renderer.waitForFinished()); +#endif +} + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + if (app.arguments().contains("--watchdog-child")) { + const auto index = app.arguments().indexOf("--allocate"); + const int mib = index >= 0 && index + 1 < app.arguments().size() ? app.arguments()[index + 1].toInt() : 1; + if (mib < 1 || mib > 128) return 2; + QByteArray memory(qsizetype(mib) * 1024 * 1024, 'x'); + fwrite("ready\n", 1, 6, stdout); fflush(stdout); + QTimer::singleShot(15000, &app, &QCoreApplication::quit); + const int result = app.exec(); + return result + (memory.at(0) == 'x' ? 0 : 1); + } + RendererWatchdogTests tests; + return QTest::qExec(&tests, argc, argv); +} +#include "test_renderer_watchdog.moc" diff --git a/tests/test_runtime_manifest.py b/tests/test_runtime_manifest.py new file mode 100644 index 0000000..b107091 --- /dev/null +++ b/tests/test_runtime_manifest.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Portable manifest/copy tests. Synthetic MZ files do not test native PE scanning.""" + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SOURCE = Path(__file__).resolve().parents[1] +WRITER = SOURCE / "cmake/WriteWindowsRuntimeManifest.cmake" +STAGER = SOURCE / "cmake/StageWindowsWorker.cmake" +INSTALLER = SOURCE / "cmake/InstallWindowsWorker.cmake" +DEPLOYMENT = SOURCE / "cmake/WindowsDeployment.cmake" +CMAKE = shutil.which("cmake") + + +class RuntimeManifestTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="docview runtime 日本語 ") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.package = self.root / "package" + self.package.mkdir() + self.executable = self.file("package/worker.exe", b"MZworker") + self.manifest = Path(str(self.executable) + ".runtime.json") + + def file(self, relative, data=b"MZfixture"): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + def invoke(self, script, definitions=(), success=True): + command = [CMAKE, *[f"-D{name}={value}" for name, value in definitions], "-P", str(script)] + return self.run_command(command, success=success) + + def run_command(self, command, success=True, env=None): + result = subprocess.run(command, capture_output=True, text=True, timeout=30, env=env) + if success: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + else: + self.assertNotEqual(result.returncode, 0, "Invalid package input was accepted") + return result + + def write(self, files, success=True): + return self.invoke(WRITER, [("WORKER_EXECUTABLE", self.executable), + ("RUNTIME_FILES", ";".join(map(str, files)))], success) + + def stage(self, sources, success=True): + # Import the portable copy function without invoking the native scanner. + script = self.root / "copy.cmake" + script.write_text(f'include([==[{STAGER.as_posix()}]==])\n' + f'docview_stage_runtime_files([==[{self.executable.as_posix()}]==] ' + f'[==[{";".join(path.as_posix() for path in sources)}]==])\n', encoding="utf-8") + return self.invoke(script, success=success) + + def assert_inventory(self, files): + result = json.loads(self.manifest.read_text()) + self.assertEqual(set(result), {"schemaVersion", "executable", "files"}) + self.assertEqual(result["schemaVersion"], 1) + self.assertEqual(result["executable"], self.executable.name) + expected = {path.name: path for path in files} + self.assertEqual({entry["path"] for entry in result["files"]}, set(expected)) + for entry in result["files"]: + self.assertEqual(set(entry), {"path", "size", "sha256"}) + path = expected[entry["path"]] + self.assertIsInstance(entry["size"], int) + self.assertEqual(entry["size"], path.stat().st_size) + self.assertEqual(entry["sha256"], hashlib.sha256(path.read_bytes()).hexdigest()) + self.assertRegex(entry["sha256"], r"^[0-9a-f]{64}$") + self.assertFalse(list(self.package.glob("*.tmp"))) + + def test_exact_schema_hashes_and_stable_order(self): + files = [self.file("package/Qt6Core.dll"), self.executable, self.file("package/zlib1.dll", b"MZother")] + self.write(files) + self.assert_inventory(files) + original = self.manifest.read_bytes() + self.write(list(reversed(files))) + self.assertEqual(self.manifest.read_bytes(), original) + + def test_executable_required_and_failed_write_preserves_manifest(self): + self.write([self.executable]) + original = self.manifest.read_bytes() + dll = self.file("package/one.dll") + self.write([dll], success=False) + self.assertEqual(self.manifest.read_bytes(), original) + self.write([], success=False) + self.assertEqual(self.manifest.read_bytes(), original) + + def test_casefold_and_exact_duplicates_rejected(self): + upper = self.file("package/Example.dll") + lower = self.file("package/example.dll") + self.write([self.executable, upper, lower], success=False) + self.write([self.executable, upper, upper], success=False) + + def test_invalid_names_and_file_kinds(self): + for name in ["CON.dll", "lpt9.dll", "aux.lib.dll", "trailing.dll.", "space name.dll", + "unexpected.exe", "notes.txt", ".hidden.dll", "a" * 125 + ".dll"]: + with self.subTest(name=name): + bad = self.file("package/" + name) + self.write([self.executable, bad], success=False) + + def test_missing_non_mz_empty_directory_and_relative_entries(self): + directory = self.package / "directory.dll" + directory.mkdir() + for path in [self.package / "missing.dll", self.file("package/invalid.dll", b"not PE"), + self.file("package/empty.dll", b""), directory, Path("relative.dll")]: + with self.subTest(path=path): + self.write([self.executable, path], success=False) + + def test_outside_directory_and_symlink_rejected(self): + external = self.file("outside/external.dll") + self.write([self.executable, external], success=False) + link = self.package / "link.dll" + try: + link.symlink_to(external) + except OSError as error: + self.skipTest(f"Cannot create a symlink on this host: {error}") + self.write([self.executable, link], success=False) + + def test_manifest_destination_symlink_rejected(self): + protected = self.file("protected.txt", b"unchanged") + try: + self.manifest.symlink_to(protected) + except OSError as error: + self.skipTest(f"Cannot create a symlink on this host: {error}") + self.write([self.executable], success=False) + self.assertEqual(protected.read_bytes(), b"unchanged") + + def test_128_files_allowed_129_rejected(self): + files = [self.executable] + [self.file(f"package/library{i}.dll") for i in range(127)] + self.write(files) + self.assert_inventory(files) + original = self.manifest.read_bytes() + self.write(files + [self.file("package/overflow.dll")], success=False) + self.assertEqual(self.manifest.read_bytes(), original) + + def test_individual_and_total_size_limits_before_hashing(self): + first = self.file("package/large.dll") + with first.open("r+b") as stream: + stream.truncate(256 * 1024 * 1024 + 1) + self.write([self.executable, first], success=False) + with first.open("r+b") as stream: + stream.truncate(256 * 1024 * 1024) + second = self.file("package/second.dll") + with second.open("r+b") as stream: + stream.truncate(256 * 1024 * 1024) + self.write([self.executable, first, second], success=False) + self.assertFalse(self.manifest.exists()) + + def test_copy_and_manifest_are_idempotent(self): + first = self.file("input/One.dll") + duplicate = self.file("another/One.dll") + second = self.file("input/Two.dll", b"MZsecond") + self.stage([first, duplicate, first, second]) + copied = [self.executable, self.package / first.name, self.package / second.name] + self.assert_inventory(copied) + original = self.manifest.read_bytes() + self.stage([second, first]) + self.assert_inventory(copied) + self.assertEqual(self.manifest.read_bytes(), original) + self.assertEqual(first.read_bytes(), duplicate.read_bytes()) + + def test_copy_executable_without_dlls(self): + self.stage([]) + self.assert_inventory([self.executable]) + + def test_os_filter_uses_exact_case_insensitive_directory_boundaries(self): + script = self.root / "os-filter.cmake" + script.write_text(f'include([==[{STAGER.as_posix()}]==])\n' + r''' +docview_regex_case_path("C:/Win(dows).old" escaped) +if(NOT "c:/WIN(dows).OLD/System32/kernel32.dll" MATCHES "^${escaped}/") + message(FATAL_ERROR "The OS filter did not preserve literal punctuation") +endif() +if("C:/WindowsXold/System32/kernel32.dll" MATCHES "^${escaped}/") + message(FATAL_ERROR "The OS filter treated a literal path as a regex") +endif() +foreach(path IN ITEMS "C:/Windows/System32/kernel32.dll" "c:/WINDOWS/SysWOW64/old.dll" "C:/Windows/OS.dll") + docview_is_windows_os_runtime("${path}" "C:/Windows/" result) + if(NOT result) + message(FATAL_ERROR "Windows OS dependency was not excluded: ${path}") + endif() +endforeach() +foreach(path IN ITEMS "C:/Windows-copy/System32/app.dll" "C:/Windows/System32-copy/app.dll" "D:/SDK/redist/runtime.dll") + docview_is_windows_os_runtime("${path}" "C:/Windows" result) + if(result) + message(FATAL_ERROR "Non-OS dependency was mistaken for an OS file: ${path}") + endif() +endforeach() +''', encoding="utf-8") + self.invoke(script) + + def test_copy_conflicting_sources_and_case_rejected(self): + first = self.file("input/one.dll") + different = self.file("another/one.dll", b"MZdifferent") + self.stage([first, different], success=False) + self.assertFalse((self.package / first.name).exists()) + case_variant = self.file("another/One.dll") + self.stage([first, case_variant], success=False) + self.assertFalse(self.manifest.exists()) + + def test_copy_existing_destination_is_never_overwritten(self): + first = self.file("input/one.dll") + original = self.file("package/one.dll", b"MZkeep") + self.write([self.executable, original]) + manifest_before = self.manifest.read_bytes() + self.stage([first], success=False) + self.assertEqual(original.read_bytes(), b"MZkeep") + self.assertEqual(self.manifest.read_bytes(), manifest_before) + + def test_copy_casefold_destination_conflict_rejected(self): + first = self.file("input/one.dll") + original = self.file("package/One.dll") + self.stage([first], success=False) + self.assertEqual(original.read_bytes(), first.read_bytes()) + + def test_copy_symlink_and_wrong_extension_rejected(self): + self.stage([self.file("input/source.txt")], success=False) + first = self.file("input/one.dll") + link = self.package / first.name + try: + link.symlink_to(first) + except OSError as error: + self.skipTest(f"Cannot create a symlink on this host: {error}") + self.stage([first], success=False) + self.assertEqual(first.read_bytes(), b"MZfixture") + + def test_install_and_installed_file_tamper_detection(self): + dll = self.file("package/Qt6Core.dll") + self.write([self.executable, dll]) + destination = self.root / "installed bin" + self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable), + ("WORKER_INSTALL_DIRECTORY", destination)]) + self.assertEqual((destination / dll.name).read_bytes(), dll.read_bytes()) + installed = destination / self.executable.name + self.assertEqual(Path(str(installed) + ".runtime.json").read_bytes(), self.manifest.read_bytes()) + self.invoke(INSTALLER, [("WORKER_EXECUTABLE", installed), ("WORKER_VERIFY_ONLY", "TRUE")]) + (destination / dll.name).write_bytes(b"MZpatched") + self.invoke(INSTALLER, [("WORKER_EXECUTABLE", installed), ("WORKER_VERIFY_ONLY", "TRUE")], success=False) + + def test_install_source_tamper_is_rejected_before_copy(self): + dll = self.file("package/Qt6Core.dll") + self.write([self.executable, dll]) + dll.write_bytes(b"MZchanged") + destination = self.root / "installed" + self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable), + ("WORKER_INSTALL_DIRECTORY", destination)], success=False) + self.assertFalse(destination.exists()) + + def test_installer_rejects_noncanonical_manifest_schema(self): + dll = self.file("package/Qt6Core.dll") + self.write([self.executable, dll]) + original = self.manifest.read_bytes() + mutations = [ + ("extra-root-field", lambda value: value.update(extra=True)), + ("string-version", lambda value: value.update(schemaVersion="1")), + ("wrong-version", lambda value: value.update(schemaVersion=2)), + ("wrong-worker", lambda value: value.update(executable="other.exe")), + ("extra-entry-field", lambda value: value["files"][0].update(extra=True)), + ("string-size", lambda value: value["files"][0].update(size=str(value["files"][0]["size"]))), + ("fractional-size", lambda value: value["files"][0].update(size=3.5)), + ("uppercase-hash", lambda value: value["files"][0].update(sha256=value["files"][0]["sha256"].upper())), + ("traversal", lambda value: value["files"][0].update(path="../outside.dll")), + ("duplicate-name", lambda value: value["files"].append(value["files"][0].copy())), + ("empty-inventory", lambda value: value.update(files=[])), + ("missing-worker", lambda value: value.update(files=[entry for entry in value["files"] if entry["path"].endswith(".dll")])), + ] + for name, mutate in mutations: + with self.subTest(name=name): + value = json.loads(original) + mutate(value) + self.manifest.write_text(json.dumps(value)) + self.invoke(INSTALLER, [("WORKER_EXECUTABLE", self.executable), + ("WORKER_VERIFY_ONLY", "TRUE")], success=False) + + def configure_synthetic_deployment(self): + # Configure the generated CMake glue using the host compiler and fake Qt + # targets. No synthetic MZ file is compiled or run as native machine code. + project = self.root / "glue" + project.mkdir() + build = self.root / "glue-build" + qt = self.file("sdk/bin/Qt6Core.dll", b"MZQtCore") + archive = self.file("sdk/bin/zip.dll", b"MZzip") + qpdf = self.file("qpdf/bin/qpdf.dll", b"MZqpdf") + trace = self.root / "deploy-arguments.json" + tool = self.file("fake-windeployqt", (f"#!{sys.executable}\n" + + "import json, os, pathlib, sys\n" + "args = sys.argv[1:]\n" + "assert '--nopatchqt' in args and '--no-compiler-runtime' in args\n" + "destination = pathlib.Path(args[args.index('--dir') + 1])\n" + f"pathlib.Path({str(trace)!r}).write_text(json.dumps(args))\n" + "if os.environ.get('DOCVIEW_MANIFEST_TEST_TAMPER') == '1':\n" + " (destination / 'Qt6Core.dll').write_bytes(b'MZmodified by deploy tool')\n").encode()) + tool.chmod(0o700) + for name in ["docview-pdf-worker.exe", "docview-archive-worker.exe"]: + worker = self.file("package/" + name, b"MZ" + name.encode()) + shutil.copyfile(qt, self.package / qt.name) + self.invoke(WRITER, [("WORKER_EXECUTABLE", worker), + ("RUNTIME_FILES", f"{worker};{self.package / qt.name}")]) + self.file("package/docview.exe", b"MZgui") + (project / "dummy.cpp").write_text("int main() { return 0; }\n") + text = f''' +cmake_minimum_required(VERSION 3.24) +project(WindowsDeploymentGlue LANGUAGES CXX) +set(WIN32 TRUE) +set(DOCVIEW_DUMPBIN_EXECUTABLE [==[{tool.as_posix()}]==] CACHE FILEPATH "") +set(DOCVIEW_WINDEPLOYQT_EXECUTABLE [==[{tool.as_posix()}]==] CACHE FILEPATH "") +set(Qt6_DIR [==[{(self.root / "sdk/lib/cmake/Qt6").as_posix()}]==]) +set(DOCVIEW_PDFIUM_ROOT [==[{(self.root / "sdk").as_posix()}]==]) +set(DOCVIEW_ZIP_TARGET MockZip) +add_library(Qt6::Core SHARED IMPORTED) +set_target_properties(Qt6::Core PROPERTIES IMPORTED_LOCATION [==[{qt.as_posix()}]==]) +add_library(MockZip SHARED IMPORTED) +set_target_properties(MockZip PROPERTIES IMPORTED_LOCATION [==[{archive.as_posix()}]==]) +add_library(qpdf::libqpdf SHARED IMPORTED) +set_target_properties(qpdf::libqpdf PROPERTIES IMPORTED_LOCATION [==[{qpdf.as_posix()}]==]) +include([==[{DEPLOYMENT.as_posix()}]==]) +foreach(name docview-pdf-worker docview-archive-worker docview) + add_executable(${{name}} dummy.cpp) + set_target_properties(${{name}} PROPERTIES SUFFIX ".exe" RUNTIME_OUTPUT_DIRECTORY [==[{self.package.as_posix()}]==]) +endforeach() +docview_configure_windows_worker(docview-pdf-worker) +docview_install_windows_worker(docview-pdf-worker) +docview_install_windows_worker(docview-archive-worker) +install(FILES [==[{(self.package / "docview.exe").as_posix()}]==] DESTINATION bin) +docview_install_windows_gui(docview) +''' + (project / "CMakeLists.txt").write_text(text, encoding="utf-8") + self.run_command([CMAKE, "-S", str(project), "-B", str(build), "-DCMAKE_BUILD_TYPE=Release", + f"-DCMAKE_INSTALL_PREFIX={self.root / 'configure-prefix'}"]) + return build, trace + + @unittest.skipIf(os.name == "nt", "Portable glue probe uses a Unix executable Python stand-in; native deployment is separate") + def test_generated_glue_uses_runtime_prefix_and_single_destdir(self): + build, trace = self.configure_synthetic_deployment() + stage = (build / "stage-docview-pdf-worker-Release.cmake").read_text() + self.assertIn((self.package / "docview-pdf-worker.exe").as_posix(), stage) + self.assertNotIn("$<", stage) + self.assertIn("CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL dumpbin", stage) + self.assertIn((self.root / "qpdf/bin").as_posix(), stage) + install = (build / "install-docview-pdf-worker-Release.cmake").read_text() + self.assertIn('${CMAKE_INSTALL_PREFIX}/bin', install) + self.assertNotIn('$ENV{DESTDIR}', install) + prefix = self.root / "selected prefix" + destdir = self.root / "stage root" + environment = os.environ.copy() + environment["DESTDIR"] = str(destdir) + self.run_command([CMAKE, "--install", str(build), "--config", "Release", "--prefix", str(prefix)], env=environment) + actual = Path(str(destdir) + str(prefix)) / "bin" + self.assertTrue((actual / "docview-pdf-worker.exe").exists()) + self.assertTrue((actual / "docview-archive-worker.exe").exists()) + self.assertFalse(prefix.exists()) + self.assertFalse((self.root / "configure-prefix").exists()) + arguments = json.loads(trace.read_text()) + self.assertEqual(arguments[arguments.index("--dir") + 1], str(actual)) + self.assertIn("--nopatchqt", arguments) + self.assertIn("Plugins=plugins", (actual / "qt.conf").read_text()) + + @unittest.skipIf(os.name == "nt", "Portable glue probe uses a Unix executable Python stand-in; native deployment is separate") + def test_generated_glue_rejects_worker_dll_modified_by_deployment(self): + build, _ = self.configure_synthetic_deployment() + environment = os.environ.copy() + environment.pop("DESTDIR", None) + environment["DOCVIEW_MANIFEST_TEST_TAMPER"] = "1" + prefix = self.root / "tampered install" + result = self.run_command([CMAKE, "--install", str(build), "--config", "Release", "--prefix", str(prefix)], + success=False, env=environment) + self.assertIn("no longer matches", result.stderr) + + @unittest.skipIf(sys.platform == "win32", "This assertion is for unsupported scanner hosts") + def test_non_windows_native_scanner_fails_explicitly(self): + result = self.invoke(STAGER, [("WORKER_EXECUTABLE", self.executable)], success=False) + self.assertIn("requires a Windows host", result.stderr) + self.assertFalse(self.manifest.exists()) + + +if __name__ == "__main__": + if not CMAKE: + raise SystemExit("cmake is required") + unittest.main() diff --git a/tests/test_runtime_staging.cpp b/tests/test_runtime_staging.cpp new file mode 100644 index 0000000..dbf50e1 --- /dev/null +++ b/tests/test_runtime_staging.cpp @@ -0,0 +1,153 @@ +#include "common/windows_runtime_staging.h" +#include "common/windows_pipe.h" +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#endif + +using namespace docview; +namespace { +const QByteArray executableBytes = "MZ synthetic worker test; never executed"; +const QByteArray libraryBytes = "MZ synthetic DLL test; never loaded"; +QJsonObject entry(const QString &name, const QByteArray &bytes) { + return {{"path", name}, {"size", bytes.size()}, + {"sha256", QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex())}}; +} +QJsonObject manifest() { + return {{"schemaVersion", 1}, {"executable", "worker.exe"}, + {"files", QJsonArray{entry("worker.exe", executableBytes), entry("Qt6Core.dll", libraryBytes)}}}; +} +bool write(const QString &path, const QByteArray &bytes) { + QFile file(path); + return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size(); +} +QByteArray read(const QString &path) { + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray{}; +} +bool populate(const QString &directory) { + return write(directory + "/worker.exe", executableBytes) && + write(directory + "/Qt6Core.dll", libraryBytes) && + write(directory + "/worker.exe.runtime.json", QJsonDocument(manifest()).toJson()); +} +} + +class RuntimeStagingTest : public QObject { + Q_OBJECT + private slots: + void invalidPipeHandlesAreRejected() { + WindowsPipeDevice device; + QString error; + QVERIFY(!device.adopt(0, 0, &error)); + QVERIFY(!device.isOpen()); + QVERIFY(!error.isEmpty()); +#ifndef Q_OS_WIN + QVERIFY(!createWindowsPipePair(&error)); + QVERIFY(error.startsWith("E_SANDBOX_UNAVAILABLE")); +#endif + } + void schema_data() { + QTest::addColumn("data"); + QTest::addColumn("valid"); + auto row = [](const char *name, const QJsonObject &object, bool valid = false) { + QTest::newRow(name) << QJsonDocument(object).toJson() << valid; + }; + row("valid", manifest(), true); + auto object = manifest(); object["schemaVersion"] = 2; row("future-version", object); + object = manifest(); object["schemaVersion"] = "1"; row("string-version", object); + object = manifest(); object["extra"] = true; row("unknown-property", object); + object = manifest(); object["executable"] = "other.exe"; row("wrong-executable", object); + object = manifest(); object["files"] = QJsonArray{}; row("empty-files", object); + object = manifest(); object["files"] = QJsonArray{entry("Qt6Core.dll", libraryBytes)}; row("missing-exe", object); + for (const auto &name : {"../evil.dll", "C:/evil.dll", "folder/evil.dll", "file.dll:stream", "CON.dll", "other.exe", ".hidden.dll"}) { + object = manifest(); object["files"] = QJsonArray{entry("worker.exe", executableBytes), entry(name, libraryBytes)}; + row(name, object); + } + object = manifest(); object["files"] = QJsonArray{entry("worker.exe", executableBytes), entry("Qt6Core.dll", libraryBytes), entry("QT6CORE.DLL", libraryBytes)}; + row("case-insensitive-collision", object); + for (const auto size : {-1.0, 0.0, 0.5, 268435457.0}) { + auto file = entry("Qt6Core.dll", libraryBytes); file["size"] = size; + object = manifest(); object["files"] = QJsonArray{entry("worker.exe", executableBytes), file}; + const auto name = QByteArray("invalid-size-") + QByteArray::number(size); + row(name.constData(), object); + } + auto file = entry("Qt6Core.dll", libraryBytes); file["sha256"] = QString(64, 'g'); + object = manifest(); object["files"] = QJsonArray{entry("worker.exe", executableBytes), file}; row("invalid-digest", object); + QJsonArray oversized; + oversized.append(entry("worker.exe", executableBytes)); + for (int i = 0; i < 128; ++i) oversized.append(entry(QString("f%1.dll").arg(i), libraryBytes)); + object = manifest(); object["files"] = oversized; row("entry-limit", object); + QJsonArray large{entry("worker.exe", executableBytes)}; + for (int i = 0; i < 3; ++i) { auto big = entry(QString("large%1.dll").arg(i), libraryBytes); big["size"] = 256 * 1024 * 1024; large.append(big); } + object = manifest(); object["files"] = large; row("aggregate-limit", object); + QTest::newRow("byte-limit") << QByteArray(128 * 1024 + 1, ' ') << false; + QTest::newRow("malformed-json") << QByteArray("{\"files\":") << false; + } + void schema() { + QFETCH(QByteArray, data); QFETCH(bool, valid); + QVector files{{"sentinel", {}, 7}}; + QString error; + QCOMPARE(parseWindowsRuntimeManifest(data, "worker.exe", &files, &error), valid); + if (valid) { QCOMPARE(files.size(), 2); QVERIFY(error.isEmpty()); } + else { QCOMPARE(files.first().path, "sentinel"); QVERIFY(error.startsWith("E_SANDBOX_UNAVAILABLE")); } + } + void copiesOnlyDeclaredFilesAndCleansUp() { + QTemporaryDir source, cache; + QVERIFY(source.isValid() && cache.isValid() && populate(source.path())); + QVERIFY(write(source.path() + "/not-listed.dll", "must not be copied")); + QString runtime; + { + WindowsRuntimeStaging staging(cache.path()); + QString error; + QVERIFY2(staging.prepare(source.path() + "/worker.exe", &error), qPrintable(error)); + runtime = staging.directoryPath(); + QCOMPARE(read(staging.executablePath()), executableBytes); + QCOMPARE(read(runtime + "/Qt6Core.dll"), libraryBytes); + QVERIFY(!QFile::exists(runtime + "/not-listed.dll")); + QVERIFY(!staging.prepare(source.path() + "/worker.exe", &error)); + QCOMPARE(staging.directoryPath(), runtime); + } + QVERIFY(!QDir(runtime).exists()); + QCOMPARE(read(source.path() + "/worker.exe"), executableBytes); + QCOMPARE(read(source.path() + "/Qt6Core.dll"), libraryBytes); + } + void changedBytesLeaveNoRuntime() { + QTemporaryDir source, cache; + QVERIFY(populate(source.path())); + auto changed = libraryBytes; changed[0] = '!'; + QVERIFY(write(source.path() + "/Qt6Core.dll", changed)); + WindowsRuntimeStaging staging(cache.path()); + QString error; + QVERIFY(!staging.prepare(source.path() + "/worker.exe", &error)); + QVERIFY(staging.directoryPath().isEmpty()); + QVERIFY(QDir(cache.path()).entryList(QDir::AllEntries | QDir::NoDotAndDotDot).isEmpty()); + QCOMPARE(read(source.path() + "/Qt6Core.dll"), changed); + } + void linkedInputRejected() { + QTemporaryDir source, cache; + QVERIFY(populate(source.path())); + QVERIFY(QFile::rename(source.path() + "/Qt6Core.dll", source.path() + "/original.dll")); + QVERIFY(QFile::link(source.path() + "/original.dll", source.path() + "/Qt6Core.dll")); + WindowsRuntimeStaging staging(cache.path()); + QVERIFY(!staging.prepare(source.path() + "/worker.exe")); + QVERIFY(staging.directoryPath().isEmpty()); + } +#ifdef Q_OS_UNIX + void hardlinkedInputRejected() { + QTemporaryDir source, cache; + QVERIFY(populate(source.path())); + QCOMPARE(::link(QFile::encodeName(source.path() + "/Qt6Core.dll").constData(), QFile::encodeName(source.path() + "/alias.dll").constData()), 0); + WindowsRuntimeStaging staging(cache.path()); + QVERIFY(!staging.prepare(source.path() + "/worker.exe")); + } +#endif +}; +QTEST_GUILESS_MAIN(RuntimeStagingTest) +#include "test_runtime_staging.moc" diff --git a/tests/test_security.cpp b/tests/test_security.cpp new file mode 100644 index 0000000..26eaa6b --- /dev/null +++ b/tests/test_security.cpp @@ -0,0 +1,419 @@ +#include "common/contracts.h" +#include "common/ipc.h" +#include "common/sandbox.h" +#include "common/session_storage.h" +#include "web/resource_policy.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif +#ifdef Q_OS_LINUX +#include +#include +#include +#include +#include +#include +#endif +using namespace docview; +class SecurityTest : public QObject { + Q_OBJECT + private slots: + void pathRejection_data() { + QTest::addColumn("path"); + for (const auto &p : + QStringList{"../secret", "/etc/passwd", "C:/secret", "foo\\bar", "x/../z", "a//b", "a/./b", + "NUL", "CON.txt", "name.", "name ", "foo:bar", QString("a") + QChar(0) + "b"}) + QTest::newRow(qPrintable(p)) << p; + } + void pathRejection() { + QFETCH(QString, path); + QVERIFY(!safeResourcePath(path)); + } + void paths() { + QVERIFY(safeResourcePath(QString::fromUtf8("書籍/第一章.xhtml"))); + QVERIFY(safeResourcePath("assets/100%.png")); + QCOMPARE(pathFromDocumentUrl(QUrl("doc://session/assets/a.png?size=2#x"), "session"), + QString("assets/a.png")); + QVERIFY(pathFromDocumentUrl(QUrl("doc://other/a"), "session").isEmpty()); + QVERIFY(pathFromDocumentUrl(QUrl("doc://session/a%2fb"), "session").isEmpty()); + QVERIFY(pathFromDocumentUrl(QUrl("doc://session/%252e%252e/a"), "session").isEmpty()); + QVERIFY(pathFromDocumentUrl(QUrl("file:///etc/passwd"), "session").isEmpty()); + } + void noFollow() { + QTemporaryDir root, outside; + QFile f(outside.filePath("secret")); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("secret"); + f.close(); + QVERIFY(QFile::link(f.fileName(), root.filePath("link"))); + QVERIFY(!openResource(root.path(), "link")); + QFile ok(root.filePath("a.txt")); + QVERIFY(ok.open(QIODevice::WriteOnly)); + ok.write("hello"); + ok.close(); + auto input = openResource(root.path(), "a.txt"); + QVERIFY(input); + QCOMPARE(input->readAll(), QByteArray("hello")); + } + void resourceFailureClasses() { + QTemporaryDir root; QVERIFY(root.isValid()); + ResourceFailure failure = ResourceFailure::None; + QVERIFY(!openResource(root.path(), "absent.css", &failure)); + QCOMPARE(failure, ResourceFailure::Missing); + QVERIFY(!openResource(root.path(), "../escape", &failure)); + QCOMPARE(failure, ResourceFailure::Blocked); + QFile file(root.filePath("local.css")); QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write("body{}"), 6); file.close(); + auto valid = openResource(root.path(), "local.css", &failure); + QVERIFY(valid); QCOMPARE(failure, ResourceFailure::None); valid.reset(); +#ifdef Q_OS_LINUX + QVERIFY(::symlink("local.css", QFile::encodeName(root.filePath("symbolic.css")).constData()) == 0); + QVERIFY(!openResource(root.path(), "symbolic.css", &failure)); QCOMPARE(failure, ResourceFailure::Blocked); + QVERIFY(::link(QFile::encodeName(file.fileName()).constData(), QFile::encodeName(root.filePath("hard.css")).constData()) == 0); + QVERIFY(!openResource(root.path(), "hard.css", &failure)); QCOMPARE(failure, ResourceFailure::Blocked); + QVERIFY(QFile::remove(root.filePath("hard.css"))); + QVERIFY(QFile::setPermissions(file.fileName(), {})); + if (::geteuid() != 0) { + QVERIFY(!openResource(root.path(), "local.css", &failure)); QCOMPARE(failure, ResourceFailure::AccessDenied); + ResourceWriter denied(root.path(), "allowed.css"); QVERIFY(denied.isValid()); + } + QVERIFY(QFile::setPermissions(file.fileName(), QFile::ReadOwner | QFile::WriteOwner)); + QVERIFY(QDir().mkdir(root.filePath("private"))); + QVERIFY(QFile::setPermissions(root.filePath("private"), QFile::ReadOwner | QFile::ExeOwner)); + if (::geteuid() != 0) { + ResourceWriter denied(root.path(), "private/denied.css"); + QVERIFY(!denied.isValid()); QCOMPARE(denied.failure(), ResourceFailure::AccessDenied); + } + QVERIFY(QFile::setPermissions(root.filePath("private"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); +#endif + QVERIFY(file.open(QIODevice::WriteOnly)); QVERIFY(file.resize(256ll * 1024 * 1024 + 1)); file.close(); + QVERIFY(!openResource(root.path(), "local.css", &failure)); QCOMPARE(failure, ResourceFailure::ResourceLimit); + ResourceWriter invalid(root.path(), "../escape"); QCOMPARE(invalid.failure(), ResourceFailure::Blocked); + ResourceWriter large(root.path(), "chunk.css"); QVERIFY(large.isValid()); + QVERIFY(!large.write(QByteArray(65537, 'x'))); QCOMPARE(large.failure(), ResourceFailure::ResourceLimit); + QVERIFY(!large.commit()); // A failed write never publishes a partial resource. + ResourceWriter unavailable(root.filePath("not-there"), "asset.css"); + QVERIFY(!unavailable.isValid()); QCOMPARE(unavailable.failure(), ResourceFailure::StorageFailed); + } +#ifdef Q_OS_LINUX + void resourceReadErrorIsReportedOnce() { + QTemporaryDir root; QFile file(root.filePath("asset")); + QVERIFY(file.open(QIODevice::WriteOnly)); file.write("text"); file.close(); + auto opened = openResource(root.path(), "asset"); QVERIFY(opened); + auto *resource = qobject_cast(opened.get()); QVERIFY(resource); + QSignalSpy errors(resource, &ResourceFile::readFailed); + const int writeOnly = ::open(QFile::encodeName(file.fileName()).constData(), O_WRONLY | O_CLOEXEC); + QVERIFY(writeOnly >= 0); + QCOMPARE(::dup2(writeOnly, resource->handle()), resource->handle()); ::close(writeOnly); + QVERIFY(resource->read(1).isEmpty()); + QCOMPARE(errors.size(), 1); QCOMPARE(qvariant_cast(errors.first().first()), ResourceFailure::ReadFailed); + QVERIFY(resource->read(1).isEmpty()); QCOMPARE(errors.size(), 1); + } + void storageFullIsNotMissingOrCorrupt() { + QTemporaryDir root; ResourceWriter writer(root.path(), "asset.css"); QVERIFY(writer.isValid()); + // Inject an actual ENOSPC device into only this test writer's owned FD. + // No filesystem is filled and no production failure hooks are needed. + int target = -1; + for (const auto &name : QDir("/proc/self/fd").entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) { + const auto path = QFileInfo("/proc/self/fd/" + name).symLinkTarget(); + if (path.startsWith(root.path() + "/.docview-") && path.endsWith(".part")) target = name.toInt(); + } + QVERIFY(target >= 0); const int full = ::open("/dev/full", O_WRONLY | O_CLOEXEC); QVERIFY(full >= 0); + QCOMPARE(::dup2(full, target), target); ::close(full); + const bool wrote = writer.write("data"); + if (wrote) QVERIFY(!writer.commit()); + QCOMPARE(writer.failure(), ResourceFailure::StorageFull); + QVERIFY(!QFile::exists(root.filePath("asset.css"))); + } +#endif + void envelopes() { + QCborMap m{{"protocolVersion", 1}, {"requestId", 1}, {"sessionId", "token"}, + {"generation", 1}, {"operation", "open"}, {"payload", QCborMap{}}}; + QVERIFY(validateEnvelope(m)); + m.insert(QStringLiteral("operation"), "executeShell"); + QVERIFY(!validateEnvelope(m)); + m.insert(QStringLiteral("operation"), "open"); + m.insert(QStringLiteral("protocolVersion"), 2); + QVERIFY(!validateEnvelope(m)); + m.insert(QStringLiteral("protocolVersion"), 1); + m.insert(QStringLiteral("result"), QCborMap{}); + QVERIFY(!validateEnvelope(m)); + } + void resourceWriterPublishesOnlyCompletedData() { + QTemporaryDir root; + { + ResourceWriter writer(root.path(), "nested/book.css"); + QVERIFY(writer.isValid()); + QVERIFY(writer.write("body {")); + QVERIFY(!openResource(root.path(), "nested/book.css")); + QVERIFY(writer.write("color: black;}")); + QCOMPARE(writer.size(), 20); + QVERIFY(writer.commit()); + QVERIFY(!writer.write("late")); + } + auto file = openResource(root.path(), "nested/book.css"); + QVERIFY(file); + QCOMPARE(file->readAll(), QByteArray("body {color: black;}")); + { + ResourceWriter cancelled(root.path(), "nested/cancelled.css"); + QVERIFY(cancelled.write("incomplete")); + } + QVERIFY(!openResource(root.path(), "nested/cancelled.css")); + QCOMPARE(QDir(root.filePath("nested")).entryList(QDir::Files | QDir::Hidden), + QStringList{"book.css"}); + } + void resourceWriterRejectsEscapesAndOversizedChunks() { + QTemporaryDir root, outside; + QFile secret(outside.filePath("secret")); + QVERIFY(secret.open(QIODevice::WriteOnly)); + QCOMPARE(secret.write("original"), 8); + secret.close(); + QVERIFY(QFile::link(outside.path(), root.filePath("escape"))); + ResourceWriter escaped(root.path(), "escape/secret"); + QVERIFY(!escaped.isValid()); + ResourceWriter traversal(root.path(), "../secret"); + QVERIFY(!traversal.isValid()); + QVERIFY(QFile::link(secret.fileName(), root.filePath("target"))); + { + ResourceWriter linked(root.path(), "target"); + QVERIFY(linked.isValid()); + QVERIFY(linked.write("changed")); + QVERIFY(!linked.commit()); + } + ResourceWriter tooLarge(root.path(), "large"); + QVERIFY(tooLarge.isValid()); + QVERIFY(!tooLarge.write(QByteArray(65537, 'x'))); + QCOMPARE(tooLarge.size(), 0); + QVERIFY(secret.open(QIODevice::ReadOnly)); + QCOMPARE(secret.readAll(), QByteArray("original")); + } + void frames() { + QLocalServer server; + server.setSocketOptions(QLocalServer::UserAccessOption); + QVERIFY(server.listen("docview-test-" + QUuid::createUuid().toString(QUuid::Id128))); + QLocalSocket client; + client.connectToServer(server.fullServerName()); + QVERIFY(client.waitForConnected()); + QVERIFY(server.waitForNewConnection()); + auto peer = server.nextPendingConnection(); + IpcChannel sender(&client), receiver(peer); + QSignalSpy received(&receiver, &IpcChannel::messageReceived); + QCborMap m{{"protocolVersion", 1}, {"requestId", 1}, {"sessionId", "token"}, + {"generation", 1}, {"operation", "open"}, {"payload", QCborMap{}}}; + QVERIFY(sender.send(m)); + QTRY_COMPARE(received.size(), 1); + QSignalSpy error(&receiver, &IpcChannel::protocolError); + char header[4]; + qToBigEndian(quint32(MaxControlFrame + 1), header); + client.write(header, 4); + client.flush(); + QTRY_COMPARE(error.size(), 1); + } + + void metadataBudgets() { + QVariantList out; + QString error; + qint64 used = 0; + QVariantList rows{ + QVariantMap{{"title", "Chapter"}, {"page", 0}, {"attackerData", QString(65536, 'x')}}}; + QVERIFY(sanitizeNavigation(rows, &out, &used, &error)); + QVERIFY(!out.first().toMap().contains("attackerData")); + QVERIFY(used < 100); + used = MaxNavigationBytes - 1; + QVERIFY(!sanitizeNavigation(rows, &out, &used, &error)); + used = 0; + QVERIFY(!sanitizeNavigation({QVariantMap{{"title", QString(4097, 'x')}}}, &out, &used, &error)); + QVERIFY(!sanitizeNavigation({QVariantMap{{"page", 4294967296ll}}}, &out, &used, &error)); + QVariantMap page{{"pageIndex", 1}, + {"width", 200}, + {"height", 200}, + {"rotation", 0}, + {"cropBox", QVariantList{0, 0, 200, 200}}}; + QVERIFY(!validatePageMetadata({page}, 2, 0)); + QVERIFY(validatePageMetadata({page}, 2, 1)); + } + void sessionCleanup() { + QTemporaryDir base; + QString stale = base.filePath("docview-expired"), live = base.filePath("docview-live"), + other = base.filePath("docview-unowned"); + for (const auto &p : {stale, live, other}) + QVERIFY(QDir().mkdir(p)); + for (const auto &p : {stale, live}) { + QFile f(p + "/.docview-session"); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("docview-session-v1\n"); + f.close(); + QVERIFY(QDir(p).mkdir("nested")); + QFile payload(p + "/nested/.payload"); + QVERIFY(payload.open(QIODevice::WriteOnly)); + QCOMPARE(payload.write("payload"), 7); + } + QLockFile lock(live + "/.lock"); + QVERIFY(lock.tryLock()); + cleanExpiredSessions(base.path()); + QVERIFY(!QFileInfo::exists(stale)); + QVERIFY(QFileInfo::exists(live)); + QVERIFY(QFileInfo::exists(live + "/.docview-session")); + QVERIFY(QFileInfo::exists(live + "/nested/.payload")); + QVERIFY(QFileInfo::exists(other)); + lock.unlock(); + cleanExpiredSessions(base.path()); + QVERIFY(!QFileInfo::exists(live)); + QVERIFY(QFileInfo::exists(other)); + } + void sessionCleanupRetriesFailedPayloadRemoval() { +#if defined(Q_OS_LINUX) || defined(Q_OS_WIN) +#ifdef Q_OS_LINUX + if (geteuid() == 0) + QSKIP("The permission failure fixture requires an unprivileged user"); +#endif + QTemporaryDir base; + const QString stale = base.filePath("docview-retry"); + QVERIFY(QDir().mkpath(stale + "/nested")); + const QString markerPath = stale + "/.docview-session"; + QFile marker(markerPath); + QVERIFY(marker.open(QIODevice::WriteOnly)); + QCOMPARE(marker.write("docview-session-v1\n"), 19); + marker.close(); + const QString payloadPath = stale + "/nested/payload"; + QFile payload(payloadPath); + QVERIFY(payload.open(QIODevice::WriteOnly)); + QCOMPARE(payload.write("retry"), 5); + payload.close(); +#ifdef Q_OS_WIN + struct OpenFile { + HANDLE handle; + ~OpenFile() { + if (handle != INVALID_HANDLE_VALUE) + CloseHandle(handle); + } + } held{CreateFileW(reinterpret_cast(payloadPath.utf16()), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, + nullptr)}; + QVERIFY(held.handle != INVALID_HANDLE_VALUE); +#else + const auto originalPermissions = QFile::permissions(stale + "/nested"); + QVERIFY(QFile::setPermissions(stale + "/nested", QFile::ReadOwner | QFile::ExeOwner)); +#endif + cleanExpiredSessions(base.path()); + const bool markerSurvived = QFileInfo::exists(markerPath); + const bool payloadSurvived = QFileInfo::exists(payloadPath); + const bool leaseRemoved = !QFileInfo::exists(stale + "/.lock"); +#ifdef Q_OS_WIN + QVERIFY(CloseHandle(held.handle)); + held.handle = INVALID_HANDLE_VALUE; +#else + QVERIFY(QFile::setPermissions(stale + "/nested", originalPermissions)); +#endif + QVERIFY(markerSurvived); + QVERIFY(payloadSurvived); + QVERIFY(leaseRemoved); + cleanExpiredSessions(base.path()); + QVERIFY(!QFileInfo::exists(stale)); +#else + QSKIP("The deletion failure fixture supports Linux and Windows"); +#endif + } + void osSandbox() { +#ifdef Q_OS_LINUX + QTemporaryDir allowed, blocked; + QFile permitted(allowed.filePath("source")), secret(blocked.filePath("secret")); + for (QFile *f : {&permitted, &secret}) { + QVERIFY(f->open(QIODevice::WriteOnly)); + f->write("test"); + f->close(); + } + const pid_t pid = fork(); + QVERIFY(pid >= 0); + if (pid == 0) { + QString error; + if (!enterSandbox({permitted.fileName()}, {allowed.path()}, &error)) + _exit(10); + int fd = open(QFile::encodeName(permitted.fileName()).constData(), O_RDONLY); + if (fd < 0) + _exit(11); + close(fd); + fd = open(QFile::encodeName(secret.fileName()).constData(), O_RDONLY); + if (fd >= 0) + _exit(12); + fd = open(QFile::encodeName(blocked.filePath("created")).constData(), O_CREAT | O_WRONLY, 0600); + if (fd >= 0) + _exit(13); + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) + _exit(14); + if (fork() >= 0) + _exit(15); + if (kill(getppid(), 0) == 0) + _exit(17); + rlimit limit{}; + if (getrlimit(RLIMIT_AS, &limit) || limit.rlim_cur != 1536ull * 1024 * 1024) + _exit(16); + _exit(0); + } + int status = 0; + QVERIFY(waitpid(pid, &status, 0) == pid); + QVERIFY(WIFEXITED(status)); + QCOMPARE(WEXITSTATUS(status), 0); +#else + QSKIP("Native Linux boundary test"); +#endif + } + void systemFontsNeedExplicitReadGrants() { +#ifdef Q_OS_LINUX + QString fontPath; + QDirIterator fonts("/usr/share/fonts", {"*.ttf", "*.otf", "*.ttc"}, QDir::Files, + QDirIterator::Subdirectories); + while (fonts.hasNext()) { + QFile font(fonts.next()); + if (font.open(QIODevice::ReadOnly) && !font.read(1).isEmpty()) { + fontPath = QFileInfo(font.fileName()).canonicalFilePath(); + break; + } + } + if (fontPath.isEmpty()) + QSKIP("No readable system font is installed under /usr/share/fonts"); + const QByteArray nativePath = QFile::encodeName(fontPath); + const pid_t pid = fork(); + QVERIFY(pid >= 0); + if (pid == 0) { + const int before = open(nativePath.constData(), O_RDONLY); + if (before < 0) + _exit(20); + close(before); + QString error; + if (!enterSandbox({}, {}, &error)) + _exit(21); + const int after = open(nativePath.constData(), O_RDONLY); + if (after >= 0) { + close(after); + _exit(22); + } + if (errno != EACCES && errno != EPERM) + _exit(23); + _exit(0); + } + int status = 0; + QVERIFY(waitpid(pid, &status, 0) == pid); + QVERIFY(WIFEXITED(status)); + QCOMPARE(WEXITSTATUS(status), 0); +#else + QSKIP("Native Linux font read-grant test"); +#endif + } +}; +QTEST_GUILESS_MAIN(SecurityTest) +#include "test_security.moc" diff --git a/tests/test_stage_pdfium_candidate.py b/tests/test_stage_pdfium_candidate.py new file mode 100644 index 0000000..54484ee --- /dev/null +++ b/tests/test_stage_pdfium_candidate.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Synthetic input and filesystem boundary tests; never stage the real candidate.""" +from contextlib import ExitStack +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import tempfile +import unittest +from unittest import mock + +SPEC = importlib.util.spec_from_file_location( + 'stage_candidate', Path(__file__).resolve().parents[1] / 'tools/stage_pdfium_candidate.py') +stager = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(stager) + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def encoded(value): + return (json.dumps(value, sort_keys=True) + '\n').encode() + + +class CandidateTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='docview-candidate-test-') + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + self.repo = self.base / 'repo' + self.repo.mkdir() + self.output = self.base / 'prefix' + self.patches = ExitStack() + self.addCleanup(self.patches.close) + self.library = b'\x7fELF synthetic candidate; not executable\n' + self.old_header = b'// public header\n#define PUBLIC_API 0\n' + self.header = self.old_header.replace(b'PUBLIC_API 0', b'PUBLIC_API 1') + self.source_record = 'tests/results/pdfium-intent-build/source-materialization.json' + self.make_fixture() + + def put(self, path, data): + if not isinstance(data, bytes): + data = encoded(data) + target = self.repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return sha(data) + + def pin(self, name, value): + self.patches.enter_context(mock.patch.object(stager, name, value)) + + def make_fixture(self): + source = stager.SOURCE + evidence = {} + source_hashes = {} + def record(name, data): + path = 'tests/results/pdfium-intent-build/' + name + '.json' + evidence[path] = self.put(path, data) + return path + library_sha = self.put(stager.LIBRARY, self.library) + args_sha = self.put(stager.GN_ARGS, b'pdf_enable_v8 = false\npdf_enable_xfa = false\n') + version_sha = self.put('.deps/pdfium/VERSION', b'BUILD=8057\n') + patch_bytes = b'fixed synthetic core patch\n' + patch_path = 'cmake/patches/pdfium-rendering-intent.patch' + patch_sha = self.put(patch_path, patch_bytes) + snapshot = 'tests/results/pdfium-intent-build/patches/' + patch_sha + '.patch' + self.put(snapshot, patch_bytes) + changed_sha = self.put(source + '/core/changed.cpp', b'fixed source\n') + record('candidate-patch', {'success': True, 'baseRevision': stager.REVISION, + 'archiveSha256': 'a' * 64, 'patchSha256': patch_sha, 'patchSnapshot': snapshot, + 'files': [{'path': 'core/changed.cpp', 'afterSha256': changed_sha}]}) + inventory_path = 'inventory.jsonl' + inventory_sha = self.put(inventory_path, encoded({'path': 'public/fpdfview.h', + 'archived': True, 'mode': '100644', 'sha256': sha(self.old_header)})) + repositories = [{'path': '.', 'revision': stager.REVISION, 'archiveSha256': 'a' * 64, + 'inventory': inventory_path, 'inventorySha256': inventory_sha}] + for component in ('libc++', 'libc++abi'): + repositories.append({'path': 'third_party/' + component + '/src', + 'revision': 'revision-' + component}) + record('source-materialization', {'success': True, 'repositories': repositories}) + tool_sha = self.put('tool.zip', b'fixed tool archive\n') + record('tool-materialization', {'success': True, + 'tools': [{'archive': 'tool.zip', 'sha256': tool_sha}], + 'versions': {'gn': 'fixed GN', 'clang': 'fixed clang', 'lld': 'fixed lld'}}) + provider_patch = b'''diff --git a/public/fpdfview.h b/public/fpdfview.h +--- a/public/fpdfview.h ++++ b/public/fpdfview.h +@@ -1,2 +1,2 @@ + // public header +-#define PUBLIC_API 0 ++#define PUBLIC_API 1 +''' + provider_path = 'provider/header.patch' + provider_sha = self.put(provider_path, provider_patch) + record('provider-patches', [{'exitCode': 0, 'patch': provider_path, 'sha256': provider_sha}]) + record('patched-final-build', {'exitCode': 0}) + self.put(source + '/public/fpdfview.h', self.header) + self.put('.deps/pdfium/include/fpdfview.h', self.header) + licenses = [] + for index in range(15): + name = 'LICENSE' if index == 0 else 'licenses/notice-' + str(index) + '.txt' + source_name = 'upstream/license-' + str(index) + data = ('synthetic notice ' + str(index) + '\n').encode() + self.put('.deps/pdfium/' + name, data) + self.put('tests/results/source-correspondence/pdfium/' + source_name, data) + self.put(source + '/license-' + str(index), data) + licenses.append({'packagedFile': name, 'sourceFile': source_name, + 'sourceSha256': sha(data), 'packagedSha256': sha(data), + 'transformedSha256': sha(data), 'exactMatch': True}) + license_sha = self.put(stager.LICENSE_RECORD, licenses) + supplement = [] + for component, name in [('libc++', 'libcxx-LICENSE.txt'), ('libc++abi', 'libcxxabi-LICENSE.txt')]: + data = ('synthetic ' + component + ' license\n').encode() + path = 'resources/licenses/pdfium-supplemental/' + name + source_hashes[path] = self.put(path, data) + self.put(source + '/third_party/' + component + '/src/LICENSE.TXT', data) + supplement.append({'name': name, 'component': component, + 'sourceRevision': 'revision-' + component, + 'sourceUrl': 'https://example.invalid/fixed-source', 'sha256': sha(data)}) + supplement_path = 'resources/licenses/pdfium-supplemental/sources.json' + source_hashes[supplement_path] = self.put(supplement_path, + {'pdfiumUpstreamCommit': stager.REVISION, 'files': supplement}) + report_path = 'tests/results/pdfium-intent-build/candidate-final/report.json' + report_sha = self.put(report_path, {'scope': 'synthetic', 'success': False}) + master = {'schemaVersion': 5, 'evidenceSha256': evidence, 'sourceSha256': source_hashes, + 'pdfiumRenderingIntentCandidate': {'sourceRevision': stager.REVISION, + 'librarySha256': library_sha, 'library': '/recorded/repo/' + stager.LIBRARY, + 'productionDependencyReplaced': False, 'patchSha256': patch_sha, + 'patchSnapshot': snapshot, 'patch': patch_path, + 'report': report_path, 'reportSha256': report_sha}} + master_sha = self.put(stager.MASTER, master) + for key, value in [('MASTER_SHA256', master_sha), ('LIBRARY_SHA256', library_sha), + ('GN_ARGS_SHA256', args_sha), ('VERSION_SHA256', version_sha), + ('LICENSE_RECORD_SHA256', license_sha)]: + self.pin(key, value) + + def assert_clean(self): + self.assertFalse(self.output.exists()) + self.assertEqual(list(self.base.glob('.pdfium-candidate-*')), []) + + def make_v2_fixture(self): + pin = dict(stager.V2_PIN) + pin['recordPaths'] = dict(pin['recordPaths']) + master = json.loads((self.repo / stager.MASTER).read_text()) + shutil.copytree(self.repo / stager.SOURCE, self.repo / pin['source']) + self.v2_library = self.library + b'distinct v2 bytes\n' + pin['librarySha256'] = self.put(pin['library'], self.v2_library) + pin['gnArgsSha256'] = sha((self.repo / pin['gnArgs']).read_bytes()) + pin['parent'] = {'path': stager.MASTER, 'sha256': stager.MASTER_SHA256} + candidate = master['pdfiumRenderingIntentCandidate'] + candidate['library'] = '/recorded/repo/' + pin['library'] + candidate['librarySha256'] = pin['librarySha256'] + candidate['report'] = 'tests/results/pdfium-intent-context/candidate-final/report.json' + candidate['reportSha256'] = self.put(candidate['report'], {'scope': 'synthetic v2'}) + for name, path in pin['recordPaths'].items(): + original = 'tests/results/pdfium-intent-build/' + name + '.json' + if original != path: + master['evidenceSha256'][path] = self.put(path, (self.repo / original).read_bytes()) + master['schemaVersion'] = 6 + master['parent'] = pin['parent'] + pin['masterSha256'] = self.put(pin['master'], master) + self.pin('V2_PIN', pin) + return pin + + def test_stage_exact_verified_bytes_and_buildinfo(self): + info = stager.stage(self.output, self.repo) + self.assertTrue(info['candidateOnly']) + self.assertFalse(info['productionDependencyReplaced']) + self.assertEqual((self.output / 'lib/libpdfium.so').read_bytes(), self.library) + self.assertEqual((self.output / 'include/fpdfview.h').read_bytes(), self.header) + stored = json.loads((self.output / 'BUILDINFO.json').read_text()) + self.assertEqual(stored, info) + for path, row in info['files'].items(): + self.assertEqual(sha((self.output / path).read_bytes()), row['sha256']) + self.assertEqual(len([r for r in info['files'].values() if r['category'] == 'license']), 17) + self.assertEqual((self.repo / stager.LIBRARY).read_bytes(), self.library) + self.assertFalse((self.output / 'include/fpdfview.h.orig').exists()) + + def test_each_tampered_input_is_rejected_before_output(self): + targets = [stager.LIBRARY, stager.GN_ARGS, stager.MASTER, 'tool.zip', + 'provider/header.patch', '.deps/pdfium/LICENSE', + '.deps/pdfium/include/fpdfview.h', stager.SOURCE + '/core/changed.cpp', + stager.SOURCE + '/third_party/libc++/src/LICENSE.TXT', + 'tests/results/pdfium-intent-build/candidate-final/report.json', + 'tests/results/pdfium-intent-build/candidate-patch.json'] + for path in targets: + with self.subTest(path=path): + p = self.repo / path + old = p.read_bytes() + p.write_bytes(old + b'tampered') + try: + with self.assertRaises(stager.StageError): + stager.stage(self.output, self.repo) + self.assert_clean() + finally: + p.write_bytes(old) + + def test_matching_header_tampering_cannot_bypass_source_inventory(self): + for path in [stager.SOURCE + '/public/fpdfview.h', '.deps/pdfium/include/fpdfview.h']: + (self.repo / path).write_bytes(self.header + b'// same injected bytes\n') + with self.assertRaisesRegex(stager.StageError, 'fixed source'): + stager.stage(self.output, self.repo) + self.assert_clean() + + def test_forged_report_and_library_do_not_change_pinned_master(self): + (self.repo / stager.LIBRARY).write_bytes(b'other library') + master = json.loads((self.repo / stager.MASTER).read_text()) + master['pdfiumRenderingIntentCandidate']['librarySha256'] = sha(b'other library') + self.put(stager.MASTER, master) + with self.assertRaisesRegex(stager.StageError, 'SHA-256 mismatch'): + stager.stage(self.output, self.repo) + self.assert_clean() + + def test_existing_empty_directory_file_and_dangling_link_are_untouched(self): + for kind in ('directory', 'file', 'symlink'): + with self.subTest(kind=kind): + if kind == 'directory': + self.output.mkdir() + elif kind == 'file': + self.output.write_bytes(b'keep') + else: + self.output.symlink_to(self.base / 'absent') + before = self.output.lstat() + with self.assertRaisesRegex(stager.StageError, 'already exists'): + stager.stage(self.output, self.repo) + self.assertEqual(self.output.lstat().st_ino, before.st_ino) + if kind == 'directory': + self.output.rmdir() + else: + self.output.unlink() + + def test_source_leaf_symlink_rejected_even_when_bytes_match(self): + outside = self.base / 'outside' + outside.write_bytes(self.library) + library = self.repo / stager.LIBRARY + library.unlink() + library.symlink_to(outside) + with self.assertRaises(OSError): + stager.stage(self.output, self.repo) + self.assert_clean() + self.assertEqual(outside.read_bytes(), self.library) + + def test_source_parent_symlink_and_output_parent_symlink_rejected(self): + directory = self.repo / '.deps/pdfium/include' + saved = self.base / 'outside-include' + directory.rename(saved) + directory.symlink_to(saved, target_is_directory=True) + with self.assertRaises(OSError): + stager.stage(self.output, self.repo) + self.assert_clean() + destination_link = self.base / 'destination-link' + destination_link.symlink_to(self.base, target_is_directory=True) + with self.assertRaises(OSError): + stager.stage(destination_link / 'prefix', self.repo) + self.assert_clean() + + def test_non_regular_library_does_not_block(self): + p = self.repo / stager.LIBRARY + p.unlink() + os.mkfifo(p) + with self.assertRaisesRegex(stager.StageError, 'regular file'): + stager.stage(self.output, self.repo) + self.assert_clean() + + def test_mid_write_failure_removes_only_private_partial_output(self): + def fail(fd, files): + stager.write_files_original(fd, {'partial': b'partial payload'}) + raise OSError('simulated full disk') + with mock.patch.object(stager, 'write_files_original', stager.write_files, create=True), \ + mock.patch.object(stager, 'write_files', fail): + with self.assertRaisesRegex(OSError, 'full disk'): + stager.stage(self.output, self.repo) + self.assert_clean() + self.assertEqual((self.repo / stager.LIBRARY).read_bytes(), self.library) + + def test_publish_race_never_replaces_new_empty_destination(self): + publish = stager.publish + def race(fd, temporary, destination): + os.mkdir(destination, dir_fd=fd) + return publish(fd, temporary, destination) + with mock.patch.object(stager, 'publish', race): + with self.assertRaises(FileExistsError): + stager.stage(self.output, self.repo) + self.assertTrue(self.output.is_dir()) + self.assertEqual(list(self.output.iterdir()), []) + self.assertEqual(list(self.base.glob('.pdfium-candidate-*')), []) + + def test_input_replacement_after_verification_copies_verified_snapshot_only(self): + writer = stager.write_files + def replace_then_write(fd, files): + (self.repo / stager.LIBRARY).write_bytes(b'late replacement') + writer(fd, files) + with mock.patch.object(stager, 'write_files', replace_then_write): + stager.stage(self.output, self.repo) + self.assertEqual((self.output / 'lib/libpdfium.so').read_bytes(), self.library) + + def test_v2_selection_uses_fixed_context_inputs_and_keeps_v1_default(self): + pin = self.make_v2_fixture() + v1_files, v1_info = stager.verify(self.repo) + self.assertEqual(v1_files['lib/libpdfium.so'], self.library) + self.assertIn('master5', v1_info) + self.assertNotIn('provenance/master6.json', v1_files) + # Selecting v2 does not accidentally use the old candidate's library. + (self.repo / stager.LIBRARY).write_bytes(b'old candidate changed') + info = stager.stage(self.output, self.repo, candidate='v2') + self.assertEqual((self.output / 'lib/libpdfium.so').read_bytes(), self.v2_library) + self.assertEqual(info['master6'], {'path': pin['master'], 'sha256': pin['masterSha256']}) + self.assertEqual(info['parent'], pin['parent']) + self.assertNotIn('master5', info) + self.assertNotIn('provenance/master5.json', info['files']) + self.assertIn('provenance/master6.json', info['files']) + self.assertIn('provenance/parent-master5.json', info['files']) + self.assertEqual(info['files']['provenance/patched-final-build.json']['source'], + pin['recordPaths']['patched-final-build']) + self.assertEqual(len([r for r in info['files'].values() if r['category'] == 'license']), 17) + self.assertIn('provenance/rendering-intent.patch', info['files']) + self.assertIn('provenance/supplemental-notices.json', info['files']) + for path, row in info['files'].items(): + self.assertEqual(sha((self.output / path).read_bytes()), row['sha256']) + + def test_v2_tampered_context_and_parent_use_same_rejection_path(self): + pin = self.make_v2_fixture() + for path in (pin['master'], pin['parent']['path'], pin['library'], pin['gnArgs'], + pin['source'] + '/core/changed.cpp', + pin['recordPaths']['candidate-patch'], pin['recordPaths']['patched-final-build'], + 'tool.zip', '.deps/pdfium/LICENSE'): + with self.subTest(path=path): + file = self.repo / path + original = file.read_bytes() + file.write_bytes(original + b'tampered') + try: + with self.assertRaises(stager.StageError): + stager.stage(self.output, self.repo, candidate='v2') + self.assert_clean() + finally: + file.write_bytes(original) + + def test_v2_without_complete_pin_is_rejected_before_reading_inputs(self): + with mock.patch.dict(stager.V2_PIN, {'masterSha256': None}), \ + mock.patch.object(stager, 'Tree') as tree: + with self.assertRaisesRegex(stager.StageError, 'not ready'): + stager.verify(self.repo, candidate='v2') + tree.assert_not_called() + pin = self.make_v2_fixture() + for missing in ('parent', 'source', 'recordPaths'): + incomplete = dict(pin) + del incomplete[missing] + with self.subTest(missing=missing), mock.patch.object(stager, 'V2_PIN', incomplete): + with self.assertRaisesRegex(stager.StageError, 'Incomplete fixed candidate pin'): + stager.verify(self.repo, candidate='v2') + self.assert_clean() + + def test_selector_cannot_supply_arbitrary_configuration_or_paths(self): + for selector in ('v3', '../../elsewhere', {'source': '/tmp/other'}): + with self.subTest(selector=selector), mock.patch.object(stager, 'Tree') as tree: + with self.assertRaisesRegex(stager.StageError, 'Unknown fixed candidate'): + stager.verify(self.repo, candidate=selector) + tree.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_stress_generator.py b/tests/test_stress_generator.py new file mode 100644 index 0000000..a646f0b --- /dev/null +++ b/tests/test_stress_generator.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Small deterministic checks; this test never creates the ~1 GiB workload.""" +import hashlib +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from generate_stress_pdf import IMAGE_BYTES, generate, validate_source + + +class StressGeneratorTest(unittest.TestCase): + def test_real_images_are_unique_used_and_deterministic(self): + with TemporaryDirectory(prefix="docview-generator-test-") as directory: + paths = [Path(directory) / name for name in ("a.pdf", "b.pdf")] + first, second = (generate(path, 5, 2) for path in paths) + self.assertEqual(first["sha256"], second["sha256"]) + data = paths[0].read_bytes() + self.assertEqual(hashlib.sha256(data).hexdigest(), first["sha256"]) + self.assertEqual(first["rasterPayloadBytes"], 2 * IMAGE_BYTES) + self.assertEqual(validate_source(paths[0], first)["uniqueRasterDigests"], 2) + self.assertEqual(data.count(b"/Subtype /Image"), 2) + self.assertNotIn(b"/Filter", data) + self.assertEqual(data.count(b"/Scan 4 0 R"), 3) + self.assertEqual(data.count(b"/Scan 5 0 R"), 2) + self.assertIn(b"DOCVIEW STRESS PAGE 00005 OF 00005 IMAGE 0000", data) + + def test_disk_limit_and_existing_files_fail_before_writing(self): + with TemporaryDirectory(prefix="docview-generator-test-") as directory: + path = Path(directory) / "source.pdf" + with patch("generate_stress_pdf.shutil.disk_usage") as usage: + usage.return_value.free = 0 + with self.assertRaises(RuntimeError): + generate(path, 5, 2) + self.assertFalse(path.exists()) + path.write_bytes(b"preserve me") + with self.assertRaises(FileExistsError): + generate(path, 5, 2) + self.assertEqual(path.read_bytes(), b"preserve me") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stress_runner.py b/tests/test_stress_runner.py new file mode 100644 index 0000000..ebf9c96 --- /dev/null +++ b/tests/test_stress_runner.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Finite runner/schema regressions. Never create a large PDF or launch a GUI.""" +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import run_stress_pdf as runner + + +def measurement(source_hash): + actions = [{"index": i, "series": "page-scroll", "command": "zoom.in" if i % 10 == 8 else + "zoom.fit_width" if i % 10 == 9 else "nav.page", "positionChanged": True, + "cacheHit": i % 2 == 0, "firstResponseFrameMs": 2, "finalQualityFrameMs": 3} for i in range(100)] + return {"schemaVersion": 2, "success": True, "format": "pdf", "documentSha256": source_hash, + "requestedOpenTimings": 3, "requestedCloseCycles": 2, "openCount": 3, + "openFrameMs": [1, 2, 3], "openRecords": [{}, {}, {}], "operations": actions, + "maximumMemoryPressureLevel": 0, "continuousScroll": {"requestedInputs": 0, "dispatchedInputs": 0}, + "tileCachePeakChargedBytes": 10, "tileCacheBudgetBytes": 20, + "openCloseCycles": [{"workload": "open-first-visible-only" if i < 2 else "navigation-and-scroll", + "stateEmpty": True, "cacheBytesAfterClose": 0} for i in range(3)]} + + +class StressRunnerTest(unittest.TestCase): + def test_schema_requires_exact_opens_pressure_zero_and_real_navigation(self): + valid = measurement("source") + checks, summary = runner.summarize_benchmark(valid, "source") + self.assertTrue(all(checks.values())) + self.assertEqual(summary["cachedNavigation"]["n"], 40) + self.assertEqual(summary["uncachedNavigation"]["n"], 40) + for field, value in [("schemaVersion", 1), ("openCount", 11), ("maximumMemoryPressureLevel", 3), + ("documentSha256", "changed"), ("requestedCloseCycles", 10), + ("tileCachePeakChargedBytes", 21)]: + with self.subTest(field=field): + invalid = copy.deepcopy(valid); invalid[field] = value + self.assertFalse(all(runner.summarize_benchmark(invalid, "source")[0].values())) + invalid = copy.deepcopy(valid) + invalid["operations"][0]["positionChanged"] = False + checks, summary = runner.summarize_benchmark(invalid, "source") + self.assertFalse(checks["page_navigation_moved"]) + self.assertEqual(summary["excludedNoopPageNavigation"], 1) + self.assertEqual(summary["cachedNavigation"]["n"], 39) + invalid = copy.deepcopy(valid); invalid["operations"][0]["finalQualityFrameMs"] = float("nan") + self.assertFalse(runner.summarize_benchmark(invalid, "source")[0]["finite_timings"]) + invalid = copy.deepcopy(valid); invalid["openCloseCycles"][0]["cacheBytesAfterClose"] = 1 + self.assertFalse(runner.summarize_benchmark(invalid, "source")[0]["close_releases_document"]) + + def test_missing_executable_removes_prior_aggregate_without_generating(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary); output = root / "output"; output.mkdir() + for name in ["report.json", "benchmark.json", "source-manifest.json"]: + (output / name).write_text('{"success":true}') + unrelated = output / "keep.txt"; unrelated.write_text("keep") + with patch.object(runner, "generate") as generate, patch("builtins.print"): + code = runner.main(["--binary", str(root / "absent"), "--output", str(output), "--work-directory", str(root / "work")]) + self.assertEqual(code, 1); generate.assert_not_called() + self.assertFalse(json.loads((output / "report.json").read_text())["success"]) + self.assertFalse((output / "benchmark.json").exists()) + self.assertFalse((output / "source-manifest.json").exists()) + self.assertEqual(unrelated.read_text(), "keep") + + def test_explicit_workload_and_executable_changes_gate_success(self): + for mutate in (False, True): + with self.subTest(mutate=mutate), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary); output = root / "output" + for name in ("docview", "docview-pdf-worker", "docview-archive-worker"): + (root / name).write_bytes(b"fixture executable") + source_hash = hashlib.sha256(b"synthetic small test").hexdigest() + def generate(path): + path.write_bytes(b"synthetic small test") + return {"sha256": source_hash, "sizeBytes": path.stat().st_size, "images": []} + def independent(command, **kwargs): + if command[0].endswith("pdfinfo"): + return subprocess.CompletedProcess(command, 0, stdout="Pages: 5000\n") + page = int(command[command.index("-f") + 1]) + return subprocess.CompletedProcess(command, 0, stdout=f"DOCVIEW STRESS PAGE {page:05d} OF 05000 IMAGE {(page - 1) % 1280:04d}") + def observed(command, env, directory, name, **kwargs): + if name == "benchmark": + for flag, value in [("--benchmark-runs", "3"), ("--benchmark-close-cycles", "2"), + ("--benchmark-operations", "100"), ("--benchmark-scroll-steps", "0")]: + self.assertEqual(command[command.index(flag) + 1], value) + (directory / "benchmark.json").write_text(json.dumps(measurement(source_hash))) + else: + smoke = directory / "last-page"; smoke.mkdir() + (smoke / "state.txt").write_text("Ready\npdf\n\n") + (smoke / "runtime.json").write_text('{"qtPlatform":"xcb"}') + (smoke / "screen.png").write_bytes(b"test fixture image") + if mutate: (root / "docview-pdf-worker").write_bytes(b"changed executable") + return {"exitCode": 0} + with patch.object(runner, "generate", side_effect=generate), patch.object(runner, "validate_source", return_value={}), \ + patch.object(runner, "run_observed", side_effect=observed), patch.object(runner.subprocess, "run", side_effect=independent), \ + patch.dict(runner.os.environ, {"QTWEBENGINE_DISABLE_SANDBOX": "0", "QTWEBENGINE_CHROMIUM_FLAGS": ""}), patch("builtins.print"): + code = runner.main(["--binary", str(root / "docview"), "--output", str(output), "--work-directory", str(root / "work")]) + result = json.loads((output / "report.json").read_text()) + self.assertEqual(code, 1 if mutate else 0) + self.assertEqual(result["success"], not mutate) + self.assertEqual(result["executablesUnchanged"], not mutate) + self.assertTrue(result["originalUnchanged"]); self.assertTrue(result["sourceRemoved"]) + self.assertEqual(set(result["executableSha256"]), {"docview", "docview-pdf-worker", "docview-archive-worker"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system_font_adapter.cpp b/tests/test_system_font_adapter.cpp new file mode 100644 index 0000000..53e731b --- /dev/null +++ b/tests/test_system_font_adapter.cpp @@ -0,0 +1,185 @@ +#include +#include +#include +#include +#include "pdf/system_font_adapter.h" +using namespace docview; +namespace { +void put16(QByteArray &bytes, int offset, quint16 value) { qToBigEndian(value, bytes.data() + offset); } +void put32(QByteArray &bytes, int offset, quint32 value) { qToBigEndian(value, bytes.data() + offset); } +void face(QByteArray &bytes, int offset, int dataOffset) { + put32(bytes, offset, 0x00010000); put16(bytes, offset + 4, 1); + put32(bytes, offset + 12, 0x68656164); // head, arbitrary contents for adapter tests only + put32(bytes, offset + 20, dataOffset); put32(bytes, offset + 24, 8); +} +QByteArray sfnt(int size = 64) { + QByteArray bytes(size, '\0'); face(bytes, 0, 32); bytes.replace(32, 8, "HEAD0000"); return bytes; +} +QByteArray ttc() { + QByteArray bytes(128, '\0'); bytes.replace(0, 4, "ttcf"); put32(bytes, 4, 0x00010000); + put32(bytes, 8, 2); put32(bytes, 12, 32); put32(bytes, 16, 80); + face(bytes, 32, 112); face(bytes, 80, 120); + bytes.replace(112, 8, "FACE0000"); bytes.replace(120, 8, "FACE1111"); return bytes; +} +struct Provider { + QByteArray bytes = sfnt(); + int index = 0; qint64 offset = -1; + int maps = 0, reads = 0, closes = 0; + int maxRead = 0; bool released = false; + QByteArray requestedName; + QString fault; + QCborMap run(const QString &operation, const QCborMap &payload, int timeout) { + if (timeout <= 0 || timeout > 5000) qFatal("invalid font timeout"); + const QString id = QString(32, 'a'); + if (operation == "font.map") { + ++maps; released = false; requestedName = payload.value("faceLocal").toByteArray(); + if (fault == "not-found") return {{"found", false}}; + if (fault == "timeout") return {{"error", QCborMap{{"code", "E_WORKER_TIMEOUT"}, {"message", "timeout"}}}}; + if (fault == "exception") throw std::runtime_error("provider failed"); + QCborMap result{{"found", true}, {"fontId", id}, {"actualFaceLocal", QByteArray("Actual Face")}, + {"charset", 128}, {"size", bytes.size()}, {"sha256", QCryptographicHash::hash(bytes, QCryptographicHash::Sha256)}, {"substituted", true}}; + if (offset < 0) result.insert(QStringLiteral("faceIndex"), index); else result.insert(QStringLiteral("faceOffset"), offset); + if (fault == "oversize") result.insert(QStringLiteral("size"), 64 * 1024 * 1024 + 1); + if (fault == "hash") result.insert(QStringLiteral("sha256"), QByteArray(32, 'z')); + if (fault == "two-selectors") result.insert(QStringLiteral("faceOffset"), 0); + return result; + } + if (operation == "font.close") { ++closes; released = true; return {{"released", fault != "close"}}; } + ++reads; + const auto at = payload.value("offset").toInteger(), size = payload.value("length").toInteger(); + maxRead = std::max(maxRead, int(size)); + QByteArray data = bytes.mid(at, size); + if (fault == "short") data.chop(1); + return {{"fontId", fault == "identity" ? QString(32, 'b') : id}, {"offset", at}, {"data", data}}; + } + SystemFontAdapter::Fetch fetch() { return [this](const QString &op, const QCborMap &payload, int timeout) { return run(op, payload, timeout); }; } +}; +void *map(SystemFontAdapter &adapter, const char *name = "Requested Face") { + auto *api = adapter.interface(); return api->MapFont(api, 400, false, 128, 0, name, nullptr); +} +} +class SystemFontAdapterTest : public QObject { + Q_OBJECT +private slots: + void staticFontAndShortBuffer() { + Provider provider; SystemFontAdapter adapter(provider.fetch()); auto *api = adapter.interface(); + QCOMPARE(api->version, 2); QVERIFY(!api->EnumFonts); + auto *font = map(adapter); QVERIFY(font); QVERIFY(provider.released); QCOMPARE(provider.closes, 1); + QVERIFY(!adapter.failed()); QCOMPARE(api->GetFontCharset(api, font), 128); + QCOMPARE(api->GetFontData(api, font, 0, nullptr, 0), 64ul); + QCOMPARE(api->GetFontData(api, font, 0x74746366, nullptr, 0), 0ul); + QCOMPARE(api->GetFontData(api, font, 0x68656164, nullptr, 0), 8ul); + QByteArray buffer(4, '?'); QCOMPARE(api->GetFontData(api, font, 0x68656164, reinterpret_cast(buffer.data()), 4), 8ul); + QCOMPARE(buffer, QByteArray("HEAD")); + buffer.fill('?'); QCOMPARE(api->GetFaceName(api, font, buffer.data(), buffer.size()), 12ul); QCOMPARE(buffer, QByteArray("Actu")); + buffer.resize(12); QCOMPARE(api->GetFaceName(api, font, buffer.data(), buffer.size()), 12ul); QCOMPARE(buffer, QByteArray("Actual Face\0", 12)); + api->Release(api); // No deletion of the enclosing adapter or live handle. + QCOMPARE(api->GetFontData(api, font, 0, nullptr, 0), 64ul); + api->DeleteFont(api, font); api->DeleteFont(api, font); + QCOMPARE(api->GetFontData(api, font, 0, nullptr, 0), 0ul); + } + void collectionSelectedFace_data() { + QTest::addColumn("index"); QTest::addColumn("byOffset"); + QTest::newRow("face0-index") << 0 << false; QTest::newRow("face1-index") << 1 << false; + QTest::newRow("face0-offset") << 0 << true; QTest::newRow("face1-offset") << 1 << true; + } + void collectionSelectedFace() { + QFETCH(int, index); QFETCH(bool, byOffset); + Provider provider; provider.bytes = ttc(); provider.index = index; + const int offset = index ? 80 : 32; if (byOffset) provider.offset = offset; + SystemFontAdapter adapter(provider.fetch()); auto *api = adapter.interface(); auto *font = map(adapter); QVERIFY(font); + QCOMPARE(api->GetFontData(api, font, 0x74746366, nullptr, 0), 128ul); + QCOMPARE(api->GetFontData(api, font, 0, nullptr, 0), static_cast(128 - offset)); + QByteArray bytes(1024, '?'); QCOMPARE(api->GetFontData(api, font, 0x74746366, reinterpret_cast(bytes.data()), bytes.size()), 128ul); + QCOMPARE(bytes.first(128), provider.bytes); QCOMPARE(bytes.sliced(128), QByteArray(896, '\0')); + bytes.fill('?'); QCOMPARE(api->GetFontData(api, font, 0, reinterpret_cast(bytes.data()), 4), static_cast(128 - offset)); + QCOMPARE(bytes.first(4), QByteArray::fromHex("00010000")); QCOMPARE(bytes[4], '?'); + bytes.resize(8); QCOMPARE(api->GetFontData(api, font, 0x68656164, reinterpret_cast(bytes.data()), 8), 8ul); + QCOMPARE(bytes, index ? QByteArray("FACE1111") : QByteArray("FACE0000")); + api->DeleteFont(api, font); + } + void chunkedTransferCacheAndRawName() { + Provider provider; provider.bytes = sfnt(2 * 65536 + 19); + SystemFontAdapter adapter(provider.fetch()); auto *api = adapter.interface(); + const QByteArray local = QByteArray::fromHex("824f829082b1"); auto *first = map(adapter, local.constData()); QVERIFY(first); + QCOMPARE(provider.requestedName, local); QCOMPARE(provider.reads, 3); QCOMPARE(provider.maxRead, 65536); + QCOMPARE(adapter.transferredBytes(), provider.bytes.size()); QCOMPARE(adapter.cacheBytes(), provider.bytes.size()); + provider.bytes[32] = '!'; // A different hash creates a distinct immutable snapshot. + auto *second = map(adapter); QVERIFY(second); QCOMPARE(provider.reads, 6); + char old[8]; QCOMPARE(api->GetFontData(api, first, 0x68656164, reinterpret_cast(old), 8), 8ul); + QCOMPARE(QByteArray(old, 8), QByteArray("HEAD0000")); + auto *third = map(adapter); QVERIFY(third); QCOMPARE(provider.reads, 6); QCOMPARE(provider.maps, 3); QCOMPARE(provider.closes, 3); + QCOMPARE(adapter.cacheBytes(), 2 * provider.bytes.size()); QVERIFY(!adapter.warnings().isEmpty()); + api->DeleteFont(api, first); api->DeleteFont(api, second); api->DeleteFont(api, third); + } + void collectionChecksumPrefix() { + Provider provider; provider.bytes = ttc(); provider.bytes.append(QByteArray(2048 - provider.bytes.size(), '\0')); + SystemFontAdapter adapter(provider.fetch()); auto *api = adapter.interface(); auto *font = map(adapter); QVERIFY(font); + QByteArray sample(1024, '?'); + QCOMPARE(api->GetFontData(api, font, 0x74746366, reinterpret_cast(sample.data()), sample.size()), 2048ul); + QCOMPARE(sample, provider.bytes.first(1024)); + api->DeleteFont(api, font); + } + void cacheEvictsOnlyUnreferencedSnapshots() { + Provider provider; SystemFontAdapter adapter(provider.fetch(), 128); auto *api = adapter.interface(); + auto *first = map(adapter); QVERIFY(first); + provider.bytes[32] = '2'; auto *second = map(adapter); QVERIFY(second); QCOMPARE(adapter.cacheBytes(), 128); + api->DeleteFont(api, first); + provider.bytes[32] = '3'; auto *third = map(adapter); QVERIFY(third); QCOMPARE(adapter.cacheBytes(), 128); + QByteArray contents(8, '?'); api->GetFontData(api, second, 0x68656164, reinterpret_cast(contents.data()), 8); + QCOMPARE(contents, QByteArray("2EAD0000")); // The still-referenced second snapshot survives. + provider.bytes[32] = '4'; const auto reads = provider.reads; + QVERIFY(!map(adapter)); QCOMPARE(adapter.errorCode(), "E_FONT_LIMIT"); + QCOMPARE(adapter.cacheBytes(), 128); QCOMPARE(provider.reads, reads); QCOMPARE(provider.closes, 4); + api->DeleteFont(api, second); api->DeleteFont(api, third); + } + void malformedData_data() { + QTest::addColumn("damage"); + for (const auto *name : {"short-header", "directory", "too-many-tables", "table-offset", "overflow-length", "duplicate-tag", "face-count", "face-offset", "face-index", "duplicate-face", "other-face-directory", "signature-range", "variable", "color"}) + QTest::newRow(name) << QString::fromLatin1(name); + } + void malformedData() { + QFETCH(QString, damage); Provider provider; + if (damage == "short-header") provider.bytes = QByteArray(4, '\0'); + if (damage == "directory") provider.bytes.resize(20); + if (damage == "too-many-tables") put16(provider.bytes, 4, 4097); + if (damage == "table-offset") put32(provider.bytes, 20, provider.bytes.size()); + if (damage == "overflow-length") put32(provider.bytes, 24, 0xffffffff); + if (damage == "duplicate-tag") { put16(provider.bytes, 4, 2); provider.bytes.replace(28, 16, provider.bytes.mid(12, 16)); } + if (damage.startsWith("face") || damage == "duplicate-face" || damage == "other-face-directory") provider.bytes = ttc(); + if (damage == "face-count") put32(provider.bytes, 8, 0xffffffff); + if (damage == "face-offset") provider.offset = 33; + if (damage == "face-index") provider.index = 2; + if (damage == "duplicate-face") put32(provider.bytes, 16, 32); + if (damage == "other-face-directory") put16(provider.bytes, 84, 4096); + if (damage == "signature-range") { provider.bytes = ttc(); put32(provider.bytes, 4, 0x00020000); put32(provider.bytes, 20, 0x44534947); put32(provider.bytes, 24, 0xffffffff); put32(provider.bytes, 28, 120); } + if (damage == "variable") put32(provider.bytes, 12, 0x66766172); + if (damage == "color") put32(provider.bytes, 12, 0x434f4c52); + SystemFontAdapter adapter(provider.fetch()); QVERIFY(!map(adapter)); QVERIFY(adapter.failed()); + QCOMPARE(adapter.errorCode(), "E_FONT_FAILED"); QCOMPARE(provider.closes, 1); QCOMPARE(adapter.cacheBytes(), 0); + } + void brokenTransfer_data() { + QTest::addColumn("fault"); QTest::addColumn("code"); + for (const auto *fault : {"short", "identity", "hash", "close", "oversize", "two-selectors"}) QTest::newRow(fault) << QString(fault) << QString("E_WORKER_CRASH"); + QTest::newRow("timeout") << QString("timeout") << QString("E_WORKER_TIMEOUT"); + QTest::newRow("exception") << QString("exception") << QString("E_FONT_FAILED"); + } + void brokenTransfer() { + QFETCH(QString, fault); QFETCH(QString, code); + Provider provider; provider.fault = fault; SystemFontAdapter adapter(provider.fetch()); + QVERIFY(!map(adapter)); QVERIFY(adapter.failed()); QCOMPARE(adapter.errorCode(), code); QCOMPARE(adapter.cacheBytes(), 0); + const auto maps = provider.maps; QVERIFY(!map(adapter)); QCOMPARE(provider.maps, maps); // Fatal state stays explicit. + } + void absenceAndNoProviderAreDistinctFromFailure() { + Provider provider; provider.fault = "not-found"; SystemFontAdapter adapter(provider.fetch()); + QVERIFY(!map(adapter)); QVERIFY(!adapter.failed()); QCOMPARE(provider.reads, 0); QVERIFY(!adapter.warnings().empty()); + SystemFontAdapter absent; QVERIFY(!map(absent)); QVERIFY(!absent.failed()); QVERIFY(!absent.warnings().empty()); + } + void distinctRequestQuota() { + Provider provider; provider.fault = "not-found"; SystemFontAdapter adapter(provider.fetch()); + for (int i = 0; i < 256; ++i) { QVERIFY(!map(adapter, QByteArray::number(i).constData())); QVERIFY(!adapter.failed()); } + QVERIFY(!map(adapter, "too many")); QCOMPARE(adapter.errorCode(), "E_FONT_LIMIT"); QCOMPARE(provider.maps, 256); + } +}; +QTEST_GUILESS_MAIN(SystemFontAdapterTest) +#include "test_system_font_adapter.moc" diff --git a/tests/test_translations.cpp b/tests/test_translations.cpp new file mode 100644 index 0000000..1efcb60 --- /dev/null +++ b/tests/test_translations.cpp @@ -0,0 +1,66 @@ +#include "app/translations.h" +#include "core/config.h" +#include "core/input.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; + +class TranslationsTest : public QObject { + Q_OBJECT + private slots: + void compiledCatalogIsEmbedded(); + void japaneseDiagnosticsAndQtButtons(); +}; + +void TranslationsTest::compiledCatalogIsEmbedded() +{ + QTranslator catalog; + QVERIFY(catalog.load(QStringLiteral(":/i18n/docview_ja.qm"))); + QCOMPARE(catalog.translate("Commands", "コマンドを入力してください"), QStringLiteral("コマンドを入力してください")); + QCOMPARE(catalog.translate("Archive", "アーカイブを読み取れません。"), QStringLiteral("アーカイブを読み取れません。")); +} + +void TranslationsTest::japaneseDiagnosticsAndQtButtons() +{ + ApplicationTranslations translations; + ConfigManager config; + QVERIFY(!config.loadData("[ui]\nfont_size = 'large'\n")); + QVERIFY2(config.lastError().contains(QStringLiteral("2行、ui.font_size")), qPrintable(config.lastError())); + QVERIFY(config.lastError().contains(QStringLiteral("数値"))); + QVERIFY(CommandRegistry::parse(":page wrong").error.contains(QStringLiteral("整数"))); + + QQmlEngine engine; + QQmlComponent component(&engine); + component.setData(R"( + import QtQuick + import QtQuick.Controls + Item { + width: 400; height: 300 + Dialog { id: dialog; standardButtons: Dialog.Open | Dialog.Cancel } + readonly property string openText: dialog.standardButton(Dialog.Open).text + readonly property string cancelText: dialog.standardButton(Dialog.Cancel).text + } + )", QUrl("qrc:/translation-test.qml")); + std::unique_ptr object(component.create()); + QVERIFY2(object, qPrintable(component.errorString())); + QCOMPARE(object->property("openText").toString().remove('&'), QStringLiteral("開く")); + QCOMPARE(object->property("cancelText").toString().remove('&'), QStringLiteral("キャンセル")); +} + +int main(int argc, char **argv) +{ + // The product's Japanese initial language must not depend on an English host. + QLocale::setDefault(QLocale(QLocale::English, QLocale::UnitedStates)); + QGuiApplication application(argc, argv); + TranslationsTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_translations.moc" diff --git a/tests/test_ubuntu_package.py b/tests/test_ubuntu_package.py new file mode 100644 index 0000000..00f7f7c --- /dev/null +++ b/tests/test_ubuntu_package.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Boundaries of Ubuntu staging and preservation of launcher arguments.""" +import importlib.util +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import package_ubuntu as package + + +class UbuntuPackageTests(unittest.TestCase): + def supplement_inputs(self, root): + source = ROOT / 'tests/results/qt-notice-supplement/collection' + directory = root / 'supplement' + shutil.copytree(source, directory) + base = root / 'base.json' + shutil.copyfile(ROOT / 'tests/results/source-archives/qt-sdk-notices-final/report.json', base) + report = json.loads((directory / 'report.json').read_text()) + known = {row['resolvedPath']: row['sha256'] for row in report['runtimeComparisons']} + return directory, base, report, known + + def test_fixed_supplement_preserves_all_six_original_files(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); directory, base, report, known = self.supplement_inputs(root) + output = root / 'output' + package.copy_sdk_supplement(directory, base, known, output) + self.assertEqual(len(list(p for p in output.rglob('*') if p.is_file())), 7) + self.assertEqual(package.sha(output / 'report.json'), package.SDK_SUPPLEMENT_REPORT_SHA) + for row in report['files']: + self.assertEqual((output / row['file']).read_bytes(), (directory / row['file']).read_bytes()) + + def test_supplement_rejects_changed_source_report_base_runtime_and_symlink(self): + for change in ('source', 'report', 'base', 'runtime', 'symlink'): + with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); directory, base, report, known = self.supplement_inputs(root) + source = directory / report['files'][0]['file'] + if change == 'source': source.write_bytes(b'changed') + elif change == 'report': (directory / 'report.json').write_text('{}') + elif change == 'base': base.write_text('{}') + elif change == 'runtime': known[next(iter(known))] = '0' * 64 + else: + outside = root / 'outside'; shutil.copyfile(source, outside) + source.unlink(); source.symlink_to(outside) + output = root / 'output' + with self.assertRaises(ValueError): + package.copy_sdk_supplement(directory, base, known, output) + self.assertFalse(output.exists()) + + def test_destination_traversal_and_noncanonical_paths_rejected(self): + for value in ('../x', '/x', 'a/../b', '.', '', 'a//b', './x', 'x\\y', 'x\0y'): + with self.subTest(value=repr(value)), self.assertRaises(ValueError): + package.canonical(value) + self.assertEqual(str(package.canonical('qt/qml/QtQuick/qmldir')), 'qt/qml/QtQuick/qmldir') + + def test_hash_mismatch_rejected_before_destination_created(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); source = root / 'input'; source.write_bytes(b'actual') + target = root / 'output' + with self.assertRaisesRegex(ValueError, 'Input changed'): + package.copy_verified(source, target, '0' * 64) + self.assertFalse(target.exists()) + + def test_conflicting_alias_is_not_overwritten(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); source = root / 'input'; source.write_bytes(b'first') + target = root / 'output' + package.copy_verified(source, target, executable=False) + package.copy_verified(source, target, executable=False) + source.write_bytes(b'different') + with self.assertRaisesRegex(ValueError, 'Conflicting'): + package.copy_verified(source, target) + self.assertEqual(target.read_bytes(), b'first') + + def test_launcher_preserves_literal_arguments_and_selects_private_runtime(self): + with tempfile.TemporaryDirectory(prefix='docview launcher ') as tmp: + root = Path(tmp); app = root / 'app'; (app / 'bin').mkdir(parents=True) + binary = app / 'bin/docview' + binary.write_text('#!' + sys.executable + '\nimport os,sys,json\nprint(json.dumps({"argv":sys.argv[1:],"lib":os.environ["LD_LIBRARY_PATH"],"plugins":os.environ["QT_PLUGIN_PATH"],"qml":os.environ["QML_IMPORT_PATH"],"helper":os.environ["QTWEBENGINEPROCESS_PATH"]}))\n') + binary.chmod(0o755) + source = (ROOT / 'resources/linux/docview-launcher').read_text() + self.assertEqual(source.count('docview_root=/opt/docview'), 1) + launcher = root / 'launcher' + launcher.write_text(source.replace('docview_root=/opt/docview', 'docview_root=' + shlex.quote(str(app)))) + literal = ['日本語 空白.pdf', 'a$(touch unexpected)', "quote'\";", '--config', 'a b.toml'] + result = json.loads(subprocess.check_output(['sh', str(launcher), *literal], text=True, + env={**os.environ, 'LD_LIBRARY_PATH':'/invalid', 'QT_PLUGIN_PATH':'/invalid'})) + self.assertEqual(result['argv'], literal) + self.assertEqual(result['lib'], str(app / 'lib') + ':' + str(app / 'qt/lib')) + self.assertEqual(result['plugins'], str(app / 'qt/plugins')) + self.assertEqual(result['qml'], str(app / 'qt/qml')) + self.assertEqual(result['helper'], str(app / 'qt/libexec/QtWebEngineProcess')) + + def test_maintainer_scripts_have_valid_shell_syntax(self): + for name in ('docview-launcher', 'deb-postinst', 'deb-prerm'): + subprocess.run(['sh', '-n', str(ROOT / 'resources/linux' / name)], check=True) + + def test_staging_modes_do_not_depend_on_builder_umask(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); regular = root / 'notice'; executable = root / 'launcher' + regular.write_text('text'); executable.write_text('text') + regular.chmod(0o666); executable.chmod(0o777); root.chmod(0o777) + package.normalize_modes(root) + self.assertEqual(regular.stat().st_mode & 0o7777, 0o644) + self.assertEqual(executable.stat().st_mode & 0o7777, 0o755) + self.assertEqual(root.stat().st_mode & 0o7777, 0o755) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_ubuntu_validation_runtime.py b/tests/test_ubuntu_validation_runtime.py new file mode 100644 index 0000000..f181ebe --- /dev/null +++ b/tests/test_ubuntu_validation_runtime.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Offline boundaries for Ubuntu installed-runtime inventory; no host ldd/dpkg.""" +import importlib.util +import io +import json +from pathlib import Path +import sys +import tempfile +import tarfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +import verify_pdfium_candidate as candidate +spec = importlib.util.spec_from_file_location('ubuntu_runtime', ROOT / 'tools/collect_ubuntu_validation_runtime.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class UbuntuRuntimeTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='docview-ubuntu-test-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.install, self.sdk, self.deps, self.system = [self.root / x for x in ('install', 'sdk', 'dependencies', 'system')] + self.doc = self.root / 'system-doc'; self.output = self.root / 'output' + for p in (self.install, self.sdk, self.deps, self.system, self.doc): p.mkdir() + self.query = {'QT_VERSION':'6.11.2', 'QT_INSTALL_PREFIX':str(self.sdk), + **{'QT_INSTALL_' + k:str(self.sdk / v) for k,v in { + 'LIBS':'lib', 'LIBEXECS':'libexec', 'QML':'qml', 'PLUGINS':'plugins', + 'DATA':'.', 'TRANSLATIONS':'translations'}.items()}} + for k,v in self.query.items(): + if k != 'QT_VERSION': Path(v).mkdir(parents=True, exist_ok=True) + for name in module.EXECUTABLES: + self.write(self.install / 'bin' / name, b'\x7fELFfake') + (self.install / 'bin' / name).chmod(0o755) + self.write(self.install / 'lib/libpdfium.so', b'\x7fELFpdfium') + self.qtpaths = self.sdk / 'bin/qtpaths'; self.write(self.qtpaths, b'fixture'); self.qtpaths.chmod(0o755) + self.write(self.sdk / 'libexec/QtWebEngineProcess', b'\x7fELFhelper') + (self.sdk / 'libexec/QtWebEngineProcess').chmod(0o755) + for name in ('libqxcb.so', 'libqwayland-generic.so'): + self.write(self.sdk / 'plugins/platforms' / name, b'\x7fELFplugin') + for name in module.QML_MODULES: self.write(self.sdk / 'qml' / name / 'qmldir', b'module fixture') + for name in ('qtwebengine_resources.pak', 'qtwebengine_resources_100p.pak', + 'qtwebengine_resources_200p.pak', 'icudtl.dat'): + self.write(self.sdk / 'resources' / name, b'fixed resources') + for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm', 'qtwebengine_locales/en-US.pak', 'qtwebengine_locales/ja.pak'): + self.write(self.sdk / 'translations' / name, b'translation') + self.lib = self.system / 'libexample.so.1'; self.write(self.lib, b'\x7fELFlibrary') + self.write(self.doc / 'example/copyright', b'Example copyright text') + self.write(self.sdk / 'LICENSES/MIT.txt', b'SDK license text') + self.write(self.sdk / 'sbom/qt.spdx.json', b'{"fixture":"metadata only"}') + self.write(self.install / 'share/doc/docview/pdfium/LICENSE', b'PDFium license') + # The runtime fixture uses a tiny, explicitly pinned synthetic provider; + # candidate correspondence and tamper cases have their own tests. + self.pins = self.root / 'pins' + base = {'schemaVersion': 1, 'pdfiumVersion': '1', 'pdfiumUpstreamCommit': 'a' * 40, + 'pdfiumLibrarySha256': module.digest(b'\x7fELFpdfium'), 'files': []} + for name in ('libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'): + text = name.encode() + base['files'].append({'name': name, 'sha256': module.digest(text), 'size': len(text)}) + self.write(self.install / candidate.SUPPLEMENT / name, text) + metadata = json.dumps(base).encode() + self.write(self.pins / 'resources/licenses/pdfium-supplemental/sources.json', metadata) + self.write(self.pins / 'cmake/pdfium.lock.json', json.dumps({'version': '1', 'upstreamCommit': 'a' * 40}).encode()) + self.write(self.install / candidate.SUPPLEMENT / 'sources.json', metadata) + pin_root = patch.object(candidate, 'ROOT', self.pins) + pin_root.start(); self.addCleanup(pin_root.stop) + self.unresolved = False; self.calls = [] + + def write(self, path, data): + path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data) + + def runner(self, args, env): + self.calls.append(args) + self.assertNotIn('LD_PRELOAD', env); self.assertNotIn('LD_AUDIT', env) + if args[0] == str(self.qtpaths): return 0, '\n'.join(k+':'+v for k,v in self.query.items()), '' + if args[0] == 'ldd': + qpdf = ('libqpdf.so.30 => ' + str(self.qpdf_library) + ' (0x5678)\n') if hasattr(self, 'qpdf_library') else '' + return 0, ('libmissing.so => not found\n' if self.unresolved else '') + 'libexample.so.1 => '+str(self.lib)+' (0x1234)\n' + qpdf, '' + if args[0] == 'readelf': return 0, ' 0x1 (RUNPATH) Library runpath: [$ORIGIN/../lib]\n', '' + if args[:2] == ['dpkg-query','--search']: return 0, 'example:amd64: '+args[-1]+'\n', '' + if args[:2] == ['dpkg-query','--show']: return 0, 'example:amd64\t1.2-3\tamd64\n', '' + self.fail('Unexpected command: '+str(args)) + + def collector(self, **kwargs): + return module.Collector(self.install,self.qtpaths,self.deps,self.output, + runner=self.runner,system_roots=(self.system,),system_doc=self.doc,**kwargs) + + def collect(self, **kwargs): + return self.collector(**kwargs).collect({'ID':'ubuntu','VERSION_ID':'24.04','PRETTY_NAME':'Fixture Ubuntu'}) + + def test_valid_runtime_and_partial_notice_scope(self): + result=self.collect() + self.assertTrue(result['runtimeValidationSuccess']) + self.assertEqual(result['systemPackages']['example:amd64']['version'],'1.2-3') + self.assertEqual(len(result['installedRpaths']),3) + self.assertTrue(any(x['origin']=='dpkg:example:amd64' for x in result['notices'])) + self.assertTrue(any(x['category']=='sdk-sbom-metadata-not-license-text' for x in result['notices'])) + self.assertFalse(result['completeChromiumNotices']);self.assertFalse(result['completeCorrespondingSources']) + self.assertFalse(result['archNoticePinReused']);self.assertFalse(result['runtimeBinariesCopied']) + self.assertTrue(result['noticeGaps']) + for x in result['notices']: + self.assertEqual(module.digest((self.output/x['copiedPath']).read_bytes()),x['sha256']) + + def test_missing_japanese_catalog_and_helper_fail_runtime(self): + (self.sdk/'translations/qtbase_ja.qm').unlink() + (self.sdk/'libexec/QtWebEngineProcess').unlink() + result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) + self.assertEqual(len(result['runtimeFailures']),2) + + def test_desktop_input_and_wayland_plugins_are_inspected(self): + paths = [self.sdk / 'plugins' / directory / 'fixture.so' for directory in + ('platforminputcontexts', 'platformthemes', 'wayland-shell-integration', + 'wayland-decoration-client', 'iconengines', 'tls', 'networkinformation')] + for path in paths: + self.write(path, b'\x7fELFdesktop-plugin') + result = self.collect() + files = {row['path'] for row in result['files']} + for path in paths: + self.assertIn('qt-sdk:' + str(path.relative_to(self.sdk)), files) + self.assertIn(['ldd', str(path)], self.calls) + + def test_required_executable_is_not_silently_non_elf(self): + (self.install/'bin/docview').write_bytes(b'not ELF') + result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) + self.assertTrue(any('ELF header' in x for x in result['runtimeFailures'])) + + def test_nonexecutable_helper_fails_runtime(self): + (self.sdk/'libexec/QtWebEngineProcess').chmod(0o644) + result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) + self.assertTrue(any('permission missing' in x for x in result['runtimeFailures'])) + + def test_unresolved_ldd_is_failed_runtime(self): + self.unresolved=True + result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) + self.assertTrue(any('libmissing.so' in x for x in result['runtimeFailures'])) + + def test_external_ldd_dependency_rejected_before_read(self): + self.lib=self.root/'private-document';self.lib.write_bytes(b'private') + with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() + + def test_qtpaths_escaped_sdk_rejected(self): + self.query['QT_INSTALL_QML']=str(self.root) + with self.assertRaisesRegex(ValueError,'escaped'):self.collect() + + def test_qtpaths_duplicate_key_and_wrong_version_rejected(self): + text='\n'.join(k+':'+v for k,v in self.query.items()) + with self.assertRaisesRegex(ValueError,'Duplicate'):module.validate_query(text+'\nQT_VERSION:6.11.2',self.sdk) + with self.assertRaisesRegex(ValueError,'6.11.2'):module.validate_query(text.replace('6.11.2','6.10.0'),self.sdk) + + def test_inside_file_symlink_preserves_target_identity(self): + p=self.sdk/'qml/QtQuick/helper.so';p.symlink_to(self.sdk/'plugins/platforms/libqxcb.so') + result=self.collect();row=next(x for x in result['files'] if x['path'].endswith('helper.so')) + self.assertTrue(row['symlinkResolution']);self.assertEqual(row['resolvedPath'],'qt-sdk:plugins/platforms/libqxcb.so') + + def test_escape_file_symlink_rejected(self): + p=self.root/'private';p.write_bytes(b'private') + (self.sdk/'qml/QtQuick/private').symlink_to(p) + with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() + + def test_directory_symlink_is_not_traversed(self): + (self.sdk/'qml/QtQuick/loop').symlink_to(self.sdk/'qml',target_is_directory=True) + with self.assertRaisesRegex(ValueError,'Directory symlink'):self.collect() + + def test_old_output_not_reused(self): + self.output.mkdir();old=self.output/'runtime.json';old.write_text('old success') + with self.assertRaises(FileExistsError):self.collect() + self.assertEqual(old.read_text(),'old success') + + def test_output_inside_input_rejected(self): + self.output=self.sdk/'new-output' + with self.assertRaisesRegex(ValueError,'outside inspected'):self.collector() + + def test_notice_symlink_cannot_collect_unrelated_document(self): + secret=self.root/'unrelated';secret.write_bytes(b'private contents') + (self.sdk/'LICENSES/secret').symlink_to(secret) + with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() + + def test_os_mismatch_does_not_create_output(self): + with self.assertRaisesRegex(ValueError,'Ubuntu 24.04'): + self.collector().collect({'ID':'arch','VERSION_ID':'rolling'}) + self.assertFalse(self.output.exists()) + + def test_bounded_walk_rejects_over_count_and_special_files(self): + with self.assertRaisesRegex(ValueError,'limit'):module.bounded_walk(self.sdk,(self.sdk,),1) + import os + os.mkfifo(self.sdk/'qml/QtQuick/fifo') + with self.assertRaisesRegex(ValueError,'Special'):self.collect() + + def test_file_size_bound(self): + with self.assertRaisesRegex(ValueError,'size limit'):module.checked_file(self.lib,(self.system,),2) + + def test_sdk_metadata_has_separate_finite_size_budget(self): + collector = self.collector() + self.output.mkdir() + metadata = self.sdk / 'sbom/large.spdx' + notice = self.sdk / 'LICENSES/small.txt' + metadata.write_bytes(b'12345678') + notice.write_bytes(b'1234') + with patch.object(module, 'MAX_NOTICE_BYTES', 4), \ + patch.object(module, 'MAX_SDK_METADATA_BYTES', 12), \ + patch.object(module, 'MAX_SDK_METADATA_FILE_BYTES', 8): + collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') + collector.notice(notice, (self.sdk,), 'SDK') + self.assertEqual(collector.notice_total, 4) + self.assertEqual(collector.sdk_metadata_total, 8) + with self.assertRaisesRegex(ValueError, 'collection limit'): + collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') + metadata.write_bytes(b'123456789') + with self.assertRaisesRegex(ValueError, 'size limit'): + collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') + + def test_input_manifest_filters_unrelated_private_fields(self): + path=self.root/'inputs.json' + row={'name':'qt.7z','sha256':'a'*64,'size':123,'url':'https://example.invalid/qt.7z', + 'kind':'qt','private':'do not copy'} + path.write_text(json.dumps([row]));result=module.manifest_record(path) + self.assertNotIn('private',result['inputs'][0]) + row['name']='../private';path.write_text(json.dumps([row])) + with self.assertRaisesRegex(ValueError,'identity'):module.manifest_record(path) + + def test_ldd_parser_rejects_unknown_output(self): + paths,missing=module.parse_ldd('linux-vdso.so.1 (0x1234)\nlibx.so => not found\n/lib/ld.so (0x5678)\n') + self.assertEqual(paths,[Path('/lib/ld.so')]);self.assertEqual(missing,['libx.so']) + with self.assertRaisesRegex(ValueError,'Unrecognized'):module.parse_ldd('unexpected arbitrary text') + + def qpdf_fixture(self): + # Tiny code-owned pins are injected only inside this test. Production + # callers cannot replace the reviewed release/library identities. + self.qpdf_root = self.root / 'qpdf-12.4.1' + self.qpdf_archive = self.root / 'qpdf-12.4.1.tar.gz' + self.qpdf_library = self.deps / 'lib/libqpdf.so.30.4.1' + files = {'LICENSE.txt': b'fixed Apache license\n', + 'NOTICE.md': b'qpdf copyright\nembedded dependency notice\n', + 'libqpdf/source.cc': b'original source\n'} + with tarfile.open(self.qpdf_archive, 'w:gz') as archive: + for name, data in files.items(): + self.write(self.qpdf_root / name, data) + info = tarfile.TarInfo('qpdf-12.4.1/' + name); info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + self.write(self.qpdf_library, b'\x7fELFfixed-qpdf') + inventory = [{'path': name, 'sha256': module.digest(data), 'size': len(data)} + for name, data in sorted(files.items())] + pin = {'version': '12.4.1', 'archiveRoot': 'qpdf-12.4.1', + 'archive': {'name': self.qpdf_archive.name, 'size': self.qpdf_archive.stat().st_size, + 'sha256': module.digest(self.qpdf_archive.read_bytes())}, + 'library': {'size': self.qpdf_library.stat().st_size, 'sha256': module.digest(self.qpdf_library.read_bytes())}, + 'sourceFiles': len(files), 'sourceBytes': sum(len(v) for v in files.values()), + 'sourceInventorySha256': module.digest(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()), + 'notices': {name: {'size': len(files[name]), 'sha256': module.digest(files[name])} + for name in ('LICENSE.txt', 'NOTICE.md')}} + pins = patch.object(module, 'QPDF_NOTICE_PIN', pin); pins.start(); self.addCleanup(pins.stop) + self.qpdf_inputs = self.root / 'qpdf-inputs.json' + self.qpdf_inputs.write_text(json.dumps([{**pin['archive'], 'kind': 'source', 'url': 'https://example.invalid/qpdf.tar.gz'}])) + return pin + + def collect_qpdf(self): + return self.collector(sources=(self.qpdf_root,), qpdf_source_archive=self.qpdf_archive).collect( + {'ID': 'ubuntu', 'VERSION_ID': '24.04'}, self.qpdf_inputs) + + def test_qpdf_notice_full_text_and_source_runtime_correspondence(self): + pin = self.qpdf_fixture(); result = self.collect_qpdf() + proof = result['qpdfNoticeCorrespondence'] + self.assertEqual(proof['status'], 'verified') + self.assertEqual(proof['archive'], pin['archive']) + self.assertEqual(proof['sourceInventorySha256'], pin['sourceInventorySha256']) + self.assertEqual(proof['sourceFiles'], 3) + self.assertEqual(proof['library']['sha256'], pin['library']['sha256']) + notice = next(x for x in proof['notices'] if x['source'] == 'NOTICE.md') + self.assertEqual((self.output / notice['copiedPath']).read_bytes(), (self.qpdf_root / 'NOTICE.md').read_bytes()) + self.assertFalse(result['completeChromiumNotices']); self.assertFalse(result['completeCorrespondingSources']) + + def test_qpdf_runtime_requires_archive_even_when_notice_present(self): + self.qpdf_fixture() + with self.assertRaisesRegex(ValueError, 'fixed source archive'): + self.collect(sources=(self.qpdf_root,)) + + def test_qpdf_modified_library_cannot_skip_known_digest_branch(self): + self.qpdf_fixture(); self.qpdf_library.write_bytes(b'\x7fELFunknown-qpdf') + with self.assertRaisesRegex(ValueError, 'runtime differs'): + self.collect_qpdf() + + def test_qpdf_tampered_archive_and_self_updated_manifest_rejected(self): + self.qpdf_fixture(); self.qpdf_archive.write_bytes(self.qpdf_archive.read_bytes() + b'changed') + metadata = json.loads(self.qpdf_inputs.read_text()); metadata[0]['sha256'] = module.digest(self.qpdf_archive.read_bytes()) + metadata[0]['size'] = self.qpdf_archive.stat().st_size; self.qpdf_inputs.write_text(json.dumps(metadata)) + with self.assertRaisesRegex(ValueError, 'archive differs'): + self.collect_qpdf() + + def test_qpdf_manifest_identity_mismatch_rejected(self): + self.qpdf_fixture(); data = json.loads(self.qpdf_inputs.read_text()); data[0]['sha256'] = '0' * 64 + self.qpdf_inputs.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, 'declared input manifest'): + self.collect_qpdf() + + def test_qpdf_missing_or_modified_notice_rejected(self): + self.qpdf_fixture(); (self.qpdf_root / 'NOTICE.md').write_bytes(b'incomplete notice') + with self.assertRaisesRegex(ValueError, 'source root with LICENSE and NOTICE'): + self.collect_qpdf() + + def test_qpdf_source_code_change_rejected_even_when_notices_match(self): + self.qpdf_fixture(); (self.qpdf_root / 'libqpdf/source.cc').write_bytes(b'modified source') + with self.assertRaisesRegex(ValueError, 'source file differs'): + self.collect_qpdf() + + def test_qpdf_extra_source_file_rejected(self): + self.qpdf_fixture(); (self.qpdf_root / 'extra.cc').write_bytes(b'new source') + with self.assertRaisesRegex(ValueError, 'source root differs'): + self.collect_qpdf() + + def test_qpdf_symlink_notice_cannot_read_unrelated_file(self): + self.qpdf_fixture(); path = self.qpdf_root / 'NOTICE.md'; private = self.root / 'private' + private.write_bytes(path.read_bytes()); path.unlink(); path.symlink_to(private) + with self.assertRaisesRegex(ValueError, 'outside allowed'): + self.collect_qpdf() + + def test_qpdf_notice_mutated_after_source_verification_rejected(self): + self.qpdf_fixture(); collector = self.collector(sources=(self.qpdf_root,), qpdf_source_archive=self.qpdf_archive) + original = collector.source_notices + def change(directory, origin, sdk=False): + if directory == self.qpdf_root: (directory / 'NOTICE.md').write_bytes(b'late substitution') + return original(directory, origin, sdk) + with patch.object(collector, 'source_notices', change), self.assertRaisesRegex(ValueError, 'notice changed'): + collector.collect({'ID': 'ubuntu', 'VERSION_ID': '24.04'}, self.qpdf_inputs) + + def test_generic_notice_markdown_basename_is_collected(self): + source = self.root / 'other-source'; self.write(source / 'NOTICE.md', b'other bounded notice') + result = self.collect(sources=(source,)) + self.assertEqual(result['qpdfNoticeCorrespondence']['status'], 'not-applicable') + self.assertTrue(any(x['path'] == 'source-0:NOTICE.md' for x in result['notices'])) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_web_qml.cpp b/tests/test_web_qml.cpp new file mode 100644 index 0000000..60a81de --- /dev/null +++ b/tests/test_web_qml.cpp @@ -0,0 +1,283 @@ +#include "web/web_profile.h" +#include "web/resource_policy.h" +#include "web/renderer_watchdog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef Q_OS_LINUX +#include +#include +#include +#endif + +class WebTestReader : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap settings READ settings NOTIFY settingsChanged) + Q_PROPERTY(QString format READ format CONSTANT) + Q_PROPERTY(QString context MEMBER context NOTIFY contextChanged) + Q_PROPERTY(bool loaded READ loaded NOTIFY loadedChanged) + Q_PROPERTY(int forbiddenConnections READ forbiddenConnections NOTIFY connectionsChanged) + Q_PROPERTY(QVariantMap index READ index NOTIFY indexChanged) + Q_PROPERTY(QVariantMap resourceIssues MEMBER resourceIssues NOTIFY resourceIssuesChanged) + Q_PROPERTY(bool allowNetworkProbe MEMBER allowNetworkProbe) + Q_PROPERTY(QUrl networkProbeUrl READ networkProbeUrl CONSTANT) + Q_PROPERTY(int interceptedRequests MEMBER interceptedRequests NOTIFY resourceIssuesChanged) + Q_PROPERTY(QString notice MEMBER notice NOTIFY noticeChanged) + Q_PROPERTY(int watchdogFailures MEMBER watchdogFailures NOTIFY watchdogChanged) + Q_PROPERTY(int heartbeatStarts MEMBER heartbeatStarts NOTIFY watchdogChanged) + Q_PROPERTY(bool deferCommit MEMBER deferCommit) + Q_PROPERTY(bool canPauseRenderers READ canPauseRenderers CONSTANT) + Q_PROPERTY(bool holdLateResources MEMBER holdLateResources) + Q_PROPERTY(int delayedResources READ delayedResources) +public: + explicit WebTestReader(QObject *parent = nullptr); + ~WebTestReader() override { resumeRenderers(); } + QVariantMap settings() const; + QString format() const { return "html"; } + bool loaded() const { return loaded_; } + int forbiddenConnections() const { return connections_; } + QVariantMap index() const { return index_; } + QString context = "content", notice; + QVariantMap resourceIssues; + bool allowNetworkProbe = false; + int interceptedRequests = 0; + int watchdogFailures = 0, heartbeatStarts = 0; + bool deferCommit = false; + bool holdLateResources = false; + int delayedResources() const { return delayedCompletions_.size(); } + Q_INVOKABLE void releaseLateResources() { + holdLateResources = false; + const auto callbacks = std::exchange(delayedCompletions_, {}); + for (const auto &done : callbacks) done(docview::ResourceFailure::None); + } + bool canPauseRenderers() const { +#if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal) + return true; +#else + return false; +#endif + } + Q_INVOKABLE void registerRenderer(const QString &token, qint64 pid, int slot) { + QString error; + if (!watchdog_.registerRenderer(token, pid, &error, slot)) qFatal("Cannot monitor test renderer: %s", qPrintable(error)); +#ifdef Q_OS_LINUX + docview::RendererProcessInfo info; + QFile stat(QStringLiteral("/proc/%1/stat").arg(pid)); + if (pid > 0 && stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info)) + identities_[token + ':' + QString::number(slot)] = {pid, info.startTime}; + else identities_.remove(token + ':' + QString::number(slot)); +#endif + } + Q_INVOKABLE quint32 beginRendererProbe(const QString &token, int slot) { + const auto id = watchdog_.beginProbe(token, slot); + if (id) { ++heartbeatStarts; emit watchdogChanged(); } + return id; + } + Q_INVOKABLE void finishRendererProbe(const QString &token, int slot, quint32 probe) { watchdog_.acknowledgeProbe(token, slot, probe); } + Q_INVOKABLE bool pauseRenderer(const QString &token, int slot) { +#if defined(Q_OS_LINUX) && defined(SYS_pidfd_open) && defined(SYS_pidfd_send_signal) + const auto identity = identities_.value(token + ':' + QString::number(slot)); + if (identity.first <= 1) { qWarning() << "No registered renderer" << slot << watchdog_.monitoredCount(); return false; } + // Only a renderer already accepted by the production ancestry/type + // verifier may be paused, and this pidfd pins its original start time. + const int fd = int(::syscall(SYS_pidfd_open, int(identity.first), 0)); + if (fd < 0) { qWarning() << "pidfd_open failed" << errno; return false; } + docview::RendererProcessInfo info; + QFile stat(QStringLiteral("/proc/%1/stat").arg(identity.first)); + const bool valid = stat.open(QIODevice::ReadOnly) && docview::parseRendererProcStat(stat.readAll(), quint64(::sysconf(_SC_PAGESIZE)), &info) && info.startTime == identity.second; + if (!valid || ::syscall(SYS_pidfd_send_signal, fd, SIGSTOP, nullptr, 0) != 0) { qWarning() << "Pause refused" << valid << errno; ::close(fd); return false; } + pausedHandles_.append(fd); return true; +#else + Q_UNUSED(token); Q_UNUSED(slot); return false; +#endif + } + Q_INVOKABLE bool hasRenderer(const QString &token, int slot) const { return identities_.contains(token + ':' + QString::number(slot)); } + Q_INVOKABLE void resumeRenderers() { +#if defined(Q_OS_LINUX) && defined(SYS_pidfd_send_signal) + for (const int fd : pausedHandles_) { ::syscall(SYS_pidfd_send_signal, fd, SIGCONT, nullptr, 0); ::close(fd); } + pausedHandles_.clear(); +#endif + } + Q_INVOKABLE void cancelPending() { + if (pendingToken_.isEmpty() || pendingToken_ == activeToken_) return; + const auto token = pendingToken_; pendingToken_.clear(); + watchdog_.removeSession(token); handlers_[token]->revoke(); emit disposeWeb(token); + loaded_ = !activeToken_.isEmpty(); emit loadedChanged(); + } + QUrl networkProbeUrl() const { return QUrl(QString("http://127.0.0.1:%1/probe").arg(server_.serverPort())); } + Q_INVOKABLE void webResourceIssues(const QString &token, const QVariantMap &counts) { + if (token != activeToken_ && token != pendingToken_) return; + for (auto it = counts.begin(); it != counts.end(); ++it) + resourceIssues[it.key()] = resourceIssues.value(it.key()).toInt() + it.value().toInt(); + emit resourceIssuesChanged(); + } + Q_INVOKABLE QVariantMap webOptions(const QString &) const { return {{"fixed", fixed_}, {"rtl", rtl_}}; } + Q_INVOKABLE void changeReading(int size, double height, bool publisher) { + reading_ = {{"font_size", size}, {"line_height", height}, {"use_publisher_style", publisher}}; + emit settingsChanged(); + } + Q_INVOKABLE QString script(const QString &) const; + Q_INVOKABLE void open(const QString &relative); + Q_INVOKABLE bool navigationAllowed(const QUrl &url, int type, const QString &token); + Q_INVOKABLE void webLoaded(const QString &token, bool ok); + Q_INVOKABLE void webIndex(const QString &, const QVariantMap &value) { index_ = value; emit indexChanged(); } + Q_INVOKABLE void webLocation(const QString &, const QVariantMap &) {} + Q_INVOKABLE void webStarted(const QString &, const QString &, quint64) {} + Q_INVOKABLE void webApplied(const QString &, const QString &, quint64) {} + Q_INVOKABLE void notify(const QString &message) { notice = message; emit noticeChanged(); } + Q_INVOKABLE void renderStopped() { notify("Renderer stopped"); } + Q_INVOKABLE void execute(const QString &, const QVariantMap & = {}) {} +signals: + void resourceIssuesChanged(); + void watchdogChanged(); + void contextChanged(); void settingsChanged(); void loadedChanged(); void indexChanged(); void noticeChanged(); void connectionsChanged(); + void stageWeb(QQuickWebEngineProfile *profile, QUrl url, QString token); + void activateWeb(QString token); void disposeWeb(QString token); void webCommand(QString id, QVariantMap args); +private: + QTemporaryDir root_; + docview::RendererWatchdog watchdog_{nullptr, docview::RendererWatchdog::DefaultLimitBytes, {}, 1500}; + QHash> identities_; + QList pausedHandles_; + QList delayedCompletions_; + QTcpServer server_; + QHash handlers_; + QString activeToken_, pendingToken_; + QVariantMap index_; + QVariantMap reading_{{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}}; + bool fixed_ = false, rtl_ = false; + bool loaded_ = false; + int connections_ = 0; +}; + +WebTestReader::WebTestReader(QObject *parent) : QObject(parent) { + connect(&watchdog_, &docview::RendererWatchdog::responseTimedOut, this, [this](const QString &token, const QString &code) { + ++watchdogFailures; + if (handlers_.contains(token)) handlers_[token]->revoke(); + if (token == activeToken_) activeToken_.clear(); + if (token == pendingToken_) pendingToken_.clear(); + emit disposeWeb(token); + loaded_ = !activeToken_.isEmpty(); emit loadedChanged(); + notify(code); emit watchdogChanged(); + }); + if (!server_.listen(QHostAddress::LocalHost, 0)) qFatal("Cannot open local observation server"); + connect(&server_, &QTcpServer::newConnection, this, [this] { + while (auto *socket = server_.nextPendingConnection()) { ++connections_; emit connectionsChanged(); socket->disconnectFromHost(); socket->deleteLater(); } + }); + auto write = [this](const QString &path, const QByteArray &bytes) { + QFile file(root_.filePath(path)); if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size()) qFatal("Cannot write fixture"); + }; + write("style.css", "h1{color:rgb(17,34,51)} section{height:900px} .hidden{display:none}"); + write("local.svg", ""); + write("next.html", "

      Unexpected refresh destination

      "); + write("base.html", "

      First

      Body

      Second

      End

      "); + write("headings.xhtml", "XHTML headings" + "

      Native first

      Body

      " + "

      Native second

      Body

      ARIA third
      " + "
      Invalid level

      End

      "); + if (!QFile::copy(root_.filePath("headings.xhtml"), root_.filePath("headings.html"))) qFatal("Cannot copy heading fixture"); + const QByteArray clampedHeadings = "Clamped headings

      First

      Middle

      " + "

      Last A

      Last B

      Short final section.

      "; + write("clamped-headings.xhtml", QByteArray(clampedHeadings).replace("CLASS", "")); + write("clamped-headings-vertical.xhtml", QByteArray(clampedHeadings).replace("CLASS", "class='vertical'")); + write("nested-headings.xhtml", "Nested headings

      Outer

      Inner first

      " + "

      Inner last

      End

      "); + QByteArray html = R"HTML(Web safety

      First heading

      SCRIPT BLOCKED

      Font request must be blocked

      Filler text.

      Second heading

      This is a stable text quote for position restoration.

      ARIA heading
      )HTML"; + html.replace("PORT", QByteArray::number(server_.serverPort())); + write("index.html", html); + write("vertical.html", "

      Vertical first

      Vertical second

      "); + QByteArray reflow = "

      Logical position

      "; + for (int i = 0; i < 100; ++i) { + reflow += "

      Paragraph " + QByteArray::number(i) + ": "; + for (int j = 0; j < 5; ++j) reflow += "This distinct reading passage verifies the same logical text anchor after font and width changes. "; + reflow += "

      "; + } + write("reflow.html", reflow + ""); + QByteArray late = reflow; + late.replace("

      Logical position

      ", "

      Logical position

      "); + write("late-layout.html", late + ""); + write("late-vertical.html", late + ""); + write("late-image.svg", ""); + if (!QFile::copy(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/fixtures/web/late-layout.ttf", root_.filePath("late-layout.ttf"))) qFatal("Cannot copy generated test font"); + write("fixed.html", "

      Fixed viewport

      Fixed text size

      "); + write("rtl.html", "

      RTL first

      مرحبا بالعالم

      RTL second

      النص الثاني

      "); + write("search.html", "

      Find text

      needle one

      NEEDLE two

      needle three

      日本語 検索

      日本語 検索

      "); + for (const auto &name : {QStringLiteral("space name.html"), QStringLiteral("日本語.html"), + QStringLiteral("hash#percent%.html"), QStringLiteral("日本語 j k 123 #%.html")}) + write(name, "" + "

      Encoded path

      Correct document

      Target

      "); +} +QVariantMap WebTestReader::settings() const { return {{"reading", reading_}}; } +QString WebTestReader::script(const QString &) const { + QFile file(QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/../resources/reader.js"); + return file.open(QIODevice::ReadOnly) ? QString::fromUtf8(file.readAll()) : QString(); +} +void WebTestReader::open(const QString &relative) { + fixed_ = relative == "fixed.html"; rtl_ = relative == "rtl.html"; + reading_ = {{"font_size", 18}, {"line_height", 1.6}, {"use_publisher_style", true}}; + pendingToken_ = QUuid::createUuid().toString(QUuid::Id128); + resourceIssues.clear(); emit resourceIssuesChanged(); + allowNetworkProbe = false; interceptedRequests = 0; + loaded_ = false; index_.clear(); emit loadedChanged(); + auto *handler = new docview::ResourceHandler(pendingToken_, root_.path(), {}, + [this](const QString &path, docview::ResourceHandler::Completion done) { + if (holdLateResources && (path == "late-image.svg" || path == "late-layout.ttf")) delayedCompletions_.append(std::move(done)); + else done(docview::ResourceFailure::None); + }); + connect(handler, &docview::ResourceHandler::blocked, this, [this](const QString &, const QString &category, int count) { + if (category == "network") { interceptedRequests += count; emit resourceIssuesChanged(); } + }); + auto *profile = docview::createDocumentProfile(pendingToken_, handler, this); + handlers_.insert(pendingToken_, handler); + QUrl url; url.setScheme("doc"); url.setHost(pendingToken_); url.setPath("/" + relative); + handler->authorizeTopLevel(url); + emit stageWeb(profile, url, pendingToken_); +} +bool WebTestReader::navigationAllowed(const QUrl &url, int type, const QString &token) { + // Test-only bypass of the UI navigation gate isolates the independent + // profile interceptor. The production Controller has no such switch. + if (allowNetworkProbe && url == networkProbeUrl()) return true; + if (!handlers_.contains(token) || docview::pathFromDocumentUrl(url, token).isEmpty() || (type != 0 && type != 1 && type != 5)) return false; + handlers_[token]->authorizeTopLevel(url); return true; +} +void WebTestReader::webLoaded(const QString &token, bool ok) { + if (token != pendingToken_) return; + if (ok && deferCommit) return; + loaded_ = ok; + if (ok) { + const auto old = activeToken_; activeToken_ = token; pendingToken_.clear(); + emit activateWeb(token); + if (!old.isEmpty()) { handlers_[old]->revoke(); emit disposeWeb(old); } + } + emit loadedChanged(); +} + +class WebTestSetup : public QObject { + Q_OBJECT +public slots: + void qmlEngineAvailable(QQmlEngine *engine) { + auto *reader = new WebTestReader(this); + engine->rootContext()->setContextProperty("reader", reader); + } +}; + +int main(int argc, char **argv) { + docview::registerDocumentScheme(); + QtWebEngineQuick::initialize(); + WebTestSetup setup; + const auto directory = QFileInfo(QString::fromUtf8(__FILE__)).absolutePath() + "/webqml"; + return quick_test_main_with_setup(argc, argv, "web_security", directory.toUtf8().constData(), &setup); +} +#include "test_web_qml.moc" diff --git a/tests/test_windows_pipe.cpp b/tests/test_windows_pipe.cpp new file mode 100644 index 0000000..ecfc72d --- /dev/null +++ b/tests/test_windows_pipe.cpp @@ -0,0 +1,185 @@ +#include "common/ipc.h" +#include "common/windows_pipe.h" + +#include +#include +#include +#ifdef Q_OS_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +using namespace docview; + +class WindowsPipeTest : public QObject { + Q_OBJECT + private slots: + void invalidHandlesAreRejected() { + WindowsPipeDevice device; + QString error; + QVERIFY(!device.adopt(0, 0, &error)); + QVERIFY(!device.isOpen()); + QVERIFY(!error.isEmpty()); +#ifndef Q_OS_WIN + QVERIFY(!createWindowsPipePair(&error)); + QVERIFY(error.startsWith("E_SANDBOX_UNAVAILABLE")); +#endif + } + + void duplexFramingAndPartialTransfers() { +#ifdef Q_OS_WIN + QString error; + auto pair = createWindowsPipePair(&error); + QVERIFY2(pair.has_value(), qPrintable(error)); + WindowsPipeDevice broker, worker; + QVERIFY(broker.adopt(pair->broker.takeReadHandle(), pair->broker.takeWriteHandle(), &error)); + QVERIFY(worker.adopt(pair->worker.takeReadHandle(), pair->worker.takeWriteHandle(), &error)); + IpcChannel parent(&broker), child(&worker); + QSignalSpy childReceived(&child, &IpcChannel::messageReceived); + QSignalSpy parentReceived(&parent, &IpcChannel::messageReceived); + QSignalSpy parentErrors(&parent, &IpcChannel::protocolError); + QSignalSpy childErrors(&child, &IpcChannel::protocolError); + const QCborMap request{{"protocolVersion", 1}, {"sessionId", "native-pipe"}, + {"requestId", 1}, {"generation", 1}, + {"operation", "render"}, { "payload", QCborMap { {"page", 0} } }}; + QVERIFY(parent.send(request)); + QTRY_COMPARE_WITH_TIMEOUT(childReceived.size(), 1, 5000); + QCOMPARE(qvariant_cast(childReceived[0][0]), request); + auto reply = request; + reply.remove(QStringLiteral("payload")); + const QByteArray pixels(6 * 1024 * 1024, char(0xa5)); + reply.insert(QStringLiteral("result"), QCborMap{{ "data", pixels }}); + QVERIFY(child.send(reply)); + QTRY_COMPARE_WITH_TIMEOUT(parentReceived.size(), 1, 15000); + QCOMPARE(qvariant_cast(parentReceived[0][0]), reply); + QTRY_COMPARE(worker.bytesToWrite(), qint64(0)); + QCOMPARE(parentErrors.size(), 0); + QCOMPARE(childErrors.size(), 0); + QCOMPARE(broker.bytesAvailable(), qint64(0)); +#else + QSKIP("Native Windows pipe execution requires Windows; Linux stub is not a native pass"); +#endif + } + + void boundedWriteDoesNotWaitForReader() { +#ifdef Q_OS_WIN + auto pair = createWindowsPipePair(); + QVERIFY(pair.has_value()); + WindowsPipeDevice broker; + QVERIFY(broker.adopt(pair->broker.takeReadHandle(), pair->broker.takeWriteHandle())); + // Keep the other endpoint open without issuing a read. A blocking pipe + // write would hang here after its kernel buffer fills. + const QByteArray bytes(WindowsPipeDevice::MaxWriteBuffer, 'x'); + QElapsedTimer elapsed; + elapsed.start(); + QCOMPARE(broker.write(bytes), qint64(bytes.size())); + QVERIFY2(elapsed.elapsed() < 500, "The GUI write path waited for a pipe reader"); + QCOMPARE(broker.bytesToWrite(), WindowsPipeDevice::MaxWriteBuffer); + QCOMPARE(broker.write("x", 1), qint64(-1)); + QVERIFY(broker.bytesToWrite() <= WindowsPipeDevice::MaxWriteBuffer); + WindowsPipeDevice worker; + QVERIFY(worker.adopt(pair->worker.takeReadHandle(), pair->worker.takeWriteHandle())); + qint64 received = 0; + connect(&worker, &QIODevice::readyRead, this, [&] { received += worker.readAll().size(); }); + QTRY_COMPARE_WITH_TIMEOUT(received, qint64(bytes.size()), 15000); + QTRY_COMPARE(broker.bytesToWrite(), qint64(0)); +#else + QSKIP("Native Windows pipe execution requires Windows; Linux stub is not a native pass"); +#endif + } + + void workerPipeCannotAcquireBrokerImpersonationToken() { +#ifdef Q_OS_WIN + auto pair = createWindowsPipePair(); + QVERIFY(pair.has_value()); + const auto workerRead = reinterpret_cast(pair->worker.readHandle()); + WindowsPipeDevice broker, worker; + QVERIFY(broker.adopt(pair->broker.takeReadHandle(), pair->broker.takeWriteHandle())); + QVERIFY(worker.adopt(pair->worker.takeReadHandle(), pair->worker.takeWriteHandle())); + QCOMPARE(broker.write("probe", 5), qint64(5)); + QTRY_COMPARE_WITH_TIMEOUT(worker.bytesAvailable(), qint64(5), 5000); + QCOMPARE(worker.readAll(), QByteArray("probe")); + + // Exercise the server end actually handed to a worker. The test runs + // outside LPAC to test the pipe's own SQOS boundary independently of + // any additional token restrictions imposed by the OS sandbox. + const bool impersonated = ImpersonateNamedPipeClient(workerRead); + HANDLE token = nullptr; + const bool opened = impersonated && OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &token); + const DWORD tokenError = GetLastError(); + if (token) + CloseHandle(token); + const bool reverted = !impersonated || RevertToSelf(); + QVERIFY(reverted); + QVERIFY(impersonated); + QVERIFY(!opened); + QCOMPARE(tokenError, DWORD(ERROR_CANT_OPEN_ANONYMOUS)); +#else + QSKIP("Native Windows pipe execution requires Windows; Linux stub is not a native pass"); +#endif + } + + void closeCancelsPendingIoWithoutStaleSignals() { +#ifdef Q_OS_WIN + DWORD before = 0; + QVERIFY(GetProcessHandleCount(GetCurrentProcess(), &before)); + for (int iteration = 0; iteration < 16; ++iteration) { + auto pair = createWindowsPipePair(); + QVERIFY(pair.has_value()); + WindowsPipeDevice broker; + QVERIFY(broker.adopt(pair->broker.takeReadHandle(), pair->broker.takeWriteHandle())); + QSignalSpy reads(&broker, &QIODevice::readyRead); + QSignalSpy writes(&broker, &QIODevice::bytesWritten); + QSignalSpy errors(&broker, &WindowsPipeDevice::transportError); + QCoreApplication::processEvents(); // Arm an overlapped read. + QCOMPARE(broker.write(QByteArray(1024 * 1024, 'x')), qint64(1024 * 1024)); + QElapsedTimer elapsed; + elapsed.start(); + broker.close(); + QVERIFY2(elapsed.elapsed() < 500, "close waited on a pipe operation on the GUI thread"); + pair->worker.close(); + QVERIFY(!broker.isOpen()); + QCOMPARE(broker.bytesToWrite(), qint64(0)); + QTest::qWait(5); + QCOMPARE(reads.size(), 0); + QCOMPARE(writes.size(), 0); + QCOMPARE(errors.size(), 0); + } + auto handleCount = [] { + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + DWORD count = 0; + return GetProcessHandleCount(GetCurrentProcess(), &count) ? count : DWORD(-1); + }; + // Qt may initialize process-wide wait infrastructure on its first use. + QTRY_VERIFY_WITH_TIMEOUT(handleCount() <= before + 8, 5000); +#else + QSKIP("Native Windows pipe execution requires Windows; Linux stub is not a native pass"); +#endif + } + + void disconnectFailsOnce() { +#ifdef Q_OS_WIN + auto pair = createWindowsPipePair(); + QVERIFY(pair.has_value()); + WindowsPipeDevice broker; + QVERIFY(broker.adopt(pair->broker.takeReadHandle(), pair->broker.takeWriteHandle())); + QSignalSpy errors(&broker, &WindowsPipeDevice::transportError); + QSignalSpy disconnected(&broker, &WindowsPipeDevice::disconnected); + pair->worker.close(); + QTRY_COMPARE_WITH_TIMEOUT(errors.size(), 1, 5000); + QCOMPARE(disconnected.size(), 1); + QVERIFY(!broker.isOpen()); + broker.close(); + QTest::qWait(20); + QCOMPARE(errors.size(), 1); + QCOMPARE(disconnected.size(), 1); +#else + QSKIP("Native Windows pipe execution requires Windows; Linux stub is not a native pass"); +#endif + } +}; + +QTEST_GUILESS_MAIN(WindowsPipeTest) +#include "test_windows_pipe.moc" diff --git a/tests/test_windows_resources.cpp b/tests/test_windows_resources.cpp new file mode 100644 index 0000000..eccb08a --- /dev/null +++ b/tests/test_windows_resources.cpp @@ -0,0 +1,108 @@ +#include "web/resource_policy.h" +#include "web/windows_resource_handles.h" +#include +#include +#include +#include +#include +#include + +using namespace docview; +namespace { +std::wstring native(const QString &path) { return QDir::toNativeSeparators(path).toStdWString(); } +bool put(const QString &path, const QByteArray &bytes) { + QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size(); +} +bool junction(const QString &path, const QString &destination) { + if (!QDir().mkpath(path)) return false; + const auto name = native(path), target = std::wstring(L"\\??\\") + native(destination); + winresource::Handle handle(CreateFileW(name.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr)); + if (!handle.valid()) return false; + struct Header { DWORD tag; WORD bytes, reserved, substituteOffset, substituteBytes, printOffset, printBytes; }; + const size_t stringBytes = (target.size() + 1) * sizeof(wchar_t); + std::vector storage(sizeof(Header) + stringBytes, 0); + auto *header = reinterpret_cast
      (storage.data()); + header->tag = IO_REPARSE_TAG_MOUNT_POINT; header->bytes = static_cast(8 + stringBytes); + header->substituteBytes = static_cast(target.size() * sizeof(wchar_t)); + header->printOffset = header->substituteBytes; header->printBytes = 0; + std::memcpy(storage.data() + sizeof(Header), target.c_str(), stringBytes); + DWORD returned = 0; + return DeviceIoControl(handle.get(), FSCTL_SET_REPARSE_POINT, storage.data(), static_cast(storage.size()), nullptr, 0, &returned, nullptr); +} +} + +class WindowsResourceTests : public QObject { + Q_OBJECT +private slots: + void pathsAndNativeComponents() { + for (const auto &name : {L"..", L"CON.txt", L"COM1", L"LPT\u00b2.log", L"asset:stream", L"a/b", L"name.", L"name ", L"a\\b"}) + QVERIFY(!winresource::safeComponent(name)); + QVERIFY(winresource::safeComponent(L"chapter.xhtml")); + for (const auto &root : {QString("C:relative"), QString("//server/share"), QString("//?/C:/tmp")}) { + QVERIFY(!openResource(root, "a")); ResourceWriter writer(root, "a"); QVERIFY(!writer.isValid()); + } + } + void readsOnlyPinnedRegularFiles() { + QTemporaryDir root; QVERIFY(root.isValid()); + QVERIFY(put(root.filePath("a.txt"), "hello")); + auto file = openResource(root.path(), "a.txt"); QVERIFY(file); + QCOMPARE(file->readAll(), QByteArray("hello")); + const auto name = native(root.filePath("a.txt")); + winresource::Handle mutate(CreateFileW(name.c_str(), GENERIC_WRITE | DELETE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, 0, nullptr)); + QVERIFY(!mutate.valid()); + QVERIFY(!openResource(root.path(), "a.txt:stream")); + QVERIFY(!openResource(root.path(), "../a.txt")); + } + void atomicPublishAndCancellation() { + QTemporaryDir root; QVERIFY(root.isValid()); + { + ResourceWriter writer(root.path(), "nested/book.css"); QVERIFY(writer.isValid()); + QVERIFY(writer.write("first")); QVERIFY(!openResource(root.path(), "nested/book.css")); + QVERIFY(writer.write(" second")); QVERIFY(writer.commit()); QVERIFY(!writer.write("late")); + } + auto file = openResource(root.path(), "nested/book.css"); QVERIFY(file); + QCOMPARE(file->readAll(), QByteArray("first second")); file.reset(); + { + ResourceWriter writer(root.path(), "nested/cancel.css"); QVERIFY(writer.write("discard")); + QVERIFY(!writer.write(QByteArray(65537, 'x'))); + } + QVERIFY(!openResource(root.path(), "nested/cancel.css")); + QCOMPARE(QDir(root.filePath("nested")).entryList(QDir::Files | QDir::Hidden), QStringList{"book.css"}); + { + ResourceWriter existing(root.path(), "nested/book.css"); QVERIFY(existing.isValid()); + QVERIFY(existing.write("replacement")); QVERIFY(!existing.commit()); + } + file = openResource(root.path(), "nested/book.css"); QVERIFY(file); + QCOMPARE(file->readAll(), QByteArray("first second")); + } + void rejectsHardLinksAndJunctions() { + QTemporaryDir root, outside; QVERIFY(root.isValid()); QVERIFY(outside.isValid()); + QVERIFY(put(outside.filePath("secret"), "unchanged")); + const auto secret = native(outside.filePath("secret")), link = native(root.filePath("hard")); + QVERIFY2(CreateHardLinkW(link.c_str(), secret.c_str(), nullptr), "An NTFS test volume is required"); + QVERIFY(!openResource(root.path(), "hard")); + { ResourceWriter writer(root.path(), "hard"); QVERIFY(writer.write("bad")); QVERIFY(!writer.commit()); } + QVERIFY2(junction(root.filePath("junction"), outside.path()), "Creating a junction on the NTFS test volume failed"); + QVERIFY(!openResource(root.path(), "junction/secret")); + QVERIFY(!openResource(root.filePath("junction"), "secret")); + { ResourceWriter writer(root.path(), "junction/secret"); QVERIFY(!writer.isValid()); } + QFile unchanged(outside.filePath("secret")); QVERIFY(unchanged.open(QIODevice::ReadOnly)); + QCOMPARE(unchanged.readAll(), QByteArray("unchanged")); unchanged.close(); + // Remove the junction itself, never recursively visit its destination. + QVERIFY(RemoveDirectoryW(native(root.filePath("junction")).c_str())); + } + void writerPinsEveryAncestorAgainstSwaps() { + QTemporaryDir root; QVERIFY(root.isValid()); + QVERIFY(QDir().mkpath(root.filePath("a/b"))); + ResourceWriter writer(root.path(), "a/b/result"); QVERIFY(writer.isValid()); + QVERIFY(!MoveFileExW(native(root.filePath("a")).c_str(), native(root.filePath("moved")).c_str(), 0)); + winresource::Handle mutate(CreateFileW(native(root.filePath("a/b")).c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr)); + QVERIFY(!mutate.valid()); + QVERIFY(writer.write("pinned")); QVERIFY(writer.commit()); + auto file = openResource(root.path(), "a/b/result"); QVERIFY(file); QCOMPARE(file->readAll(), QByteArray("pinned")); + } +}; +QTEST_GUILESS_MAIN(WindowsResourceTests) +#include "test_windows_resources.moc" diff --git a/tests/test_windows_worker.cpp b/tests/test_windows_worker.cpp new file mode 100644 index 0000000..6bba355 --- /dev/null +++ b/tests/test_windows_worker.cpp @@ -0,0 +1,198 @@ +// Compile/run only on native Windows with the production LPAC bootstrap enabled. +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include "common/worker_process.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; +namespace { +struct Fixture { + QTemporaryDir directory{QDir::tempPath() + "/docview-win-probe-XXXXXX"}; + QString source; + QByteArray sourceBytes; + std::vector sentinels; + ~Fixture() { for (auto handle : sentinels) CloseHandle(handle); } + bool prepare(const QString &mode, quint16 port = 0) { + if (!directory.isValid()) return false; + source = directory.filePath("source.json"); + if (!QDir().mkpath(directory.filePath("config")) || !QDir().mkpath(directory.filePath("cache"))) return false; + const auto put = [](const QString &path, const QByteArray &bytes) { + QFile file(path); return file.open(QIODevice::WriteOnly) && file.write(bytes) == bytes.size(); + }; + const auto sibling = directory.filePath("neighbor.txt"), config = directory.filePath("config/user-settings.json"); + if (!put(sibling, "TEMPORARY NEIGHBOR FIXTURE") || !put(config, "{\"privateFixture\":true}")) return false; + QJsonArray handles; + for (int i = 0; i < 2; ++i) { + const auto path = directory.filePath(QString("sentinel-%1.txt").arg(i)); + if (!put(path, "TEMPORARY INHERITANCE SENTINEL")) return false; + SECURITY_ATTRIBUTES attributes{sizeof(attributes), nullptr, TRUE}; + const auto name = QDir::toNativeSeparators(path).toStdWString(); + const auto handle = CreateFileW(name.c_str(), GENERIC_READ, FILE_SHARE_READ, &attributes, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) return false; + sentinels.push_back(handle); + BY_HANDLE_FILE_INFORMATION info{}; DWORD flags = 0; + if (!GetFileInformationByHandle(handle, &info) || !GetHandleInformation(handle, &flags) || !(flags & HANDLE_FLAG_INHERIT)) return false; + handles.append(QJsonObject{{"value", QString::number(reinterpret_cast(handle))}, + {"volume", qint64(info.dwVolumeSerialNumber)}, {"high", qint64(info.nFileIndexHigh)}, {"low", qint64(info.nFileIndexLow)}}); + } + const QJsonObject input{{"mode", mode}, {"fixtureRoot", directory.path()}, {"sourcePath", source}, + {"siblingPath", sibling}, {"configPath", config}, {"outputPath", directory.filePath("cache/worker-output.bin")}, + {"loopbackPort", int(port)}, {"sentinelHandles", handles}}; + sourceBytes = QJsonDocument(input).toJson(QJsonDocument::Compact); + return put(source, sourceBytes); + } +}; +QString probeExecutable() { + return QDir(QCoreApplication::applicationDirPath()).filePath("docview-windows-worker-probe.exe"); +} +bool launch(WorkerProcess &worker, const Fixture &fixture) { + return worker.start(probeExecutable(), {"--source", fixture.source}); +} +} + +class WindowsWorkerTests : public QObject { + Q_OBJECT +private slots: + void initTestCase() { + QVERIFY2(QFileInfo::exists(probeExecutable()), "Build/deploy docview-windows-worker-probe with its generated LPAC-compatible manifest and Qt dependencies"); + // A missing/unavailable sandbox is a failed native release gate. No + // skip or unrestricted-worker fallback is accepted in this suite. + } + void protectedReadinessAndReplyOrder() { + Fixture fixture; QVERIFY(fixture.prepare("echo")); + WorkerProcess worker("native-session", 37); + QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed); + QList replies; + QVERIFY(launch(worker, fixture)); + worker.request("render", {{"ordinal", 1}}, [&](const QCborMap &r) { replies << r; }); + worker.request("metadata", {{"ordinal", 2}}, [&](const QCborMap &r) { replies << r; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 20000); + QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0); + for (int i = 0; i < 2; ++i) { + QCOMPARE(replies[i].value("requestId").toInteger(), i + 2); // first ping is bootstrap + QCOMPARE(replies[i].value("sessionId").toString(), "native-session"); + QCOMPARE(replies[i].value("generation").toInteger(), 37); + const auto result = replies[i].value("result").toMap(); + QCOMPARE(result.value("received").toInteger(), i + 2); + QCOMPARE(result.value("echo").toMap().value("ordinal").toInteger(), i + 1); + QVERIFY(result.value("sandboxed").toBool()); + } + QVERIFY(worker.idle()); worker.stop(); QCOMPARE(failures.size(), 0); + } + void rejectsHostileRepliesAfterReady_data() { + QTest::addColumn("mode"); + for (const auto *mode : {"wrong-session", "wrong-generation", "wrong-request", "wrong-operation", "wrong-version", "oversize", "illegal-more"}) + QTest::newRow(mode) << QString::fromLatin1(mode); + } + void rejectsHostileRepliesAfterReady() { + QFETCH(QString, mode); Fixture fixture; QVERIFY(fixture.prepare(mode)); + WorkerProcess worker("protected-session", 7); + QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed); + int callbacks = 0; + QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000); + worker.request("render", {}, [&](const QCborMap &) { ++callbacks; }); + worker.request("metadata", {}, [&](const QCborMap &) { ++callbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000); + QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH"); + QCOMPARE(callbacks, 0); QVERIFY(worker.idle()); + QTest::qWait(100); QCOMPARE(failures.size(), 1); + } + void classifiesProtectedExit_data() { + QTest::addColumn("mode"); QTest::addColumn("code"); + QTest::newRow("ordinary") << QString("crash") << QString("E_WORKER_CRASH"); + QTest::newRow("sandbox") << QString("sandbox-unavailable") << QString("E_SANDBOX_UNAVAILABLE"); + } + void classifiesProtectedExit() { + QFETCH(QString, mode); QFETCH(QString, code); Fixture fixture; QVERIFY(fixture.prepare(mode)); + WorkerProcess worker("exit-session", 1); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed); + int callbacks = 0; + QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000); + worker.request("render", {}, [&](const QCborMap &) { ++callbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000); + QCOMPARE(failures.first()[0].toString(), code); QCOMPARE(callbacks, 0); + QTest::qWait(100); QCOMPARE(failures.size(), 1); + } + void normalErrorAndStreamKeepProtocolUsable() { + Fixture fixture; QVERIFY(fixture.prepare("stream")); + WorkerProcess worker("stream-session", 3); QSignalSpy failures(&worker, &WorkerProcess::failed); + QByteArray bytes; QStringList order; bool final = false; + QVERIFY(launch(worker, fixture)); + worker.request("extract", {{"path", "fixture"}}, [&](const QCborMap &r) { + const auto result = r.value("result").toMap(); + if (result.value("more").toBool()) { QVERIFY(!worker.idle()); QVERIFY(!final); bytes += result.value("data").toByteArray(); order << "chunk"; } + else { final = true; QCOMPARE(result.value("size").toInteger(), 9); order << "final"; } + }); + worker.request("metadata", {}, [&](const QCborMap &r) { + QVERIFY(final); QVERIFY(!r.value("result").toMap().value("receivedDuringStream").toBool()); order << "next"; + }); + QTRY_COMPARE_WITH_TIMEOUT(order.size(), 4, 20000); + QCOMPARE(bytes, QByteArray("alphabeta")); QCOMPARE(order, (QStringList{"chunk", "chunk", "final", "next"})); QCOMPARE(failures.size(), 0); + worker.stop(); + Fixture errors; QVERIFY(errors.prepare("error")); WorkerProcess errorWorker("errors", 1); + QSignalSpy errorFailures(&errorWorker, &WorkerProcess::failed); QList replies; + QVERIFY(launch(errorWorker, errors)); errorWorker.request("open", {}, [&](const QCborMap &r) { replies << r; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 20000); + QCOMPARE(replies.first().value("error").toMap().value("code").toString(), "E_FIXTURE"); QCOMPARE(errorFailures.size(), 0); + } + void actualLpacOsBoundaries() { + QTcpServer listener; QVERIFY(listener.listen(QHostAddress::LocalHost, 0)); + QSignalSpy connections(&listener, &QTcpServer::newConnection); + Fixture fixture; QVERIFY(fixture.prepare("probe-os", listener.serverPort())); + WorkerProcess worker("boundary-session", 9); QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed); + QList replies; + QVERIFY(launch(worker, fixture)); worker.request("probe", {}, [&](const QCborMap &r) { replies << r; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 25000); + QCOMPARE(ready.size(), 1); QCOMPARE(failures.size(), 0); + const auto result = replies.first().value("result").toMap(); + for (const auto *key : {"fixture_valid", "source_handle_write_denied", "source_reopen_write_denied", "parent_read_denied", + "sibling_read_denied", "config_read_denied", "arbitrary_output_denied", "extra_file_handles_absent", "loopback_denied", + "child_creation_denied", "own_profile_write_denied", "own_profile_registry_write_denied"}) { + QVERIFY2(result.value(QString::fromLatin1(key)).toBool(), qPrintable(QString::fromLatin1(key) + ": " + QString::fromUtf8(QJsonDocument::fromVariant(result.toVariantMap()).toJson(QJsonDocument::Compact)))); + } + QCOMPARE(connections.size(), 0); worker.stop(); + QFile source(fixture.source); QVERIFY(source.open(QIODevice::ReadOnly)); QCOMPARE(source.readAll(), fixture.sourceBytes); + QVERIFY(!QFileInfo::exists(fixture.directory.filePath("cache/worker-output.bin"))); + } + void gracefulCloseRequiresReply_data() { + QTest::addColumn("acknowledged"); + QTest::newRow("reply-before-exit") << true; + QTest::newRow("exit-without-reply") << false; + } + void gracefulCloseRequiresReply() { + QFETCH(bool, acknowledged); + Fixture fixture; QVERIFY(fixture.prepare(acknowledged ? "close-reply-exit" : "close-without-reply")); + WorkerProcess worker("close-session", 1); + QSignalSpy ready(&worker, &WorkerProcess::ready), failures(&worker, &WorkerProcess::failed); + int closed = 0, afterClose = 0; + QVERIFY(launch(worker, fixture)); QTRY_COMPARE_WITH_TIMEOUT(ready.size(), 1, 15000); + worker.request("close", {}, [&](const QCborMap &reply) { + QVERIFY(reply.value("result").toMap().value("closed").toBool()); ++closed; + }); + worker.request("metadata", {}, [&](const QCborMap &) { ++afterClose; }); + if (acknowledged) { + QTRY_COMPARE_WITH_TIMEOUT(closed, 1, 10000); + QTest::qWait(100); QCOMPARE(failures.size(), 0); + } else { + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 10000); + QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH"); QCOMPARE(closed, 0); + } + QCOMPARE(afterClose, 0); QVERIFY(worker.idle()); QVERIFY(!worker.isConnected()); + } +}; +QTEST_GUILESS_MAIN(WindowsWorkerTests) +#include "test_windows_worker.moc" diff --git a/tests/test_worker_deadlines.cpp b/tests/test_worker_deadlines.cpp new file mode 100644 index 0000000..77dc6ec --- /dev/null +++ b/tests/test_worker_deadlines.cpp @@ -0,0 +1,207 @@ +#include "common/ipc.h" +#include "common/sandbox.h" +#include "common/worker_process.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; + +namespace { +int stalledPeer(QCoreApplication &app) { + const auto args = app.arguments(); + const int socketIndex = args.indexOf("--socket"); + if (socketIndex < 0 || socketIndex + 1 >= args.size()) + return 120; + QLocalSocket socket; + socket.connectToServer(args[socketIndex + 1]); + if (!socket.waitForConnected(5000)) + return 121; + QString error; + if (!enterSandbox({}, {}, &error)) + return 122; + IpcChannel channel(&socket); + QCborMap extracting; + QTimer chunks; + chunks.setInterval(1000); + const auto reply = [&](QCborMap request, const QCborMap &result) { + request.remove(QStringLiteral("payload")); + request.insert(QStringLiteral("result"), result); + if (!channel.send(request)) + std::_Exit(123); + }; + QObject::connect(&chunks, &QTimer::timeout, &app, [&] { + // Progress is deliberately nonterminal: it must not reset the resource + // deadline or allow a queued request to overtake this extraction. + reply(extracting, {{"more", true}, {"data", QByteArray("progress")}}); + }); + QObject::connect(&socket, &QLocalSocket::disconnected, &app, &QCoreApplication::quit); + QObject::connect(&channel, &IpcChannel::protocolError, &app, [] { std::_Exit(124); }); + QObject::connect(&channel, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { + const auto op = request.value("operation").toString(); + if (op == "ping") + reply(request, {{"sandboxed", true}, {"pid", QCoreApplication::applicationPid()}}); + else if (op == "extract") { + extracting = request; + chunks.start(); + } + // Rendering deliberately never replies. Keep the real socket and + // event loop alive until the production broker terminates this peer. + }); + return app.exec(); +} + +QString digest(const QString &path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&file)) + return {}; + return QString::fromLatin1(hash.result().toHex()); +} +} // namespace + +class WorkerDeadlinesTest : public QObject { + Q_OBJECT + private slots: + void realBudgetsKeepTheEventLoopResponsive() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const auto archivePath = directory.filePath("docview-archive-worker"); + QVERIFY(QFile::link(QCoreApplication::applicationFilePath(), archivePath)); + WorkerProcess pdf("deadline-pdf", 1), archive("deadline-archive", 1), cancelled("cancel-pdf", 1); + QSignalSpy pdfFailures(&pdf, &WorkerProcess::failed), + archiveFailures(&archive, &WorkerProcess::failed); + QSignalSpy cancelFailures(&cancelled, &WorkerProcess::failed); + qint64 pdfPid = 0, archivePid = 0, cancelPid = 0; + int handshakes = 0; + for (auto pair : {qMakePair(&pdf, &pdfPid), qMakePair(&archive, &archivePid), + qMakePair(&cancelled, &cancelPid)}) { + QVERIFY(pair.first->start(pair.first == &archive ? archivePath + : QCoreApplication::applicationFilePath(), + {"--stalled-peer"})); + pair.first->request("ping", {}, [&, pid = pair.second](const QCborMap &value) { + const auto result = value.value("result").toMap(); + if (result.value("sandboxed").toBool()) { + *pid = result.value("pid").toInteger(); + ++handshakes; + } + }); + } + QTRY_COMPARE_WITH_TIMEOUT(handshakes, 3, 5000); + QElapsedTimer clock; + clock.start(); + qint64 pdfSlow = -1, archiveSlow = -1, pdfStopped = -1, archiveStopped = -1; + qint64 cancelDuration = -1, lastHeartbeat = 0, maxHeartbeatGap = 0; + int pdfSlowCount = 0, archiveSlowCount = 0, terminalReplies = 0, chunks = 0, heartbeats = 0; + connect(&pdf, &WorkerProcess::slow, this, [&] { + pdfSlow = clock.elapsed(); + ++pdfSlowCount; + }); + connect(&archive, &WorkerProcess::slow, this, [&] { + archiveSlow = clock.elapsed(); + ++archiveSlowCount; + }); + connect(&pdf, &WorkerProcess::failed, this, [&] { pdfStopped = clock.elapsed(); }); + connect(&archive, &WorkerProcess::failed, this, [&] { archiveStopped = clock.elapsed(); }); + QTimer heartbeat; + heartbeat.setInterval(10); + connect(&heartbeat, &QTimer::timeout, this, [&] { + const auto now = clock.elapsed(); + maxHeartbeatGap = qMax(maxHeartbeatGap, now - lastHeartbeat); + lastHeartbeat = now; + ++heartbeats; + }); + heartbeat.start(); + pdf.request("render", {}, [&](const QCborMap &) { ++terminalReplies; }); + pdf.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); + archive.request("extract", {}, [&](const QCborMap &value) { + if (value.value("result").toMap().value("more").toBool()) + ++chunks; + else + ++terminalReplies; + }); + archive.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); + cancelled.request("render", {}, [&](const QCborMap &) { ++terminalReplies; }); + cancelled.request("ping", {}, [&](const QCborMap &) { ++terminalReplies; }); + QTimer::singleShot(200, &cancelled, [&] { + QElapsedTimer stopClock; + stopClock.start(); + cancelled.stop(); + cancelDuration = stopClock.elapsed(); + }); + QTRY_VERIFY_WITH_TIMEOUT(pdfStopped >= 0 && archiveStopped >= 0, 125000); + QTest::qWait(100); + heartbeat.stop(); + QCOMPARE(pdfFailures.size(), 1); + QCOMPARE(archiveFailures.size(), 1); + QCOMPARE(cancelFailures.size(), 0); + QCOMPARE(pdfFailures[0][0].toString(), "E_WORKER_TIMEOUT"); + QCOMPARE(archiveFailures[0][0].toString(), "E_WORKER_TIMEOUT"); + QCOMPARE(pdfSlowCount, 1); + QCOMPARE(archiveSlowCount, 1); + QVERIFY2(pdfSlow >= 30000 && pdfSlow < 34000, qPrintable(QString::number(pdfSlow))); + QVERIFY2(archiveSlow >= 10000 && archiveSlow < 14000, qPrintable(QString::number(archiveSlow))); + QVERIFY2(pdfStopped >= 120000 && pdfStopped < 124000, qPrintable(QString::number(pdfStopped))); + QVERIFY2(archiveStopped >= 30000 && archiveStopped < 34000, + qPrintable(QString::number(archiveStopped))); + QVERIFY(cancelDuration >= 0 && cancelDuration < 1000); + QVERIFY(heartbeats > 5000); + QVERIFY2(maxHeartbeatGap < 1500, qPrintable(QString::number(maxHeartbeatGap))); + QVERIFY(chunks >= 20); + QCOMPARE(terminalReplies, 0); + QVERIFY(!pdf.isConnected() && !archive.isConnected() && !cancelled.isConnected()); + for (const auto pid : {pdfPid, archivePid, cancelPid}) { + QVERIFY(pid > 0); + QVERIFY(!QFileInfo::exists(QStringLiteral("/proc/%1").arg(pid))); + } + const QJsonObject report{ + {"success", true}, + {"platform", "Linux; sandboxed stalled IPC fixture; production WorkerProcess timers"}, + {"testExecutableSha256", digest(QCoreApplication::applicationFilePath())}, + {"commonLibrarySha256", digest(QCoreApplication::applicationDirPath() + "/libdocview_common.a")}, + {"pdfSlowMs", pdfSlow}, + {"pdfStopMs", pdfStopped}, + {"archiveSlowMs", archiveSlow}, + {"archiveStopMs", archiveStopped}, + {"cancelDurationMs", cancelDuration}, + {"heartbeatCount", heartbeats}, + {"maxHeartbeatGapMs", maxHeartbeatGap}, + {"archiveProgressChunks", chunks}, + {"queuedOrTerminalReplies", terminalReplies}, + {"childrenReaped", true}, + {"unrelatedUserDocuments", "none"}}; + qInfo().noquote() << QJsonDocument(report).toJson(QJsonDocument::Compact); + const auto reportPath = qEnvironmentVariable("DOCVIEW_WATCHDOG_REPORT"); + if (!reportPath.isEmpty()) { + QSaveFile output(reportPath); + QVERIFY(output.open(QIODevice::WriteOnly)); + const auto data = QJsonDocument(report).toJson(); + QCOMPARE(output.write(data), data.size()); + QVERIFY(output.commit()); + } + } +}; + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + if (app.arguments().contains("--stalled-peer")) + return stalledPeer(app); + WorkerDeadlinesTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_worker_deadlines.moc" diff --git a/tests/test_worker_process.cpp b/tests/test_worker_process.cpp new file mode 100644 index 0000000..d259214 --- /dev/null +++ b/tests/test_worker_process.cpp @@ -0,0 +1,670 @@ +#include "common/font_contract.h" +#include "common/ipc.h" +#include "common/sandbox.h" +#include "common/worker_process.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; + +#ifndef Q_MOC_RUN +namespace { + +QCborMap replyTo(const QCborMap &request, const QCborMap &result) { + QCborMap reply = request; + reply.remove(QStringLiteral("payload")); + reply.insert(QStringLiteral("result"), result); + return reply; +} + +bool writeRaw(QLocalSocket &socket, const QCborMap &reply) { + // Deliberately bypass the sender's envelope validation: this process is + // the hostile peer, and the test exercises the receiving broker. + const auto bytes = QCborValue(reply).toCbor(); + char header[4]; + qToBigEndian(quint32(bytes.size()), header); + const bool ok = socket.write(header, 4) == 4 && socket.write(bytes) == bytes.size(); + socket.flush(); + return ok; +} + +int fakeWorker(QCoreApplication &app) { + const auto args = app.arguments(); + const int socketArgument = args.indexOf("--socket"); + const int behaviorArgument = args.indexOf("--fake-worker"); + if (socketArgument < 0 || behaviorArgument < 0 || socketArgument + 1 >= args.size() || + behaviorArgument + 1 >= args.size()) + return 120; + const auto behavior = args[behaviorArgument + 1]; + QLocalSocket socket; + socket.connectToServer(args[socketArgument + 1]); + if (!socket.waitForConnected(5000)) + return 121; + // The socket is the only resource this fixture needs. Use the same + // fail-closed OS confinement as the real worker, with no file grants. + QString sandboxError; + if (!enterSandbox({}, {}, &sandboxError)) + return 122; + IpcChannel incoming(&socket); + QObject::connect(&socket, &QLocalSocket::disconnected, &app, &QCoreApplication::quit); + QObject::connect(&incoming, &IpcChannel::protocolError, &app, [] { std::_Exit(123); }); + int received = 0; + bool streaming = false; + QCborMap fontParent; + QString selectedFontId; + QByteArray fontBytes; + const auto fontRequest = [&](const QString &operation, const QCborMap &payload) { + auto request = fontParent; + request.insert(QStringLiteral("operation"), operation); + request.insert(QStringLiteral("payload"), payload); + return request; + }; + const auto mapRequest = [&] { + return fontRequest("font.map", {{"fontRequestId", 1}, + {"faceLocal", QByteArray("Helvetica")}, + {"weight", 400}, + {"italic", false}, + {"charset", 0}, + {"pitchFamily", 0}}); + }; + QObject::connect(&incoming, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { + if (behavior.startsWith("font-") && isFontOperation(request.value("operation").toString())) { + const auto operation = request.value("operation").toString(); + const auto result = request.value("result").toMap(); + if (behavior == "font-replay") { + if (!writeRaw(socket, mapRequest())) + std::_Exit(125); + return; + } + if (behavior == "font-flow" || behavior == "font-forged-id" || behavior == "font-overread" || + behavior == "font-closed-id") { + if (operation == "font.map") { + selectedFontId = result.value("fontId").toString(); + const auto id = behavior == "font-forged-id" ? QString(32, 'b') : selectedFontId; + const auto offset = behavior == "font-overread" ? 3 : 0; + if (!writeRaw(socket, fontRequest("font.read", {{"fontRequestId", 2}, + {"fontId", id}, + {"offset", offset}, + {"length", 4}}))) + std::_Exit(125); + return; + } + if (operation == "font.read") { + fontBytes = result.value("data").toByteArray(); + if (!writeRaw(socket, fontRequest("font.close", + {{"fontRequestId", 3}, {"fontId", selectedFontId}}))) + std::_Exit(125); + return; + } + if (behavior == "font-closed-id") { + if (!writeRaw(socket, fontRequest("font.close", + {{"fontRequestId", 4}, {"fontId", selectedFontId}}))) + std::_Exit(125); + return; + } + } + if (!writeRaw(socket, replyTo(fontParent, {{"fontResult", result}, + {"fontError", request.value("error").toMap()}, + {"data", fontBytes}, + {"received", received}}))) + std::_Exit(125); + return; + } + ++received; + const QCborMap result{{"sandboxed", true}, + {"received", received}, + {"receivedDuringStream", streaming}, + {"echo", request.value("payload")}}; + auto reply = replyTo(request, result); + if (behavior.startsWith("font-") && request.value("operation").toString() == "render") { + fontParent = request; + auto font = mapRequest(); + if (behavior == "font-wrong-parent") + font.insert(QStringLiteral("requestId"), request.value("requestId").toInteger() + 1); + if (behavior == "font-wrong-session") + font.insert(QStringLiteral("sessionId"), "other-session"); + if (behavior == "font-wrong-generation") + font.insert(QStringLiteral("generation"), request.value("generation").toInteger() + 1); + if (behavior == "font-bad-schema") { + auto invalid = font.value("payload").toMap(); + invalid.insert(QStringLiteral("path"), "/secret/font.ttf"); + font.insert(QStringLiteral("payload"), invalid); + } + if (!writeRaw(socket, font)) + std::_Exit(125); + if (behavior == "font-double") { + auto second = mapRequest(); + auto payload = second.value("payload").toMap(); + payload.insert(QStringLiteral("fontRequestId"), 2); + second.insert(QStringLiteral("payload"), payload); + if (!writeRaw(socket, second)) + std::_Exit(125); + } + if (behavior == "font-premature-parent" && !writeRaw(socket, reply)) + std::_Exit(125); + if (behavior == "font-timeout") { + QTimer::singleShot(20, &app, [&, request] { + auto timeout = request; + timeout.remove(QStringLiteral("payload")); + timeout.insert(QStringLiteral("error"), + QCborMap{{"code", "E_WORKER_TIMEOUT"}, {"message", "fixture timeout"}}); + if (!writeRaw(socket, timeout)) + std::_Exit(125); + }); + } + return; + } + if (behavior == "crash") + std::_Exit(73); // Unexpected exit with a live request; no core file. + if (behavior == "sandbox-unavailable") + std::_Exit(5); // The PDF worker reports sandbox failure with this code. + if (behavior == "close-without-reply") + std::_Exit(0); + if (behavior == "close-reply-exit" && request.value("operation").toString() == "close") { + if (!writeRaw(socket, replyTo(request, {{"closed", true}}))) + std::_Exit(125); + while (socket.bytesToWrite() > 0) + if (!socket.waitForBytesWritten(1000)) + std::_Exit(126); + std::_Exit(0); + } + if (behavior == "oversize") { + char header[4]; + qToBigEndian(MaxControlFrame + 1, header); + if (socket.write(header, 4) != 4) + std::_Exit(124); + socket.flush(); + return; // No body: rejection must occur at the length boundary. + } + if (behavior == "wrong-session") + reply.insert(QStringLiteral("sessionId"), QStringLiteral("different-session")); + else if (behavior == "wrong-generation") + reply.insert(QStringLiteral("generation"), request.value("generation").toInteger() + 1); + else if (behavior == "wrong-request") + reply.insert(QStringLiteral("requestId"), request.value("requestId").toInteger() + 1); + else if (behavior == "wrong-operation") + reply.insert(QStringLiteral("operation"), QStringLiteral("metadata")); + else if (behavior == "wrong-version") + reply.insert(QStringLiteral("protocolVersion"), 2); + else if (behavior == "error") { + reply.remove(QStringLiteral("result")); + reply.insert(QStringLiteral("error"), + QCborMap{{"code", "E_FIXTURE"}, {"message", "fixture error"}}); + } else if (behavior == "illegal-more") { + reply.insert(QStringLiteral("result"), QCborMap{{"more", true}, {"data", QByteArray("chunk")}}); + } else if (behavior == "stream" && request.value("operation").toString() == "extract") { + streaming = true; + if (!writeRaw( + socket, + replyTo(request, {{"more", true}, {"data", QByteArray("alpha")}, {"sandboxed", true}}))) + std::_Exit(125); + QTimer::singleShot(30, &app, [&, request] { + if (!writeRaw(socket, replyTo(request, {{"more", true}, {"data", QByteArray("beta")}}))) + std::_Exit(125); + }); + QTimer::singleShot(60, &app, [&, request] { + streaming = false; + if (!writeRaw(socket, + replyTo(request, {{"more", false}, {"size", 9}, {"received", received}}))) + std::_Exit(125); + }); + return; + } + if (!writeRaw(socket, reply)) + std::_Exit(125); + }); + return app.exec(); +} + +bool launch(WorkerProcess &worker, const QString &behavior) { + return worker.start(QCoreApplication::applicationFilePath(), {"--fake-worker", behavior}); +} + +} // namespace +#endif + +class WorkerProcessTest : public QObject { + Q_OBJECT + private slots: + void initTestCase() { +#ifndef Q_OS_LINUX + QSKIP("The production worker sandbox is verified only on Linux; no confinement bypass is used"); +#endif + } + + void successfulEnvelopesPreserveIdentityAndOrder() { + WorkerProcess worker("broker-session", 37); + QSignalSpy ready(&worker, &WorkerProcess::ready); + QSignalSpy failures(&worker, &WorkerProcess::failed); + QList replies; + QVERIFY(launch(worker, "echo")); + // Queue before the asynchronous socket connection is ready. + worker.request("ping", {{"ordinal", 1}}, [&](const QCborMap &reply) { replies << reply; }); + worker.request("metadata", {{"ordinal", 2}}, [&](const QCborMap &reply) { replies << reply; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 5000); + QCOMPARE(ready.size(), 1); + QVERIFY(worker.isConnected()); + QVERIFY(worker.idle()); + for (int i = 0; i < replies.size(); ++i) { + const auto reply = replies[i]; + QCOMPARE(reply.value("requestId").toInteger(), i + 1); + QCOMPARE(reply.value("sessionId").toString(), "broker-session"); + QCOMPARE(reply.value("generation").toInteger(), 37); + QCOMPARE(reply.value("operation").toString(), i == 0 ? "ping" : "metadata"); + const auto result = reply.value("result").toMap(); + QCOMPARE(result.value("echo").toMap().value("ordinal").toInteger(), i + 1); + QCOMPARE(result.value("received").toInteger(), i + 1); + QVERIFY(result.value("sandboxed").toBool()); + } + worker.stop(); + QCOMPARE(failures.size(), 0); + } + + void fontReverseRpcPreservesParentAndQueue() { + WorkerProcess worker("font-broker-session", 5); + QSignalSpy failures(&worker, &WorkerProcess::failed); + QStringList operations; + QList replies; + worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, auto completion) { + QVERIFY(!payload.contains(QStringLiteral("fontRequestId"))); + QVERIFY(!worker.idle()); + QCOMPARE(replies.size(), 0); + operations << operation; + QCborMap result; + if (operation == "font.map") { + result = {{"found", true}, + {"fontId", QString(32, 'a')}, + {"actualFaceLocal", QByteArray("Selected")}, + {"charset", 0}, + {"size", 4}, + {"sha256", QByteArray(32, 's')}, + {"faceIndex", 0}, + {"substituted", true}}; + } else if (operation == "font.read") { + result = {{"fontId", payload.value("fontId")}, + {"offset", payload.value("offset")}, + {"data", QByteArray("font")}}; + } else { + result = {{"released", true}}; + } + // A service running on a broker thread may complete off the GUI + // thread. WorkerProcess must post back before touching its channel. + std::thread([completion = std::move(completion), result] { completion(result); }).join(); + }); + QVERIFY(launch(worker, "font-flow")); + worker.request("render", {}, [&](const QCborMap &reply) { replies << reply; }); + worker.request("ping", {}, [&](const QCborMap &reply) { replies << reply; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 5000); + QCOMPARE(operations, QStringList({"font.map", "font.read", "font.close"})); + QCOMPARE(replies[0].value("requestId").toInteger(), 1); + QCOMPARE(replies[0].value("result").toMap().value("data").toByteArray(), QByteArray("font")); + QCOMPARE(replies[0].value("result").toMap().value("received").toInteger(), 1); + QCOMPARE(replies[1].value("result").toMap().value("received").toInteger(), 2); + QVERIFY(worker.idle()); + QCOMPARE(failures.size(), 0); + } + + void fontRequestWithoutExplicitServiceFails() { + WorkerProcess worker("no-font-service", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int replies = 0; + QVERIFY(launch(worker, "font-map")); + worker.request("render", {}, [&](const QCborMap &) { ++replies; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(failures[0][0].toString(), "E_WORKER_CRASH"); + QCOMPARE(replies, 0); + } + void fontRoleUsesExecutableNameRatherThanParentDirectory() { + QTemporaryDir directory(QDir::tempPath() + "/docview-archive-font-test-XXXXXX"); + QVERIFY(directory.isValid()); + const auto executable = directory.filePath("docview-pdf-worker"); + QVERIFY(QFile::link(QCoreApplication::applicationFilePath(), executable)); + WorkerProcess worker("font-path-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int replies = 0; + worker.setFontRequestHandler( + [](const QString &, const QCborMap &, auto complete) { complete({{"found", false}}); }); + QVERIFY(worker.start(executable, {"--fake-worker", "font-map"})); + worker.request("render", {}, [&](const QCborMap &) { ++replies; }); + QTRY_COMPARE_WITH_TIMEOUT(replies, 1, 5000); + QCOMPARE(failures.size(), 0); + } + + void hostileFontRequest_data() { + QTest::addColumn("behavior"); + for (const auto *behavior : {"font-wrong-parent", "font-wrong-session", "font-wrong-generation", + "font-bad-schema", "font-double", "font-premature-parent", "font-replay", + "font-forged-id", "font-overread", "font-closed-id"}) + QTest::newRow(behavior) << QString::fromLatin1(behavior); + } + void hostileFontRequest() { + QFETCH(QString, behavior); + WorkerProcess worker("hostile-font-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int replies = 0; + worker.setFontRequestHandler([&](const QString &operation, const QCborMap &payload, auto completion) { + QCborMap result; + if (operation == "font.map") + result = {{"found", true}, + {"fontId", QString(32, 'a')}, + {"actualFaceLocal", QByteArray("Selected")}, + {"charset", 0}, + {"size", 4}, + {"sha256", QByteArray(32, 's')}, + {"faceIndex", 0}, + {"substituted", true}}; + else if (operation == "font.read") + result = {{"fontId", payload.value("fontId")}, + {"offset", payload.value("offset")}, + {"data", QByteArray("font")}}; + else + result = {{"released", true}}; + QTimer::singleShot(10, &worker, + [completion = std::move(completion), result] { completion(result); }); + }); + QVERIFY(launch(worker, behavior)); + worker.request("render", {}, [&](const QCborMap &) { ++replies; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(failures[0][0].toString(), "E_WORKER_CRASH"); + QCOMPARE(replies, 0); + } + + void fontCompletionAfterStopAndDestructionIsDiscarded() { + auto worker = std::make_unique("stopped-font-session", 1); + QSignalSpy failures(worker.get(), &WorkerProcess::failed); + std::function complete; + int replies = 0; + worker->setFontRequestHandler( + [&](const QString &, const QCborMap &, auto completion) { complete = std::move(completion); }); + QVERIFY(launch(*worker, "font-map")); + worker->request("render", {}, [&](const QCborMap &) { ++replies; }); + QTRY_VERIFY_WITH_TIMEOUT(bool(complete), 5000); + worker->stop(); + complete({{"found", false}}); + QCoreApplication::processEvents(); + QCOMPARE(replies, 0); + QCOMPARE(failures.size(), 0); + worker.reset(); + std::thread([complete] { complete({{"found", false}}); }).join(); + QCoreApplication::processEvents(); + QCOMPARE(replies, 0); + } + + void parentFontTimeoutDiscardsLateServiceReply() { + WorkerProcess worker("font-timeout-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + std::function complete; + QList replies; + worker.setFontRequestHandler( + [&](const QString &, const QCborMap &, auto completion) { complete = std::move(completion); }); + QVERIFY(launch(worker, "font-timeout")); + worker.request("render", {}, [&](const QCborMap &reply) { replies << reply; }); + worker.request("ping", {}, [&](const QCborMap &reply) { replies << reply; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 2, 5000); + QCOMPARE(replies[0].value("error").toMap().value("code").toString(), "E_WORKER_TIMEOUT"); + QVERIFY(bool(complete)); + complete({{"found", false}}); + QTest::qWait(20); + QCOMPARE(failures.size(), 0); + QVERIFY(worker.idle()); + } + + void fontServiceErrorIsReturnedWithSubrequestIdentity() { + WorkerProcess worker("font-error-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + QList replies; + worker.setFontRequestHandler([](const QString &, const QCborMap &, auto complete) { + complete({{"error", QCborMap{{"code", "E_FONT_LIMIT"}, {"message", "fixture quota"}}}}); + }); + QVERIFY(launch(worker, "font-map")); + worker.request("render", {}, [&](const QCborMap &reply) { replies << reply; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 5000); + const auto error = replies[0].value("result").toMap().value("fontError").toMap(); + QCOMPARE(error.value("code").toString(), "E_FONT_LIMIT"); + QCOMPARE(error.value("fontRequestId").toInteger(), 1); + QCOMPARE(failures.size(), 0); + } + + void errorEnvelopeIsDeliveredWithoutBrokerFailure() { + WorkerProcess worker("error-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + QList replies; + QVERIFY(launch(worker, "error")); + worker.request("open", {}, [&](const QCborMap &reply) { replies << reply; }); + QTRY_COMPARE_WITH_TIMEOUT(replies.size(), 1, 5000); + QCOMPARE(replies.front().value("error").toMap().value("code").toString(), "E_FIXTURE"); + QVERIFY(worker.idle()); + QCOMPARE(failures.size(), 0); + } + + void closeAcknowledgementIsTerminal() { + WorkerProcess worker("close-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int closed = 0, afterClose = 0; + QVERIFY(launch(worker, "close-reply-exit")); + worker.request("close", {}, [&](const QCborMap &reply) { + QVERIFY(reply.value("result").toMap().value("closed").toBool()); + ++closed; + worker.request("ping", {}, [&](const QCborMap &) { ++afterClose; }); + }); + worker.request("metadata", {}, [&](const QCborMap &) { ++afterClose; }); + QTRY_COMPARE_WITH_TIMEOUT(closed, 1, 5000); + QTest::qWait(100); + QCOMPARE(failures.size(), 0); + QCOMPARE(afterClose, 0); + QVERIFY(worker.idle()); + QVERIFY(!worker.isConnected()); + } + + void closeExitWithoutAcknowledgementFails() { + WorkerProcess worker("missing-close-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int closed = 0; + QVERIFY(launch(worker, "close-without-reply")); + worker.request("close", {}, [&](const QCborMap &) { ++closed; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(failures.first()[0].toString(), "E_WORKER_CRASH"); + QCOMPARE(closed, 0); + QVERIFY(worker.idle()); + } + + void hostileEnvelope_data() { + QTest::addColumn("behavior"); + for (const auto *behavior : {"wrong-session", "wrong-generation", "wrong-request", "wrong-operation", + "wrong-version", "oversize", "illegal-more"}) + QTest::newRow(behavior) << QString::fromLatin1(behavior); + } + + void hostileEnvelope() { + QFETCH(QString, behavior); + WorkerProcess worker("correct-session", 7); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int callbacks = 0; + QVERIFY(launch(worker, behavior)); + worker.request("ping", {}, [&](const QCborMap &) { ++callbacks; }); + worker.request("metadata", {}, [&](const QCborMap &) { ++callbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(failures.front()[0].toString(), "E_WORKER_CRASH"); + QVERIFY(!failures.front()[1].toString().isEmpty()); + QCOMPARE(callbacks, 0); + QVERIFY(worker.idle()); + QTest::qWait(100); // Process termination must not emit a second failure. + QCOMPARE(failures.size(), 1); + QCOMPARE(callbacks, 0); + } + + void unexpectedExitWhileActiveFailsOnce() { + WorkerProcess worker("crash-session", 1); + QSignalSpy ready(&worker, &WorkerProcess::ready); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int callbacks = 0; + QVERIFY(launch(worker, "crash")); + worker.request("render", {}, [&](const QCborMap &) { ++callbacks; }); + worker.request("pages", {}, [&](const QCborMap &) { ++callbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(ready.size(), 1); + QCOMPARE(failures.front()[0].toString(), "E_WORKER_CRASH"); + QTest::qWait(100); + QCOMPARE(failures.size(), 1); + QCOMPARE(callbacks, 0); + QVERIFY(worker.idle()); + } + + void unavailableSandboxHasSpecificError() { + WorkerProcess worker("sandbox-session", 1); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int callbacks = 0; + QVERIFY(launch(worker, "sandbox-unavailable")); + worker.request("open", {}, [&](const QCborMap &) { ++callbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(failures.size(), 1, 5000); + QCOMPARE(failures.front()[0].toString(), "E_SANDBOX_UNAVAILABLE"); + QVERIFY(failures.front()[1].toString().contains("保護")); + QTest::qWait(100); + QCOMPARE(failures.size(), 1); + QCOMPARE(callbacks, 0); + QVERIFY(worker.idle()); + } + + void extractChunksKeepRequestActiveUntilFinal() { + WorkerProcess worker("stream-session", 3); + QSignalSpy failures(&worker, &WorkerProcess::failed); + QStringList delivered; + QByteArray content; + bool final = false; + QVERIFY(launch(worker, "stream")); + worker.request("extract", {{"path", "chapter.xhtml"}}, [&](const QCborMap &reply) { + const auto result = reply.value("result").toMap(); + QCOMPARE(reply.value("requestId").toInteger(), 1); + if (result.value("more").toBool()) { + QVERIFY(!worker.idle()); + QVERIFY(!final); + content += result.value("data").toByteArray(); + delivered << "chunk"; + } else { + final = true; + QCOMPARE(result.value("size").toInteger(), 9); + QCOMPARE(result.value("received").toInteger(), 1); + delivered << "final"; + } + }); + worker.request("ping", {}, [&](const QCborMap &reply) { + QVERIFY(final); + const auto result = reply.value("result").toMap(); + QVERIFY(!result.value("receivedDuringStream").toBool()); + QCOMPARE(result.value("received").toInteger(), 2); + delivered << "ping"; + }); + QTRY_COMPARE_WITH_TIMEOUT(delivered.size(), 4, 5000); + QCOMPARE(delivered, QStringList({"chunk", "chunk", "final", "ping"})); + QCOMPARE(content, "alphabeta"); + QVERIFY(worker.idle()); + QCOMPARE(failures.size(), 0); + } + + void stopDropsActiveAndQueuedReplies() { + WorkerProcess worker("stopped-session", 3); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int activeCallbacks = 0, queuedCallbacks = 0; + QVERIFY(launch(worker, "stream")); + worker.request("extract", {}, [&](const QCborMap &reply) { + ++activeCallbacks; + QVERIFY(reply.value("result").toMap().value("more").toBool()); + QCOMPARE(worker.activeRequestCount(), 1); + QCOMPARE(worker.activeRequestCount("extract"), 1); + QCOMPARE(worker.activeRequestCount("ping"), 0); + QCOMPARE(worker.queuedRequestCount(), 1); + QCOMPARE(worker.queuedRequestCount("ping"), 1); + worker.stop(); + QCOMPARE(worker.activeRequestCount(), 0); + QCOMPARE(worker.queuedRequestCount(), 0); + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(1)); + worker.stop(); // Repeated stop cannot count the same cancellation twice. + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(1)); + }); + worker.request("ping", {}, [&](const QCborMap &) { ++queuedCallbacks; }); + QTRY_COMPARE_WITH_TIMEOUT(activeCallbacks, 1, 5000); + worker.request("metadata", {}, [&](const QCborMap &) { ++queuedCallbacks; }); + QTest::qWait(150); + QCOMPARE(activeCallbacks, 1); + QCOMPARE(queuedCallbacks, 0); + QCOMPARE(failures.size(), 0); + QVERIFY(worker.idle()); + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(1)); + } + + void discardedQueuedRequestsNeverReachWorker_data() { + QTest::addColumn("selective"); + QTest::newRow("operation-only") << true; + QTest::newRow("all-queued") << false; + } + + void discardedQueuedRequestsNeverReachWorker() { + QFETCH(bool, selective); + WorkerProcess worker("discard-session", 3); + QSignalSpy failures(&worker, &WorkerProcess::failed); + int activeCallbacks = 0, droppedCallbacks = 0, keptCallbacks = 0; + bool final = false; + const auto kept = [&](const QCborMap &reply) { + ++keptCallbacks; + QVERIFY(final); + QCOMPARE(reply.value("result").toMap().value("received").toInteger(), 2); + }; + QVERIFY(launch(worker, "stream")); + worker.request("extract", {}, [&](const QCborMap &reply) { + ++activeCallbacks; + if (activeCallbacks == 1) { + QCOMPARE(worker.activeRequestCount("extract"), 1); + QCOMPARE(worker.queuedRequestCount(), 3); + QCOMPARE(worker.queuedRequestCount("render"), 2); + QCOMPARE(worker.queuedRequestCount("metadata"), 1); + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(0)); + worker.discardQueued(selective ? "render" : QString{}); + QCOMPARE(worker.queuedRequestCount(), selective ? 1 : 0); + QCOMPARE(worker.queuedRequestCount("render"), 0); + QCOMPARE(worker.activeRequestCount("extract"), 1); + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(selective ? 2 : 3)); + if (!selective) + worker.request("ping", {}, kept); + } + if (!reply.value("result").toMap().value("more").toBool()) + final = true; + }); + worker.request("render", {{"page", 1}}, [&](const QCborMap &) { ++droppedCallbacks; }); + worker.request("render", {{"page", 2}}, [&](const QCborMap &) { ++droppedCallbacks; }); + worker.request("metadata", {}, [&](const QCborMap &reply) { + if (selective) + kept(reply); + else + ++droppedCallbacks; + }); + QTRY_COMPARE_WITH_TIMEOUT(keptCallbacks, 1, 5000); + QCOMPARE(activeCallbacks, 3); + QCOMPARE(droppedCallbacks, 0); + QVERIFY(worker.idle()); + QCOMPARE(worker.activeRequestCount(), 0); + QCOMPARE(worker.queuedRequestCount(), 0); + QCOMPARE(worker.discardedQueuedRequestCount(), quint64(selective ? 2 : 3)); + QTest::qWait(100); + QCOMPARE(droppedCallbacks, 0); + QCOMPARE(failures.size(), 0); + } +}; + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + if (app.arguments().contains("--fake-worker")) + return fakeWorker(app); + WorkerProcessTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "test_worker_process.moc" diff --git a/tests/ubuntu_vm/README.md b/tests/ubuntu_vm/README.md new file mode 100644 index 0000000..7ebe878 --- /dev/null +++ b/tests/ubuntu_vm/README.md @@ -0,0 +1,97 @@ +# Ubuntu 24.04 の作業専用 VM + +この手順は Arch 開発ホスト上の既存 KVM/QEMU を使い、Ubuntu のカーネル・AppArmor・ユーザー空間で DocView をビルドして試験するためのもの。コンテナーの Ubuntu ユーザー空間だけの試験とは区別する。ホストへパッケージをインストールせず、ホストの既存文書・ホーム・Docker socket を guest に共有しない。 + +作業先の既定値は `build-ubuntu-vm`。変更する場合は全コマンドで `DOCVIEW_VM_WORK` を同じ絶対パスに設定する。`private/` の SSH 鍵・seed・known_hosts・serial console は報告物へ含めない。作業先は `.gitignore` の `/build*/` により追跡対象外。4 vCPU、8 GiB RAM、24 GiB の差分ディスクを使い、SSH は `127.0.0.1:22224` のみ。QMP socket も作業先だけに置く。 + +## 固定入力 + +- Ubuntu cloud image: `https://cloud-images.ubuntu.com/noble/20260911/noble-server-cloudimg-amd64.img`、625,256,960 bytes、SHA-256 `612b2c0cc1bc413a6cb8c38fd611794caf0f2b436c50013d8b3794db12ad7354`。同じ公式ディレクトリーの `SHA256SUMS.gpg` を、[Canonical 公開 fingerprint](https://ubuntu.com/docs/public-images/public-images-how-to/verify-image-checksum/) `D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81` に照合する。 +- ホスト補助の qemu-img 11.1.1-2: Arch の公開 package を workspace へだけ展開。固定 SHA-256 とホストに既存の読み取り専用パッケージ鍵リングによる署名検証を行う。既存 QEMU 11.1.1、OVMF `/usr/share/edk2-ovmf/x64/`、GnuPG、bsdtar を前提とする。 +- ISO 作成の pycdlib 1.14.0: PyPI wheel 213,201 bytes を取得し、公開 SHA-256 を照合して workspace にだけ展開。 +- Qt 6.11.2: [公式 basic SDK metadata](https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6112/qt6_6112/Updates.xml) と [公式 WebEngine extension metadata](https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6112/x86_64/Updates.xml)。basic、WebChannel、Positioning、WebEngine の実 archive 合計 325,996,106 bytes。Qt 公開 SHA-1 sidecar と照合し、各ファイルの SHA-256 も記録する。Qt Creator と debug symbols は取得しない。これらは公式ファイル名で RHEL 9.6 build とされており、Arch パッケージと同一バイナリーではない。 +- qpdf 12.4.1: [公式 release](https://github.com/qpdf/qpdf/releases/tag/v12.4.1) source 19,713,921 bytes、release API の SHA-256 と照合。 +- libzip 1.11.4: [公式 source](https://libzip.org/download/)、793,340 bytes。固定 SHA-256 は KDE Ark の [公開ビルド定義](https://github.com/flathub/org.kde.ark/blob/master/org.kde.ark.json) とも対応する。上流が detached signature を公開しているとは主張しない。 +- PDFium 155.0.8057.0: 既存 `.deps/pdfium` を source snapshot に含め、リポジトリーの lock と supplemental notice 検証を通常 CMake で維持する。 + +## 作成と起動 + +リポジトリー root から実行する。取得・VM 起動は通常の OS 権限が必要。 + +```sh +python3 tests/ubuntu_vm/prepare_downloads.py +python3 tests/ubuntu_vm/verify_inputs.py +python3 tests/ubuntu_vm/create_guest.py +python3 tests/ubuntu_vm/run_guest.py +``` + +最後のコマンドは VM の前景プロセスを維持する。別ターミナルから `python3 tests/ubuntu_vm/probe_guest.py` で起動・SSH・OS・Landlock・AppArmor を記録する。初回の cloud-init が完了するまで待つ。seed はパスワード認証と root SSH を無効にし、作業専用 `docview` user と新規公開鍵を設定する。guest の root 作業には、この guest user の sudo だけを使う。 + +`qmp.py query-status`、`qmp.py stop`、`qmp.py cont` で状態確認・一時停止・再開、`qmp.py system_powerdown` で正常終了できる。性能測定の並行実行を避ける場合は guest を停止する。正常終了して QEMU が終了してからだけディスクをコピー・削除する。 + +## 依存環境と本体 + +1. guest 内で APT update、必要 package の `--assume-no` simulation、`--no-install-recommends` install を行う。固定 package 一覧は [apt-packages.json](apt-packages.json)、初回の実行記録は作業先 `metadata/apt-packages.json`、候補・取得量・実配置ログは `logs/apt-*.txt`。Ubuntu 自身の署名付きリポジトリーを使う。ホスト APT/Pacman は変更しない。 +2. `python3 tests/ubuntu_vm/fetch_sdk.py` で固定 SDK/source を取得する。`sdk-downloads/` と `metadata/sdk-downloads.json`、`install_guest_sdk.py` を専用 SSH で guest の `~/incoming/` へコピーする。`python3 incoming/install_guest_sdk.py` を guest 内で実行する。 +3. Qt は `/opt/docview-qt/6.11.2/gcc_64`、qpdf/libzip は `/opt/docview-deps` へ配置される。QtWebEngineProcess のこの固定パスだけを対象に、Ubuntu 標準と同じ `flags=(unconfined) { userns, }` 形式の AppArmor profile を追加する。これは Chromium 内部の sandbox を無効にする設定ではない。`kernel.apparmor_restrict_unprivileged_userns=1` を維持し、`--no-sandbox` 等は使わない。 +4. 本体ソースが安定した時点で `python3 tests/ubuntu_vm/snapshot.py --name source-snapshot` を実行する。既存出力があれば別名にする。snapshot archive と manifest を guest の `~/incoming/source-snapshot.tar.gz`、`source-snapshot.json` へ転送し、`build_guest.py` を同ディレクトリーへコピーして実行する。全ファイルの SHA-256 を再照合してから `~/docview-source`、`~/docview-build` でビルドする。 + +guest での通常 build/test 環境は次のとおり。 + +```sh +export PATH=/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:$PATH +export PKG_CONFIG_PATH=/opt/docview-deps/lib/pkgconfig +export LD_LIBRARY_PATH=/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib +export QT_QPA_PLATFORM=xcb +export QT_QUICK_BACKEND=software +mkdir -p ~/validation/ctest +dbus-run-session -- xvfb-run -a -s '-screen 0 1920x1080x24' \ + ctest --test-dir ~/docview-build --output-on-failure \ + --output-junit ~/validation/ctest/tests.xml +``` + +guest 内の実版・kernel・LSM、入出力の hash、CTest ログは個別に保存する。後続ソース変更を同期する際は前の manifest/結果を保持し、`src`/`qml` 差分を明示する。この VM の Xvfb/Sway headless の結果は、物理 GPU・実ディスプレイ・人の IME 操作・配布ライセンス確認まで合格した証拠にはしない。Arch 用 tar/告知 manifest の一致を、異なる Ubuntu/公式 Qt SDK の配布証拠へ流用しない。 + + +## 差分と追加検証 + +`sync_patch.py --name <新しい名前> ` は指定したsourceだけを転送し、guestの変更前本文と前後SHA-256を `~/validation/source-deltas/` へ保存する。既存名は再使用しない。秘密鍵・設定・ユーザー文書はsource集合へ含めない。 + +追加Wayland試験にはguestの `sway` packageを使う。runnerは独立D-Busと一時XDG保存先、1920×1080のheadless/pixman compositorを作る。実際のサイズ変更がQt側とcompositor側で確定してから次の操作へ進む。小さい出力では縦960pxの試験前提を満たせない。 + +```sh +python3 ~/docview-source/tests/run_wayland_validation.py --build-dir ~/docview-build --output ~/validation/wayland/gui --mode gui +python3 ~/docview-source/tests/run_wayland_validation.py --build-dir ~/docview-build --output ~/validation/wayland/smoke --mode smoke +cmake --install ~/docview-build --prefix ~/docview-install +``` + +UbuntuのPython 3.12では、Arch package cacheのzstd専用fixture 5件を明示skipする。Python 3.14で実行したArchの35件を、Ubuntuの35件成功として扱わない。Ubuntuでは残り30件と、別のinstalled runtime inventoryを検査する。 + +## 配置先の検査 + +guest内で、install済みの本体に対して以下を実行できる。出力先は新しいdirectoryを選ぶ。 + +```sh +python3 ~/docview-source/tools/collect_ubuntu_validation_runtime.py \ + --prefix ~/docview-install \ + --qtpaths /opt/docview-qt/6.11.2/gcc_64/bin/qtpaths \ + --dependency-prefix /opt/docview-deps \ + --input-manifest ~/incoming/sdk-downloads.json \ + --source-root ~/dependency-sources/qpdf-12.4.1 \ + --source-root ~/dependency-sources/libzip-1.11.4 \ + --output ~/validation/new-installed-runtime +``` + +collectorは選択した信頼済みbuild/SDKにだけ`ldd`を実行し、ファイル・symbolic link・実体hash・依存解決・dpkg所有packageを確認する。任意の未信頼実行ファイルを調べるためには使わない。runtimeをコピーした配布物は作らず、告知の欠落と完全なChromium告知未確認も記録する。SDKのSBOMには10MBを越えるものがあるため、metadataは1件16MiB・合計64MiB、告知本文は1件8MiB・合計32MiBの別上限を使う。 + +本体起動時は上記の`LD_LIBRARY_PATH`を維持し、`tests/smoke.py --binary ~/docview-install/bin/docview --output ~/validation/new-installed-smoke`を専用Xvfb内で実行する。これは別prefixへのbuild/install検査であり、自己完結packageの配布確認ではない。 + +[保存済みの実行結果](../results/ui-final/ubuntu/README.md)には失敗と修正後の成功、配置後の6条件起動、依存台帳を区別して残した。試験の途中でフォントRPCの待機を修正したため、現行の`record_guest_validation.py`はこの検証で使った`~/validation/ctest-font-final/`を照合対象にする。 + +追加の限定試験では`--ctest-name canvas-ctest --output-name canvas-build-record.json --expected-groups 2 --scope-note 'Canvas focused tests'`のように記録先と範囲を指定する。既存の全体試験の台帳を上書きせず、変更後のsource・実行ファイル・JUnit・ログのhashを別に保存する。WaylandのCanvas単独実行は`run_wayland_validation.py --mode gui --suite pdf_canvas`で選択できる。 + +## Ubuntu用deb + +[パッケージ生成手順](../../docs/UBUNTU-PACKAGE.md)と[実行結果](../results/ubuntu-package/README.md)を追加した。`package_smoke.py --output <新規先>`は専用VMでインストール済みの`/usr/bin/docview`を使い、元SDK・依存・build/installを私有mount namespaceで隠して通常ユーザーでX11/Wayland各6条件を検査する。`package_lifecycle.py --smoke <成功したsmoke先> --output <新規先>`は、そのバイナリー一致を確認してDocViewだけをremove/purgeし、新しく作ったユーザーデータprobeの保持を検査する。後者の検証後、VM内のDocViewパッケージは未インストールになる。開発環境のSDKとソースは保持する。 + +クリーンOS検証は`DOCVIEW_VM_WORK=build-ubuntu-package-clean`で同じ読取専用base imageから別overlayを作り、開発VMを停止してから同じSSHポートを使う。SDKやbuildを転送せず、debと試験入力だけを転送した。`clean_package.py --phase install|smoke|remove --archive <検証済みdeb> --output <新規記録先>`を順に実行する。install phaseは宣言依存で全ELFが解決することを確認してからGUI試験ツールを追加する。[新規OSの結果](../results/ubuntu-package/clean-vm/README.md)に元image・転送内容・apt変更・3 phaseを保存した。 diff --git a/tests/ubuntu_vm/apt-packages.json b/tests/ubuntu_vm/apt-packages.json new file mode 100644 index 0000000..281b61e --- /dev/null +++ b/tests/ubuntu_vm/apt-packages.json @@ -0,0 +1,56 @@ +[ + "build-essential", + "cmake", + "ninja-build", + "pkg-config", + "libseccomp-dev", + "libfontconfig1-dev", + "libtomlplusplus-dev", + "zlib1g-dev", + "libjpeg-dev", + "libssl-dev", + "libgnutls28-dev", + "libbz2-dev", + "liblzma-dev", + "libzstd-dev", + "libgl1-mesa-dev", + "libegl1-mesa-dev", + "libxkbcommon-x11-0", + "libxcb-cursor0", + "libxcb-icccm4", + "libxcb-image0", + "libxcb-keysyms1", + "libxcb-render-util0", + "libxcb-randr0", + "libxcb-shape0", + "libxcb-sync1", + "libxcb-xfixes0", + "libxcb-xinerama0", + "libxcb-xkb1", + "libxcomposite1", + "libxdamage1", + "libxrandr2", + "libxtst6", + "libnss3", + "libasound2t64", + "libpulse0", + "libxss1", + "libgbm1", + "libopengl0", + "libvulkan1", + "xvfb", + "xauth", + "dbus-x11", + "fonts-dejavu-core", + "fonts-liberation", + "fonts-noto-cjk", + "locales", + "mesa-utils", + "weston", + "p7zip-full", + "python3-pil", + "python3-fonttools", + "python3-venv", + "poppler-utils", + "sway" +] diff --git a/tests/ubuntu_vm/build_guest.py b/tests/ubuntu_vm/build_guest.py new file mode 100644 index 0000000..15b5bd1 --- /dev/null +++ b/tests/ubuntu_vm/build_guest.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Verify and build an explicitly transferred source snapshot inside the guest.""" +import hashlib,json,os,subprocess,tarfile +from pathlib import Path +incoming=Path.home()/'incoming';source=Path.home()/'docview-source';build=Path.home()/'docview-build' +record=json.loads((incoming/'source-snapshot.json').read_text()) +with (incoming/'source-snapshot.tar.gz').open('rb') as f:assert hashlib.file_digest(f,'sha256').hexdigest()==record['archiveSha256'] +source.mkdir(exist_ok=True) +with tarfile.open(incoming/'source-snapshot.tar.gz') as t:t.extractall(source,filter='data') +for row in record['files']: + with (source/row['path']).open('rb') as f:assert hashlib.file_digest(f,'sha256').hexdigest()==row['sha256'] +env=os.environ.copy();env['PATH']='/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:'+env['PATH'];env['PKG_CONFIG_PATH']='/opt/docview-deps/lib/pkgconfig';env['LD_LIBRARY_PATH']='/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib' +print('Verified',len(record['files']),'input files; snapshot',record['archiveSha256'],flush=True) +subprocess.run(['cmake','-S',str(source),'-B',str(build),'-G','Ninja','-DCMAKE_BUILD_TYPE=Release','-DCMAKE_PREFIX_PATH=/opt/docview-qt/6.11.2/gcc_64;/opt/docview-deps'],env=env,check=True) +subprocess.run(['cmake','--build',str(build),'--parallel','4'],env=env,check=True) diff --git a/tests/ubuntu_vm/clean_package.py b/tests/ubuntu_vm/clean_package.py new file mode 100644 index 0000000..8a61d0d --- /dev/null +++ b/tests/ubuntu_vm/clean_package.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Test installation or upgrade of a deb on the dedicated VM without an SDK.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +APP = Path('/opt/docview') +ARCHIVE_SHA = '978904fd5986694f7b053381dcb6ca1ac07b92884ef9768c320923d179d873e5' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--phase', choices=['install', 'smoke', 'remove'], required=True) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--archive-sha256', default=ARCHIVE_SHA) + parser.add_argument('--upgrade', action='store_true', help='Require an existing DocView package during install') + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + assert os.getuid() != 0 and Path.home() == Path('/home/docview') + assert re.fullmatch('[0-9a-f]{64}', args.archive_sha256) + assert sha(args.archive) == args.archive_sha256 + for name in ['/opt/docview-qt', '/opt/docview-deps', '/home/docview/docview-build', '/home/docview/docview-install']: + assert not Path(name).exists(), name + output = args.output.resolve() / args.phase + output.mkdir(parents=True, exist_ok=False) + record = {'success': False, 'phase': args.phase, 'archiveSha256': args.archive_sha256, + 'installationMode': 'upgrade' if args.upgrade else 'fresh-install', + 'runnerSha256': sha(Path(__file__)), 'commands': [], 'originalSdkOrBuildPresent': False, + 'osRelease': Path('/etc/os-release').read_text(), 'uid': os.getuid()} + + def run(name, command, environment=None, allowed=(0,)): + with (output / (name + '.log')).open('w') as stream: + result = subprocess.run(command, env=environment, stdout=stream, stderr=subprocess.STDOUT, timeout=900) + record['commands'].append({'name': name, 'command': command, 'exitCode': result.returncode, + 'logSha256': sha(output / (name + '.log'))}) + assert result.returncode in allowed, name + return (output / (name + '.log')).read_text() + + def packages(name): + return run(name, ['dpkg-query', '-W', '-f=${Package}\t${Version}\t${db:Status-Status}\n']) + + minimal = {'PATH': '/usr/bin:/bin', 'HOME': '/home/docview', 'USER': 'docview', + 'LOGNAME': 'docview', 'LANG': 'C.UTF-8'} + try: + if args.phase == 'install': + before = packages('packages-before') + installed = [line for line in before.splitlines() if line.startswith('docview\t')] + if args.upgrade: + assert APP.is_dir() and Path('/usr/bin/docview').is_file() + assert len(installed) == 1 and installed[0].endswith('\tinstalled') + record['previousPackage'] = installed[0] + record['previousManifestSha256'] = sha(APP / 'share/doc/docview/package-manifest.json') + else: + assert not APP.exists() and not Path('/usr/bin/docview').exists() and not installed + run('apt-update', ['sudo', 'apt-get', 'update']) + run('apt-plan', ['sudo', 'apt-get', '-s', '--no-install-recommends', 'install', str(args.archive)]) + run('apt-install', ['sudo', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', + '--no-install-recommends', 'install', str(args.archive)]) + after = packages('packages-after-install') + rows = json.loads((APP / 'share/doc/docview/package-manifest.json').read_text())['files'] + for row in rows: + if row['path'].startswith('DEBIAN/'): continue + path = Path('/') / row['path']; info = path.stat() + assert info.st_uid == 0 and info.st_gid == 0 + assert oct(info.st_mode & 0o7777) == row['mode'] + assert info.st_size == row['size'] and sha(path) == row['sha256'] + environment = {**minimal, 'LD_LIBRARY_PATH': '/opt/docview/lib:/opt/docview/qt/lib'} + dependencies = [] + for path in sorted(APP.rglob('*')): + if not path.is_file(): continue + with path.open('rb') as stream: + if stream.read(4) != b'\x7fELF': continue + result = subprocess.run(['ldd', str(path)], env=environment, capture_output=True, text=True, timeout=30) + assert result.returncode == 0 and 'not found' not in result.stdout, str(path) + result.stdout + resolved = re.findall(r'(?:=>\s+)?(/[^\s]+)\s+\(', result.stdout) + assert resolved or result.stdout.strip() == 'statically linked' + assert all(name.startswith(('/opt/docview/', '/lib/', '/lib64/', '/usr/lib/')) for name in resolved) + dependencies.append({'path': str(path), 'sha256': sha(path), 'resolved': sorted(set(resolved))}) + record['elfDependenciesBeforeTestHelperInstallStep'] = dependencies + record['installedFilesVerified'] = sum(not row['path'].startswith('DEBIAN/') for row in rows) + record['executables'] = {name: sha(APP / 'bin' / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']} + profiles = run('apparmor-installed', ['sudo', 'cat', '/sys/kernel/security/apparmor/profiles']) + assert 'docview-bundled-qtwebengine ' in profiles and 'docview-qtwebengine ' not in profiles + assert run('userns-before', ['sysctl', '-n', 'kernel.apparmor_restrict_unprivileged_userns']).strip() == '1' + record['newInstalledPackages'] = sorted(set(after.splitlines()) - set(before.splitlines())) + # Dependency resolution is recorded before adding test infrastructure. + run('apt-test-helpers', ['sudo', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', + '--no-install-recommends', 'install', 'xvfb', 'xauth', 'sway', 'dbus-x11']) + packages('packages-after-test-helpers') + elif args.phase == 'smoke': + installed = json.loads((args.output / 'install/report.json').read_text()) + assert installed['success'] + record['executables'] = installed['executables'] + record['launcherSha256'] = sha(Path('/usr/bin/docview')) + record['smokeSourceSha256'] = sha(ROOT / 'tests/smoke.py') + record['waylandRunnerSha256'] = sha(ROOT / 'tests/run_wayland_validation.py') + for name, digest in installed['executables'].items(): assert sha(APP / 'bin' / name) == digest + run('x11', ['dbus-run-session', '--', 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24', sys.executable, + str(ROOT / 'tests/smoke.py'), '--binary', '/usr/bin/docview', '--output', str(output / 'x11')], + {**minimal, 'QT_QPA_PLATFORM': 'xcb', 'QT_QUICK_BACKEND': 'software', 'QTWEBENGINE_CHROMIUM_FLAGS': '--disable-gpu'}) + run('wayland', [sys.executable, str(ROOT / 'tests/run_wayland_validation.py'), '--build-dir', '/opt/docview/bin', + '--smoke-binary', '/usr/bin/docview', '--output', str(output / 'wayland'), '--mode', 'smoke'], minimal) + for relative in ['x11/results.json', 'wayland/smoke/results.json']: + cases = json.loads((output / relative).read_text()) + assert len(cases) == 6 and all(row['state'] == 'Ready' and row['binarySha256'] == record['launcherSha256'] for row in cases) + for name, digest in installed['executables'].items(): assert sha(APP / 'bin' / name) == digest + record.update(smokeConditions=12, executableBytesUnchanged=True, callerRuntimeVariablesAbsent=True) + else: + assert json.loads((args.output / 'smoke/report.json').read_text())['success'] + probes = [] + try: + for directory in ['.config/docview', '.local/state/docview', '.local/share/docview', 'validation/clean-user-document']: + path = Path.home() / directory / 'clean-package-probe.txt'; path.parent.mkdir(parents=True, exist_ok=True) + with path.open('x') as stream: stream.write('保持する文書と設定\n') + probes.append({'path': str(path), 'sha256': sha(path)}) + run('apt-remove', ['sudo', 'apt-get', '-y', 'remove', 'docview']) + assert not APP.exists() and not Path('/usr/bin/docview').exists() + assert not Path('/usr/share/applications/docview.desktop').exists() + assert Path('/etc/apparmor.d/docview').is_file() + assert 'docview-bundled-qtwebengine ' not in run('apparmor-removed', ['sudo', 'cat', '/sys/kernel/security/apparmor/profiles']) + assert all(sha(Path(row['path'])) == row['sha256'] for row in probes) + run('apt-purge', ['sudo', 'apt-get', '-y', 'purge', 'docview']) + assert not Path('/etc/apparmor.d/docview').exists() + assert all(sha(Path(row['path'])) == row['sha256'] for row in probes) + assert run('userns-after', ['sysctl', '-n', 'kernel.apparmor_restrict_unprivileged_userns']).strip() == '1' + record.update(payloadRemoved=True, profileUnloaded=True, conffilePurged=True, + userProbesPreserved=probes, kernelRestrictionUnchanged=True) + finally: + for row in probes: + path = Path(row['path']) + if path.is_file() and sha(path) == row['sha256']: path.unlink() + record['success'] = True + finally: + (output / 'report.json').write_text(json.dumps(record, indent=2, sort_keys=True) + '\n') + print(json.dumps({'phase': args.phase, 'success': record['success']})) + + +if __name__ == '__main__': + main() diff --git a/tests/ubuntu_vm/context_package.py b/tests/ubuntu_vm/context_package.py new file mode 100644 index 0000000..6996f27 --- /dev/null +++ b/tests/ubuntu_vm/context_package.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Build/package the pinned v2 candidate only in the dedicated Ubuntu guest. + +Input transfer is recorded separately. Existing source/build/install/provider +prefixes and earlier evidence are preserved. This does not stop the VM. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +HOME = Path('/home/docview') +PREFIX = HOME / 'validation/pdfium-context-prefix' +BUILD = HOME / 'docview-context-build' +INSTALL = HOME / 'docview-context-install' +RESULTS = HOME / 'validation/pdfium-context-package' +VERSION = '0.1.0~validation4' + + +def sha(path): + with path.open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--phase', choices=('build', 'package', 'smoke'), required=True) + parser.add_argument('--run-name', required=True) + args = parser.parse_args() + assert os.getuid() != 0 and Path.home() == HOME and ROOT == HOME / 'docview-context-source' + assert re.fullmatch('[a-z0-9-]{1,64}', args.run_name) + output = RESULTS / args.run_name; output.mkdir(parents=True, exist_ok=False) + env = {**os.environ, 'PATH': '/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:' + os.environ['PATH'], + 'PKG_CONFIG_PATH': '/opt/docview-deps/lib/pkgconfig', + 'LD_LIBRARY_PATH': '/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib', + 'QT_QPA_PLATFORM': 'xcb', 'QT_QUICK_BACKEND': 'software', + 'QTWEBENGINE_CHROMIUM_FLAGS': '--disable-gpu', 'QT_SCALE_FACTOR': '1', + 'QT_AUTO_SCREEN_SCALE_FACTOR': '0', 'XDG_SESSION_TYPE': 'x11'} + for key in ('LD_PRELOAD', 'LD_AUDIT', 'QTWEBENGINE_DISABLE_SANDBOX', 'WAYLAND_DISPLAY'): + assert not env.get(key), key + preserved = [HOME / 'docview-source/.deps/pdfium/lib/libpdfium.so', HOME / 'docview-build/docview', + HOME / 'docview-build/docview-pdf-worker', HOME / 'docview-install/bin/docview'] + before = {str(path): sha(path) for path in preserved} + report = {'success': False, 'phase': args.phase, 'commands': [], 'runnerSha256': sha(Path(__file__)), + 'providerAndOriginalBuildBefore': before, 'scope': 'Local Ubuntu candidate integration only; no public release or complete Chromium notice claim'} + + def run(label, command, timeout=1800, environment=env): + start = time.monotonic(); log = output / (label + '.log') + print('Starting', label, flush=True) + with log.open('w') as stream: + result = subprocess.run(command, cwd=ROOT, env=environment, stdout=stream, stderr=subprocess.STDOUT, timeout=timeout) + report['commands'].append({'name': label, 'command': list(map(str, command)), 'exitCode': result.returncode, + 'seconds': time.monotonic() - start, 'logSha256': sha(log)}) + print('Finished', label, result.returncode, flush=True) + assert result.returncode == 0, label + ' failed; inspect ' + str(log) + + try: + sys.path.insert(0, str(ROOT / 'tools')) + from verify_pdfium_candidate import verify_prefix, verify_installed + report['candidatePrefix'], _ = verify_prefix(PREFIX, PREFIX / 'lib/libpdfium.so', PREFIX / 'include') + if args.phase == 'build': + run('configure', ['cmake', '-S', str(ROOT), '-B', str(BUILD), '-G', 'Ninja', '-DCMAKE_BUILD_TYPE=Release', + '-DCMAKE_PREFIX_PATH=/opt/docview-qt/6.11.2/gcc_64;/opt/docview-deps', + '-DDOCVIEW_PDFIUM_ROOT=' + str(PREFIX)]) + run('build', ['cmake', '--build', str(BUILD), '--parallel', '4']) + run('candidate-integration-tests', [sys.executable, str(ROOT / 'tests/test_pdfium_candidate_integration.py'), '-v']) + run('ctest', ['dbus-run-session', '--', 'xvfb-run', '-a', '-s', '-screen 0 1920x1080x24', + 'ctest', '--test-dir', str(BUILD), '--output-on-failure', '--output-junit', str(output / 'tests.xml')]) + shutil.copyfile(BUILD / 'Testing/Temporary/LastTest.log', output / 'LastTest.log') + run('install', ['cmake', '--install', str(BUILD), '--prefix', str(INSTALL)]) + report['installedCandidate'] = verify_installed(INSTALL) + report['binaries'] = {name: sha(BUILD / name) for name in ('docview', 'docview-pdf-worker', 'docview-archive-worker')} + elif args.phase == 'package': + package_command = [sys.executable, str(ROOT / 'tools/package_ubuntu.py'), '--prefix', str(INSTALL), + '--qtpaths', '/opt/docview-qt/6.11.2/gcc_64/bin/qtpaths', '--dependency-prefix', '/opt/docview-deps', + '--source-root', str(HOME / 'dependency-sources/qpdf-12.4.1'), + '--source-root', str(HOME / 'dependency-sources/libzip-1.11.4'), + '--input-manifest', str(HOME / 'incoming/sdk-downloads.json'), + '--sdk-notices', str(ROOT / 'tests/results/source-archives/qt-sdk-notices-final'), + '--sdk-supplement', str(ROOT / 'tests/results/qt-notice-supplement/collection'), + '--qt-licenses', str(ROOT / 'tests/results/ubuntu-package/inputs/qt-licenses'), '--version', VERSION] + for name in ('package', 'repeat'): + destination = output / name + run(name, [*package_command, '--output', str(destination)]) + run(name + '-verify', [sys.executable, str(ROOT / 'tools/verify_ubuntu_package.py'), '--archive', + str(destination / ('docview_' + VERSION + '_amd64.deb')), '--output', str(destination / 'verification.json')]) + archive = 'docview_' + VERSION + '_amd64.deb' + report['archiveSha256'] = sha(output / 'package' / archive) + report['repeatSha256'] = sha(output / 'repeat' / archive) + assert report['archiveSha256'] == report['repeatSha256'] + report['archive'] = str(output / 'package' / archive) + else: + package = json.loads((RESULTS / 'package/report.json').read_text()) + assert package['success'] and sha(Path(package['archive'])) == package['archiveSha256'] + run('apt-install', ['sudo', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', + '--no-install-recommends', 'install', package['archive']]) + report['installedCandidate'] = verify_installed(Path('/opt/docview')) + run('installed-smoke', [sys.executable, str(ROOT / 'tests/ubuntu_vm/package_smoke.py'), + '--output', str(output / 'installed-smoke'), '--hide-prefix', str(BUILD), '--hide-prefix', str(INSTALL), + '--hide-prefix', str(PREFIX), '--hide-prefix', str(HOME / 'validation/pdfium-intent-context')], + environment={key: value for key, value in os.environ.items() if key not in ( + 'LD_LIBRARY_PATH', 'QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH', 'QML_IMPORT_PATH', + 'QML2_IMPORT_PATH', 'QTWEBENGINEPROCESS_PATH', 'QTWEBENGINE_DISABLE_SANDBOX')}) + run('lifecycle', [sys.executable, str(ROOT / 'tests/ubuntu_vm/package_lifecycle.py'), '--smoke', + str(output / 'installed-smoke'), '--output', str(output / 'lifecycle')]) + report['packagePurged'] = not Path('/opt/docview').exists() and not Path('/etc/apparmor.d/docview').exists() + assert report['packagePurged'] + after = {str(path): sha(path) for path in preserved} + report['providerAndOriginalBuildAfter'] = after + assert before == after + report['originalsUnchanged'] = report['success'] = True + finally: + report['evidenceSha256'] = {str(path.relative_to(output)): sha(path) + for path in sorted(output.rglob('*')) if path.is_file() and path.suffix != '.deb'} + (output / 'report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps({'phase': args.phase, 'success': report['success']})) + + +if __name__ == '__main__': + main() diff --git a/tests/ubuntu_vm/create_guest.py b/tests/ubuntu_vm/create_guest.py new file mode 100644 index 0000000..467a77b --- /dev/null +++ b/tests/ubuntu_vm/create_guest.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Disposable local Ubuntu validation VM helper; no host package installation.""" +import os +from pathlib import Path +import os,sys,subprocess,shutil,io,json +r=Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +sys.path.insert(0,str(r/'tools/python')) +import pycdlib +key=r/'private/id_ed25519' +if not key.exists():subprocess.run(['ssh-keygen','-q','-t','ed25519','-N','','-C','docview-ubuntu-vm-local','-f',str(key)],check=True) +os.chmod(key,0o600) +public=key.with_suffix('.pub').read_text().strip() +userdata='#cloud-config\nhostname: docview-ubuntu2404\nmanage_etc_hosts: true\nssh_pwauth: false\ndisable_root: true\nssh_quiet_keygen: true\nno_ssh_fingerprints: true\nssh_publish_hostkeys:\n enabled: false\nusers:\n - name: docview\n groups: [adm, sudo]\n shell: /bin/bash\n lock_passwd: true\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n - '+public+'\n' +userdata += '\nssh:\n emit_keys_to_console: false\nruncmd:\n - [sh, -c, \"systemctl restart ssh.service; echo DOCVIEW_SSH_DIAG; ip -4 addr show; ss -ltn; systemctl --no-pager status ssh.service; journalctl -u ssh.service -n 20 --no-pager\"]\n' +metadata='instance-id: docview-ubuntu2404-20260919b\nlocal-hostname: docview-ubuntu2404\n' +iso=pycdlib.PyCdlib();iso.new(interchange_level=3,joliet=3,rock_ridge='1.09',vol_ident='CIDATA') +for data,short,long in [(userdata,'USER_DAT.;1','user-data'),(metadata,'META_DAT.;1','meta-data')]: + b=data.encode();iso.add_fp(io.BytesIO(b),len(b),iso_path='/'+short,rr_name=long,joliet_path='/'+long) +iso.write(str(r/'private/seed.iso'));iso.close();os.chmod(r/'private/seed.iso',0o600) +qimg=str(r/'tools/usr/bin/qemu-img') +subprocess.run([qimg,'--version'],check=True) +base=r/'downloads/noble-server-cloudimg-amd64.img';base.chmod(0o444) +overlay=r/'guest.qcow2' +if not overlay.exists():subprocess.run([qimg,'create','-f','qcow2','-F','qcow2','-b',str(base),str(overlay),'24G'],check=True) +subprocess.run([qimg,'check',str(overlay)],check=True) +vars=r/'OVMF_VARS.4m.fd' +if not vars.exists():shutil.copyfile('/usr/share/edk2-ovmf/x64/OVMF_VARS.4m.fd',vars) +print('Created dedicated SSH key, seed ISO and 24 GiB qcow2 overlay; no key contents logged.') diff --git a/tests/ubuntu_vm/fetch_sdk.py b/tests/ubuntu_vm/fetch_sdk.py new file mode 100644 index 0000000..9de4c73 --- /dev/null +++ b/tests/ubuntu_vm/fetch_sdk.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Download the fixed SDK/source inputs into a disposable VM workspace. + +Qt's public repository provides SHA-1 sidecars; record independent SHA-256 +digests as well. No archives are executed/extracted by this script. +""" +import argparse +import concurrent.futures +import hashlib +import json +from pathlib import Path +import urllib.request +import xml.etree.ElementTree as ET + +parser = argparse.ArgumentParser() +parser.add_argument('--work', type=Path, default=Path('build-ubuntu-vm')) +args = parser.parse_args() +root = args.work.resolve() +downloads = root / 'sdk-downloads' +downloads.mkdir(parents=True, exist_ok=True) +base = 'https://download.qt.io/online/qtsdkrepository/' +repositories = [ + ('qt-base-Updates.xml', 'linux_x64/desktop/qt6_6112/qt6_6112/'), + ('qt-webengine-Updates.xml', 'linux_x64/extensions/qtwebengine/6112/x86_64/'), +] +selected = { + 'qt.qt6.6112.linux_gcc_64', + 'qt.qt6.6112.addons.qtwebchannel.linux_gcc_64', + 'qt.qt6.6112.addons.qtpositioning.linux_gcc_64', + 'extensions.qtwebengine.6112.linux_gcc_64', +} +inputs = [] +for metadata, repository in repositories: + for package in ET.fromstring((root / 'downloads' / metadata).read_bytes()).findall('PackageUpdate'): + name = package.findtext('Name') + if name not in selected: + continue + for archive in package.findtext('DownloadableArchives').split(','): + filename = package.findtext('Version') + archive.strip() + url = base + repository + name + '/' + filename + inputs.append({'name': filename, 'url': url, 'kind': 'qt', + 'checksumUrl': url + '.sha1', 'checksumAlgorithm': 'sha1'}) +inputs += [ + {'name': 'qpdf-12.4.1.tar.gz', 'kind': 'source', + 'url': 'https://github.com/qpdf/qpdf/releases/download/v12.4.1/qpdf-12.4.1.tar.gz', + 'checksumAlgorithm': 'sha256', + 'expected': 'f045aa277be2356ff53a89a8622945958291177d2483afc20ede7c8a8cd3873c', + 'checksumSource': 'https://api.github.com/repos/qpdf/qpdf/releases/tags/v12.4.1'}, + {'name': 'libzip-1.11.4.tar.xz', 'kind': 'source', + 'url': 'https://libzip.org/download/libzip-1.11.4.tar.xz', + 'checksumAlgorithm': 'sha256', + 'expected': '8a247f57d1e3e6f6d11413b12a6f28a9d388de110adc0ec608d893180ed7097b', + 'checksumSource': 'https://github.com/flathub/org.kde.ark/blob/master/org.kde.ark.json'}, +] + + +def fetch(item): + item = dict(item) + target = downloads / item['name'] + if 'checksumUrl' in item: + with urllib.request.urlopen(item['checksumUrl'], timeout=45) as response: + checksum = response.read(512).decode().strip().split()[0] + if len(checksum) != 40 or any(c not in '0123456789abcdef' for c in checksum): + raise ValueError('invalid SHA-1 sidecar') + item['expected'] = checksum + (downloads / (item['name'] + '.sha1')).write_text(checksum + '\n') + if not target.exists(): + partial = target.with_suffix(target.suffix + '.part') + count = 0 + with urllib.request.urlopen(item['url'], timeout=90) as response, partial.open('wb') as stream: + item['finalUrl'] = response.url + while block := response.read(1024 * 1024): + count += len(block) + if count > 250 * 1024 * 1024: + raise ValueError('archive exceeds per-file download bound') + stream.write(block) + partial.replace(target) + with target.open('rb') as stream: + actual = hashlib.file_digest(stream, item['checksumAlgorithm']).hexdigest() + if actual != item['expected']: + raise ValueError('checksum mismatch: ' + item['name']) + with target.open('rb') as stream: + item['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest() + item['size'] = target.stat().st_size + item['checksumMatched'] = True + print(item['name'], item['size'], 'verified', flush=True) + return item + + +with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + records = list(pool.map(fetch, inputs)) +(root / 'metadata' / 'sdk-downloads.json').write_text(json.dumps(records, indent=2) + '\n') +print('Total verified bytes:', sum(item['size'] for item in records), flush=True) diff --git a/tests/ubuntu_vm/guest_command.py b/tests/ubuntu_vm/guest_command.py new file mode 100644 index 0000000..a8e2742 --- /dev/null +++ b/tests/ubuntu_vm/guest_command.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Disposable local Ubuntu validation VM helper; no host package installation.""" +import os +from pathlib import Path +import subprocess,sys,shlex +r=Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +ssh=['ssh','-i',str(r/'private/id_ed25519'),'-p','22224','-o','IdentitiesOnly=yes','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','UserKnownHostsFile='+str(r/'private/known_hosts'),'-o','ConnectTimeout=5','docview@127.0.0.1'] +logname=sys.argv[1]; command=sys.argv[2:] +p=subprocess.run(ssh+[shlex.join(command)],capture_output=True,text=True) +(r/'logs'/logname).write_text(p.stdout+p.stderr) +print('guest command exit',p.returncode,'log',logname) +print((p.stdout+p.stderr)[-3000:]) +raise SystemExit(p.returncode) diff --git a/tests/ubuntu_vm/install_guest_sdk.py b/tests/ubuntu_vm/install_guest_sdk.py new file mode 100644 index 0000000..2c03d6d --- /dev/null +++ b/tests/ubuntu_vm/install_guest_sdk.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Run inside the disposable Ubuntu VM, as its docview user.""" +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile + +incoming = Path.home() / 'incoming' +qt = Path('/opt/docview-qt/6.11.2/gcc_64') +prefix = Path('/opt/docview-deps') +records = json.loads((incoming / 'sdk-downloads.json').read_text()) +for item in records: + with (incoming / item['name']).open('rb') as stream: + assert hashlib.file_digest(stream, 'sha256').hexdigest() == item['sha256'] +subprocess.run(['sudo', 'mkdir', '-p', str(qt), str(prefix)], check=True) +for item in records: + if item['kind'] == 'qt': + target = qt / 'lib' if 'icu-linux' in item['name'] else qt + subprocess.run(['sudo', '7z', 'x', '-y', '-bso0', '-bsp0', '-o' + str(target), + str(incoming / item['name'])], check=True) +sources = Path.home() / 'dependency-sources' +sources.mkdir(exist_ok=True) +for name in ['qpdf-12.4.1.tar.gz', 'libzip-1.11.4.tar.xz']: + with tarfile.open(incoming / name) as archive: + archive.extractall(sources, filter='data') +print('All SDK/source hashes verified again inside guest; extracted into dedicated prefixes.', flush=True) +subprocess.run([str(qt / 'bin/qtpaths'), '--query', 'QT_VERSION'], check=True) +subprocess.run([str(qt / 'bin/qtpaths'), '--query', 'QT_INSTALL_PREFIX'], check=True) + +for project, extra in [ + ('qpdf-12.4.1', ['-DBUILD_STATIC_LIBS=OFF', '-DBUILD_DOC=OFF', '-DINSTALL_EXAMPLES=OFF']), + ('libzip-1.11.4', ['-DBUILD_TOOLS=OFF', '-DBUILD_REGRESS=OFF', '-DBUILD_EXAMPLES=OFF', '-DBUILD_DOC=OFF']), +]: + source = sources / project + build = Path.home() / 'dependency-builds' / project + subprocess.run(['cmake', '-S', str(source), '-B', str(build), '-G', 'Ninja', + '-DCMAKE_BUILD_TYPE=Release', '-DCMAKE_INSTALL_PREFIX=' + str(prefix), + '-DCMAKE_INSTALL_LIBDIR=lib', *extra], check=True) + targets = ['--target', 'libqpdf', 'qpdf', 'fix-qdf', 'zlib-flate'] if project.startswith('qpdf') else [] + subprocess.run(['cmake', '--build', str(build), '--parallel', '4', *targets], check=True) + if project.startswith('qpdf'): + for component in ['lib', 'dev', 'cli']: + subprocess.run(['sudo', 'cmake', '--install', str(build), '--component', component], check=True) + else: + subprocess.run(['sudo', 'cmake', '--install', str(build)], check=True) + +profile = '''# Dedicated fixed Qt SDK in this disposable test VM only. +# Same userns opt-in pattern as Ubuntu's QtWebEngineProcess profile. +abi , +include +profile docview-qtwebengine /opt/docview-qt/6.11.2/gcc_64/libexec/QtWebEngineProcess flags=(unconfined) { + userns, +} +''' +local = Path.home() / 'docview-qtwebengine.apparmor' +local.write_text(profile) +subprocess.run(['sudo', 'install', '-m', '0644', str(local), + '/etc/apparmor.d/docview-qtwebengine'], check=True) +subprocess.run(['sudo', 'apparmor_parser', '-r', '/etc/apparmor.d/docview-qtwebengine'], check=True) +print('Guest dependency prefixes and exact QtWebEngineProcess AppArmor userns profile installed.', flush=True) diff --git a/tests/ubuntu_vm/package_lifecycle.py b/tests/ubuntu_vm/package_lifecycle.py new file mode 100644 index 0000000..a035948 --- /dev/null +++ b/tests/ubuntu_vm/package_lifecycle.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Remove/purge the tested local package in the dedicated validation VM.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--smoke', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + assert os.getuid() != 0 and Path.home() == Path('/home/docview') + smoke = json.loads((args.smoke / 'report.json').read_text()) + assert smoke['success'] and smoke['smokeConditions'] == 12 + for name, digest in smoke['executables'].items(): + assert sha(Path('/opt/docview/bin') / name) == digest + args.output.mkdir(parents=True, exist_ok=False) + record = {'success': False, 'runnerSha256': sha(Path(__file__)), + 'smokeReportSha256': sha(args.smoke / 'report.json'), 'commands': []} + + def run(name, command, allowed=(0,)): + result = subprocess.run(command, capture_output=True, text=True, timeout=180) + log = args.output / (name + '.log') + log.write_text(result.stdout + result.stderr) + record['commands'].append({'name': name, 'command': command, 'exitCode': result.returncode, + 'logSha256': sha(log)}) + assert result.returncode in allowed, name + return result.stdout + + def profiles(label): + return run(label, ['sudo', 'cat', '/sys/kernel/security/apparmor/profiles']) + + bundled = 'docview-bundled-qtwebengine ' + sentinels = [] + try: + before = profiles('profiles-installed') + assert bundled in before and 'docview-qtwebengine ' in before + record['installedPackage'] = run('package-installed', ['dpkg-query', '-W', + '-f=${Package} ${Version} ${db:Status-Status} ${Installed-Size}\n', 'docview']).strip() + assert ' installed ' in record['installedPackage'] + assert run('userns-before', ['sysctl', '-n', 'kernel.apparmor_restrict_unprivileged_userns']).strip() == '1' + for relative in ['.config/docview', '.local/state/docview', '.local/share/docview', + 'validation/package-user-document']: + directory = Path.home() / relative + directory.mkdir(parents=True, exist_ok=True) + path = directory / 'deb-preservation-probe.txt' + with path.open('x') as stream: + stream.write('DocView package removal preservation probe — 日本語\n') + sentinels.append({'path': str(path), 'sha256': sha(path)}) + run('apt-remove', ['sudo', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', 'remove', 'docview']) + after = profiles('profiles-removed') + assert bundled not in after and 'docview-qtwebengine ' in after + for name in ['/opt/docview', '/usr/bin/docview', '/usr/share/applications/docview.desktop']: + assert not Path(name).exists(), name + assert Path('/etc/apparmor.d/docview').is_file() + assert all(sha(Path(row['path'])) == row['sha256'] for row in sentinels) + record['remove'] = {'payloadRemoved': True, 'profileUnloaded': True, + 'conffilePreserved': True, 'userProbesPreserved': True} + run('apt-purge', ['sudo', 'env', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', 'purge', 'docview']) + assert not Path('/etc/apparmor.d/docview').exists() + assert all(sha(Path(row['path'])) == row['sha256'] for row in sentinels) + assert run('userns-after', ['sysctl', '-n', 'kernel.apparmor_restrict_unprivileged_userns']).strip() == '1' + assert Path('/opt/docview-qt/6.11.2/gcc_64/bin/qtpaths').is_file() + assert Path('/opt/docview-deps/lib/libqpdf.so.30').is_file() + assert Path('/home/docview/docview-install/bin/docview').is_file() + assert Path('/home/docview/docview-build/docview').is_file() + record['purge'] = {'conffileRemoved': True, 'userProbesPreserved': True} + record.update(success=True, userProbes=sentinels, originalDevelopmentPrefixesRestored=True, + kernelUserNamespaceRestrictionUnchanged=True) + finally: + # Delete only these newly-created probes after preserving their hashes. + for row in sentinels: + path = Path(row['path']) + if path.is_file() and sha(path) == row['sha256']: + path.unlink() + (args.output / 'report.json').write_text(json.dumps(record, indent=2, sort_keys=True) + '\n') + print(json.dumps({key: record[key] for key in ('success', 'remove', 'purge')})) + + +if __name__ == '__main__': + main() diff --git a/tests/ubuntu_vm/package_smoke.py b/tests/ubuntu_vm/package_smoke.py new file mode 100644 index 0000000..c5d8ff8 --- /dev/null +++ b/tests/ubuntu_vm/package_smoke.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Validate the installed local deb with original SDK/build prefixes hidden. + +Runs only in the dedicated Ubuntu guest. Root is used for a private mount +namespace; the viewer, compositor, and document workers run as docview. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +APP = Path('/opt/docview') +HIDDEN = ['/opt/docview-qt', '/opt/docview-deps', '/home/docview/docview-install', + '/home/docview/docview-build'] + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def user_run(output): + assert os.getuid() != 0 + assert not any(name in os.environ for name in ('LD_LIBRARY_PATH', 'QT_PLUGIN_PATH', + 'QT_QPA_PLATFORM_PLUGIN_PATH', 'QML_IMPORT_PATH', 'QML2_IMPORT_PATH', + 'QTWEBENGINEPROCESS_PATH', 'QTWEBENGINE_DISABLE_SANDBOX')) + hidden = {name: not Path(name).exists() or not any(Path(name).iterdir()) for name in HIDDEN} + assert all(hidden.values()) + manifest = json.loads((APP / 'share/doc/docview/package-manifest.json').read_text()) + installed = [] + for row in manifest['files']: + if row['path'].startswith('DEBIAN/'): + continue + path = Path('/') / row['path'] + value = path.stat() + assert value.st_uid == 0 and value.st_gid == 0 + assert oct(value.st_mode & 0o7777) == row['mode'] + assert value.st_size == row['size'] and sha(path) == row['sha256'] + installed.append(row['path']) + environment = {**os.environ, 'LD_LIBRARY_PATH': str(APP / 'lib') + ':' + str(APP / 'qt/lib')} + dependencies = [] + for path in sorted(APP.rglob('*')): + if not path.is_file(): continue + with path.open('rb') as stream: + if stream.read(4) != b'\x7fELF': continue + result = subprocess.run(['ldd', str(path)], env=environment, capture_output=True, text=True, timeout=30) + assert result.returncode == 0 and 'not found' not in result.stdout, str(path) + resolved = re.findall(r'(?:=>\s+)?(/[^\s]+)\s+\(', result.stdout) + no_needed = False + if not resolved: + dynamic = subprocess.check_output(['readelf', '-d', str(path)], text=True, timeout=30) + no_needed = '(NEEDED)' not in dynamic and result.stdout.strip() == 'statically linked' + assert (resolved or no_needed) and all(name.startswith(('/opt/docview/', '/lib/', '/lib64/', '/usr/lib/')) for name in resolved), str(path) + ': ' + result.stdout + dependencies.append({'path': str(path), 'sha256': sha(path), 'resolved': sorted(set(resolved)), + 'noDynamicDependencies': no_needed}) + identities = {name: sha(APP / 'bin' / name) for name in + ['docview', 'docview-pdf-worker', 'docview-archive-worker']} + launcher = sha(Path('/usr/bin/docview')) + record = {'success': False, 'hiddenOriginalPrefixes': hidden, 'uid': os.getuid(), + 'callerRuntimeVariablesAbsent': True, 'installedFilesVerified': len(installed), + 'executables': identities, 'launcherSha256': launcher, 'elfDependencies': dependencies, + 'runnerSha256': sha(Path(__file__)), 'smokeSourceSha256': sha(ROOT / 'tests/smoke.py'), + 'waylandRunnerSha256': sha(ROOT / 'tests/run_wayland_validation.py'), + 'scope': 'Dedicated existing Ubuntu VM, private mount namespace, Xvfb and headless Sway/software; not a clean OS or physical desktop'} + commands = [ + ('x11', ['dbus-run-session', '--', 'xvfb-run', '-a', '-s', '-screen 0 1100x760x24', + sys.executable, str(ROOT / 'tests/smoke.py'), '--binary', '/usr/bin/docview', + '--output', str(output / 'x11')]), + ('wayland', [sys.executable, str(ROOT / 'tests/run_wayland_validation.py'), + '--build-dir', '/opt/docview/bin', '--smoke-binary', '/usr/bin/docview', + '--output', str(output / 'wayland'), '--mode', 'smoke'])] + runs = [] + try: + for name, command in commands: + env = dict(os.environ) + if name == 'x11': + env.update(QT_QPA_PLATFORM='xcb', QT_QUICK_BACKEND='software', + QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu') + with (output / (name + '.log')).open('w') as log: + result = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=300) + runs.append({'name': name, 'exitCode': result.returncode}) + assert result.returncode == 0, name + ' smoke failed' + values = json.loads((output / name / ('smoke/results.json' if name == 'wayland' else 'results.json')).read_text()) + assert len(values) == 6 and all(row['state'] == 'Ready' and row['binarySha256'] == launcher for row in values) + assert all(sha(APP / 'bin' / name) == digest for name, digest in identities.items()) + assert sha(Path('/usr/bin/docview')) == launcher + record.update(success=True, executableBytesUnchanged=True, smokeConditions=12) + finally: + record['runs'] = runs + (output / 'report.json').write_text(json.dumps(record, indent=2, sort_keys=True) + '\n') + print(json.dumps({key: record[key] for key in ('success', 'installedFilesVerified', 'smokeConditions')})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--isolated', action='store_true') + parser.add_argument('--user-run', action='store_true') + parser.add_argument('--hide-prefix', action='append', default=[], type=Path, + help='Additional dedicated guest build/install prefix to hide in the private namespace') + args = parser.parse_args() + output = args.output.resolve() + assert len(args.hide_prefix) <= 8 + additional = [] + for prefix in args.hide_prefix: + assert prefix.is_absolute() and not prefix.is_symlink() + prefix = prefix.absolute() + assert prefix.is_relative_to('/home/docview') and prefix != Path('/home/docview') + assert '..' not in prefix.parts and not ROOT.is_relative_to(prefix) and not output.is_relative_to(prefix) + additional.extend(['--hide-prefix', str(prefix)]) + if str(prefix) not in HIDDEN: HIDDEN.append(str(prefix)) + if args.user_run: + user_run(output) + elif args.isolated: + assert os.getuid() == 0 + with tempfile.TemporaryDirectory(prefix='docview-hidden-runtime-') as temporary: + Path(temporary).chmod(0o755) + for name in HIDDEN: + if Path(name).is_dir(): + subprocess.run(['mount', '--bind', temporary, name], check=True) + subprocess.run(['runuser', '-u', 'docview', '--', 'env', '-i', + 'HOME=/home/docview', 'USER=docview', 'LOGNAME=docview', + 'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', sys.executable, + str(Path(__file__)), '--user-run', '--output', str(output), *additional], check=True) + else: + assert os.getuid() != 0 and Path.home() == Path('/home/docview') + output.mkdir(parents=True, exist_ok=False) + subprocess.run(['sudo', 'unshare', '--mount', '--propagation', 'private', + sys.executable, str(Path(__file__)), '--isolated', '--output', str(output), *additional], check=True) + + +if __name__ == '__main__': + main() diff --git a/tests/ubuntu_vm/prepare_downloads.py b/tests/ubuntu_vm/prepare_downloads.py new file mode 100644 index 0000000..b15c8b4 --- /dev/null +++ b/tests/ubuntu_vm/prepare_downloads.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Disposable local Ubuntu validation VM helper; no host package installation.""" +import os +import urllib.request, hashlib, json, pathlib, time, subprocess, concurrent.futures +ROOT=pathlib.Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +D=ROOT/'downloads' +for sub in ['downloads','metadata','logs','tools','private']: (ROOT/sub).mkdir(parents=True,exist_ok=True) +os.chmod(ROOT/'private',0o700) +entries=[ + ('SHA256SUMS','https://cloud-images.ubuntu.com/noble/20260911/SHA256SUMS',100000), + ('SHA256SUMS.gpg','https://cloud-images.ubuntu.com/noble/20260911/SHA256SUMS.gpg',100000), + ('ubuntu-cloud-key.asc','https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xD2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81',100000), + ('noble-server-cloudimg-amd64.img','https://cloud-images.ubuntu.com/noble/20260911/noble-server-cloudimg-amd64.img',700*1024*1024), + ('noble-server-cloudimg-amd64.manifest','https://cloud-images.ubuntu.com/noble/20260911/noble-server-cloudimg-amd64.manifest',100000), + ('qemu-img-11.1.1-2-x86_64.pkg.tar.zst','https://geo.mirror.pkgbuild.com/extra/os/x86_64/qemu-img-11.1.1-2-x86_64.pkg.tar.zst',5*1024*1024), + ('qemu-img-11.1.1-2-x86_64.pkg.tar.zst.sig','https://geo.mirror.pkgbuild.com/extra/os/x86_64/qemu-img-11.1.1-2-x86_64.pkg.tar.zst.sig',100000), + ('qt-base-Updates.xml','https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6112/qt6_6112/Updates.xml',1000000), + ('qt-webengine-Updates.xml','https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6112/x86_64/Updates.xml',1000000), + ('pycdlib.json','https://pypi.org/pypi/pycdlib/1.14.0/json',1000000)] +def fetch(e): + name,url,limit=e;p=D/name;start=time.time();h=hashlib.sha256();n=0 + if p.exists(): + with p.open('rb') as f: + for b in iter(lambda:f.read(1024*1024),b''):h.update(b);n+=len(b) + return dict(name=name,url=url,size=n,sha256=h.hexdigest(),reused=True) + try: + with urllib.request.urlopen(url,timeout=60) as r,p.with_suffix(p.suffix+'.part').open('wb') as f: + final=r.url + while True: + b=r.read(1024*1024) + if not b:break + n+=len(b) + if n>limit:raise RuntimeError('download size bound exceeded') + f.write(b);h.update(b) + p.with_suffix(p.suffix+'.part').rename(p) + print(name,n,'downloaded',flush=True) + return dict(name=name,url=url,finalUrl=final,size=n,sha256=h.hexdigest(),seconds=round(time.time()-start,3)) + except Exception as ex: + print(name,'FAILED',str(ex),flush=True);return dict(name=name,url=url,error=str(ex)) +with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: records=list(pool.map(fetch,entries)) +(ROOT/'metadata/downloads.json').write_text(json.dumps(records,indent=2)+'\n') +if any('error' in e for e in records):raise SystemExit(1) diff --git a/tests/ubuntu_vm/probe_guest.py b/tests/ubuntu_vm/probe_guest.py new file mode 100644 index 0000000..3138b7e --- /dev/null +++ b/tests/ubuntu_vm/probe_guest.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Disposable local Ubuntu validation VM helper; no host package installation.""" +import os +from pathlib import Path +import subprocess,json,time +r=Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +ssh=['ssh','-i',str(r/'private/id_ed25519'),'-p','22224','-o','IdentitiesOnly=yes','-o','BatchMode=yes','-o','PasswordAuthentication=no','-o','StrictHostKeyChecking=accept-new','-o','UserKnownHostsFile='+str(r/'private/known_hosts'),'-o','ConnectTimeout=3','docview@127.0.0.1'] +probe='import subprocess,json,pathlib,ctypes,platform,os\nlibc=ctypes.CDLL(None,use_errno=True);abi=libc.syscall(444,0,0,1)\nresult={\'osRelease\':pathlib.Path(\'/etc/os-release\').read_text(),\'uname\':platform.uname()._asdict(),\'uid\':os.getuid(),\'landlockABI\':abi,\'landlockErrno\':ctypes.get_errno()}\ncommands={\'lsm\':[\'sudo\',\'cat\',\'/sys/kernel/security/lsm\'],\'kernelConfig\':[\'bash\',\'-c\',\'grep -E "^CONFIG_(SECURITY_LANDLOCK|SECCOMP|SECCOMP_FILTER|USER_NS|SECURITY_APPARMOR)=" /boot/config-$(uname -r)\'],\'apparmor\':[\'sudo\',\'aa-status\',\'--json\'],\'sysctls\':[\'sysctl\',\'kernel.unprivileged_userns_clone\',\'kernel.apparmor_restrict_unprivileged_userns\'],\'unprivilegedUserNamespace\':[\'unshare\',\'--user\',\'--map-root-user\',\'true\'],\'disk\':[\'df\',\'-h\',\'/\'],\'cloudInit\':[\'cloud-init\',\'status\']}\nfor k,v in commands.items():\n p=subprocess.run(v,capture_output=True,text=True);result[k]={\'command\':v,\'exitCode\':p.returncode,\'stdout\':p.stdout,\'stderr\':p.stderr}\nprint(json.dumps(result,indent=2))\n' +for attempt in range(10): + p=subprocess.run(ssh+['python3','-'],input=probe,capture_output=True,text=True) + if p.returncode==0: + (r/'metadata/guest-probe.json').write_text(p.stdout);(r/'logs/ssh-probe.txt').write_text(p.stderr);j=json.loads(p.stdout);print('Guest ready:',j['uname']['release'],'Landlock ABI',j['landlockABI'],'unprivileged namespace exit',j['unprivilegedUserNamespace']['exitCode']);break + time.sleep(2) +else:print(p.stderr);raise SystemExit(p.returncode) diff --git a/tests/ubuntu_vm/qmp.py b/tests/ubuntu_vm/qmp.py new file mode 100644 index 0000000..b22fc83 --- /dev/null +++ b/tests/ubuntu_vm/qmp.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Read status or pause/resume/power down this dedicated local VM.""" +import argparse +import json +import os +from pathlib import Path +import socket + +parser = argparse.ArgumentParser() +parser.add_argument('command', choices=['query-status', 'stop', 'cont', 'system_powerdown']) +args = parser.parse_args() +root = Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +with socket.socket(socket.AF_UNIX) as stream: + stream.settimeout(5) + stream.connect(str(root / 'qmp.sock')) + reader = stream.makefile('rb') + json.loads(reader.readline()) + for operation in ['qmp_capabilities', args.command]: + stream.sendall(json.dumps({'execute': operation}).encode() + b'\n') + while True: + response = json.loads(reader.readline()) + if 'return' in response or 'error' in response: + break + if operation == args.command: + print(json.dumps(response)) + if 'error' in response: + raise SystemExit(1) diff --git a/tests/ubuntu_vm/qpdf_notice_package.py b/tests/ubuntu_vm/qpdf_notice_package.py new file mode 100644 index 0000000..920e668 --- /dev/null +++ b/tests/ubuntu_vm/qpdf_notice_package.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Repackage the fixed Ubuntu v2 install with pinned qpdf notices, without rebuilding.""" +from pathlib import Path +import hashlib +import json +import os +import subprocess +import sys +import tarfile +import time + +HOME = Path('/home/docview') +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = HOME / 'validation/qpdf-notice-package' +PRIOR_SOURCE = HOME / 'docview-context-source' +INSTALL = HOME / 'docview-context-install' +VERSION = '0.1.0~validation5' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def main(): + assert Path.home() == HOME and os.getuid() == 1000 + assert ROOT == HOME / 'validation/qpdf-notice-tools' + OUTPUT.mkdir(parents=True, exist_ok=False) + sys.path.insert(0, str(ROOT / 'tools')) + from verify_ubuntu_package import inspect, verify, MANIFEST + from collect_ubuntu_validation_runtime import QPDF_NOTICE_PIN + env = {**os.environ, 'PATH': '/opt/docview-deps/bin:/opt/docview-qt/6.11.2/gcc_64/bin:' + os.environ['PATH'], + 'LD_LIBRARY_PATH': '/opt/docview-deps/lib:/opt/docview-qt/6.11.2/gcc_64/lib'} + for key in ('LD_PRELOAD', 'LD_AUDIT', 'QTWEBENGINE_DISABLE_SANDBOX'): + env.pop(key, None) + preserved = [INSTALL/'bin'/name for name in ('docview','docview-pdf-worker','docview-archive-worker')] + preserved += [INSTALL/'lib/libpdfium.so', HOME/'validation/pdfium-context-prefix/BUILDINFO.json', + Path('/opt/docview-deps/lib/libqpdf.so.30')] + before = {str(p):sha(p) for p in preserved} + report = {'success': False, 'version': VERSION, 'scope': 'Notice collection and packaging only; no C++ rebuild or repeated full CTest', + 'runtimeBefore': before, 'runnerSha256': sha(Path(__file__)), 'commands': []} + def run(name, command): + start = time.monotonic() + with (OUTPUT/(name+'.log')).open('w') as log: + result = subprocess.run(command, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=900) + report['commands'].append({'name':name, 'command':command, 'exitCode':result.returncode, + 'seconds':time.monotonic()-start, 'logSha256':sha(OUTPUT/(name+'.log'))}) + assert result.returncode == 0, name + try: + for name in ('test_ubuntu_validation_runtime','test_qpdf_notice_package','test_pdfium_candidate_integration'): + run(name,[sys.executable,str(ROOT/'tests'/(name+'.py')),'-v']) + command = [sys.executable,str(ROOT/'tools/package_ubuntu.py'),'--prefix',str(INSTALL), + '--qtpaths','/opt/docview-qt/6.11.2/gcc_64/bin/qtpaths','--dependency-prefix','/opt/docview-deps', + '--source-root',str(HOME/'dependency-sources/qpdf-12.4.1'), + '--source-root',str(HOME/'dependency-sources/libzip-1.11.4'), + '--qpdf-source-archive',str(HOME/'incoming/qpdf-12.4.1.tar.gz'), + '--input-manifest',str(HOME/'incoming/sdk-downloads.json'), + '--sdk-notices',str(PRIOR_SOURCE/'tests/results/source-archives/qt-sdk-notices-final'), + '--sdk-supplement',str(PRIOR_SOURCE/'tests/results/qt-notice-supplement/collection'), + '--qt-licenses',str(PRIOR_SOURCE/'tests/results/ubuntu-package/inputs/qt-licenses'), '--version',VERSION] + archives = [] + for name in ('package','repeat'): + run(name,[*command,'--output',str(OUTPUT/name)]) + archive = OUTPUT/name/('docview_'+VERSION+'_amd64.deb'); archives.append(archive) + run(name+'-verify',[sys.executable,str(ROOT/'tools/verify_ubuntu_package.py'),'--archive',str(archive), + '--output',str(OUTPUT/name/'verification.json')]) + assert sha(archives[0]) == sha(archives[1]) + prior = HOME/'validation/pdfium-context-package/package/package/docview_0.1.0~validation4_amd64.deb' + assert sha(prior) == 'bd4f00912078d406cb466cd6910ed01c0c9fdd32b76cb2f47b74cfb9719f1bd9' + def inventory(path): + data, _, _ = inspect(path, '--fsys-tarfile') + control, _, _ = inspect(path, '--ctrl-tarfile') + return {**data, **{'DEBIAN/'+name:dict(row,path='DEBIAN/'+name) for name,row in control.items()}} + old, new = inventory(prior), inventory(archives[0]) + differences = {'added':[new[k] for k in sorted(new.keys()-old.keys())], + 'removed':[old[k] for k in sorted(old.keys()-new.keys())], + 'changed':[{'path':k,'before':old[k],'after':new[k]} for k in sorted(old.keys()&new.keys()) if old[k]!=new[k]]} + protected = lambda k: not k.startswith(('DEBIAN/','opt/docview/share/doc/docview/')) + protected_paths = {k for k in old.keys()|new.keys() if protected(k)} + unchanged = all(old.get(k)==new.get(k) for k in protected_paths) + assert unchanged, 'Runtime or application payload changed' + diff = {'success': True, 'method':'Bounded raw data/control inventory; old validation4 is not accepted by the current notice verifier', + 'oldArchiveSha256':sha(prior),'newArchiveSha256':sha(archives[0]),'oldFiles':len(old),'newFiles':len(new), + 'allRuntimeAndApplicationFilesUnchanged':unchanged,'protectedFileCount':len(protected_paths),**differences} + (OUTPUT/'payload-difference.json').write_text(json.dumps(diff,indent=2)+'\n') + result = verify(archives[0]) + notice = next(row for row in result['qpdfNoticeCorrespondence']['notices'] if row['source']=='NOTICE.md') + wanted = './opt/docview/share/doc/docview/third-party/runtime/'+notice['copiedPath'] + process = subprocess.Popen(['dpkg-deb','--fsys-tarfile',str(archives[0])],stdout=subprocess.PIPE) + found = None + with tarfile.open(fileobj=process.stdout,mode='r|') as tar: + for member in tar: + if member.name == wanted: + assert member.isfile() and member.size==QPDF_NOTICE_PIN['notices']['NOTICE.md']['size'] + found = tar.extractfile(member).read() + process.stdout.close(); assert process.wait(timeout=15)==0 + assert found is not None and hashlib.sha256(found).hexdigest()==QPDF_NOTICE_PIN['notices']['NOTICE.md']['sha256'] + assert found==(HOME/'dependency-sources/qpdf-12.4.1/NOTICE.md').read_bytes() + (OUTPUT/'NOTICE.from-deb.md').write_bytes(found) + report.update(success=True, archive=str(archives[0]),archiveSha256=sha(archives[0]), + repeatSha256=sha(archives[1]),archiveBytes=archives[0].stat().st_size, + qpdfNoticeCorrespondence=result['qpdfNoticeCorrespondence'],pdfiumCorrespondence=result['pdfiumCorrespondence'], + actualNoticeBytesMatchSelectedSource=True, actualPayloadCompared=True) + finally: + report['runtimeAfter']={str(p):sha(p) for p in preserved} + report['runtimeUnchanged']=report['runtimeBefore']==report['runtimeAfter'] + report['success']=report['success'] and report['runtimeUnchanged'] + (OUTPUT/'report.json').write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps({k:report[k] for k in ('success','archiveSha256','archiveBytes','runtimeUnchanged')})) + + +if __name__=='__main__': main() diff --git a/tests/ubuntu_vm/record_guest_validation.py b/tests/ubuntu_vm/record_guest_validation.py new file mode 100644 index 0000000..14483a0 --- /dev/null +++ b/tests/ubuntu_vm/record_guest_validation.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Record this dedicated guest build without copying VM credentials or user data.""" +import argparse +import ctypes +import hashlib +import json +from pathlib import Path +import platform +import re +import subprocess +import xml.etree.ElementTree as ET + + +def sha(path): + with path.open('rb') as handle: + return hashlib.file_digest(handle, 'sha256').hexdigest() + + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--ctest-name', default='ctest-font-final') +parser.add_argument('--output-name', default='build-record.json') +parser.add_argument('--expected-groups', type=int, default=22) +parser.add_argument('--scope-note', default='Full 22-group baseline; later focused results are recorded separately.') +args = parser.parse_args() +if (not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,63}', args.ctest_name) + or not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,63}\.json', args.output_name) + or not 1 <= args.expected_groups <= 100): + parser.error('Use bounded local evidence names and 1–100 expected groups') + +home = Path.home() +source, build = home / 'docview-source', home / 'docview-build' +validation = home / 'validation' +output = validation / args.output_name +if output.exists(): + raise SystemExit('Keep previous evidence; output already exists') +ctest = validation / args.ctest_name +cases = ET.parse(ctest / 'tests.xml').findall('.//testcase') +if len(cases) != args.expected_groups or any(x.find('failure') is not None or x.find('skipped') is not None for x in cases): + raise SystemExit('Expected all selected CTest groups to pass') +names = ['docview', 'docview-pdf-worker', 'docview-archive-worker', 'test_app', + 'test_core', 'test_pdf', 'test_pdf_canvas', 'test_web_qml', 'test_translations'] +files = [source / n for n in ('CMakeLists.txt', 'resources.qrc')] +for folder in ('src', 'qml', 'cmake', 'resources', 'tools'): + files += [p for p in (source / folder).rglob('*') + if p.is_file() and not p.is_symlink() and '__pycache__' not in p.parts] +files += [p for p in (source / 'tests').rglob('*') + if p.is_file() and not p.is_symlink() and 'results' not in p.parts + and '__pycache__' not in p.parts and p.suffix in ('.cpp', '.h', '.py', '.qml')] +libc = ctypes.CDLL(None, use_errno=True) +landlock_abi = libc.syscall(444, 0, 0, 1) +record = { + 'scope': 'Ubuntu 24.04 dedicated KVM guest; Xvfb/Sway software rendering, no physical GPU or IME acceptance', + 'os': platform.freedesktop_os_release(), 'kernel': platform.release(), + 'compiler': subprocess.check_output(['c++', '--version'], text=True).splitlines()[0], + 'cmake': subprocess.check_output(['cmake', '--version'], text=True).splitlines()[0], + 'qt': subprocess.check_output(['/opt/docview-qt/6.11.2/gcc_64/bin/qtpaths', '--query', 'QT_VERSION'], text=True).strip(), + 'landlockABI': landlock_abi, + 'apparmorRestrictUnprivilegedUserns': Path('/proc/sys/kernel/apparmor_restrict_unprivileged_userns').read_text().strip(), + 'qtWebEngineApparmorProfileSha256': sha(Path('/etc/apparmor.d/docview-qtwebengine')), + 'binaries': {n: sha(build / n) for n in names}, + 'installedBinaries': {n: sha(home / 'docview-install/bin' / n) for n in names[:3]}, + 'sourceSha256': {str(p.relative_to(source)): sha(p) for p in sorted(set(files))}, + 'ctest': {'directory': args.ctest_name, 'groups': len(cases), 'failures': 0, 'junitSha256': sha(ctest / 'tests.xml'), + 'logSha256': sha(ctest / 'LastTest.log')}, + 'focusedTestScope': args.scope_note, + 'pythonProvenanceScope': '30 passed and 5 explicit zstd skips with Python 3.12; Arch Python 3.14 executes all 35 cases.', + 'productionBinariesChangedByTestFixes': False, +} +with output.open('x') as handle: + handle.write(json.dumps(record, ensure_ascii=False, indent=2) + '\n') +print(json.dumps({'ctestGroups': len(cases), 'sourceFiles': len(record['sourceSha256']), + 'productionBinarySha256': record['binaries']['docview']})) diff --git a/tests/ubuntu_vm/run_guest.py b/tests/ubuntu_vm/run_guest.py new file mode 100644 index 0000000..bc5d835 --- /dev/null +++ b/tests/ubuntu_vm/run_guest.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Disposable local Ubuntu validation VM helper; no host package installation.""" +import os +from pathlib import Path +import subprocess,json,socket +r=Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +with socket.socket() as port_probe: + port_probe.bind(('127.0.0.1',22224)) +cmd=['qemu-system-x86_64','-name','docview-ubuntu2404-validation', + '-machine','q35,accel=kvm','-cpu','host','-smp','4','-m','8192', + '-display','none','-monitor','none','-serial','file:'+str(r/'private/serial.log'), + '-pidfile',str(r/'qemu.pid'),'-qmp','unix:'+str(r/'qmp.sock')+',server=on,wait=off', + '-drive','if=pflash,format=raw,readonly=on,file=/usr/share/edk2-ovmf/x64/OVMF_CODE.4m.fd', + '-drive','if=pflash,format=raw,file='+str(r/'OVMF_VARS.4m.fd'), + '-drive','if=virtio,format=qcow2,file='+str(r/'guest.qcow2'), + '-drive','if=virtio,format=raw,readonly=on,file='+str(r/'private/seed.iso'), + '-netdev','user,id=net0,hostfwd=tcp:127.0.0.1:22224-:22', + '-device','virtio-net-pci,netdev=net0','-device','virtio-rng-pci', + '-sandbox','on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny'] +(r/'metadata/qemu-active-command.json').write_text(json.dumps(cmd,indent=2)+'\n') +print('Running dedicated Ubuntu guest; 4 vCPU / 8 GiB / 24 GiB / SSH 127.0.0.1:22224',flush=True) +with (r/'logs/qemu-runtime.txt').open('ab') as log: + p=subprocess.run(cmd,stdout=log,stderr=subprocess.STDOUT) +print('QEMU exit',p.returncode,flush=True) +raise SystemExit(p.returncode) diff --git a/tests/ubuntu_vm/snapshot.py b/tests/ubuntu_vm/snapshot.py new file mode 100644 index 0000000..4dbe4d5 --- /dev/null +++ b/tests/ubuntu_vm/snapshot.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Create a bounded source/PDFium snapshot, excluding results, builds and VM keys.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--name', required=True) +args = parser.parse_args() +if not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,63}', args.name): + parser.error('Use a short lowercase snapshot name') +work = Path(os.environ.get('DOCVIEW_VM_WORK', ROOT / 'build-ubuntu-vm')).resolve() +archive = work / (args.name + '.tar.gz') +manifest = work / 'metadata' / (args.name + '.json') +if archive.exists() or manifest.exists(): + parser.error('Choose a new name to preserve previous source evidence') +files = [ROOT / name for name in ('CMakeLists.txt', 'resources.qrc', 'README.md')] +for name in ('src', 'qml', 'cmake', 'resources', 'tools', 'tests', 'docs', '.deps/pdfium'): + directory = ROOT / name + for base, directories, leaves in os.walk(directory, followlinks=False): + directories[:] = [d for d in directories if d not in ('results', '__pycache__', '.git') + and not (Path(base) / d).is_symlink()] + files.extend(Path(base) / leaf for leaf in leaves if leaf != 'validation-record.json') +rows, total = [], 0 +for path in sorted(set(files)): + if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(ROOT): + raise SystemExit('Unexpected non-regular source input') + size = path.stat().st_size + total += size + if len(rows) >= 10000 or size > 256 * 1024 * 1024 or total > 512 * 1024 * 1024: + raise SystemExit('Source snapshot exceeds finite limits') + with path.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + rows.append({'path': str(path.relative_to(ROOT)), 'size': size, 'sha256': digest}) +work.mkdir(parents=True, exist_ok=True) +manifest.parent.mkdir(parents=True, exist_ok=True) +with tarfile.open(archive, 'x:gz') as stream: + for row in rows: + stream.add(ROOT / row['path'], arcname=row['path'], recursive=False) +with archive.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() +manifest.write_text(json.dumps({'archiveSha256': digest, 'files': rows}, indent=2) + '\n') +print(json.dumps({'files': len(rows), 'sourceBytes': total, 'archiveSha256': digest})) diff --git a/tests/ubuntu_vm/sync_patch.py b/tests/ubuntu_vm/sync_patch.py new file mode 100644 index 0000000..2d75c1c --- /dev/null +++ b/tests/ubuntu_vm/sync_patch.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Transfer an explicit source delta to this task's dedicated Ubuntu guest.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import subprocess +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--name', required=True) +parser.add_argument('files', nargs='+') +args = parser.parse_args() +if not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,63}', args.name): + parser.error('Use a short lowercase name for a new delta') +work = Path(os.environ.get('DOCVIEW_VM_WORK', ROOT / 'build-ubuntu-vm')).resolve() +output = work / 'source-deltas' / args.name +output.mkdir(parents=True, exist_ok=False) +rows, payload = [], {} +for name in sorted(set(args.files)): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts or str(relative) != name: + raise SystemExit('Only canonical repository-relative source paths are allowed') + if relative.parts[0] not in ('src', 'qml', 'resources', 'cmake', 'tools', 'tests', 'CMakeLists.txt', 'README.md'): + raise SystemExit('Path is not in the allowed source set') + path = ROOT / relative + if path.is_symlink() or not path.resolve().is_relative_to(ROOT) or not path.is_file(): + raise SystemExit('Only regular local source files are allowed') + data = path.read_bytes() + if len(data) > 8 * 1024 * 1024 or len(payload) >= 256: + raise SystemExit('Source delta exceeds finite limits') + payload[name] = data + rows.append({'path': name, 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()}) +archive = output / 'patch.tar.gz' +with tarfile.open(archive, 'w:gz') as stream: + for name, data in payload.items(): + info = tarfile.TarInfo(name) + info.size, info.mode = len(data), 0o644 + stream.addfile(info, io.BytesIO(data)) +record = {'name': args.name, 'files': rows, + 'archiveSha256': hashlib.sha256(archive.read_bytes()).hexdigest()} +manifest = output / 'patch.json' +manifest.write_text(json.dumps(record, indent=2) + '\n') +apply = output / 'apply.py' +apply.write_text('''import hashlib,json,shutil,tarfile +from pathlib import Path +here=Path(__file__).resolve().parent +record=json.loads((here/'patch.json').read_text()) +assert hashlib.sha256((here/'patch.tar.gz').read_bytes()).hexdigest()==record['archiveSha256'] +source=Path.home()/'docview-source' +history=Path.home()/'validation/source-deltas'/record['name'] +history.mkdir(parents=True,exist_ok=False) +changes=[] +with tarfile.open(here/'patch.tar.gz') as archive: + for row in record['files']: + rel=Path(row['path']) + assert not rel.is_absolute() and '..' not in rel.parts + data=archive.extractfile(row['path']).read() + assert len(data)==row['size'] and hashlib.sha256(data).hexdigest()==row['sha256'] + target=source/rel + assert target.resolve().is_relative_to(source) and not target.is_symlink() + before=None + if target.exists(): + before=hashlib.sha256(target.read_bytes()).hexdigest() + original=history/'originals'/rel + original.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(target,original) + target.parent.mkdir(parents=True,exist_ok=True) + target.write_bytes(data) + changes.append({**row,'previousSha256':before,'changed':before!=row['sha256']}) +(history/'record.json').write_text(json.dumps({**record,'files':changes},indent=2)+'\\n') +print(json.dumps({'sourceDelta':record['name'],'files':len(changes),'changed':sum(x['changed'] for x in changes)})) +''') +base = ['-i', str(work / 'private/id_ed25519'), '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(work / 'private/known_hosts')] +remote = 'incoming/delta-' + args.name +ssh = ['ssh', *base, '-p', '22224', 'docview@127.0.0.1'] +subprocess.run([*ssh, 'mkdir -p ' + remote], check=True) +subprocess.run(['scp', *base, '-P', '22224', str(archive), str(manifest), str(apply), + 'docview@127.0.0.1:' + remote + '/'], check=True) +subprocess.run([*ssh, 'python3 ' + remote + '/apply.py'], check=True) diff --git a/tests/ubuntu_vm/verify_inputs.py b/tests/ubuntu_vm/verify_inputs.py new file mode 100644 index 0000000..f8ba740 --- /dev/null +++ b/tests/ubuntu_vm/verify_inputs.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Verify VM inputs and install small helper tools into the VM workspace only.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import urllib.request +import zipfile + +root = Path(os.environ.get('DOCVIEW_VM_WORK', 'build-ubuntu-vm')).resolve() +downloads = root / 'downloads' +keyring = root / 'private/gnupg' +keyring.mkdir(mode=0o700, parents=True, exist_ok=True) +fingerprint = 'D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81' + + +def run(command, log): + result = subprocess.run(command, capture_output=True, text=True) + (root / 'logs' / log).write_text(result.stdout + result.stderr) + result.check_returncode() + return result.stdout + result.stderr + + +run(['gpg', '--homedir', str(keyring), '--batch', '--import', + str(downloads / 'ubuntu-cloud-key.asc')], 'ubuntu-key-import.txt') +identity = run(['gpg', '--homedir', str(keyring), '--batch', '--with-colons', + '--fingerprint'], 'ubuntu-key-fingerprint.txt') +assert 'fpr:::::::::' + fingerprint + ':' in identity +verification = run(['gpg', '--homedir', str(keyring), '--batch', '--status-fd', '1', + '--verify', str(downloads / 'SHA256SUMS.gpg'), + str(downloads / 'SHA256SUMS')], 'ubuntu-sha-signature.txt') +assert 'VALIDSIG ' + fingerprint + ' ' in verification +filename = 'noble-server-cloudimg-amd64.img' +expected = next(line.split()[0] for line in (downloads / 'SHA256SUMS').read_text().splitlines() + if line.split()[1].lstrip('*') == filename) +with (downloads / filename).open('rb') as stream: + actual = hashlib.file_digest(stream, 'sha256').hexdigest() +assert actual == expected +qemu = downloads / 'qemu-img-11.1.1-2-x86_64.pkg.tar.zst' +with qemu.open('rb') as stream: + assert hashlib.file_digest(stream, 'sha256').hexdigest() == 'a095493f3fffa82cc5db3a8409950124e67e45cb6ca30456a5851362be5c396a' +# This host-only bootstrap recipe was tested on Arch. Its trusted repository +# keyring is read-only; an Ubuntu guest does not use this host package. +run(['gpgv', '--keyring', '/etc/pacman.d/gnupg/pubring.gpg', str(qemu) + '.sig', + str(qemu)], 'qemu-package-signature.txt') +subprocess.run(['bsdtar', '-xf', str(qemu), '-C', str(root / 'tools'), + 'usr/bin/qemu-img'], check=True) +package = json.loads((downloads / 'pycdlib.json').read_text()) +wheel = next(item for item in package['urls'] if item['filename'].endswith('.whl')) +target = downloads / wheel['filename'] +if not target.exists(): + with urllib.request.urlopen(wheel['url'], timeout=45) as response: + target.write_bytes(response.read(300000)) +with target.open('rb') as stream: + assert hashlib.file_digest(stream, 'sha256').hexdigest() == wheel['digests']['sha256'] +assert target.stat().st_size == wheel['size'] +with zipfile.ZipFile(target) as archive: + archive.extractall(root / 'tools/python') +report = {'ubuntuImageSignatureValid': True, 'ubuntuSigningFingerprint': fingerprint, + 'ubuntuImageSha256': actual, 'ubuntuImageSize': (downloads / filename).stat().st_size, + 'qemuPackageSha256MatchesLocalRepositoryDatabase': True, 'qemuPackageSignatureValid': True} +(root / 'metadata/verification.json').write_text(json.dumps(report, indent=2) + '\n') +print('Verified Ubuntu signed checksum/image, QEMU package and local ISO helper.') diff --git a/tests/webqml/tst_reader.qml b/tests/webqml/tst_reader.qml new file mode 100644 index 0000000..22fc61f --- /dev/null +++ b/tests/webqml/tst_reader.qml @@ -0,0 +1,514 @@ +import QtQuick +import QtTest +import QtWebEngine +import "../../qml" as App + +Item { + id: harness + width: 900 + height: 650 + App.WebPane { id: pane; anchors.fill: parent } + TestCase { + id: test + name: "WebDocumentSecurity" + when: windowShown + function evaluate(source, world) { + var complete = false + var output + pane.activeView.runJavaScript(source, world === undefined ? WebEngineScript.ApplicationWorld : world, function(result) { output = result; complete = true }) + tryVerify(function() { return complete }, 5000) + return output + } + function load(path) { + reader.open(path) + tryCompare(reader, "loaded", true, 15000) + tryVerify(function() { return pane.activeView && pane.activeView.documentReady }, 5000) + tryVerify(function() { return reader.index.headings && reader.index.headings.length > 0 }, 5000) + } + function anchorFraction(saved) { + var offset = Number(/:([0-9]+)\)$/.exec(saved.cfi)[1]) + var coordinate = /^(vertical|sideways)/.test(saved.writingMode || "") ? "left/innerWidth" : "top/innerHeight" + return evaluate("(function(){var n=document.getElementById(" + JSON.stringify(saved.fragment) + ").firstChild;var r=document.createRange();r.setStart(n," + offset + ");r.collapse(true);return r.getBoundingClientRect()." + coordinate + "})()") + } + function cleanup() { + reader.releaseLateResources() + reader.deferCommit = false + reader.cancelPending() + reader.resumeRenderers() + } + function test_encoded_filename_data() { + return [{tag:"spaces", path:"space name.html"}, {tag:"Japanese", path:"日本語.html"}, + {tag:"reserved characters", path:"hash#percent%.html"}, + {tag:"mixed", path:"日本語 j k 123 #%.html"}] + } + function test_encoded_filename(data) { + load(data.path) + compare(evaluate("document.getElementById('value').textContent"), "Correct document") + compare(evaluate("decodeURIComponent(location.pathname).slice(1)"), data.path) + var original = String(pane.activeView.url) + wait(1200) // Canonical comparison must still reject author meta-refresh. + compare(String(pane.activeView.url), original) + compare(evaluate("document.getElementById('value').textContent"), "Correct document") + pane.command("navigate", {url:original + "#target"}) + tryVerify(function() { return String(pane.activeView.url).endsWith("#target") }, 3000) + tryVerify(function() { return evaluate("scrollY") > 500 }, 3000) + } + function test_late_resource_restores_logical_character_data() { + return [{tag:"decoded image", kind:"image"}, {tag:"font swap", kind:"font"}, + {tag:"vertical image", kind:"image", vertical:true}, {tag:"vertical font", kind:"font", vertical:true}, + {tag:"native anchor image", kind:"image", nativeAnchor:true}, {tag:"native anchor font", kind:"font", nativeAnchor:true}] + } + function test_generated_heading_ignores_author_base() { + load("base.html") + compare(reader.index.headings[0].href, "base.html#one") + compare(reader.index.headings[1].href, "base.html#two") + compare(reader.index.outline[0].href, "folder/other.html#two") + pane.command("heading.index", {index:1}) + tryVerify(function() { return evaluate("scrollY") > 1000 }, 3000) + verify(String(pane.activeView.url).endsWith("/base.html")) + } + function test_xhtml_native_and_aria_heading_levels_data() { + return [{tag:"XHTML", path:"headings.xhtml", mime:"application/xhtml+xml", nativeTag:"h1"}, + {tag:"HTML", path:"headings.html", mime:"text/html", nativeTag:"H1"}] + } + function test_xhtml_native_and_aria_heading_levels(data) { + load(data.path) + compare(evaluate("document.contentType"), data.mime) + compare(evaluate("document.querySelector('h1').tagName"), data.nativeTag) + compare(reader.index.headings.length, 3) + compare(reader.index.headings.map(function(row) { return row.level }), [1, 2, 3]) + compare(reader.index.headings.map(function(row) { return row.title }), ["Native first", "Native second", "ARIA third"]) + compare(reader.index.outline.map(function(row) { return row.depth }), [0, 1, 2]) + pane.command("heading.index", {index:0}) + tryVerify(function() { return Math.abs(evaluate("document.querySelector('h1').getBoundingClientRect().top")) < 3 }, 3000) + pane.command("nav.heading_next", {}) + tryVerify(function() { return Math.abs(evaluate("document.querySelector('h2').getBoundingClientRect().top")) < 3 }, 3000) + pane.command("nav.heading_previous", {}) + tryVerify(function() { return Math.abs(evaluate("document.querySelector('h1').getBoundingClientRect().top")) < 3 }, 3000) + verify(String(pane.activeView.url).endsWith("/" + data.path)) + } + function test_clamped_heading_destinations_are_not_reselected_data() { + return [{tag:"horizontal", path:"clamped-headings.xhtml"}, + {tag:"vertical-rl", path:"clamped-headings-vertical.xhtml"}] + } + function test_nested_heading_scroll_counts_visible_movement() { + load("nested-headings.xhtml") + var result = evaluate("(()=>{var r=__docviewReader.command('nav.heading_next',{});return {result:r,windowY:scrollY,innerY:document.getElementById('scroller').scrollTop}})()") + compare(result.result.moved, true) + compare(result.result.title, "Inner last") + compare(result.windowY, 0) + verify(result.innerY > 700) + result = evaluate("__docviewReader.command('nav.heading_next',{})") + compare(result.moved, false) + compare(result.boundary, 1) + result = evaluate("__docviewReader.command('nav.heading_previous',{})") + compare(result.moved, true) + compare(result.title, "Inner first") + compare(evaluate("document.getElementById('scroller').scrollTop"), 0) + } + function test_clamped_heading_destinations_are_not_reselected(data) { + load(data.path) + compare(reader.index.headings.length, 4) + var next = evaluate("(()=>{__docviewReader.command('heading.index',{index:0});return [0,1,2,3].map(()=>{var r=__docviewReader.command('nav.heading_next',{});return {moved:r.moved,boundary:r.boundary||0,title:r.title||'',x:scrollX,y:scrollY}})})()") + compare(next[0].title, "Middle") + compare(next[1].title, "Last A") + verify(next[0].x !== next[1].x || next[0].y !== next[1].y) + compare(next[2].moved, false) + compare(next[2].boundary, 1) + compare(next[3].moved, false) + compare(next[3].boundary, 1) + compare(next[2].x, next[1].x); compare(next[2].y, next[1].y) + var previous = evaluate("[0,1,2].map(()=>{var r=__docviewReader.command('nav.heading_previous',{});return {moved:r.moved,boundary:r.boundary||0,title:r.title||''}})") + compare(previous[0].title, "Middle") + compare(previous[1].title, "First") + compare(previous[2].moved, false) + compare(previous[2].boundary, -1) + // Scrolling away permits the destination again; extending the + // layout makes the two former duplicates independently reachable. + var changed = evaluate("(()=>{__docviewReader.command('nav.document_end',{});__docviewReader.command(getComputedStyle(document.body).writingMode==='vertical-rl'?'scroll.right':'scroll.up',{});var resumed=__docviewReader.command('nav.heading_next',{});document.body.style.paddingBlockEnd='900px';var a=__docviewReader.command('nav.heading_next',{}),b=__docviewReader.command('nav.heading_next',{});return {resumed:resumed,a:a,b:b}})()") + compare(changed.resumed.moved, true) + compare(changed.resumed.title, "Last A") + compare(changed.a.moved, true); compare(changed.a.title, "Last A") + compare(changed.b.moved, true); compare(changed.b.title, "Last B") + } + function test_late_resource_restores_logical_character(data) { + load(data.vertical ? "late-vertical.html" : "late-layout.html") + if (data.nativeAnchor) evaluate("document.documentElement.style.overflowAnchor='auto'") + evaluate("document.getElementById('paragraph-40').scrollIntoView()") + var saved = evaluate("__docviewReader.location()") + var originalOffset = anchorFraction(saved) + var extent = data.vertical ? "document.body.scrollWidth" : "document.body.scrollHeight" + var originalHeight = evaluate(extent) + reader.holdLateResources = true + if (data.kind === "image") evaluate("document.getElementById('late-image').src='late-image.svg'") + else evaluate("document.body.style.fontFamily='Late, serif'") + tryVerify(function() { return reader.delayedResources > 0 }, 5000) + reader.releaseLateResources() + tryVerify(function() { + return data.kind === "image" ? evaluate("document.getElementById('late-image').naturalHeight") === 900 : evaluate("document.fonts.check('14px Late')") + }, 5000) + tryVerify(function() { return Math.abs(anchorFraction(saved) - originalOffset) < 0.04 }, 5000) + verify(evaluate(extent) > originalHeight + 700) + compare(evaluate("__docviewReader.location().cfi"), saved.cfi) + compare(evaluate("getComputedStyle(document.documentElement).overflowAnchor"), data.nativeAnchor ? "auto" : "none") + } + function test_late_image_after_continued_scroll() { + load("late-layout.html") + reader.holdLateResources = true + evaluate("document.getElementById('late-image').src='late-image.svg'") + tryVerify(function() { return reader.delayedResources > 0 }, 5000) + // The final scroll has no intervening location poll. This exercises + // the scroll-event path also used by scrollbar dragging/inertia. + var saved = evaluate("(function(){document.getElementById('paragraph-40').scrollIntoView();var s=__docviewReader.location();document.getElementById('paragraph-20').scrollIntoView();__docviewReader.location();document.getElementById('paragraph-40').scrollIntoView();return s})()") + var offset = anchorFraction(saved) + reader.releaseLateResources() + tryVerify(function() { return evaluate("document.getElementById('late-image').naturalHeight") === 900 }, 5000) + tryVerify(function() { return Math.abs(anchorFraction(saved) - offset) < 0.04 }, 3000) + compare(evaluate("__docviewReader.location().cfi"), saved.cfi) + } + function test_late_image_after_pointer_without_scroll() { + load("late-layout.html") + evaluate("document.getElementById('paragraph-40').scrollIntoView()") + var saved = evaluate("__docviewReader.location()"), offset = anchorFraction(saved) + reader.holdLateResources = true + evaluate("document.getElementById('late-image').src='late-image.svg'") + tryVerify(function() { return reader.delayedResources > 0 }, 5000) + mouseClick(pane.activeView, 400, 100) + reader.releaseLateResources() + tryVerify(function() { return evaluate("document.getElementById('late-image').naturalHeight") === 900 }, 5000) + tryVerify(function() { return Math.abs(anchorFraction(saved) - offset) < 0.04 }, 3000) + } + function test_layout_change_before_location_poll() { + load("late-layout.html") + evaluate("document.getElementById('paragraph-40').scrollIntoView()") + var saved = evaluate("__docviewReader.location()"), offset = anchorFraction(saved) + // Model a layout update observed by the heartbeat before the + // corresponding image/font completion event is dispatched. + evaluate("document.body.style.paddingTop='900px';__docviewReader.location()") + tryVerify(function() { return Math.abs(anchorFraction(saved) - offset) < 0.04 }, 3000) + compare(evaluate("__docviewReader.location().cfi"), saved.cfi) + } + function test_late_image_preserves_new_navigation() { + load("late-layout.html") + evaluate("document.getElementById('paragraph-40').scrollIntoView();__docviewReader.location()") + reader.holdLateResources = true + evaluate("document.getElementById('late-image').src='late-image.svg'") + tryVerify(function() { return reader.delayedResources > 0 }, 5000) + pane.command("nav.document_end", {}) + tryVerify(function() { return evaluate("__docviewReader.location().progression") > 0.999 }, 5000) + var current = evaluate("__docviewReader.location()") + reader.releaseLateResources() + tryCompare(pane.activeView, "heartbeatPending", 0, 3000) + tryVerify(function() { return evaluate("document.getElementById('late-image').naturalHeight") === 900 }, 5000) + tryVerify(function() { return evaluate("__docviewReader.location().progression") > 0.999 }, 5000) + compare(evaluate("__docviewReader.location().cfi"), current.cfi) + } + function test_renderer_response_timeout_active_and_reopen() { + if (!reader.canPauseRenderers) skip("Real SIGSTOP test requires Linux pidfd") + load("reflow.html") + var view = pane.activeView, failures = reader.watchdogFailures + tryVerify(function() { return reader.hasRenderer(view.documentToken, view.rendererSlot) }, 5000) + tryCompare(view, "heartbeatPending", 0, 3000) + verify(reader.pauseRenderer(view.documentToken, view.rendererSlot)) + pane.heartbeat(view) + verify(view.heartbeatPending > 0) + var starts = reader.heartbeatStarts, probe = view.heartbeatPending + for (var i = 0; i < 10; ++i) pane.heartbeat(view) + compare(reader.heartbeatStarts, starts) + compare(view.heartbeatPending, probe) + tryCompare(reader, "watchdogFailures", failures + 1, 6000) + compare(pane.activeView, null) + compare(reader.notice, "E_RENDERER_TIMEOUT") + load("reflow.html") + compare(evaluate("document.querySelector('h1').textContent"), "Logical position") + } + function test_renderer_response_timeout_pending_preserves_old_document() { + if (!reader.canPauseRenderers) skip("Real SIGSTOP test requires Linux pidfd") + load("reflow.html") + var old = pane.activeView, failures = reader.watchdogFailures + reader.deferCommit = true + reader.open("vertical.html") + tryVerify(function() { return pane.pendingView && pane.pendingView.documentReady }, 5000) + var pending = pane.pendingView + verify(pending.renderProcessPid !== old.renderProcessPid) + tryVerify(function() { return reader.hasRenderer(pending.documentToken, pending.rendererSlot) }, 5000) + tryCompare(pending, "heartbeatPending", 0, 3000) + verify(reader.pauseRenderer(pending.documentToken, pending.rendererSlot)) + pane.heartbeat(pending) + tryCompare(reader, "watchdogFailures", failures + 1, 6000) + compare(pane.pendingView, null) + compare(pane.activeView, old) + compare(evaluate("document.querySelector('h1').textContent"), "Logical position") + } + function test_renderer_response_probe_cancelled_with_pending_document() { + if (!reader.canPauseRenderers) skip("Real SIGSTOP test requires Linux pidfd") + load("reflow.html") + var old = pane.activeView, failures = reader.watchdogFailures + reader.deferCommit = true + reader.open("vertical.html") + tryVerify(function() { return pane.pendingView && pane.pendingView.documentReady }, 5000) + var pending = pane.pendingView + tryVerify(function() { return reader.hasRenderer(pending.documentToken, pending.rendererSlot) }, 5000) + tryCompare(pending, "heartbeatPending", 0, 3000) + verify(reader.pauseRenderer(pending.documentToken, pending.rendererSlot)) + pane.heartbeat(pending) + verify(pending.heartbeatPending > 0) + reader.cancelPending() + reader.resumeRenderers() + wait(2000) + compare(reader.watchdogFailures, failures) + compare(pane.pendingView, null) + compare(pane.activeView, old) + compare(evaluate("document.querySelector('h1').textContent"), "Logical position") + } + function test_spread_pairing_boundaries() { + function row(side, policy, layout) { return {href:"p.xhtml",linear:true,layout:layout||"fixed",spread:side||"",renditionSpread:policy||"both"} } + var spine = [row("left"),row("right"),row("center"),row("left"),row("right"),row("","none"),row("","both","reflowable")] + compare(pane.spreadFor(spine,0,false,true), {left:0,right:1}) + compare(pane.spreadFor(spine,1,false,true), {left:0,right:1}) + compare(pane.spreadFor(spine,2,false,true), {center:2}) + compare(pane.spreadFor(spine,4,false,true), {left:3,right:4}) + compare(pane.spreadFor(spine,5,false,true), {center:5}) + compare(pane.spreadFor(spine,6,false,true), {center:6}) + compare(pane.spreadFor([row("right"),row("left")],0,true,true), {right:0,left:1}) + compare(pane.spreadFor([row("left"),row("left"),row("right")],0,false,true), {left:0}) + compare(pane.spreadFor([row("left"),row("left"),row("right")],1,false,true), {left:1,right:2}) + compare(pane.spreadFor([row("left","landscape"),row("right","landscape")],0,false,false), {center:0}) + compare(pane.spreadFor([row("left","auto"),row("right","auto")],0,false,true), {left:0,right:1}) + compare(pane.spreadFor([row(),row(),row()],0,false,true), {right:0}) + compare(pane.spreadFor([row(),row(),row()],1,false,true), {left:1,right:2}) + } + function test_resource_issues_count_real_interceptor_denial() { + load("index.html") + reader.allowNetworkProbe = true + pane.activeView.permittedUrl = String(reader.networkProbeUrl) + pane.activeView.url = reader.networkProbeUrl + tryVerify(function() { return reader.interceptedRequests > 0 }, 5000) + compare(reader.forbiddenConnections, 0) + reader.allowNetworkProbe = false + } + function test_resource_issues_count_real_csp_events() { + load("index.html") + tryVerify(function() { return reader.resourceIssues["csp.style"] >= 1 && reader.resourceIssues["csp.font"] >= 1 && reader.resourceIssues["csp.image"] >= 1 }, 5000) + compare(reader.forbiddenConnections, 0) + var previous = JSON.stringify(reader.resourceIssues) + // Forged events cannot add warnings, including from the privileged + // test world. Real events above came from authored resource loads. + evaluate("document.dispatchEvent(new SecurityPolicyViolationEvent('securitypolicyviolation',{effectiveDirective:'img-src',disposition:'enforce'}))") + wait(500) + compare(JSON.stringify(reader.resourceIssues), previous) + pane.collectResourceIssues(pane.activeView) + wait(100) + compare(JSON.stringify(reader.resourceIssues), previous) + Object.keys(reader.resourceIssues).forEach(function(key) { + verify(/^csp\.(image|style|font|script|frame|connect|form|base|object|media|other)$/.test(key)) + verify(Number.isInteger(reader.resourceIssues[key]) && reader.resourceIssues[key] > 0) + }) + } + function test_static_boundary_and_resources() { + load("index.html") + compare(evaluate("document.getElementById('marker').textContent"), "SCRIPT BLOCKED") + compare(evaluate("typeof globalThis.__docviewReader"), "object") + var mainWorld = evaluate("typeof globalThis.__docviewReader", WebEngineScript.MainWorld) + verify(mainWorld === undefined || mainWorld === "undefined") + compare(evaluate("getComputedStyle(document.querySelector('h1')).color"), "rgb(17, 34, 51)") + verify(evaluate("document.getElementById('local-image').complete && document.getElementById('local-image').naturalWidth===80")) + compare(reader.index.headings.length, 3) + compare(reader.index.outline.length, 1) + compare(reader.index.headings[2].source, "html-heading") + // Observe the authored refresh and any deferred CSS/font requests. + wait(1400) + verify(String(pane.activeView.url).endsWith("/index.html")) + compare(reader.forbiddenConnections, 0) + compare(evaluate("document.querySelectorAll('iframe').length"), 2) + verify(evaluate("Array.from(document.querySelectorAll('iframe')).every(f=>{try{return !f.contentDocument||!f.contentDocument.body||!f.contentDocument.body.textContent}catch(e){return true}})")) + compare(evaluate("document.getElementById('submit').click(); document.getElementById('input').value"), "ORIGINAL") + pane.focusContent() + evaluate("document.getElementById('input').focus()") + keyClick(Qt.Key_X) + compare(evaluate("document.getElementById('input').value"), "ORIGINAL") + wait(100) + compare(reader.forbiddenConnections, 0) + } + function test_headings_and_cfi_restoration() { + load("index.html") + var before = evaluate("document.documentElement.outerHTML") + pane.command("nav.heading_next", {}) + wait(150) + pane.command("nav.heading_next", {}) + tryVerify(function() { return evaluate("window.scrollY") > 400 }, 3000) + var saved = evaluate("__docviewReader.location()") + verify(saved.cfi.indexOf("epubcfi(") === 0) + verify(saved.progression > 0) + pane.command("nav.document_start", {}) + tryVerify(function() { return evaluate("window.scrollY") < 10 }, 3000) + pane.command("location.restore", saved) + tryVerify(function() { return evaluate("window.scrollY") > 400 }, 3000) + // Indexing and position helpers do not insert IDs or nodes. + compare(evaluate("document.documentElement.outerHTML"), before) + } + function test_vertical_reading_axis() { + load("vertical.html") + compare(evaluate("getComputedStyle(document.body).writingMode"), "vertical-rl") + pane.command("scroll.half_down", {}) + tryVerify(function() { return evaluate("window.scrollX") < -100 }, 3000) + verify(evaluate("__docviewReader.location().progression") > 0) + pane.command("nav.document_start", {}) + tryVerify(function() { return Math.abs(evaluate("window.scrollX")) < 2 }, 3000) + } + function test_keyboard_link_navigation() { + load("index.html") + pane.focusContent() + pane.command("link.next", {}) + tryCompare(pane.activeView, "activeFocus", true, 2000) + compare(evaluate("document.activeElement.textContent"), "Second heading") + pane.command("link.activate", {}) + tryVerify(function() { return String(pane.activeView.url).endsWith("#second") }, 5000) + verify(evaluate("window.scrollY") > 400) + compare(reader.forbiddenConnections, 0) + } + function test_link_clear_removes_selection_and_restores_style() { + load("index.html") + pane.focusContent() + var originalUrl = String(pane.activeView.url) + var authored = evaluate("(()=>{var a=document.querySelector('nav a');a.style.setProperty('outline-color','red','important');a.style.setProperty('outline-width','5px','important');a.style.setProperty('outline-style','dotted','important');a.style.setProperty('outline-offset','7px','important');return a.getAttribute('style')})()") + pane.command("link.next", {}) + tryCompare(pane.activeView, "activeFocus", true, 2000) + tryVerify(function() { return evaluate("document.activeElement.localName") === "a" }, 3000) + pane.command("link.clear", {}) + tryVerify(function() { return evaluate("__docviewReader.command('link.activate',{}).empty") === true }, 3000) + compare(evaluate("document.querySelector('nav a').getAttribute('style')"), authored) + verify(evaluate("document.activeElement.localName") !== "a") + pane.command("link.activate", {}) + tryCompare(reader, "notice", "Tabでリンクを選択してください", 3000) + compare(String(pane.activeView.url), originalUrl) + pane.command("link.next", {}) + tryVerify(function() { return evaluate("document.activeElement.textContent") === "Second heading" }, 3000) + } + function test_heading_boundary_is_bound_to_navigation() { + load("headings.xhtml") + var resource = String(pane.activeView.url) + pane.command("navigate", {url:resource, boundary:"heading.last"}) + tryVerify(function() { return Math.abs(evaluate("document.getElementById('aria').getBoundingClientRect().top")) < 3 }, 3000) + pane.command("navigate", {url:resource, boundary:"heading.first"}) + tryVerify(function() { return Math.abs(evaluate("document.querySelector('h1').getBoundingClientRect().top")) < 3 }, 3000) + // A replacement navigation must not inherit a previous heading target. + pane.command("navigate", {url:resource, boundary:"heading.last"}) + pane.command("navigate", {url:resource + "#two"}) + tryVerify(function() { return Math.abs(evaluate("document.querySelector('h2').getBoundingClientRect().top")) < 3 }, 3000) + compare(pane.activeView.pendingBoundary, "") + } + function test_typography_preserves_logical_text() { + load("reflow.html") + var original = evaluate("document.documentElement.outerHTML") + compare(evaluate("getComputedStyle(document.body).fontSize"), "14px") + evaluate("document.getElementById('paragraph-35').scrollIntoView();scrollBy(0,30)") + var saved = evaluate("__docviewReader.location()") + verify(saved.textQuote.length > 20) + var count = evaluate("document.querySelectorAll('*').length") + // Change immediately: restoration must not depend on the polling timer. + reader.changeReading(30, 2, false) + tryCompare(pane.activeView, "documentReady", true) + tryVerify(function() { return evaluate("getComputedStyle(document.body).fontSize") === "30px" }, 5000) + wait(250) + var after = evaluate("__docviewReader.location()") + compare(after.cfi.split(":")[0], saved.cfi.split(":")[0]) + verify(Math.abs(anchorFraction(saved) - saved.offsetY) < 0.01, JSON.stringify({before:saved, after:evaluate("__docviewReader.location()"), anchor:anchorFraction(saved)})) + compare(evaluate("getComputedStyle(document.getElementById('paragraph-35')).fontSize"), "30px") + compare(evaluate("getComputedStyle(document.getElementById('paragraph-35')).lineHeight"), "60px") + compare(evaluate("document.querySelectorAll('*').length"), count) + reader.changeReading(18, 1.6, true) + tryVerify(function() { return evaluate("getComputedStyle(document.body).fontSize") === "14px" }, 5000) + wait(200) + compare(evaluate("__docviewReader.location().cfi.split(':')[0]"), saved.cfi.split(":")[0]) + compare(evaluate("document.documentElement.outerHTML"), original) + // Reopen the same authored DOM in a fresh document session. + load("reflow.html") + pane.command("location.restore", saved) + tryVerify(function() { return Math.abs(anchorFraction(saved) - saved.offsetY) < 0.01 }, 5000) + pane.command("scroll.half_down", {}) + tryVerify(function() { return evaluate("__docviewReader.location().cfi") !== saved.cfi }, 3000) + reader.notice = "" + pane.command("location.restore", {cfi:"epubcfi(/broken)", fragment:saved.fragment, progression:0.5}) + tryVerify(function() { return reader.notice.indexOf("E_LOCATOR_UNRESOLVED") >= 0 }, 3000) + } + function test_resize_preserves_logical_text() { + load("reflow.html") + evaluate("document.getElementById('paragraph-42').scrollIntoView();scrollBy(0,25)") + var saved = evaluate("__docviewReader.location()") + pane.updateLocation(pane.activeView) + tryVerify(function() { return pane.activeView.lastLocation && pane.activeView.lastLocation.fragment === saved.fragment }, 3000) + var oldWidth = harness.width + harness.width = 620 + wait(350) + verify(Math.abs(anchorFraction(saved) - saved.offsetY) < 0.01, JSON.stringify({before:saved, after:evaluate("__docviewReader.location()"), anchor:anchorFraction(saved)})) + harness.width = oldWidth + wait(350) + verify(Math.abs(anchorFraction(saved) - saved.offsetY) < 0.01, JSON.stringify({before:saved, after:evaluate("__docviewReader.location()"), anchor:anchorFraction(saved)})) + } + function test_fixed_viewport_ignores_font_override() { + load("fixed.html") + tryCompare(pane.activeView, "fixedLayout", true) + compare(pane.activeView.fixedViewport.width, 800) + compare(pane.activeView.fixedViewport.height, 600) + verify(Math.abs(pane.activeView.zoomFactor - Math.min(pane.width / 800, pane.height / 600)) < 0.02) + verify(Math.abs(evaluate("document.getElementById('frame').getBoundingClientRect().width") - 800) < 0.05) + reader.changeReading(40, 2.5, false) + wait(250) + compare(evaluate("getComputedStyle(document.body).fontSize"), "19px") + verify(Math.abs(evaluate("document.getElementById('frame').getBoundingClientRect().height") - 600) < 0.05) + } + function test_rtl_direction_and_logical_navigation() { + load("rtl.html") + compare(evaluate("getComputedStyle(document.body).direction"), "rtl") + pane.command("scroll.half_down", {}) + tryVerify(function() { return evaluate("scrollY") > 100 }, 3000) + compare(evaluate("scrollX"), 0) + pane.command("nav.document_start", {}) + tryVerify(function() { return evaluate("scrollY") < 2 }, 3000) + pane.command("nav.heading_next", {}) + wait(100) + pane.command("nav.heading_next", {}) + tryVerify(function() { return evaluate("scrollY") > 1000 }, 3000) + compare(evaluate("getComputedStyle(document.body).direction"), "rtl") + } + function test_search_forward_backward_and_wrap() { + load("search.html") + var result = null + function found(value) { result = {count:value.numberOfMatches, active:value.activeMatch} } + pane.activeView.findTextFinished.connect(found) + pane.command("search", {query:"needle"}) + tryVerify(function() { return result && result.count === 3 && result.active === 1 }, 5000) + result = null + pane.command("search.next", {}) + tryVerify(function() { return result && result.active === 2 }, 5000) + result = null + pane.command("search.previous", {}) + tryVerify(function() { return result && result.active === 1 }, 5000) + result = null + pane.command("search.previous", {}) + tryVerify(function() { return result && result.active === 3 }, 5000) + result = null + pane.command("search.next", {}) + tryVerify(function() { return result && result.active === 1 }, 5000) + pane.command("nav.document_start", {}) + result = null + pane.command("search", {query:"日本語"}) + tryVerify(function() { return result && result.count === 2 }, 5000) + pane.activeView.findTextFinished.disconnect(found) + } + function test_search_cancel_discards_completion() { + load("search.html") + reader.notice = "" + var cleared = false + function found(value) { if (value.numberOfMatches === 0) cleared = true } + pane.activeView.findTextFinished.connect(found) + pane.command("search", {query:"not-present-in-this-document"}) + pane.command("search.cancel", {}) + tryVerify(function() { return cleared }, 5000) + wait(100) + compare(reader.notice, "") + pane.activeView.findTextFinished.disconnect(found) + } + } +} diff --git a/tests/windows_worker_probe.cpp b/tests/windows_worker_probe.cpp new file mode 100644 index 0000000..c23634d --- /dev/null +++ b/tests/windows_worker_probe.cpp @@ -0,0 +1,205 @@ +// Native-Windows hostile worker fixture. It must pass the production bootstrap. +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include "common/ipc.h" +#include "common/worker_runtime.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace docview; +namespace { +std::wstring native(const QString &path) { return QDir::toNativeSeparators(path).toStdWString(); } +QCborMap response(const QCborMap &request, const QCborMap &result) { + auto reply = request; reply.remove(QStringLiteral("payload")); reply.insert(QStringLiteral("result"), result); return reply; +} +bool raw(WorkerRuntime &runtime, const QCborMap &reply) { + const auto bytes = QCborValue(reply).toCbor(); char header[4]; qToBigEndian(quint32(bytes.size()), header); + return runtime.transport->write(header, 4) == 4 && runtime.transport->write(bytes) == bytes.size() && runtime.flushWrites(); +} +bool fixturePaths(const QJsonObject &fixture) { + const QString root = QDir::fromNativeSeparators(fixture.value("fixtureRoot").toString()); + if (!QDir::isAbsolutePath(root) || !QFileInfo(root).fileName().startsWith("docview-win-probe-") || + QDir::cleanPath(root) != root || root.startsWith("//") || root.contains(QChar::Null)) return false; + for (const auto &key : {"sourcePath", "siblingPath", "configPath", "outputPath"}) { + const auto path = QDir::fromNativeSeparators(fixture.value(key).toString()); + if (path.contains(QChar::Null) || QDir::cleanPath(path) != path || !path.startsWith(root + '/', Qt::CaseInsensitive)) return false; + } + return true; +} +bool deniedOpen(const QString &path, DWORD access, DWORD disposition, DWORD flags, DWORD *failure) { + const auto wide = native(path); + HANDLE handle = CreateFileW(wide.c_str(), access, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, disposition, flags | FILE_FLAG_OPEN_REPARSE_POINT, nullptr); + if (handle != INVALID_HANDLE_VALUE) { CloseHandle(handle); *failure = 0; return false; } + *failure = GetLastError(); + return *failure == ERROR_ACCESS_DENIED || *failure == ERROR_PRIVILEGE_NOT_HELD; +} +QCborMap probeOs(WorkerRuntime &runtime, const QJsonObject &fixture, const QString &packageSid) { + QCborMap result; + if (!fixturePaths(fixture)) return {{"fixture_valid", false}}; + result.insert(QStringLiteral("fixture_valid"), true); + const auto checkOpen = [&](const char *label, const QString &path, DWORD access, DWORD disposition, DWORD flags = 0) { + DWORD error = 0; const bool denied = deniedOpen(path, access, disposition, flags, &error); + result.insert(QString::fromLatin1(label), denied); result.insert(QString::fromLatin1(label) + "_error", qint64(error)); + }; + DWORD written = 0; + const bool nativeWrite = WriteFile(reinterpret_cast(_get_osfhandle(runtime.source.handle())), "X", 1, &written, nullptr); + const DWORD writeError = nativeWrite ? 0 : GetLastError(); + result.insert(QStringLiteral("source_handle_write_denied"), !nativeWrite && writeError == ERROR_ACCESS_DENIED); + result.insert(QStringLiteral("source_handle_write_error"), qint64(writeError)); + checkOpen("source_reopen_write_denied", fixture.value("sourcePath").toString(), GENERIC_WRITE, OPEN_EXISTING); + if (result.value("source_reopen_write_denied_error").toInteger() == ERROR_SHARING_VIOLATION) + result.insert(QStringLiteral("source_reopen_write_denied"), true); // broker's pinned read-only sharing also enforces this boundary + checkOpen("parent_read_denied", fixture.value("fixtureRoot").toString(), FILE_LIST_DIRECTORY, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS); + checkOpen("sibling_read_denied", fixture.value("siblingPath").toString(), GENERIC_READ, OPEN_EXISTING); + checkOpen("config_read_denied", fixture.value("configPath").toString(), GENERIC_READ, OPEN_EXISTING); + checkOpen("arbitrary_output_denied", fixture.value("outputPath").toString(), GENERIC_WRITE, CREATE_NEW); + + bool extraAbsent = true; + const auto sentinels = fixture.value("sentinelHandles").toArray(); + if (sentinels.size() != 2) extraAbsent = false; + for (const auto &value : sentinels) { + const auto item = value.toObject(); bool ok = false; + const auto number = item.value("value").toString().toULongLong(&ok); + BY_HANDLE_FILE_INFORMATION info{}; + if (!ok || !number) { extraAbsent = false; continue; } + const HANDLE handle = reinterpret_cast(static_cast(number)); + // Handle numbers can be reused inside the child. Compare file identities, + // not merely numeric validity, and never read the sentinel's content. + if (GetFileType(handle) == FILE_TYPE_DISK && GetFileInformationByHandle(handle, &info) && + info.dwVolumeSerialNumber == quint64(item.value("volume").toInteger()) && + info.nFileIndexHigh == quint64(item.value("high").toInteger()) && info.nFileIndexLow == quint64(item.value("low").toInteger())) extraAbsent = false; + } + result.insert(QStringLiteral("extra_file_handles_absent"), extraAbsent); + + int networkError = 0; bool connected = false; + WSADATA wsa{}; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) networkError = WSAGetLastError(); + else { + SOCKET socket = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (socket == INVALID_SOCKET) networkError = WSAGetLastError(); + else { + u_long nonblocking = 1; ioctlsocket(socket, FIONBIO, &nonblocking); + sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + const int port = fixture.value("loopbackPort").toInt(); + if (port < 1 || port > 65535) { closesocket(socket); WSACleanup(); return {{"fixture_valid", false}}; } + address.sin_port = htons(static_cast(port)); + connected = ::connect(socket, reinterpret_cast(&address), sizeof(address)) == 0; + networkError = connected ? 0 : WSAGetLastError(); + if (networkError == WSAEWOULDBLOCK) { + fd_set writes, errors; FD_ZERO(&writes); FD_ZERO(&errors); FD_SET(socket, &writes); FD_SET(socket, &errors); + timeval timeout{2, 0}; + if (select(0, nullptr, &writes, &errors, &timeout) > 0) { + int size = sizeof(networkError); + if (getsockopt(socket, SOL_SOCKET, SO_ERROR, reinterpret_cast(&networkError), &size) == 0) connected = networkError == 0; + } + } + closesocket(socket); + } + WSACleanup(); + } + result.insert(QStringLiteral("loopback_denied"), !connected && networkError == WSAEACCES); + result.insert(QStringLiteral("loopback_error"), networkError); + + auto image = native(QCoreApplication::applicationFilePath()); + auto command = std::wstring(L"\"") + image + L"\" --child-marker"; + STARTUPINFOW startup{}; startup.cb = sizeof(startup); PROCESS_INFORMATION child{}; + const bool spawned = CreateProcessW(image.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &startup, &child); + const DWORD childError = spawned ? 0 : GetLastError(); + if (spawned) { TerminateProcess(child.hProcess, 93); WaitForSingleObject(child.hProcess, 5000); CloseHandle(child.hThread); CloseHandle(child.hProcess); } + result.insert(QStringLiteral("child_creation_denied"), !spawned && (childError == ERROR_ACCESS_DENIED || childError == ERROR_CHILD_PROCESS_BLOCKED || childError == ERROR_NOT_ENOUGH_QUOTA)); + result.insert(QStringLiteral("child_creation_error"), qint64(childError)); + + PWSTR folder = nullptr; + const auto sid = packageSid.toStdWString(); + if (SUCCEEDED(GetAppContainerFolderPath(sid.c_str(), &folder)) && folder) { + // This is the unique profile created for this protected test worker. + checkOpen("own_profile_write_denied", QString::fromWCharArray(folder) + "/docview-probe-write.tmp", GENERIC_WRITE, CREATE_NEW); + CoTaskMemFree(folder); + } else result.insert(QStringLiteral("own_profile_write_denied"), false); + HKEY registry = nullptr; + const auto registryStatus = GetAppContainerRegistryLocation(KEY_SET_VALUE | KEY_CREATE_SUB_KEY, ®istry); + bool registryDenied = registryStatus == HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED); + if (registryStatus == S_OK) { + const DWORD marker = 1; + const auto status = RegSetValueExW(registry, L"DocViewProbe", 0, REG_DWORD, reinterpret_cast(&marker), sizeof(marker)); + registryDenied = status == ERROR_ACCESS_DENIED; + if (status == ERROR_SUCCESS) RegDeleteValueW(registry, L"DocViewProbe"); + RegCloseKey(registry); + } + result.insert(QStringLiteral("own_profile_registry_write_denied"), registryDenied); + return result; +} +} + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + if (app.arguments().contains("--child-marker")) return 92; + QCommandLineParser parser; addWorkerOptions(parser); parser.process(app); + QString error; auto runtime = startWorkerRuntime(parser, {}, &error); + if (!runtime) return 5; + if (runtime->source.size() < 2 || runtime->source.size() > 65536) return 120; + const auto fixture = QJsonDocument::fromJson(runtime->source.readAll()).object(); + const auto behavior = fixture.value("mode").toString(); + if (behavior.isEmpty() || behavior.size() > 64) return 120; + IpcChannel channel(runtime->transport.get()); + QObject::connect(&channel, &IpcChannel::protocolError, &app, [] { std::_Exit(123); }); + int received = 0; bool streaming = false; + QObject::connect(&channel, &IpcChannel::messageReceived, &app, [&](const QCborMap &request) { + ++received; + // Broker readiness always verifies the production protected bootstrap, + // even when a later request deliberately crashes or corrupts protocol. + if (received == 1) { + if (request.value("operation").toString() != "ping" || !channel.send(response(request, {{"ready", true}})) || !runtime->flushWrites()) std::_Exit(124); + return; + } + if (behavior == "crash") std::_Exit(73); + if (behavior == "sandbox-unavailable") std::_Exit(5); + if (behavior == "close-without-reply") std::_Exit(0); + if (behavior == "close-reply-exit" && request.value("operation").toString() == "close") { + if (!raw(*runtime, response(request, {{"closed", true}}))) std::_Exit(125); + std::_Exit(0); + } + if (behavior == "oversize") { + char header[4]; qToBigEndian(MaxControlFrame + 1, header); + if (runtime->transport->write(header, 4) != 4 || !runtime->flushWrites()) std::_Exit(124); + return; + } + QCborMap result{{"received", received}, {"sandboxed", true}, {"echo", request.value("payload")}, {"receivedDuringStream", streaming}}; + auto reply = response(request, result); + if (behavior == "probe-os") reply = response(request, probeOs(*runtime, fixture, parser.value("sandbox-sid"))); + else if (behavior == "wrong-session") reply.insert(QStringLiteral("sessionId"), "foreign"); + else if (behavior == "wrong-generation") reply.insert(QStringLiteral("generation"), request.value("generation").toInteger() + 1); + else if (behavior == "wrong-request") reply.insert(QStringLiteral("requestId"), request.value("requestId").toInteger() + 1); + else if (behavior == "wrong-operation") reply.insert(QStringLiteral("operation"), "metadata"); + else if (behavior == "wrong-version") reply.insert(QStringLiteral("protocolVersion"), 2); + else if (behavior == "illegal-more") reply.insert(QStringLiteral("result"), QCborMap{{"more", true}}); + else if (behavior == "error") { reply.remove(QStringLiteral("result")); reply.insert(QStringLiteral("error"), QCborMap{{"code", "E_FIXTURE"}, {"message", "fixture error"}}); } + else if (behavior == "stream" && request.value("operation").toString() == "extract") { + streaming = true; + if (!raw(*runtime, response(request, {{"more", true}, {"data", QByteArray("alpha")}}))) std::_Exit(125); + QTimer::singleShot(30, &app, [&, request] { if (!raw(*runtime, response(request, {{"more", true}, {"data", QByteArray("beta")}}))) std::_Exit(125); }); + QTimer::singleShot(60, &app, [&, request] { + streaming = false; + if (!raw(*runtime, response(request, {{"more", false}, {"size", 9}}))) std::_Exit(125); + }); + return; + } + if (!raw(*runtime, reply)) std::_Exit(125); + }); + return app.exec(); +} diff --git a/tools/collect_linux_dependencies.py b/tools/collect_linux_dependencies.py new file mode 100644 index 0000000..5c96511 --- /dev/null +++ b/tools/collect_linux_dependencies.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Inventory a trusted DocView install and its Arch Linux system dependencies. + +This does not download sources, infer a license choice, or certify redistribution. +Only use ldd on executables built by this project, never on an input document. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shutil +import subprocess +import sys +from urllib.parse import quote + +ROOT = Path(__file__).resolve().parents[1] +EXECUTABLES = ('docview', 'docview-pdf-worker', 'docview-archive-worker') +MAX_FILES = 10000 +MAX_COMPONENTS = 256 +MAX_LICENSE_BYTES = 32 * 1024 * 1024 +QT_EMBEDDED_NOTICE_METADATA_SHA256 = '8d90e93aa487eddef4b81722a53b89953cbb07b45d02488e8ea0413f300fd723' + + +def sha256(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def run(args): + environment = {**os.environ, 'LC_ALL': 'C'} + for key in ('LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT'): + environment.pop(key, None) + result = subprocess.run(args, check=True, text=True, capture_output=True, timeout=30, + env=environment) + return result.stdout + + +def pacman_fields(text): + result, key = {}, None + for line in text.splitlines(): + if line.startswith('%') and line.endswith('%'): + key = line[1:-1] + result[key] = [] + elif line and key: + result[key].append(line) + return result + + +class PackageDatabase: + def __init__(self, root=Path('/var/lib/pacman/local')): + self.packages, self.owners = {}, {} + for directory in sorted(root.iterdir()): + if not directory.is_dir() or not (directory / 'desc').is_file(): + continue + desc = pacman_fields((directory / 'desc').read_text()) + name = desc['NAME'][0] + self.packages[name] = desc + for item in pacman_fields((directory / 'files').read_text()).get('FILES', []): + if not item.endswith('/'): + self.owners['/' + item] = name + + def owner(self, path): + value = self.owners.get(str(path)) or self.owners.get(str(path.resolve())) + if not value: + raise ValueError(f'No installed Arch package owns runtime file: {path}') + return value + + +def ldd_paths(output): + paths = set() + for line in output.splitlines(): + if 'not found' in line: + raise ValueError('Unresolved ELF dependency: ' + line.strip()) + value = line.strip() + if not value or value.startswith('linux-vdso'): + continue + match = re.fullmatch(r'(?:\S+ => )?(/.+?) \(0x[0-9a-fA-F]+\)', value) + if not match: + raise ValueError('Unrecognized ldd dependency: ' + value) + paths.add(Path(match[1])) + return paths + + +def elf(path): + with path.open('rb') as stream: + return stream.read(4) == b'\x7fELF' + + +def qt_runtime_files(query): + """Finite candidate modules; not a claim that every style/plugin was loaded.""" + files = {Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess'} + qml = Path(query['QT_INSTALL_QML']) + for relative in ('QtQml', 'QtQuick/Controls', 'QtQuick/Dialogs', 'QtQuick/Layouts', + 'QtQuick/Templates', 'QtQuick/Window', 'QtQuick/NativeStyle', + 'QtQuick/Effects', 'QtWebEngine'): + directory = qml / relative + if directory.is_dir(): + files.update(p for p in directory.rglob('*') if p.is_file()) + files.update(p for p in (qml / 'QtQuick').glob('*') if p.is_file()) + plugins = Path(query['QT_INSTALL_PLUGINS']) + for relative in ('platforms/libqxcb.so', 'platforms/libqwayland-generic.so', + 'platforms/libqwayland-egl.so'): + path = plugins / relative + if path.is_file(): + files.add(path) + for filename in ('libqjpeg.so', 'libqgif.so', 'libqico.so', 'libqsvg.so', 'libqwebp.so'): + path = plugins / 'imageformats' / filename + if path.is_file(): + files.add(path) + for relative in ('xcbglintegrations', 'wayland-graphics-integration-client'): + directory = plugins / relative + if directory.is_dir(): + files.update(p for p in directory.rglob('*') if p.is_file()) + resources = Path(query['QT_INSTALL_DATA']) / 'resources' + files.update(p for p in resources.glob('*') if p.is_file() and + (p.name.startswith('qtwebengine') or p.name in ('icudtl.dat', 'v8_context_snapshot.bin'))) + translations = Path(query['QT_INSTALL_TRANSLATIONS']) + for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm'): + path = translations / name + if not path.is_file(): + raise ValueError('Required Japanese Qt translation is missing: ' + name) + files.add(path) + files.update(p for p in (translations / 'qtwebengine_locales').glob('*') if p.is_file()) + for pattern in ('qtbase_*.qm', 'qtdeclarative_*.qm', 'qtwebengine_*.qm'): + files.update(p for p in translations.glob(pattern) if p.is_file()) + if not files or len(files) > MAX_FILES or not all(p.is_file() for p in files): + raise ValueError('Missing or oversized Qt runtime inventory') + return files + + +def license_candidates(name, desc, base=Path('/usr/share/licenses')): + result = set() + directory = base / name + if directory.is_dir(): + result.update(p for p in directory.rglob('*') if p.is_file()) + # Include the generic text when Arch uses a shared SPDX license directory. + for label in desc.get('LICENSE', []): + for token in re.findall(r'[A-Za-z0-9][A-Za-z0-9.+-]*', label): + path = base / 'spdx' / (token + '.txt') + if path.is_file(): + result.add(path) + return sorted(result) + + +def license_supplements(name, version, base=ROOT / 'resources/licenses/linux-supplemental'): + result = [] + upstream_version = version.rsplit('-', 1)[0].split(':')[-1] + for row in json.loads((base / 'sources.json').read_text()): + if row['package'] != name: + continue + if row['version'] != upstream_version: + raise ValueError('Supplemental license version needs review: ' + name) + for filename, expected in sorted(row['files'].items()): + if Path(filename).name != filename: + raise ValueError('Unsafe supplemental license filename') + path = base / name / filename + if sha256(path) != expected: + raise ValueError('Supplemental license digest mismatch: ' + str(path)) + result.append((path, row)) + return result + + +def qt_embedded_notices(qt_version, package_version, system_files, + base=ROOT / 'resources/licenses/qt-embedded-notices'): + """Bind the reviewed partial notice set to the exact system DataPack variant.""" + source = base / 'sources.json' + if not source.is_file() or source.stat().st_size > 65536: + raise ValueError('Qt embedded notice metadata missing or oversized') + metadata_bytes = source.read_bytes() + if hashlib.sha256(metadata_bytes).hexdigest() != QT_EMBEDDED_NOTICE_METADATA_SHA256: + raise ValueError('Qt embedded notice metadata changed; review required') + value = json.loads(metadata_bytes) + if (value['schemaVersion'] != 1 or value['qtVersion'] != qt_version or + value['packageVersion'] != package_version or value['package'] != 'qt6-webengine' or + value['completeChromiumNotices'] is not False or value['runtimeDistribution'] != 'system-not-bundled'): + raise ValueError('Qt embedded notice target version or scope needs review') + for expected in value['dataPacks']: + matches = [row for row in system_files if row['path'] == expected['path']] + if (len(matches) != 1 or any(matches[0].get(key) != expected[key] for key in ('package', 'size', 'sha256')) or + expected['packageVersion'] != package_version): + raise ValueError('Qt embedded notice DataPack inventory mismatch; review required') + for row in value['files']: + if Path(row['name']).name != row['name']: + raise ValueError('Unsafe Qt embedded notice filename') + path = base / row['name'] + if (not path.is_file() or path.stat().st_size != row['size'] or sha256(path) != row['sha256']): + raise ValueError('Qt embedded notice text missing or digest/size mismatch') + return source, value + + +def pdfium_supplemental_notices(prefix, locked): + relative = Path('share/doc/docview/pdfium/supplemental') + source = prefix / relative / 'sources.json' + if not source.is_file() or source.stat().st_size > 65536: + raise ValueError('PDFium supplemental notice metadata missing or oversized') + value = json.loads(source.read_text()) + if (value.get('schemaVersion') != 1 or value.get('pdfiumVersion') != locked['version'] or + value.get('pdfiumUpstreamCommit') != locked['upstreamCommit'] or + value.get('pdfiumLibrarySha256') != sha256(prefix / 'lib/libpdfium.so')): + raise ValueError('PDFium supplemental notice source or binary needs review') + rows = value.get('files') + if (not isinstance(rows, list) or len(rows) != 2 or + any(not isinstance(r, dict) for r in rows) or {r.get('name') for r in rows} != { + 'libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'}): + raise ValueError('Unexpected PDFium supplemental notice set') + files = [] + for row in rows: + path = prefix / relative / row['name'] + if (not path.is_file() or path.stat().st_size > 1024 * 1024 or + path.stat().st_size != row['size'] or sha256(path) != row['sha256']): + raise ValueError('PDFium supplemental notice digest or size mismatch') + files.append({**row, 'path': (relative / row['name']).as_posix()}) + return {'metadataPath': (relative / 'sources.json').as_posix(), 'metadataSha256': sha256(source), + 'scope': value['scope'], 'providerRecipeCommit': value['providerRecipeCommit'], + 'pdfiumLibrarySha256': value['pdfiumLibrarySha256'], 'files': files} + + +def inventory(prefix, output, qtpaths='/usr/lib/qt6/bin/qtpaths'): + prefix, output = prefix.resolve(strict=True), output.resolve() + if sys.platform != 'linux' or platform.machine() != 'x86_64': + raise ValueError('This inventory targets Arch Linux x86_64 only') + os_release = platform.freedesktop_os_release() + if os_release.get('ID') != 'arch': + raise ValueError('An Arch package database is required; this is not an Ubuntu package') + if output.exists() and any(output.iterdir()): + raise ValueError('Inventory output directory must be empty') + output.mkdir(parents=True, exist_ok=True) + query = dict(line.split(':', 1) for line in run([qtpaths, '--query']).splitlines() if ':' in line) + if query.get('QT_VERSION') != '6.11.2': + raise ValueError('Expected the verified Qt 6.11.2 runtime') + database = PackageDatabase() + inputs = [prefix / 'bin' / name for name in EXECUTABLES] + before = {str(p.relative_to(prefix)): sha256(p) for p in inputs} + bundled = prefix / 'lib/libpdfium.so' + expected_pdfium = ROOT / '.deps/pdfium/lib/libpdfium.so' + if not bundled.is_file() or sha256(bundled) != sha256(expected_pdfium): + raise ValueError('Installed PDFium differs from the pinned local archive') + candidates = qt_runtime_files(query) + system = {p.resolve(): {'qt-runtime-candidate'} for p in candidates} + edges = [] + for path in inputs + sorted(candidates): + if not elf(path): + continue + label = str(path.relative_to(prefix)) if path.is_relative_to(prefix) else str(path) + resolved = [] + for dependency in sorted(ldd_paths(run(['ldd', str(path)]))): + real = dependency.resolve(strict=True) + if real.is_relative_to(prefix): + if real != bundled: + raise ValueError('Unexpected bundled shared library: ' + str(real)) + resolved.append('bundle:lib/libpdfium.so') + else: + system.setdefault(real, set()).add('elf-dependency') + resolved.append(str(real)) + edges.append({'elf': label, 'resolved': sorted(resolved)}) + if len(system) > MAX_FILES: + raise ValueError('Runtime inventory exceeds file limit') + components, system_files = {}, [] + # toml++ is compiled into DocView as well as potentially dynamically linked. + names = {'tomlplusplus', 'qt6-base', 'qt6-declarative', 'qt6-webengine'} + for path, roles in sorted(system.items()): + owner = database.owner(path) + names.add(owner) + system_files.append({'path': str(path), 'package': owner, 'size': path.stat().st_size, + 'sha256': sha256(path), 'roles': sorted(roles)}) + if len(names) > MAX_COMPONENTS: + raise ValueError('Runtime inventory exceeds package limit') + total_license_bytes = 0 + for name in sorted(names): + desc = database.packages[name] + version = desc['VERSION'][0] + base = desc.get('BASE', [name])[0] + licenses = [] + embedded_notices = None + for source in license_candidates(name, desc): + if source.stat().st_size > 8 * 1024 * 1024: + raise ValueError('License file exceeds limit: ' + str(source)) + total_license_bytes += source.stat().st_size + if total_license_bytes > MAX_LICENSE_BYTES: + raise ValueError('License collection exceeds limit') + relative = Path('licenses') / name / source.relative_to('/usr/share/licenses') + target = output / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + licenses.append({'path': relative.as_posix(), 'sourcePath': str(source), + 'sha256': sha256(target), 'size': target.stat().st_size}) + for source, provenance in license_supplements(name, version): + total_license_bytes += source.stat().st_size + if total_license_bytes > MAX_LICENSE_BYTES: + raise ValueError('License collection exceeds limit') + relative = Path('licenses') / name / 'upstream' / source.name + target = output / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + licenses.append({'path': relative.as_posix(), 'sourceUrl': provenance['url'], + 'upstreamVersion': provenance['version'], + 'downloadSha256': provenance['downloadSha256'], + 'collectionScope': 'Upstream notice text only; not a complete source collection', + 'sha256': sha256(target), 'size': target.stat().st_size}) + if name == 'qt6-webengine': + metadata_path, reviewed = qt_embedded_notices(query['QT_VERSION'], version, system_files) + folder = Path('licenses') / name / 'embedded-resource-notices' + metadata_relative = folder / 'sources.json' + (output / folder).mkdir(parents=True, exist_ok=True) + shutil.copyfile(metadata_path, output / metadata_relative) + if sha256(output / metadata_relative) != QT_EMBEDDED_NOTICE_METADATA_SHA256: + raise ValueError('Qt embedded notice metadata changed while copying') + total_license_bytes += (output / metadata_relative).stat().st_size + for row in reviewed['files']: + relative = folder / row['name'] + target = output / relative + shutil.copyfile(metadata_path.parent / row['name'], target) + if target.stat().st_size != row['size'] or sha256(target) != row['sha256']: + raise ValueError('Qt embedded notice text changed while copying') + total_license_bytes += row['size'] + licenses.append({'path': relative.as_posix(), 'size': row['size'], 'sha256': row['sha256'], + 'origin': 'reviewed-installed-DataPack-resource-comment', + 'sourceResources': row['sourceResources'], + 'collectionScope': reviewed['scope']}) + if total_license_bytes > MAX_LICENSE_BYTES: + raise ValueError('License collection exceeds limit') + embedded_notices = {'status': 'collected-reviewed-resource-subset', + 'metadataPath': metadata_relative.as_posix(), + 'metadataSha256': QT_EMBEDDED_NOTICE_METADATA_SHA256, + 'noticeCount': len(reviewed['files']), + 'sourceResourceCount': sum(len(row['sourceResources']) for row in reviewed['files']), + 'runtimeDistribution': reviewed['runtimeDistribution'], + 'completeChromiumNotices': False, 'scope': reviewed['scope']} + components[name] = { + 'version': version, 'architecture': desc.get('ARCH', ['unknown'])[0], + 'distribution': 'system-not-bundled', + 'upstreamHome': desc.get('URL', [''])[0], + 'licenseLabelsFromPackage': desc.get('LICENSE', []), + 'licenseTexts': licenses, + 'noticeStatus': 'collected-package-or-upstream-texts-not-a-compliance-verdict' if licenses else 'text-not-found', + 'source': {'status': 'not-collected', 'packageBase': base, 'packageVersion': version, + 'recipeRepository': 'https://gitlab.archlinux.org/archlinux/packaging/packages/' + quote(base), + 'recipeCommit': None, + 'note': 'This package contains source pointers, not complete corresponding source. Selected exact recipes and patches are recorded separately in the repository source-correspondence evidence.'}} + if name == 'tomlplusplus': + components[name]['usage'] = ['system runtime library', 'headers compiled into DocView'] + if embedded_notices: + components[name]['embeddedResourceNotices'] = embedded_notices + locked = json.loads((ROOT / 'cmake/pdfium.lock.json').read_text()) + pdfium_supplements = pdfium_supplemental_notices(prefix, locked) + pdfium_licenses = [] + for path in sorted((prefix / 'share/doc/docview/pdfium').rglob('*')): + if path.is_file() and path.name != 'sources.json': + pdfium_licenses.append({'path': path.relative_to(prefix).as_posix(), 'sha256': sha256(path), + 'size': path.stat().st_size}) + if not pdfium_licenses: + raise ValueError('PDFium archive license texts missing from install') + components['PDFium-independent-worker'] = { + 'version': locked['version'], 'distribution': 'bundled', 'path': 'lib/libpdfium.so', + 'sha256': sha256(bundled), 'size': bundled.stat().st_size, + 'licenseTexts': pdfium_licenses, 'upstreamHome': locked['upstream'], + 'supplementalNotices': pdfium_supplements, + 'source': {'status': 'not-collected', 'upstreamCommit': locked['upstreamCommit'], + 'sourceUrl': locked['upstream'], 'binaryProvider': locked['binaryProvider'], + 'binaryArchive': locked['linuxX64Archive'], 'archiveSha256': locked['linuxX64Sha256'], + 'buildOptions': locked['buildOptions'], + 'note': 'Upstream revision and provider archive are pinned; corresponding source tree and all dependency sources are not included.'}} + versions = {} + for path in inputs + [bundled]: + values = sorted(set(re.findall(r'Name: ((?:GLIBC|GLIBCXX|CXXABI)_[A-Za-z0-9_.]+)', + run(['readelf', '--version-info', str(path)])))) + versions[path.relative_to(prefix).as_posix()] = values + if before != {str(p.relative_to(prefix)): sha256(p) for p in inputs}: + raise ValueError('Installed executables changed during inventory') + result = {'schemaVersion': 1, + 'scope': 'Local Arch Linux x86_64 development package; not a clean-OS or redistribution acceptance', + 'host': {'id': os_release['ID'], 'prettyName': os_release.get('PRETTY_NAME', ''), + 'architecture': platform.machine(), 'qtVersion': query['QT_VERSION'], + 'glibcPackage': database.packages['glibc']['VERSION'][0]}, + 'executableSha256': before, 'requiredSymbolVersions': versions, + 'components': components, 'systemFiles': system_files, 'elfResolution': edges, + 'systemRuntimeCandidateBytes': sum(row['size'] for row in system_files), + 'scopeLimits': [ + 'Qt libraries, selected QML/style/plugin candidates, WebEngine helper/resources/locales and ELF closure are system dependencies, not copied runtime binaries.', + 'Candidate styles/plugins are a bounded inventory, not a trace proving every file was used. Other platform, IME, theme, GPU and font providers may load additional files.', + 'System fonts and their licenses are not bundled; FontBroker uses locally installed fonts.', + 'Arch package license labels can list alternatives. This record does not choose a license for DocView or resolve every component condition.', + 'Qt WebEngine includes Chromium third parties. Seven reviewed notice texts from 13 system DataPack resources are included, but neither these nor the top-level package license are a complete build-specific Chromium attribution collection.', + 'Complete corresponding dependency sources are not included in this package. Selected source-correspondence evidence is maintained separately; URLs and license texts alone do not establish source fulfillment.', + 'Ubuntu 24.04, Windows, clean installation and signed distribution remain unverified.']} + (output / 'dependency-manifest.json').write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + '\n') + lines = ['DocView Linux development package: third-party notices', '', + 'Local Arch Linux x86_64 artifact. Not a complete redistribution approval.', + 'DocView public license: unspecified. This inventory does not license DocView.', + 'Only the independent PDFium binary is bundled; the other runtime libraries use the host system.', + 'License texts below are copied verbatim from installed packages, recorded versioned upstream sources, or reviewed system DataPack resource comments. Package labels may be alternatives.', + 'Seven notice texts extracted from 13 fixed QtWebEngine DataPack resources are included; the Qt runtime remains system-only and complete Chromium attribution remains uncollected.', + 'Complete corresponding dependency sources and build materials are NOT included in this package.', + 'Qt WebEngine Chromium build-specific notices remain to be completed before a release.', + 'See dependency-manifest.json for exact versions, hashes, source pointers and limitations.', '', + 'References:', 'https://doc.qt.io/qt-6/qtwebengine-licensing.html', + 'https://doc.qt.io/qt-6/linux-deployment.html', ''] + for name, item in sorted(components.items()): + lines += [f'{name} {item["version"]} [{item["distribution"]}]', + ' Upstream: ' + item['upstreamHome'], + ' Package license labels: ' + '; '.join(item.get('licenseLabelsFromPackage', ['See bundled PDFium notices'])), + ' Source status: ' + item['source']['status']] + lines += [' License text: ' + text['path'] for text in item['licenseTexts']] + if not item['licenseTexts']: + lines.append(' License text: NOT COLLECTED from the installed package') + lines.append('') + (output / 'NOTICE.txt').write_text('\n'.join(lines), encoding='utf-8') + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--qtpaths', default='/usr/lib/qt6/bin/qtpaths') + args = parser.parse_args() + result = inventory(args.prefix, args.output, args.qtpaths) + print(f'{len(result["components"])} components; {len(result["systemFiles"])} system runtime candidates; dependency sources not collected') + + +if __name__ == '__main__': + main() diff --git a/tools/collect_ubuntu_validation_runtime.py b/tools/collect_ubuntu_validation_runtime.py new file mode 100644 index 0000000..8825039 --- /dev/null +++ b/tools/collect_ubuntu_validation_runtime.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +"""Inspect a trusted Ubuntu install with an explicitly selected Qt SDK. + +This writes a bounded runtime inventory and partial notice ledger, never a tar. +Only use on DocView/SDK binaries from trusted builds: ldd executes loader code. +No Arch notice pins or claims of complete source/license fulfillment are reused. +""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import platform +import re +import stat +import subprocess +import tarfile +import tempfile + +from verify_pdfium_candidate import verify_installed as verify_pdfium_installed + +MAX_FILES = 10000 +MAX_FILE_BYTES = 512 * 1024 * 1024 +MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024 +MAX_NOTICES = 2048 +MAX_NOTICE_BYTES = 32 * 1024 * 1024 +MAX_SDK_METADATA_BYTES = 64 * 1024 * 1024 +MAX_SDK_METADATA_FILE_BYTES = 16 * 1024 * 1024 +MAX_COMMAND_BYTES = 4 * 1024 * 1024 +MAX_COMPONENTS = 256 +EXECUTABLES = ('docview', 'docview-pdf-worker', 'docview-archive-worker') +QML_MODULES = ('QtQml', 'QtQuick', 'QtQuick/Controls', 'QtQuick/Dialogs', + 'QtQuick/Layouts', 'QtQuick/Templates', 'QtQuick/Window', 'QtWebEngine') +LICENSE_NAMES = ('LICENSE', 'LICENSE.txt', 'LICENSE.md', 'LICENSE-MIT', 'COPYING', + 'COPYING.txt', 'COPYRIGHT', 'copyright', 'NOTICE', 'NOTICE.txt', 'NOTICE.md') + +# The Ubuntu validation dependency was built from this archived release. These +# reviewed identities are code-owned, never taken from a caller's manifest. +QPDF_NOTICE_PIN = { + 'version': '12.4.1', + 'archive': {'name': 'qpdf-12.4.1.tar.gz', 'size': 19713921, + 'sha256': 'f045aa277be2356ff53a89a8622945958291177d2483afc20ede7c8a8cd3873c'}, + 'archiveRoot': 'qpdf-12.4.1', + 'sourceFiles': 2900, 'sourceBytes': 65617659, + 'sourceInventorySha256': 'b0c7f66f3a3ea35afb5db36716b2f7d6adfc25d54d408af088c3e3b60f321423', + 'library': {'sha256': '215d435d9636075495df489058ef3ed5ce2a00c7f39164d62cebc3ba5b4cfe9d', + 'size': 4648400}, + 'notices': { + 'LICENSE.txt': {'size': 11358, 'sha256': 'cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30'}, + 'NOTICE.md': {'size': 2729, 'sha256': 'b207f65a9e5491195ded63b2941199b19a4d30148871f2742c88eae7bfc513a6'}, + }, +} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def beneath(path, roots): + return any(path.is_relative_to(root) for root in roots) + + +def checked_file(path, roots, maximum=MAX_FILE_BYTES): + path = Path(path).absolute() + if not beneath(path, roots): + raise ValueError('File request is outside allowed roots') + target = path.resolve(strict=True) + if not beneath(target, roots): + raise ValueError('Symlink target is outside allowed roots') + before = target.stat() + if not stat.S_ISREG(before.st_mode) or not 0 <= before.st_size <= maximum: + raise ValueError('File is special or exceeds size limit') + with target.open('rb') as source: + actual = hashlib.file_digest(source, 'sha256').hexdigest() + after = target.stat() + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != ( + after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): + raise ValueError('File changed during inventory') + return {'path': str(path), 'resolvedPath': str(target), 'size': before.st_size, + 'sha256': actual, 'symlinkResolution': str(path) != str(target)} + + +def bounded_walk(directory, roots, limit=MAX_FILES): + """Follow file symlinks with identity checks; never recurse through dir links.""" + directory = Path(directory) + if not beneath(directory.absolute(), roots) or not beneath(directory.resolve(), roots): + raise ValueError('Directory is outside allowed roots') + if directory.is_symlink(): + raise ValueError('Directory symlink is not traversed') + pending, result, visited = [directory], [], 0 + while pending: + current = pending.pop() + with os.scandir(current) as entries: + for entry in entries: + visited += 1 + if visited > limit: + raise ValueError('Directory entry limit exceeded') + path = Path(entry.path) + if entry.is_symlink(): + if path.is_dir(): + raise ValueError('Directory symlink is not traversed') + # Validate now, before any later command/file read. + target = path.resolve(strict=True) + if not beneath(target, roots) or not target.is_file(): + raise ValueError('Symlink target is outside allowed roots or special') + result.append(path) + elif entry.is_dir(follow_symlinks=False): + pending.append(path) + elif entry.is_file(follow_symlinks=False): + result.append(path) + else: + raise ValueError('Special file in selected directory') + return sorted(result) + + +def run_command(arguments, environment): + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + p = subprocess.run(arguments, env=environment, stdout=stdout, stderr=stderr, timeout=30) + if stdout.tell() > MAX_COMMAND_BYTES or stderr.tell() > MAX_COMMAND_BYTES: + raise ValueError('Command output exceeds limit') + stdout.seek(0); stderr.seek(0) + return p.returncode, stdout.read().decode('utf-8', 'strict'), stderr.read().decode('utf-8', 'replace') + + +def parse_ldd(text): + paths, missing = set(), [] + for line in text.splitlines(): + value = line.strip() + if not value or value.startswith(('linux-vdso.', 'linux-gate.')): + continue + if re.fullmatch(r'\S+ => not found', value): + missing.append(value.split()[0]); continue + match = re.fullmatch(r'(?:\S+ => )?(/.+?) \(0x[0-9a-fA-F]+\)', value) + if not match: + raise ValueError('Unrecognized ldd result') + paths.add(Path(match[1])) + return sorted(paths), sorted(missing) + + +def validate_query(text, sdk): + result = {} + for line in text.splitlines(): + if ':' not in line: + raise ValueError('Invalid qtpaths output') + key, value = line.split(':', 1) + if key in result: + raise ValueError('Duplicate qtpaths key') + result[key] = value + if result.get('QT_VERSION') != '6.11.2': + raise ValueError('Expected Qt SDK 6.11.2') + for key in ('QT_INSTALL_PREFIX', 'QT_INSTALL_LIBS', 'QT_INSTALL_LIBEXECS', 'QT_INSTALL_QML', + 'QT_INSTALL_PLUGINS', 'QT_INSTALL_DATA', 'QT_INSTALL_TRANSLATIONS'): + path = Path(result.get(key, '')) + if not path.is_absolute() or '..' in path.parts or not path.is_dir(): + raise ValueError('Missing or invalid Qt runtime directory: ' + key) + if not path.resolve().is_relative_to(sdk): + raise ValueError('Qt runtime directory escaped selected SDK') + result[key] = str(path) + if Path(result['QT_INSTALL_PREFIX']).resolve() != sdk: + raise ValueError('qtpaths prefix differs from selected SDK') + return result + + +def runtime_candidates(prefix, query): + required = {prefix / 'bin' / name for name in EXECUTABLES} | {prefix / 'lib/libpdfium.so'} + qml, plugins = Path(query['QT_INSTALL_QML']), Path(query['QT_INSTALL_PLUGINS']) + required.add(Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess') + required.add(plugins / 'platforms/libqxcb.so') + required.update(qml / module / 'qmldir' for module in QML_MODULES) + resource = Path(query['QT_INSTALL_DATA']) / 'resources' + required.update(resource / name for name in ('qtwebengine_resources.pak', + 'qtwebengine_resources_100p.pak', 'qtwebengine_resources_200p.pak', 'icudtl.dat')) + translation = Path(query['QT_INSTALL_TRANSLATIONS']) + required.update(translation / name for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')) + required.update(translation / 'qtwebengine_locales' / name for name in ('en-US.pak', 'ja.pak')) + missing = [str(path) for path in sorted(required) if not path.is_file()] + wayland = [plugins / 'platforms' / name for name in ('libqwayland-generic.so', 'libqwayland.so')] + if not any(p.is_file() for p in wayland): + missing.append(str(plugins / 'platforms') + '/{libqwayland-generic.so|libqwayland.so}') + roots = (Path(query['QT_INSTALL_PREFIX']).resolve(), prefix) + files = {p for p in required if p.is_file()} | {p for p in wayland if p.is_file()} + for relative in ('QtQml', 'QtQuick', 'QtWebEngine'): + path = qml / relative + if path.is_dir(): files.update(bounded_walk(path, roots)) + for relative in ('platforms', 'imageformats', 'xcbglintegrations', 'wayland-graphics-integration-client', + 'platforminputcontexts', 'platformthemes', 'wayland-shell-integration', + 'wayland-decoration-client', 'iconengines', 'tls', 'networkinformation'): + path = plugins / relative + if path.is_dir(): files.update(bounded_walk(path, roots)) + for directory in (resource, translation / 'qtwebengine_locales'): + if directory.is_dir(): files.update(bounded_walk(directory, roots)) + return sorted(files), missing + + +def manifest_record(path): + path = Path(path) + if not path.is_file() or path.stat().st_size > 1024 * 1024: + raise ValueError('Input manifest missing or oversized') + raw = path.read_bytes(); items = json.loads(raw) + if not isinstance(items, list) or len(items) > 256: + raise ValueError('Expected bounded SDK/source input manifest list') + result = [] + for row in items: + if not isinstance(row, dict): raise ValueError('Invalid manifest row') + name, sha, size, url = (row.get(k) for k in ('name', 'sha256', 'size', 'url')) + if (not isinstance(name, str) or Path(name).name != name or len(name) > 256 or + not isinstance(sha, str) or not re.fullmatch('[0-9a-f]{64}', sha) or type(size) is not int or size < 0 or + not isinstance(url, str) or not url.startswith('https://') or len(url) > 4096): + raise ValueError('Invalid input manifest identity') + result.append({'name': name, 'sha256': sha, 'size': size, 'url': url, + 'kind': row.get('kind') if row.get('kind') in ('qt', 'source') else 'unspecified'}) + return {'manifestSha256': digest(raw), 'inputs': result, + 'scope': 'Provided download identities; archives and extracted tree correspondence not reverified here'} + + +class Collector: + def __init__(self, prefix, qtpaths, dependency_prefix, output, sources=(), runner=run_command, + system_roots=None, system_doc=Path('/usr/share/doc'), qpdf_source_archive=None): + self.prefix = Path(prefix).resolve(strict=True) + self.qtpaths = Path(qtpaths).absolute() + self.sdk = self.qtpaths.parent.parent.resolve(strict=True) + self.deps = Path(dependency_prefix).resolve(strict=True) + if self.qtpaths.parent.name != 'bin' or self.qtpaths.name not in ('qtpaths', 'qtpaths6'): + raise ValueError('Select SDK/bin/qtpaths explicitly') + self.output, self.sources, self.runner = Path(output), [Path(p).resolve(strict=True) for p in sources], runner + if len(self.sources) > 8: + raise ValueError('At most eight selected source roots are allowed') + self.system_roots = tuple(Path(p).absolute() for p in (system_roots or ('/lib', '/lib64', '/usr/lib', '/usr/lib64'))) + self.allowed = (self.prefix, self.sdk, self.deps) + self.system_roots + for root in (self.prefix, self.sdk, self.deps, *self.sources): + if not root.is_dir() or root in (Path('/'), Path('/usr'), Path('/opt'), Path('/home'), Path('/tmp'), Path.home()): + raise ValueError('A specific installed/source root is required') + if any(self.output.resolve().is_relative_to(p) for p in (self.prefix, self.sdk, self.deps, *self.sources)): + raise ValueError('Output must be outside inspected roots') + self.system_doc = Path(system_doc) + self.environment = {**os.environ, 'LC_ALL': 'C', + 'LD_LIBRARY_PATH': str(self.deps / 'lib') + ':' + str(self.sdk / 'lib')} + for key in ('LD_PRELOAD', 'LD_AUDIT'): + self.environment.pop(key, None) + self.rows, self.notices, self.missing_notices, self.total, self.notice_total = {}, [], [], 0, 0 + self.sdk_metadata_total = 0 + self.qpdf_source_archive = Path(qpdf_source_archive).absolute() if qpdf_source_archive else None + + def qpdf_correspondence(self, input_manifest): + # Detect the dependency by its actual path, regardless of its digest; + # a modified library must not evade verification by becoming unknown. + libraries = {} + for row in self.rows.values(): + paths = (Path(row['path']), Path(row['resolvedPath'])) + if any(re.fullmatch(r'libqpdf\.so(?:\.[0-9]+)*', p.name) for p in paths): + libraries[row['resolvedPath']] = row + if not libraries: + if self.qpdf_source_archive: + raise ValueError('qpdf source archive supplied without a qpdf runtime dependency') + return {'status': 'not-applicable'} + if len(libraries) != 1 or self.qpdf_source_archive is None: + raise ValueError('Exactly one qpdf runtime and its fixed source archive are required') + pin = QPDF_NOTICE_PIN + library = next(iter(libraries.values())) + real = Path(library['resolvedPath']) + if not real.is_relative_to(self.deps / 'lib'): + raise ValueError('qpdf runtime must belong to the selected dependency prefix') + current = checked_file(real, (self.deps,)) + if any(current[k] != pin['library'][k] or library[k] != current[k] for k in ('sha256', 'size')): + raise ValueError('qpdf runtime differs from the fixed notice correspondence') + archive = self.qpdf_source_archive + if archive.is_symlink(): + raise ValueError('qpdf source archive may not be a symlink') + identity = checked_file(archive, (archive.parent,), 32 * 1024 * 1024) + if archive.name != pin['archive']['name'] or any(identity[k] != pin['archive'][k] for k in ('sha256', 'size')): + raise ValueError('qpdf source archive differs from the fixed release') + if input_manifest.get('inputs') is not None: + rows = [r for r in input_manifest['inputs'] if r['name'] == pin['archive']['name']] + if len(rows) != 1 or rows[0]['kind'] != 'source' or any(rows[0][k] != pin['archive'][k] for k in ('sha256', 'size')): + raise ValueError('qpdf declared input manifest differs from the fixed release') + raw = archive.read_bytes() + if digest(raw) != identity['sha256']: + raise ValueError('qpdf source archive changed while reading') + inventory, seen, total = [], set(), 0 + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as source: + for member in source: + name = member.name.rstrip('/') + parts = name.split('/') + if (name in seen or len(seen) >= 10000 or parts[0] != pin['archiveRoot'] or + any(p in ('', '.', '..') for p in parts) or '\\' in name): + raise ValueError('Invalid qpdf archive path or entry count') + seen.add(name) + if member.isdir(): + continue + if not member.isfile() or len(parts) < 2 or not 0 <= member.size <= 32 * 1024 * 1024: + raise ValueError('Invalid qpdf archive member') + total += member.size + if total > 128 * 1024 * 1024: + raise ValueError('qpdf expanded source exceeds limit') + data = source.extractfile(member).read() + inventory.append({'path': '/'.join(parts[1:]), 'size': len(data), 'sha256': digest(data)}) + inventory.sort(key=lambda row: row['path']) + inventory_sha = digest(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()) + if (len(inventory) != pin['sourceFiles'] or total != pin['sourceBytes'] or + inventory_sha != pin['sourceInventorySha256']): + raise ValueError('qpdf source inventory differs from the fixed release') + candidates = [p for p in self.sources if all((p / name).is_file() for name in pin['notices'])] + candidates = [p for p in candidates if all( + checked_file(p / name, (p,), 64 * 1024)['sha256'] == expected['sha256'] + for name, expected in pin['notices'].items())] + if len(candidates) != 1: + raise ValueError('Exactly one matching qpdf source root with LICENSE and NOTICE is required') + root = candidates[0] + actual = bounded_walk(root, (root,)) + if any(p.is_symlink() for p in actual) or {p.relative_to(root).as_posix() for p in actual} != {r['path'] for r in inventory}: + raise ValueError('qpdf selected source root differs from archive entries') + for entry in inventory: + row = checked_file(root / entry['path'], (root,), 32 * 1024 * 1024) + if any(row[k] != entry[k] for k in ('sha256', 'size')): + raise ValueError('qpdf selected source file differs from archive: ' + entry['path']) + return {'status': 'verified', 'version': pin['version'], 'archive': dict(pin['archive']), + 'sourceRoot': 'source-' + str(self.sources.index(root)) + ':', 'sourceFiles': len(inventory), 'sourceBytes': total, + 'sourceInventorySha256': inventory_sha, + 'library': {'path': self.label(Path(library['path'])), 'sha256': current['sha256'], 'size': current['size']}, + 'notices': [{'source': name, **expected, 'copiedPath': 'notices/' + expected['sha256'] + '.txt'} + for name, expected in pin['notices'].items()]} + + def label(self, path): + path = Path(path) + for name, root in [('install', self.prefix), ('qt-sdk', self.sdk), ('dependencies', self.deps), + *[(f'source-{i}', p) for i, p in enumerate(self.sources)]]: + if path.is_relative_to(root): return name + ':' + path.relative_to(root).as_posix() + home = str(Path.home()) + return str(path).replace(home + '/', '$HOME/', 1) if str(path).startswith(home + '/') else str(path) + + def command(self, args): + return self.runner([str(a) for a in args], self.environment) + + def file(self, path, role): + key = str(Path(path).absolute()) + if key not in self.rows: + row = checked_file(Path(key), self.allowed) + self.total += row['size'] + if len(self.rows) >= MAX_FILES or self.total > MAX_TOTAL_BYTES: + raise ValueError('Runtime inventory limit exceeded') + row['roles'] = []; self.rows[key] = row + row = self.rows[key] + if role not in row['roles']: row['roles'].append(role) + return row + + def notice(self, path, roots, origin, category='notice'): + sdk_metadata = category == 'sdk-sbom-metadata-not-license-text' + row = checked_file(path, roots, MAX_SDK_METADATA_FILE_BYTES if sdk_metadata else 8 * 1024 * 1024) + if sdk_metadata: + self.sdk_metadata_total += row['size'] + else: + self.notice_total += row['size'] + if (len(self.notices) >= MAX_NOTICES or self.notice_total > MAX_NOTICE_BYTES or + self.sdk_metadata_total > MAX_SDK_METADATA_BYTES): + raise ValueError('Notice collection limit exceeded') + relative = Path('notices' if category == 'notice' else 'sdk-metadata') / (row['sha256'] + '.txt') + target = self.output / relative + target.parent.mkdir(parents=True, exist_ok=True) + raw = Path(row['resolvedPath']).read_bytes() + if digest(raw) != row['sha256']: raise ValueError('Notice changed while copying') + target.write_bytes(raw) + row.update({'path': self.label(path), 'resolvedPath': self.label(Path(row['resolvedPath'])), + 'copiedPath': str(relative), 'origin': origin, 'category': category}) + self.notices.append(row) + + def source_notices(self, directory, origin, sdk=False): + found = set() + for name in LICENSE_NAMES: + p = directory / name + if p.exists(): found.add(p) + for name in ('LICENSES', 'licenses', 'Licenses'): + p = directory / name + if p.is_dir(): found.update(bounded_walk(p, (directory,), MAX_NOTICES)) + if not found: self.missing_notices.append({'origin': origin, 'status': 'no-text-found-in-selected-locations'}) + for path in sorted(found): self.notice(path, (directory,), origin) + sbom = directory / 'sbom' + if sdk and sbom.is_dir(): + for path in bounded_walk(sbom, (directory,), MAX_NOTICES): + self.notice(path, (directory,), origin, 'sdk-sbom-metadata-not-license-text') + + def collect(self, os_release, input_manifest=None): + if os_release.get('ID') != 'ubuntu' or os_release.get('VERSION_ID') != '24.04': + raise ValueError('This collector requires an Ubuntu 24.04 guest') + # A candidate must carry its reviewed build/source/notice correspondence; + # removing that metadata cannot fall back to an arbitrary provider binary. + pdfium_correspondence = verify_pdfium_installed(self.prefix) + input_record = manifest_record(input_manifest) if input_manifest else {'status': 'not-provided'} + self.output.mkdir(parents=True, exist_ok=False) + checked_file(self.qtpaths, (self.sdk,)) + if not os.access(self.qtpaths, os.X_OK): + raise ValueError('Selected qtpaths is not executable') + code, text, _ = self.command([self.qtpaths, '--query']) + if code: raise ValueError('qtpaths query failed') + query = validate_query(text, self.sdk) + candidates, missing = runtime_candidates(self.prefix, query) + edges, runtime_failures = [], list(missing) + for path in candidates: self.file(path, 'required-or-selected-runtime') + for path in ([self.prefix / 'bin' / name for name in EXECUTABLES] + + [self.prefix / 'lib/libpdfium.so', Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess']): + if path.is_file(): + with path.open('rb') as f: + if f.read(4) != b'\x7fELF': runtime_failures.append(self.label(path) + ': required ELF header missing') + if path.name != 'libpdfium.so' and not os.access(path, os.X_OK): + runtime_failures.append(self.label(path) + ': executable permission missing') + # Every selected ELF (including QML/plugins/helper) receives ldd; ldd already + # reports its transitive ELF dependencies, which are additionally hashed. + for path in candidates: + with path.open('rb') as f: is_elf = f.read(4) == b'\x7fELF' + if not is_elf: continue + code, text, _ = self.command(['ldd', path]) + dependencies, unresolved = parse_ldd(text) + if code: runtime_failures.append(self.label(path) + ': ldd returned ' + str(code)) + runtime_failures.extend(self.label(path) + ': missing ' + item for item in unresolved) + for dependency in dependencies: self.file(dependency, 'elf-dependency') + edges.append({'elf': self.label(path), 'dependencies': [self.label(p) for p in dependencies], + 'unresolved': unresolved, 'exitCode': code}) + rpaths = [] + for path in [self.prefix / 'bin' / name for name in EXECUTABLES]: + if not path.is_file(): continue + code, text, _ = self.command(['readelf', '-d', path]) + if code: runtime_failures.append(self.label(path) + ': readelf failed') + rpaths.append({'elf': self.label(path), 'exitCode': code, + 'dynamicPathEntries': [line.strip() for line in text.splitlines() if re.search(r'\((?:RUNPATH|RPATH)\)', line)]}) + qpdf_correspondence = self.qpdf_correspondence(input_record) + packages, unowned = {}, [] + for key, row in sorted(self.rows.items()): + real = Path(row['resolvedPath']) + if not beneath(real, self.system_roots): continue + owners = set() + paths = {key, str(real)} + # dpkg can retain the pre-usrmerge pathname while ldd resolves the + # canonical /usr/lib object. Check only these equivalent aliases. + for value in list(paths): + for short, long in (('/lib/', '/usr/lib/'), ('/lib64/', '/usr/lib64/')): + if value.startswith(long): paths.add(short + value[len(long):]) + if value.startswith(short): paths.add(long + value[len(short):]) + for candidate in sorted(paths): + if not Path(candidate).exists() or Path(candidate).resolve() != real: continue + code, text, _ = self.command(['dpkg-query', '--search', candidate]) + for line in text.splitlines() if code == 0 else []: + if ': ' not in line: continue + owner, filename = line.rsplit(': ', 1) + if filename != candidate: continue + for name in owner.split(', '): + if re.fullmatch(r'[a-z0-9][a-z0-9+.-]*(?::[a-z0-9][a-z0-9-]*)?', name): owners.add(name) + if owners: break + row['systemOwners'] = sorted(owners) + if not owners: unowned.append(self.label(real)) + for owner in owners: + if owner in packages: continue + if len(packages) >= MAX_COMPONENTS: raise ValueError('System package limit exceeded') + code, text, _ = self.command(['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\t${Architecture}\n', owner]) + fields = text.strip().split('\t') + if code or len(fields) != 3 or fields[0] != owner: raise ValueError('Invalid dpkg package identity') + packages[owner] = {'version': fields[1], 'architecture': fields[2]} + copyright_path = self.system_doc / owner.split(':')[0] / 'copyright' + if copyright_path.is_file(): self.notice(copyright_path, (self.system_doc,), 'dpkg:' + owner) + else: self.missing_notices.append({'origin': 'dpkg:' + owner, 'status': 'copyright-file-missing'}) + self.source_notices(self.sdk, 'selected-Qt-SDK', sdk=True) + for name in ('qpdf', 'libzip'): + path = self.deps / 'share/doc' / name + if path.is_dir(): self.source_notices(path, 'dependency-prefix:' + name) + else: self.missing_notices.append({'origin': 'dependency-prefix:' + name, 'status': 'notice-directory-missing'}) + for i, source in enumerate(self.sources): self.source_notices(source, 'selected-source-' + str(i)) + if qpdf_correspondence['status'] == 'verified': + for expected in qpdf_correspondence['notices']: + source = qpdf_correspondence['sourceRoot'] + expected['source'] + matching = [n for n in self.notices if n['path'] == source] + if len(matching) != 1 or any(matching[0][k] != expected[k] for k in ('sha256', 'size', 'copiedPath')): + raise ValueError('qpdf notice changed after source verification') + pdfium = self.prefix / 'share/doc/docview/pdfium' + if pdfium.is_dir(): + for path in bounded_walk(pdfium, (self.prefix,), MAX_NOTICES): + category = 'source-metadata' if path.name == 'sources.json' or 'candidate' in path.relative_to(pdfium).parts else 'notice' + self.notice(path, (self.prefix,), 'installed-PDFium', category) + else: self.missing_notices.append({'origin': 'installed-PDFium', 'status': 'notice-directory-missing'}) + rows = [] + for row in self.rows.values(): + rows.append({**row, 'path': self.label(Path(row['path'])), + 'resolvedPath': self.label(Path(row['resolvedPath'])), 'roles': sorted(row['roles'])}) + return {'schemaVersion': 1, 'scope': 'Ubuntu guest installed-runtime validation and partial notices only; no distribution package', + 'runtimeValidationSuccess': not runtime_failures, 'runtimeFailures': runtime_failures, + 'osRelease': {k: os_release[k] for k in ('ID', 'VERSION_ID', 'PRETTY_NAME') if k in os_release}, + 'architecture': platform.machine(), 'qtVersion': query['QT_VERSION'], + 'qtPaths': {k: self.label(Path(v)) for k, v in query.items() if k.startswith('QT_INSTALL_') and k in ( + 'QT_INSTALL_PREFIX','QT_INSTALL_LIBS','QT_INSTALL_LIBEXECS','QT_INSTALL_QML','QT_INSTALL_PLUGINS','QT_INSTALL_DATA','QT_INSTALL_TRANSLATIONS')}, + 'loaderEnvironment': {'LD_LIBRARY_PATHEntries': ['dependencies:lib', 'qt-sdk:lib'], 'LD_PRELOAD': 'removed', 'LD_AUDIT': 'removed'}, + 'files': sorted(rows, key=lambda r:r['path']), 'elfResolution': edges, 'installedRpaths': rpaths, + 'pdfiumCorrespondence': pdfium_correspondence, + 'qpdfNoticeCorrespondence': qpdf_correspondence, + 'systemPackages': packages, 'systemOwnershipUnresolved': sorted(set(unowned)), + 'notices': self.notices, 'noticeGaps': self.missing_notices, + 'inputManifest': input_record, + 'completeChromiumNotices': False, 'completeCorrespondingSources': False, 'legalComplianceVerdict': 'not-assessed', + 'archNoticePinReused': False, 'runtimeBinariesCopied': False, + 'limitations': ['Successful ldd is not GUI rendering or sandbox execution evidence.', + 'Selected Qt plugins/QML are candidates; this is not a trace of every runtime-loaded file.', + 'Explicit /opt SDK and dependency loader environment is required for this validation.', + 'No whole source-tree, package signature, complete license, or reproducible-build verification.', + 'Missing notices are reported separately; runtime success does not mean notice completeness.']} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', required=True, type=Path) + parser.add_argument('--qtpaths', required=True, type=Path) + parser.add_argument('--dependency-prefix', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path, help='New output directory; never overwrite earlier evidence') + parser.add_argument('--input-manifest', type=Path) + parser.add_argument('--source-root', action='append', default=[], type=Path) + parser.add_argument('--qpdf-source-archive', type=Path, help='Fixed qpdf release archive required when libqpdf is included') + args = parser.parse_args() + try: + collector = Collector(args.prefix, args.qtpaths, args.dependency_prefix, args.output, args.source_root, + qpdf_source_archive=args.qpdf_source_archive) + result = collector.collect(platform.freedesktop_os_release(), args.input_manifest) + (args.output / 'runtime.json').write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + '\n') + print(json.dumps({'runtimeValidationSuccess': result['runtimeValidationSuccess'], 'files': len(result['files']), + 'systemPackages': len(result['systemPackages']), 'notices': len(result['notices']), + 'noticeGaps': len(result['noticeGaps'])})) + return 0 if result['runtimeValidationSuccess'] else 1 + except (ValueError, OSError, tarfile.TarError, subprocess.TimeoutExpired) as error: + # Never preserve an earlier success: --output is exclusive and exceptions + # produce no runtime.json. Partial copied notices alone prove no success. + print('Validation failed: ' + str(error)) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/package_linux_development.py b/tools/package_linux_development.py new file mode 100644 index 0000000..0f5f4a8 --- /dev/null +++ b/tools/package_linux_development.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Build a deterministic, local Arch development tar.gz from a trusted install. + +No system runtime binaries or user configuration/history are copied. This is not +a self-contained Linux release. The same install, host inventory, packaging code, +Python/zlib and SOURCE_DATE_EPOCH produce the same archive bytes. +""" +import argparse +import gzip +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import tarfile +import tempfile +import zlib + +from collect_linux_dependencies import EXECUTABLES, ROOT, inventory, sha256 + +PACKAGE_NAME = 'docview-0.1.0-arch-x86_64-development' +MAX_PAYLOAD_BYTES = 512 * 1024 * 1024 +MANIFEST = 'share/doc/docview/package-manifest.json' + + +def payload_files(directory): + result, size = [], 0 + for path in sorted(directory.rglob('*')): + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): + raise ValueError('Package contains a link or special file: ' + str(path)) + if stat.S_ISDIR(info.st_mode): + continue + if info.st_nlink != 1: + raise ValueError('Package contains a multiply-linked file: ' + str(path)) + size += info.st_size + if size > MAX_PAYLOAD_BYTES or len(result) >= 10000: + raise ValueError('Package payload exceeds its finite limit') + result.append(path) + return result + + +def copy_install(prefix, destination): + allowed = {Path('bin') / name for name in EXECUTABLES} | {Path('lib/libpdfium.so')} + files = payload_files(prefix) + present = {path.relative_to(prefix) for path in files} + if not allowed <= present: + raise ValueError('Install is missing an application executable or PDFium') + initial_hashes = {str(path): sha256(prefix / path) for path in allowed} + for path in files: + relative = path.relative_to(prefix) + if relative not in allowed and not relative.is_relative_to('share/doc/docview'): + raise ValueError('Unexpected file in install payload: ' + str(relative)) + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(path, target) + target.chmod(0o755 if relative in allowed and relative.parts[0] == 'bin' else 0o644) + if initial_hashes != {str(path): sha256(prefix / path) for path in allowed} or initial_hashes != { + str(path): sha256(destination / path) for path in allowed}: + raise ValueError('Installed binaries changed while copying package input') + + +def describe_payload(directory, epoch): + files = [] + for path in payload_files(directory): + relative = path.relative_to(directory).as_posix() + if relative == MANIFEST: + continue + files.append({'path': relative, 'size': path.stat().st_size, 'sha256': sha256(path), + 'mode': '0755' if os.access(path, os.X_OK) else '0644'}) + return {'schemaVersion': 1, 'name': PACKAGE_NAME, 'sourceDateEpoch': epoch, + 'scope': 'Local Arch Linux development payload; not a clean Ubuntu/Windows package', + 'docviewLicense': 'unspecified', 'dependencySourceFulfillment': 'not-complete', + 'excludedFromOwnDigest': [MANIFEST], 'files': files} + + +def write_archive(directory, archive, epoch): + if not 0 <= epoch <= 0xffffffff: + raise ValueError('SOURCE_DATE_EPOCH must fit a gzip timestamp') + files = payload_files(directory) + with archive.open('xb') as raw: + with gzip.GzipFile(filename='', mode='wb', fileobj=raw, compresslevel=9, mtime=epoch) as zipped: + with tarfile.open(fileobj=zipped, mode='w', format=tarfile.PAX_FORMAT) as tar: + for path in files: + relative = path.relative_to(directory).as_posix() + info = tarfile.TarInfo(PACKAGE_NAME + '/' + relative) + info.size = path.stat().st_size + info.mode = 0o755 if os.access(path, os.X_OK) else 0o644 + info.uid = info.gid = 0 + info.uname = info.gname = '' + info.mtime = epoch + with path.open('rb') as source: + tar.addfile(info, source) + + +def verify_and_extract(archive, destination): + """Reject links, traversal, duplicate members and hash mismatches before use.""" + if destination.exists() and any(destination.iterdir()): + raise ValueError('Extraction destination must be empty') + destination.mkdir(parents=True, exist_ok=True) + seen, total = set(), 0 + with tarfile.open(archive, 'r:gz') as tar: + members = tar.getmembers() + if not members or len(members) > 10000: + raise ValueError('Invalid archive member count') + for member in members: + path = PurePosixPath(member.name) + if (not member.isfile() or path.is_absolute() or '..' in path.parts or + len(path.parts) < 2 or path.parts[0] != PACKAGE_NAME or + str(path) != member.name or member.name in seen or member.mode not in (0o644, 0o755)): + raise ValueError('Unsafe or duplicate archive member: ' + member.name) + seen.add(member.name) + total += member.size + if total > MAX_PAYLOAD_BYTES: + raise ValueError('Archive exceeds unpacked size limit') + manifest_member = tar.getmember(PACKAGE_NAME + '/' + MANIFEST) + if manifest_member.size > 8 * 1024 * 1024: + raise ValueError('Oversized payload manifest') + with tar.extractfile(manifest_member) as source: + manifest = json.load(source) + if manifest.get('schemaVersion') != 1 or manifest.get('name') != PACKAGE_NAME: + raise ValueError('Invalid payload manifest') + expected = {} + for row in manifest['files']: + key = PACKAGE_NAME + '/' + row['path'] + if key in expected or key not in seen or not re.fullmatch('[0-9a-f]{64}', row['sha256']): + raise ValueError('Invalid manifest file entry') + expected[key] = row + if set(expected) | {manifest_member.name} != seen: + raise ValueError('Manifest and archive entries differ') + for member in members: + data = tar.extractfile(member) + target = destination / member.name + target.parent.mkdir(parents=True, exist_ok=True) + digest, written = hashlib.sha256(), 0 + with data, target.open('xb') as output: + for chunk in iter(lambda: data.read(1024 * 1024), b''): + written += len(chunk) + digest.update(chunk) + output.write(chunk) + if member.name in expected: + row = expected[member.name] + if (row['size'] != written or row['sha256'] != digest.hexdigest() or + int(row['mode'], 8) != member.mode): + raise ValueError('Payload integrity failure: ' + member.name) + target.chmod(member.mode) + return destination / PACKAGE_NAME + + +def create_package(prefix, output, epoch=0, qtpaths='/usr/lib/qt6/bin/qtpaths'): + prefix, output = prefix.resolve(strict=True), output.resolve() + output.mkdir(parents=True, exist_ok=True) + archive = output / (PACKAGE_NAME + '.tar.gz') + report_path = output / (PACKAGE_NAME + '.json') + if archive.exists() or report_path.exists(): + raise ValueError('Package output already exists; select a new output directory') + with tempfile.TemporaryDirectory(prefix='docview-package-') as temporary: + staging = Path(temporary) / 'payload' + staging.mkdir() + copy_install(prefix, staging) + documentation = staging / 'share/doc/docview' + for source, name in [(ROOT / 'docs/LINUX-DEVELOPMENT-PACKAGE.md', 'LINUX-DEVELOPMENT-PACKAGE.md'), + (ROOT / 'resources/licenses/README.md', 'THIRD-PARTY-SCOPE.md')]: + shutil.copyfile(source, documentation / name) + notices = documentation / 'third-party' + if notices.exists(): + raise ValueError('Install already contains generated third-party inventory; use a fresh install') + dependencies = inventory(staging, notices, qtpaths) + manifest = describe_payload(staging, epoch) + (staging / MANIFEST).write_text(json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + '\n') + temporary_archive = Path(temporary) / 'package.tar.gz' + write_archive(staging, temporary_archive, epoch) + extracted = verify_and_extract(temporary_archive, Path(temporary) / 'verified') + # A second archive from the verified extraction establishes deterministic + # metadata/order/compression without relying on filesystem mtimes. + repeated = Path(temporary) / 'repeated.tar.gz' + write_archive(extracted, repeated, epoch) + if sha256(temporary_archive) != sha256(repeated): + raise ValueError('Package is not byte reproducible after extraction') + report = {'schemaVersion': 1, 'name': PACKAGE_NAME, 'archive': archive.name, + 'archiveSha256': sha256(temporary_archive), 'archiveBytes': temporary_archive.stat().st_size, + 'unpackedFileBytes': sum(p.stat().st_size for p in payload_files(staging)), + 'payloadFileCount': len(manifest['files']) + 1, 'sourceDateEpoch': epoch, + 'pythonVersion': platform_version(), 'zlibVersion': zlib.ZLIB_RUNTIME_VERSION, + 'executableSha256': dependencies['executableSha256'], + 'systemRuntimeCandidateBytesNotBundled': dependencies['systemRuntimeCandidateBytes'], + 'systemComponentCount': len(dependencies['components']) - 1, + 'archiveRoundTripSha256Matches': True, 'guiExtractionSmoke': 'not-run-by-packager', + 'scope': dependencies['scope'], 'dependencySources': 'not-collected', + 'completeChromiumNotices': False, 'cleanOsAcceptance': False} + shutil.copyfile(temporary_archive, archive) + report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + '\n') + return report + + +def platform_version(): + import platform + return platform.python_version() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument('--prefix', type=Path, help='Trusted, fresh cmake install tree') + source.add_argument('--build-dir', type=Path, help='Run cmake --install into a private temporary tree; no build') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--epoch', type=int, default=int(os.environ.get('SOURCE_DATE_EPOCH', '0'))) + parser.add_argument('--qtpaths', default='/usr/lib/qt6/bin/qtpaths') + args = parser.parse_args() + if args.build_dir: + with tempfile.TemporaryDirectory(prefix='docview-install-') as directory: + subprocess.run(['cmake', '--install', str(args.build_dir.resolve()), '--prefix', directory], check=True) + result = create_package(Path(directory), args.output, args.epoch, args.qtpaths) + else: + result = create_package(args.prefix, args.output, args.epoch, args.qtpaths) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/package_ubuntu.py b/tools/package_ubuntu.py new file mode 100644 index 0000000..06a27e7 --- /dev/null +++ b/tools/package_ubuntu.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Build a local Ubuntu 24.04 amd64 deb with an explicitly selected Qt SDK.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import re +import shutil +import subprocess + +from collect_ubuntu_validation_runtime import Collector, EXECUTABLES, bounded_walk +from verify_pdfium_candidate import verify_installed as verify_pdfium_installed + +ROOT = Path(__file__).resolve().parents[1] +VERSION = '0.1.0~validation3' +VERSIONS = (VERSION, '0.1.0~validation4', '0.1.0~validation5') +MANIFEST = 'opt/docview/share/doc/docview/package-manifest.json' +SDK_SUPPLEMENT_REPORT_SHA = 'e218e5a24d136dbfe2982e56dbf9bb1e3fd104644c967ca02234b5e6be47096e' + + +def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + + +def canonical(name): + if not isinstance(name, str) or '\\' in name or '\x00' in name: + raise ValueError('Invalid relative path') + path = PurePosixPath(name) + if path.is_absolute() or '..' in path.parts or str(path) != name or name in ('', '.'): + raise ValueError('Invalid relative path') + return path + + +def copy_verified(source, target, digest=None, executable=None): + if not source.is_file() or source.stat().st_size > 512 * 1024**2: + raise ValueError('Invalid or oversized package input') + expected = digest or sha(source) + if sha(source) != expected: + raise ValueError('Input changed: ' + str(source)) + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + if sha(target) != expected: + raise ValueError('Conflicting package destination') + return + shutil.copyfile(source, target) + target.chmod(0o755 if (os.access(source, os.X_OK) if executable is None else executable) else 0o644) + if sha(target) != expected or sha(source) != expected: + raise ValueError('Package input changed while copying') + + +def normalize_modes(directory): + for path in [directory, *directory.rglob('*')]: + if path.is_symlink() or (not path.is_dir() and not path.is_file()): + raise ValueError('Unexpected staged link or special file') + path.chmod(0o755 if path.is_dir() or path.stat().st_mode & 0o111 else 0o644) + + +def copy_sdk_supplement(directory, base_report_path, known_runtime, destination): + directory = directory.resolve(strict=True) + report_path = directory / 'report.json' + if report_path.is_symlink() or sha(report_path) != SDK_SUPPLEMENT_REPORT_SHA: + raise ValueError('Qt SDK supplement report differs from the reviewed fixed input') + report = json.loads(report_path.read_text()) + if sha(base_report_path) != report['baseNoticeReportSha256']: + raise ValueError('Qt SDK supplement belongs to a different base notice report') + for row in report['runtimeComparisons']: + if known_runtime.get(row['resolvedPath']) != row['sha256']: + raise ValueError('Qt SDK supplement belongs to a different runtime') + inputs = [] + for row in report['files']: + name = canonical(row['file']) + source = directory / name + if source.is_symlink() or not source.resolve(strict=True).is_relative_to(directory): + raise ValueError('Qt SDK supplement source escapes its selected directory') + if source.stat().st_size != row['bytes'] or sha(source) != row['sha256']: + raise ValueError('Qt SDK supplement source changed') + inputs.append((source, destination / name, row['sha256'])) + # Check every input before creating the destination, then verify each copy. + for source, target, digest in inputs: + copy_verified(source, target, digest, executable=False) + copy_verified(report_path, destination / 'report.json', SDK_SUPPLEMENT_REPORT_SHA, executable=False) + return SDK_SUPPLEMENT_REPORT_SHA + + +def create(args): + version = getattr(args, 'version', VERSION) + if version not in VERSIONS: + raise ValueError('Unsupported local validation package version') + if platform.freedesktop_os_release().get('ID') != 'ubuntu' or platform.freedesktop_os_release().get('VERSION_ID') != '24.04': + raise ValueError('Build this package on Ubuntu 24.04') + if subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip() != 'amd64': + raise ValueError('Only amd64 is supported') + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + collector = Collector(args.prefix, args.qtpaths, args.dependency_prefix, output / 'runtime', args.source_root, + qpdf_source_archive=getattr(args, 'qpdf_source_archive', None)) + runtime = collector.collect(platform.freedesktop_os_release(), args.input_manifest) + if not runtime['runtimeValidationSuccess'] or runtime['systemOwnershipUnresolved']: + raise ValueError('Runtime inventory has unresolved dependencies') + (output / 'runtime/runtime.json').write_text(json.dumps(runtime, indent=2, sort_keys=True) + '\n') + stage = output / 'stage'; stage.mkdir() + app = stage / 'opt/docview' + mapping = {'install': (collector.prefix, app), 'qt-sdk': (collector.sdk, app / 'qt'), + 'dependencies': (collector.deps, app)} + copied = [] + for row in runtime['files']: + if not row['path'].startswith(tuple(key + ':' for key in mapping)): + continue + kind, relative = row['path'].split(':', 1) + source_root, destination_root = mapping[kind] + # The inventory has one harmless bin/../lib alias for PDFium. Normalize + # the lexical destination while checking the actual source separately. + normalized = os.path.normpath(relative) + canonical(normalized) + source = source_root / normalized + if not source.resolve(strict=True).is_relative_to(source_root): + raise ValueError('Runtime source escapes its selected prefix') + target = destination_root / normalized + copy_verified(source, target, row['sha256']) + copied.append({'source': row['path'], 'path': str(target.relative_to(stage)), 'sha256': row['sha256']}) + documentation = app / 'share/doc/docview' + for path in bounded_walk(collector.prefix / 'share/doc/docview', (collector.prefix,)): + copy_verified(path, documentation / path.relative_to(collector.prefix / 'share/doc/docview'), executable=False) + pdfium_correspondence = verify_pdfium_installed(app) + if pdfium_correspondence != runtime['pdfiumCorrespondence']: + raise ValueError('PDFium correspondence changed during packaging') + # Preserve selected runtime notices and SBOMs with the original scope ledger. + shutil.copytree(output / 'runtime', documentation / 'third-party/runtime') + supplemental = args.sdk_notices.resolve(strict=True) + notice_report = json.loads((supplemental / 'report.json').read_text()) + if not notice_report['success'] or len(notice_report['notices']) != 129: + raise ValueError('Verified Qt SDK notice extraction is required') + known_runtime = {row['resolvedPath']: row['sha256'] for row in runtime['files']} + for row in notice_report['testedRuntimeComparisons']: + if known_runtime.get(row['resolvedPath']) != row['sha256']: + raise ValueError('Qt SDK notice extraction belongs to a different runtime') + copy_verified(supplemental / 'report.json', documentation / 'third-party/qt-sdk/source-report.json', executable=False) + copy_verified(supplemental / 'THIRD_PARTY_NOTICES.sdk-extract.txt', documentation / 'third-party/qt-sdk/NOTICES.txt', + notice_report['noticeBundleSha256'], executable=False) + for row in notice_report['notices']: + name = canonical(row['file']) + copy_verified(supplemental / name, documentation / 'third-party/qt-sdk' / name, row['sha256'], executable=False) + supplement_sha = copy_sdk_supplement(args.sdk_supplement, supplemental / 'report.json', known_runtime, + documentation / 'third-party/qt-sdk-supplement') + for path in bounded_walk(args.qt_licenses.resolve(strict=True), (args.qt_licenses.resolve(strict=True),)): + copy_verified(path, documentation / 'third-party/qt-licenses' / path.relative_to(args.qt_licenses.resolve()), executable=False) + (documentation / 'UBUNTU-PACKAGE.txt').write_text( + 'DocView — Ubuntu 24.04 amd64\n\nStart: docview [document]\n' + 'Remove application: sudo apt remove docview\n' + 'Remove application and its system profile: sudo apt purge docview\n' + 'User documents, preferences and reading history are preserved.\n\n' + 'This local validation package includes a private Qt SDK runtime, qpdf, libzip and PDFium.\n' + 'System dependencies are declared in the Debian control metadata.\n' + 'Third-party notices and source references are under third-party/.\n' + 'The Qt SDK supplement retains WebM/WebP Patent files declared by the fixed upstream metadata.\n' + 'Notice/source completeness and public release conditions have not been finalized.\n') + for source, target, executable in [ + ('docview-launcher', 'usr/bin/docview', True), ('docview.desktop', 'usr/share/applications/docview.desktop', False), + ('docview.apparmor', 'etc/apparmor.d/docview', False), ('deb-postinst', 'DEBIAN/postinst', True), + ('deb-prerm', 'DEBIAN/prerm', True)]: + copy_verified(ROOT / 'resources/linux' / source, stage / target, executable=executable) + for target, prefix in [(app / 'bin/qt.conf', '../qt'), (app / 'qt/libexec/qt.conf', '..')]: + target.write_text('[Paths]\nPrefix=' + prefix + '\nLibraries=lib\nLibraryExecutables=libexec\n' + 'Plugins=plugins\nQmlImports=qml\nTranslations=translations\nData=.\n') + dependencies = {'apparmor', 'fonts-dejavu-core', 'fonts-noto-cjk'} + for name, package in runtime['systemPackages'].items(): + if not re.fullmatch(r'[a-z0-9][a-z0-9+.-]*(?::amd64)?', name) or not re.fullmatch(r'[0-9A-Za-z.+:~\-]+', package['version']): + raise ValueError('Invalid Debian dependency identity') + dependencies.add(name.split(':')[0] + ' (>= ' + package['version'] + ')') + control = stage / 'DEBIAN/control' + control.write_text('Package: docview\nVersion: ' + version + '\nArchitecture: amd64\n' + 'Maintainer: DocView contributors\nSection: text\nPriority: optional\n' + 'Depends: ' + ', '.join(sorted(dependencies)) + '\n' + 'Description: Local PDF, HTML and EPUB document viewer\n' + ' A keyboard-oriented Qt Quick viewer with isolated PDF and archive workers.\n') + (stage / 'DEBIAN/conffiles').write_text('/etc/apparmor.d/docview\n') + normalize_modes(stage) + payload = [] + total = 0 + for path in sorted(stage.rglob('*')): + if path.is_symlink() or (not path.is_dir() and not path.is_file()): + raise ValueError('Unexpected staged link or special file') + if not path.is_file(): continue + total += path.stat().st_size + if total > 2 * 1024**3 or len(payload) >= 10000: + raise ValueError('Package exceeds finite payload limits') + payload.append({'path': str(path.relative_to(stage)), 'size': path.stat().st_size, + 'sha256': sha(path), 'mode': oct(path.stat().st_mode & 0o777)}) + manifest = {'schemaVersion': 1, 'package': 'docview', 'version': version, 'target': 'Ubuntu 24.04 amd64', + 'files': payload, 'excludedFromOwnDigest': [MANIFEST], 'runtimeCopies': copied, + 'sdkSupplementReportSha256': supplement_sha, + 'pdfiumCorrespondence': pdfium_correspondence, + 'qpdfNoticeCorrespondence': runtime['qpdfNoticeCorrespondence'], + 'completeChromiumNotices': False, 'completeCorrespondingSources': False, + 'publicReleaseApproved': False} + (stage / MANIFEST).write_text(json.dumps(manifest, indent=2, sort_keys=True) + '\n') + (stage / MANIFEST).chmod(0o644) + # Debian's size field includes the data archive, rounded to KiB per file + # and directory. Its control row is itself recorded in our manifest, so + # settle the tiny self-size dependency before building the archive. + for _ in range(8): + installed_kib = sum(1 if path.is_dir() else (path.stat().st_size + 1023) // 1024 + for path in stage.rglob('*') if path.relative_to(stage).parts[0] != 'DEBIAN') + previous = control.read_text() + updated = re.sub(r'^Installed-Size:.*\n', '', previous, flags=re.M) + updated += 'Installed-Size: ' + str(installed_kib) + '\n' + if updated == previous: + break + control.write_text(updated) + row = next(row for row in payload if row['path'] == 'DEBIAN/control') + row.update(size=control.stat().st_size, sha256=sha(control)) + (stage / MANIFEST).write_text(json.dumps(manifest, indent=2, sort_keys=True) + '\n') + else: + raise ValueError('Installed size did not converge') + total = sum(row['size'] for row in payload) + environment = {**os.environ, 'SOURCE_DATE_EPOCH': '0', 'LC_ALL': 'C'} + archive = output / ('docview_' + version + '_amd64.deb') + command = ['dpkg-deb', '--root-owner-group', '-Zxz', '-z6', '--threads-max=2', '--build', str(stage), str(archive)] + subprocess.run(command, env=environment, check=True, timeout=300) + report = {'success': True, 'archive': archive.name, 'archiveSha256': sha(archive), 'archiveBytes': archive.stat().st_size, + 'payloadFiles': len(payload) + 1, 'payloadBytes': total + (stage / MANIFEST).stat().st_size, + 'sourceDateEpoch': 0, 'packagerSha256': sha(Path(__file__)), + 'collectorSha256': sha(ROOT / 'tools/collect_ubuntu_validation_runtime.py'), + 'runtimeRecordSha256': sha(output / 'runtime/runtime.json'), 'systemDependencies': sorted(dependencies), + 'sdkSupplementReportSha256': supplement_sha, + 'pdfiumCorrespondence': pdfium_correspondence, + 'qpdfNoticeCorrespondence': runtime['qpdfNoticeCorrespondence'], + 'manifestSha256': sha(stage / MANIFEST), 'installedSmoke': 'not-run-by-packager', + 'installedSizeKiB': installed_kib, + 'executableSha256': {name: sha(app / 'bin' / name) for name in EXECUTABLES}, + 'completeChromiumNotices': False, 'completeCorrespondingSources': False, 'publicReleaseApproved': False} + (output / 'report.json').write_text(json.dumps(report, indent=2, sort_keys=True) + '\n') + print(json.dumps({k: report[k] for k in ('success', 'archiveBytes', 'payloadFiles', 'payloadBytes')})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', type=Path, required=True) + parser.add_argument('--qtpaths', type=Path, required=True) + parser.add_argument('--dependency-prefix', type=Path, required=True) + parser.add_argument('--source-root', type=Path, action='append', default=[]) + parser.add_argument('--input-manifest', type=Path, required=True) + parser.add_argument('--sdk-notices', type=Path, required=True) + parser.add_argument('--sdk-supplement', type=Path, required=True) + parser.add_argument('--qt-licenses', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--version', choices=VERSIONS, default=VERSION) + parser.add_argument('--qpdf-source-archive', type=Path, + help='Pinned qpdf source archive required when bundling the known qpdf runtime') + create(parser.parse_args()) + + +if __name__ == '__main__': + main() diff --git a/tools/record_dependency_provenance.py b/tools/record_dependency_provenance.py new file mode 100644 index 0000000..38d9993 --- /dev/null +++ b/tools/record_dependency_provenance.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Record bounded local provenance; never download, build, or execute DocView. + +This is an evidence ledger, not a license-compliance assessment. Package cache +metadata is matched to the bundled inventory, not assumed to authenticate the +installed files. Raw packager/build-directory/host inventory fields are omitted. +Python 3.14+ supplies the streaming zstd tar reader used for Arch cache files. +""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import tarfile +import tempfile + +from package_linux_development import MANIFEST, ROOT, verify_and_extract +from collect_linux_dependencies import qt_embedded_notices + +DEPENDENCIES = 'share/doc/docview/third-party/dependency-manifest.json' +PRIMARY_CACHE_HASHES = {'qt6-base', 'qt6-declarative', 'qt6-webengine', 'tomlplusplus'} +PKG_FIELDS = {'pkgname', 'pkgbase', 'pkgver', 'arch', 'license', 'builddate'} +BUILD_FIELDS = {'format', 'pkgname', 'pkgbase', 'pkgver', 'pkgarch', + 'pkgbuild_sha256sum', 'builddate', 'buildtool', 'buildtoolver'} +MULTI_FIELDS = {'pkgname', 'license'} +MAX_METADATA = 1024 * 1024 +MAX_PREFIX = 8 * 1024 * 1024 +MAX_ARCHIVE_HASH = 256 * 1024 * 1024 + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def file_digest(path): + result = hashlib.sha256() + with path.open('rb') as source: + for chunk in iter(lambda: source.read(1024 * 1024), b''): + result.update(chunk) + return result.hexdigest() + + +def canonical(value): + return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + '\n').encode() + + +def bounded_bytes(path, maximum=MAX_METADATA): + with path.open('rb') as source: + data = source.read(maximum + 1) + if len(data) > maximum: + raise ValueError('Input exceeds its finite metadata bound') + return data + + +def parse_metadata(data, fields): + """Discard sensitive fields before validating/exporting the allowed subset.""" + if len(data) > MAX_METADATA or b'\0' in data: + raise ValueError('Invalid package metadata size or encoding') + result = {} + for line in data.decode('utf-8', errors='strict').splitlines(): + if not line or line.startswith('#'): + continue + key, separator, value = line.partition(' = ') + if not separator: + raise ValueError('Malformed package metadata record') + if key not in fields: + continue + # The selected identity/license/build-tool fields never need paths, + # email addresses, control characters, or freeform descriptions. + if not value or len(value) > 512 or not re.fullmatch(r'[A-Za-z0-9_:.,+() /&*~=-]+', value): + raise ValueError('Invalid selected package metadata value') + if '/' in value or '@' in value: + raise ValueError('Selected package metadata contains a path or address') + if key in result and key not in MULTI_FIELDS: + raise ValueError('Duplicate scalar package metadata') + result.setdefault(key, []).append(value) + return {key: sorted(values) if key in MULTI_FIELDS else values[0] + for key, values in sorted(result.items())} + + +def cache_metadata(path, expected): + """Read only the bounded metadata prefix; do not unpack package payloads.""" + raw = {} + with tarfile.open(path, mode='r|zst') as archive: + for index, member in enumerate(archive): + if index >= 32 or member.offset_data + member.size > MAX_PREFIX: + raise ValueError('Package metadata is outside the bounded prefix') + if member.name in ('.PKGINFO', '.BUILDINFO'): + if member.name in raw or not member.isfile() or member.size > MAX_METADATA: + raise ValueError('Invalid package metadata member') + raw[member.name] = archive.extractfile(member).read(MAX_METADATA + 1) + if len(raw) == 2: + break + if set(raw) != {'.PKGINFO', '.BUILDINFO'}: + raise ValueError('Package cache lacks required metadata') + package = parse_metadata(raw['.PKGINFO'], PKG_FIELDS) + build = parse_metadata(raw['.BUILDINFO'], BUILD_FIELDS) + name, version, arch, base = expected + for fields, arch_key in ((package, 'arch'), (build, 'pkgarch')): + if (name not in fields.get('pkgname', []) or fields.get('pkgver') != version or + fields.get(arch_key) != arch or fields.get('pkgbase', name) != base): + raise ValueError('Cached package identity does not match the final inventory') + if (build.get('format') != '2' or + not re.fullmatch('[0-9a-f]{64}', build.get('pkgbuild_sha256sum', ''))): + raise ValueError('Missing supported build format or PKGBUILD digest') + return {'status': 'identity-matched-metadata', 'file': path.name, + 'compressedBytes': path.stat().st_size, + 'packageInfo': {'rawSha256': digest(raw['.PKGINFO']), 'selectedFields': package}, + 'buildInfo': {'rawSha256': digest(raw['.BUILDINFO']), 'selectedFields': build}, + 'signatureVerification': 'not-performed', + 'installedFileToCachedPayloadComparison': 'not-performed', + 'recipeCommit': None, + 'recipeCommitStatus': 'PKGBUILD hash observed; Git revision not resolved by this collector; ' + 'see separate source-correspondence records'} + + +def cache_record(cache, name, component): + version, arch = component['version'], component['architecture'] + base = component['source']['packageBase'] + if not all(re.fullmatch('[A-Za-z0-9_.+:~-]+', v) for v in (name, version, arch, base)): + raise ValueError('Unsafe component identity in dependency manifest') + path = cache / f'{name}-{version}-{arch}.pkg.tar.zst' + if not path.is_file(): + return {'status': 'not-present-in-selected-cache'} + record = cache_metadata(path, (name, version, arch, base)) + if name in PRIMARY_CACHE_HASHES: + if path.stat().st_size > MAX_ARCHIVE_HASH: + raise ValueError('Selected package exceeds bounded archive hashing limit') + record['archiveSha256'] = file_digest(path) + else: + record['archiveHashStatus'] = 'not-computed; raw metadata digests recorded' + return record + + +def source_evidence(path, label, needles): + data = bounded_bytes(path) + lines = data.decode('utf-8').splitlines() + return {'path': label, 'sha256': digest(data), + 'selectedLines': [{'line': i, 'text': line.strip()} + for i, line in enumerate(lines, 1) + if any(needle in line for needle in needles)]} + + +def inspect_elf(tool, arguments, executable, maximum=32 * 1024 * 1024): + # readelf/nm inspect bytes. Neither the input executable nor ldd is run. + with tempfile.TemporaryFile() as output: + result = subprocess.run([tool, *arguments, str(executable)], stdout=output, + stderr=subprocess.DEVNULL, timeout=20, check=False) + if result.returncode or output.tell() > maximum: + raise ValueError('Bounded ELF inspection failed') + output.seek(0) + return output.read(maximum + 1).decode('utf-8', errors='strict') + + +def license_evidence(payload, row, bundled=False): + relative = row['path'] if bundled else 'share/doc/docview/third-party/' + row['path'] + path = PurePosixPath(relative) + if path.is_absolute() or '..' in path.parts or str(path) != relative: + raise ValueError('Unsafe license evidence path') + data = bounded_bytes(payload / relative, 8 * MAX_METADATA) + if len(data) != row['size'] or digest(data) != row['sha256']: + raise ValueError('License text disagrees with packaged inventory') + return {'payloadPath': relative, 'size': len(data), 'sha256': digest(data)} + + +def qt_embedded_notice_record(payload, manifest, licenses, root): + component = manifest['components']['qt6-webengine'] + relative = 'licenses/qt6-webengine/embedded-resource-notices' + full_relative = 'share/doc/docview/third-party/' + relative + directory = payload / full_relative + inventory = [row for row in licenses if row['payloadPath'].startswith(full_relative + '/')] + supplied = component.get('embeddedResourceNotices') + if supplied is None: + if inventory or directory.exists(): + raise ValueError('Qt embedded notice payload lacks manifest metadata') + return {'status': 'not-in-this-package', 'completeChromiumNotices': False, + 'scope': 'Legacy package; no embedded Qt resource notice claim'} + original_path, metadata = qt_embedded_notices( + manifest['host']['qtVersion'], component['version'], manifest['systemFiles'], + root / 'resources/licenses/qt-embedded-notices') + original = bounded_bytes(original_path, 65536) + packaged = bounded_bytes(directory / 'sources.json', 65536) + if original != packaged: + raise ValueError('Qt embedded notice metadata differs from repository original') + names = {row['name'] for row in metadata['files']} + if {path.name for path in directory.iterdir()} != names | {'sources.json'}: + raise ValueError('Unexpected Qt embedded notice payload contents') + if component['source']['status'] != 'not-collected': + raise ValueError('Partial Qt notices cannot claim complete source collection') + evidence, expected_rows = [], [] + for row in metadata['files']: + data = bounded_bytes(directory / row['name']) + source = bounded_bytes(original_path.parent / row['name']) + if data != source or len(data) != row['size'] or digest(data) != row['sha256']: + raise ValueError('Qt embedded notice differs from original or metadata') + evidence.append({'payloadPath': full_relative + '/' + row['name'], + 'size': len(data), 'sha256': digest(data)}) + expected_rows.append({'path': relative + '/' + row['name'], 'size': row['size'], 'sha256': row['sha256'], + 'origin': 'reviewed-installed-DataPack-resource-comment', + 'sourceResources': row['sourceResources'], 'collectionScope': metadata['scope']}) + if sorted(inventory, key=lambda row: row['payloadPath']) != sorted(evidence, key=lambda row: row['payloadPath']): + raise ValueError('Qt embedded notice license inventory differs from metadata') + recorded_rows = [row for row in component['licenseTexts'] if row['path'].startswith(relative + '/')] + if sorted(recorded_rows, key=lambda row: row['path']) != sorted(expected_rows, key=lambda row: row['path']): + raise ValueError('Qt embedded notice resource provenance differs from metadata') + expected = {'status': 'collected-reviewed-resource-subset', + 'metadataPath': relative + '/sources.json', 'metadataSha256': digest(original), + 'noticeCount': len(metadata['files']), + 'sourceResourceCount': sum(len(row['sourceResources']) for row in metadata['files']), + 'runtimeDistribution': 'system-not-bundled', 'completeChromiumNotices': False, + 'scope': metadata['scope']} + if supplied != expected: + raise ValueError('Qt embedded notice manifest metadata mismatch') + return {'status': 'repository-original-and-package-matched', + 'metadata': {'payloadPath': full_relative + '/sources.json', 'sha256': digest(original)}, + 'repositoryMetadata': {'path': 'resources/licenses/qt-embedded-notices/sources.json', + 'sha256': digest(original)}, + 'noticeCount': expected['noticeCount'], 'sourceResourceCount': expected['sourceResourceCount'], + 'completeChromiumNotices': False, 'runtimeDistribution': 'system-not-bundled', + 'sourceCollectionStatus': 'not-collected', 'dataPacks': metadata['dataPacks'], + 'files': expected_rows, 'scope': metadata['scope'], + 'scopeLimit': 'Repository originals and packaged notice bytes matched to the recorded system DataPack inventory; ' + 'host DataPacks were not rehashed here. No complete Chromium attribution, source collection, or legal assessment.'} + + +def pdfium_supplemental_record(payload, component, licenses, lock, library_hash, root): + relative = 'share/doc/docview/pdfium/supplemental' + inventory = [row for row in licenses if row['payloadPath'].startswith(relative + '/')] + supplied = component.get('supplementalNotices') + directory = payload / relative + if supplied is None: + if inventory or directory.exists(): + raise ValueError('PDFium supplemental payload lacks manifest metadata') + return {'status': 'not-in-this-package', + 'scope': 'Legacy provider-only notices; no supplemental notice claim'} + original_root = root / 'resources/licenses/pdfium-supplemental' + original = bounded_bytes(original_root / 'sources.json', 65536) + packaged = bounded_bytes(directory / 'sources.json', 65536) + if original != packaged: + raise ValueError('PDFium supplemental metadata differs from repository original') + metadata = json.loads(original) + if (type(metadata.get('schemaVersion')) is not int or metadata['schemaVersion'] != 1 or + metadata.get('pdfiumVersion') != lock['version'] or + metadata.get('pdfiumUpstreamCommit') != lock['upstreamCommit'] or + metadata.get('pdfiumLibrarySha256') != library_hash): + raise ValueError('PDFium supplemental source pin or library hash mismatch') + rows = metadata.get('files') + names = {'libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'} + if (not isinstance(rows, list) or len(rows) != 2 or + not all(isinstance(row, dict) for row in rows) or + {row.get('name') for row in rows} != names): + raise ValueError('Unexpected PDFium supplemental notice set') + if (not re.fullmatch('[0-9a-f]{40}', metadata.get('providerRecipeCommit', '')) or + {path.name for path in directory.iterdir()} != names | {'sources.json'}): + raise ValueError('Invalid PDFium supplemental recipe or payload contents') + files, evidence = [], [] + for row in rows: + if (type(row.get('size')) is not int or not 0 < row['size'] <= MAX_METADATA or + not re.fullmatch('[0-9a-f]{64}', row.get('sha256', '')) or + not re.fullmatch('[0-9a-f]{40}', row.get('sourceRevision', '')) or + not isinstance(row.get('sourceUrl'), str) or + not row['sourceUrl'].startswith('https://chromium.googlesource.com/') or + '/+/' + row['sourceRevision'] + '/' not in row['sourceUrl']): + raise ValueError('Invalid PDFium supplemental fixed-source notice metadata') + source = bounded_bytes(original_root / row['name']) + data = bounded_bytes(directory / row['name']) + if (source != data or len(source) != row['size'] or digest(source) != row['sha256']): + raise ValueError('PDFium supplemental notice differs from original or metadata') + path = relative + '/' + row['name'] + files.append({**row, 'path': path}) + evidence.append({'payloadPath': path, 'size': len(data), 'sha256': digest(data)}) + if sorted(inventory, key=lambda row: row['payloadPath']) != sorted(evidence, key=lambda row: row['payloadPath']): + raise ValueError('PDFium supplemental license inventory differs from metadata') + expected = {'metadataPath': relative + '/sources.json', 'metadataSha256': digest(original), + 'scope': metadata['scope'], 'providerRecipeCommit': metadata['providerRecipeCommit'], + 'pdfiumLibrarySha256': library_hash, 'files': files} + if supplied != expected: + raise ValueError('PDFium supplemental manifest metadata mismatch') + return {'status': 'repository-original-and-package-matched', **expected, + 'repositoryMetadata': {'path': 'resources/licenses/pdfium-supplemental/sources.json', + 'sha256': digest(original)}, + 'scopeLimit': 'Fixed-source notice bytes and recorded pins matched; ' + 'no new source download, build reproduction or legal assessment'} + + +def pdfium_record(payload, manifest, pdfium_root, root): + component = manifest['components']['PDFium-independent-worker'] + lock_data = bounded_bytes(root / 'cmake/pdfium.lock.json') + lock = json.loads(lock_data) + version_data = bounded_bytes(pdfium_root / 'VERSION') + values = dict(re.findall(r'^(MAJOR|MINOR|BUILD|PATCH)=(\d+)$', version_data.decode(), re.M)) + if set(values) != {'MAJOR', 'MINOR', 'BUILD', 'PATCH'}: + raise ValueError('Invalid PDFium VERSION metadata') + version = '.'.join(values[key] for key in ('MAJOR', 'MINOR', 'BUILD', 'PATCH')) + args_data = bounded_bytes(pdfium_root / 'args.gn') + args = {} + for line in args_data.decode().splitlines(): + match = re.fullmatch(r'([a-z_][a-z_0-9]*)\s*=\s*(true|false|"[A-Za-z0-9_-]+")', line.strip()) + if not match or match[1] in args: + raise ValueError('Unsupported or duplicate PDFium build argument') + args[match[1]] = json.loads(match[2]) + if (version != component['version'] or version != lock['version'] or + lock['upstreamCommit'] != component['source']['upstreamCommit'] or + lock['linuxX64Sha256'] != component['source']['archiveSha256'] or + any(args.get(key) != value for key, value in lock['buildOptions'].items())): + raise ValueError('Local PDFium build metadata disagrees with final inventory/lock') + packaged_library = payload / component['path'] + library_hash = file_digest(packaged_library) + if (library_hash != component['sha256'] or + library_hash != file_digest(pdfium_root / 'lib/libpdfium.so')): + raise ValueError('Local PDFium library differs from packaged library') + licenses = [license_evidence(payload, row, True) for row in component['licenseTexts']] + if len({row['payloadPath'] for row in licenses}) != len(licenses): + raise ValueError('Duplicate PDFium license inventory entry') + for row in licenses: + if not row['payloadPath'].startswith('share/doc/docview/pdfium/'): + raise ValueError('Unexpected PDFium license prefix') + suffix = row['payloadPath'].removeprefix('share/doc/docview/pdfium/') + if suffix.startswith('supplemental/'): + continue + if file_digest(pdfium_root / suffix) != row['sha256']: + raise ValueError('Local PDFium license differs from package') + supplements = pdfium_supplemental_record(payload, component, licenses, lock, library_hash, root) + return {'version': version, 'library': {'payloadPath': component['path'], + 'sha256': library_hash, 'size': packaged_library.stat().st_size}, + 'localLibraryMatchesPayload': True, 'licenseTexts': licenses, + 'supplementalNotices': supplements, + 'providerLicense': 'MIT (top-level LICENSE; PDFium upstream license is separate)', + 'pdfiumUpstreamLicense': 'BSD-3-Clause text in licenses/pdfium.txt; see full file', + 'buildMetadata': { + 'VERSION': {'sha256': digest(version_data), 'values': values}, + 'args.gn': {'sha256': digest(args_data), 'values': args}, + 'lock': {'path': 'cmake/pdfium.lock.json', 'sha256': digest(lock_data)}}, + 'pinnedProviderArchive': {'url': lock['linuxX64Archive'], + 'sha256FromLock': lock['linuxX64Sha256'], + 'archiveReverifiedThisRun': False}, + 'upstreamCommit': lock['upstreamCommit'], 'upstream': lock['upstream'], + 'binaryProvider': lock['binaryProvider'], + 'remaining': ['Provider recipe/patch correspondence is not independently verified by this collector; ' + 'see tests/results/source-correspondence/pdfium records', + 'Transitive source correspondence is not independently verified by this collector; ' + 'see separate source-correspondence records', + 'Completeness of corresponding source and applicable obligations is not assessed here']} + + +def make_record(archive, cache, pdfium_root, root=ROOT): + with tempfile.TemporaryDirectory(prefix='docview-provenance-') as temporary: + payload = verify_and_extract(archive, Path(temporary) / 'verified') + manifest_bytes = bounded_bytes(payload / DEPENDENCIES, 16 * MAX_METADATA) + manifest = json.loads(manifest_bytes) + package_manifest_bytes = bounded_bytes(payload / MANIFEST, 8 * MAX_METADATA) + package_manifest = json.loads(package_manifest_bytes) + payload_rows = package_manifest['files'] + runtime_rows = [row for row in payload_rows if row['path'].startswith(('bin/', 'lib/'))] + if {row['path'] for row in runtime_rows} != { + 'bin/docview', 'bin/docview-pdf-worker', 'bin/docview-archive-worker', 'lib/libpdfium.so'}: + raise ValueError('Unexpected runtime payload; distribution scope must be reviewed') + components = {} + for name, item in sorted(manifest['components'].items()): + if name == 'PDFium-independent-worker': + continue + if item['distribution'] != 'system-not-bundled': + raise ValueError('Unreviewed dependency distribution category') + files = [row for row in manifest['systemFiles'] if row['package'] == name] + components[name] = { + 'version': item['version'], 'architecture': item['architecture'], + 'runtimeBinaryDistribution': 'system-only; not in this tar', + 'headerCodePresence': ('defined toml symbols observed in packaged DocView' + if name == 'tomlplusplus' else 'not-audited'), + 'usageFromManifest': item.get('usage', []), + 'licenseLabelsFromPackage': item['licenseLabelsFromPackage'], + 'licenseTexts': [license_evidence(payload, row) for row in item['licenseTexts']], + 'sourceCollectionStatus': item['source']['status'], + 'sourceCollectionStatusScope': 'Status copied from this packaged inventory; ' + 'separate source-correspondence records may contain later collection evidence', + 'obligationAssessment': 'not-performed', + 'recipeRepository': item['source']['recipeRepository'], + 'recordedSystemFiles': {'count': len(files), + 'canonicalRowsSha256': digest(canonical(files)), + 'evidence': 'packaged dependency-manifest.json#/systemFiles', + 'hostFilesRehashedThisRun': False}, + 'cachedPackage': cache_record(cache, name, item)} + if name == 'qt6-webengine': + components[name]['embeddedResourceNotices'] = qt_embedded_notice_record( + payload, manifest, components[name]['licenseTexts'], root) + dynamic, embedded = [], {} + for relative in ('bin/docview', 'bin/docview-pdf-worker', 'bin/docview-archive-worker'): + text = inspect_elf('readelf', ['--dynamic', '--wide'], payload / relative) + needed = sorted(re.findall(r'\(NEEDED\).*?\[([^\]]+)\]', text)) + if not needed or not any(name.startswith('libQt6') for name in needed): + raise ValueError('Expected dynamic Qt dependency was not found') + dynamic.append({'payloadPath': relative, 'directNeeded': needed}) + text = inspect_elf('nm', ['--defined-only', '--demangle'], payload / 'bin/docview') + symbols = sorted({line.split(' ', 2)[-1] for line in text.splitlines() + if re.match(r'^[0-9a-fA-F]+ [A-Za-z] toml::', line)}) + if not symbols: + raise ValueError('No defined toml symbols; header-code inference needs review') + embedded = {'component': 'tomlplusplus', 'payloadPath': 'bin/docview', + 'definedSymbolCount': len(symbols), 'definedSymbols': symbols, + 'interpretation': 'Header-derived code is present in this executable; ' + 'the toml++ shared-library binary is still system-only.', + 'sourceEvidence': source_evidence(root / 'src/core/config.cpp', + 'src/core/config.cpp', ['#include '])} + qt_files = [row for row in manifest['systemFiles'] + if row['package'].startswith('qt6-') and + re.fullmatch(r'/usr/lib/libQt6[^/]+\.so(?:\.[0-9]+)+', row['path'])] + pdfium = pdfium_record(payload, manifest, pdfium_root, root) + return {'schemaVersion': 1, 'scope': 'Local final Arch development package evidence', + 'legalComplianceVerdict': 'not-assessed', 'docviewLicense': 'unspecified', + 'sourceCollectionIsNotObligationAssessment': True, + 'archive': {'file': archive.name, 'sha256': file_digest(archive), + 'compressedBytes': archive.stat().st_size, + 'payloadFileCount': len(payload_rows) + 1, + 'payloadBytes': sum(row['size'] for row in payload_rows) + len(package_manifest_bytes), + 'payloadIntegrity': 'all members verified against packaged payload manifest', + 'manifestExcludesOwnDigest': True, + 'packageManifestSha256': digest(package_manifest_bytes), + 'dependencyManifestSha256': digest(manifest_bytes), 'runtimeFiles': runtime_rows}, + 'tools': {name: inspect_elf(name, ['--version'], Path('/dev/null'), MAX_METADATA) + .splitlines()[0] for name in ('nm', 'readelf')}, + 'pdfium': pdfium, 'systemComponents': components, + 'dynamicLinkEvidence': {'method': 'readelf DT_NEEDED on verified packaged executables', + 'executables': dynamic, 'qtLibraryRowsFromPackagedInventory': qt_files, + 'qtAndWebEngineRuntimeBinariesInPayload': False, + 'scope': 'Direct ELF links and recorded system resolutions; no claim that ' + 'all Qt header-derived machine code is absent'}, + 'embeddedHeaderEvidence': embedded, + 'privacy': {'omitted': ['home paths', 'user names', 'packager identities and emails', + 'build directories', 'complete builder installed-package inventory'], + 'rawCacheMetadataCopied': False}, + 'limits': ['Cache metadata is identity-matched, not signature-authenticated', + 'Cache metadata is read only from its bounded prefix; the rest of each cache tar is not validated', + 'Only four primary cache archives receive a whole-archive SHA-256', + 'PKGBUILD SHA-256 is evidence of a recipe digest, not a recovered recipe or Git commit', + 'Recorded installed file hashes are from the final package manifest, not remeasured here', + 'This collector does not independently collect or verify complete source trees, recipe patches, ' + 'or WebEngine build-specific full Chromium notices; see separate source-correspondence records', + 'This is not a reproducible-build, clean-OS, target-OS, or license-compliance result']} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', type=Path, default=ROOT / 'tests/results/linux-development-package/final/docview-0.1.0-arch-x86_64-development.tar.gz') + parser.add_argument('--package-cache', type=Path, default=Path('/var/cache/pacman/pkg')) + parser.add_argument('--pdfium-root', type=Path, default=ROOT / '.deps/pdfium') + parser.add_argument('--output', type=Path, default=ROOT / 'tests/results/dependency-provenance/local-final') + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + target = args.output / 'provenance.json' + if target.exists(): + raise ValueError('Output already exists; choose a new evidence directory') + record = make_record(args.archive, args.package_cache, args.pdfium_root) + data = canonical(record) + target.write_bytes(data) + matched = sum(c['cachedPackage']['status'] == 'identity-matched-metadata' + for c in record['systemComponents'].values()) + print(json.dumps({'recordSha256': digest(data), 'payloadFileCount': record['archive']['payloadFileCount'], + 'cacheMetadataMatched': matched, 'systemComponents': len(record['systemComponents'])})) + + +if __name__ == '__main__': + main() diff --git a/tools/stage_pdfium_candidate.py b/tools/stage_pdfium_candidate.py new file mode 100644 index 0000000..58b88b8 --- /dev/null +++ b/tools/stage_pdfium_candidate.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +"""Verify and stage the fixed Linux PDFium candidate into a new CMake prefix.""" +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import secrets +import shutil +import stat +import sys + +ROOT = Path(__file__).resolve().parents[1] +MASTER = 'tests/results/pdfium-intent-build/record.json' +MASTER_SHA256 = '47b4dc2efbf8e671f904610b3047400dbd3ceb0fa10d6bd53fb04f3727943e7e' +LIBRARY_SHA256 = '0c968f503ce549bad5301af2cc3fd0b83c37437315ac0e62dff439d6a1c6cc4d' +REVISION = 'a5a7089234f121990b336b3841008009dca143bf' +SOURCE = 'build-pdfium-intent/source' +LIBRARY = SOURCE + '/out/patched/libpdfium.so' +GN_ARGS = SOURCE + '/out/patched/args.gn' +GN_ARGS_SHA256 = '5a2e04e1c0bb3eb05dc415dfa005a917804be8f63ade2365016c148e8efd8197' +LICENSE_RECORD = 'tests/results/source-correspondence/pdfium/license-correspondence.json' +LICENSE_RECORD_SHA256 = 'e579762985e5d828c413bc1ba7cb9e2551a83a0ec695f99cf0603be341ad928c' +VERSION_SHA256 = '7124a23c02e7383b7d80cc2cbc593fd8162ee536ca4ea8a85f0442f0c95c197a' +MAX_FILE = 256 * 1024 * 1024 +MAX_OUTPUT = 128 * 1024 * 1024 + +# Internal fixed choices only. The context candidate stays unavailable until its +# immutable anchor is supplied; neither the CLI nor callers can supply pin paths. +V2_PIN = { + 'master': 'tests/results/pdfium-intent-context/record.json', + 'masterSha256': '80af72b06d5c9a6a3ab8b9311373e6a23e8df28668ce575e1124350e69aa160b', + 'parent': {'path': MASTER, 'sha256': MASTER_SHA256}, + 'source': 'build-pdfium-intent-context/source', + 'library': 'build-pdfium-intent-context/source/out/patched/libpdfium.so', + 'librarySha256': 'cb049fe434f4c911b5eb047c0aca572d4dd9f5e405dde329b76c05a6aa771403', + 'gnArgs': 'build-pdfium-intent-context/source/out/patched/args.gn', + 'gnArgsSha256': GN_ARGS_SHA256, + 'schemaVersion': 6, + 'anchorName': 'master6', + 'recordPaths': { + 'candidate-patch': 'tests/results/pdfium-intent-context/candidate-patch.json', + 'source-materialization': 'tests/results/pdfium-intent-build/source-materialization.json', + 'tool-materialization': 'tests/results/pdfium-intent-build/tool-materialization.json', + 'provider-patches': 'tests/results/pdfium-intent-build/provider-patches.json', + 'patched-final-build': 'tests/results/pdfium-intent-context/build-2.json', + }, +} + + +class StageError(ValueError): + pass + + +def require(condition, message): + if not condition: + raise StageError(message) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def relative(name): + require(isinstance(name, str) and len(name) <= 1024, 'Invalid relative path') + p = PurePosixPath(name) + require(name and not p.is_absolute() and '\\' not in name and '\0' not in name + and all(x not in ('', '.', '..') for x in name.split('/')), + 'Unsafe relative path: ' + name) + return p.parts + + +def candidate_pin(candidate): + if candidate == 'v1': + # Read the original constants at call time, preserving the existing API + # and the synthetic fixture tests' independently substituted anchors. + pin = { + 'master': MASTER, 'masterSha256': MASTER_SHA256, 'parent': None, + 'source': SOURCE, 'library': LIBRARY, 'librarySha256': LIBRARY_SHA256, + 'gnArgs': GN_ARGS, 'gnArgsSha256': GN_ARGS_SHA256, + 'schemaVersion': 5, 'anchorName': 'master5', + 'recordPaths': {name: 'tests/results/pdfium-intent-build/' + name + '.json' + for name in ('candidate-patch', 'source-materialization', + 'tool-materialization', 'provider-patches', + 'patched-final-build')}, + } + elif candidate == 'v2': + pin = dict(V2_PIN) + else: + raise StageError('Unknown fixed candidate selector') + required = {'master', 'masterSha256', 'parent', 'source', 'library', + 'librarySha256', 'gnArgs', 'gnArgsSha256', 'schemaVersion', + 'anchorName', 'recordPaths'} + require(set(pin) == required, 'Incomplete fixed candidate pin') + for key in ('masterSha256', 'librarySha256', 'gnArgsSha256'): + require(isinstance(pin[key], str) and re.fullmatch('[0-9a-f]{64}', pin[key]), + 'Fixed candidate pin is not ready: ' + key) + for key in ('master', 'source', 'library', 'gnArgs'): + relative(pin[key]) + schema = 5 if candidate == 'v1' else 6 + require(type(pin['schemaVersion']) is int and pin['schemaVersion'] == schema + and pin['anchorName'] == 'master' + str(schema), 'Invalid fixed anchor identity') + require(pin['library'] == pin['source'] + '/out/patched/libpdfium.so' + and pin['gnArgs'] == pin['source'] + '/out/patched/args.gn', + 'Inconsistent fixed candidate source paths') + names = {'candidate-patch', 'source-materialization', 'tool-materialization', + 'provider-patches', 'patched-final-build'} + require(isinstance(pin['recordPaths'], dict) and set(pin['recordPaths']) == names, + 'Incomplete fixed candidate record paths') + for path in pin['recordPaths'].values(): + relative(path) + if candidate == 'v2': + parent = pin['parent'] + require(isinstance(parent, dict) and set(parent) == {'path', 'sha256'}, + 'Missing fixed parent anchor') + relative(parent['path']) + require(isinstance(parent['sha256'], str) + and re.fullmatch('[0-9a-f]{64}', parent['sha256']), + 'Fixed parent anchor is not ready') + return pin + + +def open_directory(path): + """Open each component without following symlinks, including parent paths.""" + path = Path(os.path.abspath(path)) + fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for part in path.parts[1:]: + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = nxt + return fd + except BaseException: + os.close(fd) + raise + + +class Tree: + def __init__(self, path): + self.fd = open_directory(path) + + def __enter__(self): + return self + + def __exit__(self, *_): + os.close(self.fd) + + def read(self, name, expected=None, limit=MAX_FILE): + parts = relative(name) + fd = os.dup(self.fd) + try: + for part in parts[:-1]: + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) + os.close(fd) + fd = nxt + file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=fd) + with os.fdopen(file_fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + require(stat.S_ISREG(info.st_mode) and info.st_size <= limit, + 'Not a bounded regular file: ' + name) + data = stream.read(limit + 1) + require(len(data) <= limit, 'File exceeds limit: ' + name) + if expected is not None: + require(re.fullmatch('[0-9a-f]{64}', expected) is not None, + 'Invalid SHA-256: ' + name) + require(digest(data) == expected, 'SHA-256 mismatch: ' + name) + return data + finally: + os.close(fd) + + +def json_bytes(data): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'Duplicate JSON key: ' + key) + result[key] = value + return result + return json.loads(data, object_pairs_hook=unique) + + +def undo_header_patch(data, patch, header): + """Reverse only exact header hunks; never execute a patch or its filenames.""" + target = 'public/' + header + hunks = [] + active = False + old = new = None + for line in patch.decode('utf-8').splitlines(keepends=True): + if line.startswith('diff --git '): + if old is not None: + hunks.append((old, new)) + old = new = None + active = False + elif line.startswith('+++ b/'): + active = line.removeprefix('+++ b/').rstrip('\n') == target + elif active and line.startswith('@@ '): + if old is not None: + hunks.append((old, new)) + old, new = [], [] + elif active and old is not None: + if line.startswith((' ', '-')): + old.append(line[1:].encode()) + if line.startswith((' ', '+')): + new.append(line[1:].encode()) + if old is not None: + hunks.append((old, new)) + lines = data.splitlines(keepends=True) + for old, new in reversed(hunks): + require(new, 'Empty reverse patch hunk') + positions = [i for i in range(len(lines) - len(new) + 1) + if lines[i:i + len(new)] == new] + require(len(positions) == 1, 'Header patch context mismatch: ' + header) + i = positions[0] + lines[i:i + len(new)] = old + return b''.join(lines) + + +def verify(repo=ROOT, candidate='v1'): + """Return fully verified, bounded output bytes. This performs no writes.""" + pin = candidate_pin(candidate) + output = {} + metadata = {} + total = 0 + with Tree(repo) as tree: + def add(destination, origin, expected, category, **details): + nonlocal total + relative(destination) + require(destination not in output, 'Duplicate output: ' + destination) + data = tree.read(origin, expected) + total += len(data) + require(len(output) < 256 and total <= MAX_OUTPUT, 'Output limit exceeded') + output[destination] = data + metadata[destination] = {'source': origin, 'sha256': digest(data), + 'bytes': len(data), 'category': category, **details} + return data + + master_bytes = add('provenance/' + pin['anchorName'] + '.json', + pin['master'], pin['masterSha256'], 'evidence') + master = json_bytes(master_bytes) + require(master['schemaVersion'] == pin['schemaVersion'], + 'Not the fixed ' + pin['anchorName'] + ' record') + if pin['parent']: + require(master.get('parent') == pin['parent'], 'Parent anchor identity mismatch') + add('provenance/parent-master5.json', pin['parent']['path'], + pin['parent']['sha256'], 'evidence') + candidate = master['pdfiumRenderingIntentCandidate'] + require(candidate['sourceRevision'] == REVISION + and candidate['librarySha256'] == pin['librarySha256'] + and candidate['productionDependencyReplaced'] is False, + 'Candidate does not match the independently fixed anchor') + # The old record contains an absolute source path. It is checked as data, + # never used to choose which library the tool copies. + require(candidate['library'].endswith('/' + pin['library']), 'Unexpected recorded library') + + def record(name): + path = pin['recordPaths'][name] + return json_bytes(add('provenance/' + name + '.json', path, + master['evidenceSha256'][path], 'evidence')) + + patch = record('candidate-patch') + material = record('source-materialization') + tools = record('tool-materialization') + provider = record('provider-patches') + build = record('patched-final-build') + require(patch['success'] is True and material['success'] is True + and tools['success'] is True and build['exitCode'] == 0, + 'Incomplete candidate build evidence') + require(patch['baseRevision'] == REVISION + and patch['patchSha256'] == candidate['patchSha256'] + and patch['patchSnapshot'] == candidate['patchSnapshot'], + 'Patch evidence mismatch') + add('provenance/rendering-intent.patch', candidate['patchSnapshot'], + candidate['patchSha256'], 'patch') + tree.read(candidate['patch'], candidate['patchSha256']) + add('provenance/candidate-report.json', candidate['report'], + candidate['reportSha256'], 'evidence') + for row in patch['files']: + tree.read(pin['source'] + '/' + '/'.join(relative(row['path'])), row['afterSha256']) + require(0 < len(patch['files']) <= 128, 'Invalid changed-source count') + + repositories = {row['path']: row for row in material['repositories']} + upstream = repositories['.'] + require(upstream['revision'] == REVISION + and upstream['archiveSha256'] == patch['archiveSha256'], + 'Source revision/archive mismatch') + inventory_data = tree.read(upstream['inventory'], upstream['inventorySha256'], 16 * 1024**2) + inventory = {} + for line in inventory_data.splitlines(): + row = json_bytes(line) + require(row['path'] not in inventory, 'Duplicate source inventory path') + inventory[row['path']] = row + require(len(inventory) <= 100000, 'Source inventory limit exceeded') + for tool in tools['tools']: + tree.read(tool['archive'], tool['sha256']) + require(set(tools['versions']) == {'gn', 'clang', 'lld'}, 'Missing tool versions') + args = add('args.gn', pin['gnArgs'], pin['gnArgsSha256'], 'build-options') + add('lib/libpdfium.so', pin['library'], pin['librarySha256'], 'library') + add('VERSION', '.deps/pdfium/VERSION', VERSION_SHA256, 'version') + + patches = [] + for row in provider: + require(row['exitCode'] == 0, 'Provider patch did not apply') + data = add('provenance/provider-' + PurePosixPath(row['patch']).name, + row['patch'], row['sha256'], 'patch') + patches.append(data) + headers = sorted(k for k, r in inventory.items() + if k.startswith('public/') and k.endswith('.h') + and r['archived'] and r['mode'] == '100644') + require(0 < len(headers) <= 128 and 'public/fpdfview.h' in headers, + 'Missing public header inventory') + for public in headers: + header = public.removeprefix('public/') + current = tree.read(pin['source'] + '/' + public, limit=4 * 1024**2) + original = current + for data in reversed(patches): + original = undo_header_patch(original, data, header) + require(digest(original) == inventory[public]['sha256'], + 'Header differs from fixed source and provider patches: ' + header) + add('include/' + header, '.deps/pdfium/include/' + header, + digest(current), 'header', upstreamSha256=digest(original)) + + licenses = json_bytes(add('provenance/license-correspondence.json', LICENSE_RECORD, + LICENSE_RECORD_SHA256, 'evidence')) + require(len(licenses) == 15, 'Unexpected provider notice count') + for row in licenses: + require(row['exactMatch'] is True + and row['packagedSha256'] == row['transformedSha256'], + 'Notice correspondence not established') + tree.read('tests/results/source-correspondence/pdfium/' + row['sourceFile'], + row['sourceSha256']) + if row['sourceFile'].startswith('upstream/'): + source_path = row['sourceFile'].removeprefix('upstream/') + elif row['sourceFile'].startswith('dependencies/'): + source_path = row['sourceFile'].removeprefix('dependencies/') + else: + source_path = None # Provider packaging notice, not PDFium source. + if source_path: + tree.read(pin['source'] + '/' + source_path, row['sourceSha256']) + add(row['packagedFile'], '.deps/pdfium/' + row['packagedFile'], + row['packagedSha256'], 'license', correspondence=row) + + supplement_path = 'resources/licenses/pdfium-supplemental/sources.json' + supplement = json_bytes(add('provenance/supplemental-notices.json', supplement_path, + master['sourceSha256'][supplement_path], 'evidence')) + require(supplement['pdfiumUpstreamCommit'] == REVISION, 'Supplement revision mismatch') + components = {'libc++': 'third_party/libc++/src', 'libc++abi': 'third_party/libc++abi/src'} + require(len(supplement['files']) == len(components), 'Supplement count mismatch') + for row in supplement['files']: + component = components[row['component']] + require(repositories[component]['revision'] == row['sourceRevision'], + 'Supplement dependency revision mismatch') + tree.read(pin['source'] + '/' + component + '/LICENSE.TXT', row['sha256']) + path = 'resources/licenses/pdfium-supplemental/' + row['name'] + require(master['sourceSha256'][path] == row['sha256'], 'Supplement anchor mismatch') + add('licenses/' + row['name'], path, row['sha256'], 'license', + correspondence={'sourceRevision': row['sourceRevision'], 'sourceUrl': row['sourceUrl']}) + + info = {'schemaVersion': 1, 'candidateOnly': True, 'productionDependencyReplaced': False, + 'sourceRevision': REVISION, + pin['anchorName']: {'path': pin['master'], 'sha256': pin['masterSha256']}, + 'librarySha256': pin['librarySha256'], 'patchSnapshot': candidate['patchSnapshot'], + 'patchSha256': candidate['patchSha256'], 'sourceRepositories': material['repositories'], + 'changedSources': patch['files'], 'gnArgs': args.decode('utf-8'), + 'gnArgsSha256': pin['gnArgsSha256'], 'toolVersions': tools['versions'], + 'toolArchives': tools['tools'], 'files': metadata, + 'stagerSha256': digest(Path(__file__).read_bytes()), + 'scope': 'Fixed Linux x64 CMake candidate prefix only. No build, install, publication, ' + 'human rendering acceptance, or complete license compliance is asserted.'} + if pin['parent']: + info['parent'] = dict(pin['parent']) + output['BUILDINFO.json'] = (json.dumps(info, ensure_ascii=False, indent=2) + '\n').encode() + return output, info + + +def write_files(fd, files): + for name, data in sorted(files.items()): + parts = relative(name) + parent = os.dup(fd) + try: + for part in parts[:-1]: + try: + os.mkdir(part, 0o755, dir_fd=parent) + except FileExistsError: + pass + nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + os.close(parent) + parent = nxt + mode = 0o755 if name == 'lib/libpdfium.so' else 0o644 + file_fd = os.open(parts[-1], os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + mode, dir_fd=parent) + with os.fdopen(file_fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + finally: + os.close(parent) + + +def publish(parent_fd, temporary, destination): + """Linux atomic no-replace publication: even an empty existing dir is protected.""" + libc = ctypes.CDLL(None, use_errno=True) + rename = getattr(libc, 'renameat2', None) + require(rename is not None, 'Atomic no-replace rename is unavailable') + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + if rename(parent_fd, os.fsencode(temporary), parent_fd, os.fsencode(destination), 1): + error = ctypes.get_errno() + raise OSError(error, os.strerror(error), destination) + + +def stage(output, repo=ROOT, candidate='v1'): + require(sys.platform.startswith('linux'), 'This fixed candidate is Linux-only') + output = Path(os.path.abspath(output)) + parent_fd = open_directory(output.parent) + temporary = '.pdfium-candidate-' + secrets.token_hex(12) + created = False + try: + try: + os.stat(output.name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + raise StageError('Output already exists: ' + str(output)) + files, info = verify(repo, candidate=candidate) + os.mkdir(temporary, 0o700, dir_fd=parent_fd) + created = True + fd = os.open(temporary, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd) + try: + write_files(fd, files) + os.fsync(fd) + finally: + os.close(fd) + publish(parent_fd, temporary, output.name) + created = False + os.fsync(parent_fd) + return info + finally: + if created: + shutil.rmtree(temporary, dir_fd=parent_fd) + os.close(parent_fd) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, help='New prefix; its parent must already exist') + parser.add_argument('--verify-only', action='store_true', help='Check inputs without writing a prefix') + parser.add_argument('--candidate', choices=('v1', 'v2'), default='v1', + help='Select a fixed internal candidate pin (default: v1)') + args = parser.parse_args() + if args.verify_only == bool(args.output): + parser.error('Choose exactly one of --output or --verify-only') + try: + info = (verify(candidate=args.candidate)[1] if args.verify_only + else stage(args.output, candidate=args.candidate)) + except (StageError, OSError, KeyError, TypeError, json.JSONDecodeError) as error: + print('PDFium candidate rejected: ' + str(error), file=sys.stderr) + return 1 + print(json.dumps({'verified': True, 'staged': not args.verify_only, + 'librarySha256': info['librarySha256'], + 'files': len(info['files']), 'candidateOnly': True})) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/verify_pdfium_candidate.py b/tools/verify_pdfium_candidate.py new file mode 100644 index 0000000..fc7e0a4 --- /dev/null +++ b/tools/verify_pdfium_candidate.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Verify the one reviewed Linux candidate, or the unchanged provider install. + +The reviewed-v2 lock is a repository input, never supplied by the package being +checked. An absent lock deliberately prevents candidate configuration/packaging. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +from stage_pdfium_candidate import Tree, digest, json_bytes, relative, require + +ROOT = Path(__file__).resolve().parents[1] +LOCK = 'cmake/pdfium-candidate-v2.lock.json' +DOC = 'share/doc/docview/pdfium/' +CANDIDATE = DOC + 'candidate/' +SUPPLEMENT = DOC + 'supplemental/' +MAX_METADATA = 4 * 1024 * 1024 +MAX_TOTAL = 128 * 1024 * 1024 +METADATA_PATHS = (CANDIDATE + 'BUILDINFO.json', SUPPLEMENT + 'sources.json') + + +def root_path(root): + return Path(root) if root is not None else ROOT + + +def source_metadata(root=None): + with Tree(root_path(root)) as tree: + raw = tree.read('resources/licenses/pdfium-supplemental/sources.json', limit=65536) + source = json_bytes(raw) + upstream = json_bytes(tree.read('cmake/pdfium.lock.json', limit=65536)) + require(source['pdfiumVersion'] == upstream['version'] and + source['pdfiumUpstreamCommit'] == upstream['upstreamCommit'], + 'Provider source pin differs from supplemental notices') + return raw, source + + +def reviewed_lock(root=None): + with Tree(root_path(root)) as tree: + try: + raw = tree.read(LOCK, limit=65536) + except FileNotFoundError as error: + raise ValueError('The reviewed-v2 PDFium lock is not available; candidate rejected') from error + lock = json_bytes(raw) + hashes = ('librarySha256', 'buildInfoSha256', 'anchorRecordSha256', 'patchSha256', + 'gnArgsSha256', 'supplementalSourceSha256') + require(set(lock) == {'schemaVersion', 'candidateId', 'sourceRevision', 'anchorRecordPath', *hashes} + and lock['schemaVersion'] == 1 and lock['candidateId'] == 'reviewed-v2', + 'Unexpected reviewed PDFium candidate lock') + for field in hashes: + require(isinstance(lock[field], str) and re.fullmatch('[0-9a-f]{64}', lock[field]), + 'Invalid candidate digest: ' + field) + require(isinstance(lock['sourceRevision'], str) and + re.fullmatch('[0-9a-f]{40}', lock['sourceRevision']), 'Invalid candidate revision') + relative(lock['anchorRecordPath']) + require(re.fullmatch(r'provenance/[A-Za-z0-9_-]+[.]json', lock['anchorRecordPath']), + 'Invalid candidate anchor path') + base_raw, base = source_metadata(root) + require(lock['sourceRevision'] == base['pdfiumUpstreamCommit'] and + lock['supplementalSourceSha256'] == digest(base_raw), + 'Candidate notices do not match the reviewed source pin') + return lock + + +def buildinfo(raw, lock): + require(len(raw) <= MAX_METADATA and digest(raw) == lock['buildInfoSha256'], + 'Candidate BUILDINFO differs from reviewed-v2') + info = json_bytes(raw) + require(info['schemaVersion'] == 1 and info['candidateOnly'] is True and + info['sourceRevision'] == lock['sourceRevision'] and + info['librarySha256'] == lock['librarySha256'] and + info['patchSha256'] == lock['patchSha256'] and + info['gnArgsSha256'] == lock['gnArgsSha256'], 'Candidate BUILDINFO identity mismatch') + files = info['files'] + require(isinstance(files, dict) and 1 <= len(files) <= 256, 'Invalid candidate file list') + total = 0 + for name, row in files.items(): + relative(name) + require(re.fullmatch(r'[A-Za-z0-9_./+-]+', name) and name != 'BUILDINFO.json' and + (name in ('lib/libpdfium.so', 'LICENSE', 'VERSION', 'args.gn') or + name.startswith(('include/', 'licenses/', 'provenance/'))), + 'Unexpected candidate file path') + require(isinstance(row, dict) and isinstance(row.get('sha256'), str) and + re.fullmatch('[0-9a-f]{64}', row['sha256']) and type(row.get('bytes')) is int and + 0 <= row['bytes'] <= MAX_TOTAL, 'Invalid candidate file identity') + total += row['bytes'] + require(total <= MAX_TOTAL and 'include/fpdfview.h' in files and 'LICENSE' in files and + 'VERSION' in files, 'Incomplete or oversized candidate prefix') + for path, expected in { + 'lib/libpdfium.so': lock['librarySha256'], 'args.gn': lock['gnArgsSha256'], + lock['anchorRecordPath']: lock['anchorRecordSha256'], + 'provenance/rendering-intent.patch': lock['patchSha256'], + 'provenance/supplemental-notices.json': lock['supplementalSourceSha256']}.items(): + require(files.get(path, {}).get('sha256') == expected, 'Missing candidate correspondence: ' + path) + return info + + +def installed_path(name): + relative(name) + if name == 'lib/libpdfium.so': + return name + if name == 'LICENSE' or name.startswith('licenses/'): + return DOC + name + return CANDIDATE + name + + +def candidate_supplement(lock, base_raw): + value = json_bytes(base_raw) + value['pdfiumLibrarySha256'] = lock['librarySha256'] + value['scope'] = ('Fixed-source supplemental notices for the reviewed-v2 PDFium candidate. ' + 'The provider source notice record is retained unchanged in candidate/provenance. ' + 'This is not human rendering approval or a complete license assessment.') + value['candidateCorrespondence'] = {key: lock[key] for key in + ('candidateId', 'buildInfoSha256', 'anchorRecordSha256', 'patchSha256', 'supplementalSourceSha256')} + return (json.dumps(value, indent=2, sort_keys=True) + '\n').encode() + + +def verify_rows(raw, rows, lock, installed=False): + info = buildinfo(raw, lock) + expected = {} + for name, entry in info['files'].items(): + path = installed_path(name) if installed else name + expected[path] = {'sha256': entry['sha256'], 'size': entry['bytes']} + expected[(CANDIDATE if installed else '') + 'BUILDINFO.json'] = { + 'sha256': lock['buildInfoSha256'], 'size': len(raw)} + for name, entry in expected.items(): + actual = rows.get(name, {}) + require(all(actual.get(field) == value for field, value in entry.items()), + 'Candidate file missing or changed: ' + name) + if installed: + require({p for p in rows if p.startswith(CANDIDATE)} == + {p for p in expected if p.startswith(CANDIDATE)}, 'Unregistered installed candidate metadata') + else: + require(set(rows) == set(expected), 'Unregistered candidate prefix file') + return info + + +def verify_payload(rows, contents, root=None): + """Check hashed files plus bounded metadata read from an install or a deb.""" + base_raw, base = source_metadata(root) + library_hash = rows.get('lib/libpdfium.so', {}).get('sha256') + is_candidate = any(name.startswith(CANDIDATE) for name in rows) + if is_candidate: + lock = reviewed_lock(root) + verify_rows(contents.get(CANDIDATE + 'BUILDINFO.json', b''), rows, lock, installed=True) + require(library_hash == lock['librarySha256'], 'Installed candidate library differs from reviewed-v2') + expected_raw = candidate_supplement(lock, base_raw) + identity = {'kind': 'reviewed-candidate', 'candidateId': lock['candidateId'], + 'buildInfoSha256': lock['buildInfoSha256'], + 'anchorRecordSha256': lock['anchorRecordSha256'], 'patchSha256': lock['patchSha256']} + else: + require(library_hash == base['pdfiumLibrarySha256'], + 'Unknown PDFium library or missing candidate provenance') + expected_raw = base_raw + identity = {'kind': 'original-provider'} + require(contents.get(SUPPLEMENT + 'sources.json') == expected_raw, + 'Installed supplemental notice correspondence differs from reviewed input') + require(len(base['files']) == 2 and {row['name'] for row in base['files']} == + {'libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'}, 'Unexpected supplemental notice set') + for row in base['files']: + item = rows.get(SUPPLEMENT + row['name'], {}) + require(item.get('sha256') == row['sha256'] and item.get('size') == row['size'], + 'Installed supplemental notice text differs from reviewed input') + if is_candidate: + candidate_item = rows.get(DOC + 'licenses/' + row['name'], {}) + require(candidate_item.get('sha256') == row['sha256'] and candidate_item.get('size') == row['size'], + 'Candidate notice copy differs from reviewed source') + return {**identity, 'librarySha256': library_hash, 'sourceRevision': base['pdfiumUpstreamCommit'], + 'supplementalManifestSha256': digest(expected_raw)} + + +def files_beneath(path): + """Finite list without following any directory or file links.""" + pending, files, count = [Path(path)], [], 0 + while pending: + directory = pending.pop() + with os.scandir(directory) as entries: + for entry in entries: + count += 1 + require(count <= 512, 'Candidate file count exceeds limit') + require(not entry.is_symlink(), 'Candidate links are not accepted') + if entry.is_dir(follow_symlinks=False): pending.append(Path(entry.path)) + else: + require(entry.is_file(follow_symlinks=False), 'Candidate special file rejected') + files.append(Path(entry.path).relative_to(path).as_posix()) + return sorted(files) + + +def verify_prefix(prefix, library, include_dir, root=None): + prefix = Path(os.path.abspath(prefix)) + require(Path(os.path.abspath(library)) == prefix / 'lib/libpdfium.so' and + Path(os.path.abspath(include_dir)) == prefix / 'include', + 'CMake PDFium library/headers differ from the selected candidate prefix') + lock = reviewed_lock(root) + rows = {} + with Tree(prefix) as tree: + raw = tree.read('BUILDINFO.json', limit=MAX_METADATA) + info = buildinfo(raw, lock) + names = files_beneath(prefix) + require(set(names) == set(info['files']) | {'BUILDINFO.json'}, 'Unregistered candidate prefix file') + for name in names: + data = tree.read(name, limit=MAX_TOTAL) + rows[name] = {'sha256': digest(data), 'size': len(data)} + verify_rows(raw, rows, lock) + base_raw, base = source_metadata(root) + for row in base['files']: + require(rows.get('licenses/' + row['name']) == {'sha256': row['sha256'], 'size': row['size']}, + 'Candidate supplemental notice differs from fixed source') + return {'candidateId': lock['candidateId'], 'librarySha256': lock['librarySha256'], + 'buildInfoSha256': lock['buildInfoSha256'], + 'installFiles': [{'source': name, 'destination': installed_path(name)} + for name in sorted(info['files']) + if name != 'lib/libpdfium.so' and name != 'LICENSE' and not name.startswith('licenses/')] + + [{'source': 'BUILDINFO.json', 'destination': CANDIDATE + 'BUILDINFO.json'}]}, \ + candidate_supplement(lock, base_raw) + + +def verify_installed(prefix, root=None): + prefix = Path(os.path.abspath(prefix)) + rows, contents, total = {}, {}, 0 + with Tree(prefix) as tree: + paths = ['lib/libpdfium.so'] + [DOC + name for name in files_beneath(prefix / DOC)] + for name in paths: + data = tree.read(name, limit=MAX_TOTAL) + total += len(data) + require(total <= MAX_TOTAL, 'Installed PDFium correspondence exceeds limit') + rows[name] = {'sha256': digest(data), 'size': len(data)} + if name in METADATA_PATHS: + require(len(data) <= MAX_METADATA, 'Installed PDFium metadata exceeds limit') + contents[name] = data + return verify_payload(rows, contents, root) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', type=Path, required=True) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--include-dir', type=Path, required=True) + parser.add_argument('--notice-output', type=Path, required=True) + args = parser.parse_args() + try: + report, supplemental = verify_prefix(args.prefix, args.library, args.include_dir) + # Only CMake's own generated metadata is written, after all inputs pass. + args.notice_output.parent.mkdir(parents=True, exist_ok=True) + args.notice_output.write_bytes(supplemental) + print(json.dumps(report)) + return 0 + except (ValueError, OSError, KeyError, TypeError) as error: + print('PDFium candidate rejected: ' + str(error), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/verify_ubuntu_package.py b/tools/verify_ubuntu_package.py new file mode 100644 index 0000000..2d8a429 --- /dev/null +++ b/tools/verify_ubuntu_package.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Read a local DocView deb and verify every data/control payload before installation.""" +import argparse +import hashlib +import json +import re +from pathlib import Path +import subprocess +import tarfile +import threading + +from package_ubuntu import MANIFEST, canonical, sha +from verify_pdfium_candidate import METADATA_PATHS as PDFIUM_METADATA_PATHS, MAX_METADATA, verify_payload as verify_pdfium_payload + +RUNTIME_RECORD = 'share/doc/docview/third-party/runtime/runtime.json' +METADATA_PATHS = (*PDFIUM_METADATA_PATHS, RUNTIME_RECORD) + + +def inspect(archive, flag): + rows, manifest, total, metadata = {}, None, 0, {} + process = subprocess.Popen(['dpkg-deb', flag, str(archive)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + timer = threading.Timer(180, process.kill); timer.start() + try: + with tarfile.open(fileobj=process.stdout, mode='r|') as tar: + for member in tar: + if member.name == '.' and member.isdir(): continue + name = str(canonical(member.name.removeprefix('./').rstrip('/'))) + if member.uid or member.gid: + raise ValueError('Non-root archive ownership') + if member.isdir(): continue + if name in rows or len(rows) >= 10000 or not member.isfile() or member.mode not in (0o644, 0o755): + raise ValueError('Unsafe or duplicate deb payload entry: ' + name + ' mode=' + oct(member.mode)) + total += member.size + if member.size > 512 * 1024**2 or total > 2 * 1024**3: + raise ValueError('Deb payload exceeds inspection limits') + digest = hashlib.sha256(); chunks = [] + with tar.extractfile(member) as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + if name == MANIFEST or name.removeprefix('opt/docview/') in METADATA_PATHS or (flag == '--ctrl-tarfile' and name == 'control'): + maximum = 8 * 1024**2 if name == MANIFEST else (65536 if name == 'control' else MAX_METADATA) + if member.size > maximum: raise ValueError('Oversized manifest or PDFium correspondence') + chunks.append(chunk) + rows[name] = {'path': name, 'size': member.size, 'sha256': digest.hexdigest(), 'mode': oct(member.mode)} + if name == MANIFEST: + manifest = json.loads(b''.join(chunks)) + elif flag == '--ctrl-tarfile' and name == 'control': + metadata['control'] = b''.join(chunks) + elif name.removeprefix('opt/docview/') in METADATA_PATHS: + metadata[name.removeprefix('opt/docview/')] = b''.join(chunks) + if process.wait(timeout=15): + raise ValueError('dpkg-deb failed reading package') + finally: + timer.cancel() + if process.poll() is None: process.kill(); process.wait() + process.stdout.close() + process.stderr.close() + return rows, manifest, metadata + + + +def verify_qpdf_payload(data, metadata, manifest): + """Bind qpdf notice claims to fixed source identities and actual bytes. + + Earlier deficient archives remain historical evidence. Raw inventory can + compare them, but this current verifier does not certify their omission. + """ + summary = manifest.get('qpdfNoticeCorrespondence') + from collect_ubuntu_validation_runtime import QPDF_NOTICE_PIN as pin + if not isinstance(summary, dict) or summary.get('status') != 'verified': + raise ValueError('Missing verified qpdf notice correspondence') + try: + runtime = json.loads(metadata[RUNTIME_RECORD]) + except (KeyError, ValueError, TypeError) as error: + raise ValueError('Missing or invalid packaged runtime record') from error + if runtime.get('qpdfNoticeCorrespondence') != summary: + raise ValueError('qpdf correspondence differs between manifest and runtime record') + for key in ('version', 'archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256'): + if summary.get(key) != pin[key]: + raise ValueError('qpdf fixed source correspondence differs: ' + key) + source_root = summary.get('sourceRoot') + if not isinstance(source_root, str) or not re.fullmatch(r'source-[0-9]{1,3}:', source_root): + raise ValueError('Invalid qpdf source root identity') + library = summary.get('library') + if not isinstance(library, dict): + raise ValueError('Missing qpdf library identity') + path = library.get('path', '') + if not isinstance(path, str) or not re.fullmatch(r'dependencies:lib/libqpdf[.]so(?:[.][0-9]+)*', path): + raise ValueError('Invalid qpdf runtime path') + library_name = 'opt/docview/' + path.split(':', 1)[1] + def matches(row, expected): + return isinstance(row, dict) and all(row.get(key) == expected[key] for key in ('sha256', 'size')) + if not matches(library, pin['library']) or not matches(data.get(library_name), pin['library']): + raise ValueError('Packaged qpdf library differs from the reviewed runtime') + # The inspected executables load SONAME libqpdf.so.30. A correct, unused + # alias cannot authorize a different library at that actual loader path. + if not matches(data.get('opt/docview/lib/libqpdf.so.30'), pin['library']): + raise ValueError('Packaged qpdf SONAME library differs from the reviewed runtime') + for name, row in data.items(): + if re.fullmatch(r'opt/docview/lib/libqpdf[.]so(?:[.][0-9]+)*', name) and not matches(row, pin['library']): + raise ValueError('Conflicting packaged qpdf runtime alias') + notices = summary.get('notices') + if not isinstance(notices, list) or len(notices) != len(pin['notices']): + raise ValueError('Missing qpdf source notices') + by_name = {row.get('source'): row for row in notices if isinstance(row, dict)} + if len(by_name) != len(notices): + raise ValueError('Invalid or duplicate qpdf source notice') + for source, expected in pin['notices'].items(): + row = by_name.get(source) + expected_path = 'notices/' + expected['sha256'] + '.txt' + if not matches(row, expected) or row.get('copiedPath') != expected_path: + raise ValueError('qpdf source notice correspondence differs') + name = 'opt/docview/share/doc/docview/third-party/runtime/' + expected_path + if not matches(data.get(name), expected): + raise ValueError('Packaged qpdf source notice is absent or changed') + return summary + + +def verify(archive): + data, manifest, metadata = inspect(archive, '--fsys-tarfile') + controls, _, control_metadata = inspect(archive, '--ctrl-tarfile') + actual = dict(data) + actual.pop(MANIFEST) + actual.update({'DEBIAN/' + name: dict(row, path='DEBIAN/' + name) for name, row in controls.items()}) + if not manifest or manifest['schemaVersion'] != 1 or manifest['package'] != 'docview': + raise ValueError('Missing or invalid DocView manifest') + control_text = control_metadata.get('control', b'').decode('utf-8', 'strict') + for field, expected_value in [('Package', 'docview'), ('Version', manifest.get('version'))]: + values = re.findall(r'^' + field + r': ([^\r\n]+)$', control_text, re.M) + if not isinstance(expected_value, str) or values != [expected_value]: + raise ValueError('Deb control identity differs from manifest: ' + field) + expected = {str(canonical(row['path'])): row for row in manifest['files']} + if len(expected) != len(manifest['files']) or actual != expected: + raise ValueError('Deb payload and manifest differ') + pdfium = verify_pdfium_payload({name.removeprefix('opt/docview/'): row for name, row in data.items() + if name.startswith('opt/docview/')}, metadata) + # Legacy provider packages predate this field; they still undergo the same + # pinned library/notice check. A candidate never gets that legacy exception. + if (pdfium['kind'] == 'reviewed-candidate' or 'pdfiumCorrespondence' in manifest) and manifest.get('pdfiumCorrespondence') != pdfium: + raise ValueError('Deb PDFium correspondence summary differs from reviewed payload') + archive_sha = sha(archive) + qpdf = verify_qpdf_payload(data, metadata, manifest) + return {'success': True, 'archiveSha256': archive_sha, 'archiveBytes': archive.stat().st_size, + 'filesVerified': len(actual) + 1, 'dataFiles': len(data), 'controlFiles': len(controls), + 'packageManifestSha256': data[MANIFEST]['sha256'], 'rootOwnership': True, + 'linksOrSpecialFiles': False, 'allSizesModesAndHashesMatch': True, + 'pdfiumCorrespondence': pdfium, 'qpdfNoticeCorrespondence': qpdf} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + report = verify(args.archive.resolve(strict=True)) + with args.output.open('x') as stream: + json.dump(report, stream, indent=2); stream.write('\n') + print(json.dumps(report)) + + +if __name__ == '__main__': + main()