diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a37fc68..ac8ce757 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,8 +70,8 @@ jobs: - name: Prepare bundled Python seed shell: pwsh run: | - $installer = Join-Path $PWD "python-3.11.0-amd64.exe" - $target = Join-Path $PWD "python311" + $installer = Join-Path $env:RUNNER_TEMP "python-3.11.0-amd64.exe" + $target = Join-Path $env:RUNNER_TEMP "latexsnipper-python-seed" Invoke-WebRequest ` -Uri "https://www.python.org/ftp/python/3.11.0/python-3.11.0-amd64.exe" ` -OutFile $installer @@ -93,6 +93,7 @@ jobs: "PrependPath=0", "Shortcuts=0" ) ` + -WindowStyle Hidden ` -Wait ` -PassThru if ($process.ExitCode -ne 0) { @@ -102,11 +103,13 @@ jobs: throw "Bundled Python seed was not created: $target" } Remove-Item -LiteralPath $installer -Force - & (Join-Path $target "python.exe") -m pip install --upgrade pip wheel + & (Join-Path $target "python.exe") -m pip install --upgrade pip wheel setuptools packaging + if ($LASTEXITCODE -ne 0) { throw "Bundled Python tooling installation failed." } + "LATEXSNIPPER_BUNDLED_PYTHON=$target" >> $env:GITHUB_ENV - name: Build unsigned installer shell: pwsh - run: powershell -ExecutionPolicy Bypass -File scripts\build_github_release_installer.ps1 -PythonPath python + run: ./scripts/build_github_release_installer.ps1 -BundledPythonPath $env:LATEXSNIPPER_BUNDLED_PYTHON - name: Upload unsigned installer artifact id: upload-unsigned diff --git a/.gitignore b/.gitignore index c49fc26c..d15207a5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,20 +28,9 @@ office_plugin/release/InstallerAssets/ *.tar *.gz -# Embedded runtime (do not version) -tools/deps/python311/ -tools/deps/python311-*/ - -# Local runtime components (do not version) -/python311/ -python-*.exe - # Logs *.log -# Local app state -tools/deps/.deps_state.json - # Local OCR/PDF regression outputs test_pdf/outputs/** !test_pdf/outputs/ @@ -54,3 +43,6 @@ user_manual/* !user_manual/*.typ !user_manual/*.md !user_manual/*.png + +# Local editor settings +.vscode/ diff --git a/.pylintrc b/.pylintrc index d6d578d6..5d519b27 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,6 +1,6 @@ [MASTER] jobs=0 -ignore-patterns=^\.?#,.*__pycache__.*,.*\.venv.*,.*python311.*,.*build.* +ignore-patterns=^\.?#,.*__pycache__.*,.*\.venv.*,.*build.* [MESSAGES CONTROL] disable=all diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 619fe78d..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "git.ignoreLimitWarning": true, - "python-envs.defaultEnvManager": "ms-python.python:system", - "python.defaultInterpreterPath": "${workspaceFolder}\\tools\\deps\\python311\\python.exe" -} diff --git a/Inno/latexsnipper.iss b/Inno/latexsnipper.iss index 07c6887c..44dd120b 100644 --- a/Inno/latexsnipper.iss +++ b/Inno/latexsnipper.iss @@ -5,11 +5,7 @@ #define MyAppPublisher "MathCraft" #define MyAppURL "https://github.com/SakuraMathcraft/LaTeXSnipper" #define MyAppExeName "LaTeXSnipper.exe" -#if GetEnv("LATEXSNIPPER_REPO_ROOT") != "" -#define MyRepoRoot GetEnv("LATEXSNIPPER_REPO_ROOT") -#else -#define MyRepoRoot "E:\LaTexSnipper" -#endif +#define MyRepoRoot AddBackslash(SourcePath) + ".." #define MyBuildDir MyRepoRoot + "\dist\LaTeXSnipper" #define MyOutputDir MyRepoRoot + "\dist\installer" #define MyLicenseFile MyRepoRoot + "\LICENSE" diff --git a/LaTeXSnipper.spec b/LaTeXSnipper.spec index 877e55a9..05c7ccab 100644 --- a/LaTeXSnipper.spec +++ b/LaTeXSnipper.spec @@ -26,7 +26,6 @@ sys.path.insert(0, str(SRC)) from runtime.product_version import PRODUCT_VERSION, write_windows_version_info APP_NAME = os.environ.get("LATEXSNIPPER_BUILD_NAME", "LaTeXSnipper") -BUNDLED_DEPS_DIR_ENV = os.environ.get("LATEXSNIPPER_BUNDLED_DEPS_DIR", "").strip() VERSION_INFO_PATH = ROOT / "build" / "version_info.txt" write_windows_version_info(VERSION_INFO_PATH, PRODUCT_VERSION) @@ -337,21 +336,16 @@ def _prune_qt_webengine_payload(dist_root: Path): _remove_optional_qt_plugins(plugins_dir, removable_plugins) -def _resolve_bundled_deps_root() -> Path: - if BUNDLED_DEPS_DIR_ENV: - return Path(BUNDLED_DEPS_DIR_ENV).expanduser() - return ROOT +# The release workflow prepares a clean seed outside the checkout. +bundled_python = os.environ.get("LATEXSNIPPER_BUNDLED_PYTHON", "").strip() +if not bundled_python: + raise RuntimeError("LATEXSNIPPER_BUNDLED_PYTHON must identify the CI Python seed") +bundled_python = Path(bundled_python).resolve() +if not (bundled_python / "python.exe").is_file(): + raise RuntimeError(f"Bundled Python seed is missing python.exe: {bundled_python}") +extra_datas += _collect_tree_as_datas(bundled_python, "deps/python311") -# Bundle dependency runtime. -BUNDLED_DEPS_ROOT = _resolve_bundled_deps_root() -BUNDLED_PY311 = BUNDLED_DEPS_ROOT / "python311" -if BUNDLED_PY311.exists(): - extra_datas += _collect_tree_as_datas(BUNDLED_PY311, "deps/python311") - print(f"[SPEC] include bundled python311: {BUNDLED_PY311}") -else: - print(f"[SPEC] bundled python311 not found, skip: {BUNDLED_PY311}") - a = Analysis( [str(SRC / "main.py")], pathex=[str(SRC), str(ROOT)], diff --git a/README.zh-CN.md b/README.zh-CN.md index 1d1d2479..66d83750 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,7 +14,7 @@ [English](readme.md) · 简体中文 -LaTeXSnipper v3.0.0:识别历史、公式编辑器与实时预览 +LaTeXSnipper:识别历史、公式编辑器与实时预览 diff --git a/README_MATHCRAFT_OCR.md b/README_MATHCRAFT_OCR.md index 9afdea87..b9eb46fc 100644 --- a/README_MATHCRAFT_OCR.md +++ b/README_MATHCRAFT_OCR.md @@ -98,9 +98,9 @@ mathcraft warmup --profile mixed --provider auto Recognize an image: ```powershell -mathcraft ocr "C:\path\to\page.png" --profile mixed --provider auto --output result.md -mathcraft ocr "C:\path\to\page.png" --profile mixed --provider auto --output-dir "D:\MathCraft\outputs" -mathcraft ocr "C:\path\to\formula.png" --profile formula --provider auto --json +mathcraft ocr "./examples/page.png" --profile mixed --provider auto --output result.md +mathcraft ocr "./examples/page.png" --profile mixed --provider auto --output-dir "./outputs" +mathcraft ocr "./examples/formula.png" --profile formula --provider auto --json ``` Run JSONL worker mode: @@ -122,14 +122,14 @@ Linux: ${XDG_DATA_HOME:-~/.local/share}/LaTeXSnipper/MathCraft/models or from a custom root: ```powershell -$env:MATHCRAFT_HOME="D:\MathCraft\models" +$env:MATHCRAFT_HOME="./models" mathcraft doctor --provider auto ``` Persist the custom root for future PowerShell sessions: ```powershell -setx MATHCRAFT_HOME "D:\MathCraft\models" +setx MATHCRAFT_HOME "./models" ``` Restore the default user cache root: @@ -198,7 +198,7 @@ The actual provider is available on recognition results through the `provider` f Run tests from the repository root: ```powershell -cd E:\LaTexSnipper +# Run from the repository root python .\test\test_mathcraft_ocr.py python .\test\test_mathcraft_document_engine.py ``` @@ -206,7 +206,7 @@ python .\test\test_mathcraft_document_engine.py Build package artifacts: ```powershell -cd E:\LaTexSnipper +# Run from the repository root python -m build --no-isolation --outdir .\release_assets\mathcraft-ocr-package\dist . ``` diff --git a/benchmarks/mathcraft_ocr/README.md b/benchmarks/mathcraft_ocr/README.md index 0f605d61..3f433a5d 100644 --- a/benchmarks/mathcraft_ocr/README.md +++ b/benchmarks/mathcraft_ocr/README.md @@ -25,7 +25,7 @@ Third-party systems may appear only in related work or public-result context, wi Keep external datasets and large run artifacts outside the repository: ```text -E:\MathCraftBenchData +./benchmark-data ``` Manifest files live under: @@ -45,16 +45,16 @@ images, and dataset-provided normalized LaTeX labels as ground truth. Run MathCraft GPU: ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 -DataRoot ./benchmark-data ``` The runner creates: ```text -E:\MathCraftBenchData\manifests\mathwriting_test.jsonl -E:\MathCraftBenchData\raw\mathwriting\test_images -E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl -E:\MathCraftBenchData\runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json +./benchmark-data\manifests\mathwriting_test.jsonl +./benchmark-data\raw\mathwriting\test_images +./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl +./benchmark-data\runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json ``` It also writes exact, BLEU-4, normalized edit distance, and latency summaries @@ -62,15 +62,15 @@ after the full JSONL is combined. Render success is computed separately: ```powershell python benchmarks\mathcraft_ocr\reports\render_formula_success.py ` - --results E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\mathwriting_test.jsonl ` - --output-dir E:\MathCraftBenchData\runs\mathwriting_test_gpu\render_success + --results ./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\mathwriting_test.jsonl ` + --output-dir ./benchmark-data\runs\mathwriting_test_gpu\render_success ``` Run official CDM on MathWriting predictions: ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 -DataRoot ./benchmark-data ``` Baseline comparisons must be restricted to the same split, normalized label, and @@ -79,7 +79,7 @@ offline rasterization protocol. ## UniMER-Test ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_unimer_test.ps1 +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_unimer_test.ps1 -DataRoot ./benchmark-data ``` The script downloads UniMER-Test from Hugging Face when needed, creates the manifest, @@ -90,18 +90,18 @@ Analyze text metrics: ```powershell python benchmarks\mathcraft_ocr\reports\analyze_unimer_results.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\unimer_test_gpu + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output-dir ./benchmark-data\runs\unimer_test_gpu ``` Analyze render-consistency fallback samples: ```powershell python benchmarks\mathcraft_ocr\reports\render_unimer_samples.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output-dir ./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated ` --per-subset 20 ` --min-similarity 0.95 ` --keep-images @@ -111,9 +111,9 @@ Prepare official CDM batch JSON input: ```powershell python benchmarks\mathcraft_ocr\reports\prepare_cdm_input.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` --subset all ``` @@ -123,21 +123,21 @@ The generated JSON follows the official CDM batch fields `img_id`, `gt`, and Run the official UniMERNet CDM runtime with resumable shards: ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_official_cdm.ps1 +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_official_cdm.ps1 -DataRoot ./benchmark-data ``` The CDM runner uses the official `evaluation.py` from the local UniMERNet clone -under `E:\MathCraftBenchData\sources\UniMERNet_official\cdm`. It intentionally +under `./benchmark-data\sources\UniMERNet_official\cdm`. It intentionally uses the system Python configured for that official runtime; MathCraft OCR -inference remains on `tools\deps\python311\python.exe`. The default official +inference remains on the developer-selected Python interpreter. The default official CDM pool count is 8 on the local Windows evaluation host. For a quick validation run: ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_official_cdm.ps1 ` - -InputPath E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_all_20_cdm.json ` - -OutputDir E:\MathCraftBenchData\runs\cdm_official_unimer_20_runner ` +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_official_cdm.ps1 -DataRoot ./benchmark-data ` + -InputPath ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_all_20_cdm.json ` + -OutputDir ./benchmark-data\runs\cdm_official_unimer_20_runner ` -ShardSize 10 ` -Limit 20 ` -Pools 8 @@ -155,7 +155,7 @@ render skips are not confused with interrupted runs. The full official CDM UniMER-Test run is complete: ```text -Run: E:\MathCraftBenchData\runs\cdm_official_unimer_full +Run: ./benchmark-data\runs\cdm_official_unimer_full Mean CDM: 0.929 ExpRate: 0.648 Expected: 23757 @@ -170,7 +170,7 @@ benchmarks/mathcraft_ocr/results/unimer_test_gpu ``` Large JSONL files, downloaded data, and rendered PNG pairs remain under -`E:\MathCraftBenchData`. +`./benchmark-data`. ## OpenStax Page Images @@ -181,7 +181,7 @@ for exact-match formula accuracy because no page-level ground truth is included. Run the default systematic mixed-page benchmark: ```powershell -powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_openstax_pages.ps1 +powershell -ExecutionPolicy Bypass -File benchmarks\mathcraft_ocr\run_openstax_pages.ps1 -DataRoot ./benchmark-data ``` The runner creates a manifest, renders selected OpenStax PDF pages, runs mixed @@ -205,10 +205,14 @@ Generate OpenStax block-overlay figure assets from the formal run: ```powershell python benchmarks\mathcraft_ocr\visualization\make_openstax_block_gallery.py ` - --results E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` + --results ./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` + --output-dir ./benchmark-data\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` --limit 6 ``` -Generated OpenStax page images and overlays remain under `E:\MathCraftBenchData` +Generated OpenStax page images and overlays remain under `./benchmark-data` because they are derived from licensed OpenStax PDF content. + +## Portable benchmark environment + +Run examples from the repository root. `./benchmark-data` is an example external-data directory; use `-DataRoot` to choose its location. The PowerShell runners resolve the checkout from the script location and use the active `python`; inference runners accept `-PythonPath`, and CDM runners accept `-Python`. Install CDM tools on PATH or pass `-PathPrepend` with your own tool directories. No drive letter or developer environment is required. diff --git a/benchmarks/mathcraft_ocr/REPRODUCE_MATHWRITING.md b/benchmarks/mathcraft_ocr/REPRODUCE_MATHWRITING.md index f15a9d0b..d85aad92 100644 --- a/benchmarks/mathcraft_ocr/REPRODUCE_MATHWRITING.md +++ b/benchmarks/mathcraft_ocr/REPRODUCE_MATHWRITING.md @@ -8,7 +8,7 @@ Large parquet files, exported images, full JSONL results, render outputs, and official CDM outputs stay under: ```text -E:\MathCraftBenchData +./benchmark-data ``` ## Inputs @@ -16,7 +16,7 @@ E:\MathCraftBenchData Expected local parquet: ```text -E:\MathCraftBenchData\raw\mathwriting\test-00000-of-00001-694f317d8b634199.parquet +./benchmark-data\raw\mathwriting\test-00000-of-00001-694f317d8b634199.parquet ``` The manifest generator validates: @@ -29,14 +29,14 @@ data_type = human Generated fixed-test assets: ```text -E:\MathCraftBenchData\manifests\mathwriting_test.jsonl -E:\MathCraftBenchData\raw\mathwriting\test_images +./benchmark-data\manifests\mathwriting_test.jsonl +./benchmark-data\raw\mathwriting\test_images ``` ## Run MathCraft GPU ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 -DataRoot ./benchmark-data ``` Defaults: @@ -52,20 +52,20 @@ Progress: Write-Progress plus per-shard ETA Formal output after a full run: ```text -E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl -E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_subset_metrics.csv -E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_report.md -E:\MathCraftBenchData\runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json +./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl +./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_subset_metrics.csv +./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_report.md +./benchmark-data\runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json ``` ## Render Success ```powershell -E:\LaTexSnipper\tools\deps\python311\python.exe ` - E:\LaTexSnipper\benchmarks\mathcraft_ocr\reports\render_formula_success.py ` - --results E:\MathCraftBenchData\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\mathwriting_test.jsonl ` - --output-dir E:\MathCraftBenchData\runs\mathwriting_test_gpu\render_success +python ` + .\benchmarks\mathcraft_ocr\reports\render_formula_success.py ` + --results ./benchmark-data\runs\mathwriting_test_gpu\mathwriting_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\mathwriting_test.jsonl ` + --output-dir ./benchmark-data\runs\mathwriting_test_gpu\render_success ``` ## Official CDM @@ -73,13 +73,13 @@ E:\LaTexSnipper\tools\deps\python311\python.exe ` The MathCraft run creates official CDM input automatically. Then run: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 -DataRoot ./benchmark-data ``` For a bounded CDM validation: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 ` +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_mathwriting_official_cdm.ps1 -DataRoot ./benchmark-data ` -Limit 100 ` -ShardSize 20 ` -Pools 8 diff --git a/benchmarks/mathcraft_ocr/REPRODUCE_OPENSTAX.md b/benchmarks/mathcraft_ocr/REPRODUCE_OPENSTAX.md index 62d893ca..9ad4c456 100644 --- a/benchmarks/mathcraft_ocr/REPRODUCE_OPENSTAX.md +++ b/benchmarks/mathcraft_ocr/REPRODUCE_OPENSTAX.md @@ -7,7 +7,7 @@ evidence. They do not provide formula-level ground truth labels. ## Default Systematic Run ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 -DataRoot ./benchmark-data ``` Default settings: @@ -25,17 +25,17 @@ command. ## Outputs ```text -E:\MathCraftBenchData\manifests\openstax_mixed_gpu_144dpi.jsonl -E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl -E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_report.md -E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_summary.csv -E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_page_metrics.csv +./benchmark-data\manifests\openstax_mixed_gpu_144dpi.jsonl +./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl +./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_report.md +./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_summary.csv +./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_page_metrics.csv ``` Small source-control evidence tables are mirrored to: ```text -E:\LaTexSnipper\benchmarks\mathcraft_ocr\results\openstax_mixed_gpu_144dpi +.\benchmarks\mathcraft_ocr\results\openstax_mixed_gpu_144dpi ``` Current formal run summary: @@ -52,20 +52,20 @@ Current formal run summary: Generate paper-oriented block overlay panels from the formal run: ```powershell -E:\LaTexSnipper\tools\deps\python311\python.exe ` - E:\LaTexSnipper\benchmarks\mathcraft_ocr\visualization\make_openstax_block_gallery.py ` - --results E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` +python ` + .\benchmarks\mathcraft_ocr\visualization\make_openstax_block_gallery.py ` + --results ./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` + --output-dir ./benchmark-data\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` --limit 6 ``` -The generated PNG panels and HTML gallery remain in `E:\MathCraftBenchData` +The generated PNG panels and HTML gallery remain in `./benchmark-data` because the page images are rendered from OpenStax PDFs. ## Smaller Trial ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 ` +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 -DataRoot ./benchmark-data ` -Pages "calculus_v1:120-124,college_algebra:80-84" ` -RunId "openstax_trial_gpu_144dpi" ``` @@ -75,10 +75,10 @@ powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_oc Use explicit page ranges and keep `-ShardSize 1` for best resume behavior: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 ` +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_openstax_pages.ps1 -DataRoot ./benchmark-data ` -Pages "calculus_v1:20-219,college_algebra:20-219" ` -RunId "openstax_400pages_gpu_144dpi" ``` Large JSONL outputs, rendered page images, and downloaded PDFs remain under -`E:\MathCraftBenchData` and should not be committed. +`./benchmark-data` and should not be committed. diff --git a/benchmarks/mathcraft_ocr/REPRODUCE_UNIMER.md b/benchmarks/mathcraft_ocr/REPRODUCE_UNIMER.md index 5fffb35b..6456d05b 100644 --- a/benchmarks/mathcraft_ocr/REPRODUCE_UNIMER.md +++ b/benchmarks/mathcraft_ocr/REPRODUCE_UNIMER.md @@ -7,14 +7,14 @@ paper tables. Large datasets and run outputs are kept outside the repository. - Windows 11 - TeX Live 2024 for render checks -- Python runtime: `tools\deps\python311\python.exe` +- Python runtime: the developer-selected Python interpreter - ONNX provider requested: `gpu` - Observed active provider: `CUDAExecutionProvider` Install benchmark-only metric dependencies into the development runtime: ```powershell -.\tools\deps\python311\python.exe -m pip install rapidfuzz evaluate +python -m pip install rapidfuzz evaluate ``` ## Data Root @@ -22,7 +22,7 @@ Install benchmark-only metric dependencies into the development runtime: Use a local data root: ```text -E:\MathCraftBenchData +./benchmark-data ``` The scripts download `UniMER-Test.zip` from the public Hugging Face dataset @@ -31,7 +31,7 @@ The scripts download `UniMER-Test.zip` from the public Hugging Face dataset ## Run MathCraft OCR ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_unimer_test.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_unimer_test.ps1 -DataRoot ./benchmark-data ``` The runner is sharded and resumable. Completed shard files are skipped; partial @@ -40,16 +40,16 @@ shards are rerun. Main output: ```text -E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl +./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ``` ## Text Metrics ```powershell -.\tools\deps\python311\python.exe benchmarks\mathcraft_ocr\reports\analyze_unimer_results.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\unimer_test_gpu +python benchmarks\mathcraft_ocr\reports\analyze_unimer_results.py ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output-dir ./benchmark-data\runs\unimer_test_gpu ``` This generates BLEU-4, normalized edit distance, exact-match, similarity, and @@ -60,10 +60,10 @@ latency tables. This is a local render-consistency fallback for paper analysis, not CDM. ```powershell -.\tools\deps\python311\python.exe benchmarks\mathcraft_ocr\reports\render_unimer_samples.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated ` +python benchmarks\mathcraft_ocr\reports\render_unimer_samples.py ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output-dir ./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated ` --per-subset 20 ` --min-similarity 0.95 ` --keep-images @@ -72,10 +72,10 @@ This is a local render-consistency fallback for paper analysis, not CDM. Create an HTML gallery: ```powershell -.\tools\deps\python311\python.exe benchmarks\mathcraft_ocr\visualization\make_render_gallery.py ` - --rows E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated\unimer_render_consistency_rows.csv ` - --image-dir E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated\rendered_pairs ` - --output E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated\render_gallery.html ` +python benchmarks\mathcraft_ocr\visualization\make_render_gallery.py ` + --rows ./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated\unimer_render_consistency_rows.csv ` + --image-dir ./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated\rendered_pairs ` + --output ./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated\render_gallery.html ` --limit 40 ``` @@ -85,20 +85,20 @@ CDM expects a batch JSON file with `img_id`, `gt`, and `pred` fields. Convert th full MathCraft UniMER-Test run to that schema: ```powershell -.\tools\deps\python311\python.exe benchmarks\mathcraft_ocr\reports\prepare_cdm_input.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` +python benchmarks\mathcraft_ocr\reports\prepare_cdm_input.py ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` --subset all ``` Subset conversion is also supported: ```powershell -.\tools\deps\python311\python.exe benchmarks\mathcraft_ocr\reports\prepare_cdm_input.py ` - --results E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --output E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_cpe_cdm.json ` +python benchmarks\mathcraft_ocr\reports\prepare_cdm_input.py ` + --results ./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --output ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_cpe_cdm.json ` --subset cpe ``` @@ -111,31 +111,30 @@ dependencies; do not label local fallback metrics as CDM. Run the official UniMERNet CDM evaluator through the resumable benchmark entry: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_official_cdm.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_official_cdm.ps1 -DataRoot ./benchmark-data ``` Defaults: ```text -Input: E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json -Output: E:\MathCraftBenchData\runs\cdm_official_unimer_full -CDM code: E:\MathCraftBenchData\sources\UniMERNet_official\cdm -Python: D:\Python312\python.exe +Input: ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json +Output: ./benchmark-data\runs\cdm_official_unimer_full +CDM code: ./benchmark-data\sources\UniMERNet_official\cdm +Python: python Pools: 8 ShardSize: 100 ``` -`tools\deps\python311` is the MathCraft development/runtime environment. The -official CDM clone is evaluated with the system Python above because the -isolated `tools\deps` runtime does not expose the official CDM source tree on -`sys.path`. +MathCraft inference uses the selected development environment. The official CDM +runner can use a separate environment with its own dependencies and source tree. +Pass -PythonPath to select an interpreter for the benchmark runners. The runner is resumable. A shard is skipped only when it has a local completion marker from a successful official CDM process. It writes: ```text -E:\MathCraftBenchData\runs\cdm_official_unimer_full\metrics_res.json -E:\MathCraftBenchData\runs\cdm_official_unimer_full\cdm_shard_summary.csv +./benchmark-data\runs\cdm_official_unimer_full\metrics_res.json +./benchmark-data\runs\cdm_official_unimer_full\cdm_shard_summary.csv ``` The runner also writes `.mathcraft_cdm_complete.json` for each shard after the @@ -146,9 +145,9 @@ the shard CSV records `expected_rows`, `evaluated_rows`, and `skipped_rows`. For a bounded check: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_official_cdm.ps1 ` - -InputPath E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_all_20_cdm.json ` - -OutputDir E:\MathCraftBenchData\runs\cdm_official_unimer_20_runner ` +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_official_cdm.ps1 -DataRoot ./benchmark-data ` + -InputPath ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_all_20_cdm.json ` + -OutputDir ./benchmark-data\runs\cdm_official_unimer_20_runner ` -ShardSize 10 ` -Limit 20 ``` @@ -182,7 +181,7 @@ prints `extract bbox` progress during the rendering/bbox stage. Full official CDM run: ```text -Run: E:\MathCraftBenchData\runs\cdm_official_unimer_full +Run: ./benchmark-data\runs\cdm_official_unimer_full Mean CDM: 0.929 ExpRate: 0.648 Expected: 23757 @@ -194,12 +193,12 @@ Shards: 238 / 238 complete Generate compact source-controlled CDM tables: ```powershell -D:\Python312\python.exe E:\LaTexSnipper\benchmarks\mathcraft_ocr\reports\analyze_cdm_results.py ` - --metrics E:\MathCraftBenchData\runs\cdm_official_unimer_full\metrics_res.json ` - --input E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` - --manifest E:\MathCraftBenchData\manifests\unimer_test_full.jsonl ` - --shards E:\MathCraftBenchData\runs\cdm_official_unimer_full\cdm_shard_summary.csv ` - --output-dir E:\LaTexSnipper\benchmarks\mathcraft_ocr\results\unimer_test_gpu +python .\benchmarks\mathcraft_ocr\reports\analyze_cdm_results.py ` + --metrics ./benchmark-data\runs\cdm_official_unimer_full\metrics_res.json ` + --input ./benchmark-data\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json ` + --manifest ./benchmark-data\manifests\unimer_test_full.jsonl ` + --shards ./benchmark-data\runs\cdm_official_unimer_full\cdm_shard_summary.csv ` + --output-dir .\benchmarks\mathcraft_ocr\results\unimer_test_gpu ``` ## Source-Controlled Result Evidence @@ -210,7 +209,7 @@ Small result tables are copied into: benchmarks/mathcraft_ocr/results/unimer_test_gpu ``` -Do not commit `E:\MathCraftBenchData`, full JSONL outputs, downloaded datasets, +Do not commit `./benchmark-data`, full JSONL outputs, downloaded datasets, or rendered PNG pairs into the repository. ## Related Page-Level Data diff --git a/benchmarks/mathcraft_ocr/datasets/create_openstax_manifest.py b/benchmarks/mathcraft_ocr/datasets/create_openstax_manifest.py index 9bae51ca..b41418fa 100644 --- a/benchmarks/mathcraft_ocr/datasets/create_openstax_manifest.py +++ b/benchmarks/mathcraft_ocr/datasets/create_openstax_manifest.py @@ -46,8 +46,8 @@ class OpenStaxSource: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Create MathCraft page-level manifest from OpenStax PDFs.") - parser.add_argument("--data-root", default=r"E:\MathCraftBenchData") - parser.add_argument("--output", default=r"E:\MathCraftBenchData\manifests\openstax_mixed_gpu_144dpi.jsonl") + parser.add_argument("--data-root", required=True) + parser.add_argument("--output", help="Defaults to manifests/openstax_mixed_gpu_144dpi.jsonl under --data-root") parser.add_argument( "--pages", default=",".join(f"{key}:{value}" for key, value in DEFAULT_PAGES.items()), @@ -59,7 +59,7 @@ def main(argv: list[str] | None = None) -> int: data_root = Path(args.data_root) raw_dir = data_root / "raw" / "openstax" image_dir = data_root / "processed" / "openstax_pages" - output = Path(args.output) + output = Path(args.output) if args.output else data_root / "manifests" / "openstax_mixed_gpu_144dpi.jsonl" raw_dir.mkdir(parents=True, exist_ok=True) image_dir.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True) diff --git a/benchmarks/mathcraft_ocr/reports/run_official_cdm.py b/benchmarks/mathcraft_ocr/reports/run_official_cdm.py index 76f6604d..fe975e5f 100644 --- a/benchmarks/mathcraft_ocr/reports/run_official_cdm.py +++ b/benchmarks/mathcraft_ocr/reports/run_official_cdm.py @@ -46,10 +46,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--force", action="store_true") args = parser.parse_args(argv) - input_path = Path(args.input) - output_dir = Path(args.output_dir) - cdm_dir = Path(args.cdm_dir) - python_path = Path(args.python) + input_path = Path(args.input).resolve() + output_dir = Path(args.output_dir).resolve() + cdm_dir = Path(args.cdm_dir).resolve() + python_path = Path(args.python).resolve() validate_args(input_path, output_dir, cdm_dir, python_path, args.shard_size, args.pools) diff --git a/benchmarks/mathcraft_ocr/results/unimer_test_gpu/cdm_official_shard_summary.csv b/benchmarks/mathcraft_ocr/results/unimer_test_gpu/cdm_official_shard_summary.csv index 9a22b10b..73dd8b9e 100644 --- a/benchmarks/mathcraft_ocr/results/unimer_test_gpu/cdm_official_shard_summary.csv +++ b/benchmarks/mathcraft_ocr/results/unimer_test_gpu/cdm_official_shard_summary.csv @@ -1,239 +1,239 @@ shard,start,end_exclusive,expected_rows,evaluated_rows,skipped_rows,state,mean_score,exp_rate,metrics_path -1,0,100,100,100,0,complete,0.99,0.79,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00000_00099\metrics_res.json -2,100,200,100,100,0,complete,0.994,0.83,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00100_00199\metrics_res.json -3,200,300,100,100,0,complete,0.989,0.76,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00200_00299\metrics_res.json -4,300,400,100,100,0,complete,0.997,0.9,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00300_00399\metrics_res.json -5,400,500,100,100,0,complete,0.992,0.79,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00400_00499\metrics_res.json -6,500,600,100,100,0,complete,0.986,0.74,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00500_00599\metrics_res.json -7,600,700,100,100,0,complete,0.992,0.84,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00600_00699\metrics_res.json -8,700,800,100,100,0,complete,0.985,0.85,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00700_00799\metrics_res.json -9,800,900,100,100,0,complete,0.99,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00800_00899\metrics_res.json -10,900,1000,100,100,0,complete,0.994,0.86,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00900_00999\metrics_res.json -11,1000,1100,100,100,0,complete,0.991,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01000_01099\metrics_res.json -12,1100,1200,100,100,0,complete,0.993,0.83,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01100_01199\metrics_res.json -13,1200,1300,100,100,0,complete,0.983,0.77,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01200_01299\metrics_res.json -14,1300,1400,100,100,0,complete,0.992,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01300_01399\metrics_res.json -15,1400,1500,100,100,0,complete,0.989,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01400_01499\metrics_res.json -16,1500,1600,100,100,0,complete,0.992,0.86,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01500_01599\metrics_res.json -17,1600,1700,100,100,0,complete,0.988,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01600_01699\metrics_res.json -18,1700,1800,100,100,0,complete,0.984,0.77,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01700_01799\metrics_res.json -19,1800,1900,100,100,0,complete,0.992,0.85,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01800_01899\metrics_res.json -20,1900,2000,100,100,0,complete,0.983,0.73,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01900_01999\metrics_res.json -21,2000,2100,100,100,0,complete,0.991,0.79,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02000_02099\metrics_res.json -22,2100,2200,100,100,0,complete,0.984,0.83,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02100_02199\metrics_res.json -23,2200,2300,100,100,0,complete,0.992,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02200_02299\metrics_res.json -24,2300,2400,100,100,0,complete,0.995,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02300_02399\metrics_res.json -25,2400,2500,100,100,0,complete,0.987,0.72,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02400_02499\metrics_res.json -26,2500,2600,100,100,0,complete,0.982,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02500_02599\metrics_res.json -27,2600,2700,100,100,0,complete,0.992,0.87,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02600_02699\metrics_res.json -28,2700,2800,100,100,0,complete,0.991,0.82,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02700_02799\metrics_res.json -29,2800,2900,100,100,0,complete,0.992,0.88,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02800_02899\metrics_res.json -30,2900,3000,100,100,0,complete,0.983,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02900_02999\metrics_res.json -31,3000,3100,100,100,0,complete,0.989,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03000_03099\metrics_res.json -32,3100,3200,100,100,0,complete,0.979,0.86,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03100_03199\metrics_res.json -33,3200,3300,100,100,0,complete,0.989,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03200_03299\metrics_res.json -34,3300,3400,100,100,0,complete,0.993,0.79,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03300_03399\metrics_res.json -35,3400,3500,100,100,0,complete,0.989,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03400_03499\metrics_res.json -36,3500,3600,100,100,0,complete,0.977,0.82,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03500_03599\metrics_res.json -37,3600,3700,100,100,0,complete,0.98,0.86,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03600_03699\metrics_res.json -38,3700,3800,100,100,0,complete,0.993,0.87,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03700_03799\metrics_res.json -39,3800,3900,100,100,0,complete,0.994,0.89,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03800_03899\metrics_res.json -40,3900,4000,100,100,0,complete,0.983,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03900_03999\metrics_res.json -41,4000,4100,100,100,0,complete,0.992,0.87,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04000_04099\metrics_res.json -42,4100,4200,100,100,0,complete,0.99,0.76,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04100_04199\metrics_res.json -43,4200,4300,100,100,0,complete,0.989,0.82,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04200_04299\metrics_res.json -44,4300,4400,100,100,0,complete,0.994,0.86,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04300_04399\metrics_res.json -45,4400,4500,100,100,0,complete,0.992,0.94,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04400_04499\metrics_res.json -46,4500,4600,100,100,0,complete,0.997,0.92,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04500_04599\metrics_res.json -47,4600,4700,100,100,0,complete,0.995,0.93,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04600_04699\metrics_res.json -48,4700,4800,100,100,0,complete,0.996,0.91,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04700_04799\metrics_res.json -49,4800,4900,100,100,0,complete,0.991,0.93,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04800_04899\metrics_res.json -50,4900,5000,100,100,0,complete,0.993,0.92,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04900_04999\metrics_res.json -51,5000,5100,100,100,0,complete,0.989,0.91,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05000_05099\metrics_res.json -52,5100,5200,100,99,1,complete,0.992,0.899,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05100_05199\metrics_res.json -53,5200,5300,100,100,0,complete,0.992,0.9,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05200_05299\metrics_res.json -54,5300,5400,100,100,0,complete,0.995,0.97,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05300_05399\metrics_res.json -55,5400,5500,100,100,0,complete,0.985,0.92,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05400_05499\metrics_res.json -56,5500,5600,100,100,0,complete,0.997,0.93,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05500_05599\metrics_res.json -57,5600,5700,100,100,0,complete,0.988,0.89,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05600_05699\metrics_res.json -58,5700,5800,100,100,0,complete,0.986,0.87,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05700_05799\metrics_res.json -59,5800,5900,100,100,0,complete,0.996,0.91,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05800_05899\metrics_res.json -60,5900,6000,100,100,0,complete,0.992,0.94,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05900_05999\metrics_res.json -61,6000,6100,100,100,0,complete,0.994,0.93,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06000_06099\metrics_res.json -62,6100,6200,100,100,0,complete,0.979,0.83,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06100_06199\metrics_res.json -63,6200,6300,100,100,0,complete,0.996,0.95,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06200_06299\metrics_res.json -64,6300,6400,100,100,0,complete,0.989,0.94,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06300_06399\metrics_res.json -65,6400,6500,100,100,0,complete,0.991,0.91,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06400_06499\metrics_res.json -66,6500,6600,100,100,0,complete,0.996,0.94,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06500_06599\metrics_res.json -67,6600,6700,100,100,0,complete,0.994,0.94,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06600_06699\metrics_res.json -68,6700,6800,100,100,0,complete,0.959,0.71,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06700_06799\metrics_res.json -69,6800,6900,100,99,1,complete,0.813,0.374,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06800_06899\metrics_res.json -70,6900,7000,100,99,1,complete,0.864,0.293,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06900_06999\metrics_res.json -71,7000,7100,100,99,1,complete,0.845,0.253,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07000_07099\metrics_res.json -72,7100,7200,100,100,0,complete,0.913,0.29,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07100_07199\metrics_res.json -73,7200,7300,100,100,0,complete,0.86,0.3,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07200_07299\metrics_res.json -74,7300,7400,100,100,0,complete,0.863,0.3,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07300_07399\metrics_res.json -75,7400,7500,100,100,0,complete,0.863,0.29,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07400_07499\metrics_res.json -76,7500,7600,100,100,0,complete,0.844,0.35,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07500_07599\metrics_res.json -77,7600,7700,100,100,0,complete,0.85,0.3,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07600_07699\metrics_res.json -78,7700,7800,100,100,0,complete,0.922,0.47,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07700_07799\metrics_res.json -79,7800,7900,100,100,0,complete,0.876,0.28,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07800_07899\metrics_res.json -80,7900,8000,100,100,0,complete,0.892,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07900_07999\metrics_res.json -81,8000,8100,100,99,1,complete,0.898,0.364,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08000_08099\metrics_res.json -82,8100,8200,100,99,1,complete,0.893,0.354,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08100_08199\metrics_res.json -83,8200,8300,100,100,0,complete,0.848,0.29,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08200_08299\metrics_res.json -84,8300,8400,100,100,0,complete,0.872,0.27,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08300_08399\metrics_res.json -85,8400,8500,100,100,0,complete,0.868,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08400_08499\metrics_res.json -86,8500,8600,100,99,1,complete,0.847,0.313,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08500_08599\metrics_res.json -87,8600,8700,100,100,0,complete,0.916,0.41,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08600_08699\metrics_res.json -88,8700,8800,100,100,0,complete,0.88,0.28,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08700_08799\metrics_res.json -89,8800,8900,100,100,0,complete,0.875,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08800_08899\metrics_res.json -90,8900,9000,100,100,0,complete,0.857,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08900_08999\metrics_res.json -91,9000,9100,100,100,0,complete,0.867,0.29,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09000_09099\metrics_res.json -92,9100,9200,100,100,0,complete,0.886,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09100_09199\metrics_res.json -93,9200,9300,100,100,0,complete,0.875,0.25,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09200_09299\metrics_res.json -94,9300,9400,100,100,0,complete,0.902,0.34,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09300_09399\metrics_res.json -95,9400,9500,100,100,0,complete,0.902,0.39,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09400_09499\metrics_res.json -96,9500,9600,100,100,0,complete,0.867,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09500_09599\metrics_res.json -97,9600,9700,100,100,0,complete,0.885,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09600_09699\metrics_res.json -98,9700,9800,100,100,0,complete,0.884,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09700_09799\metrics_res.json -99,9800,9900,100,100,0,complete,0.896,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09800_09899\metrics_res.json -100,9900,10000,100,100,0,complete,0.882,0.27,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09900_09999\metrics_res.json -101,10000,10100,100,100,0,complete,0.897,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10000_10099\metrics_res.json -102,10100,10200,100,100,0,complete,0.882,0.4,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10100_10199\metrics_res.json -103,10200,10300,100,99,1,complete,0.901,0.313,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10200_10299\metrics_res.json -104,10300,10400,100,100,0,complete,0.836,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10300_10399\metrics_res.json -105,10400,10500,100,100,0,complete,0.894,0.36,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10400_10499\metrics_res.json -106,10500,10600,100,100,0,complete,0.914,0.28,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10500_10599\metrics_res.json -107,10600,10700,100,100,0,complete,0.885,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10600_10699\metrics_res.json -108,10700,10800,100,100,0,complete,0.891,0.37,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10700_10799\metrics_res.json -109,10800,10900,100,100,0,complete,0.891,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10800_10899\metrics_res.json -110,10900,11000,100,100,0,complete,0.868,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10900_10999\metrics_res.json -111,11000,11100,100,100,0,complete,0.934,0.4,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11000_11099\metrics_res.json -112,11100,11200,100,100,0,complete,0.909,0.27,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11100_11199\metrics_res.json -113,11200,11300,100,100,0,complete,0.869,0.3,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11200_11299\metrics_res.json -114,11300,11400,100,100,0,complete,0.897,0.41,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11300_11399\metrics_res.json -115,11400,11500,100,100,0,complete,0.9,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11400_11499\metrics_res.json -116,11500,11600,100,99,1,complete,0.843,0.333,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11500_11599\metrics_res.json -117,11600,11700,100,100,0,complete,0.828,0.26,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11600_11699\metrics_res.json -118,11700,11800,100,99,1,complete,0.9,0.343,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11700_11799\metrics_res.json -119,11800,11900,100,100,0,complete,0.846,0.34,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11800_11899\metrics_res.json -120,11900,12000,100,100,0,complete,0.844,0.29,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11900_11999\metrics_res.json -121,12000,12100,100,100,0,complete,0.888,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12000_12099\metrics_res.json -122,12100,12200,100,99,1,complete,0.872,0.343,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12100_12199\metrics_res.json -123,12200,12300,100,100,0,complete,0.901,0.28,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12200_12299\metrics_res.json -124,12300,12400,100,100,0,complete,0.895,0.32,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12300_12399\metrics_res.json -125,12400,12500,100,100,0,complete,0.912,0.43,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12400_12499\metrics_res.json -126,12500,12600,100,100,0,complete,0.9,0.31,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12500_12599\metrics_res.json -127,12600,12700,100,100,0,complete,0.872,0.35,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12600_12699\metrics_res.json -128,12700,12800,100,100,0,complete,0.94,0.82,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12700_12799\metrics_res.json -129,12800,12900,100,100,0,complete,0.921,0.71,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12800_12899\metrics_res.json -130,12900,13000,100,99,1,complete,0.943,0.788,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12900_12999\metrics_res.json -131,13000,13100,100,99,1,complete,0.922,0.737,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13000_13099\metrics_res.json -132,13100,13200,100,99,1,complete,0.905,0.747,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13100_13199\metrics_res.json -133,13200,13300,100,100,0,complete,0.912,0.68,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13200_13299\metrics_res.json -134,13300,13400,100,97,3,complete,0.964,0.866,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13300_13399\metrics_res.json -135,13400,13500,100,100,0,complete,0.939,0.75,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13400_13499\metrics_res.json -136,13500,13600,100,99,1,complete,0.912,0.707,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13500_13599\metrics_res.json -137,13600,13700,100,99,1,complete,0.929,0.758,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13600_13699\metrics_res.json -138,13700,13800,100,98,2,complete,0.954,0.827,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13700_13799\metrics_res.json -139,13800,13900,100,98,2,complete,0.941,0.724,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13800_13899\metrics_res.json -140,13900,14000,100,100,0,complete,0.968,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13900_13999\metrics_res.json -141,14000,14100,100,99,1,complete,0.919,0.768,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14000_14099\metrics_res.json -142,14100,14200,100,98,2,complete,0.97,0.796,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14100_14199\metrics_res.json -143,14200,14300,100,100,0,complete,0.928,0.73,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14200_14299\metrics_res.json -144,14300,14400,100,100,0,complete,0.951,0.77,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14300_14399\metrics_res.json -145,14400,14500,100,100,0,complete,0.922,0.76,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14400_14499\metrics_res.json -146,14500,14600,100,97,3,complete,0.942,0.742,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14500_14599\metrics_res.json -147,14600,14700,100,99,1,complete,0.947,0.768,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14600_14699\metrics_res.json -148,14700,14800,100,100,0,complete,0.944,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14700_14799\metrics_res.json -149,14800,14900,100,100,0,complete,0.952,0.77,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14800_14899\metrics_res.json -150,14900,15000,100,100,0,complete,0.957,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14900_14999\metrics_res.json -151,15000,15100,100,98,2,complete,0.935,0.806,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15000_15099\metrics_res.json -152,15100,15200,100,98,2,complete,0.958,0.806,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15100_15199\metrics_res.json -153,15200,15300,100,100,0,complete,0.915,0.68,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15200_15299\metrics_res.json -154,15300,15400,100,100,0,complete,0.943,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15300_15399\metrics_res.json -155,15400,15500,100,100,0,complete,0.916,0.7,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15400_15499\metrics_res.json -156,15500,15600,100,96,4,complete,0.951,0.781,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15500_15599\metrics_res.json -157,15600,15700,100,100,0,complete,0.935,0.74,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15600_15699\metrics_res.json -158,15700,15800,100,99,1,complete,0.929,0.758,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15700_15799\metrics_res.json -159,15800,15900,100,100,0,complete,0.939,0.82,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15800_15899\metrics_res.json -160,15900,16000,100,100,0,complete,0.93,0.78,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15900_15999\metrics_res.json -161,16000,16100,100,100,0,complete,0.936,0.75,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16000_16099\metrics_res.json -162,16100,16200,100,100,0,complete,0.955,0.81,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16100_16199\metrics_res.json -163,16200,16300,100,100,0,complete,0.923,0.73,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16200_16299\metrics_res.json -164,16300,16400,100,99,1,complete,0.941,0.778,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16300_16399\metrics_res.json -165,16400,16500,100,100,0,complete,0.95,0.8,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16400_16499\metrics_res.json -166,16500,16600,100,99,1,complete,0.942,0.737,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16500_16599\metrics_res.json -167,16600,16700,100,99,1,complete,0.956,0.758,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16600_16699\metrics_res.json -168,16700,16800,100,99,1,complete,0.93,0.778,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16700_16799\metrics_res.json -169,16800,16900,100,99,1,complete,0.934,0.778,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16800_16899\metrics_res.json -170,16900,17000,100,100,0,complete,0.938,0.84,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16900_16999\metrics_res.json -171,17000,17100,100,100,0,complete,0.914,0.74,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17000_17099\metrics_res.json -172,17100,17200,100,100,0,complete,0.935,0.77,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17100_17199\metrics_res.json -173,17200,17300,100,100,0,complete,0.956,0.85,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17200_17299\metrics_res.json -174,17300,17400,100,100,0,complete,0.936,0.76,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17300_17399\metrics_res.json -175,17400,17500,100,100,0,complete,0.911,0.69,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17400_17499\metrics_res.json -176,17500,17600,100,100,0,complete,0.875,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17500_17599\metrics_res.json -177,17600,17700,100,99,1,complete,0.911,0.657,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17600_17699\metrics_res.json -178,17700,17800,100,100,0,complete,0.895,0.62,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17700_17799\metrics_res.json -179,17800,17900,100,100,0,complete,0.905,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17800_17899\metrics_res.json -180,17900,18000,100,100,0,complete,0.878,0.63,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17900_17999\metrics_res.json -181,18000,18100,100,100,0,complete,0.924,0.66,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18000_18099\metrics_res.json -182,18100,18200,100,100,0,complete,0.86,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18100_18199\metrics_res.json -183,18200,18300,100,100,0,complete,0.92,0.64,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18200_18299\metrics_res.json -184,18300,18400,100,99,1,complete,0.878,0.586,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18300_18399\metrics_res.json -185,18400,18500,100,100,0,complete,0.917,0.67,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18400_18499\metrics_res.json -186,18500,18600,100,100,0,complete,0.89,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18500_18599\metrics_res.json -187,18600,18700,100,100,0,complete,0.896,0.58,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18600_18699\metrics_res.json -188,18700,18800,100,99,1,complete,0.928,0.667,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18700_18799\metrics_res.json -189,18800,18900,100,100,0,complete,0.916,0.64,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18800_18899\metrics_res.json -190,18900,19000,100,100,0,complete,0.899,0.62,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18900_18999\metrics_res.json -191,19000,19100,100,100,0,complete,0.917,0.7,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19000_19099\metrics_res.json -192,19100,19200,100,100,0,complete,0.896,0.63,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19100_19199\metrics_res.json -193,19200,19300,100,99,1,complete,0.912,0.667,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19200_19299\metrics_res.json -194,19300,19400,100,100,0,complete,0.86,0.48,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19300_19399\metrics_res.json -195,19400,19500,100,100,0,complete,0.891,0.6,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19400_19499\metrics_res.json -196,19500,19600,100,100,0,complete,0.879,0.58,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19500_19599\metrics_res.json -197,19600,19700,100,100,0,complete,0.91,0.66,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19600_19699\metrics_res.json -198,19700,19800,100,100,0,complete,0.914,0.69,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19700_19799\metrics_res.json -199,19800,19900,100,100,0,complete,0.915,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19800_19899\metrics_res.json -200,19900,20000,100,100,0,complete,0.925,0.71,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19900_19999\metrics_res.json -201,20000,20100,100,100,0,complete,0.916,0.69,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20000_20099\metrics_res.json -202,20100,20200,100,100,0,complete,0.902,0.64,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20100_20199\metrics_res.json -203,20200,20300,100,99,1,complete,0.928,0.687,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20200_20299\metrics_res.json -204,20300,20400,100,100,0,complete,0.904,0.66,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20300_20399\metrics_res.json -205,20400,20500,100,100,0,complete,0.886,0.62,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20400_20499\metrics_res.json -206,20500,20600,100,100,0,complete,0.933,0.72,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20500_20599\metrics_res.json -207,20600,20700,100,100,0,complete,0.893,0.59,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20600_20699\metrics_res.json -208,20700,20800,100,100,0,complete,0.894,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20700_20799\metrics_res.json -209,20800,20900,100,100,0,complete,0.919,0.67,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20800_20899\metrics_res.json -210,20900,21000,100,100,0,complete,0.906,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20900_20999\metrics_res.json -211,21000,21100,100,99,1,complete,0.89,0.626,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21000_21099\metrics_res.json -212,21100,21200,100,100,0,complete,0.881,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21100_21199\metrics_res.json -213,21200,21300,100,99,1,complete,0.904,0.657,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21200_21299\metrics_res.json -214,21300,21400,100,100,0,complete,0.876,0.58,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21300_21399\metrics_res.json -215,21400,21500,100,99,1,complete,0.944,0.778,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21400_21499\metrics_res.json -216,21500,21600,100,100,0,complete,0.928,0.68,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21500_21599\metrics_res.json -217,21600,21700,100,100,0,complete,0.918,0.67,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21600_21699\metrics_res.json -218,21700,21800,100,100,0,complete,0.899,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21700_21799\metrics_res.json -219,21800,21900,100,99,1,complete,0.932,0.687,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21800_21899\metrics_res.json -220,21900,22000,100,99,1,complete,0.915,0.697,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21900_21999\metrics_res.json -221,22000,22100,100,100,0,complete,0.879,0.57,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22000_22099\metrics_res.json -222,22100,22200,100,99,1,complete,0.877,0.606,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22100_22199\metrics_res.json -223,22200,22300,100,100,0,complete,0.879,0.55,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22200_22299\metrics_res.json -224,22300,22400,100,100,0,complete,0.887,0.66,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22300_22399\metrics_res.json -225,22400,22500,100,100,0,complete,0.895,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22400_22499\metrics_res.json -226,22500,22600,100,100,0,complete,0.911,0.64,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22500_22599\metrics_res.json -227,22600,22700,100,100,0,complete,0.911,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22600_22699\metrics_res.json -228,22700,22800,100,100,0,complete,0.926,0.7,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22700_22799\metrics_res.json -229,22800,22900,100,100,0,complete,0.905,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22800_22899\metrics_res.json -230,22900,23000,100,100,0,complete,0.921,0.67,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22900_22999\metrics_res.json -231,23000,23100,100,100,0,complete,0.887,0.61,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23000_23099\metrics_res.json -232,23100,23200,100,100,0,complete,0.901,0.66,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23100_23199\metrics_res.json -233,23200,23300,100,99,1,complete,0.905,0.646,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23200_23299\metrics_res.json -234,23300,23400,100,100,0,complete,0.906,0.64,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23300_23399\metrics_res.json -235,23400,23500,100,100,0,complete,0.891,0.58,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23400_23499\metrics_res.json -236,23500,23600,100,100,0,complete,0.88,0.65,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23500_23599\metrics_res.json -237,23600,23700,100,100,0,complete,0.904,0.58,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23600_23699\metrics_res.json -238,23700,23757,57,57,0,complete,0.965,0.772,E:\MathCraftBenchData\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23700_23756\metrics_res.json +1,0,100,100,100,0,complete,0.99,0.79,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00000_00099\metrics_res.json +2,100,200,100,100,0,complete,0.994,0.83,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00100_00199\metrics_res.json +3,200,300,100,100,0,complete,0.989,0.76,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00200_00299\metrics_res.json +4,300,400,100,100,0,complete,0.997,0.9,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00300_00399\metrics_res.json +5,400,500,100,100,0,complete,0.992,0.79,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00400_00499\metrics_res.json +6,500,600,100,100,0,complete,0.986,0.74,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00500_00599\metrics_res.json +7,600,700,100,100,0,complete,0.992,0.84,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00600_00699\metrics_res.json +8,700,800,100,100,0,complete,0.985,0.85,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00700_00799\metrics_res.json +9,800,900,100,100,0,complete,0.99,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00800_00899\metrics_res.json +10,900,1000,100,100,0,complete,0.994,0.86,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_00900_00999\metrics_res.json +11,1000,1100,100,100,0,complete,0.991,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01000_01099\metrics_res.json +12,1100,1200,100,100,0,complete,0.993,0.83,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01100_01199\metrics_res.json +13,1200,1300,100,100,0,complete,0.983,0.77,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01200_01299\metrics_res.json +14,1300,1400,100,100,0,complete,0.992,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01300_01399\metrics_res.json +15,1400,1500,100,100,0,complete,0.989,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01400_01499\metrics_res.json +16,1500,1600,100,100,0,complete,0.992,0.86,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01500_01599\metrics_res.json +17,1600,1700,100,100,0,complete,0.988,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01600_01699\metrics_res.json +18,1700,1800,100,100,0,complete,0.984,0.77,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01700_01799\metrics_res.json +19,1800,1900,100,100,0,complete,0.992,0.85,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01800_01899\metrics_res.json +20,1900,2000,100,100,0,complete,0.983,0.73,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_01900_01999\metrics_res.json +21,2000,2100,100,100,0,complete,0.991,0.79,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02000_02099\metrics_res.json +22,2100,2200,100,100,0,complete,0.984,0.83,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02100_02199\metrics_res.json +23,2200,2300,100,100,0,complete,0.992,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02200_02299\metrics_res.json +24,2300,2400,100,100,0,complete,0.995,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02300_02399\metrics_res.json +25,2400,2500,100,100,0,complete,0.987,0.72,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02400_02499\metrics_res.json +26,2500,2600,100,100,0,complete,0.982,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02500_02599\metrics_res.json +27,2600,2700,100,100,0,complete,0.992,0.87,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02600_02699\metrics_res.json +28,2700,2800,100,100,0,complete,0.991,0.82,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02700_02799\metrics_res.json +29,2800,2900,100,100,0,complete,0.992,0.88,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02800_02899\metrics_res.json +30,2900,3000,100,100,0,complete,0.983,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_02900_02999\metrics_res.json +31,3000,3100,100,100,0,complete,0.989,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03000_03099\metrics_res.json +32,3100,3200,100,100,0,complete,0.979,0.86,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03100_03199\metrics_res.json +33,3200,3300,100,100,0,complete,0.989,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03200_03299\metrics_res.json +34,3300,3400,100,100,0,complete,0.993,0.79,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03300_03399\metrics_res.json +35,3400,3500,100,100,0,complete,0.989,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03400_03499\metrics_res.json +36,3500,3600,100,100,0,complete,0.977,0.82,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03500_03599\metrics_res.json +37,3600,3700,100,100,0,complete,0.98,0.86,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03600_03699\metrics_res.json +38,3700,3800,100,100,0,complete,0.993,0.87,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03700_03799\metrics_res.json +39,3800,3900,100,100,0,complete,0.994,0.89,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03800_03899\metrics_res.json +40,3900,4000,100,100,0,complete,0.983,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_03900_03999\metrics_res.json +41,4000,4100,100,100,0,complete,0.992,0.87,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04000_04099\metrics_res.json +42,4100,4200,100,100,0,complete,0.99,0.76,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04100_04199\metrics_res.json +43,4200,4300,100,100,0,complete,0.989,0.82,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04200_04299\metrics_res.json +44,4300,4400,100,100,0,complete,0.994,0.86,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04300_04399\metrics_res.json +45,4400,4500,100,100,0,complete,0.992,0.94,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04400_04499\metrics_res.json +46,4500,4600,100,100,0,complete,0.997,0.92,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04500_04599\metrics_res.json +47,4600,4700,100,100,0,complete,0.995,0.93,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04600_04699\metrics_res.json +48,4700,4800,100,100,0,complete,0.996,0.91,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04700_04799\metrics_res.json +49,4800,4900,100,100,0,complete,0.991,0.93,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04800_04899\metrics_res.json +50,4900,5000,100,100,0,complete,0.993,0.92,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_04900_04999\metrics_res.json +51,5000,5100,100,100,0,complete,0.989,0.91,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05000_05099\metrics_res.json +52,5100,5200,100,99,1,complete,0.992,0.899,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05100_05199\metrics_res.json +53,5200,5300,100,100,0,complete,0.992,0.9,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05200_05299\metrics_res.json +54,5300,5400,100,100,0,complete,0.995,0.97,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05300_05399\metrics_res.json +55,5400,5500,100,100,0,complete,0.985,0.92,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05400_05499\metrics_res.json +56,5500,5600,100,100,0,complete,0.997,0.93,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05500_05599\metrics_res.json +57,5600,5700,100,100,0,complete,0.988,0.89,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05600_05699\metrics_res.json +58,5700,5800,100,100,0,complete,0.986,0.87,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05700_05799\metrics_res.json +59,5800,5900,100,100,0,complete,0.996,0.91,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05800_05899\metrics_res.json +60,5900,6000,100,100,0,complete,0.992,0.94,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_05900_05999\metrics_res.json +61,6000,6100,100,100,0,complete,0.994,0.93,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06000_06099\metrics_res.json +62,6100,6200,100,100,0,complete,0.979,0.83,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06100_06199\metrics_res.json +63,6200,6300,100,100,0,complete,0.996,0.95,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06200_06299\metrics_res.json +64,6300,6400,100,100,0,complete,0.989,0.94,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06300_06399\metrics_res.json +65,6400,6500,100,100,0,complete,0.991,0.91,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06400_06499\metrics_res.json +66,6500,6600,100,100,0,complete,0.996,0.94,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06500_06599\metrics_res.json +67,6600,6700,100,100,0,complete,0.994,0.94,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06600_06699\metrics_res.json +68,6700,6800,100,100,0,complete,0.959,0.71,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06700_06799\metrics_res.json +69,6800,6900,100,99,1,complete,0.813,0.374,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06800_06899\metrics_res.json +70,6900,7000,100,99,1,complete,0.864,0.293,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_06900_06999\metrics_res.json +71,7000,7100,100,99,1,complete,0.845,0.253,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07000_07099\metrics_res.json +72,7100,7200,100,100,0,complete,0.913,0.29,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07100_07199\metrics_res.json +73,7200,7300,100,100,0,complete,0.86,0.3,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07200_07299\metrics_res.json +74,7300,7400,100,100,0,complete,0.863,0.3,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07300_07399\metrics_res.json +75,7400,7500,100,100,0,complete,0.863,0.29,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07400_07499\metrics_res.json +76,7500,7600,100,100,0,complete,0.844,0.35,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07500_07599\metrics_res.json +77,7600,7700,100,100,0,complete,0.85,0.3,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07600_07699\metrics_res.json +78,7700,7800,100,100,0,complete,0.922,0.47,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07700_07799\metrics_res.json +79,7800,7900,100,100,0,complete,0.876,0.28,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07800_07899\metrics_res.json +80,7900,8000,100,100,0,complete,0.892,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_07900_07999\metrics_res.json +81,8000,8100,100,99,1,complete,0.898,0.364,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08000_08099\metrics_res.json +82,8100,8200,100,99,1,complete,0.893,0.354,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08100_08199\metrics_res.json +83,8200,8300,100,100,0,complete,0.848,0.29,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08200_08299\metrics_res.json +84,8300,8400,100,100,0,complete,0.872,0.27,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08300_08399\metrics_res.json +85,8400,8500,100,100,0,complete,0.868,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08400_08499\metrics_res.json +86,8500,8600,100,99,1,complete,0.847,0.313,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08500_08599\metrics_res.json +87,8600,8700,100,100,0,complete,0.916,0.41,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08600_08699\metrics_res.json +88,8700,8800,100,100,0,complete,0.88,0.28,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08700_08799\metrics_res.json +89,8800,8900,100,100,0,complete,0.875,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08800_08899\metrics_res.json +90,8900,9000,100,100,0,complete,0.857,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_08900_08999\metrics_res.json +91,9000,9100,100,100,0,complete,0.867,0.29,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09000_09099\metrics_res.json +92,9100,9200,100,100,0,complete,0.886,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09100_09199\metrics_res.json +93,9200,9300,100,100,0,complete,0.875,0.25,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09200_09299\metrics_res.json +94,9300,9400,100,100,0,complete,0.902,0.34,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09300_09399\metrics_res.json +95,9400,9500,100,100,0,complete,0.902,0.39,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09400_09499\metrics_res.json +96,9500,9600,100,100,0,complete,0.867,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09500_09599\metrics_res.json +97,9600,9700,100,100,0,complete,0.885,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09600_09699\metrics_res.json +98,9700,9800,100,100,0,complete,0.884,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09700_09799\metrics_res.json +99,9800,9900,100,100,0,complete,0.896,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09800_09899\metrics_res.json +100,9900,10000,100,100,0,complete,0.882,0.27,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_09900_09999\metrics_res.json +101,10000,10100,100,100,0,complete,0.897,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10000_10099\metrics_res.json +102,10100,10200,100,100,0,complete,0.882,0.4,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10100_10199\metrics_res.json +103,10200,10300,100,99,1,complete,0.901,0.313,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10200_10299\metrics_res.json +104,10300,10400,100,100,0,complete,0.836,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10300_10399\metrics_res.json +105,10400,10500,100,100,0,complete,0.894,0.36,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10400_10499\metrics_res.json +106,10500,10600,100,100,0,complete,0.914,0.28,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10500_10599\metrics_res.json +107,10600,10700,100,100,0,complete,0.885,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10600_10699\metrics_res.json +108,10700,10800,100,100,0,complete,0.891,0.37,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10700_10799\metrics_res.json +109,10800,10900,100,100,0,complete,0.891,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10800_10899\metrics_res.json +110,10900,11000,100,100,0,complete,0.868,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_10900_10999\metrics_res.json +111,11000,11100,100,100,0,complete,0.934,0.4,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11000_11099\metrics_res.json +112,11100,11200,100,100,0,complete,0.909,0.27,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11100_11199\metrics_res.json +113,11200,11300,100,100,0,complete,0.869,0.3,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11200_11299\metrics_res.json +114,11300,11400,100,100,0,complete,0.897,0.41,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11300_11399\metrics_res.json +115,11400,11500,100,100,0,complete,0.9,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11400_11499\metrics_res.json +116,11500,11600,100,99,1,complete,0.843,0.333,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11500_11599\metrics_res.json +117,11600,11700,100,100,0,complete,0.828,0.26,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11600_11699\metrics_res.json +118,11700,11800,100,99,1,complete,0.9,0.343,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11700_11799\metrics_res.json +119,11800,11900,100,100,0,complete,0.846,0.34,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11800_11899\metrics_res.json +120,11900,12000,100,100,0,complete,0.844,0.29,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_11900_11999\metrics_res.json +121,12000,12100,100,100,0,complete,0.888,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12000_12099\metrics_res.json +122,12100,12200,100,99,1,complete,0.872,0.343,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12100_12199\metrics_res.json +123,12200,12300,100,100,0,complete,0.901,0.28,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12200_12299\metrics_res.json +124,12300,12400,100,100,0,complete,0.895,0.32,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12300_12399\metrics_res.json +125,12400,12500,100,100,0,complete,0.912,0.43,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12400_12499\metrics_res.json +126,12500,12600,100,100,0,complete,0.9,0.31,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12500_12599\metrics_res.json +127,12600,12700,100,100,0,complete,0.872,0.35,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12600_12699\metrics_res.json +128,12700,12800,100,100,0,complete,0.94,0.82,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12700_12799\metrics_res.json +129,12800,12900,100,100,0,complete,0.921,0.71,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12800_12899\metrics_res.json +130,12900,13000,100,99,1,complete,0.943,0.788,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_12900_12999\metrics_res.json +131,13000,13100,100,99,1,complete,0.922,0.737,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13000_13099\metrics_res.json +132,13100,13200,100,99,1,complete,0.905,0.747,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13100_13199\metrics_res.json +133,13200,13300,100,100,0,complete,0.912,0.68,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13200_13299\metrics_res.json +134,13300,13400,100,97,3,complete,0.964,0.866,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13300_13399\metrics_res.json +135,13400,13500,100,100,0,complete,0.939,0.75,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13400_13499\metrics_res.json +136,13500,13600,100,99,1,complete,0.912,0.707,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13500_13599\metrics_res.json +137,13600,13700,100,99,1,complete,0.929,0.758,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13600_13699\metrics_res.json +138,13700,13800,100,98,2,complete,0.954,0.827,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13700_13799\metrics_res.json +139,13800,13900,100,98,2,complete,0.941,0.724,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13800_13899\metrics_res.json +140,13900,14000,100,100,0,complete,0.968,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_13900_13999\metrics_res.json +141,14000,14100,100,99,1,complete,0.919,0.768,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14000_14099\metrics_res.json +142,14100,14200,100,98,2,complete,0.97,0.796,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14100_14199\metrics_res.json +143,14200,14300,100,100,0,complete,0.928,0.73,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14200_14299\metrics_res.json +144,14300,14400,100,100,0,complete,0.951,0.77,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14300_14399\metrics_res.json +145,14400,14500,100,100,0,complete,0.922,0.76,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14400_14499\metrics_res.json +146,14500,14600,100,97,3,complete,0.942,0.742,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14500_14599\metrics_res.json +147,14600,14700,100,99,1,complete,0.947,0.768,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14600_14699\metrics_res.json +148,14700,14800,100,100,0,complete,0.944,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14700_14799\metrics_res.json +149,14800,14900,100,100,0,complete,0.952,0.77,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14800_14899\metrics_res.json +150,14900,15000,100,100,0,complete,0.957,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_14900_14999\metrics_res.json +151,15000,15100,100,98,2,complete,0.935,0.806,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15000_15099\metrics_res.json +152,15100,15200,100,98,2,complete,0.958,0.806,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15100_15199\metrics_res.json +153,15200,15300,100,100,0,complete,0.915,0.68,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15200_15299\metrics_res.json +154,15300,15400,100,100,0,complete,0.943,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15300_15399\metrics_res.json +155,15400,15500,100,100,0,complete,0.916,0.7,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15400_15499\metrics_res.json +156,15500,15600,100,96,4,complete,0.951,0.781,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15500_15599\metrics_res.json +157,15600,15700,100,100,0,complete,0.935,0.74,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15600_15699\metrics_res.json +158,15700,15800,100,99,1,complete,0.929,0.758,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15700_15799\metrics_res.json +159,15800,15900,100,100,0,complete,0.939,0.82,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15800_15899\metrics_res.json +160,15900,16000,100,100,0,complete,0.93,0.78,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_15900_15999\metrics_res.json +161,16000,16100,100,100,0,complete,0.936,0.75,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16000_16099\metrics_res.json +162,16100,16200,100,100,0,complete,0.955,0.81,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16100_16199\metrics_res.json +163,16200,16300,100,100,0,complete,0.923,0.73,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16200_16299\metrics_res.json +164,16300,16400,100,99,1,complete,0.941,0.778,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16300_16399\metrics_res.json +165,16400,16500,100,100,0,complete,0.95,0.8,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16400_16499\metrics_res.json +166,16500,16600,100,99,1,complete,0.942,0.737,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16500_16599\metrics_res.json +167,16600,16700,100,99,1,complete,0.956,0.758,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16600_16699\metrics_res.json +168,16700,16800,100,99,1,complete,0.93,0.778,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16700_16799\metrics_res.json +169,16800,16900,100,99,1,complete,0.934,0.778,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16800_16899\metrics_res.json +170,16900,17000,100,100,0,complete,0.938,0.84,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_16900_16999\metrics_res.json +171,17000,17100,100,100,0,complete,0.914,0.74,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17000_17099\metrics_res.json +172,17100,17200,100,100,0,complete,0.935,0.77,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17100_17199\metrics_res.json +173,17200,17300,100,100,0,complete,0.956,0.85,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17200_17299\metrics_res.json +174,17300,17400,100,100,0,complete,0.936,0.76,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17300_17399\metrics_res.json +175,17400,17500,100,100,0,complete,0.911,0.69,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17400_17499\metrics_res.json +176,17500,17600,100,100,0,complete,0.875,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17500_17599\metrics_res.json +177,17600,17700,100,99,1,complete,0.911,0.657,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17600_17699\metrics_res.json +178,17700,17800,100,100,0,complete,0.895,0.62,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17700_17799\metrics_res.json +179,17800,17900,100,100,0,complete,0.905,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17800_17899\metrics_res.json +180,17900,18000,100,100,0,complete,0.878,0.63,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_17900_17999\metrics_res.json +181,18000,18100,100,100,0,complete,0.924,0.66,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18000_18099\metrics_res.json +182,18100,18200,100,100,0,complete,0.86,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18100_18199\metrics_res.json +183,18200,18300,100,100,0,complete,0.92,0.64,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18200_18299\metrics_res.json +184,18300,18400,100,99,1,complete,0.878,0.586,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18300_18399\metrics_res.json +185,18400,18500,100,100,0,complete,0.917,0.67,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18400_18499\metrics_res.json +186,18500,18600,100,100,0,complete,0.89,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18500_18599\metrics_res.json +187,18600,18700,100,100,0,complete,0.896,0.58,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18600_18699\metrics_res.json +188,18700,18800,100,99,1,complete,0.928,0.667,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18700_18799\metrics_res.json +189,18800,18900,100,100,0,complete,0.916,0.64,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18800_18899\metrics_res.json +190,18900,19000,100,100,0,complete,0.899,0.62,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_18900_18999\metrics_res.json +191,19000,19100,100,100,0,complete,0.917,0.7,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19000_19099\metrics_res.json +192,19100,19200,100,100,0,complete,0.896,0.63,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19100_19199\metrics_res.json +193,19200,19300,100,99,1,complete,0.912,0.667,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19200_19299\metrics_res.json +194,19300,19400,100,100,0,complete,0.86,0.48,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19300_19399\metrics_res.json +195,19400,19500,100,100,0,complete,0.891,0.6,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19400_19499\metrics_res.json +196,19500,19600,100,100,0,complete,0.879,0.58,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19500_19599\metrics_res.json +197,19600,19700,100,100,0,complete,0.91,0.66,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19600_19699\metrics_res.json +198,19700,19800,100,100,0,complete,0.914,0.69,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19700_19799\metrics_res.json +199,19800,19900,100,100,0,complete,0.915,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19800_19899\metrics_res.json +200,19900,20000,100,100,0,complete,0.925,0.71,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_19900_19999\metrics_res.json +201,20000,20100,100,100,0,complete,0.916,0.69,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20000_20099\metrics_res.json +202,20100,20200,100,100,0,complete,0.902,0.64,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20100_20199\metrics_res.json +203,20200,20300,100,99,1,complete,0.928,0.687,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20200_20299\metrics_res.json +204,20300,20400,100,100,0,complete,0.904,0.66,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20300_20399\metrics_res.json +205,20400,20500,100,100,0,complete,0.886,0.62,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20400_20499\metrics_res.json +206,20500,20600,100,100,0,complete,0.933,0.72,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20500_20599\metrics_res.json +207,20600,20700,100,100,0,complete,0.893,0.59,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20600_20699\metrics_res.json +208,20700,20800,100,100,0,complete,0.894,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20700_20799\metrics_res.json +209,20800,20900,100,100,0,complete,0.919,0.67,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20800_20899\metrics_res.json +210,20900,21000,100,100,0,complete,0.906,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_20900_20999\metrics_res.json +211,21000,21100,100,99,1,complete,0.89,0.626,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21000_21099\metrics_res.json +212,21100,21200,100,100,0,complete,0.881,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21100_21199\metrics_res.json +213,21200,21300,100,99,1,complete,0.904,0.657,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21200_21299\metrics_res.json +214,21300,21400,100,100,0,complete,0.876,0.58,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21300_21399\metrics_res.json +215,21400,21500,100,99,1,complete,0.944,0.778,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21400_21499\metrics_res.json +216,21500,21600,100,100,0,complete,0.928,0.68,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21500_21599\metrics_res.json +217,21600,21700,100,100,0,complete,0.918,0.67,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21600_21699\metrics_res.json +218,21700,21800,100,100,0,complete,0.899,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21700_21799\metrics_res.json +219,21800,21900,100,99,1,complete,0.932,0.687,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21800_21899\metrics_res.json +220,21900,22000,100,99,1,complete,0.915,0.697,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_21900_21999\metrics_res.json +221,22000,22100,100,100,0,complete,0.879,0.57,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22000_22099\metrics_res.json +222,22100,22200,100,99,1,complete,0.877,0.606,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22100_22199\metrics_res.json +223,22200,22300,100,100,0,complete,0.879,0.55,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22200_22299\metrics_res.json +224,22300,22400,100,100,0,complete,0.887,0.66,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22300_22399\metrics_res.json +225,22400,22500,100,100,0,complete,0.895,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22400_22499\metrics_res.json +226,22500,22600,100,100,0,complete,0.911,0.64,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22500_22599\metrics_res.json +227,22600,22700,100,100,0,complete,0.911,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22600_22699\metrics_res.json +228,22700,22800,100,100,0,complete,0.926,0.7,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22700_22799\metrics_res.json +229,22800,22900,100,100,0,complete,0.905,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22800_22899\metrics_res.json +230,22900,23000,100,100,0,complete,0.921,0.67,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_22900_22999\metrics_res.json +231,23000,23100,100,100,0,complete,0.887,0.61,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23000_23099\metrics_res.json +232,23100,23200,100,100,0,complete,0.901,0.66,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23100_23199\metrics_res.json +233,23200,23300,100,99,1,complete,0.905,0.646,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23200_23299\metrics_res.json +234,23300,23400,100,100,0,complete,0.906,0.64,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23300_23399\metrics_res.json +235,23400,23500,100,100,0,complete,0.891,0.58,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23400_23499\metrics_res.json +236,23500,23600,100,100,0,complete,0.88,0.65,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23500_23599\metrics_res.json +237,23600,23700,100,100,0,complete,0.904,0.58,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23600_23699\metrics_res.json +238,23700,23757,57,57,0,complete,0.965,0.772,./benchmark-data\runs\cdm_official_unimer_full\unimer_test_full_cdm_shard_23700_23756\metrics_res.json diff --git a/benchmarks/mathcraft_ocr/run_mathwriting_official_cdm.ps1 b/benchmarks/mathcraft_ocr/run_mathwriting_official_cdm.ps1 index 461d37e6..edb9b770 100644 --- a/benchmarks/mathcraft_ocr/run_mathwriting_official_cdm.ps1 +++ b/benchmarks/mathcraft_ocr/run_mathwriting_official_cdm.ps1 @@ -1,10 +1,12 @@ param( - [string]$RepoRoot = "E:\LaTexSnipper", - [string]$DataRoot = "E:\MathCraftBenchData", - [string]$CdmDir = "E:\MathCraftBenchData\sources\UniMERNet_official\cdm", - [string]$Python = "D:\Python312\python.exe", - [string]$InputPath = "E:\MathCraftBenchData\runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json", - [string]$OutputDir = "E:\MathCraftBenchData\runs\cdm_official_mathwriting_test", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [Parameter(Mandatory = $true)] + [string]$DataRoot, + [string]$CdmDir = (Join-Path $DataRoot "sources\UniMERNet_official\cdm"), + [string]$Python = "python", + [string]$PathPrepend = "", + [string]$InputPath = (Join-Path $DataRoot "runs\mathwriting_test_gpu\cdm_input\mathwriting_test_full_cdm.json"), + [string]$OutputDir = (Join-Path $DataRoot "runs\cdm_official_mathwriting_test"), [int]$ShardSize = 100, [int]$Pools = 8, [int]$StartOffset = 0, @@ -17,7 +19,7 @@ $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest $runner = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\reports\run_official_cdm.py" -$pathPrepend = "E:\MathCraftBenchData\tools\unix_shims;E:\MathCraftBenchData\tools\Ghostscript\gs10.07.1\bin;C:\Program Files\ImageMagick-7.1.2-Q16" +$Python = (Get-Command $Python -CommandType Application -ErrorAction Stop).Source foreach ($path in @($runner, $CdmDir, $Python, $InputPath)) { if (-not (Test-Path -LiteralPath $path)) { @@ -31,7 +33,6 @@ $argsList = @( "--output-dir", $OutputDir, "--cdm-dir", $CdmDir, "--python", $Python, - "--path-prepend", $pathPrepend, "--shard-size", $ShardSize, "--pools", $Pools, "--start-offset", $StartOffset, @@ -39,8 +40,14 @@ $argsList = @( "--max-shards", $MaxShards ) +if ($PathPrepend) { + $argsList += @("--path-prepend", $PathPrepend) +} + if ($Force) { $argsList += "--force" } & $Python @argsList + +exit $LASTEXITCODE diff --git a/benchmarks/mathcraft_ocr/run_mathwriting_test.ps1 b/benchmarks/mathcraft_ocr/run_mathwriting_test.ps1 index c28681bd..56846d01 100644 --- a/benchmarks/mathcraft_ocr/run_mathwriting_test.ps1 +++ b/benchmarks/mathcraft_ocr/run_mathwriting_test.ps1 @@ -1,6 +1,8 @@ param( - [string]$RepoRoot = "E:\LaTexSnipper", - [string]$DataRoot = "E:\MathCraftBenchData", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$PythonPath = "python", + [Parameter(Mandatory = $true)] + [string]$DataRoot, [ValidateSet("gpu")] [string]$Provider = "gpu", [int]$ShardSize = 100, @@ -24,7 +26,7 @@ if ($Limit -lt 0) { throw "Limit must be non-negative." } -$python = Join-Path $RepoRoot "tools\deps\python311\python.exe" +$python = (Get-Command $PythonPath -CommandType Application -ErrorAction Stop).Source $manifestScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\datasets\create_mathwriting_manifest.py" $runnerScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\runners\run_mathcraft.py" $combineScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\reports\combine_jsonl.py" diff --git a/benchmarks/mathcraft_ocr/run_official_cdm.ps1 b/benchmarks/mathcraft_ocr/run_official_cdm.ps1 index e04575ad..a9f7f706 100644 --- a/benchmarks/mathcraft_ocr/run_official_cdm.ps1 +++ b/benchmarks/mathcraft_ocr/run_official_cdm.ps1 @@ -1,10 +1,12 @@ param( - [string]$RepoRoot = "E:\LaTexSnipper", - [string]$DataRoot = "E:\MathCraftBenchData", - [string]$CdmDir = "E:\MathCraftBenchData\sources\UniMERNet_official\cdm", - [string]$Python = "D:\Python312\python.exe", - [string]$InputPath = "E:\MathCraftBenchData\runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json", - [string]$OutputDir = "E:\MathCraftBenchData\runs\cdm_official_unimer_full", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [Parameter(Mandatory = $true)] + [string]$DataRoot, + [string]$CdmDir = (Join-Path $DataRoot "sources\UniMERNet_official\cdm"), + [string]$Python = "python", + [string]$PathPrepend = "", + [string]$InputPath = (Join-Path $DataRoot "runs\unimer_test_gpu\cdm_input\unimer_test_full_cdm.json"), + [string]$OutputDir = (Join-Path $DataRoot "runs\cdm_official_unimer_full"), [int]$ShardSize = 100, [int]$Pools = 8, [int]$StartOffset = 0, @@ -21,7 +23,7 @@ if ($Pools -gt 8) { } $runner = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\reports\run_official_cdm.py" -$pathPrepend = "E:\MathCraftBenchData\tools\unix_shims;E:\MathCraftBenchData\tools\Ghostscript\gs10.07.1\bin;C:\Program Files\ImageMagick-7.1.2-Q16" +$Python = (Get-Command $Python -CommandType Application -ErrorAction Stop).Source foreach ($path in @($runner, $CdmDir, $Python, $InputPath)) { if (-not (Test-Path -LiteralPath $path)) { @@ -35,7 +37,6 @@ $argsList = @( "--output-dir", $OutputDir, "--cdm-dir", $CdmDir, "--python", $Python, - "--path-prepend", $pathPrepend, "--shard-size", $ShardSize, "--pools", $Pools, "--start-offset", $StartOffset, @@ -43,8 +44,14 @@ $argsList = @( "--max-shards", $MaxShards ) +if ($PathPrepend) { + $argsList += @("--path-prepend", $PathPrepend) +} + if ($Force) { $argsList += "--force" } & $Python @argsList + +exit $LASTEXITCODE diff --git a/benchmarks/mathcraft_ocr/run_openstax_pages.ps1 b/benchmarks/mathcraft_ocr/run_openstax_pages.ps1 index 93191d60..d6bbfea6 100644 --- a/benchmarks/mathcraft_ocr/run_openstax_pages.ps1 +++ b/benchmarks/mathcraft_ocr/run_openstax_pages.ps1 @@ -1,6 +1,8 @@ param( - [string]$RepoRoot = "E:\LaTexSnipper", - [string]$DataRoot = "E:\MathCraftBenchData", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$PythonPath = "python", + [Parameter(Mandatory = $true)] + [string]$DataRoot, [ValidateSet("gpu", "cpu", "auto")] [string]$Provider = "gpu", [string]$Pages = "calculus_v1:40-89+120-169,college_algebra:60-109+180-229", @@ -29,7 +31,7 @@ if ($Dpi -le 0) { throw "Dpi must be positive." } -$python = Join-Path $RepoRoot "tools\deps\python311\python.exe" +$python = (Get-Command $PythonPath -CommandType Application -ErrorAction Stop).Source $manifestScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\datasets\create_openstax_manifest.py" $runnerScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\runners\run_mathcraft.py" $combineScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\reports\combine_jsonl.py" diff --git a/benchmarks/mathcraft_ocr/run_unimer_test.ps1 b/benchmarks/mathcraft_ocr/run_unimer_test.ps1 index 2e95fb9f..4868e966 100644 --- a/benchmarks/mathcraft_ocr/run_unimer_test.ps1 +++ b/benchmarks/mathcraft_ocr/run_unimer_test.ps1 @@ -1,6 +1,8 @@ param( - [string]$RepoRoot = "E:\LaTexSnipper", - [string]$DataRoot = "E:\MathCraftBenchData", + [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path, + [string]$PythonPath = "python", + [Parameter(Mandatory = $true)] + [string]$DataRoot, [ValidateSet("gpu", "cpu", "auto")] [string]$Provider = "gpu", [int]$ShardSize = 250, @@ -22,7 +24,7 @@ if ($Limit -lt 0) { throw "Limit must be non-negative." } -$python = Join-Path $RepoRoot "tools\deps\python311\python.exe" +$python = (Get-Command $PythonPath -CommandType Application -ErrorAction Stop).Source $manifestScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\datasets\create_unimer_manifest.py" $runnerScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\runners\run_mathcraft.py" $combineScript = Join-Path $RepoRoot "benchmarks\mathcraft_ocr\reports\combine_jsonl.py" diff --git a/benchmarks/mathcraft_ocr/visualization/make_openstax_block_gallery.py b/benchmarks/mathcraft_ocr/visualization/make_openstax_block_gallery.py index e2f84d5d..bb49d8da 100644 --- a/benchmarks/mathcraft_ocr/visualization/make_openstax_block_gallery.py +++ b/benchmarks/mathcraft_ocr/visualization/make_openstax_block_gallery.py @@ -312,12 +312,14 @@ def is_formula_block(block: dict[str, Any]) -> bool: def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: candidates = [ - Path("C:/Windows/Fonts/arial.ttf"), - Path("C:/Windows/Fonts/calibri.ttf"), + "arial.ttf", + "calibri.ttf", ] - for path in candidates: - if path.exists(): - return ImageFont.truetype(str(path), size=size) + for font in candidates: + try: + return ImageFont.truetype(font, size=size) + except OSError: + continue return ImageFont.load_default() diff --git a/docs/client_onboarding_plan.md b/docs/client_onboarding_plan.md new file mode 100644 index 00000000..bd4a9ced --- /dev/null +++ b/docs/client_onboarding_plan.md @@ -0,0 +1,72 @@ +# 客户端首次使用引导方案 + +日期:2026-09-23 +状态:方案已确认,待实现。本文描述目标行为,不代表功能已经上线。 +适用范围:客户端主窗口;不涉及 Office 插件编辑器。 + +## 1. 目标与入口 + +首次进入主窗口后,通过轻量高亮引导帮助用户认识截图识别、结果、导出、快捷键和模型准备状态。复用现有 PyQt6 / QFluentWidgets 界面、配置与模型生命周期,不另建初始化流程。 + +主窗口“截图识别”右侧新增紧凑的“ⓘ 快速入门”入口。截图识别保持主操作视觉层级并占据剩余宽度,引导按钮按内容确定宽度,不将原按钮机械地平分。空间不足时显示图标,保留提示文字和可访问名称。点击该入口始终可以重新查看引导。 + +欢迎和结束使用居中卡片,中间步骤通过柔和遮罩、高亮区域和说明卡片指向真实控件。步骤指示、上一页、下一页、跳过及关闭行为保持一致;Esc 可以退出。不通过启动全屏网页或独立安装向导替代主窗口交互。 + +## 2. 六步内容 + +| 步骤 | 标题与说明 | 操作与定位 | +| --- | --- | --- | +| 1 | 欢迎使用 LaTeXSnipper / 您的快速上手向导。 | 居中欢迎卡片;开始引导、跳过 | +| 2 | 从这里开始 / 识别、预览,再导出您的公式。 | 在同一步中突出截图识别、实时渲染预览区和导出入口,不要求用户必须识别成功才能继续 | +| 3 | 让操作更顺手 / 设置您习惯的截图快捷键。 | 显示当前实际快捷键;修改快捷键、下一步;打开现有配置界面,保存或取消后返回本步 | +| 4 | 关于模型准备 / 本地模型会在后台准备,首次使用可能需要下载。您可以在日志中查看详情。 | 固定说明与“查看日志”入口;复用现有忙碌状态显示不确定态 ProgressRing,不阻塞继续 | +| 5 | 一起让它变得更好 / 如果 LaTeXSnipper 对您有帮助,欢迎支持项目。 | Star、问题反馈、社区 / 支持项目;全部可选,可直接下一步 | +| 6 | 开始使用吧 / 欢迎,让公式处理更简单。 | 固定“开始使用”按钮,关闭引导并回到主窗口 | + +标题与说明接入现有国际化机制。文案保持简洁;不使用倒计时、强制赞助、强制打开网页或强制配置快捷键。 + +## 3. 模型准备说明 + +第四步统一使用说明性文案,介绍本地模型后台准备和日志入口。只复用主窗口已有的忙碌状态控制不确定态 ProgressRing,不新增下载进度、成功、失败、外部模型等引导专用状态分支,也不提供引导专用重试逻辑。具体运行状态、错误和配置继续由现有主窗口、日志及设置界面负责。 + +引导不重复启动下载或预热,不等待模型就绪,也不因关闭而取消后台任务。最后一步固定为“开始使用吧”和“开始使用”按钮,不订阅模型状态,不切换标题或操作。所有模型状态下均使用同一套六步流程。 + +## 4. 首次显示、退出与重新查看 + +- 主窗口完成首次布局且可交互后再显示;避免遮挡启动错误或依赖配置对话框。同一时刻只存在一个引导实例。 +- 自动引导完成或确认退出时,统一保存 `onboarding_auto_show: false`,不再自动弹出。跳过、关闭或按 Esc 时显示退出确认卡片;“继续引导”返回当前步骤,“退出引导”确认退出。确认卡片中再次按 Esc 或点击关闭则返回当前步骤,不修改偏好。 +- 需要重看时,点击主窗口“快速入门”。手动重看不修改自动显示偏好。 +- 直接关闭应用不修改偏好。再次自动显示时从欢迎页开始,不持久化步骤编号。 +- 配置只保存 `onboarding_auto_show` 一个布尔值,默认开启,普通版本升级不重置。 + +## 5. 焦点与辅助操作 + +打开运行日志、快捷键设置或其他配置界面时,暂停遮罩及步骤输入,让现有窗口正常交互;返回主窗口后恢复相应步骤。引导中不启动实际截图识别;用户结束引导后通过主窗口操作,不建立截图完成后恢复引导的回调。 + +步骤切换保持键盘焦点可见,支持 Tab 操作,窗口缩放或移动后重新计算高亮位置。目标控件暂不可见时采用可读的说明卡片或定位到当前有效入口,不出现悬空高亮。重复点击引导入口只聚焦已有引导,不叠加窗口。 + +## 6. 支持项目入口 + +- Star 打开项目主页,由用户自行操作。 +- 问题反馈打开项目 Issues。 +- “社区 / 支持项目”合并为一个入口,按当前界面语言打开中文 README 的“支持本项目”区域或英文 README 的“Support the Project”区域;不自动加载远程二维码,不把三个大二维码同时放入引导。 +- 链接复用现有项目地址定义;只有明确点击才打开外部浏览器。无法打开时显示简洁提示并允许继续。 +- 后续若需要内嵌二维码,再单独确定资源来源与更新策略,不作为本阶段前置条件。 + +## 7. 实现边界 + +使用一个引导控制器管理步骤、显示来源、暂停恢复和退出;步骤定义描述文案、目标控件和动作;共享高亮 / 卡片组件只负责显示。仅复用现有忙碌状态和设置入口,不复制模型状态机,不新增进度协议、轮询或结束页回调。 + +配置中只保留一个自动显示偏好。无需旧引导迁移、新版本强制重播、多个完成标志或重复状态存储。不要将本机依赖路径写入引导、构建配置或共享开发文档。 + +## 8. 分阶段实施与验收 + +1. **引导基础与主窗口入口:** 实现紧凑入口、六步框架、定位遮罩、键盘交互、首次显示、完成 / 退出偏好及手动重播。验证窗口缩放、重复打开、完成后重启和中途退出及直接关闭应用。 +2. **业务状态与操作接入:** 接通实际快捷键、日志、现有忙碌指示和支持链接。验证打开设置后返回,以及模型运行状态不改变引导流程、引导关闭不取消后台任务。 +3. **视觉与交互验收:** 检查明暗主题、中文 / 英文、不同 DPI 和窗口尺寸、焦点恢复;确认没有遮罩残留或重复预热,结束页文案与按钮始终一致。更新当前用户手册后再构建发布。 + +各阶段独立检查差异并验证当前行为;不添加只证明旧文件或旧接口不存在的墓碑测试。当前已实现六步引导、入口、偏好、快捷键与日志返回、支持链接和忙碌指示。行为测试覆盖完成、退出、重播、弹窗返回、焦点、缩放与固定结束页;真实主窗口及不同 DPI 的桌面验收仍待完成。 + +## 9. 后续工作接续 + +在客户端 worktree 中先检查分支和工作树,继续第 8 节第 3 项的真实桌面验收。入口参考 `src/ui/main_window_setup.py` 中的 `capture_button`;启动时机参考 `src/ui/main_window.py`;状态参考 `src/ui/model_runtime_controller.py`;配置继续使用现有配置管理器。 diff --git a/docs/faq.md b/docs/faq.md index 9c3577c9..88f54d54 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -106,7 +106,7 @@ Pandoc does not follow the active dependency root. It is deployed once under the app-managed tools directory and remains available after the user switches Python dependency roots. -Linux/macOS release packages do not bundle build-machine environments from `tools/deps/`. +Linux/macOS release packages do not bundle build-machine virtual environments. ## When does Dependency Management initialize pip? @@ -173,7 +173,7 @@ Wayland restricts application-level screen capture. LaTeXSnipper uses Qt capture Pandoc is only needed for the optional desktop export formats: Word `.docx`, ODT `.odt`, PowerPoint `.pptx`, EPUB `.epub`, PDF `.pdf`, standalone HTML `.html`, Typst `.typ`, and plain text `.txt`. PDF export also requires a LaTeX PDF engine such as XeLaTeX, LuaLaTeX, or pdfLaTeX. Core recognition, editing, preview, handwriting, and built-in LaTeX/Markdown/MathML/HTML/SVG exports work without Pandoc. -Dependency Management manages the optional `PANDOC` layer. Manually downloaded or generated Pandoc binaries should not live under `src/`; local developer/build tools belong under `tools/deps/` or the app-managed dependency directory. +Dependency Management manages the optional `PANDOC` layer. Manually downloaded or generated Pandoc binaries should not live under `src/`; developer tools may be installed in a location of your choice; app-managed tools use the application data directory. ## How does PDF recognition work? @@ -208,4 +208,4 @@ Issues without logs are usually not actionable. ## Which Python environment should contributors use? -Use `tools/deps/python311` for local development, checks, packaging helpers, and IDE integration. The repository-root `python311/` is the Windows template runtime and must not be polluted with development packages or used for ruff, pyright, pytest, or builds. +Use a supported Python environment of your choice (venv, Conda, uv, or another manager). Activate it for development and checks, and select the same interpreter in your IDE. No repository-specific environment path is required. Client installers are built by `.github/workflows/release.yml`; Windows runtime preparation happens in the runner temporary directory, independently of developer environments. diff --git a/docs/latexsnipper-3.0.0.png b/docs/latexsnipper.png similarity index 100% rename from docs/latexsnipper-3.0.0.png rename to docs/latexsnipper.png diff --git a/docs/mathcraft_ocr_literature_data_inventory.md b/docs/mathcraft_ocr_literature_data_inventory.md index 44f6c194..9cc6e9d5 100644 --- a/docs/mathcraft_ocr_literature_data_inventory.md +++ b/docs/mathcraft_ocr_literature_data_inventory.md @@ -6,19 +6,19 @@ This inventory records the paper evidence that is already reproducible, the public datasets still worth adding, and the claims each source can support. The benchmark repository should contain scripts, schemas, reports, and compact tables. Downloaded PDFs, rendered pages, large JSONL files, and generated figure -assets stay under `E:\MathCraftBenchData`. +assets stay under `./benchmark-data`. ## Current Evidence Status | Evidence line | Status | Local artifact | Paper use | | --- | --- | --- | --- | -| UniMER-Test formula OCR | Formal run complete | `E:\MathCraftBenchData\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl` | Main formula-recognition benchmark | -| UniMER-Test render consistency | Formal auxiliary complete | `E:\MathCraftBenchData\runs\unimer_test_gpu\render_exact_underestimated` | Show exact-match underestimation cases | -| OpenStax mixed pages | Formal run complete | `E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi` | Main mixed-document evidence | -| OpenStax block visualizations | Regenerable figure assets complete | `E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\figures\block_gallery` | Qualitative structure-recovery figures | -| MathWriting | Formal run complete | `E:\MathCraftBenchData\runs\mathwriting_test_gpu` | Independent handwritten benchmark and exact-match stress evidence | +| UniMER-Test formula OCR | Formal run complete | `./benchmark-data\runs\unimer_test_gpu\unimer_test_gpu_full.jsonl` | Main formula-recognition benchmark | +| UniMER-Test render consistency | Formal auxiliary complete | `./benchmark-data\runs\unimer_test_gpu\render_exact_underestimated` | Show exact-match underestimation cases | +| OpenStax mixed pages | Formal run complete | `./benchmark-data\runs\openstax_mixed_gpu_144dpi` | Main mixed-document evidence | +| OpenStax block visualizations | Regenerable figure assets complete | `./benchmark-data\runs\openstax_mixed_gpu_144dpi\figures\block_gallery` | Qualitative structure-recovery figures | +| MathWriting | Formal run complete | `./benchmark-data\runs\mathwriting_test_gpu` | Independent handwritten benchmark and exact-match stress evidence | | CROHME | Candidate only | Not downloaded as formal benchmark | Standard HME comparison if data access is cleared | -| CDM | Official full run complete | `E:\MathCraftBenchData\runs\cdm_official_unimer_full` | Full UniMER-Test render-aware evidence: mean CDM 0.929, ExpRate@CDM 0.648 | +| CDM | Official full run complete | `./benchmark-data\runs\cdm_official_unimer_full` | Full UniMER-Test render-aware evidence: mean CDM 0.929, ExpRate@CDM 0.648 | ## Formal Public Benchmarks @@ -30,7 +30,7 @@ Source: [UniMERNet paper](https://arxiv.org/abs/2404.15254), Local manifest: ```text -E:\MathCraftBenchData\manifests\unimer_test_full.jsonl +./benchmark-data\manifests\unimer_test_full.jsonl ``` Scale: @@ -46,7 +46,7 @@ Scale: Current MathCraft result: ```text -E:\LaTexSnipper\benchmarks\mathcraft_ocr\results\unimer_test_gpu +.\benchmarks\mathcraft_ocr\results\unimer_test_gpu ``` Use in paper: @@ -77,13 +77,13 @@ Local protocol: Local manifest: ```text -E:\MathCraftBenchData\manifests\mathwriting_test.jsonl +./benchmark-data\manifests\mathwriting_test.jsonl ``` Run entry: ```powershell -powershell -ExecutionPolicy Bypass -File E:\LaTexSnipper\benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 +powershell -ExecutionPolicy Bypass -File .\benchmarks\mathcraft_ocr\run_mathwriting_test.ps1 ``` Current MathCraft result: @@ -135,7 +135,7 @@ Source pages are rendered from public OpenStax PDFs: Local formal run: ```text -E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi +./benchmark-data\runs\openstax_mixed_gpu_144dpi ``` Default protocol: @@ -155,16 +155,16 @@ Default protocol: Source-control evidence: ```text -E:\LaTexSnipper\benchmarks\mathcraft_ocr\results\openstax_mixed_gpu_144dpi +.\benchmarks\mathcraft_ocr\results\openstax_mixed_gpu_144dpi ``` Figure-generation entry: ```powershell -E:\LaTexSnipper\tools\deps\python311\python.exe ` - E:\LaTexSnipper\benchmarks\mathcraft_ocr\visualization\make_openstax_block_gallery.py ` - --results E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` - --output-dir E:\MathCraftBenchData\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` +python ` + .\benchmarks\mathcraft_ocr\visualization\make_openstax_block_gallery.py ` + --results ./benchmark-data\runs\openstax_mixed_gpu_144dpi\openstax_mixed_gpu_144dpi_full.jsonl ` + --output-dir ./benchmark-data\runs\openstax_mixed_gpu_144dpi\figures\block_gallery ` --limit 6 ``` @@ -214,7 +214,7 @@ Status: complete. Implementation: - Uses the official UniMERNet CDM implementation from the local clone under - `E:\MathCraftBenchData\sources\UniMERNet_official\cdm`. + `./benchmark-data\sources\UniMERNet_official\cdm`. - Converts MathCraft outputs into the official `img_id` / `gt` / `pred` batch format before running the official CDM scorer. - Keeps the current render-consistency fallback under a different name; it is @@ -263,9 +263,9 @@ models are rerun under the same local protocol. | Source | Repository policy | | --- | --- | -| UniMER-Test | Keep generated compact reports in repo; keep full data/results under `E:\MathCraftBenchData` | +| UniMER-Test | Keep generated compact reports in repo; keep full data/results under `./benchmark-data` | | OpenStax | Keep scripts and compact metrics in repo; keep rendered page images and overlays local unless publication license review is complete | -| MathWriting | Keep scripts and compact metrics in repo; keep parquet, images, full results, and CDM render artifacts under `E:\MathCraftBenchData` | +| MathWriting | Keep scripts and compact metrics in repo; keep parquet, images, full results, and CDM render artifacts under `./benchmark-data` | | CROHME/HME100K | Do not add to formal benchmark until license/access is recorded | | Project `test_pdf` assets | Internal regression only unless the original PDF source is public and attributable | diff --git a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md index d5fb53b0..f4353716 100644 --- a/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md +++ b/office_plugin/tests/LaTeXSnipper.OfficePlugin.WordParsingE2E/README.md @@ -19,7 +19,7 @@ 运行前必须关闭所有 Word 窗口,且测试完成前不要再次启动 Word,以确保测试实例与日常文档隔离: ```powershell -E:\LaTexSnipper\office_plugin\tools\Test-WordFormulaParsingE2E.ps1 +.\office_plugin\tools\Test-WordFormulaParsingE2E.ps1 ``` 通过后,测试项目的 `artifacts` 目录会生成 OMML/OLE 两份 DOCX 和 Word 导出的同名 PDF。该目录已被 Git 忽略,只用于本地结构与视觉复核;可通过脚本的 `-OutputDirectory` 参数改写输出位置。 diff --git a/office_plugin/tools/Build-NativeOleHandler.ps1 b/office_plugin/tools/Build-NativeOleHandler.ps1 index 60ee92b5..b67e9477 100644 --- a/office_plugin/tools/Build-NativeOleHandler.ps1 +++ b/office_plugin/tools/Build-NativeOleHandler.ps1 @@ -37,7 +37,6 @@ function Find-NativeBuildEnvironment { } foreach ($candidate in @( - "D:\Microsoft Visual Studio\2022\Community", "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community", "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Community" )) { diff --git a/office_plugin/tools/Build-VstoAddIns.ps1 b/office_plugin/tools/Build-VstoAddIns.ps1 index 5df1031a..a4ba8f89 100644 --- a/office_plugin/tools/Build-VstoAddIns.ps1 +++ b/office_plugin/tools/Build-VstoAddIns.ps1 @@ -47,7 +47,6 @@ function Find-VstoBuildEnvironment { } foreach ($candidate in @( - "D:\Microsoft Visual Studio\2022\Community", "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community", "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Community" )) { diff --git a/pyproject.toml b/pyproject.toml index abd25ae2..b43cc681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,14 +89,10 @@ mathcraft_ocr = ["manifests/*.json"] [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["test"] -addopts = ["--ignore=python311", "--ignore=tools/deps", "--ignore=.venv", "--ignore=build", "--ignore=dist"] +addopts = ["--ignore=.venv", "--ignore=build", "--ignore=dist"] [tool.vulture] paths = ["src"] -exclude = [ - "build/", - "python311/", - "tools/deps/python311/" -] +exclude = ["build/", "dist/", ".venv/"] min_confidence = 70 sort_by_size = true diff --git a/pyrightconfig.json b/pyrightconfig.json index d8550d6d..15c00e8d 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -7,14 +7,13 @@ ], "extraPaths": [ ".", - "src", - "tools/deps/python311/Lib/site-packages" + "src" ], "exclude": [ "**/__pycache__", "build", - "python311", - "tools/deps/python311" + "dist", + ".venv" ], "reportUnusedImport": "warning", "reportUnusedVariable": "warning", @@ -29,8 +28,7 @@ { "root": "src", "extraPaths": [ - ".", - "tools/deps/python311/Lib/site-packages" + "." ] } ] diff --git a/readme.md b/readme.md index be5f7849..945177ef 100644 --- a/readme.md +++ b/readme.md @@ -14,7 +14,7 @@ English · [简体中文](README.zh-CN.md) -LaTeXSnipper v3.0.0: recognition history, formula editor, and live preview +LaTeXSnipper: recognition history, formula editor, and live preview diff --git a/scripts/build_deb.sh b/scripts/build_deb.sh index be0b58c3..bc9eda70 100755 --- a/scripts/build_deb.sh +++ b/scripts/build_deb.sh @@ -31,8 +31,8 @@ command -v python3 >/dev/null 2>&1 || die "python3 is required" [[ -f "$SPEC_FILE" ]] || die "missing spec file: $SPEC_FILE" clear_debian_package_outputs "$DEB_PATH" "$DEB_OUTPUT_DIR/SHA256SUMS-linux.txt" -log_step "1/5" "Preparing isolated Python runtime" -BUILD_PYTHON="$(prepare_python_runtime "$PROJECT_ROOT")" +log_step "1/5" "Using the runner Python environment" +BUILD_PYTHON="$(resolve_build_python)" install_python_requirements \ "$BUILD_PYTHON" \ "$PROJECT_ROOT/requirements-linux.txt" \ @@ -50,7 +50,7 @@ cd "$PROJECT_ROOT" [[ -d "$DIST_DIR" ]] || die "PyInstaller output was not created: $DIST_DIR" PYINSTALLER_BIN="$DIST_DIR/LaTeXSnipper" -PYINSTALLER_ARCHIVE_VIEWER="$(dirname "$BUILD_PYTHON")/pyi-archive_viewer" +PYINSTALLER_ARCHIVE_VIEWER="$("$BUILD_PYTHON" -c 'import sysconfig; print(sysconfig.get_path("scripts"))')/pyi-archive_viewer" HOTKEY_ARCHIVE_VERIFIER="$PROJECT_ROOT/scripts/verify_linux_hotkey_archive.py" [[ -f "$PYINSTALLER_BIN" ]] || die "PyInstaller executable was not created: $PYINSTALLER_BIN" [[ -x "$PYINSTALLER_ARCHIVE_VIEWER" ]] || die "PyInstaller archive viewer was not found: $PYINSTALLER_ARCHIVE_VIEWER" diff --git a/scripts/build_github_release_installer.ps1 b/scripts/build_github_release_installer.ps1 index 48bca9f3..4bb791d3 100644 --- a/scripts/build_github_release_installer.ps1 +++ b/scripts/build_github_release_installer.ps1 @@ -1,9 +1,8 @@ param( - [switch]$Sign, - [string]$CertificateThumbprint = "", - [string]$TimestampUrl = "http://timestamp.digicert.com", - [string]$InnoCompiler = "", - [string]$PythonPath = "" + [Parameter(Mandatory = $true)] + [string]$BundledPythonPath, + [string]$PythonPath = "python", + [string]$InnoCompiler = "" ) $ErrorActionPreference = "Stop" @@ -34,101 +33,6 @@ function Find-Tool { throw "Could not find $ToolName." } -function Find-WindowsSdkTool { - param([string]$ToolName) - - $roots = @() - if ($env:ProgramFiles) { - $roots += (Join-Path $env:ProgramFiles "Windows Kits\10\bin") - } - $programFilesX86 = ${env:ProgramFiles(x86)} - if ($programFilesX86) { - $roots += (Join-Path $programFilesX86 "Windows Kits\10\bin") - } - - foreach ($root in $roots) { - if (-not (Test-Path $root)) { - continue - } - $candidate = Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | - Sort-Object Name -Descending | - ForEach-Object { Join-Path $_.FullName "x64\$ToolName" } | - Where-Object { Test-Path $_ } | - Select-Object -First 1 - if ($candidate) { - return $candidate - } - } - - $command = Get-Command $ToolName -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - - throw "Could not find $ToolName. Install the Windows SDK or put it on PATH." -} - -function Resolve-BuildPython { - param( - [string]$Root, - [string]$RequestedPython - ) - - $candidates = @() - if (-not [string]::IsNullOrWhiteSpace($RequestedPython)) { - $candidates += $RequestedPython - } - - $candidates += (Join-Path $Root "tools\deps\python311\python.exe") - - foreach ($candidate in $candidates) { - if ([string]::IsNullOrWhiteSpace($candidate)) { - continue - } - if (Test-Path $candidate) { - return (Resolve-Path $candidate).Path - } - $command = Get-Command $candidate -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - } - - $pythonCommand = Get-Command "python" -ErrorAction SilentlyContinue - if ($pythonCommand) { - return $pythonCommand.Source - } - - throw "Could not find build Python. Pass -PythonPath or install Python on PATH." -} - -function Invoke-CodeSign { - param( - [string]$Signtool, - [string]$Path, - [string]$Thumbprint, - [string]$TimestampUrl - ) - - if (-not (Test-Path $Path)) { - throw "Cannot sign missing file: $Path" - } - - $args = @("sign", "/fd", "SHA256", "/td", "SHA256", "/tr", $TimestampUrl) - if ([string]::IsNullOrWhiteSpace($Thumbprint)) { - $args += "/a" - } - else { - $args += @("/sha1", $Thumbprint) - } - $args += $Path - - & $Signtool @args - if ($LASTEXITCODE -ne 0) { - throw "signtool failed with exit code $LASTEXITCODE for $Path" - } -} - function Write-Sha256File { param([string]$Path) @@ -184,13 +88,7 @@ if "HTTPSHandler" not in handlers: } function Normalize-BundledPythonSeed { - param([string]$Root) - - $seedRoot = Join-Path $Root "python311" - if (-not (Test-Path $seedRoot)) { - Write-Host "Bundled Python seed not found, skip normalization: $seedRoot" - return - } + param([string]$SeedRoot) $pythonExe = Join-Path $seedRoot "python.exe" if (-not (Test-Path $pythonExe)) { @@ -377,37 +275,14 @@ function Remove-PythonCache { Remove-Item -Force } -function Stage-BundledPythonSeed { - param([string]$Root) - - $source = Join-Path $Root "python311" - if (-not (Test-Path -LiteralPath $source)) { - throw "Bundled Python template not found: $source" - } - - $stagingBase = Join-Path $Root "build\github-release" - New-Item -ItemType Directory -Path $stagingBase -Force | Out-Null - $stagingBase = (Resolve-Path -LiteralPath $stagingBase).Path - $stagedRoot = Join-Path $stagingBase "bundled-deps" - if (Test-Path -LiteralPath $stagedRoot) { - $resolvedStagedRoot = (Resolve-Path -LiteralPath $stagedRoot).Path - $expectedPrefix = $stagingBase.TrimEnd('\') + '\' - if (-not $resolvedStagedRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing to replace bundled dependency stage outside build directory: $resolvedStagedRoot" - } - Remove-Item -LiteralPath $stagedRoot -Recurse -Force - } - New-Item -ItemType Directory -Path $stagedRoot -Force | Out-Null - Copy-Item -LiteralPath $source -Destination (Join-Path $stagedRoot "python311") -Recurse -Force - - Write-Host "Bundled Python template staged: $stagedRoot" - return $stagedRoot -} - $root = Resolve-RepoRoot -$python = Resolve-BuildPython -Root $root -RequestedPython $PythonPath -$bundledDepsRoot = Stage-BundledPythonSeed -Root $root -Normalize-BundledPythonSeed -Root $bundledDepsRoot +$python = (Get-Command $PythonPath -CommandType Application -ErrorAction Stop).Source +$bundledPython = (Resolve-Path -LiteralPath $BundledPythonPath).Path +$runnerTemp = (Resolve-Path -LiteralPath $env:RUNNER_TEMP).Path.TrimEnd('\') + '\' +if (-not $bundledPython.StartsWith($runnerTemp, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Bundled Python must be prepared inside RUNNER_TEMP: $bundledPython" +} +Normalize-BundledPythonSeed -SeedRoot $bundledPython $isccCandidates = @() if ($InnoCompiler) { @@ -419,7 +294,6 @@ if (${env:ProgramFiles(x86)}) { if ($env:ProgramFiles) { $isccCandidates += (Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe") } -$isccCandidates += "D:\Program Files (x86)\Inno Setup 6\ISCC.exe" $iscc = Find-Tool -ToolName "ISCC.exe" -Candidates $isccCandidates $buildName = "LaTeXSnipper" @@ -438,10 +312,10 @@ if (-not (Test-Path $iss)) { } $oldBuildName = $env:LATEXSNIPPER_BUILD_NAME -$oldBundledDepsDir = $env:LATEXSNIPPER_BUNDLED_DEPS_DIR +$oldBundledPython = $env:LATEXSNIPPER_BUNDLED_PYTHON try { $env:LATEXSNIPPER_BUILD_NAME = $buildName - $env:LATEXSNIPPER_BUNDLED_DEPS_DIR = $bundledDepsRoot + $env:LATEXSNIPPER_BUNDLED_PYTHON = $bundledPython foreach ($path in @($distAppDir, $pyinstallerWorkDir)) { if (Test-Path -LiteralPath $path) { @@ -476,7 +350,7 @@ try { } finally { $env:LATEXSNIPPER_BUILD_NAME = $oldBuildName - $env:LATEXSNIPPER_BUNDLED_DEPS_DIR = $oldBundledDepsDir + $env:LATEXSNIPPER_BUNDLED_PYTHON = $oldBundledPython } $appExe = Join-Path $distAppDir "$buildName.exe" @@ -484,26 +358,13 @@ if (-not (Test-Path $appExe)) { throw "PyInstaller output exe not found: $appExe" } -$signtool = "" -if ($Sign) { - $signtool = Find-WindowsSdkTool -ToolName "signtool.exe" - Invoke-CodeSign -Signtool $signtool -Path $appExe -Thumbprint $CertificateThumbprint -TimestampUrl $TimestampUrl -} - -$oldRepoRoot = $env:LATEXSNIPPER_REPO_ROOT -try { - $env:LATEXSNIPPER_REPO_ROOT = $root - if (Test-Path $installerOutputDir) { - Get-ChildItem -LiteralPath $installerOutputDir -Filter "LaTeXSnipperSetup-*.exe" -File | - Remove-Item -Force - } - & $iscc $iss - if ($LASTEXITCODE -ne 0) { - throw "Inno Setup failed with exit code $LASTEXITCODE" - } +if (Test-Path $installerOutputDir) { + Get-ChildItem -LiteralPath $installerOutputDir -Filter "LaTeXSnipperSetup-*.exe" -File | + Remove-Item -Force } -finally { - $env:LATEXSNIPPER_REPO_ROOT = $oldRepoRoot +& $iscc $iss +if ($LASTEXITCODE -ne 0) { + throw "Inno Setup failed with exit code $LASTEXITCODE" } $installer = Get-ChildItem -LiteralPath $installerOutputDir -Filter "LaTeXSnipperSetup-*.exe" -File | @@ -513,10 +374,6 @@ if (-not $installer -or -not (Test-Path -LiteralPath $installer.FullName)) { throw "Installer output not found in: $installerOutputDir" } -if ($Sign) { - Invoke-CodeSign -Signtool $signtool -Path $installer.FullName -Thumbprint $CertificateThumbprint -TimestampUrl $TimestampUrl -} - $hash = Write-Sha256File -Path $installer.FullName Write-Host "" @@ -524,9 +381,4 @@ Write-Host "GitHub release installer created:" Write-Host " $($installer.FullName)" Write-Host "SHA256:" Write-Host " $hash" -if ($Sign) { - Write-Host "Signing: completed" -} -else { - Write-Host "Signing: skipped. Submit the installer to SignPath, or rerun with -Sign when a trusted code-signing certificate is available." -} +Write-Host "Signing is handled by the release workflow through SignPath." diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh index 298402fa..708216ec 100755 --- a/scripts/build_macos.sh +++ b/scripts/build_macos.sh @@ -57,8 +57,8 @@ if [[ ! -f "$ICON_SOURCE" && ! -f "$PROJECT_ROOT/src/assets/icon.icns" ]]; then echo "warning: no application icon source was found; the app will use the default icon" fi -log_step "1/6" "Preparing isolated Python runtime" -BUILD_PYTHON="$(prepare_python_runtime "$PROJECT_ROOT")" +log_step "1/6" "Using the runner Python environment" +BUILD_PYTHON="$(resolve_build_python)" install_python_requirements \ "$BUILD_PYTHON" \ "$PROJECT_ROOT/requirements-macos.txt" \ diff --git a/scripts/package_common.sh b/scripts/package_common.sh index 7debde7a..9c58defe 100644 --- a/scripts/package_common.sh +++ b/scripts/package_common.sh @@ -27,78 +27,9 @@ print(version) PY } -prepare_python_runtime() { - local project_root="$1" - local platform arch runtime_dir runtime_python create_python - platform="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m | tr '[:upper:]' '[:lower:]')" - runtime_dir="$project_root/tools/deps/python311-$platform-$arch" - runtime_python="$runtime_dir/bin/python3" - create_python="$(find_python311_command)" - [[ -n "$create_python" ]] || exit 1 - - mkdir -p "$(dirname "$runtime_dir")" - - local rebuild=false - if [[ ! -x "$runtime_python" ]]; then - rebuild=true - elif [[ -L "$runtime_python" ]]; then - rebuild=true - elif ! "$runtime_python" - <<'PY' >/dev/null 2>&1; then -import sys - -raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1) -PY - rebuild=true - elif ! "$runtime_python" -c "print('ok')" >/dev/null 2>&1; then - rebuild=true - fi - - if [[ "$rebuild" == "true" ]]; then - rm -rf "$runtime_dir" - "$create_python" -m venv --copies "$runtime_dir" || die "failed to create isolated Python 3.11 runtime at $runtime_dir" - fi - - "$runtime_python" - <<'PY' >/dev/null 2>&1 || die "isolated build runtime must be Python 3.11: $runtime_python" -import sys - -raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1) -PY - - "$runtime_python" -m ensurepip --upgrade >/dev/null 2>&1 || true - "$runtime_python" -m pip install --upgrade pip wheel setuptools >&2 - echo "$runtime_python" -} - -find_python311_command() { - local override="${PYTHON311:-}" - if [[ -n "$override" ]]; then - [[ -x "$override" ]] || die "PYTHON311 is not executable: $override" - "$override" - <<'PY' >/dev/null 2>&1 || die "PYTHON311 must point to Python 3.11: $override" -import sys - -raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1) -PY - echo "$override" - return - fi - - local candidate resolved - for candidate in python3.11 python3; do - if command -v "$candidate" >/dev/null 2>&1; then - resolved="$(command -v "$candidate")" - if "$resolved" - <<'PY' >/dev/null 2>&1; then -import sys - -raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1) -PY - echo "$resolved" - return - fi - fi - done - - die "Python 3.11 is required for packaging; install python3.11 or set PYTHON311=/path/to/python3.11" +# actions/setup-python supplies the build interpreter on PATH. +resolve_build_python() { + command -v python3 || die "Python is required on the release runner" } install_python_requirements() { diff --git a/src/application/app_runner.py b/src/application/app_runner.py index 42372a28..ef4b5bb7 100644 --- a/src/application/app_runner.py +++ b/src/application/app_runner.py @@ -7,7 +7,7 @@ from PyQt6.QtCore import QTimer from PyQt6.QtWidgets import QApplication -from application.dependency_controller import ensure_deps +from application.dependency_controller import dependencies_ready, ensure_deps from localization.manager import translate as tr from ui.runtime_log_controller import apply_runtime_log_window_preference from ui.startup_splash import ( @@ -39,7 +39,7 @@ def _create_window(main_window_cls, splash): win.show() win.start_post_show_tasks() QTimer.singleShot( - 0, lambda: apply_runtime_log_window_preference(force=False, tee=True) + 0, lambda: apply_runtime_log_window_preference(force=False) ) finish_startup_splash(splash, win) print("[INFO] 应用界面已就绪") @@ -74,7 +74,7 @@ def _run_development(app, main_window_cls) -> int: ) deps_check_message = startup_status_message(tr("检查依赖...")) update_startup_splash(splash, deps_check_message) - deps_ready_cached = os.environ.get("LATEXSNIPPER_DEPS_OK") == "1" + deps_ready_cached = dependencies_ready() needs_interactive_deps_ui = bool( open_dependency_management or (not deps_ready_cached) ) diff --git a/src/application/bootstrap.py b/src/application/bootstrap.py index 4a8e2ea6..7a3e4f9d 100644 --- a/src/application/bootstrap.py +++ b/src/application/bootstrap.py @@ -1,9 +1,8 @@ from __future__ import annotations import atexit -import json +import logging import os -import subprocess import sys from dataclasses import dataclass from pathlib import Path @@ -16,12 +15,10 @@ from preview.math_preview import configure_math_preview_runtime from runtime.app_paths import resource_path from runtime.native_runtime import configure_native_runtime_environment -from runtime.dependency_runtime import DEPENDENCY_PYTHON_DIRNAME, dependency_venv_python from application.python_runtime_resolver import ( APP_DIR, _append_private_site_packages, _clean_bad_env, - _config_path, _find_install_base_python, _in_ide, _initial_deps_dir, @@ -29,7 +26,6 @@ _relaunch_with, _same_exe, _sanitize_sys_path, - _win_subprocess_kwargs, ensure_full_python_or_prompt, resolve_install_base_dir, ) @@ -37,7 +33,7 @@ ensure_single_instance, release_single_instance_lock, ) -from application.dependency_controller import deps_force_entered, ensure_deps +from application.dependency_controller import ensure_deps from ui.startup_splash import ( ensure_startup_splash, hide_startup_splash_for_modal, @@ -100,72 +96,6 @@ def _ensure_src_path() -> None: sys.path.insert(0, src_dir) -def _maybe_redirect_to_private_python(install_base_dir: Path) -> None: - if not _is_packaged_mode(): - return - - py_exe_path = _find_install_base_python(install_base_dir) - py_exe = ( - py_exe_path - if py_exe_path is not None - else dependency_venv_python(install_base_dir / DEPENDENCY_PYTHON_DIRNAME) - ) - - if not py_exe.exists(): - print(f"[WARN] 依赖目录 Python 不存在,继续使用内置运行时: {py_exe}") - return - - if os.environ.get("LATEXSNIPPER_FORCE_PRIVATE_PY") != "1": - print("[DEBUG] 使用内置运行时启动,依赖目录由子进程使用") - return - - if os.environ.get("LATEXSNIPPER_INNER_PY") == "1": - print("[DEBUG] 已在依赖目录 Python 中运行") - return - - print(f"[DEBUG] 切换到依赖目录 Python: {py_exe}") - env = os.environ.copy() - env["LATEXSNIPPER_INNER_PY"] = "1" - - raw_pref = ( - (os.environ.get("LATEXSNIPPER_SHOW_RUNTIME_LOG", "") or "").strip().lower() - ) - if raw_pref in ("1", "true", "yes", "on", "0", "false", "no", "off"): - show_runtime_log = raw_pref in ("1", "true", "yes", "on") - else: - show_runtime_log = False - try: - cfg_path = _config_path() - if cfg_path.exists(): - cfg_data = json.loads(cfg_path.read_text(encoding="utf-8")) - raw = ( - cfg_data.get("show_runtime_log", False) - if isinstance(cfg_data, dict) - else False - ) - if isinstance(raw, bool): - show_runtime_log = raw - elif isinstance(raw, (int, float)): - show_runtime_log = bool(raw) - elif isinstance(raw, str): - show_runtime_log = raw.strip().lower() in ("1", "true", "yes", "on") - except Exception: - pass - env["LATEXSNIPPER_SHOW_RUNTIME_LOG"] = "1" if show_runtime_log else "0" - - run_py = py_exe - pyw = py_exe.parent / "pythonw.exe" - if pyw.exists(): - run_py = pyw - argv = [ - str(run_py), - os.path.join(os.path.dirname(os.path.dirname(__file__)), "main.py"), - *sys.argv[1:], - ] - subprocess.Popen(argv, env=env, **_win_subprocess_kwargs()) - raise SystemExit(0) - - def _prepare_python_runtime(install_base_dir: Path) -> tuple[Path, str]: base_dir = Path(install_base_dir) _clean_bad_env() @@ -187,7 +117,7 @@ def _prepare_python_runtime(install_base_dir: Path) -> tuple[Path, str]: if not _same_exe(sys.executable, target_py): _relaunch_with(target_py) elif _in_ide(): - print("[DEBUG] IDE 中运行,保持当前解释器并使用私有依赖路径") + logging.getLogger(__name__).debug("IDE 中运行,保持当前解释器并使用私有依赖路径") return base_dir, target_py @@ -213,7 +143,7 @@ def _prepare_python_runtime_for_dependency_management( os.environ.pop("PYTHONPATH", None) os.environ.pop("MATHCRAFT_HOME", None) - print("[DEBUG] 依赖管理模式:等待用户选择后准备 Python 环境") + logging.getLogger(__name__).debug("依赖管理模式:等待用户选择后准备 Python 环境") return base_dir, target_py @@ -229,7 +159,7 @@ def _bootstrap_dependencies(base_dir: Path, target_py: str) -> None: os.environ.get("LATEXSNIPPER_OPEN_DEPENDENCY_MANAGEMENT", "") == "1" ) if open_dependency_management: - print("[DEBUG] 依赖管理模式:统一验证依赖") + logging.getLogger(__name__).debug("依赖管理模式:统一验证依赖") return ensure_startup_splash(tr("检查依赖...")) @@ -242,12 +172,11 @@ def _bootstrap_dependencies(base_dir: Path, target_py: str) -> None: before_show_ui=hide_startup_splash_for_modal, after_force_enter=mark_startup_force_entered, ) - if ok: - os.environ["LATEXSNIPPER_DEPS_OK"] = "1" - if deps_force_entered(): - mark_startup_force_entered() + if not ok: + raise SystemExit(0) except Exception as e: print(f"[WARN] 依赖管理启动失败: {e}") + raise SystemExit(1) from e def bootstrap_application() -> BootstrapContext: @@ -263,12 +192,12 @@ def bootstrap_application() -> BootstrapContext: _ensure_src_path() configure_default_webengine_profile() - print(f"[DEBUG] 应用根目录: {APP_DIR}") - print(f"[DEBUG] 打包模式: {_is_packaged_mode()}") + logging.getLogger(__name__).debug(f"应用根目录: {APP_DIR}") + logging.getLogger(__name__).debug(f"打包模式: {_is_packaged_mode()}") deps_dir = _initial_deps_dir() deps_dir.mkdir(parents=True, exist_ok=True) - print(f"[DEBUG] 依赖目录: {deps_dir}") + logging.getLogger(__name__).debug(f"依赖目录: {deps_dir}") install_base_dir = resolve_install_base_dir() open_dependency_management = ( @@ -279,7 +208,6 @@ def bootstrap_application() -> BootstrapContext: install_base_dir ) else: - _maybe_redirect_to_private_python(install_base_dir) base_dir, target_py = _prepare_python_runtime(install_base_dir) _bootstrap_dependencies(base_dir, target_py) @@ -288,7 +216,7 @@ def bootstrap_application() -> BootstrapContext: for var in ("PYTHONHOME", "PYTHONPATH", "MATHCRAFT_HOME"): if var in os.environ: - print(f"[DEBUG] 清除环境变量 {var}") + logging.getLogger(__name__).debug(f"清除环境变量 {var}") os.environ.pop(var) _BOOTSTRAP_CONTEXT = BootstrapContext( diff --git a/src/application/dependency_controller.py b/src/application/dependency_controller.py index a22b9e05..0b4c256a 100644 --- a/src/application/dependency_controller.py +++ b/src/application/dependency_controller.py @@ -2,8 +2,6 @@ from __future__ import annotations -import os - from bootstrap.deps_context import was_last_ensure_deps_force_enter from ui.startup_splash import ( hide_startup_splash_for_modal, @@ -11,13 +9,21 @@ ) +_dependencies_ready = False + + +def dependencies_ready() -> bool: + return _dependencies_ready + + def deps_force_entered() -> bool: return was_last_ensure_deps_force_enter() def ensure_deps(*args, **kwargs): + global _dependencies_ready from_settings = bool(kwargs.get("from_settings", False)) - if os.environ.get("LATEXSNIPPER_DEPS_OK") == "1" and not from_settings: + if dependencies_ready() and not from_settings: return True from bootstrap.deps_entry import ensure_deps as run_dependency_bootstrap @@ -28,7 +34,7 @@ def ensure_deps(*args, **kwargs): kwargs.setdefault("after_force_enter", mark_startup_force_entered) ok = run_dependency_bootstrap(*args, **kwargs) if ok: - os.environ["LATEXSNIPPER_DEPS_OK"] = "1" + _dependencies_ready = True if deps_force_entered(): mark_startup_force_entered() return ok diff --git a/src/application/python_runtime_resolver.py b/src/application/python_runtime_resolver.py index f48ebc48..2deb2cdf 100644 --- a/src/application/python_runtime_resolver.py +++ b/src/application/python_runtime_resolver.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import os import re import subprocess @@ -310,7 +311,7 @@ def _save_install_base_dir(p: Path) -> None: cfg = json.loads(c.read_text("utf-8") or "{}") cfg["install_base_dir"] = str(p) c.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"[DEBUG] 依赖目录配置已保存: {p}") + logging.getLogger(__name__).debug(f"依赖目录配置已保存: {p}") except Exception as e: print(f"[WARN] 保存配置失败: {e}") @@ -329,7 +330,7 @@ def resolve_install_base_dir() -> Path: if not p and _is_packaged_mode() and os.name != "nt": p = _default_packaged_user_deps_dir() p.mkdir(parents=True, exist_ok=True) - print(f"[DEBUG] Linux/macOS 打包依赖目录: {p}") + logging.getLogger(__name__).debug(f"Linux/macOS 打包依赖目录: {p}") _save_install_base_dir(p) if not p and os.name == "nt": @@ -339,12 +340,12 @@ def resolve_install_base_dir() -> Path: bundled.mkdir(parents=True, exist_ok=True) except Exception: pass - print(f"[DEBUG] 首次启动使用内置依赖目录: {bundled}") + logging.getLogger(__name__).debug(f"首次启动使用内置依赖目录: {bundled}") _save_install_base_dir(bundled) p = bundled if not p: - print("[DEBUG] 首次启动选择依赖安装目录") + logging.getLogger(__name__).debug("首次启动选择依赖安装目录") try: p = _select_install_base_dir() except RuntimeError: @@ -356,11 +357,11 @@ def resolve_install_base_dir() -> Path: py_exe = _find_install_base_python(p) if py_exe is not None and py_exe.exists(): - print(f"[DEBUG] 已复用目录内 Python: {py_exe}") + logging.getLogger(__name__).debug(f"已复用目录内 Python: {py_exe}") _save_install_base_dir(p) return p - print(f"[DEBUG] 选定目录未检测到可复用 Python: {p / DEPENDENCY_PYTHON_DIRNAME}") + logging.getLogger(__name__).debug(f"选定目录未检测到可复用 Python: {p / DEPENDENCY_PYTHON_DIRNAME}") _save_install_base_dir(p) return p @@ -543,7 +544,7 @@ def _append_private_site_packages(pyexe: str | None): sys.path.append(pstr) except Exception: pass - print(f"[DEBUG] 已挂载依赖运行时路径: {base}") + logging.getLogger(__name__).debug(f"已挂载依赖运行时路径: {base}") def _allowed_roots_for(pyexe: str | None, base_dir: Path) -> list[str]: @@ -617,6 +618,7 @@ def _relaunch_with(pyexe: str): sys.exit(5) env = os.environ.copy() env["LATEXSNIPPER_BOOTSTRAPPED"] = "1" + env["LATEXSNIPPER_RESTART"] = "1" env["PYTHONNOUSERSITE"] = "1" if os.name == "nt" else "0" env.pop("PYTHONHOME", None) env.pop("PYTHONPATH", None) @@ -625,14 +627,14 @@ def _relaunch_with(pyexe: str): env.setdefault("QT_QPA_PLATFORM", "windows") elif sys.platform.startswith("linux"): env.setdefault("QT_QPA_PLATFORM", "xcb") - argv = [pyexe, os.path.abspath(__file__), *sys.argv[1:]] - print(f"[DEBUG] 使用私有解释器重启: {pyexe}") + argv = [pyexe, str(Path(__file__).resolve().parents[1] / "main.py"), *sys.argv[1:]] + logging.getLogger(__name__).debug(f"使用私有解释器重启: {pyexe}") try: proc = subprocess.Popen(argv, env=env, **_win_subprocess_kwargs()) except Exception as e: print(f"[ERR] 启动子进程失败: {e}") sys.exit(6) - print(f"[DEBUG] 私有解释器子进程已启动: pid={getattr(proc, 'pid', None)}") + logging.getLogger(__name__).debug(f"私有解释器子进程已启动: pid={getattr(proc, 'pid', None)}") sys.exit(0) @@ -715,7 +717,7 @@ def _find_full_python(base_dir: Path) -> str | None: return str(candidate) except Exception: pass - print(f"[DEBUG] 忽略不支持依赖运行时的 Python: {candidate}") + logging.getLogger(__name__).debug(f"忽略不支持依赖运行时的 Python: {candidate}") if getattr(sys, "frozen", False): return None @@ -734,16 +736,16 @@ def ensure_full_python_or_prompt(base_dir: Path) -> str | None: py_norm = os.path.normcase(os.path.abspath(py)) bundled_norm = os.path.normcase(os.path.abspath(str(base_dir))) if py_norm.startswith(bundled_norm): - print(f"[DEBUG] 使用依赖目录 Python: {py}") + logging.getLogger(__name__).debug(f"使用依赖目录 Python: {py}") else: - print(f"[DEBUG] 使用外部私有 Python: {py}") + logging.getLogger(__name__).debug(f"使用外部私有 Python: {py}") return py - print("[DEBUG] 依赖目录未检测到可用 Python,使用内置运行时启动依赖管理") + logging.getLogger(__name__).debug("依赖目录未检测到可用 Python,使用内置运行时启动依赖管理") return sys.executable py = _find_full_python(base_dir) if py: - print(f"[DEBUG] 使用依赖目录 Python: {py}") + logging.getLogger(__name__).debug(f"使用依赖目录 Python: {py}") return py system_python = find_system_python3() diff --git a/src/application/restart.py b/src/application/restart.py index 4f784465..0e189273 100644 --- a/src/application/restart.py +++ b/src/application/restart.py @@ -14,7 +14,6 @@ def build_restart_with_dependency_management_launch( env = dict(base_env or os.environ.copy()) env["LATEXSNIPPER_OPEN_DEPENDENCY_MANAGEMENT"] = "1" env["LATEXSNIPPER_RESTART"] = "1" - env.pop("LATEXSNIPPER_DEPS_OK", None) script_path = os.path.abspath(argv0) if script_path.lower().endswith(".py"): diff --git a/src/assets/i18n/latexsnipper_en_US.qm b/src/assets/i18n/latexsnipper_en_US.qm index b0498cbe..a6abcadd 100644 Binary files a/src/assets/i18n/latexsnipper_en_US.qm and b/src/assets/i18n/latexsnipper_en_US.qm differ diff --git a/src/assets/i18n/latexsnipper_en_US.ts b/src/assets/i18n/latexsnipper_en_US.ts index 1212e960..4017cf99 100644 --- a/src/assets/i18n/latexsnipper_en_US.ts +++ b/src/assets/i18n/latexsnipper_en_US.ts @@ -29,7 +29,7 @@ % TODO: Complete the document body - + (无变更日志) (No changelog) @@ -153,43 +153,43 @@ GPU magnifier fallback - + GPU 未检测 GPU not detected - + GitHub API 本次返回了空发布列表,但这不代表项目没有发布版本。 The GitHub API returned an empty release list, but this does not mean the project has no releases. - - + + GitHub API 请求受限,请稍后重试或设置 GITHUB_TOKEN。 GitHub API requests are restricted. Try again later or set GITHUB_TOKEN. - + GitHub API 返回 HTTP {code},请稍后重试。 GitHub API returned HTTP {code}. Try again later. - + GitHub Releases 响应格式异常。 The GitHub Releases response format is invalid. - + GitHub 暂时没有返回发布列表,请稍后重试或打开发布页查看。 GitHub temporarily returned no releases. Try again later or open the releases page. - + GitHub 限频: 剩余=0 重置≈{reset} GitHub rate limit: remaining=0, resets around {reset} - + GitHub 限频: 剩余={remaining} 重置≈{reset} GitHub rate limit: remaining={remaining}, resets around {reset} @@ -259,7 +259,7 @@ LaTeX Result - + LaTeX 编辑器 LaTeX Editor @@ -274,7 +274,7 @@ LaTeX path not configured - + LaTeXSnipper LaTeXSnipper @@ -284,7 +284,7 @@ LaTeXSnipper - Capture shortcut: {hotkey} | {display} - + LaTeXSnipper 已在运行。 LaTeXSnipper is already running. @@ -366,7 +366,7 @@ Open System Settings -> Privacy & Security -> Screen & System Audi The MathCraft OCR model cache is incomplete. Restore the model weights and retry. - + MathCraft OCR 正在预热 MathCraft OCR is warming up @@ -456,7 +456,7 @@ Open System Settings -> Privacy & Security -> Screen & System Audi The MinerU parsing task failed. Check the model configuration and service log. - + PDF 共 {total} 页,输入识别页码或范围: The PDF has {total} pages. Enter a page or page range: @@ -468,7 +468,7 @@ Open System Settings -> Privacy & Security -> Screen & System Audi - + PDF 打开失败: {error} Failed to open PDF: {error} @@ -478,12 +478,12 @@ Open System Settings -> Privacy & Security -> Screen & System Audi PDF files (*.pdf) - + PDF 文件 (*.pdf);;所有文件 (*.*) PDF files (*.pdf);;All files (*.*) - + PDF 文件不存在: {path} PDF file not found: {path} @@ -503,12 +503,12 @@ Open System Settings -> Privacy & Security -> Screen & System Audi PDF Rendering Resolution - + PDF 识别 PDF - + PDF 识别完成 PDF recognition completed @@ -619,7 +619,7 @@ Open System Settings -> Privacy & Security -> Screen & System Audi Failed to parse the SyncTeX output. - + TLS 证书校验失败,请检查系统证书、代理或安全软件设置。 TLS certificate validation failed. Check system certificates, proxy, or security software settings. @@ -686,7 +686,7 @@ Current content: WebEngine is unavailable; the formula preview cannot be displayed. - + WebEngine 未加载,无法渲染公式预览。 请确保已安装 PyQtWebEngine。 WebEngine is unavailable, so the formula preview cannot be rendered. @@ -713,12 +713,12 @@ Make sure PyQtWebEngine is installed. pdftocairo did not generate an SVG file - + pip 不可用 pip unavailable - + pip 初始化失败 pip initialization failed @@ -802,7 +802,7 @@ Example: {path} {message}; the previous PDF preview has been preserved. - + {message};建议稍后重试或设置 GITHUB_TOKEN。 {message}; try again later or set GITHUB_TOKEN. @@ -812,18 +812,18 @@ Example: {path} {source}, line {line} - - + + {text} 下载速度:{speed} {text} Download speed: {speed} - + {text} 下载速度:计算中... {text} Download speed: calculating... - + {text} 剩余:{eta} {text} Remaining: {eta} @@ -849,6 +849,11 @@ Example: {path} ✓ 已验证 ✓ Verified + + + 一起让它变得更好 + Help us make it better + 三角恒等式 @@ -859,6 +864,12 @@ Example: {path} 上一个 Previous + + + + 上一步 + Back + 上下标 @@ -884,6 +895,12 @@ Example: {path} 下一个 Next + + + + 下一步 + Next + 下标 @@ -900,48 +917,48 @@ Example: {path} Download - + 下载失败 Download failed - + 下载失败:{message} Download failed: {message} - + 下载完成: {path} Download complete: {path} - + 下载完成,正在退出程序并启动安装器... Download completed. Exiting and launching the installer... - + 下载已暂停 Download paused - + 下载已暂停,可稍后继续下载。 Download paused. You can resume it later. - - + + 下载并安装 Download and Install - + 下载校验失败 Download verification failed - + 下载校验失败:安装包 SHA256 与线上发布信息不一致 Download verification failed: the package SHA256 does not match the online release information @@ -951,7 +968,7 @@ Example: {path} Download source: - + 下载链接已复制到剪贴板。 The download link has been copied to the clipboard. @@ -976,6 +993,11 @@ Example: {path} 不支持该图片编码格式。 This image encoding is unsupported. + + + 为项目点亮 Star + Star on GitHub + 主环境终端已经在运行。 @@ -1048,7 +1070,12 @@ Example: {path} Required only for cross-origin browser requests. Separate multiple origins with commas. - + + 从这里开始 + Start here + + + 以下依赖无法正常工作:{layers}。请查看日志。 The following dependencies are not working: {layers}. Review the log. @@ -1068,7 +1095,7 @@ Example: {path} Example: C:\Program Files\MiKTeX\miktex\bin\x64\pdflatex.exe - + 依赖下载完成,正在验证... Dependency download completed. Verifying... @@ -1137,6 +1164,11 @@ Restart and open Dependency Management now? 保存配置后新密钥才会生效。 The new key takes effect after you save the configuration. + + + 修改快捷键 + Change shortcut + 健康检查通过 @@ -1200,7 +1232,7 @@ Restart and open Dependency Management now? Formula Favorites - + 公式渲染出错 Formula rendering error @@ -1225,16 +1257,27 @@ Restart and open Dependency Management now? 关于 LaTeXSnipper About LaTeXSnipper + + + 关于模型准备 + Getting the model ready + - + 关闭 Close + + + + 关闭引导 + Close tour + 关闭搜索 @@ -1257,7 +1300,7 @@ Restart and open Dependency Management now? Close Editor - + 关闭该窗口会暂停下载,是否继续? Closing this window will pause the download. Continue? @@ -1284,7 +1327,7 @@ Restart and open Dependency Management now? Built-in MathCraft OCR for formulas, mixed content, text, and PDF documents. - + 内置 PDF 识别缺少渲染 DPI The built-in PDF recognizer requires a rendering DPI @@ -1320,8 +1363,8 @@ Restart and open Dependency Management now? Your preview will appear here after you write - - + + 准备运行环境... Preparing runtime environment... @@ -1351,17 +1394,18 @@ Restart and open Dependency Management now? Switched successfully - + 初始化依赖环境 Initialize Dependency Environment - + + 初始化失败 Initialization failed - + 初始化识别与预览... Initializing recognition and preview... @@ -1401,7 +1445,7 @@ Restart and open Dependency Management now? - + 加载界面组件... Loading interface components... @@ -1441,7 +1485,7 @@ Restart and open Dependency Management now? Removal Progress - + 历史记录 History @@ -1451,13 +1495,18 @@ Restart and open Dependency Management now? Raw log: - + + 反馈问题 + Report an issue + + + 发现新版本: {latest} (当前 {current}) New version available: {latest} (current: {current}) - + @@ -1465,7 +1514,7 @@ Restart and open Dependency Management now? Cancel - + 变更日志 / 诊断输出... Changelog / diagnostic output... @@ -1500,7 +1549,7 @@ Restart and open Dependency Management now? Show the runtime log window after startup - + 启动安装器失败 Failed to start installer @@ -1515,7 +1564,7 @@ Restart and open Dependency Management now? Failed to enable: {message} - + 响应格式不是 release 列表 The response is not a release list @@ -1541,7 +1590,7 @@ Restart and open Dependency Management now? Failed to load image: {error} - + 图片处理失败 Image processing failed @@ -1581,7 +1630,7 @@ Restart and open Dependency Management now? The image file exceeds the size limit. - + 图片识别 Image @@ -1592,7 +1641,7 @@ Restart and open Dependency Management now? Lasso Correction - + 在此输入 LaTeX 公式,下方将实时渲染... Enter a LaTeX formula here for a live preview below... @@ -1615,7 +1664,7 @@ Restart and open Dependency Management now? - + 复制 Copy @@ -1647,7 +1696,7 @@ Restart and open Dependency Management now? - + 复制失败 Copy Failed @@ -1667,7 +1716,7 @@ Restart and open Dependency Management now? Copy access key - + 复制链接 Copy Link @@ -1733,7 +1782,7 @@ Restart and open Dependency Management now? External model is not configured - + 外部模型未配置,请先完成必要配置。 Configure the external model before continuing. @@ -1763,11 +1812,16 @@ Restart and open Dependency Management now? Polynomial expansion - + 失败 Failed + + + 如果 LaTeXSnipper 对您有帮助,欢迎支持项目。 + If LaTeXSnipper helps you, consider supporting the project. + 如果是从 DMG 或 Downloads 直接打开,请先将 LaTeXSnipper.app 移到 /Applications,并确保授权的是当前运行的同一副本。 @@ -1793,44 +1847,49 @@ Restart and open Dependency Management now? Secure tunnel (recommended) - + 安装包 SHA256 与线上发布信息不一致,请重新下载。 The installer SHA256 does not match the release metadata. Download it again. - + 安装包不存在 Installer not found - + 安装包已存在 Installer already exists - + + 安装失败,请查看日志。 + Installation failed. Check the log for details. + + + 安装完成 Installation complete - + 安装已下载 Installer Downloaded - + 安装已取消,更新包保留在本地 Installation canceled; the update package remains on this device - + 安装进度 Installation Progress - + - + 完成 Done @@ -1846,7 +1905,7 @@ Restart and open Dependency Management now? Definite integral - + 实时渲染预览 Live Preview @@ -1871,7 +1930,7 @@ Restart and open Dependency Management now? Key updated - + 导出 Export @@ -2000,12 +2059,12 @@ Restart and open Dependency Management now? Workflow - + 已下载到 {path},SHA256 已生成 Downloaded to {path}; SHA256 generated - + 已下载更新包:{name} SHA256: @@ -2121,13 +2180,13 @@ Launch the installer now? - + 已取消 Canceled - + 已取消安装 Installation canceled @@ -2138,7 +2197,7 @@ Launch the installer now? Export canceled - + 已取消截图 Capture canceled @@ -2166,8 +2225,8 @@ Launch the installer now? - - + + 已复制 Copied @@ -2258,10 +2317,15 @@ Launch the installer now? Undone - + 已改用本地缓存的发布信息。 Using cached release information. + + + 已暂停,点击“继续下载”恢复。 + Paused. Click “Resume Download” to continue. + @@ -2305,7 +2369,7 @@ Launch the installer now? - + 已清空 Cleared @@ -2341,7 +2405,7 @@ Launch the installer now? Confirmed and copied to clipboard - + 已经是最新版本: {version} Already up to date: {version} @@ -2398,10 +2462,25 @@ Path: {path} Apply preset - + 建议:检查网络、代理或 DNS;也可以直接打开发布页。 Suggestion: check the network, proxy, or DNS, or open the releases page directly. + + + 开始使用 + Get started + + + + 开始使用吧 + You are ready to explore + + + + 开始引导 + Take the tour + 开始截图识别 @@ -2443,12 +2522,12 @@ Path: {path} Current theme: {theme} - + 当前依赖环境无法初始化 pip:{error} Unable to initialize pip in the current dependency environment: {error} - + 当前依赖环境缺少 pip,且无法通过 ensurepip 初始化。请更换依赖目录或使用正常 Python 环境。 The dependency environment has no pip and ensurepip could not initialize it. Choose another dependency directory or a working Python environment. @@ -2505,7 +2584,7 @@ Select Download to initialize it before entering the main app. A connection test is already running in the background. - + 当前已有识别任务,请稍候。 A recognition task is already running. Please wait. @@ -2585,17 +2664,17 @@ Select Download to initialize it before entering the main app. Current source - + 当前版本: {version} Current version: {version} - + 当前版本仅提供网页链接,请手动下载。 This release only provides a web link. Download it manually. - + 当前版本高于线上稳定版本: {latest} (当前 {current}) Current version is newer than the stable release: {latest} (current: {current}) @@ -2679,7 +2758,7 @@ Select Download to initialize it before entering the main app. Quick Insert - + 快捷键 Shortcut @@ -2698,6 +2777,16 @@ Select Download to initialize it before entering the main app. 快捷键注册失败 Hotkey registration failed + + + 快速入门 + Quick start + + + + 您的快速上手向导。 + A quick guide to getting started. + 我已了解远程设备可向本机提交图片,并确认连接方式安全 @@ -2709,27 +2798,27 @@ Select Download to initialize it before entering the main app. Capture, recognize, handwrite, edit, and export mathematical content. - + 截图启动失败 Failed to start capture - + 截图失败 Capture failed - + 截图权限不足 Insufficient capture permission - + 截图权限被拒绝 Capture permission was denied - + 截图识别 Capture & Recognize @@ -2740,7 +2829,8 @@ Select Download to initialize it before entering the main app. Capture & Recognize ({hotkey}) - + + 所选依赖已安装并验证通过。 The selected dependencies were installed and verified. @@ -2752,7 +2842,7 @@ Select Download to initialize it before entering the main app. - + 手写识别 Handwriting @@ -2819,7 +2909,7 @@ Select Download to initialize it before entering the main app. Open Editor - + 打开链接 Open Link @@ -2937,17 +3027,17 @@ Select Download to initialize it before entering the main app. - - - - + + + + - + 提示 Notice @@ -3013,12 +3103,12 @@ Select Download to initialize it before entering the main app. Supports Ollama, OpenAI-compatible services, and MinerU Local. For online services, verify authentication, model name, quota, and service terms. - + 支持格式: Supported formats: - + 支持格式: PDF Supported format: PDF @@ -3033,7 +3123,7 @@ Select Download to initialize it before entering the main app. Favorite - + 收藏夹 Favorites @@ -3055,7 +3145,7 @@ Select Download to initialize it before entering the main app. - + 数学工作台 Math Workspace @@ -3102,12 +3192,12 @@ Select Download to initialize it before entering the main app. Solve equation - + 无公式 No formula - + 无可下载资产 No downloadable asset @@ -3117,7 +3207,7 @@ Select Download to initialize it before entering the main app. No available backend - + 无安装包 No Installer @@ -3136,6 +3226,11 @@ Select Download to initialize it before entering the main app. 无法打开模型缓存: {error} Could not open the model cache: {error} + + + 无法打开链接,请检查默认浏览器设置。 + Could not open the link. Check your default browser settings. + 无法更新自动化接口配置。 @@ -3175,7 +3270,7 @@ Select Download to initialize it before entering the main app. Unable to navigate - + 无法连接 GitHub,请检查网络、代理或 DNS 设置。 Unable to connect to GitHub. Check network, proxy, or DNS settings. @@ -3216,7 +3311,7 @@ Select Download to initialize it before entering the main app. Nothing to clean - + 日志内容已复制到剪贴板 Log content has been copied to the clipboard @@ -3226,7 +3321,7 @@ Select Download to initialize it before entering the main app. Log file: {path} - + 日志视图已清空 The log view has been cleared @@ -3251,18 +3346,19 @@ Select Download to initialize it before entering the main app. Unavailable - - + + + 暂停下载 Pause Download - + 暂时无法确认更新:{message} Unable to check for updates: {message} - + 暂时无法获取更新信息。可稍后重试,或直接打开 GitHub Releases 页面查看。 Unable to retrieve update information. Try again later or open the GitHub Releases page directly. @@ -3282,44 +3378,44 @@ Select Download to initialize it before entering the main app. Updating; waiting for the current recognition task to finish... - + 更新包 Update package - + 更新包已保留,下次打开可继续下载。 The update package was retained and can resume next time. - + 更新包已保留:{path} The update package was retained: {path} - + 更新已下载 Update downloaded - + 更新缓存失效,请重新检查。 The update cache is stale. Check again. - + 更新请求失败,请检查网络后重试。 Update request failed. Check the network and try again. - + 最新在前 Newest first - + 最早在前 Oldest first @@ -3329,13 +3425,13 @@ Select Download to initialize it before entering the main app. The server returned {status_code}. Try again later or check the service log. - + 未创建识别任务。 No recognition task was created. - - + + 未加载 Not loaded @@ -3374,12 +3470,12 @@ Select Download to initialize it before entering the main app. The MathCraft OCR package was not found. Check that the app files are complete. - + 未找到下载完成的安装包:{path} The downloaded installer was not found: {path} - + 未找到可用的 GitHub Release,请确认发布页已创建。 No usable GitHub Release was found. Make sure a release page has been created. @@ -3428,7 +3524,7 @@ Select Download to initialize it before entering the main app. {detail} - + 未检测到 NVIDIA GPU,继续安装 onnxruntime-gpu 可能无法启用 CUDAExecutionProvider,是否继续? No NVIDIA GPU was detected. Installing onnxruntime-gpu may not enable CUDAExecutionProvider. Continue? @@ -3460,7 +3556,7 @@ Select Download to initialize it before entering the main app. Not generated - + 未知 Unknown @@ -3522,6 +3618,11 @@ Select Download to initialize it before entering the main app. 本地 Ollama Local Ollama + + + 本地模型会在后台准备,首次使用可能需要下载。您可以在日志中查看详情。 + Local models prepare in the background and may need a download on first use. Open the logs for details. + 本清理入口仅适用于 macOS。 @@ -3529,13 +3630,13 @@ Select Download to initialize it before entering the main app. - + 权限不足 Insufficient Permissions - + 来源 Source @@ -3544,6 +3645,16 @@ Select Download to initialize it before entering the main app. 极限 Limit + + + 查看快速入门 + View quick start + + + + 查看日志 + View logs + 查看说明 @@ -3572,13 +3683,13 @@ Select Download to initialize it before entering the main app. - + 检查依赖... Checking dependencies... - + 检查更新 Check for Updates @@ -3598,12 +3709,12 @@ Select Download to initialize it before entering the main app. Detecting... - + 检测到 MathCraft 模型权重缺失或不完整,正在自动补全。 MathCraft model weights are missing or incomplete. Completing them automatically. - + 检测到已存在安装包,是否继续重新下载并覆盖? An installer already exists. Download again and overwrite it? @@ -3657,8 +3768,8 @@ Select Download to initialize it before entering the main app. The model name is empty. Enter the model name used by the local service. - - + + @@ -3726,17 +3837,27 @@ Select Download to initialize it before entering the main app. Eraser mode - + + 欢迎使用 LaTeXSnipper + Welcome to LaTeXSnipper + + + + 欢迎,让公式处理更简单。 + Welcome. Make working with formulas simpler. + + + 正在下载 {name} ({percent}% , {current}/{total} 字节) Downloading {name} ({percent}%, {current}/{total} bytes) - + 正在下载 {name}... Downloading {name}... - + 正在下载更新包... Downloading update package... @@ -3746,10 +3867,15 @@ Select Download to initialize it before entering the main app. Saving... - + 正在修复模型缓存 Repairing model cache + + + 正在准备 Pandoc... + Preparing Pandoc... + 正在初始化 Python 依赖环境... @@ -3771,17 +3897,17 @@ Select Download to initialize it before entering the main app. Uninstalling {layer}. Do not close this window... - + 正在取消识别... Canceling recognition... - + 正在启动... Starting... - + 正在安装第 {current}/{total} 个包:{package} Installing package {current}/{total}: {package} @@ -3808,30 +3934,30 @@ Select Download to initialize it before entering the main app. - - + + 正在联网获取最新版本信息,请保持与 GitHub 的连接畅通... Fetching the latest release information. Keep the connection to GitHub available... - + 正在获取... Fetching... - + 正在解析 PDF 文档结构... Parsing PDF document structure... - + 正在识别 Recognizing - + 正在识别 PDF... Recognizing PDF... @@ -3842,15 +3968,15 @@ Select Download to initialize it before entering the main app. - - - - + + + + 正在跳过依赖安装并进入主程序... Skipping dependency installation and opening the application... - + 正在遍历寻找缺失的库,完成后将自动下载,请不要关闭此窗口(๑•̀ㅂ•́)و✧)... Checking for missing libraries. Downloads will start automatically; do not close this window... @@ -4042,7 +4168,7 @@ Select Download to initialize it before entering the main app. - + 清空 Clear @@ -4067,7 +4193,7 @@ Select Download to initialize it before entering the main app. Clear View - + 清空预览 Clear Preview @@ -4087,7 +4213,7 @@ Select Download to initialize it before entering the main app. Click to load into the editor and render - + 版本更新 Version Update @@ -4132,7 +4258,7 @@ Select Download to initialize it before entering the main app. Bind address - + 目标依赖目录未检测到可复用 Python 环境。 是否现在使用系统 Python 初始化以下目录后继续安装依赖? @@ -4195,12 +4321,12 @@ The uninstall progress window will open after confirmation. Confirm - + 确认关闭 Confirm Close - + 确认安装更新 Confirm Update Installation @@ -4235,6 +4361,11 @@ The uninstall progress window will open after confirmation. 示例已载入 Example Loaded + + + 社区 / 支持项目 + Community / Support the project + 私钥文件 (*.pem *.key);;所有文件 (*) @@ -4324,13 +4455,18 @@ The uninstall progress window will open after confirmation. The result was inserted into the main window. The handwriting content was preserved. - - + + 继续下载 Resume Download - + + 继续引导 + Continue tour + + + 缓存已过期,请重新检查 The cache has expired. Check again. @@ -4441,7 +4577,7 @@ The uninstall progress window will open after confirmation. Missing PyMuPDF dependency: {error} - + 缺少 PyMuPDF 依赖: {error} 请在依赖环境中安装 pymupdf。 PyMuPDF is missing: {error} @@ -4562,7 +4698,7 @@ Install pymupdf in the dependency environment. Erase only the stroke segments inside a freeform selection for local rewriting - + 获取超时,可重新检查。 The request timed out. Try checking again. @@ -4592,7 +4728,12 @@ Install pymupdf in the dependency environment. Evaluate - + + 让操作更顺手 + Make it your own + + + 设置 Settings @@ -4617,6 +4758,11 @@ Install pymupdf in the dependency environment. 设置快捷键 Set Shortcut + + + 设置您习惯的截图快捷键。 + Choose a screenshot shortcut that works for you. + @@ -4628,13 +4774,18 @@ Install pymupdf in the dependency environment. 证书文件 (*.pem *.crt *.cer);;所有文件 (*) Certificate files (*.pem *.crt *.cer);;All files (*) + + + 识别、预览,再导出您的公式。 + Recognize, preview, and export your formulas. + 识别中 Recognizing - + 识别中... Recognizing... @@ -4715,7 +4866,7 @@ Install pymupdf in the dependency environment. - + @@ -4842,7 +4993,7 @@ Install pymupdf in the dependency environment. Drop a single image or PDF file. Supported image formats: {formats}. - + 请拖入或选择 PDF 文件。 Drop or select a PDF file. @@ -4852,7 +5003,7 @@ Install pymupdf in the dependency environment. Choose another {format} combination and retry - + 请检查 MathJax 资源是否正确打包 Check that MathJax resources are packaged correctly @@ -4882,7 +5033,7 @@ Install pymupdf in the dependency environment. Enter a LaTeX path or select Auto Detect - + 请输入页码或页码范围。 Enter a page number or page range. @@ -4897,7 +5048,7 @@ Install pymupdf in the dependency environment. Select a recommended preset before applying it. - + 请选择依赖安装目录 Select a dependency installation directory @@ -4912,12 +5063,12 @@ Install pymupdf in the dependency environment. Whether resource paths are correct - + 起始页不能大于结束页。 The first page cannot be greater than the last page. - + 超出设定: connect={connect}s read={read}s 可点“重新检查”再次发起。 Timed out: connect={connect}s read={read}s @@ -4966,6 +5117,11 @@ Click “Check Again” to retry. 跳转未完成 Navigation incomplete + + + 跳过 + Skip + @@ -5106,7 +5262,7 @@ Continue? Product (prod) - + 连接 GitHub 超时,请检查网络或代理后重试。 Connection to GitHub timed out. Check the network or proxy and try again. @@ -5142,10 +5298,30 @@ Continue? Quit LaTeXSnipper - + 退出下载 Cancel Download + + + 退出下载? + Exit download? + + + + 退出将停止当前任务。再次进入依赖管理并开始安装时,将跳过已安装的依赖;未完成的下载可能需要重新下载。 + Exiting stops the current task. When you start installation again in Dependency Management, installed dependencies will be skipped. Incomplete downloads may need to restart. + + + + 退出引导 + Exit tour + + + + 退出快速入门? + Exit quick start? + 退出确认 @@ -5199,7 +5375,7 @@ Continue? Select HTTPS Certificate - + 选择 PDF 文件 Select PDF File @@ -5235,7 +5411,7 @@ Continue? Select feature layers to install: - + 选择页码 Select Page @@ -5245,7 +5421,12 @@ Continue? Some dependencies failed to install. Check the log and try again. - + + 部分依赖验证失败,请查看日志。 + Some dependencies failed verification. Check the log for details. + + + 部分验证失败 Some checks failed @@ -5298,7 +5479,7 @@ Continue? Rename Formula - + 重新下载 Download Again @@ -5308,7 +5489,7 @@ Continue? Restart LaTeXSnipper to apply the language change. - + 重新检查 Check Again @@ -5319,11 +5500,11 @@ Continue? - - - - - + + + + + @@ -5345,7 +5526,7 @@ Continue? Error: - + 错误信息: Error details: @@ -5365,22 +5546,27 @@ Continue? Remote access acknowledgement required - + + 需要重看时,点击主窗口“快速入门”。 + To view it again, click “Quick start” in the main window. + + + 页码不能超过 PDF 总页数 {total}。 The page number cannot exceed the PDF page count ({total}). - + 页码必须从 1 开始。 Page numbers must start at 1. - + 页码范围只能包含数字和连字符。 A page range may contain only numbers and a hyphen. - + 页码范围格式应为 3 或 3-7。 Use 3 or 3-7 as the page-range format. @@ -5399,7 +5585,7 @@ Continue? Preset applied - + 首次预热可能需要下载或初始化模型权重,网速较慢时耗时会更长,请稍候。 The first warm-up may download or initialize model weights and can take longer on a slow connection. @@ -5443,7 +5629,7 @@ Continue? Advanced settings: - + (GitHub 限频) (GitHub rate limit) diff --git a/src/bootstrap/deps_entry.py b/src/bootstrap/deps_entry.py index 90bac6d9..21bc576b 100644 --- a/src/bootstrap/deps_entry.py +++ b/src/bootstrap/deps_entry.py @@ -504,10 +504,8 @@ def _notify_after_force_enter(): existing_pyexe = _find_existing_python(Path(deps_dir)) pyexe = existing_pyexe or _dependency_venv_python(py_root) if existing_pyexe and existing_pyexe.exists(): - print(f"[DEBUG] 使用依赖目录 Python: {pyexe}") use_bundled_python = False else: - print(f"[DEBUG] 依赖目录尚未初始化 Python: {pyexe}") use_bundled_python = True else: py_root = Path(deps_dir) / DEPENDENCY_PYTHON_DIRNAME @@ -515,11 +513,6 @@ def _notify_after_force_enter(): pyexe = existing_pyexe or _dependency_venv_python(py_root) deps_dir_resolved = str(Path(deps_dir).resolve()) - is_packaged = hasattr(sys, "_MEIPASS") or "_internal" in str( - Path(__file__).parent - ) - mode_str = "打包模式" if is_packaged else "开发模式" - def _path_is_under(child: str | None, parent: str) -> bool: if not child: return False @@ -533,30 +526,20 @@ def _path_is_under(child: str | None, parent: str) -> bool: current_site_in_deps = _path_is_under(current_site, deps_dir_resolved) if current_site_in_deps: - print( - f"[DEBUG] {mode_str}:当前 Python 环境与依赖目录一致: {current_pyexe}" - ) pyexe = current_pyexe use_bundled_python = False else: if existing_pyexe and existing_pyexe.exists(): use_bundled_python = False pyexe = existing_pyexe - print(f"[DEBUG] {mode_str}:使用依赖目录 Python: {pyexe}") else: use_bundled_python = True - print(f"[DEBUG] {mode_str}:依赖目录尚未初始化 Python: {pyexe}") - - if use_bundled_python and not _is_usable_python(pyexe): - print("[DEBUG] 目标依赖目录未检测到可复用 Python") pip_ready_event.clear() try: - if from_settings and always_show_ui: - print("[DEBUG] 等待依赖管理确认后初始化 pip") - elif use_bundled_python and not _is_usable_python(pyexe): - print("[DEBUG] 依赖目录 Python 尚未初始化,跳过 pip 预检查") - else: + if not (from_settings and always_show_ui) and ( + not use_bundled_python or _is_usable_python(pyexe) + ): _ensure_pip(pyexe) state_path = Path(deps_dir) / STATE_FILE if not state_path.exists(): @@ -857,6 +840,7 @@ def _switch_deps_context(target_deps_dir: str) -> tuple[list[str], bool]: state_lock = threading.Lock() dlg = InstallProgressDialog() + dlg.confirm_cancel = True info = dlg.info_label logw = dlg.log_view btn_cancel = dlg.cancel_button @@ -901,13 +885,15 @@ def _append_log(text: str): def _set_progress(val: int): if _is_alive(progress): try: - progress.setValue(int(val)) + progress.setValue(min(95, int(val))) except RuntimeError: pass def _render_info_text(): text = net_speed_state.get("base_text", "") or "" - if net_speed_state.get("busy", False): + if paused: + text = tr("已暂停,点击“继续下载”恢复。") + elif net_speed_state.get("busy", False): pip_speed = ( net_speed_state.get("pip_speed_text") or "" ).strip() @@ -978,15 +964,21 @@ def _set_network_speed_busy(is_busy: bool): net_speed_state["pip_progress_text"] = "" _render_info_text() + from qfluentwidgets import FluentIcon + + btn_pause.setEnabled(False) + def toggle_pause(): nonlocal paused + try: + worker.set_paused(not paused) + except psutil.Error as exc: + _append_log(f"[WARN] 无法暂停任务: {exc}") + return paused = not paused - if paused: - pause_event.clear() - btn_pause.setText(tr("继续下载")) - else: - pause_event.set() - btn_pause.setText(tr("暂停下载")) + btn_pause.setText(tr("继续下载") if paused else tr("暂停下载")) + btn_pause.setIcon(FluentIcon.PLAY if paused else FluentIcon.PAUSE) + _render_info_text() btn_pause.clicked.connect(toggle_pause) pause_event.set() @@ -1009,29 +1001,22 @@ def toggle_pause(): init_worker_holder["obj"] = init_worker def request_cancel(): - ui_closed["value"] = True - stop_event.set() - for t in timer_holder.values(): - if t is not None: - try: - t.stop() - except Exception: - pass - try: - if init_worker is not None and init_worker.isRunning(): - init_worker.stop() - if worker.isRunning(): - worker.stop() - except Exception: - pass - if _is_alive(dlg): - try: - dlg.reject() - except RuntimeError: - pass + dlg.close() btn_cancel.clicked.connect(request_cancel) + def set_pause_available(available): + nonlocal paused + if ui_closed["value"] or not _is_alive(btn_pause): + return + if not available: + paused = False + btn_pause.setText(tr("暂停下载")) + btn_pause.setIcon(FluentIcon.PAUSE) + _render_info_text() + btn_pause.setEnabled(available) + + worker.pausable_changed.connect(set_pause_available) worker.log_updated.connect(_append_log) worker.progress_updated.connect(_set_progress) worker.status_updated.connect(_set_info_text) @@ -1041,13 +1026,18 @@ def request_cancel(): init_worker.progress_updated.connect(_set_progress) init_worker.status_updated.connect(_set_info_text) - def _finalize_done_ui(): + def _finalize_done_ui(message): + nonlocal paused + paused = False if completion_state["final_ui_applied"]: return completion_state["final_ui_applied"] = True + if _is_alive(dlg): + dlg.confirm_cancel = False _set_network_speed_busy(False) + _set_info_text(message) if _is_alive(progress): - _set_progress(progress.maximum()) + progress.setValue(progress.maximum()) if _is_alive(btn_cancel): btn_cancel.setText(tr("完成")) if _is_alive(btn_pause): @@ -1090,9 +1080,12 @@ def on_install_done(success: bool): if _is_alive(dlg): title, message = _install_failure_dialog_copy() show_info_bar(dlg, title, message, "error", 6000) - _finalize_done_ui() + _finalize_done_ui(tr("安装失败,请查看日志。")) return + nonlocal paused + paused = False + btn_pause.setEnabled(False) _append_log("\n[INFO] 正在验证所选依赖...") _set_info_text(tr("依赖下载完成,正在验证...")) @@ -1132,7 +1125,10 @@ def on_verify_done(_ok_layers: list, fail_layers: list): tr("所选依赖已安装并验证通过。"), "success", ) - _finalize_done_ui() + _finalize_done_ui( + tr("部分依赖验证失败,请查看日志。") if fail_layers + else tr("所选依赖已安装并验证通过。") + ) verify_worker.done.connect(on_verify_done) verify_worker.start() @@ -1160,7 +1156,7 @@ def on_python_init_done(success: bool, message: str): "error", 7000, ) - _finalize_done_ui() + _finalize_done_ui(tr("初始化失败")) return _apply_runtime_context(Path(pyexe)) _append_log("[INFO] Python 依赖环境初始化完成,开始安装依赖包。") @@ -1207,7 +1203,20 @@ def drain_log_queue(): speed_timer.start() def on_close_event(event): + if not dlg.confirm_exit(): + event.ignore() + return ui_closed["value"] = True + stop_event.set() + for signal, slot in ( + (worker.pausable_changed, set_pause_available), + (worker.status_updated, _set_info_text), + (worker.busy_state_changed, _set_network_speed_busy), + ): + try: + signal.disconnect(slot) + except (TypeError, RuntimeError): + pass try: for t in timer_holder.values(): if t is not None: diff --git a/src/bootstrap/deps_pandoc.py b/src/bootstrap/deps_pandoc.py index 3ec4dc8f..49df7d85 100644 --- a/src/bootstrap/deps_pandoc.py +++ b/src/bootstrap/deps_pandoc.py @@ -3,11 +3,12 @@ import sys from pathlib import Path +from bootstrap.download_control import wait_for_download from bootstrap.deps_context import flags from runtime.app_paths import app_tools_dir -def _ensure_pandoc_binary(pyexe: str, log_fn=None, progress_fn=None, stop_event=None) -> bool: +def _ensure_pandoc_binary(pyexe: str, log_fn=None, progress_fn=None, stop_event=None, pause_event=None) -> bool: """Ensure the pandoc executable is available.""" if stop_event and stop_event.is_set(): if log_fn: @@ -67,7 +68,7 @@ def _resolve_pandoc_exe() -> str | None: log_fn("[INFO] Pandoc: 从镜像下载 pandoc 二进制...") if progress_fn: progress_fn(85) - ok = _download_pandoc_from_mirrors(pyexe, log_fn, stop_event=stop_event) + ok = _download_pandoc_from_mirrors(pyexe, log_fn, stop_event=stop_event, pause_event=pause_event) if ok: if log_fn: log_fn("[INFO] Pandoc: pandoc 二进制文件就绪") @@ -283,7 +284,7 @@ def _rank_mirrors_by_speed(mirrors: list[str], log_fn=None) -> list[str]: return ranked -def _download_pandoc_from_mirrors(pyexe: str | None = None, log_fn=None, stop_event=None) -> bool: +def _download_pandoc_from_mirrors(pyexe: str | None = None, log_fn=None, stop_event=None, pause_event=None) -> bool: """Download pandoc and extract it into the shared app tools directory.""" import urllib.request import time as _time @@ -307,6 +308,8 @@ def _download_pandoc_from_mirrors(pyexe: str | None = None, log_fn=None, stop_ev mirrors = _rank_mirrors_by_speed(mirrors, log_fn) for idx, url in enumerate(mirrors, start=1): + if not wait_for_download(pause_event, stop_event): + return False if stop_event and stop_event.is_set(): if log_fn: log_fn("[INFO] Pandoc: 下载已取消") @@ -328,6 +331,9 @@ def _download_pandoc_from_mirrors(pyexe: str | None = None, log_fn=None, stop_ev chunk_size = 64 * 1024 # 64 KB while True: + if not wait_for_download(pause_event, stop_event): + resp.close() + return False chunk = resp.read(chunk_size) if not chunk: break diff --git a/src/bootstrap/deps_workers.py b/src/bootstrap/deps_workers.py index f9dbb36a..34bdffe1 100644 --- a/src/bootstrap/deps_workers.py +++ b/src/bootstrap/deps_workers.py @@ -10,6 +10,7 @@ from backend.mathcraft.runtime_policy import onnxruntime_cpu_spec, onnxruntime_gpu_spec from localization.manager import translate as tr +from bootstrap.download_control import DownloadControl from bootstrap.deps_context import flags from bootstrap.deps_layer_specs import ( LAYER_MAP, @@ -49,6 +50,7 @@ class InstallWorker(QThread): progress_updated = pyqtSignal(int) status_updated = pyqtSignal(str) busy_state_changed = pyqtSignal(bool) + pausable_changed = pyqtSignal(bool) done = pyqtSignal(bool) def __init__(self, pyexe, pkgs, stop_event, pause_event, state_lock, state, state_path, chosen_layers, log_q, @@ -61,13 +63,23 @@ def __init__(self, pyexe, pkgs, stop_event, pause_event, state_lock, state, stat self.pkgs = pkgs self.stop_event = stop_event self.pause_event = pause_event + self.download_control = DownloadControl(pause_event) self.state_lock = state_lock self.state = state self.state_path = state_path self.chosen_layers = chosen_layers self.log_q = log_q + def set_process(self, proc): + self.proc = proc + self.download_control.set_process(proc) + + def set_paused(self, paused): + self.download_control.set_paused(paused) + def _emit_done_safe(self, ok: bool): + self.set_paused(False) + self.pausable_changed.emit(False) if not self._done_emitted: self._done_emitted = True try: @@ -82,6 +94,7 @@ def _emit_done_safe(self, ok: bool): def stop(self): """Stop an install from the UI.""" self.stop_event.set() + self.set_paused(False) if hasattr(self, "proc") and self.proc and self.proc.poll() is None: _terminate_process(self.proc) self.proc = None @@ -177,6 +190,7 @@ def _resolve_layer_pkg_spec(pkg_spec: str) -> str: total = len(pending) if pending: + self.pausable_changed.emit(True) self.log_updated.emit(f"[INFO] 需要安装 {len(pending)} 个包(跳过 {len(skipped)} 个已安装)") done_count = 0 @@ -211,7 +225,7 @@ def _resolve_layer_pkg_spec(pkg_spec: str) -> str: use_mirror=self.mirror, flags=flags, pause_event=self.pause_event, - proc_setter=lambda p: setattr(self, "proc", p), + proc_setter=self.set_process, ) except Exception as e: ok = False @@ -245,7 +259,8 @@ def _resolve_layer_pkg_spec(pkg_spec: str) -> str: return base_progress = pip_progress_max if pending else 20 - self.log_updated.emit("[INFO] Pandoc: 检查 pandoc 二进制文件...") + self.pausable_changed.emit(True) + self.status_updated.emit(tr("正在准备 Pandoc...")) def _pandoc_progress(pct: int): @@ -257,8 +272,11 @@ def _pandoc_progress(pct: int): self.log_updated.emit, progress_fn=_pandoc_progress, stop_event=self.stop_event, + pause_event=self.pause_event, ) + self.set_paused(False) + self.pausable_changed.emit(False) runtime_ort_ok = True runtime_ort_err = "" if want_gpu_runtime: @@ -269,7 +287,7 @@ def _pandoc_progress(pct: int): self.pause_event, self.log_q, use_mirror=self.mirror, - proc_setter=lambda p: setattr(self, "proc", p), + proc_setter=self.set_process, ) if not runtime_ort_ok: self.log_updated.emit(f"[WARN] onnxruntime-gpu 运行时验证失败: {runtime_ort_err[:400]}") diff --git a/src/bootstrap/download_control.py b/src/bootstrap/download_control.py new file mode 100644 index 00000000..233ef569 --- /dev/null +++ b/src/bootstrap/download_control.py @@ -0,0 +1,62 @@ +"""Coordinate cooperative downloads and pip process suspension.""" + +import threading +import psutil + + +class DownloadControl: + def __init__(self, pause_event): + self.event = pause_event + self.proc = None + self._suspended = [] + self._lock = threading.RLock() + + def set_process(self, proc): + with self._lock: + self._resume() + self.proc = proc + if proc is not None and not self.event.is_set(): + self._suspend() + + def _suspend(self): + if self.proc is None or self.proc.poll() is not None: + return + root = psutil.Process(self.proc.pid) + root.suspend() + self._suspended.append(root) + for child in root.children(recursive=True): + try: + child.suspend() + self._suspended.append(child) + except psutil.NoSuchProcess: + pass + + def _resume(self): + for proc in reversed(self._suspended): + try: + proc.resume() + except psutil.NoSuchProcess: + pass + self._suspended.clear() + + def set_paused(self, paused): + with self._lock: + if paused: + self.event.clear() + try: + self._suspend() + except psutil.Error: + self.event.set() + self._resume() + raise + else: + self._resume() + self.event.set() + + +def wait_for_download(pause_event, stop_event): + while pause_event is not None and not pause_event.is_set(): + if stop_event is not None and stop_event.is_set(): + return False + pause_event.wait(0.1) + return stop_event is None or not stop_event.is_set() diff --git a/src/bootstrap/progress_dialog.py b/src/bootstrap/progress_dialog.py index f2ff1fa1..f9a05cae 100644 --- a/src/bootstrap/progress_dialog.py +++ b/src/bootstrap/progress_dialog.py @@ -11,11 +11,11 @@ QDialog, QHBoxLayout, QLabel, - QProgressBar, + QMessageBox, QTextEdit, QVBoxLayout, ) -from qfluentwidgets import FluentIcon, PushButton +from qfluentwidgets import FluentIcon, PushButton, ProgressBar from runtime.app_paths import resource_path @@ -23,6 +23,7 @@ class InstallProgressDialog(QDialog): def __init__(self) -> None: super().__init__() + self.confirm_cancel = False self.setWindowTitle(tr("安装进度")) self.resize(680, 440) self.setWindowIcon(QIcon(resource_path("assets/icon.ico"))) @@ -32,10 +33,14 @@ def __init__(self) -> None: ) self.log_view = QTextEdit() self.log_view.setReadOnly(True) - self.progress_bar = QProgressBar() + self.progress_bar = ProgressBar(self) self.progress_bar.setRange(0, 100) - self.progress_bar.setFixedHeight(20) + self.progress_bar.setFixedHeight(6) self.progress_bar.setMinimumWidth(400) + self.percent_label = QLabel("0%", self) + self.progress_bar.valueChanged.connect( + lambda value: self.percent_label.setText(f"{max(0, min(100, value))}%") + ) self.cancel_button = PushButton(FluentIcon.CLOSE, tr("退出下载")) self.cancel_button.setFixedHeight(32) self.pause_button = PushButton(FluentIcon.PAUSE, tr("暂停下载")) @@ -47,11 +52,35 @@ def __init__(self) -> None: layout = QVBoxLayout(self) layout.addWidget(self.info_label) layout.addWidget(self.log_view, 1) - layout.addWidget(self.progress_bar) + progress_row = QHBoxLayout() + progress_row.addWidget(self.progress_bar, 1) + progress_row.addWidget(self.percent_label) + layout.addLayout(progress_row) layout.addLayout(button_row) self._theme_is_dark_cached: bool | None = None self._apply_theme_styles(force=True) + def confirm_exit(self): + if not self.confirm_cancel: + return True + answer = QMessageBox.question( + self, + tr("退出下载?"), + tr("退出将停止当前任务。再次进入依赖管理并开始安装时,将跳过已安装的依赖;未完成的下载可能需要重新下载。"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return False + self.confirm_cancel = False + return True + + def reject(self): + if self.confirm_cancel: + self.close() + else: + super().reject() + @staticmethod def _is_dark_ui() -> bool: app = QApplication.instance() @@ -66,18 +95,7 @@ def _apply_theme_styles(self, *, force: bool = False) -> None: return self._theme_is_dark_cached = dark muted = "#a9b3bf" if dark else "#666666" - text = "#e7ebf0" if dark else "#222222" - background = "#232934" if dark else "#ffffff" - border = "#465162" if dark else "#cfd6dd" - chunk = "#4c9aff" if dark else "#1976d2" self.info_label.setStyleSheet(f"color: {muted};") - self.progress_bar.setStyleSheet( - "QProgressBar {" - f"border: 1px solid {border}; border-radius: 6px; text-align: center; " - f"background-color: {background}; color: {text};" - "}" - f"QProgressBar::chunk {{ background: {chunk}; border-radius: 6px; }}" - ) def event(self, event) -> bool: if event.type() in { diff --git a/src/capture/capture_controller.py b/src/capture/capture_controller.py index 0679b566..fd5a6459 100644 --- a/src/capture/capture_controller.py +++ b/src/capture/capture_controller.py @@ -13,6 +13,9 @@ class CaptureControllerMixin: def start_capture(self, preserve_pinned_result: bool = False): + tour = getattr(self, "onboarding", None) + if tour is not None and tour.active: + return if self._capture_start_pending or self.overlay is not None: try: if self.overlay is not None: diff --git a/src/main.py b/src/main.py index e448a1ec..7c2210f2 100644 --- a/src/main.py +++ b/src/main.py @@ -29,7 +29,7 @@ def main() -> int: try: bootstrap_application() - apply_runtime_log_window_preference(force=False, tee=True) + apply_runtime_log_window_preference(force=False) return run_application(MainWindow) except Exception: logging.exception("LaTeXSnipper 启动失败") diff --git a/src/preview/math_preview.py b/src/preview/math_preview.py index 36d635b4..8bf05fdc 100644 --- a/src/preview/math_preview.py +++ b/src/preview/math_preview.py @@ -16,9 +16,6 @@ "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js" ) -_MATHJAX_LOGGED_KEYS: set[str] = set() - - def configure_math_preview_runtime(app_dir: Path | str | None) -> None: global APP_DIR APP_DIR = Path(app_dir) if app_dir else None @@ -338,18 +335,9 @@ def get_mathjax_base_url() -> QUrl: try: mode = _current_render_mode() if mode == "mathjax_cdn": - if "cdn" not in _MATHJAX_LOGGED_KEYS: - print("[DEBUG] 使用 CDN MathJax") - _MATHJAX_LOGGED_KEYS.add("cdn") return QUrl(MATHJAX_CDN_URL.rsplit("/", 1)[0] + "/") - if mode.startswith("latex_"): - mode_key = f"latex:{mode}" - if mode_key not in _MATHJAX_LOGGED_KEYS: - print(f"[DEBUG] LaTeX 渲染模式仍使用本地 MathJax base: {mode}") - _MATHJAX_LOGGED_KEYS.add(mode_key) - - actual_app_dir, source_desc = _mathjax_base_dir() + actual_app_dir, _ = _mathjax_base_dir() es5_dir = actual_app_dir / "assets" / "MathJax-3.2.2" / "es5" tex_chtml = es5_dir / "tex-mml-chtml.js" if not tex_chtml.exists(): @@ -359,16 +347,6 @@ def get_mathjax_base_url() -> QUrl: url_str = url.toString() if not url_str.startswith("file:///"): print(f"[ERR] URL 格式异常,应以 file:/// 开头: {url_str}") - else: - local_key = f"local:{source_desc}:{url_str}" - if local_key not in _MATHJAX_LOGGED_KEYS: - label = ( - "使用本地资源" - if source_desc == "本地资源" - else f"使用本地资源({source_desc})" - ) - print(f"[DEBUG] MathJax {label}: {url_str}") - _MATHJAX_LOGGED_KEYS.add(local_key) return url except Exception as exc: diff --git a/src/runtime/runtime_logging.py b/src/runtime/runtime_logging.py index e7c3aba6..a97fc667 100644 --- a/src/runtime/runtime_logging.py +++ b/src/runtime/runtime_logging.py @@ -2,6 +2,7 @@ from __future__ import annotations +import atexit import builtins import io import logging @@ -222,7 +223,10 @@ def init_app_logging() -> Path: sh.setFormatter(fmt) root.addHandler(sh) - _RUNTIME_SESSION_HANDLER = None + hook_runtime_log_streams(tee=not getattr(sys, "frozen", False)) + _RUNTIME_SESSION_HANDLER = logging.StreamHandler(_RUNTIME_LOG_FH_OUT) + _RUNTIME_SESSION_HANDLER.setFormatter(fmt) + root.addHandler(_RUNTIME_SESSION_HANDLER) global _ORIGINAL_PRINT, _PRINT_BRIDGE_INSTALLED if (not _PRINT_BRIDGE_INSTALLED) and (file_handler is not None): @@ -274,9 +278,9 @@ def runtime_log_path() -> Path: def cleanup_runtime_log_session(): global _RUNTIME_LOG_FH_OUT, _RUNTIME_LOG_FH_ERR, _RUNTIME_SESSION_HANDLER try: - if isinstance(sys.stdout, TeeWriter): + if isinstance(sys.stdout, TeeWriter) or sys.stdout is _RUNTIME_LOG_FH_OUT: sys.stdout = sys.__stdout__ - if isinstance(sys.stderr, TeeWriter): + if isinstance(sys.stderr, TeeWriter) or sys.stderr is _RUNTIME_LOG_FH_ERR: sys.stderr = sys.__stderr__ except Exception: pass @@ -310,29 +314,13 @@ def cleanup_runtime_log_session(): except Exception: pass _RUNTIME_SESSION_HANDLER = None - try: - p = runtime_log_path() - if p.exists(): - p.unlink() - except Exception: - pass - _RUNTIME_LOG_WINDOW_READY = False def ensure_runtime_log_cleanup_hook(): global _RUNTIME_LOG_CLEANUP_HOOKED - if _RUNTIME_LOG_CLEANUP_HOOKED: - return - try: - from PyQt6.QtWidgets import QApplication - - app = QApplication.instance() - if app is None: - return - app.aboutToQuit.connect(cleanup_runtime_log_session) + if not _RUNTIME_LOG_CLEANUP_HOOKED: + atexit.register(cleanup_runtime_log_session) _RUNTIME_LOG_CLEANUP_HOOKED = True - except Exception: - pass def hook_runtime_log_streams(tee: bool = True): diff --git a/src/runtime/webengine_runtime.py b/src/runtime/webengine_runtime.py index ff76dcdb..0079d1de 100644 --- a/src/runtime/webengine_runtime.py +++ b/src/runtime/webengine_runtime.py @@ -25,13 +25,9 @@ def configure_default_webengine_profile() -> None: print(f"[WARN] QWebEngine 配置失败: {e}") -def _webengine_diag_enabled() -> bool: - return str(os.environ.get("LATEXSNIPPER_WEBENGINE_DIAG", "0")).strip() in ("1", "true", "yes", "on") - - def log_webengine_diagnostics(stage: str, err: Exception | None = None, app_dir: Path | None = None) -> None: """Output diagnostics for packaged WebEngine failures.""" - if err is None and not _webengine_diag_enabled(): + if err is None and not logging.getLogger("webengine").isEnabledFor(logging.DEBUG): return try: logger = logging.getLogger("webengine") diff --git a/src/ui/app_lifecycle_controller.py b/src/ui/app_lifecycle_controller.py index 9963795e..20899fc2 100644 --- a/src/ui/app_lifecycle_controller.py +++ b/src/ui/app_lifecycle_controller.py @@ -121,7 +121,7 @@ def apply_runtime_log_window_preference(self, enabled: bool): """Apply the runtime-log window preference.""" try: os.environ["LATEXSNIPPER_SHOW_RUNTIME_LOG"] = "1" if enabled else "0" - apply_runtime_log_window_preference(force=False, tee=True) + apply_runtime_log_window_preference(force=False) except Exception as e: print(f"[WARN] 应用运行日志窗口设置失败: {e}") diff --git a/src/ui/controllers/pdf.py b/src/ui/controllers/pdf.py index 6f84d711..210ba666 100644 --- a/src/ui/controllers/pdf.py +++ b/src/ui/controllers/pdf.py @@ -8,6 +8,7 @@ from PyQt6.QtCore import QThread, QTimer, Qt from PyQt6.QtWidgets import QApplication, QDialog, QInputDialog, QProgressDialog +from qfluentwidgets import ProgressBar from recognition.external_pdf_worker import ExternalModelPdfWorker from ui.notifications import show_user_notice @@ -204,7 +205,11 @@ def _recognize_pdf_file(self, file_path: str | Path): if doc_mode == "parse" else tr("正在识别 PDF...") ) + self._pdf_progress_text = progress_text self.pdf_progress = QProgressDialog(progress_text, tr("取消"), 0, pages, self) + progress_bar = ProgressBar(self.pdf_progress) + progress_bar.setRange(0, pages) + self.pdf_progress.setBar(progress_bar) self.pdf_progress.setWindowModality(Qt.WindowModality.NonModal) self.pdf_progress.setMinimumDuration(0) @@ -284,6 +289,11 @@ def _on_pdf_progress(self, current: int, total: int): if self.pdf_progress: try: self.pdf_progress.setMaximum(total) + if total > 0: + percent = max(0, min(100, int(current * 100 / total))) + self.pdf_progress.setLabelText( + f"{self._pdf_progress_text} {percent}% ({current}/{total})" + ) self.pdf_progress.setValue(current) except Exception: pass diff --git a/src/ui/main_window.py b/src/ui/main_window.py index b156e3a1..01053dab 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -45,6 +45,7 @@ class MainWindow( ): """Main application window based on QMainWindow.""" + modelStatusChanged = pyqtSignal() _model_warmup_result_signal = pyqtSignal() _preview_latex_render_request = pyqtSignal(str, str) @@ -81,6 +82,8 @@ def start_post_show_tasks(self): return self._post_show_tasks_started = True + QTimer.singleShot(0, self.onboarding.start_automatically) + seq = getattr(self, "_pending_hotkey_seq", None) if seq: QTimer.singleShot(0, lambda seq=seq: self.register_hotkey(seq)) diff --git a/src/ui/main_window_setup.py b/src/ui/main_window_setup.py index 567848e1..28c64620 100644 --- a/src/ui/main_window_setup.py +++ b/src/ui/main_window_setup.py @@ -32,6 +32,7 @@ from ui.favorites_window import FavoritesWindow from ui.automation_api_controller import AutomationApiController from ui.theme_controller import normalize_theme_mode +from ui.onboarding_controller import OnboardingController from ui.window_helpers import ( select_existing_directory_with_icon as _select_existing_directory_with_icon, ) @@ -170,7 +171,14 @@ def __init__(self, startup_progress=None): self.capture_button = PushButton(FluentIcon.SEARCH, tr("截图识别")) self.capture_button.setFixedHeight(40) self.capture_button.clicked.connect(self.start_capture) - left_layout.addWidget(self.capture_button) + capture_row = QHBoxLayout() + capture_row.addWidget(self.capture_button, 1) + self.onboarding_button = PushButton(FluentIcon.HELP, tr("快速入门")) + self.onboarding_button.setFixedHeight(40) + self.onboarding_button.setToolTip(tr("查看快速入门")) + self.onboarding_button.clicked.connect(lambda: self.onboarding.start()) + capture_row.addWidget(self.onboarding_button) + left_layout.addLayout(capture_row) history_header = QHBoxLayout() history_header.setContentsMargins(0, 0, 0, 0) @@ -429,5 +437,6 @@ def __init__(self, startup_progress=None): self.setMinimumSize(required_width, DEFAULT_MAIN_WINDOW_SIZE[1]) self.resize(required_width, DEFAULT_MAIN_WINDOW_SIZE[1]) + self.onboarding = OnboardingController(self) self.install_platform_lifecycle_hooks() QApplication.instance().aboutToQuit.connect(self._graceful_shutdown) diff --git a/src/ui/model_runtime_controller.py b/src/ui/model_runtime_controller.py index b221f344..f3fa0aed 100644 --- a/src/ui/model_runtime_controller.py +++ b/src/ui/model_runtime_controller.py @@ -426,10 +426,6 @@ def _apply_mathcraft_env(self): env_pyexe = pyexe except Exception: pass - try: - os.environ.pop("LATEXSNIPPER_SHARED_TORCH_SITE", None) - except Exception: - pass try: new_state = (env_pyexe,) old_state = getattr(self, "_mathcraft_env_state", None) diff --git a/src/ui/onboarding_controller.py b/src/ui/onboarding_controller.py new file mode 100644 index 00000000..24675f94 --- /dev/null +++ b/src/ui/onboarding_controller.py @@ -0,0 +1,180 @@ +"""Coordinate the tour without owning recognition or model initialization.""" + +import sys + +from PyQt6 import sip +from PyQt6.QtCore import QEvent, QObject, QTimer, QUrl +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import QApplication +from qfluentwidgets import qconfig + +from localization.manager import current_ui_language, translate as tr +from runtime.hotkey_config import display_hotkey +from ui.onboarding_steps import tour_steps +from ui.onboarding_view import TourSurface +from ui.runtime_log_controller import show_runtime_log_window +from update.release_types import _RELEASES_PAGE + +AUTO_SHOW_KEY = "onboarding_auto_show" +PROJECT_URL = _RELEASES_PAGE.removesuffix("/releases") + + +class OnboardingController(QObject): + def __init__(self, window): + super().__init__(window) + self.window = window + self.central_widget = window.centralWidget() + self.surface = TourSurface(self.central_widget) + self.surface.hide() + self.steps = tour_steps() + self.index = 0 + self.active = False + self.confirming_exit = False + self.automatic = False + self.auxiliary = None + self.surface.nextRequested.connect(self.advance) + self.surface.backRequested.connect(self.back) + self.surface.exitRequested.connect(self.request_exit) + self.surface.actionRequested.connect(self.action) + window.installEventFilter(self) + self.central_widget.installEventFilter(self) + window.modelStatusChanged.connect(self.refresh_busy) + qconfig.themeChanged.connect(self.surface.apply_theme) + + def start_automatically(self): + if ( + self.window.cfg.get(AUTO_SHOW_KEY, True) + and not QApplication.activeModalWidget() + ): + self.start(automatic=True) + + def start(self, automatic=False): + if self.active: + if self.auxiliary is not None: + self.auxiliary.raise_() + self.auxiliary.activateWindow() + else: + self.surface.raise_() + self.surface.next.setFocus() + return + self.active = True + self.confirming_exit = False + self.automatic = automatic + self.index = 0 + self.render() + + def render(self): + if not self.active or sip.isdeleted(self.window): + return + step = self.steps[self.index] + self.surface.targets = [ + widget + for name in step.targets + if (widget := getattr(self.window, name, None)) is not None + ] + actions = [] + detail = "" + if self.index == 2: + detail = display_hotkey(self.window.cfg.get("hotkey"), sys.platform) + actions = [("shortcut", tr("修改快捷键"))] + elif self.index == 3: + actions = [("logs", tr("查看日志"))] + elif self.index == 4: + actions = [ + ("star", tr("为项目点亮 Star")), + ("issues", tr("反馈问题")), + ("support", tr("社区 / 支持项目")), + ] + self.surface.show() + self.surface.set_content(step, self.index, len(self.steps), actions, detail) + self.refresh_busy() + self.surface.reposition() + self.surface.raise_() + + def refresh_busy(self): + if sip.isdeleted(self.window) or sip.isdeleted(self.surface): + return + self.surface.ring.setVisible( + self.active + and not self.confirming_exit + and self.index == 3 + and bool(self.window._model_warmup_in_progress) + ) + if self.surface.isVisible(): + self.surface.reposition() + + def advance(self): + if self.confirming_exit or self.index == len(self.steps) - 1: + self.finish() + else: + self.index += 1 + self.render() + + def back(self): + if self.confirming_exit: + self.confirming_exit = False + else: + self.index = max(0, self.index - 1) + self.render() + + def request_exit(self): + if self.confirming_exit: + self.back() + return + self.confirming_exit = True + self.surface.show_exit() + + def finish(self): + if self.automatic: + self.window.cfg.set(AUTO_SHOW_KEY, False) + self.active = False + self.surface.hide() + self.window.onboarding_button.setFocus() + + def action(self, key): + if key in ("shortcut", "logs"): + self.surface.hide() + if key == "shortcut": + self.window.set_shortcut() + auxiliary = self.window.shortcut_window + else: + auxiliary = show_runtime_log_window(self.window) + if auxiliary is None: + self.render() + return + self.auxiliary = auxiliary + auxiliary.installEventFilter(self) + return + url = PROJECT_URL + if key == "issues": + url += "/issues" + elif key == "support": + page = ( + "README.zh-CN.md#支持本项目" + if current_ui_language() == "zh_CN" + else "readme.md#support-the-project" + ) + url += f"/blob/main/{page}" + if not QDesktopServices.openUrl(QUrl(url)): + self.surface.detail.setText(tr("无法打开链接,请检查默认浏览器设置。")) + self.surface.detail.show() + self.surface.reposition() + + def eventFilter(self, watched, event): + if sip.isdeleted(self.window) or sip.isdeleted(self.surface): + self.active = False + return False + if watched is self.auxiliary and event.type() in ( + QEvent.Type.Hide, + QEvent.Type.Close, + ): + watched.removeEventFilter(self) + self.auxiliary = None + if self.active: + QTimer.singleShot(0, self.render) + elif watched is self.central_widget and event.type() == QEvent.Type.Resize: + self.surface.reposition() + elif watched is self.window and event.type() == QEvent.Type.Close: + self.active = False + self.surface.hide() + return False diff --git a/src/ui/onboarding_steps.py b/src/ui/onboarding_steps.py new file mode 100644 index 00000000..fc9208db --- /dev/null +++ b/src/ui/onboarding_steps.py @@ -0,0 +1,43 @@ +"""Declarative content for the main-window introduction.""" + +from dataclasses import dataclass + +from localization.manager import translate as tr + + +@dataclass(frozen=True) +class TourStep: + title: str + description: str + icon: str + targets: tuple[str, ...] = () + + +def tour_steps() -> tuple[TourStep, ...]: + return ( + TourStep(tr("欢迎使用 LaTeXSnipper"), tr("您的快速上手向导。"), "HOME"), + TourStep( + tr("从这里开始"), + tr("识别、预览,再导出您的公式。"), + "SEARCH", + ("capture_button", "preview_view", "preview_fallback_label", "export_btn"), + ), + TourStep( + tr("让操作更顺手"), + tr("设置您习惯的截图快捷键。"), + "EDIT", + ("change_key_button",), + ), + TourStep( + tr("关于模型准备"), + tr("本地模型会在后台准备,首次使用可能需要下载。您可以在日志中查看详情。"), + "SYNC", + ("status_label",), + ), + TourStep( + tr("一起让它变得更好"), + tr("如果 LaTeXSnipper 对您有帮助,欢迎支持项目。"), + "HEART", + ), + TourStep(tr("开始使用吧"), tr("欢迎,让公式处理更简单。"), "ACCEPT"), + ) diff --git a/src/ui/onboarding_view.py b/src/ui/onboarding_view.py new file mode 100644 index 00000000..deee51f9 --- /dev/null +++ b/src/ui/onboarding_view.py @@ -0,0 +1,219 @@ +"""Native Fluent tour card and main-window spotlight surface.""" + +from PyQt6.QtCore import QPoint, QRect, QRectF, Qt, pyqtSignal +from PyQt6.QtGui import QColor, QPainter, QPainterPath, QPen +from PyQt6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) +from qfluentwidgets import ( + BodyLabel, + FluentIcon, + PrimaryPushButton, + ProgressRing, + PushButton, + SubtitleLabel, + TransparentToolButton, + isDarkTheme, + themeColor, +) + +from localization.manager import translate as tr + + +class TourSurface(QWidget): + nextRequested = pyqtSignal() + backRequested = pyqtSignal() + exitRequested = pyqtSignal() + actionRequested = pyqtSignal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.targets = [] + self.card = QFrame(self) + self.card.setObjectName("tourCard") + layout = QVBoxLayout(self.card) + layout.setContentsMargins(28, 24, 28, 24) + layout.setSpacing(16) + header = QHBoxLayout() + self.brand = BodyLabel("LaTeXSnipper", self.card) + header.addWidget(self.brand) + header.addStretch() + self.counter = BodyLabel(self.card) + header.addWidget(self.counter) + self.close_button = TransparentToolButton(FluentIcon.CLOSE, self.card) + self.close_button.setAccessibleName(tr("关闭引导")) + self.close_button.setToolTip(tr("关闭引导")) + self.close_button.clicked.connect(self.exitRequested) + header.addWidget(self.close_button) + layout.addLayout(header) + self.hero = QLabel(self.card) + self.hero.setFixedHeight(64) + layout.addWidget(self.hero) + self.title = SubtitleLabel(self.card) + self.title.setWordWrap(True) + layout.addWidget(self.title) + self.description = BodyLabel(self.card) + self.description.setWordWrap(True) + layout.addWidget(self.description) + self.detail = BodyLabel(self.card) + self.detail.setWordWrap(True) + layout.addWidget(self.detail) + self.ring = ProgressRing(self.card) + self.ring.setFixedSize(28, 28) + layout.addWidget(self.ring) + self.actions_widget = QWidget(self.card) + self.actions = QVBoxLayout(self.actions_widget) + self.actions.setContentsMargins(0, 0, 0, 0) + self.actions.setSpacing(8) + layout.addWidget(self.actions_widget) + footer = QHBoxLayout() + self.back = PushButton(tr("上一步"), self.card) + self.back.clicked.connect(self.backRequested) + footer.addWidget(self.back) + footer.addStretch() + self.skip = PushButton(tr("跳过"), self.card) + self.skip.clicked.connect(self.exitRequested) + footer.addWidget(self.skip) + self.next = PrimaryPushButton(tr("下一步"), self.card) + self.next.clicked.connect(self.nextRequested) + footer.addWidget(self.next) + layout.addLayout(footer) + + def set_content(self, step, index, count, actions, detail=""): + self.title.setText(step.title) + self.description.setText(step.description) + self.detail.setText(detail) + self.detail.setVisible(bool(detail)) + self.counter.setText(f"{index + 1:02d} / {count:02d}") + self.hero.setPixmap(getattr(FluentIcon, step.icon).icon().pixmap(48, 48)) + self.hero.show() + self.back.setText(tr("上一步")) + self.back.setVisible(index > 0) + self.skip.show() + self.next.setText( + tr("开始使用") + if index == count - 1 + else tr("开始引导") + if index == 0 + else tr("下一步") + ) + while self.actions.count(): + self.actions.takeAt(0).widget().deleteLater() + for key, label in actions: + button = PushButton(label, self.actions_widget) + button.setFixedHeight(36) + button.clicked.connect( + lambda checked=False, key=key: self.actionRequested.emit(key) + ) + self.actions.addWidget(button) + self.actions_widget.setMinimumHeight( + len(actions) * 36 + max(0, len(actions) - 1) * 8 + ) + self.actions_widget.setVisible(bool(actions)) + self.apply_theme() + self.reposition() + self.next.setFocus() + + def show_exit(self): + self.title.setText(tr("退出快速入门?")) + self.description.setText(tr("需要重看时,点击主窗口“快速入门”。")) + self.hero.hide() + self.detail.hide() + self.ring.hide() + self.actions_widget.hide() + self.back.show() + self.back.setText(tr("继续引导")) + self.skip.hide() + self.next.setText(tr("退出引导")) + self.targets = [] + self.reposition() + self.back.setFocus() + + def apply_theme(self): + dark = isDarkTheme() + background = "#25282e" if dark else "#ffffff" + border = "#454b55" if dark else "#dce3ed" + self.card.setStyleSheet( + f"QFrame#tourCard {{ background: {background}; border: 1px solid {border}; border-radius: 18px; }}" + ) + self.brand.setStyleSheet(f"color: {themeColor().name()}; font-weight: 600;") + self.update() + + def target_rects(self): + return [ + QRectF( + QRect(self.mapFromGlobal(widget.mapToGlobal(QPoint())), widget.size()) + ).adjusted(-5, -5, 5, 5) + for widget in self.targets + if widget.isVisible() + ] + + def reposition(self): + self.setGeometry(self.parentWidget().rect()) + self.card.setFixedWidth(min(500, max(280, self.width() - 40))) + self.card.adjustSize() + x = (self.width() - self.card.width()) // 2 + y = (self.height() - self.card.height()) // 2 + targets = self.target_rects() + if targets: + target = targets[0] + if target.right() + 24 + self.card.width() < self.width() - 20: + x = int(target.right() + 24) + elif target.left() - 24 - self.card.width() > 20: + x = int(target.left() - 24 - self.card.width()) + y = min( + max(20, int(target.center().y() - self.card.height() / 2)), + max(20, self.height() - self.card.height() - 20), + ) + self.card.move(x, max(20, y)) + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + mask = QPainterPath() + mask.addRect(QRectF(self.rect())) + for rect in self.target_rects(): + hole = QPainterPath() + hole.addRoundedRect(rect, 8, 8) + mask = mask.subtracted(hole) + painter.fillPath(mask, QColor(0, 0, 0, 145)) + painter.setPen(QPen(themeColor(), 2)) + for rect in self.target_rects(): + painter.drawRoundedRect(rect, 8, 8) + + def focusNextPrevChild(self, forward): + current = QApplication.focusWidget() or self.next + candidate = current + while True: + candidate = ( + candidate.nextInFocusChain() + if forward + else candidate.previousInFocusChain() + ) + if candidate is current: + return True + if ( + self.card.isAncestorOf(candidate) + and candidate.isVisible() + and candidate.isEnabled() + and candidate.focusPolicy() & Qt.FocusPolicy.TabFocus + ): + candidate.setFocus( + Qt.FocusReason.TabFocusReason + if forward + else Qt.FocusReason.BacktabFocusReason + ) + return True + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape: + self.exitRequested.emit() + else: + super().keyPressEvent(event) diff --git a/src/ui/runtime_log_controller.py b/src/ui/runtime_log_controller.py index e26ce5ae..ce2690ac 100644 --- a/src/ui/runtime_log_controller.py +++ b/src/ui/runtime_log_controller.py @@ -9,10 +9,9 @@ from PyQt6.QtWidgets import QApplication from runtime.app_paths import app_config_path -from runtime.runtime_logging import hook_runtime_log_streams, runtime_log_path +from runtime.runtime_logging import runtime_log_path from ui.runtime_log_dialog import RuntimeLogDialog -_RUNTIME_LOG_WINDOW_READY = False _RUNTIME_LOG_DIALOG: RuntimeLogDialog | None = None def show_runtime_log_window(parent=None): @@ -32,6 +31,8 @@ def show_runtime_log_window(parent=None): except Exception: pass + return _RUNTIME_LOG_DIALOG + def refresh_runtime_log_dialog_theme(force: bool = True) -> None: try: @@ -41,13 +42,8 @@ def refresh_runtime_log_dialog_theme(force: bool = True) -> None: pass -def apply_runtime_log_window_preference(force: bool = False, tee: bool = True): +def apply_runtime_log_window_preference(force: bool = False): """Apply the preference for the scrollable GUI runtime-log window.""" - global _RUNTIME_LOG_WINDOW_READY - - if getattr(sys, "frozen", False): - tee = False - def _read_runtime_log_window_pref(default: bool = False) -> bool: try: cfg = app_config_path() @@ -83,19 +79,4 @@ def _read_runtime_log_window_pref(default: bool = False) -> bool: pass return - try: - if _RUNTIME_LOG_WINDOW_READY: - show_runtime_log_window() - return - hook_runtime_log_streams(tee=tee) - show_runtime_log_window() - _RUNTIME_LOG_WINDOW_READY = True - print("[DEBUG] 运行日志窗口已打开") - except Exception: - try: - if sys.__stdout__ and not getattr(sys.__stdout__, "closed", False): - sys.stdout = sys.__stdout__ - if sys.__stderr__ and not getattr(sys.__stderr__, "closed", False): - sys.stderr = sys.__stderr__ - except Exception: - pass + show_runtime_log_window() diff --git a/src/ui/runtime_log_dialog.py b/src/ui/runtime_log_dialog.py index 69bc020d..a9a2e595 100644 --- a/src/ui/runtime_log_dialog.py +++ b/src/ui/runtime_log_dialog.py @@ -86,9 +86,6 @@ def _apply_theme_styles(self, force: bool = False): pass def _ensure_file(self): - self._log_file.parent.mkdir(parents=True, exist_ok=True) - if not self._log_file.exists(): - self._log_file.write_text("", encoding="utf-8") self._pos = 0 def _open_dir(self): diff --git a/src/ui/startup_splash.py b/src/ui/startup_splash.py index 0f81f61a..fbc37ecf 100644 --- a/src/ui/startup_splash.py +++ b/src/ui/startup_splash.py @@ -12,6 +12,7 @@ from runtime.app_paths import resource_path _STARTUP_SPLASH = None +_FORCE_ENTERED = False class StartupDialog(QWidget): @@ -207,7 +208,8 @@ def hide_startup_splash_for_modal(): def mark_startup_force_entered(): - os.environ["LATEXSNIPPER_FORCE_ENTERED"] = "1" + global _FORCE_ENTERED + _FORCE_ENTERED = True app = QApplication.instance() if app is not None: return take_startup_splash(app, tr("正在跳过依赖安装并进入主程序...")) @@ -215,7 +217,7 @@ def mark_startup_force_entered(): def startup_force_enter_pending() -> bool: - return os.environ.get("LATEXSNIPPER_FORCE_ENTERED") == "1" + return _FORCE_ENTERED def startup_status_message(default: str) -> str: @@ -227,6 +229,8 @@ def startup_status_message(default: str) -> str: def startup_deps_resume_message() -> str: - if os.environ.pop("LATEXSNIPPER_FORCE_ENTERED", "0") == "1": + global _FORCE_ENTERED + if _FORCE_ENTERED: + _FORCE_ENTERED = False return tr("正在跳过依赖安装并进入主程序...") return tr("加载界面组件...") diff --git a/src/ui/status_controller.py b/src/ui/status_controller.py index b79978ef..b13104e3 100644 --- a/src/ui/status_controller.py +++ b/src/ui/status_controller.py @@ -66,6 +66,9 @@ def refresh_status_label(self): def set_model_status(self, msg: str): self.model_status = msg self.refresh_status_label() + signal = getattr(self, "modelStatusChanged", None) + if signal is not None: + signal.emit() def set_action_status(self, msg: str, auto_clear_ms: int = 2500, parent=None): self.show_action_status( diff --git a/src/update/github_release_client.py b/src/update/github_release_client.py index a19fbd11..0a14871c 100644 --- a/src/update/github_release_client.py +++ b/src/update/github_release_client.py @@ -1,3 +1,4 @@ +import logging import os import sys from datetime import datetime, timezone @@ -11,7 +12,6 @@ from update.release_cache import _load_cached_info, _save_cached_info from update.release_types import ( CONNECT_TIMEOUT, - DEBUG_LOG, READ_TIMEOUT, ReleaseInfo, __version__, @@ -107,11 +107,9 @@ def _configure_tls_verify(): # Set envs for child requests/urllib callers in this process. os.environ["REQUESTS_CA_BUNDLE"] = ca_path os.environ["SSL_CERT_FILE"] = ca_path - if DEBUG_LOG: - print(f"[DEBUG] 更新检查 TLS CA bundle: {ca_path}") + logging.getLogger(__name__).debug("更新检查 TLS CA bundle: %s", ca_path) else: - if DEBUG_LOG: - print("[WARN] 未找到 TLS CA bundle,更新检查可能因 HTTPS 验证失败") + logging.getLogger(__name__).warning("未找到 TLS CA bundle,更新检查可能因 HTTPS 验证失败") def _attach_auth_headers(h: dict): diff --git a/src/update/release_types.py b/src/update/release_types.py index 139374bb..43d32a3a 100644 --- a/src/update/release_types.py +++ b/src/update/release_types.py @@ -1,4 +1,3 @@ -import os import re from dataclasses import dataclass @@ -13,12 +12,7 @@ CONNECT_TIMEOUT = 6 READ_TIMEOUT = 8 -DEBUG_LOG = os.environ.get("LATEXSNIPPER_UPDATE_DEBUG", "").strip().lower() in { - "1", - "true", - "yes", - "on", -} + @dataclass diff --git a/src/update/update_dialog.py b/src/update/update_dialog.py index 8f63ce63..19fdc678 100644 --- a/src/update/update_dialog.py +++ b/src/update/update_dialog.py @@ -1,4 +1,5 @@ from localization.manager import translate as tr +import logging import html import os import subprocess @@ -15,10 +16,9 @@ QHBoxLayout, QLabel, QMessageBox, - QProgressBar, QVBoxLayout, ) -from qfluentwidgets import FluentIcon, InfoBar, InfoBarPosition, PushButton +from qfluentwidgets import FluentIcon, InfoBar, InfoBarPosition, PushButton, ProgressBar, IndeterminateProgressBar from update.dialog_helpers import ( _clear_global, @@ -45,7 +45,6 @@ ) from update.release_types import ( CONNECT_TIMEOUT, - DEBUG_LOG, READ_TIMEOUT, ReleaseInfo, __version__, @@ -97,9 +96,22 @@ def check_update_dialog(parent=None): lay.addWidget(lbl_current) lbl_status = QLabel(tr("正在联网获取最新版本信息,请保持与 GitHub 的连接畅通...")) lay.addWidget(lbl_status) - bar = QProgressBar() - bar.setRange(0, 0) + bar = ProgressBar(dlg) + busy_bar = IndeterminateProgressBar(dlg) lay.addWidget(bar) + lay.addWidget(busy_bar) + + def set_progress_range(minimum, maximum): + busy = minimum == maximum == 0 + bar.setVisible(not busy) + busy_bar.setVisible(busy) + if busy: + busy_bar.start() + else: + busy_bar.stop() + bar.setRange(minimum, maximum) + + set_progress_range(0, 0) txt = RemoteImageBrowser() txt.setOpenExternalLinks(True) @@ -168,7 +180,7 @@ def watchdog_timeout(): if state["aborted"] or state["done"] or (not dlg.isVisible()): return state["done"] = True - bar.setRange(0, 1) + set_progress_range(0, 1) lbl_status.setText(tr("获取超时,可重新检查。")) txt.start_new_html( "
"
@@ -201,13 +213,13 @@ def render_changelog(changelog: str):
         txt.start_new_markdown(changelog, css)
 
     def on_result(info, err, diag):
-        if DEBUG_LOG and diag:
-            print(f"[DEBUG] 更新检查诊断: {diag}")
+        if diag:
+            logging.getLogger(__name__).debug("更新检查诊断: %s", diag)
         if state["aborted"] or state["done"] or (not dlg.isVisible()):
             return
         state["done"] = True
         watchdog.stop()
-        bar.setRange(0, 1)
+        set_progress_range(0, 1)
         dlg.unsetCursor()
         if err:
             message = _brief_error_message(err)
@@ -409,7 +421,7 @@ def _maybe_launch_installer(path: str):
     def _on_download_progress(cur: int, total: int, path: object):
         if state["aborted"] or (not dlg.isVisible()):
             return
-        bar.setRange(0, max(total, 1))
+        set_progress_range(0, total if total > 0 else 0)
         bar.setValue(max(0, min(cur, max(total, 1))))
         name = Path(str(path or "")).name or tr("更新包")
         if total > 0:
@@ -432,7 +444,7 @@ def _on_download_done(path: object, err: object):
             btn_copy.setEnabled(bool(state.get("info")))
             btn_retry.setEnabled(True)
             dlg.unsetCursor()
-            bar.setRange(0, 1)
+            set_progress_range(0, 1)
             lbl_status.setText(tr("下载已暂停,可稍后继续下载。"))
             InfoBar.info(
                 title=tr("下载已暂停"),
@@ -449,7 +461,7 @@ def _on_download_done(path: object, err: object):
             btn_copy.setEnabled(bool(state.get("info")))
             btn_retry.setEnabled(True)
             dlg.unsetCursor()
-            bar.setRange(0, 1)
+            set_progress_range(0, 1)
             lbl_status.setText(tr("下载失败:{message}").format(message=message))
             InfoBar.error(
                 title=tr("下载失败"),
@@ -459,7 +471,7 @@ def _on_download_done(path: object, err: object):
                 position=InfoBarPosition.TOP,
             )
             return
-        bar.setRange(0, 1)
+        set_progress_range(0, 1)
         bar.setValue(1)
         _maybe_launch_installer(str(path or ""))
         _refresh_download_button()
@@ -489,7 +501,7 @@ def start_fetch():
         txt.start_new_html(
             f"

{html.escape(tr('正在获取...'))}

" ) - bar.setRange(0, 0) + set_progress_range(0, 0) btn_open.setEnabled(False) btn_copy.setEnabled(False) btn_retry.setEnabled(False) @@ -569,7 +581,7 @@ def do_download(): btn_copy.setEnabled(False) btn_retry.setEnabled(False) dlg.setCursor(Qt.CursorShape.BusyCursor) - bar.setRange(0, 100) + set_progress_range(0, 100) bar.setValue(0) lbl_status.setText(tr("正在下载更新包...")) state["downloading"] = True diff --git a/test/test_dependency_python.py b/test/test_dependency_python.py index c62836d9..6d11f0b2 100644 --- a/test/test_dependency_python.py +++ b/test/test_dependency_python.py @@ -6,12 +6,12 @@ def test_dependency_python_cleans_quoted_paths() -> None: from runtime.dependency_python import clean_path_value, normalize_deps_base_dir - assert clean_path_value('"E:\\LaTexSnipper\\broken\\python\\python.exe') == ( - "E:\\LaTexSnipper\\broken\\python\\python.exe" + assert clean_path_value('"C:\\Example Project\\broken\\python\\python.exe') == ( + "C:\\Example Project\\broken\\python\\python.exe" ) assert clean_path_value( - "'E:\\LaTexSnipper\\tools\\deps\\python311\\python.exe'" - ) == ("E:\\LaTexSnipper\\tools\\deps\\python311\\python.exe") - assert normalize_deps_base_dir("E:\\LaTexSnipper\\deps\\python") == Path( - "E:\\LaTexSnipper\\deps\\python" + "'C:\\Developer Envs\\mathcraft\\Scripts\\python.exe'" + ) == ("C:\\Developer Envs\\mathcraft\\Scripts\\python.exe") + assert normalize_deps_base_dir("C:\\Example Project\\deps\\python") == Path( + "C:\\Example Project\\deps\\python" ) diff --git a/test/test_dependency_startup_state.py b/test/test_dependency_startup_state.py new file mode 100644 index 00000000..08d8977f --- /dev/null +++ b/test/test_dependency_startup_state.py @@ -0,0 +1,44 @@ +from application import dependency_controller as controller +from ui import startup_splash + + +def test_dependency_cache_reused_but_settings_checks_again(monkeypatch): + from bootstrap import deps_entry + + calls = [] + monkeypatch.setattr(controller, "_dependencies_ready", False) + monkeypatch.setattr(controller, "deps_force_entered", lambda: False) + monkeypatch.setattr( + deps_entry, "ensure_deps", lambda *args, **kwargs: calls.append(kwargs) or True + ) + assert controller.ensure_deps(prompt_ui=False) + assert controller.ensure_deps(prompt_ui=False) + assert len(calls) == 1 + assert controller.ensure_deps(prompt_ui=False, from_settings=True) + assert len(calls) == 2 + + +def test_force_enter_message_consumed_once(monkeypatch): + monkeypatch.setattr(startup_splash, "_FORCE_ENTERED", False) + monkeypatch.setattr(startup_splash, "ensure_startup_splash", lambda *args: None) + monkeypatch.setattr(startup_splash, "take_startup_splash", lambda *args: None) + startup_splash.mark_startup_force_entered() + assert startup_splash.startup_force_enter_pending() + message = startup_splash.startup_deps_resume_message() + assert not startup_splash.startup_force_enter_pending() + assert startup_splash.startup_deps_resume_message() != message + + +def test_cancelled_dependency_startup_exits(monkeypatch, tmp_path): + import pytest + from application import bootstrap + + monkeypatch.delenv("LATEXSNIPPER_BOOTSTRAPPED", raising=False) + monkeypatch.delenv("LATEXSNIPPER_OPEN_DEPENDENCY_MANAGEMENT", raising=False) + monkeypatch.setattr(bootstrap, "_sanitize_sys_path", lambda *args: None) + monkeypatch.setattr(bootstrap, "_is_packaged_mode", lambda: False) + monkeypatch.setattr(bootstrap, "ensure_startup_splash", lambda *args: None) + monkeypatch.setattr(bootstrap, "ensure_deps", lambda **kwargs: False) + with pytest.raises(SystemExit) as result: + bootstrap._bootstrap_dependencies(tmp_path, "python") + assert result.value.code == 0 diff --git a/test/test_download_control.py b/test/test_download_control.py new file mode 100644 index 00000000..e19b128d --- /dev/null +++ b/test/test_download_control.py @@ -0,0 +1,79 @@ +import subprocess +import sys +import threading +import time + +from bootstrap.download_control import DownloadControl, wait_for_download + + +def test_pause_stops_running_process_and_resume_continues(tmp_path): + heartbeat = tmp_path / "heartbeat" + code = ( + "import pathlib,sys,time; p=pathlib.Path(sys.argv[1]); " + + "\nwhile True:\n p.write_text(str(time.monotonic()))\n time.sleep(0.02)" + ) + proc = subprocess.Popen([sys.executable, "-c", code, str(heartbeat)]) + gate = threading.Event() + gate.set() + control = DownloadControl(gate) + control.set_process(proc) + try: + deadline = time.monotonic() + 5 + while not heartbeat.exists() and time.monotonic() < deadline: + time.sleep(0.02) + assert heartbeat.exists() + control.set_paused(True) + before = heartbeat.read_bytes() + time.sleep(0.15) + assert heartbeat.read_bytes() == before + control.set_paused(False) + deadline = time.monotonic() + 5 + while heartbeat.read_bytes() == before and time.monotonic() < deadline: + time.sleep(0.02) + assert heartbeat.read_bytes() != before + finally: + control.set_paused(False) + proc.terminate() + proc.wait(timeout=5) + control.set_process(None) + + +def test_cooperative_pause_can_resume_or_cancel(): + gate, stop, done = threading.Event(), threading.Event(), threading.Event() + result = [] + + def run(): + result.append(wait_for_download(gate, stop)) + done.set() + + thread = threading.Thread(target=run) + thread.start() + assert not done.wait(0.05) + gate.set() + assert done.wait(1) + thread.join() + assert result == [True] + gate.clear() + stop.set() + assert wait_for_download(gate, stop) is False + + +def test_exit_confirmation_keeps_task_until_confirmed(monkeypatch): + from PyQt6.QtWidgets import QApplication, QMessageBox + from bootstrap.progress_dialog import InstallProgressDialog + + app = QApplication.instance() or QApplication([]) + dialog = InstallProgressDialog() + dialog.confirm_cancel = True + monkeypatch.setattr( + QMessageBox, "question", lambda *args: QMessageBox.StandardButton.No + ) + assert dialog.confirm_exit() is False + assert dialog.confirm_cancel + monkeypatch.setattr( + QMessageBox, "question", lambda *args: QMessageBox.StandardButton.Yes + ) + assert dialog.confirm_exit() is True + assert dialog.confirm_exit() is True + dialog.close() + app.processEvents() diff --git a/test/test_main_window_import.py b/test/test_main_window_import.py new file mode 100644 index 00000000..851b5f2a --- /dev/null +++ b/test/test_main_window_import.py @@ -0,0 +1,21 @@ +"""Verify the real UI import graph in an isolated interpreter.""" + +import os +from pathlib import Path +import subprocess +import sys + + +def test_main_window_imports_without_test_stubs(): + root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + result = subprocess.run( + [sys.executable, "-c", "import sys; sys.path[:0] = sys.argv[1:]; from ui.main_window import MainWindow; assert callable(MainWindow)", str(root / "src"), str(root)], + cwd=root, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/test/test_onboarding.py b/test/test_onboarding.py new file mode 100644 index 00000000..e71e50ea --- /dev/null +++ b/test/test_onboarding.py @@ -0,0 +1,262 @@ +"""Behavioral coverage for the shared native onboarding flow.""" + +from PyQt6.QtCore import QPoint, pyqtSignal, Qt +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import ( + QApplication, + QDialog, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) +import pytest + +from ui.onboarding_controller import AUTO_SHOW_KEY, OnboardingController + + +class Config: + def __init__(self): + self.data = {} + + def get(self, key, default=None): + return self.data.get(key, default) + + def set(self, key, value): + self.data[key] = value + + +class Host(QMainWindow): + modelStatusChanged = pyqtSignal() + + def __init__(self): + super().__init__() + self.cfg = Config() + self._model_warmup_in_progress = False + self.setCentralWidget(QWidget()) + layout = QVBoxLayout(self.centralWidget()) + for name in ( + "capture_button", + "latex_editor", + "preview_view", + "export_btn", + "change_key_button", + "status_label", + "onboarding_button", + ): + widget = QPushButton(name) + setattr(self, name, widget) + layout.addWidget(widget) + self.resize(1180, 720) + self.onboarding = OnboardingController(self) + + def set_shortcut(self): + self.shortcut_window = QDialog(self) + self.shortcut_window.show() + + +@pytest.fixture(scope="session") +def qt_app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def host(qt_app): + app = qt_app + window = Host() + window.show() + app.processEvents() + yield window + window.close() + window.deleteLater() + app.processEvents() + + +def test_complete_and_manual_replay_preserve_preference(host): + tour = host.onboarding + tour.start_automatically() + for _ in range(6): + tour.advance() + assert not tour.active + assert host.cfg.get(AUTO_SHOW_KEY) is False + tour.start_automatically() + assert not tour.active + tour.start() + tour.finish() + assert host.cfg.get(AUTO_SHOW_KEY) is False + + +@pytest.mark.parametrize("action", ["skip", "close_button", "escape"]) +def test_automatic_exit_disables_future_tours(host, action): + tour = host.onboarding + tour.start_automatically() + tour.advance() + if action == "escape": + QTest.keyClick(tour.surface, Qt.Key.Key_Escape) + else: + getattr(tour.surface, action).click() + assert tour.active + assert host.cfg.get(AUTO_SHOW_KEY, True) is True + tour.surface.back.click() + assert tour.index == 1 + tour.request_exit() + tour.surface.next.click() + assert not tour.active + assert host.cfg.get(AUTO_SHOW_KEY) is False + + +@pytest.mark.parametrize("preference", [True, False]) +def test_manual_exit_and_application_close_preserve_preference(host, preference): + host.cfg.set(AUTO_SHOW_KEY, preference) + tour = host.onboarding + tour.start() + tour.finish() + assert host.cfg.get(AUTO_SHOW_KEY) is preference + tour.start(automatic=True) + host.close() + assert not tour.active + assert host.cfg.get(AUTO_SHOW_KEY) is preference + + +def test_model_busy_only_affects_preparation_ring(host): + tour = host.onboarding + tour.start() + for _ in range(3): + tour.advance() + host._model_warmup_in_progress = True + host.modelStatusChanged.emit() + assert tour.surface.ring.isVisible() + host._model_warmup_in_progress = False + host.modelStatusChanged.emit() + assert not tour.surface.ring.isVisible() + tour.advance() + tour.advance() + title = tour.surface.title.text() + host._model_warmup_in_progress = True + host.modelStatusChanged.emit() + assert tour.surface.title.text() == title + assert not tour.surface.ring.isVisible() + tour.advance() + assert host._model_warmup_in_progress + + +def test_shortcut_pause_resume_and_single_instance(host): + tour = host.onboarding + tour.start() + tour.advance() + tour.advance() + tour.action("shortcut") + assert not tour.surface.isVisible() + tour.start() + assert tour.index == 2 + host.cfg.set("hotkey", "Ctrl+Shift+K") + host.shortcut_window.close() + QApplication.processEvents() + assert tour.surface.isVisible() + assert tour.surface.detail.text() == "Ctrl+Shift+K" + + +def test_resize_keeps_card_inside_surface(host): + tour = host.onboarding + tour.start() + for _ in range(6): + host.resize(980, 680) + QApplication.processEvents() + assert tour.surface.rect().contains(tour.surface.card.geometry()) + tour.advance() + + +def test_tab_stays_inside_card_and_closed_tour_does_not_reopen(host): + tour = host.onboarding + tour.start() + for _ in range(16): + QTest.keyClick(QApplication.focusWidget(), Qt.Key.Key_Tab) + assert tour.surface.card.isAncestorOf(QApplication.focusWidget()) + tour.finish() + tour.render() + assert not tour.surface.isVisible() + + +@pytest.mark.parametrize("origin", [(140, 90), (380, 220)]) +def test_spotlights_align_with_nested_widgets_after_move_and_resize(host, origin): + panel = QWidget(host.centralWidget()) + panel.setGeometry(35, 60, 700, 450) + panel.show() + target = host.capture_button + target.setParent(panel) + target.setGeometry(25, 30, 200, 40) + target.show() + host.move(*origin) + host.resize(1250, 780) + host.onboarding.start() + host.onboarding.advance() + QApplication.processEvents() + surface = host.onboarding.surface + for widget, rect in zip(surface.targets, surface.target_rects()): + actual = surface.mapToGlobal(rect.topLeft().toPoint() + QPoint(5, 5)) + assert actual == widget.mapToGlobal(QPoint()) + assert rect.width() == widget.width() + 10 + assert rect.height() == widget.height() + 10 + assert host.preview_view in surface.targets + host.onboarding.advance() + rect = surface.target_rects()[0] + assert surface.mapToGlobal( + rect.topLeft().toPoint() + QPoint(5, 5) + ) == host.change_key_button.mapToGlobal(QPoint()) + + +@pytest.mark.parametrize( + "language,page,fragment", + [ + ("zh_CN", "README.zh-CN.md", "支持本项目"), + ("en_US", "readme.md", "support-the-project"), + ], +) +def test_combined_support_opens_readme_for_current_ui_language( + host, monkeypatch, language, page, fragment +): + import ui.onboarding_controller as controller + + opened = [] + monkeypatch.setattr(controller, "current_ui_language", lambda: language) + monkeypatch.setattr( + controller.QDesktopServices, "openUrl", lambda url: opened.append(url) or True + ) + tour = host.onboarding + tour.start() + for _ in range(4): + tour.advance() + assert tour.surface.actions.count() == 3 + tour.surface.actions.itemAt(2).widget().click() + assert len(opened) == 1 + assert opened[0].path().endswith("/" + page) + assert opened[0].fragment() == fragment + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + assert page in {entry.name for entry in root.iterdir() if entry.is_file()} + + +@pytest.mark.parametrize("with_auxiliary", [False, True]) +def test_window_destruction_does_not_access_deleted_qt_objects( + qt_app, monkeypatch, with_auxiliary +): + import sys + from PyQt6 import sip + from PyQt6.QtCore import QEvent + + errors = [] + monkeypatch.setattr(sys, "excepthook", lambda *error: errors.append(error)) + window = Host() + window.show() + tour = window.onboarding + tour.start() + if with_auxiliary: + tour.action("shortcut") + window.shortcut_window.close() + sip.delete(window) + QApplication.processEvents() + assert not errors + assert tour.eventFilter(None, QEvent(QEvent.Type.ChildRemoved)) is False + tour.render() + tour.refresh_busy() diff --git a/test/test_python_runtime_resolver.py b/test/test_python_runtime_resolver.py index c6f035bb..4888e7f1 100644 --- a/test/test_python_runtime_resolver.py +++ b/test/test_python_runtime_resolver.py @@ -38,3 +38,19 @@ def fake_run(cmd, *args, **kwargs): assert resolver._find_full_python(tmp_path) == str(pyexe) assert commands assert all("ensurepip" not in code and "venv" not in code for code in commands) + + +def test_private_interpreter_relaunches_application(monkeypatch): + import pytest + from types import SimpleNamespace + from application import python_runtime_resolver as resolver + + calls = [] + monkeypatch.setattr(subprocess, "Popen", lambda argv, **kwargs: calls.append((argv, kwargs)) or SimpleNamespace(pid=123)) + monkeypatch.setattr(sys, "argv", [str(SRC / "main.py"), "--example"]) + with pytest.raises(SystemExit) as result: + resolver._relaunch_with(sys.executable) + assert result.value.code == 0 + argv, kwargs = calls[0] + assert argv == [sys.executable, str(SRC / "main.py"), "--example"] + assert kwargs["env"]["LATEXSNIPPER_RESTART"] == "1" diff --git a/test/test_runtime_logging.py b/test/test_runtime_logging.py index c1e74e0b..71c7fb2d 100644 --- a/test/test_runtime_logging.py +++ b/test/test_runtime_logging.py @@ -39,9 +39,13 @@ def test_persistent_log_excludes_debug_and_strips_prefixes(tmp_path: Path) -> No target = Path(sys.argv[1]) runtime_logging.app_log_dir = lambda: target runtime_logging.app_state_dir = lambda: target / "state" +runtime_logging.runtime_log_path().write_text("previous-session", encoding="utf-8") runtime_logging.init_app_logging() print("[DEBUG] debug-only") print("[INFO] ready") +logging.info("logging-record") +sys.stderr.write("stderr-record\\n") +assert "ready" in runtime_logging.runtime_log_path().read_text(encoding="utf-8") logging.shutdown() """ env = os.environ.copy() @@ -62,6 +66,11 @@ def test_persistent_log_excludes_debug_and_strips_prefixes(tmp_path: Path) -> No assert "debug-only" not in text assert "[INFO] [INFO] ready" not in text assert text.count("ready") == 1 + session = (tmp_path / "runtime-session.log").read_text(encoding="utf-8") + assert "previous-session" not in session + assert session.count("ready") == 1 + assert session.count("logging-record") == 1 + assert "stderr-record" in session def test_plain_log_rotation_is_bounded(tmp_path: Path) -> None: diff --git a/test/test_runtime_packaging.py b/test/test_runtime_packaging.py index 37d3a6ad..7578639b 100644 --- a/test/test_runtime_packaging.py +++ b/test/test_runtime_packaging.py @@ -1,9 +1,13 @@ # pyright: reportMissingImports=false +import ast +import os import re import unittest from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] @@ -21,3 +25,51 @@ def test_pyinstaller_spec_keeps_psutil_for_packaged_speed_meter(self): self.assertIn('"psutil"', hiddenimports.group(1)) self.assertNotIn('"psutil"', excludes.group(1)) self.assertNotIn('"psutil"', prune_prefixes.group(1)) + + +def run_seed_collection(): + """Execute the spec's seed-input boundary without invoking PyInstaller.""" + path = ROOT / "LaTeXSnipper.spec" + nodes = ast.parse(path.read_text(encoding="utf-8")).body + start = next( + i + for i, node in enumerate(nodes) + if isinstance(node, ast.Assign) + and any( + isinstance(t, ast.Name) and t.id == "bundled_python" for t in node.targets + ) + ) + end = next( + i + for i, node in enumerate(nodes[start:], start) + if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "a" for t in node.targets) + ) + namespace = { + "os": os, + "Path": Path, + "extra_datas": [], + "_collect_tree_as_datas": lambda source, destination: [(source, destination)], + } + exec( + compile(ast.Module(body=nodes[start:end], type_ignores=[]), str(path), "exec"), + namespace, + ) + return namespace["extra_datas"] + + +def test_windows_bundle_collects_explicit_seed_path(tmp_path, monkeypatch): + seed = tmp_path / "runner inputs" / "python seed" + seed.mkdir(parents=True) + (seed / "python.exe").touch() + monkeypatch.setenv("LATEXSNIPPER_BUNDLED_PYTHON", str(seed)) + assert run_seed_collection() == [(seed.resolve(), "deps/python311")] + + +def test_windows_bundle_requires_usable_seed(tmp_path, monkeypatch): + monkeypatch.delenv("LATEXSNIPPER_BUNDLED_PYTHON", raising=False) + with pytest.raises(RuntimeError, match="must identify"): + run_seed_collection() + monkeypatch.setenv("LATEXSNIPPER_BUNDLED_PYTHON", str(tmp_path)) + with pytest.raises(RuntimeError, match="missing python.exe"): + run_seed_collection() diff --git a/user_manual/user_manual.md b/user_manual/user_manual.md index 54fe1e4b..ee0bacdc 100644 --- a/user_manual/user_manual.md +++ b/user_manual/user_manual.md @@ -11,7 +11,7 @@ --- -LaTeXSnipper v3.0.0 封面图 +LaTeXSnipper 封面图 @@ -124,6 +124,8 @@ LaTeXSnipper 首次启动或检测到关键依赖缺失时会打开“依赖管 ## 主窗口功能入口与识别流程 +首次进入主窗口会显示六步快速入门,介绍截图、导出、快捷键、模型准备与支持入口。完成或确认退出引导后不再自动显示;直接关闭应用不修改偏好。随时可点击“快速入门”重新查看。模型在后台准备,引导无需等待模型就绪。 + ### 主窗口按钮分别做什么 当前主窗口左侧是截图和历史区,右侧是 LaTeX 编辑、预览和工具入口: @@ -506,7 +508,7 @@ Windows 安装包默认使用内置依赖根: <安装目录>\_internal\deps ``` -Linux/macOS 安装包不会把构建机的 `tools/deps/python311` 或任意虚拟环境打进安装包。默认依赖根是用户可写目录: +Linux/macOS 安装包不会把构建机的虚拟环境打进安装包。默认依赖根是用户可写目录: ```text Linux: ~/.latexsnipper/deps @@ -1007,65 +1009,34 @@ README 中提供了三种平台的源码运行步骤,经验证均正确可行 > - **macOS:** `python3 -m venv .venv` → `pip install -r requirements-macos.txt` → `python src/main.py` > - `requirements-linux.txt` 和 `requirements-macos.txt` 通过 `-r requirements.txt` 自动包含公共依赖;Linux 额外使用 `pynput` 支持全局快捷键,macOS 使用原生全局快捷键实现。 -### 开发环境路径不要混用 - -**现象:** 本地存在 `python311/` 和 `tools/deps/python311/` 两套 Python,不确定应该用哪一个。 +### 开发环境与发布构建 -**规则:** +开发者可以自行选择 venv、Conda、uv 等环境及存放位置。激活环境后,运行、检查和 IDE 使用同一解释器即可;项目不要求固定的环境目录。 -- `python311/` 是 Windows 安装包的内置 Python 模板,只用于打包复制,不要安装开发依赖,不要执行 ruff/pyright/pytest。 -- `tools/deps/python311/` 是 IDE、开发检查和 Actions 打包使用的项目环境,可以安装 `requirements*.txt`、`ruff`、`pytest`、`pyright`。 -- Linux/macOS 安装包运行时不会携带 `tools/deps/python311`。Linux 默认依赖根是 `~/.latexsnipper/deps`,macOS 默认依赖根是 `~/Library/Application Support/LaTeXSnipper/deps`;用户切换依赖目录后,以配置中的 `install_base_dir` 为准。 - -**Windows 开发者初始化示例:** +Windows 示例(`.venv` 仅为示例,可替换为自己的路径): ```powershell -py -3.11 -m venv tools\deps\python311 -.\tools\deps\python311\python.exe -m pip install --upgrade pip wheel setuptools -.\tools\deps\python311\python.exe -m pip install -r requirements.txt -.\tools\deps\python311\python.exe -m pip install -r requirements-build.txt -.\tools\deps\python311\python.exe -m pip install ruff pytest pyright +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.txt +python -m pip install ruff pytest pyright +python src/main.py ``` -> [!NOTE] -> **路径速查** -> -> - 仓库根目录 `python311/` — Windows 模板环境,不要污染。 -> - `tools/deps/python311/` — 开发、检查、构建环境。 -> - `~/.latexsnipper/deps` — Linux 默认运行时依赖根。 -> - `~/Library/Application Support/LaTeXSnipper/deps` — macOS 默认运行时依赖根。 -> - `<依赖根>/python` — 程序创建的主 Python 依赖环境。 -> - `<应用状态目录>/tools/pandoc` — 程序创建的共享工具目录,切换依赖根后继续复用。 +Linux/macOS 使用各自的 requirements 文件,并激活所选环境。客户端安装包统一由 GitHub Actions 的 `release.yml` 工作流构建;Windows 内置运行时在 runner 临时目录准备,不收集开发者的 Python 环境。 -### 开发者验证命令注意事项 +### 开发者验证命令 -常用验证命令: +在已激活并安装检查工具的环境中执行: ```bash -.\tools\deps\python311\python.exe -m ruff check . -.\tools\deps\python311\python.exe -m pytest test -.\tools\deps\python311\python.exe -m pyright -.\tools\deps\python311\python.exe -m compileall -q src mathcraft_ocr test +python -m ruff check . +python -m pytest test +python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" +python -m compileall -q src mathcraft_ocr test ``` -> [!WARNING] -> **运行前必做** -> -> `ruff`、`pytest`、`pyright` 不在任何 `requirements*.txt` 中。 -> 首次运行验证前需要手动安装: -> -> ```bash -> .\tools\deps\python311\python.exe -m pip install ruff pytest pyright -> ``` - -### `pyrightconfig.json` 中的路径 - -类型检查应使用 `tools/deps/python311` 的第三方包解析环境,根目录 `python311/` 只作为 Windows 模板排除在检查外。不要为了让 pyright 通过而向根目录模板安装开发包。 - -> [!TIP] -> **提示** -> -> 如果 `tools/deps/python311` 不存在,先创建开发环境;不要改用根目录 `python311` 作为替代。 +IDE 中选择相同的解释器;类型检查通过 `--pythonpath` 指定所选解释器,无需在项目配置中硬编码第三方包目录。 --- diff --git a/user_manual/user_manual.typ b/user_manual/user_manual.typ index 40f56ff4..a6fd311b 100644 --- a/user_manual/user_manual.typ +++ b/user_manual/user_manual.typ @@ -112,7 +112,7 @@ // ── 封面图 ── #align(center)[ - #image("../docs/latexsnipper-3.0.0.png", width: 100%) + #image("../docs/latexsnipper.png", width: 100%) #v(1em) ] @@ -240,6 +240,8 @@ LaTeXSnipper 首次启动或检测到关键依赖缺失时会打开“依赖管 // ═══════════════════════════════════════════ #heading(level: 1)[主窗口功能入口与识别流程] +首次进入主窗口会显示六步快速入门,介绍截图、导出、快捷键、模型准备与支持入口。完成或确认退出引导后不再自动显示;直接关闭应用不修改偏好。随时可点击“快速入门”重新查看。模型在后台准备,引导无需等待模型就绪。 + == 主窗口按钮分别做什么 当前主窗口左侧是截图和历史区,右侧是 LaTeX 编辑、预览和工具入口: @@ -674,7 +676,7 @@ LaTeXSnipper 使用 `install_base_dir` 作为活动 Python 依赖根。用户在 <安装目录>\_internal\deps ``` -Linux/macOS 安装包不会把构建机的 `tools/deps/python311` 或任意虚拟环境打进安装包。默认依赖根是用户可写目录: +Linux/macOS 安装包不会把构建机的虚拟环境打进安装包。默认依赖根是用户可写目录: ```text Linux: ~/.latexsnipper/deps @@ -1266,65 +1268,34 @@ README 中提供了三种平台的源码运行步骤,经验证均正确可行 - `requirements-linux.txt` 和 `requirements-macos.txt` 通过 `-r requirements.txt` 自动包含公共依赖;Linux 额外使用 `pynput` 支持全局快捷键,macOS 使用原生全局快捷键实现。 ]) -== 开发环境路径不要混用 - -*现象:* 本地存在 `python311/` 和 `tools/deps/python311/` 两套 Python,不确定应该用哪一个。 - -#v(0.35em) - -*规则:* -- `python311/` 是 Windows 安装包的内置 Python 模板,只用于打包复制,不要安装开发依赖,不要执行 ruff/pyright/pytest。 -- `tools/deps/python311/` 是 IDE、开发检查和 Actions 打包使用的项目环境,可以安装 `requirements*.txt`、`ruff`、`pytest`、`pyright`。 -- Linux/macOS 安装包运行时不会携带 `tools/deps/python311`。Linux 默认依赖根是 `~/.latexsnipper/deps`,macOS 默认依赖根是 `~/Library/Application Support/LaTeXSnipper/deps`;用户切换依赖目录后,以配置中的 `install_base_dir` 为准。 +== 开发环境与发布构建 -#v(0.35em) +开发者可以自行选择 venv、Conda、uv 等环境及存放位置。激活环境后,运行、检查和 IDE 使用同一解释器即可;项目不要求固定的环境目录。 -*Windows 开发者初始化示例:* +Windows 示例(`.venv` 仅为示例,可替换为自己的路径): -```text -py -3.11 -m venv tools\deps\python311 -.\tools\deps\python311\python.exe -m pip install --upgrade pip wheel setuptools -.\tools\deps\python311\python.exe -m pip install -r requirements.txt -.\tools\deps\python311\python.exe -m pip install -r requirements-build.txt -.\tools\deps\python311\python.exe -m pip install ruff pytest pyright +```powershell +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.txt +python -m pip install ruff pytest pyright +python src/main.py ``` -#info-block("路径速查", [ - - 仓库根目录 `python311/` — Windows 模板环境,不要污染。 - - `tools/deps/python311/` — 开发、检查、构建环境。 - - `~/.latexsnipper/deps` — Linux 默认运行时依赖根。 - - `~/Library/Application Support/LaTeXSnipper/deps` — macOS 默认运行时依赖根。 - - `<依赖根>/python` — 程序创建的主 Python 依赖环境。 - - `<应用状态目录>/tools/pandoc` — 程序创建的共享工具目录,切换依赖根后继续复用。 -]) +Linux/macOS 使用各自的 requirements 文件,并激活所选环境。客户端安装包统一由 GitHub Actions 的 `release.yml` 工作流构建;Windows 内置运行时在 runner 临时目录准备,不收集开发者的 Python 环境。 -== 开发者验证命令注意事项 +== 开发者验证命令 -常用验证命令: +在已激活并安装检查工具的环境中执行: -```text -.\tools\deps\python311\python.exe -m ruff check . -.\tools\deps\python311\python.exe -m pytest test -.\tools\deps\python311\python.exe -m pyright -.\tools\deps\python311\python.exe -m compileall -q src mathcraft_ocr test +```bash +python -m ruff check . +python -m pytest test +python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" +python -m compileall -q src mathcraft_ocr test ``` -#warn-block("运行前必做", [ - `ruff`、`pytest`、`pyright` 不在任何 `requirements*.txt` 中。 - 首次运行验证前需要手动安装: - #v(0.25em) - ```text - .\tools\deps\python311\python.exe -m pip install ruff pytest pyright - ``` -]) - -== `pyrightconfig.json` 中的路径 - -类型检查应使用 `tools/deps/python311` 的第三方包解析环境,根目录 `python311/` 只作为 Windows 模板排除在检查外。不要为了让 pyright 通过而向根目录模板安装开发包。 - -#tip-block("提示", [ - 如果 `tools/deps/python311` 不存在,先创建开发环境;不要改用根目录 `python311` 作为替代。 -]) +IDE 中选择相同的解释器;类型检查通过 `--pythonpath` 指定所选解释器,无需在项目配置中硬编码第三方包目录。 #pagebreak()