This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 9a969fc33c1f [improvement](docs) Replace scheduled PDF export with a 
local versioned CLI (#4558)
9a969fc33c1f is described below

commit 9a969fc33c1fa48ba09267331512f7620dd23084
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sat Oct 31 02:31:43 2026 +0000

    [improvement](docs) Replace scheduled PDF export with a local versioned CLI 
(#4558)
    
    Retire the scheduled PDF job that fails while launching Chromium and
    provide a local command for exporting a selected documentation version
    from the working tree. `Cron Generate Pdf` has already been disabled in
    GitHub Actions; this PR removes its scheduled workflow definition.
    
    ## Changes and review
    
    - **Goal:** Replace automatic Dev-only online crawling with an explicit
    local `yarn docs:pdf --version <version>` command. Support Dev/current,
    active releases, and historical source directories, with English and
    Chinese selection.
    - **Scope:** Retire the cron workflow, update its existing security-test
    coverage, and add the CLI, executable shell wrapper, renderer, verifier,
    setup guide, and shared documentation inventory. PDF binaries remain
    local artifacts.
    - **Information architecture:** Read the selected version's source root
    and sidebar. Preserve sidebar order, append other source pages, and use
    the native Docusaurus slug rules. Documentation content and
    active-version configuration are preserved.
    - **Paths and links:** Produce stable version/language filenames, a
    manifest, and SHA-256 checksums. Restore document/heading links across
    print batches and add contents/bookmarks.
    - **Runtime:** Use local Pandoc, Python/pypdf, curl, and an installed
    Chrome/Chromium with its normal sandbox. Expand tabs/details and
    navigation cards; retain animation URLs. Fail on unsupported components
    and missing images, with an explicit placeholder option for the latter.
    No site UI changes are included.
    - **Validation:** The command, source inventory, content conversion,
    workflow security checks, and real PDF samples were tested as listed
    below.
    - **Other findings:** No additional blocking issues found. Runtime
    dependencies and the working-tree snapshot behavior are documented. The
    separate manual GitHub PDF workflow remains available.
    
    ## Usage
    
    ```sh
    python3 -m venv tmp/pdfs/.venv
    tmp/pdfs/.venv/bin/python -m pip install -r 
scripts/docs-pdf/requirements.txt
    
    yarn docs:pdf --list-versions
    yarn docs:pdf --version 4.x
    ./scripts/docs-pdf/generate.sh 4.x zh-CN
    yarn docs:pdf --version dev --locale zh-CN
    yarn docs:pdf --version 2.1 --locale en --output ./output/pdf/2.1-english
    ```
    
    The default output directory is `output/pdf/docs/<version>/`. Chrome and
    the repository's Python virtual environment are detected automatically;
    `--chrome` and `--python` provide overrides.
    
    ## Validation details
    
    - 8 Node tests passed for selection/validation, current-version aliases,
    native slugs, sidebars, MDX cards/tabs/details, custom heading
    destinations, and real inventories for all six versions in both
    languages.
    - 18 existing workflow security/authorization tests passed after
    removing the retired cron job from their expected workflow list.
    - Generated four sample PDFs: 4.x English/Chinese, Dev Chinese, and 2.1
    English. All passed hash, page/document count, bookmark, internal-link,
    and extracted-text verification with zero missing images. Rendered
    representative pages were visually inspected.
    - Verified that `--resume` retains the other locale, checksum corruption
    is rejected, and a missing Chrome executable fails before output
    creation.
    - Shell wrapper passed Bash syntax and ShellCheck, positional/flag
    argument forwarding, spaces in arguments, invocation outside the
    repository, help, and child exit-code checks.
    - `git diff --check` passed.
    
    The local export tooling has been split out of archive-page PR #4555
    into this PR. Both PRs use the identical shared inventory helper.
    
    ## Versions
    
    - [x] dev
    - [x] 4.x
    - [x] 3.x
    - [x] 2.1 or older (not covered by version/language sync gate)
    
    ## Languages
    
    - [x] Chinese
    - [x] English
    
    ## Docs Checklist
    
    - [x] Checked by AI
    - [x] Test Cases Built
    - [x] Updated required version and language counterparts, or explained
    why not
    - [x] If only one language changed, confirmed whether source/translation
    counterparts need sync
    
    ---------
    
    Co-authored-by: morningman <[email protected]>
---
 .github/scripts/test_review.py          |   2 +-
 .github/workflows/cron-generate-pdf.yml |  98 ------------
 package.json                            |   1 +
 scripts/docs-pdf/README.md              | 163 +++++++++++++++++++
 scripts/docs-pdf/cli.cjs                |  55 +++++++
 scripts/docs-pdf/docs-pdf.test.cjs      | 109 +++++++++++++
 scripts/docs-pdf/generate.cjs           | 272 ++++++++++++++++++++++++++++++++
 scripts/docs-pdf/generate.sh            |  48 ++++++
 scripts/docs-pdf/merge.py               |  72 +++++++++
 scripts/docs-pdf/print.css              |  27 ++++
 scripts/docs-pdf/requirements.txt       |   1 +
 scripts/docs-pdf/verify.py              |  37 +++++
 12 files changed, 786 insertions(+), 99 deletions(-)

diff --git a/.github/scripts/test_review.py b/.github/scripts/test_review.py
index 8e0af1ba5834..45308ca22cd9 100644
--- a/.github/scripts/test_review.py
+++ b/.github/scripts/test_review.py
@@ -294,7 +294,7 @@ class WorkflowTests(unittest.TestCase):
                     if name == "opencode-review.yml":
                         self.assertEqual(step["with"]["fetch-depth"], 0)
         for name in ("cron-deploy-website.yml", "manual-deploy-website.yml",
-                     "cron-generate-pdf.yml", "manual-generate-pdf.yml"):
+                     "manual-generate-pdf.yml"):
             workflow = workflows[name]
             build = workflow["jobs"]["build"]
             self.assertNotIn("environment", build)
diff --git a/.github/workflows/cron-generate-pdf.yml 
b/.github/workflows/cron-generate-pdf.yml
deleted file mode 100644
index 835748931cf7..000000000000
--- a/.github/workflows/cron-generate-pdf.yml
+++ /dev/null
@@ -1,98 +0,0 @@
-name: Cron Generate Pdf
-
-on: 
-  schedule:
-    - cron: '0 12,18,1 * * *'
-
-
-permissions:
-  contents: read
-
-concurrency:
-  group: production-pdf
-  cancel-in-progress: false
-
-jobs:
-  source:
-    if: github.ref == 'refs/heads/master'
-    runs-on: ubuntu-latest
-    timeout-minutes: 5
-    outputs:
-      source_sha: ${{ steps.source.outputs.source_sha }}
-      branch: ${{ steps.source.outputs.branch }}
-    steps:
-      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
-        with:
-          ref: ${{ github.sha }}
-          persist-credentials: false
-      - name: Resolve a protected branch to an immutable commit
-        id: source
-        env:
-          GH_TOKEN: ${{ github.token }}
-          DEPLOY_BRANCH: master
-        run: python3 .github/scripts/review.py deploy-ref
-
-  build:
-    needs: source
-    runs-on: ubuntu-latest
-    timeout-minutes: 90
-    steps:
-    - name: Checkout
-      uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
-      with:
-          persist-credentials: false
-          ref: ${{ needs.source.outputs.source_sha }}
-
-    - name: Use Node.js
-      uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3
-      with:
-        node-version: 20
-
-    - name: Generate PDF
-      run: |
-        npx --yes [email protected] 
--initialDocURLs="https://doris.apache.org/docs/dev/getting-started/intro"; 
--paginationSelector=".pagination-nav__link--next" --contentSelector="article" 
--coverImage="https://cdn.selectdb.com/static/doris_logo_512_4903556647.png"; 
--coverTitle="Apache Doris Docs (English)" --outputPDFFilename="Apache Doris 
Docs (English).pdf" --tocOnlyH1=true
-        npx --yes [email protected] 
--initialDocURLs="https://doris.apache.org/zh-CN/docs/dev/getting-started/intro";
 --paginationSelector=".pagination-nav__link--next" --contentSelector="article" 
--coverImage="https://cdn.selectdb.com/static/doris_logo_512_4903556647.png"; 
--coverTitle="Apache Doris Docs (中文)" --outputPDFFilename="Apache Doris Docs 
(中文).pdf" --tocOnlyH1=true
-        mkdir -p ./build-pdf/assets/files/
-        cp -- ./*.pdf ./build-pdf/assets/files/
-        ls ./build-pdf/assets/files/
-    - name: Upload the site artifact for the isolated deployment job
-      uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 
v4
-      with:
-        name: site-build-${{ github.run_id }}-${{ github.run_attempt }}
-        path: build-pdf
-        include-hidden-files: true
-        compression-level: 0
-        if-no-files-found: error
-        retention-days: 1
-
-  deploy:
-    needs: [source, build]
-    runs-on: ubuntu-latest
-    timeout-minutes: 180
-    environment: Production
-    permissions:
-      contents: read
-    env:
-      DORIS_COMMIT: ${{ needs.source.outputs.source_sha }}
-    steps:
-      - name: Checkout trusted deployment code
-        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
-        with:
-          ref: ${{ github.sha }}
-          persist-credentials: false
-          submodules: recursive
-      - uses: 
actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
-        with:
-          name: site-build-${{ github.run_id }}-${{ github.run_attempt }}
-          path: build-pdf
-      - name: Upload files to OSS
-        uses: ./.github/actions/aliyun-oss-website-action
-        with:
-            accessKeyId: ${{ secrets.ALIYUN_ACCESS_KEY_ID }}
-            accessKeySecret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
-            bucket: ${{ secrets.ALIYUN_OSS_BUCKET }}
-            # Transfer-acceleration endpoint, same as the website deploys.
-            endpoint: oss-accelerate.aliyuncs.com
-            folder: build-pdf
-            onlyUpload: true
-            skipSetting: true
diff --git a/package.json b/package.json
index 7d6dc92a94e5..ccd3271d25e9 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
         "generatePDF": "npx vitpress-generate-pdf -buildDirPath='./build/'  
--firstDocPath='/docs/summary/basic-summary/' 
--paginationSelector='.pagination-nav__link--next' --contentSelector='article' 
--coverImage='https://cdn.selectdb.com/images/doris-logo-512.png' 
--coverTitle='Apache Doris 1.1 (English)' --outputPDFFilename='Apache Doris 1.1 
(English).pdf'",
         "write-translations": "docusaurus write-translations --locale zh-CN",
         "write-heading-ids": "docusaurus write-heading-ids",
+        "docs:pdf": "node scripts/docs-pdf/cli.cjs",
         "docs:manifest": "node scripts/docs-governance/manifest.js --output 
website-quality-governance/generated/docs-manifest.json",
         "docs:links": "node scripts/docs-governance/lint-links.js",
         "docs:links:changed": "node scripts/docs-governance/lint-links.js 
--changed",
diff --git a/scripts/docs-pdf/README.md b/scripts/docs-pdf/README.md
new file mode 100644
index 000000000000..a140536137f9
--- /dev/null
+++ b/scripts/docs-pdf/README.md
@@ -0,0 +1,163 @@
+# Export documentation to PDF locally
+
+Generate the documentation for a selected version from this checkout. The
+command reads local Markdown/MDX and version sidebars, so you can export Dev,
+active releases, or historical versions without building or deploying the site.
+Remote images are downloaded and embedded; uploading the finished PDF is a
+separate step. The working tree is exported, including uncommitted 
documentation
+edits; the recorded source commit identifies the checkout base.
+
+## Quick start
+
+Requirements: Node.js 20+, Python 3.10+, Pandoc, curl, and a recent Chrome or
+Chromium. Install a Chinese font such as Noto Sans CJK when exporting Chinese
+on Linux. macOS can use its system Chinese fonts.
+
+Run these setup and export commands from the repository root:
+
+```sh
+yarn install --frozen-lockfile
+python3 -m venv tmp/pdfs/.venv
+tmp/pdfs/.venv/bin/python -m pip install -r scripts/docs-pdf/requirements.txt
+
+# Check available versions, then export the Chinese 4.x documentation.
+./scripts/docs-pdf/generate.sh --list-versions
+./scripts/docs-pdf/generate.sh 4.x zh-CN
+```
+
+The PDF is written to `output/pdf/docs/4.x/apache-doris-4.x-zh-CN.pdf`.
+Omit `zh-CN` to export both English and Chinese.
+
+The command detects this virtual environment and common Chrome/Chromium paths.
+Use `--python` and `--chrome` to select other executables. Chrome's normal
+sandbox remains enabled; a Linux sandbox error needs a working local browser
+installation and OS sandbox configuration.
+
+## Export a version
+
+The shell wrapper locates the repository root and accepts the version and
+optional language as positional arguments:
+
+```sh
+./scripts/docs-pdf/generate.sh 4.x
+./scripts/docs-pdf/generate.sh dev zh-CN
+./scripts/docs-pdf/generate.sh 2.1 en --output ./output/pdf/2.1-english
+./scripts/docs-pdf/generate.sh --list-versions
+```
+
+It also accepts the original `--version` and `--locale` flags and forwards
+additional options unchanged. You can invoke it by its path from another
+directory; relative option paths are resolved from the repository root.
+
+The Yarn command is also available directly:
+
+```sh
+# Discover the versions present in this checkout.
+yarn docs:pdf --list-versions
+
+# Export both English and Chinese for 4.x.
+yarn docs:pdf --version 4.x
+
+# Export only Chinese for Dev (current is an alias for dev).
+yarn docs:pdf --version dev --locale zh-CN
+
+# Export a historical version to a chosen directory.
+yarn docs:pdf --version 2.1 --locale en --output ./output/pdf/2.1-english
+
+# Explicit browser/interpreter selection, if needed.
+yarn docs:pdf --version 3.x --locale zh-CN \
+  --chrome '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \
+  --python tmp/pdfs/.venv/bin/python
+```
+
+## Parameters
+
+`generate.sh <version> [locale] [options]` maps the first two positional
+arguments to `--version` and `--locale`. The remaining options are forwarded
+to the CLI.
+
+| Parameter | Default | Meaning |
+| --- | --- | --- |
+| `--version <version>` | Required for an export | `dev` (`current` is an 
alias), or a version directory present in `versioned_docs/`. Use 
`--list-versions` for the names available in this checkout. |
+| `--locale <locales>` | `en,zh-CN` | Export `en`, `zh-CN`, or both as 
`en,zh-CN`. The selected source directories must exist. |
+| `--output <directory>` | `output/pdf/docs/<version>/` | Destination for the 
selected PDFs and their manifest/checksums. With the shell wrapper, relative 
paths start at the repository root. |
+| `--chrome <executable>` | Auto-detected | Chrome/Chromium executable. Paths 
containing spaces must be quoted. |
+| `--python <executable>` | Repository virtual environment, then `python3` | 
Python interpreter with the dependencies from `requirements.txt` installed. |
+| `--limit <count>` | All source pages | Export the first `<count>` source 
documents as a sample. The count must be a positive integer and requires an 
explicit `--output`; it does not specify the number of printed PDF pages. |
+| `--resume` | Off | Keep manifest entries for other locales while 
regenerating the selected locale. An existing manifest must have the same 
source commit. |
+| `--allow-missing-images` | Off | Replace unreachable images with links to 
their original URLs and record warnings in the manifest. |
+| `--list-versions` | Off | Print the versions available locally and exit 
without exporting. |
+| `--help` | Off | Print usage and exit. The shell wrapper also accepts `-h`. |
+
+`--chrome` takes precedence over `DOCS_PDF_CHROME` and
+`PUPPETEER_EXECUTABLE_PATH`. `--python` takes precedence over
+`DOCS_PDF_PYTHON`. Without an explicit interpreter, the command checks
+`tmp/pdfs/.venv/bin/python` (`Scripts/python.exe` on Windows), then uses
+`python3`.
+
+## Output
+
+By default, output goes to `output/pdf/docs/<version>/`:
+
+- `apache-doris-<version>-en.pdf` and/or `apache-doris-<version>-zh-CN.pdf`
+- `manifest.json`: source commit, selected source files, document/page counts,
+  sample status, image warnings, file sizes, and SHA-256 hashes
+- `SHA256SUMS`: checksums for the PDFs listed in the manifest
+
+The PDF has a cover, clickable contents, document bookmarks, internal links,
+and page numbers. Source pages follow sidebar order, with pages outside the
+sidebar appended. All Tab content and collapsed details are expanded.
+Navigation cards retain their titles, descriptions, and links; animations are
+represented by links to the online resource. Unsupported MDX components fail
+with their source filename instead of silently dropping content.
+
+The selected locale source directory must exist. Supported languages are
+`en` and `zh-CN`. Unknown versions, locales, and options fail before creating
+output. The tool requires no OSS credentials and performs no upload.
+
+## Samples, retries, and verification
+
+```sh
+# A small sample: the explicit output directory prevents replacing a full PDF.
+./scripts/docs-pdf/generate.sh 4.x zh-CN --limit 3 \
+  --output ./tmp/pdfs/4.x-sample
+
+# Preserve other locales in the manifest when regenerating a selection.
+./scripts/docs-pdf/generate.sh 4.x en --resume
+
+# Check hashes, counts, bookmarks, internal links, and sample extracted text.
+tmp/pdfs/.venv/bin/python scripts/docs-pdf/verify.py output/pdf/docs/4.x
+
+# Run the command/inventory/content conversion tests.
+node --test scripts/docs-pdf/docs-pdf.test.cjs
+```
+
+`--limit` produces a partial sample and records `sample: true` in the manifest.
+`--resume` requires the same source commit as the existing manifest. Temporary
+render batches and cached images live under `tmp/pdfs/docs/<version>/`.
+
+Missing images fail the export by default. Retry after fixing the network or
+source image. `--allow-missing-images` explicitly permits linked placeholders
+and records them in the manifest; review those warnings before distributing
+the result. The verifier also requires `--allow-missing-images` to accept them.
+PDF files are replaced only after their render batches merge successfully.
+
+## Troubleshooting
+
+| Error or symptom | Action |
+| --- | --- |
+| Yarn is missing, or a Node module cannot be found | Make Yarn available in 
`PATH` and run `yarn install --frozen-lockfile` from the repository root. |
+| `Unknown documentation version` | Run `./scripts/docs-pdf/generate.sh 
--list-versions`. Select a documentation directory name such as `4.x`, rather 
than a Doris patch release number such as `4.1.0`. |
+| Pandoc or curl is missing | Install the named tool and make it available in 
`PATH`. |
+| Python cannot import `pypdf` | Repeat the virtual-environment setup above, 
or pass its interpreter with `--python`. |
+| Chrome is missing or cannot render MathML | Install a recent Chrome/Chromium 
and pass the full executable path with `--chrome`. |
+| `No usable sandbox!` on Linux | Check the local Chrome installation and OS 
sandbox configuration. The exporter keeps Chrome's normal sandbox enabled. |
+| A remote image cannot be retrieved | Check the image URL and network 
connection, then retry. Use `--allow-missing-images` only when a PDF with 
linked placeholders is acceptable. |
+| `Cannot resume PDFs from a different source commit` | Choose a new output 
directory, or regenerate the desired languages without `--resume`. |
+
+Check the command's exit status and run the verifier before uploading a PDF.
+When reusing an output directory, a failed export can leave the previous
+successful PDF in place. Review `manifest.json` to identify the files and
+source commit for the completed export.
+
+The retired scheduled workflow was `.github/workflows/cron-generate-pdf.yml`.
diff --git a/scripts/docs-pdf/cli.cjs b/scripts/docs-pdf/cli.cjs
new file mode 100644
index 000000000000..46199caaf9ab
--- /dev/null
+++ b/scripts/docs-pdf/cli.cjs
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+const fs = require('node:fs');
+const path = require('node:path');
+const {parseArgs} = require('node:util');
+const {availableVersions, normalizeVersion, contentDirectory} = 
require('../docs-inventory.cjs');
+const root = path.resolve(__dirname, '../..');
+const help = `Export a local Apache Doris documentation version to PDF.
+
+Usage: yarn docs:pdf --version <version> [options]
+
+  --version <version>       Required: dev (alias: current), or a 
versioned_docs version
+  --locale <locales>        en, zh-CN, or en,zh-CN (default: en,zh-CN)
+  --output <directory>      Default: output/pdf/docs/<version>/
+  --chrome <executable>     Installed Chrome/Chromium; auto-detected when 
omitted
+  --python <executable>     Python with pypdf (default: repo venv, then 
python3)
+  --limit <count>           Sample export; requires an explicit --output
+  --allow-missing-images   Use linked placeholders for unreachable images
+  --resume                 Retain PDFs for other locales from the same commit
+  --list-versions           List versions available in this checkout
+  --help                   Show this help
+
+Outputs: PDF files, manifest.json, and SHA256SUMS. Upload is a separate manual 
step.
+`;
+function parseOptions(args, siteDir = root) {
+    const {values} = parseArgs({args, options: {
+        version: {type: 'string'}, locale: {type: 'string', default: 
'en,zh-CN'}, output: {type: 'string'},
+        chrome: {type: 'string'}, python: {type: 'string'},
+        'allow-missing-images': {type: 'boolean'}, limit: {type: 'string'}, 
resume: {type: 'boolean'}, 'list-versions': {type: 'boolean'}, help: {type: 
'boolean'},
+    }});
+    if (values.help || values['list-versions']) return values;
+    if (!values.version?.trim()) throw new Error('Specify --version. Use 
--list-versions to see available versions.');
+    const version = normalizeVersion(values.version.trim());
+    if (!availableVersions(siteDir).includes(version)) throw new 
Error(`Unknown documentation version: ${values.version}. Use --list-versions.`);
+    const locales = values.locale.split(',').map(locale => locale.trim());
+    if (new Set(locales).size !== locales.length || locales.some(locale => 
!['en', 'zh-CN'].includes(locale)))
+        throw new Error('--locale must be en, zh-CN, or en,zh-CN.');
+    for (const locale of locales) if (!fs.existsSync(contentDirectory(siteDir, 
version, locale)))
+        throw new Error(`Missing ${locale} source for ${version}.`);
+    const limit = values.limit === undefined ? 0 : Number(values.limit);
+    if (values.limit !== undefined && (!Number.isSafeInteger(limit) || limit < 
1)) throw new Error('--limit must be a positive integer.');
+    if (limit && !values.output) throw new Error('Sample exports require an 
explicit --output directory.');
+    const venvPython = path.join(siteDir, 'tmp/pdfs/.venv', process.platform 
=== 'win32' ? 'Scripts/python.exe' : 'bin/python');
+    const python = values.python || process.env.DOCS_PDF_PYTHON || 
(fs.existsSync(venvPython) ? venvPython : 'python3');
+    return {version, locales, limit, allowMissingImages: 
values['allow-missing-images'] ?? false, resume: values.resume ?? false, python,
+        chrome: values.chrome || process.env.DOCS_PDF_CHROME || 
process.env.PUPPETEER_EXECUTABLE_PATH,
+        output: values.output ? path.resolve(values.output) : 
path.join(siteDir, 'output/pdf/docs', version)};
+}
+async function main() {
+    const options = parseOptions(process.argv.slice(2));
+    if (options.help) return process.stdout.write(help);
+    if (options['list-versions']) return 
process.stdout.write(availableVersions(root).join('\n') + '\n');
+    await require('./generate.cjs').generate(options);
+}
+if (require.main === module) main().catch(error => {console.error(`PDF export 
failed: ${error.message}`); process.exitCode = 1;});
+module.exports = {parseOptions};
diff --git a/scripts/docs-pdf/docs-pdf.test.cjs 
b/scripts/docs-pdf/docs-pdf.test.cjs
new file mode 100644
index 000000000000..b93b9fcde21e
--- /dev/null
+++ b/scripts/docs-pdf/docs-pdf.test.cjs
@@ -0,0 +1,109 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const {execFileSync, spawnSync} = require('node:child_process');
+const test = require('node:test');
+const {parseOptions} = require('./cli.cjs');
+const {normalize, addHeadingTargets} = require('./generate.cjs');
+const cheerio = require('cheerio');
+const {availableVersions, inventory, filename} = 
require('../docs-inventory.cjs');
+const root = path.resolve(__dirname, '../..');
+function fixture(t) {
+    const directory = fs.mkdtempSync(path.join(os.tmpdir(), 
'doris-pdf-test-'));
+    t.after(() => fs.rmSync(directory, {recursive: true, force: true}));
+    const write = (file, text) => {const full = path.join(directory, file); 
fs.mkdirSync(path.dirname(full), {recursive: true}); fs.writeFileSync(full, 
text);};
+    for (const locale of ['en', 'zh-CN']) {
+        const current = locale === 'en' ? 'docs' : 
'i18n/zh-CN/docusaurus-plugin-content-docs/current';
+        const versioned = locale === 'en' ? 'versioned_docs/version-4.x' : 
'i18n/zh-CN/docusaurus-plugin-content-docs/version-4.x';
+        for (const base of [current, versioned]) {
+            write(`${base}/intro.md`, '---\ntitle: Intro\n---\nWelcome');
+            write(`${base}/01-topic.md`, '---\ntitle: Topic\nslug: 
/custom-topic\n---\nTopic');
+            write(`${base}/99-extra.md`, '# Extra\nOutside the sidebar');
+        }
+    }
+    write('sidebars.ts', "const sidebars = {docs: [{type: 'category', label: 
'Start', link: {type: 'doc', id: 'intro'}, items: ['topic']}]}; export default 
sidebars;");
+    write('versioned_sidebars/version-4.x-sidebars.json', 
JSON.stringify({docs: ['intro', 'topic']}));
+    return {directory, write};
+}
+
+test('version discovery and current alias use the actual local source roots', 
t => {
+    const {directory} = fixture(t);
+    assert.deepEqual(availableVersions(directory), ['dev', '4.x']);
+    for (const version of ['dev', 'current', '4.x']) for (const locale of 
['en', 'zh-CN']) {
+        const docs = inventory(directory, version, locale);
+        assert.deepEqual(docs.map(doc => doc.id), ['intro', 'topic', 'extra']);
+        assert.equal(docs[1].route, `${locale === 'en' ? '' : 
'/zh-CN'}/docs/${version === 'current' ? 'dev' : version}/custom-topic`);
+    }
+    assert.equal(filename('current', 'zh-CN'), 'apache-doris-dev-zh-CN.pdf');
+});
+
+test('explicit CLI selection, locale subsets, output paths, and samples are 
retained', t => {
+    const {directory} = fixture(t);
+    const options = parseOptions(['--version', 'current', '--locale', 'zh-CN', 
'--output', 'folder with spaces', '--limit', '2', '--resume'], directory);
+    assert.equal(options.version, 'dev');
+    assert.deepEqual(options.locales, ['zh-CN']);
+    assert.equal(options.limit, 2);
+    assert.equal(options.resume, true);
+    assert.equal(options.output, path.resolve('folder with spaces'));
+    assert.deepEqual(parseOptions(['--version', '4.x'], directory).locales, 
['en', 'zh-CN']);
+});
+
+test('invalid selections fail without creating files', t => {
+    const {directory} = fixture(t);
+    for (const args of [[], ['--version', '9.x'], ['--version', '../4.x'], 
['--version', '4.x', '--locale', 'ja'],
+        ['--version', '4.x', '--locale', 'en,en'], ['--version', '4.x', 
'--limit', '2'],
+        ['--version', '4.x', '--limit', '-1', '--output', 'sample'], 
['--version', '4.x', '--typo']]) assert.throws(() => parseOptions(args, 
directory));
+    fs.rmSync(path.join(directory, 
'i18n/zh-CN/docusaurus-plugin-content-docs/version-4.x'), {recursive: true});
+    assert.throws(() => parseOptions(['--version', '4.x'], directory), 
/Missing zh-CN source/);
+    assert(!fs.existsSync(path.join(directory, 'output')));
+    assert(!fs.existsSync(path.join(directory, 'tmp')));
+});
+
+test('CLI help and version listing do not load the renderer or create 
outputs', () => {
+    const cli = path.join(__dirname, 'cli.cjs');
+    const help = execFileSync(process.execPath, [cli, '--help'], {encoding: 
'utf8'});
+    assert(help.includes('--version'));
+    const versions = execFileSync(process.execPath, [cli, '--list-versions'], 
{encoding: 'utf8'}).trim().split('\n');
+    assert(versions.includes('dev') && versions.includes('4.x') && 
versions.includes('2.1'));
+    const invalid = spawnSync(process.execPath, [cli, '--version', 'missing'], 
{encoding: 'utf8'});
+    assert.equal(invalid.status, 1);
+    assert(invalid.stderr.includes('Unknown documentation version'));
+});
+
+test('printing retains every tab/detail body and preserves fenced and inline 
examples', () => {
+    const code = '```sql\nSELECT "<Tabs>";\n```\n\n```sh\nexport 
JAVA_TOOL_OPTIONS="x"\n```';
+    const result = normalize('import Tabs from 
"@theme/Tabs";\n<Tabs>\n<TabItem label="S3">\nAWS\n</TabItem>\n<TabItem 
label="OSS">\nAliyun\n</TabItem>\n</Tabs>\n<details><summary>More</summary>\nHidden\n</details>\n'
 + code);
+    assert(result.includes('### S3') && result.includes('### OSS') && 
result.includes('Hidden'));
+    assert(result.includes(code));
+    assert(!result.includes('import Tabs') && !result.includes('<TabItem'));
+    assert.equal(normalize('Type: <INT>; literal: `<INT>`'), 'Type: 
&lt;INT&gt;; literal: `<INT>`');
+});
+
+test('current-version cards preserve titles/descriptions/links; animations 
remain usable links', () => {
+    const result = normalize(`import GettingStartedCard from 
'@site/src/components/card';\n<GettingStartedCard title="What's Doris" 
description="Core features" link="quick-start" />\n<iframe 
src="/animations/intro.html" title="Doris animation"></iframe>`);
+    assert(result.includes('What&#39;s Doris') && result.includes('Core 
features') && result.includes('(quick-start)'));
+    assert(result.includes('[Doris animation](/animations/intro.html)'));
+    assert(!result.includes('<GettingStartedCard') && 
!result.includes('<iframe'));
+    assert.throws(() => normalize('import Widget from "./widget";\n<Widget 
/>'), /Unsupported MDX component Widget/);
+});
+
+test('real current/active/historical inventories keep sidebar entries and 
outside-sidebar sources', () => {
+    for (const version of availableVersions(root)) for (const locale of ['en', 
'zh-CN']) {
+        const docs = inventory(root, version, locale);
+        assert(docs.length > 700);
+        assert.equal(docs.length, new Set(docs.map(doc => doc.source)).size);
+        assert(docs[0].id.includes('intro') || 
docs[0].id.includes('what-is-apache-doris'));
+    }
+});
+
+test('custom heading IDs remain destinations and card links are not nested 
inside marker links', () => {
+    const $ = cheerio.load('<article><h1>Title</h1><h2 id="stable-anchor">New 
heading</h2><h3 id="card"><a href="quick-start">Quick 
Start</a></h3></article>');
+    const aliases = {};
+    addHeadingTargets($, $('article'), 'doc-1', aliases);
+    assert.equal(aliases['doc-1#stable-anchor'], 'doc-1#new-heading');
+    assert.equal(aliases['doc-1#card'], 'doc-1#quick-start');
+    assert.equal($('a[href="quick-start"]').length, 1);
+    assert.equal($('a a').length, 0);
+    assert.equal($('a[href^="https://archive.invalid/start/";]').length, 3);
+});
diff --git a/scripts/docs-pdf/generate.cjs b/scripts/docs-pdf/generate.cjs
new file mode 100644
index 000000000000..32397ec12788
--- /dev/null
+++ b/scripts/docs-pdf/generate.cjs
@@ -0,0 +1,272 @@
+#!/usr/bin/env node
+// Local-only export: never uploads files or requires a production build.
+const fs = require('node:fs');
+const path = require('node:path');
+const {execFile, execFileSync} = require('node:child_process');
+const {createHash} = require('node:crypto');
+const {pathToFileURL} = require('node:url');
+const cheerio = require('cheerio');
+const GithubSlugger = require('github-slugger');
+const puppeteer = require('puppeteer');
+const {inventory, filename} = require('../docs-inventory.cjs');
+const root = path.resolve(__dirname, '../..');
+const escape = text => String(text).replace(/[&<>"']/g, c => ({'&': '&amp;', 
'<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
+const ts = require('typescript');
+
+function staticProps(tag) {
+    const source = ts.createSourceFile('component.tsx', `const element = 
(${tag});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+    let opening;
+    function visit(node) {
+        if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) 
opening = node;
+        if (!opening) ts.forEachChild(node, visit);
+    }
+    visit(source);
+    const props = {};
+    for (const attr of opening?.attributes.properties ?? []) {
+        if (!ts.isJsxAttribute(attr) || !attr.initializer) continue;
+        const value = ts.isJsxExpression(attr.initializer) ? 
attr.initializer.expression : attr.initializer;
+        if (value && (ts.isStringLiteral(value) || 
ts.isNoSubstitutionTemplateLiteral(value))) props[attr.name.getText(source)] = 
value.text;
+    }
+    return props;
+}
+function addHeadingTargets($, article, docKey, aliases) {
+    const slugger = new GithubSlugger();
+    article.find('h1,h2,h3,h4,h5,h6').each((index, heading) => {
+        const original = $(heading).attr('id');
+        const headingKey = docKey + (index ? '#' + 
slugger.slug($(heading).text()) : '');
+        if (index && original && `${docKey}#${original}` !== headingKey) 
aliases[`${docKey}#${original}`] = headingKey;
+        const inner = $(heading).html();
+        $(heading).attr('id', headingKey);
+        if ($(heading).find('a[href]').length) $(heading).append(` <a 
class="section-link" 
href="https://archive.invalid/start/${escape(headingKey)}">§</a>`);
+        else $(heading).html(`<a 
href="https://archive.invalid/start/${escape(headingKey)}">${inner}</a>`);
+    });
+}
+function findChrome(explicit) {
+    const candidates = explicit ? [explicit] : [
+        ...(process.platform === 'darwin' ? ['/Applications/Google 
Chrome.app/Contents/MacOS/Google Chrome',
+            '/Applications/Chromium.app/Contents/MacOS/Chromium'] : []),
+        ...(process.env.PATH ?? '').split(path.delimiter).flatMap(dir => 
['google-chrome-stable', 'google-chrome', 'chromium',
+            'chromium-browser', 'chrome.exe'].map(name => path.join(dir, 
name))),
+        puppeteer.executablePath(),
+    ];
+    const executable = candidates.find(file => {try {fs.accessSync(file, 
fs.constants.X_OK); return fs.statSync(file).isFile();} catch {return false;}});
+    if (!executable) throw new Error('No Chrome/Chromium executable found. 
Install Chrome and pass --chrome <executable>.');
+    return executable;
+}
+function checkCommand(executable, args, message) {
+    try {execFileSync(executable, args, {stdio: 'pipe'});} catch {throw new 
Error(message);}
+}
+
+function checkComponents(content) {
+    for (const match of 
content.matchAll(/^import\s+(\w+)\s+from\s+['"][^'"]+['"]/gm)) {
+        if (!['Tabs', 'TabItem', 'GettingStartedCard'].includes(match[1]) && 
new RegExp(`<${match[1]}\\b`).test(content))
+            throw new Error(`Unsupported MDX component ${match[1]}; provide a 
static printable representation before exporting.`);
+    }
+    return content;
+}
+function normalize(content) {
+    // Transform presentation components outside code fences only. Every tab
+    // and collapsed details body is included in the printed document.
+    return content.split(/(^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\2[ 
\t]*$)/gm)
+        .filter(part => part && !/^(`{3,}|~{3,})$/.test(part))
+        .map(part => /^[ \t]*(`{3,}|~{3,})/.test(part) ? part : 
checkComponents(part)
+            .replace(/<GettingStartedCard\b[\s\S]*?\/>/g, tag => {
+                const props = staticProps(tag);
+                if (!props.title || !props.link) throw new 
Error('GettingStartedCard requires static title and link properties.');
+                return `\n\n### 
[${escape(props.title)}](${props.link})\n\n${props.description ?? ''}\n\n`;
+            })
+            .replace(/<iframe\b[\s\S]*?<\/iframe>/g, tag => {
+                const props = staticProps(tag);
+                if (!props.src) throw new Error('Iframe requires a static src 
property.');
+                return `\n\n[${escape(props.title || 'Interactive 
content')}](${props.src})\n\n`;
+            })
+            .replace(/^import\s+\w+\s+from\s+['"][^'"]+['"];?\s*$/gm, '')
+            .replace(/<Tabs\b[^>]*>|<\/Tabs>|<\/TabItem>/g, '\n\n')
+            .replace(/<TabItem\b[\s\S]*?>/g, tag => '\n\n### ' + 
(tag.match(/label\s*=\s*["']([^"']+)/)?.[1] ?? 'Example') + '\n\n')
+            .replace(/^:::(\w+)\s*(.*?)\s*$/gm, (_, kind, title) => `\n<div 
class="admonition">\n\n**${title || kind.toUpperCase()}**\n`)
+            .replace(/^:::\s*$/gm, '\n</div>\n')
+            .replace(/<details\b[^>]*>/g, '<div class="details">')
+            .replace(/<\/details>/g, '</div>')
+            .replace(/<summary>([\s\S]*?)<\/summary>/g, '\n\n**$1**\n\n')
+            .replace(/\sstyle=\{\{[\s\S]*?\}\}/g, '')
+            .replace(/^ 
+(?=<\/?(?:div|p|img|table|thead|tbody|tr|th|td|ul|ol|li)\b)/gm, '')
+            .replace(/(`+)[^\n]*?\1|<\/?[A-Z][A-Za-z0-9_]*\s*\/?>/g,
+                match => match.startsWith('`') ? match : escape(match))
+            .replace(/^(#{1,5})\s/gm, '#$1 ')).join('');
+}
+
+function markdownToHtml(input) {
+    return new Promise((resolve, reject) => {
+        const child = execFile('pandoc', 
['--from=markdown+markdown_in_html_blocks+pipe_tables+raw_html', '--to=html5', 
'--mathml', '--wrap=none'],
+            {encoding: 'utf8', maxBuffer: 64 * 1024 * 1024}, (error, stdout, 
stderr) => error ? reject(new Error(stderr || error.message)) : 
resolve(stdout));
+        child.stdin.end(input);
+    });
+}
+async function imageSource(src, doc, temporary, imageCache) {
+    const clean = decodeURI(src.split(/[?#]/)[0]);
+    let local;
+    if (/^https?:/.test(src)) {
+        const url = new URL(src);
+        local = path.join(root, 'static', url.pathname);
+    } else local = clean.startsWith('/') ? path.join(root, 'static', clean) : 
path.resolve(path.dirname(doc.file), clean);
+    if (fs.existsSync(local) && fs.statSync(local).isFile()) return 
pathToFileURL(local).href;
+    // Older Markdown sometimes addresses shared assets through a localized
+    // route or several ../ segments. They still live under static/images.
+    if ((!/^https?:/.test(src) || new URL(src).hostname === 
'doris.apache.org') && clean.includes('/images/')) {
+        const shared = path.join(root, 'static', 
clean.slice(clean.indexOf('/images/')));
+        if (fs.existsSync(shared) && fs.statSync(shared).isFile()) return 
pathToFileURL(shared).href;
+    }
+    const url = /^https?:/.test(src) ? src : new URL(src, 
`https://doris.apache.org${doc.route}/`).href;
+    if (!imageCache.has(url)) imageCache.set(url, (async () => {
+        const target = path.join(temporary, 'images', 
createHash('sha256').update(url).digest('hex') + (path.extname(clean) || 
'.png'));
+        if (fs.existsSync(target)) return pathToFileURL(target).href;
+        try {
+            fs.mkdirSync(path.dirname(target), {recursive: true});
+            const partial = target + '.download';
+            try {
+                const type = await new Promise((resolve, reject) => 
execFile('curl', ['--fail', '--location', '--silent', '--show-error',
+                    '--connect-timeout', '15', '--max-time', '120', 
'--output', partial, '--write-out', '%{content_type}', url],
+                    {encoding: 'utf8', maxBuffer: 1024 * 1024}, (error, 
stdout, stderr) => error ? reject(new Error(stderr || error.message)) : 
resolve(stdout)));
+                if (!type.startsWith('image/')) throw new Error(`Expected an 
image, received ${type}`);
+                fs.renameSync(partial, target);
+            } finally {fs.rmSync(partial, {force: true});}
+            return pathToFileURL(target).href;
+        } catch (error) {return {url, error: error.message};}
+    })());
+    return imageCache.get(url);
+}
+
+async function generate({version, locales, output, python = 'python3', chrome, 
limit = 0, resume = false, allowMissingImages = false}) {
+    const versions = [version];
+    const temporary = path.join(root, 'tmp/pdfs/docs', version);
+    const commit = execFileSync('git', ['rev-parse', 'HEAD'], {cwd: root, 
encoding: 'utf8'}).trim();
+    const date = new Date().toISOString().slice(0, 10);
+    const css = fs.readFileSync(path.join(__dirname, 'print.css'), 'utf8');
+    const imageCache = new Map();
+    checkCommand('pandoc', ['--version'], 'Pandoc is required. Install it 
before exporting.');
+    checkCommand('curl', ['--version'], 'curl is required to cache remote 
documentation images.');
+    checkCommand(python, ['-c', 'import pypdf'], 'Python with pypdf is 
required. Install pypdf and pass --python <executable>.');
+    const executablePath = findChrome(chrome);
+    // Validate the entire selection before creating output or starting Chrome.
+    const selections = new Map(locales.map(locale => [locale, inventory(root, 
version, locale)]));
+    if ([...selections.values()].some(docs => !docs.length)) throw new 
Error('No documentation pages found for this selection.');
+    const manifestPath = path.join(output, 'manifest.json');
+    const manifest = resume && fs.existsSync(manifestPath)
+        ? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
+        : {generatedAt: new Date().toISOString(), sourceCommit: commit, files: 
[]};
+    if (manifest.sourceCommit !== commit) throw new Error('Cannot resume PDFs 
from a different source commit.');
+    fs.mkdirSync(output, {recursive: true});
+    fs.mkdirSync(temporary, {recursive: true});
+    const browser = await puppeteer.launch({headless: true, executablePath,
+        args: ['--allow-file-access-from-files', '--disable-dev-shm-usage']});
+    try {
+        for (const version of versions) for (const locale of locales) {
+            let docs = selections.get(locale);
+            if (limit) docs = docs.slice(0, limit);
+            docs.forEach((doc, index) => {doc.key = `doc-${index + 1}`;});
+            const bySource = new Map(docs.map(doc => 
[doc.source.replace(/\.mdx?$/, ''), doc]));
+            const byRoute = new Map(docs.map(doc => [doc.route.replace(/\/$/, 
''), doc]));
+            const cn = locale === 'zh-CN';
+            const title = `Apache Doris ${version === 'dev' ? 'Dev' : version} 
${cn ? '中文手册' : 'Documentation (English)'}`;
+            const contentsTitle = cn ? '目录' : 'Contents';
+            const directory = path.join(temporary, 
`${version}-${locale}${limit ? '-sample' : ''}`);
+            fs.mkdirSync(directory, {recursive: true});
+            const batches = [], warnings = [], headingAliases = {};
+            const cover = `<div class="cover"><img 
src="${pathToFileURL(path.join(root, 
'static/images/logo-doris.svg')).href}"><h1>${escape(title)}</h1><p>${cn ? 
'归档版本文档' : 'Archived documentation'}</p><p>${date} · ${docs.length} ${cn ? 
'篇文档' : 'documents'}</p><p>Source: ${commit.slice(0, 12)}</p></div>`;
+            const toc = `<nav 
class="toc"><h1>${contentsTitle}</h1><ol>${docs.map(doc => `<li><a 
href="https://archive.invalid/${doc.key}";>${escape(doc.title)}</a></li>`).join('')}</ol></nav>`;
+            const page = await browser.newPage();
+            try {
+                if (!await page.evaluate(() => typeof MathMLElement !== 
'undefined'))
+                    throw new Error('This browser cannot render MathML. Use an 
installed modern Chrome/Chromium with --chrome.');
+                for (let start = 0; start < docs.length; start += 40) {
+                    const selected = docs.slice(start, start + 40);
+                    const fragments = new Array(selected.length);
+                    let next = 0;
+                    await Promise.all(Array.from({length: Math.min(4, 
selected.length)}, async () => {
+                        while (next < selected.length) {
+                            const index = next++, doc = selected[index];
+                            let normalized;
+                            try {normalized = normalize(doc.content);} catch 
(error) {throw new Error(`${doc.source}: ${error.message}`);}
+                            const converted = await markdownToHtml(`# 
${escape(doc.title)} {.doc-title}\n\n${normalized}`);
+                            // Repair legacy raw HTML in isolation so an 
unclosed
+                            // tag cannot consume the following document.
+                            const parsed = cheerio.load(converted);
+                            fragments[index] = `<section class="archive-doc" 
data-key="${doc.key}">${parsed('body').html()}</section>`;
+                        }
+                    }));
+                    const $ = cheerio.load(fragments.join('\n'));
+                    for (const doc of selected) {
+                        const article = 
$(`.archive-doc[data-key="${doc.key}"]`);
+                        if (!article.length) throw new Error(`Missing rendered 
document: ${doc.source}`);
+                        addHeadingTargets($, article, doc.key, headingAliases);
+                        article.find('a[href]').each((_, anchor) => {
+                            const href = $(anchor).attr('href');
+                            if (href.startsWith('https://archive.invalid/')) 
return;
+                            const [pathname, fragment] = href.split('#');
+                            let target;
+                            if (!pathname) target = doc;
+                            else if (!/^(?:[a-z]+:|\/\/)/i.test(pathname) && 
!pathname.startsWith('/'))
+                                target = 
bySource.get(path.posix.normalize(path.posix.join(path.posix.dirname(doc.source),
 decodeURI(pathname))).replace(/\.mdx?$/, ''));
+                            else {
+                                try {target = byRoute.get(decodeURI(new 
URL(href, 'https://doris.apache.org').pathname).replace(/\/$/, ''));} catch {}
+                            }
+                            if (target) $(anchor).attr('href', 
`https://archive.invalid/${target.key}${fragment ? '#' + fragment : ''}`);
+                            else if (!/^(?:[a-z]+:|\/\/)/i.test(href)) 
$(anchor).attr('href', new URL(href, 
`https://doris.apache.org${doc.route}`).href);
+                        });
+                        const images = article.find('img').toArray();
+                        for (let offset = 0; offset < images.length; offset += 
8) {
+                            await Promise.all(images.slice(offset, offset + 
8).map(async element => {
+                                const img = $(element), src = img.attr('src');
+                                if (!src || src.startsWith('data:')) return;
+                                const resolved = await imageSource(src, doc, 
temporary, imageCache);
+                                if (typeof resolved === 'string') 
img.attr('src', 
resolved).removeAttr('loading').removeAttr('width').removeAttr('height');
+                                else {
+                                    if (!allowMissingImages) throw new 
Error(`${doc.source}: could not retrieve ${resolved.url}: ${resolved.error}. 
Retry, or use --allow-missing-images for placeholders.`);
+                                    warnings.push({source: doc.source, 
...resolved});
+                                    img.replaceWith(`<p 
class="missing-image">${escape(img.attr('alt') || (cn ? '图片' : 'Image'))}: <a 
href="${escape(resolved.url)}">${escape(resolved.url)}</a> (${cn ? '源图片不可用' : 
'source image unavailable'})</p>`);
+                                }
+                            }));
+                        }
+                    }
+                    const htmlPath = path.join(directory, `${start}.html`);
+                    const pdfPath = path.join(directory, `${start}.pdf`);
+                    const html = `<!doctype html><html 
lang="${locale}"><head><meta 
charset="utf-8"><title>${escape(title)}</title><style>${css}</style></head><body>${start
 === 0 ? cover + toc : ''}${$('body').html()}</body></html>`;
+                    fs.writeFileSync(htmlPath, html);
+                    const digest = 
createHash('sha256').update(html).digest('hex');
+                    if (fs.existsSync(pdfPath) && fs.existsSync(pdfPath + 
'.sha256') && fs.readFileSync(pdfPath + '.sha256', 'utf8') === digest) {
+                        batches.push(pdfPath);
+                        console.log(`${version} ${locale}: ${Math.min(start + 
40, docs.length)}/${docs.length} (cached)`);
+                        continue;
+                    }
+                    await page.goto(pathToFileURL(htmlPath).href, {waitUntil: 
'load', timeout: 120000});
+                    await page.evaluate(async () => {await 
document.fonts.ready; await Promise.race([
+                        Promise.all(Array.from(document.images, img => 
img.decode().catch(() => {}))),
+                        new Promise(resolve => setTimeout(resolve, 30000)),
+                    ]);});
+                    const broken = await page.$$eval('img', images => 
images.filter(img => !img.complete || img.naturalWidth === 0).map(img => 
img.src));
+                    if (broken.length) throw new Error(`Unrendered images: 
${broken.join(', ')}`);
+                    await page.pdf({path: pdfPath, format: 'A4', 
preferCSSPageSize: true, printBackground: true, timeout: 0});
+                    fs.writeFileSync(pdfPath + '.sha256', digest);
+                    batches.push(pdfPath);
+                    console.log(`${version} ${locale}: ${Math.min(start + 40, 
docs.length)}/${docs.length}`);
+                }
+            } finally {await page.close();}
+            const outputPath = path.join(output, filename(version, locale));
+            const mergedPath = path.join(directory, 'merged.pdf');
+            const jobPath = path.join(directory, 'merge.json');
+            fs.writeFileSync(jobPath, JSON.stringify({title, contentsTitle, 
version, locale, commit, docs: docs.map(({key, title}) => ({key, title})), 
batches, headingAliases, output: mergedPath}));
+            const stats = JSON.parse(execFileSync(python, 
[path.join(__dirname, 'merge.py'), jobPath], {encoding: 'utf8', maxBuffer: 10 * 
1024 * 1024}));
+            fs.copyFileSync(mergedPath, outputPath + '.partial');
+            fs.renameSync(outputPath + '.partial', outputPath);
+            const bytes = fs.readFileSync(outputPath);
+            manifest.files = manifest.files.filter(file => file.version !== 
version || file.locale !== locale);
+            manifest.files.push({version, locale, filename: 
path.basename(outputPath),
+                sample: Boolean(limit), bytes: bytes.length,
+                sha256: createHash('sha256').update(bytes).digest('hex'), 
...stats, sourceFiles: docs.map(doc => path.relative(root, doc.file)), 
imageWarnings: warnings});
+            fs.writeFileSync(path.join(output, 'manifest.json'), 
JSON.stringify(manifest, null, 2) + '\n');
+            fs.writeFileSync(path.join(output, 'SHA256SUMS'), 
manifest.files.map(file => `${file.sha256}  ${file.filename}`).join('\n') + 
'\n');
+            console.log(`Saved ${outputPath} (${stats.pages} pages, 
${warnings.length} image warnings)`);
+        }
+    } finally {await browser.close();}
+}
+module.exports = {generate, normalize, findChrome, addHeadingTargets};
diff --git a/scripts/docs-pdf/generate.sh b/scripts/docs-pdf/generate.sh
new file mode 100755
index 000000000000..2bc822f1e012
--- /dev/null
+++ b/scripts/docs-pdf/generate.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+    cat <<'EOF'
+Usage:
+  ./scripts/docs-pdf/generate.sh <version> [locale] [options]
+  ./scripts/docs-pdf/generate.sh --version <version> [options]
+  ./scripts/docs-pdf/generate.sh --list-versions
+
+Examples:
+  ./scripts/docs-pdf/generate.sh 4.x zh-CN
+  ./scripts/docs-pdf/generate.sh dev
+  ./scripts/docs-pdf/generate.sh 2.1 en --output ./output/pdf/2.1-english
+
+Locale: en, zh-CN, or en,zh-CN. Omit it to export both languages.
+Additional options are passed to yarn docs:pdf.
+Relative paths are resolved from the repository root.
+Run yarn docs:pdf --help for the complete option list.
+EOF
+}
+
+case "${1:-}" in
+    '') usage >&2; exit 2 ;;
+    -h|--help) usage; exit 0 ;;
+esac
+
+docs_pdf_repo_root="$(CDPATH='' cd -- "$(dirname -- 
"${BASH_SOURCE[0]}")/../.." && pwd -P)"
+
+if [[ "$1" != -* ]]; then
+    docs_pdf_version="$1"
+    shift
+    if (( $# > 0 )) && [[ "$1" != -* ]]; then
+        docs_pdf_locale="$1"
+        shift
+        set -- --version "$docs_pdf_version" --locale "$docs_pdf_locale" "$@"
+    else
+        set -- --version "$docs_pdf_version" "$@"
+    fi
+fi
+
+if ! command -v yarn >/dev/null 2>&1; then
+    printf 'yarn is required to run the documentation PDF exporter.\n' >&2
+    exit 127
+fi
+
+cd -- "$docs_pdf_repo_root"
+exec yarn docs:pdf "$@"
diff --git a/scripts/docs-pdf/merge.py b/scripts/docs-pdf/merge.py
new file mode 100644
index 000000000000..e9d566c9883e
--- /dev/null
+++ b/scripts/docs-pdf/merge.py
@@ -0,0 +1,72 @@
+"""Merge print batches, restore cross-batch links and add document 
bookmarks."""
+import json
+import sys
+from pathlib import Path
+from pypdf import PdfReader, PdfWriter
+from pypdf.generic import (
+    ArrayObject, DecodedStreamObject, DictionaryObject, FloatObject,
+    NameObject, NumberObject,
+)
+
+job = json.loads(Path(sys.argv[1]).read_text())
+writer = PdfWriter()
+for batch in job['batches']:
+    writer.append(PdfReader(batch), import_outline=False)
+
+prefix = 'https://archive.invalid/'
+targets = {'contents': (1, 40, 800)}
+for index, page in enumerate(writer.pages):
+    for ref in page.get('/Annots', []):
+        link = ref.get_object()
+        uri = str(link.get('/A', {}).get('/URI', ''))
+        if uri.startswith(prefix + 'start/'):
+            rect = link['/Rect']
+            targets[uri[len(prefix + 'start/'):]] = (index, float(rect[0]), 
float(rect[3]))
+
+for alias, canonical in job.get('headingAliases', {}).items():
+    if canonical in targets:
+        targets.setdefault(alias, targets[canonical])
+
+for page in writer.pages:
+    for ref in page.get('/Annots', []):
+        link = ref.get_object()
+        uri = str(link.get('/A', {}).get('/URI', ''))
+        if not uri.startswith(prefix):
+            continue
+        key = uri[len(prefix):].removeprefix('start/')
+        target = targets.get(key) or targets.get(key.split('#')[0])
+        if target is None:
+            raise RuntimeError(f'Unresolved internal PDF link: {key}')
+        destination = ArrayObject([writer.pages[target[0]].indirect_reference,
+                                   NameObject('/XYZ'), FloatObject(target[1]),
+                                   FloatObject(target[2]), NumberObject(0)])
+        link[NameObject('/A')] = DictionaryObject({NameObject('/S'): 
NameObject('/GoTo'),
+                                                   NameObject('/D'): 
destination})
+
+missing = [doc['key'] for doc in job['docs'] if doc['key'] not in targets]
+if missing:
+    raise RuntimeError(f'Missing document headings: {missing}')
+writer.add_outline_item(job['contentsTitle'], 1)
+for doc in job['docs']:
+    writer.add_outline_item(doc['title'], targets[doc['key']][0])
+writer.add_metadata({'/Title': job['title'], '/Author': 'Apache Doris',
+                     '/Subject': f"Archived documentation, source commit 
{job['commit']}",
+                     '/Keywords': f"Apache 
Doris,{job['version']},{job['locale']},archive"})
+font = writer._add_object(DictionaryObject({NameObject('/Type'): 
NameObject('/Font'),
+    NameObject('/Subtype'): NameObject('/Type1'), NameObject('/BaseFont'): 
NameObject('/Helvetica')}))
+for index in range(1, len(writer.pages)):
+    page = writer.pages[index]
+    resources = page['/Resources'].get_object()
+    if '/Font' not in resources:
+        resources[NameObject('/Font')] = DictionaryObject()
+    resources['/Font'][NameObject('/ArchiveFooter')] = font
+    stream = DecodedStreamObject()
+    stream.set_data(f'\nQ\nq BT /ArchiveFooter 8 Tf 0.3 0.35 0.32 rg 1 0 0 1 
495 22 Tm ({index + 1} / {len(writer.pages)}) Tj ET Q\n'.encode('ascii'))
+    old = page.get('/Contents')
+    contents = old.get_object() if old is not None else None
+    values = list(contents) if isinstance(contents, ArrayObject) else ([old] 
if old is not None else [])
+    start = DecodedStreamObject()
+    start.set_data(b'q\n')
+    page[NameObject('/Contents')] = ArrayObject([writer._add_object(start)] + 
values + [writer._add_object(stream)])
+writer.write(job['output'])
+print(json.dumps({'pages': len(writer.pages), 'documents': len(job['docs']), 
'bookmarks': len(job['docs']) + 1}))
diff --git a/scripts/docs-pdf/print.css b/scripts/docs-pdf/print.css
new file mode 100644
index 000000000000..32fa09fa08b0
--- /dev/null
+++ b/scripts/docs-pdf/print.css
@@ -0,0 +1,27 @@
+@page { size: A4; margin: 18mm 16mm 20mm; }
+html { color: #172b25; background: white; }
+body { font: 10pt/1.55 Arial, 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans 
CJK SC', sans-serif; overflow-wrap: anywhere; }
+h1 { font-size: 23pt; line-height: 1.2; }
+h1.doc-title { break-before: page; border-bottom: 2pt solid #11a679; 
padding-bottom: 10pt; }
+h2, h3, h4, h5, h6 { break-after: avoid; line-height: 1.3; margin-top: 16pt; }
+h2 { font-size: 16pt; } h3 { font-size: 13pt; } h4, h5, h6 { font-size: 11pt; }
+h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { color: inherit; text-decoration: none; }
+a { color: #087352; text-decoration: underline; }
+pre { font: 8pt/1.4 Menlo, Consolas, 'PingFang SC', 'Noto Sans Mono CJK SC', 
monospace; white-space: pre-wrap; overflow-wrap: anywhere; background: #f4f7f6; 
padding: 8pt; border: .5pt solid #d9e5df; }
+code { font: .9em Menlo, Consolas, 'PingFang SC', 'Noto Sans Mono CJK SC', 
monospace; }
+table { width: 100%; border-collapse: collapse; font-size: 8pt; table-layout: 
fixed; margin: 10pt 0; }
+th, td { border: .5pt solid #c4d2cc; padding: 4pt; vertical-align: top; 
overflow-wrap: anywhere; }
+th { background: #e8f4ee; } tr { break-inside: avoid; }
+img { max-width: 100%; height: auto; max-height: 220mm; object-fit: contain; }
+figure { margin: 10pt 0; break-inside: avoid; }
+blockquote, .admonition { border-left: 3pt solid #11a679; padding: 6pt 10pt; 
margin: 10pt 0; background: #f1f8f5; }
+details > * { display: block; } summary { font-weight: bold; }
+.cover { padding-top: 50mm; text-align: center; break-after: page; }
+.cover img { width: 65mm; margin-bottom: 20mm; }
+.cover h1 { font-size: 32pt; }
+.cover p { color: #465c52; font-size: 12pt; }
+.toc { break-after: page; } .toc ol { padding-left: 2.6em; } .toc li { margin: 
2pt 0; }
+.source { font-size: 8pt; color: #576b61; }
+.missing-image { border: 1pt solid #9d7630; padding: 8pt; font-size: 9pt; }
+
+.section-link { font-size: .75em; }
diff --git a/scripts/docs-pdf/requirements.txt 
b/scripts/docs-pdf/requirements.txt
new file mode 100644
index 000000000000..fd8ffbb5aa4e
--- /dev/null
+++ b/scripts/docs-pdf/requirements.txt
@@ -0,0 +1 @@
+pypdf>=6.0,<7
diff --git a/scripts/docs-pdf/verify.py b/scripts/docs-pdf/verify.py
new file mode 100644
index 000000000000..19452946ffc7
--- /dev/null
+++ b/scripts/docs-pdf/verify.py
@@ -0,0 +1,37 @@
+"""Check an exported archive against its source-coverage manifest."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+from pypdf import PdfReader
+
+parser = argparse.ArgumentParser(description=__doc__)
+parser.add_argument('directory', type=Path)
+parser.add_argument('--allow-missing-images', action='store_true')
+args = parser.parse_args()
+directory = args.directory
+manifest = json.loads((directory / 'manifest.json').read_text())
+assert manifest['files'], 'No PDF files in manifest'
+for item in manifest['files']:
+    assert args.allow_missing_images or not item['imageWarnings'], 
item['filename']
+    file = directory / item['filename']
+    data = file.read_bytes()
+    assert len(data) == item['bytes'], file
+    assert hashlib.sha256(data).hexdigest() == item['sha256'], file
+    reader = PdfReader(file)
+    assert len(reader.pages) == item['pages'], file
+    assert len(reader.outline) == item['documents'] + 1, file
+    assert len(set(item['sourceFiles'])) == item['documents'], file
+    assert reader.metadata.author == 'Apache Doris', file
+    for page in reader.pages:
+        for ref in page.get('/Annots', []):
+            uri = str(ref.get_object().get('/A', {}).get('/URI', ''))
+            assert 'archive.invalid' not in uri, (file, uri)
+    sample = sorted({index for index in [0, 1, 30, len(reader.pages) // 2, 
len(reader.pages) - 1] if index < len(reader.pages)})
+    for index in sample:
+        text = reader.pages[index].extract_text()
+        assert text.strip(), (file, index)
+        assert '\ufffd' not in text, (file, index)
+    print(json.dumps({'file': item['filename'], 'pages': len(reader.pages),
+                      'documents': item['documents'], 'imageWarnings': 
len(item['imageWarnings']),
+                      'status': 'pass'}))


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to