From 76706dc48c759b9cbb86d34e5db71709aa6bfb7f Mon Sep 17 00:00:00 2001 From: mk Date: Wed, 29 Apr 2026 07:36:23 +0800 Subject: [PATCH] first version --- .gitignore | 23 + CHANGELOG.md | 17 + CONTRIBUTING.md | 65 ++ LICENSE | 21 + README.md | 519 ++++++++ esbuild.config.mjs | 46 + main.js | 2015 ++++++++++++++++++++++++++++++++ manifest.json | 9 + package-lock.json | 611 ++++++++++ package.json | 30 + scripts/verify-code-export.mjs | 127 ++ src/exporter.ts | 848 ++++++++++++++ src/main.ts | 474 ++++++++ src/mermaid.ts | 70 ++ src/pandoc.ts | 357 ++++++ src/renderer.ts | 567 +++++++++ src/settings.ts | 308 +++++ src/types.ts | 144 +++ src/utils.ts | 246 ++++ styles.css | 67 ++ styles/default.css | 314 +++++ templates/default.html | 114 ++ tsconfig.json | 17 + version-bump.mjs | 14 + versions.json | 3 + 25 files changed, 7026 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 main.js create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/verify-code-export.mjs create mode 100644 src/exporter.ts create mode 100644 src/main.ts create mode 100644 src/mermaid.ts create mode 100644 src/pandoc.ts create mode 100644 src/renderer.ts create mode 100644 src/settings.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 styles.css create mode 100644 styles/default.css create mode 100644 templates/default.html create mode 100644 tsconfig.json create mode 100644 version-bump.mjs create mode 100644 versions.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b204a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules/ + +# Build output +main.js.map + +# Obsidian plugin temp files +.obsidian/ +tmp/ + +# System files +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor state +.idea/ +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..008c81c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to Obsidian Press are documented here. + +This project follows semantic versioning where practical. + +## 1.0.0 - 2026-04-28 + +- Added PDF export through Pandoc with XeLaTeX, pdfLaTeX, LuaLaTeX, wkhtmltopdf, WeasyPrint, and Typst engines. +- Added DOCX and HTML export. +- Added current note, current folder, and whole vault export commands. +- Added optional folder picker commands for one-off export destinations. +- Added right-click menu entries for note and folder PDF export. +- Added Obsidian Markdown preprocessing for callouts, wikilinks, embeds, highlights, comments, image sizes, math, tables, and Mermaid diagrams. +- Added runtime PATH support for Homebrew and BasicTeX/MacTeX CLI tools. +- Added writable temporary directory handling for Pandoc, LaTeX, and LuaLaTeX cache files. +- Added CJK font settings and LaTeX engine support. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0b5d6f6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +Thanks for your interest in improving Obsidian Press. + +## Development Setup + +```bash +npm install +npm run dev +``` + +For a production build: + +```bash +npm run build +``` + +Before opening a pull request, run: + +```bash +npm run typecheck +npm run build +``` + +## Local Obsidian Testing + +Build the plugin, then copy these files into a test vault: + +```bash +mkdir -p /path/to/vault/.obsidian/plugins/obsidian-press +cp main.js manifest.json styles.css /path/to/vault/.obsidian/plugins/obsidian-press/ +``` + +Restart Obsidian or reload the plugin after copying files. + +## Dependency Testing + +PDF export depends on external tools. Verify the common macOS paths with: + +```bash +PATH=/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:$PATH which \ + pandoc xelatex pdflatex lualatex wkhtmltopdf weasyprint typst mmdc +``` + +If a bug only happens with one PDF engine, include the engine name, version, operating system, and the smallest Markdown file that reproduces the issue. + +## Pull Request Guidelines + +- Keep changes focused. Avoid unrelated refactors. +- Prefer existing module boundaries: settings UI in `src/settings.ts`, export orchestration in `src/exporter.ts`, Pandoc execution in `src/pandoc.ts`, Markdown preprocessing in `src/renderer.ts`. +- Use `spawn` with argument arrays for external commands when possible. +- Do not pass user-controlled values through shell strings unless there is no practical alternative. +- Update `README.md` and `CHANGELOG.md` for user-visible changes. + +## Reporting Issues + +Include: + +- Obsidian version +- Operating system +- Plugin version +- Export format and PDF engine +- Relevant settings +- Error message from Obsidian notices or the developer console +- A minimal Markdown sample when possible diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a4715c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Obsidian Press Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..81e5e52 --- /dev/null +++ b/README.md @@ -0,0 +1,519 @@ +# Obsidian Press + +High-fidelity PDF export plugin for Obsidian, powered by [Pandoc](https://pandoc.org/). + +Obsidian Press converts Obsidian Markdown into Pandoc-compatible Markdown, then exports notes to PDF, Word, or HTML. It is desktop-only because it calls local command-line tools such as Pandoc, LaTeX, wkhtmltopdf, WeasyPrint, Typst, and Mermaid CLI. + +## Status + +- Version: `1.0.0` +- License: MIT +- Obsidian: desktop app only +- Minimum Obsidian version: `0.15.0` + +## Features + +- **Multiple PDF engines** — XeLaTeX, pdfLaTeX, LuaLaTeX, wkhtmltopdf, WeasyPrint, Typst +- **Export formats** — PDF, Word (DOCX), HTML +- **Batch export** — Export current folder or entire vault with concurrency control +- **Mermaid diagrams** — Pre-render Mermaid code blocks to SVG via `mmdc` +- **Custom styling** — Custom CSS (HTML engines) and Pandoc templates +- **CJK support** — Auto-detect Chinese/Japanese/Korean fonts for LaTeX engines +- **Code highlighting** — 6 built-in themes (Pygments, Tango, Zenburn, Breeze Dark, Kate, Monochrome) +- **Full content fidelity** — Images, math formulas, tables, callouts, wikilinks, embeds, highlights, superscript/subscript + +## Prerequisites + +### Required: Pandoc + +Pandoc is the document converter used by every export format. + +Download sources: + +- Official site: +- GitHub releases: +- Homebrew formula: + +```bash +# macOS +brew install pandoc + +# Ubuntu/Debian +sudo apt install pandoc + +# Windows +winget install JohnMacFarlane.Pandoc +``` + +Verify: + +```bash +which pandoc +pandoc --version +``` + +### PDF Engine (at least one) + +The plugin supports these engines: + +- **XeLaTeX** (`xelatex`) — recommended for high-quality PDF and CJK text +- **pdfLaTeX** (`pdflatex`) — classic LaTeX engine; CJK notes are exported with XeLaTeX automatically +- **LuaLaTeX** (`lualatex`) — LaTeX engine with good CJK support +- **wkhtmltopdf** (`wkhtmltopdf`) — HTML/CSS-based PDF output +- **WeasyPrint** (`weasyprint`) — HTML/CSS-based PDF output +- **Typst** (`typst`) — experimental Pandoc Typst backend + +| Engine | Install | Quality | CJK Support | +|--------|---------|---------|-------------| +| **XeLaTeX** | `brew install --cask mactex-no-gui` or install BasicTeX/MacTeX | Best typesetting | Yes | +| **wkhtmltopdf** (lightweight) | `brew install wkhtmltopdf` | Good | Yes | +| **WeasyPrint** | `pip install weasyprint` | Good | Yes | +| **pdfLaTeX** | Included in BasicTeX/MacTeX/TeX Live | Good | No | +| **LuaLaTeX** | Included in BasicTeX/MacTeX/TeX Live | Good | Yes | +| **Typst** | `brew install typst` | Experimental | Yes | + +### macOS: Install All Supported Engines + +Install Homebrew first if needed: . + +This installs Pandoc, Typst, wkhtmltopdf, WeasyPrint, SVG conversion support, and Mermaid CLI: + +```bash +brew install pandoc typst wkhtmltopdf librsvg +pip install weasyprint +npm install -g @mermaid-js/mermaid-cli +``` + +Install LaTeX engines (`xelatex`, `pdflatex`, `lualatex`) with one of these: + +```bash +# Full TeX Live distribution without GUI apps +brew install --cask mactex-no-gui + +# Smaller distribution; enough for the CLI engines +brew install --cask basictex +``` + +Download sources: + +- MacTeX: +- BasicTeX packages: +- CTAN MacTeX directory: + +If the Homebrew `mactex-no-gui` or `basictex` cask points to an expired CTAN package, download the current BasicTeX package directly and install it: + +```bash +curl -L https://mirrors.ctan.org/systems/mac/mactex/mactex-basictex-20260301.pkg -o /tmp/mactex-basictex.pkg +sudo installer -pkg /tmp/mactex-basictex.pkg -target / +``` + +BasicTeX installs the TeX CLI tools into `/Library/TeX/texbin`. Obsidian Press adds this directory to its runtime PATH, but after installing BasicTeX/MacTeX you should restart Obsidian. + +Verify all engines: + +```bash +PATH=/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:$PATH which \ + pandoc xelatex pdflatex lualatex wkhtmltopdf weasyprint typst mmdc +``` + +Expected locations on Apple Silicon macOS after the installation used during development: + +```text +/opt/homebrew/bin/pandoc +/Library/TeX/texbin/xelatex +/Library/TeX/texbin/pdflatex +/Library/TeX/texbin/lualatex +/usr/local/bin/wkhtmltopdf +/opt/homebrew/bin/weasyprint +/opt/homebrew/bin/typst +/opt/homebrew/bin/mmdc +/opt/homebrew/bin/rsvg-convert +``` + +### Optional: Mermaid CLI + +Mermaid CLI renders Mermaid code blocks to SVG before Pandoc runs. + +Download sources: + +- npm package: +- GitHub repository: + +```bash +npm install -g @mermaid-js/mermaid-cli +``` + +Verify: + +```bash +which mmdc +mmdc --version +``` + +## Installation + +### From Source + +```bash +git clone +cd obsidian-press +npm install +npm run build +``` + +Then copy the built files to your vault: + +```bash +mkdir -p /path/to/your-vault/.obsidian/plugins/obsidian-press/ +cp main.js manifest.json styles.css /path/to/your-vault/.obsidian/plugins/obsidian-press/ +``` + +In Obsidian: **Settings → Community Plugins → Enable "Obsidian Press"**. + +### Pre-built + +Copy `main.js`, `manifest.json`, `styles.css` into `/.obsidian/plugins/obsidian-press/` and enable the plugin in Obsidian settings. + +## Usage + +### Commands + +| Command | Description | +|---------|-------------| +| Export current note to PDF | Export the active Markdown file to PDF | +| Export current note to PDF... | Choose an output folder, then export the active Markdown file to PDF | +| Export current note to Word (DOCX) | Export to Word format | +| Export current note to Word (DOCX)... | Choose an output folder, then export to Word format | +| Export current note to HTML | Export to HTML | +| Export current note to HTML... | Choose an output folder, then export to HTML | +| Export all notes in current folder | Batch export the folder containing the active file | +| Export all notes in current folder... | Choose an output folder, then batch export the current folder | +| Export entire vault | Batch export all Markdown files in the vault | +| Export entire vault... | Choose an output folder, then batch export the whole vault | + +Access via **Command Palette** (`Cmd/Ctrl + P`) or the ribbon icon (file output icon in the left sidebar). + +### Right-Click Menu + +- Right-click a `.md` file → **导出为 PDF(Obsidian Press)** +- Right-click a `.md` file → **选择目录导出为 PDF(Obsidian Press)** +- Right-click a folder → **全部导出为 PDF(Obsidian Press)** +- Right-click a folder → **选择目录全部导出为 PDF(Obsidian Press)** + +## Settings + +### General + +| Setting | Default | Description | +|---------|---------|-------------| +| Pandoc path | `/opt/homebrew/bin/pandoc` | Path to the Pandoc executable. Required for every export format and every PDF engine | +| PDF engine | XeLaTeX | Engine used for PDF output. XeLaTeX is recommended for Chinese/Japanese/Korean notes and high-quality typesetting | +| Default format | PDF | Default export format | + +### Output + +| Setting | Default | Description | +|---------|---------|-------------| +| Output directory | `pdf` | Relative to vault root, or absolute path | +| File naming | Same as source | `same` / `timestamp` / `suffix` | +| Open after export | On | Auto-open exported file | + +### Typography + +| Setting | Default | Description | +|---------|---------|-------------| +| Font size | 11pt | Base font size | +| Page size | A4 | A4, Letter, Legal, A3 | +| Page margin | 25mm | Page margin | +| Code theme | Tango | Syntax highlight theme. Tango is the default because it gives PDF code blocks a visible background | +| CJK font | (auto-detect) | Chinese/Japanese/Korean font. On macOS, XeLaTeX falls back to `STHeitiSC-Medium` when this is empty | +| Enable CJK support | On | CJK font config for LaTeX | + +### Advanced + +| Setting | Default | Description | +|---------|---------|-------------| +| Custom CSS file | (empty) | Path to custom CSS (HTML engines only) | +| Custom Pandoc template | (empty) | Path to custom Pandoc template | +| Mermaid CLI path | `mmdc` | Path to mermaid-cli binary | +| Mermaid theme | Default | Mermaid diagram theme | +| Extra Pandoc arguments | (empty) | Additional CLI args for Pandoc | + +### Batch Export + +| Setting | Default | Description | +|---------|---------|-------------| +| Concurrency | 3 | Parallel export count | +| Skip errors | On | Continue on failure | + +## Content Fidelity + +Obsidian-specific syntax is pre-processed before passing to Pandoc: + +PDF export is powered by Pandoc engines. XeLaTeX is the recommended default for mixed Chinese/English notes, while Typst, wkhtmltopdf, and WeasyPrint are available for users who prefer those toolchains. + +| Obsidian Syntax | Output | +|----------------|--------| +| `> [!note] Title` callouts | Styled `
` blocks with icons and colors | +| `[[wikilink]]` | Standard Markdown links | +| `![[image.png]]` embeds | Absolute path image references | +| `![[other-note]]` embeds | Inlined note content (up to 5 levels deep) | +| `==highlighted text==` | `` HTML tags | +| `^superscript^` | `` HTML tags | +| `~~subscript~~` | `` HTML tags | +| `%%comment%%` | Removed from output | +| ` ```mermaid ``` ` | Pre-rendered SVG images | +| ` ```ts ... ``` ` code blocks | Preserved for Pandoc syntax highlighting | +| Inline code spans | Preserved without Obsidian syntax conversion | +| LaTeX math (`$...$`, `$$...$$`) | Native Pandoc math rendering | +| Pipe/grid tables | Native Pandoc table support | +| Images with `\|size` | Resized image tags | + +## Project Structure + +``` +obsidian-press/ +├── src/ +│ ├── main.ts # Plugin entry: commands, menus, lifecycle +│ ├── settings.ts # Settings tab UI +│ ├── types.ts # TypeScript interfaces and types +│ ├── pandoc.ts # Pandoc CLI wrapper (spawn with array args) +│ ├── renderer.ts # Obsidian MD → standard MD preprocessing +│ ├── mermaid.ts # Mermaid → SVG pre-rendering +│ ├── exporter.ts # Export orchestration (single/batch/vault) +│ └── utils.ts # Path resolution, font detection, helpers +├── styles/ +│ └── default.css # Built-in PDF styles (callouts, code, tables) +├── templates/ +│ └── default.html # HTML template for wkhtmltopdf +├── manifest.json +├── package.json +├── esbuild.config.mjs +└── styles.css # Plugin UI styles (in Obsidian) +``` + +## Development + +```bash +npm install +npm run dev # Watch mode with esbuild +npm run typecheck +npm run build # Production build → main.js +``` + +`npm run build` runs TypeScript type checking before bundling. + +### Release Checklist + +1. Update `version` in `package.json` and `manifest.json`. +2. Update `versions.json` for Obsidian plugin compatibility. +3. Update `CHANGELOG.md`. +4. Run: + +```bash +npm install +npm run typecheck +npm run build +npm run verify:code-export +``` + +5. Verify the release files exist: + +```bash +ls -lh main.js manifest.json styles.css +``` + +6. Package or attach these files for a manual release: + +```text +main.js +manifest.json +styles.css +``` + +### Engine Verification + +The project has been verified with all supported PDF engines on macOS: + +```text +xelatex +pdflatex +lualatex +wkhtmltopdf +weasyprint +typst +``` + +You can run a quick local Pandoc check with: + +```bash +mkdir -p /tmp/obsidian-press-engine-test +cat > /tmp/obsidian-press-engine-test/input.md <<'EOF' +# Obsidian Press Engine Test + +This is a PDF export test. + +- Math: $E = mc^2$ + +| A | B | +|---|---| +| 1 | 2 | +EOF + +for engine in xelatex pdflatex lualatex wkhtmltopdf weasyprint typst; do + PATH=/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:$PATH \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + TEXMFVAR=/tmp/obsidian-press-engine-test/tex-cache \ + TEXMFCACHE=/tmp/obsidian-press-engine-test/tex-cache \ + pandoc /tmp/obsidian-press-engine-test/input.md \ + -o /tmp/obsidian-press-engine-test/$engine.pdf \ + --from markdown+pipe_tables+tex_math_dollars \ + --standalone \ + --pdf-engine=$engine +done +``` + +## Troubleshooting + +### "pandoc not found" + +Set the full path to pandoc in plugin settings (e.g., `/opt/homebrew/bin/pandoc` or `/usr/local/bin/pandoc`). + +Find your pandoc path: +```bash +which pandoc +``` + +### "PDF engine not installed" + +Install a PDF engine from the prerequisites list. wkhtmltopdf is the lightest option: +```bash +brew install wkhtmltopdf +``` + +For LaTeX engines on macOS, BasicTeX installs binaries under `/Library/TeX/texbin`: + +```bash +PATH=/Library/TeX/texbin:$PATH which xelatex pdflatex lualatex +``` + +Restart Obsidian after installing BasicTeX or MacTeX. + +### "openTempFile: permission denied" or read-only file system errors + +Pandoc or a PDF engine is trying to create temporary files in a non-writable directory. Obsidian Press sets `TMPDIR`, `TMP`, `TEMP`, `TEXMFVAR`, and `TEXMFCACHE` to the plugin's temporary directory at runtime. If this still appears: + +1. Restart Obsidian after updating the plugin. +2. Make sure the vault is writable. +3. Avoid placing the vault in a read-only synced or mounted location. + +### LuaLaTeX reports missing `lualatex-math.sty` + +BasicTeX is intentionally small and may not include every LuaLaTeX package. Install the missing package in user mode: + +```bash +LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 /Library/TeX/texbin/tlmgr init-usertree +LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 /Library/TeX/texbin/tlmgr --usermode \ + --repository https://mirrors.tuna.tsinghua.edu.cn/CTAN/systems/texlive/tlnet \ + install lualatex-math +``` + +### PDF code blocks have no visible background + +Pandoc highlight themes without a background can make code blocks look too close to normal paragraphs. Obsidian Press uses Tango by default because it includes a visible code block background. + +For LaTeX PDF engines, Obsidian Press uses Pandoc's `--listings` mode and injects a small `\lstset{...}` header so long code lines wrap within the page instead of overflowing horizontally. + +If you disable listings manually through custom Pandoc arguments and rely on Pandoc's default LaTeX highlighter, a highlighted background may require `framed.sty`. BasicTeX may not include it: + +```bash +LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 /Library/TeX/texbin/tlmgr --usermode \ + --repository https://mirrors.aliyun.com/CTAN/systems/texlive/tlnet \ + install framed +``` + +You can verify code block export locally: + +```bash +npm run verify:code-export +``` + +### XeLaTeX reports missing `svg.sty` + +This happens when a note contains SVG images or Mermaid diagrams rendered to SVG, and BasicTeX does not include the LaTeX SVG package. Install the missing TeX packages into the user TeX tree: + +```bash +mkdir -p /tmp/texlive-svg-packages +cd /tmp/texlive-svg-packages +for pkg in svg transparent trimspaces catchfile import; do + curl -L https://mirrors.aliyun.com/CTAN/systems/texlive/tlnet/archive/$pkg.tar.xz -o $pkg.tar.xz + tar -xJf $pkg.tar.xz -C ~/Library/texmf +done +PATH=/Library/TeX/texbin:$PATH mktexlsr ~/Library/texmf +``` + +Pandoc also needs `rsvg-convert` to convert SVG files before LaTeX consumes them: + +```bash +brew install librsvg +which rsvg-convert +``` + +### XeLaTeX reports `Cannot determine size of graphic` for `.webp` + +LaTeX engines cannot reliably include WebP images directly. Obsidian Press converts local WebP images to temporary PNG files before running LaTeX. This requires `dwebp`, which is provided by WebP tools: + +```bash +brew install webp +which dwebp +``` + +The temporary PNG files are written under the plugin temp directory and cleaned up after export. + +### Typst reports `file not found` for `https:/...` images + +Pandoc's Typst backend may treat remote image URLs with query strings as local file paths. Obsidian Press downloads remote images to the plugin temp directory before Typst runs, strips WeChat's `tp=webp` image parameter when possible, and converts downloaded WebP images to PNG. + +If a remote image cannot be downloaded, the export continues with a small placeholder image instead of failing the whole document. + +### Mermaid diagrams not rendering + +Install the Mermaid CLI: +```bash +npm install -g @mermaid-js/mermaid-cli +``` + +### CJK characters not showing + +1. Enable "Enable CJK support" in settings +2. Set a CJK font name. On macOS with BasicTeX, `STHeitiSC-Medium` is a reliable choice; `PingFang SC` may be unavailable to XeTeX because it is a system-reserved font. +3. Use XeLaTeX or LuaLaTeX as the PDF engine + +pdfLaTeX cannot process Chinese/Japanese/Korean Unicode text directly. If `pdfLaTeX` is selected for a CJK note, Obsidian Press automatically uses XeLaTeX for that export. + +If XeLaTeX reports `File 'xeCJK.sty' not found`, install the missing package: + +```bash +LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 /Library/TeX/texbin/tlmgr --usermode \ + --repository https://mirrors.aliyun.com/CTAN/systems/texlive/tlnet \ + install xecjk +``` + +### Export takes too long + +- Reduce concurrency in batch settings +- Large files with many images or Mermaid diagrams take longer +- wkhtmltopdf is faster than XeLaTeX for simple documents + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, local testing, dependency checks, and pull request guidelines. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..616f5ae --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,46 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository +*/ +`; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { js: banner }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..d669fb8 --- /dev/null +++ b/main.js @@ -0,0 +1,2015 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository +*/ + +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + default: () => ObsidianPressPlugin +}); +module.exports = __toCommonJS(main_exports); +var import_obsidian4 = require("obsidian"); +var path5 = __toESM(require("path")); + +// src/types.ts +var DEFAULT_SETTINGS = { + pandocPath: "/opt/homebrew/bin/pandoc", + pdfEngine: "xelatex", + enginePath: "", + outputDir: "pdf", + outputNaming: "same", + openAfterExport: true, + defaultFormat: "pdf", + customCssPath: "", + customTemplatePath: "", + fontSize: 11, + pageSize: "A4", + pageMargin: "25", + codeTheme: "tango", + cjkFont: "", + enableCjk: true, + mermaidPath: "mmdc", + mermaidTheme: "default", + extraArgs: "", + concurrency: 3, + skipErrors: true +}; + +// src/settings.ts +var import_obsidian = require("obsidian"); +var ObsidianPressSettingTab = class extends import_obsidian.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + const { containerEl } = this; + containerEl.empty(); + new import_obsidian.Setting(containerEl).setName("General").setHeading(); + new import_obsidian.Setting(containerEl).setName("Pandoc path").setDesc("Path to the pandoc binary").addText( + (text) => text.setPlaceholder("/opt/homebrew/bin/pandoc").setValue(this.plugin.settings.pandocPath).onChange(async (value) => { + this.plugin.settings.pandocPath = value || "/opt/homebrew/bin/pandoc"; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("PDF engine").setDesc("The PDF rendering engine to use").addDropdown( + (dropdown) => dropdown.addOption("xelatex", "XeLaTeX (best quality)").addOption("pdflatex", "pdfLaTeX (auto XeLaTeX for CJK)").addOption("lualatex", "LuaLaTeX").addOption("wkhtmltopdf", "wkhtmltopdf (lightweight)").addOption("weasyprint", "WeasyPrint (CSS-based)").addOption("typst", "Typst (experimental)").setValue(this.plugin.settings.pdfEngine).onChange(async (value) => { + this.plugin.settings.pdfEngine = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Default format").setDesc("Default export format").addDropdown( + (dropdown) => dropdown.addOption("pdf", "PDF").addOption("docx", "Word (DOCX)").addOption("html", "HTML").setValue(this.plugin.settings.defaultFormat).onChange(async (value) => { + this.plugin.settings.defaultFormat = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Output").setHeading(); + new import_obsidian.Setting(containerEl).setName("Output directory").setDesc( + "Relative to vault root, or absolute path. Leave empty for 'pdf' folder" + ).addText( + (text) => text.setPlaceholder("pdf").setValue(this.plugin.settings.outputDir).onChange(async (value) => { + this.plugin.settings.outputDir = value || "pdf"; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("File naming").setDesc("How output files are named").addDropdown( + (dropdown) => dropdown.addOption("same", "Same as source (note.pdf)").addOption("timestamp", "With timestamp (note_2024-01-01T00-00-00.pdf)").addOption("suffix", "With suffix (note_export.pdf)").setValue(this.plugin.settings.outputNaming).onChange(async (value) => { + this.plugin.settings.outputNaming = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Open after export").setDesc("Automatically open the exported file after completion").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.openAfterExport).onChange(async (value) => { + this.plugin.settings.openAfterExport = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Typography").setHeading(); + new import_obsidian.Setting(containerEl).setName("Font size").setDesc("Base font size in points").addText( + (text) => text.setPlaceholder("11").setValue(String(this.plugin.settings.fontSize)).onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0 && num <= 72) { + this.plugin.settings.fontSize = num; + await this.plugin.saveSettings(); + } + }) + ); + new import_obsidian.Setting(containerEl).setName("Page size").setDesc("PDF page dimensions").addDropdown( + (dropdown) => dropdown.addOption("A4", "A4").addOption("Letter", "Letter").addOption("Legal", "Legal").addOption("A3", "A3").setValue(this.plugin.settings.pageSize).onChange(async (value) => { + this.plugin.settings.pageSize = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Page margin").setDesc("Page margin in millimeters").addText( + (text) => text.setPlaceholder("25").setValue(this.plugin.settings.pageMargin).onChange(async (value) => { + this.plugin.settings.pageMargin = value || "25"; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Code highlight theme").setDesc("Syntax highlighting theme for code blocks").addDropdown( + (dropdown) => dropdown.addOption("tango", "Tango (default)").addOption("pygments", "Pygments (minimal background)").addOption("zenburn", "Zenburn").addOption("breezedark", "Breeze Dark").addOption("kate", "Kate").addOption("monochrome", "Monochrome").setValue(this.plugin.settings.codeTheme).onChange(async (value) => { + this.plugin.settings.codeTheme = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("CJK font").setDesc( + "Chinese/Japanese/Korean font name. On macOS, STHeitiSC-Medium is a reliable XeLaTeX choice." + ).addText( + (text) => text.setPlaceholder("STHeitiSC-Medium").setValue(this.plugin.settings.cjkFont).onChange(async (value) => { + this.plugin.settings.cjkFont = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Enable CJK support").setDesc("Add CJK font configuration for LaTeX engines").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.enableCjk).onChange(async (value) => { + this.plugin.settings.enableCjk = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Advanced").setHeading(); + new import_obsidian.Setting(containerEl).setName("Custom CSS file").setDesc( + "Path to custom CSS file (relative to vault root, for HTML-based engines)" + ).addText( + (text) => text.setPlaceholder("styles/custom-pdf.css").setValue(this.plugin.settings.customCssPath).onChange(async (value) => { + this.plugin.settings.customCssPath = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Custom Pandoc template").setDesc("Path to custom Pandoc template file").addText( + (text) => text.setPlaceholder("templates/custom.html").setValue(this.plugin.settings.customTemplatePath).onChange(async (value) => { + this.plugin.settings.customTemplatePath = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Mermaid CLI path").setDesc("Path to mmdc binary for Mermaid diagram rendering").addText( + (text) => text.setPlaceholder("mmdc").setValue(this.plugin.settings.mermaidPath).onChange(async (value) => { + this.plugin.settings.mermaidPath = value || "mmdc"; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Mermaid theme").setDesc("Theme for Mermaid diagrams").addDropdown( + (dropdown) => dropdown.addOption("default", "Default").addOption("dark", "Dark").addOption("forest", "Forest").addOption("neutral", "Neutral").setValue(this.plugin.settings.mermaidTheme).onChange(async (value) => { + this.plugin.settings.mermaidTheme = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Extra Pandoc arguments").setDesc("Additional command-line arguments passed to Pandoc").addText( + (text) => text.setPlaceholder("--pdf-engine-opt=--enable-local-file-access").setValue(this.plugin.settings.extraArgs).onChange(async (value) => { + this.plugin.settings.extraArgs = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian.Setting(containerEl).setName("Batch Export").setHeading(); + new import_obsidian.Setting(containerEl).setName("Concurrency").setDesc("Number of files to export in parallel").addText( + (text) => text.setPlaceholder("3").setValue(String(this.plugin.settings.concurrency)).onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0 && num <= 20) { + this.plugin.settings.concurrency = num; + await this.plugin.saveSettings(); + } + }) + ); + new import_obsidian.Setting(containerEl).setName("Skip errors").setDesc("Continue exporting remaining files if one fails").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.skipErrors).onChange(async (value) => { + this.plugin.settings.skipErrors = value; + await this.plugin.saveSettings(); + }) + ); + } +}; + +// src/exporter.ts +var import_obsidian3 = require("obsidian"); +var path4 = __toESM(require("path")); +var fs4 = __toESM(require("fs")); +var import_child_process3 = require("child_process"); + +// src/renderer.ts +var import_obsidian2 = require("obsidian"); + +// src/utils.ts +var import_child_process = require("child_process"); +var path = __toESM(require("path")); +var fs = __toESM(require("fs")); +var os = __toESM(require("os")); +function runCommand(cmd, options) { + return new Promise((resolve) => { + (0, import_child_process.exec)( + cmd, + { + timeout: (options == null ? void 0 : options.timeout) || 3e4, + maxBuffer: 10 * 1024 * 1024, + // Use login shell so PATH includes Homebrew + shell: "/bin/zsh", + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}` + } + }, + (error, stdout, stderr) => { + resolve({ + stdout: stdout || "", + stderr: stderr || "", + code: error ? error.code || 1 : 0 + }); + } + ); + }); +} +function getVaultPath(app) { + return app.vault.adapter.basePath; +} +function resolveAttachmentPath(src, currentFile, app) { + var _a, _b; + const vaultPath = getVaultPath(app); + if (path.isAbsolute(src)) { + return src; + } + if (src.startsWith("http://") || src.startsWith("https://")) { + return src; + } + if (src.startsWith("data:")) { + return src; + } + const currentDir = path.dirname(currentFile.path); + const relativePath = path.join(currentDir, src); + const absRelative = path.join(vaultPath, relativePath); + if (fs.existsSync(absRelative)) { + return absRelative; + } + const absRoot = path.join(vaultPath, src); + if (fs.existsSync(absRoot)) { + return absRoot; + } + const attachmentFolder = (_b = (_a = app.vault).getConfig) == null ? void 0 : _b.call(_a, "attachmentFolderPath"); + if (attachmentFolder) { + const absAttachment = path.join(vaultPath, attachmentFolder, src); + if (fs.existsSync(absAttachment)) { + return absAttachment; + } + } + return src; +} +async function checkCommandExists(cmd) { + const platform2 = os.platform(); + const checkCmd = platform2 === "win32" ? `where ${cmd}` : `which ${cmd}`; + const { code } = await runCommand(checkCmd); + return code === 0; +} +function getTmpDir(vaultPath) { + const tmpDir = path.join( + vaultPath, + ".obsidian", + "plugins", + "obsidian-press", + "tmp" + ); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + return tmpDir; +} +function createSemaphore(limit) { + let current = 0; + const queue = []; + return { + async acquire() { + if (current < limit) { + current++; + return; + } + return new Promise((resolve) => { + queue.push(resolve); + }); + }, + release() { + current--; + if (queue.length > 0) { + current++; + const next = queue.shift(); + next(); + } + } + }; +} +function getOutputPath(file, vaultPath, outputDir, naming, format) { + const baseName = path.basename(file.path, ".md"); + let fileName; + switch (naming) { + case "timestamp": + const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19); + fileName = `${baseName}_${ts}.${format}`; + break; + case "suffix": + fileName = `${baseName}_export.${format}`; + break; + default: + fileName = `${baseName}.${format}`; + } + let outDir; + if (path.isAbsolute(outputDir)) { + outDir = outputDir; + } else if (outputDir) { + outDir = path.join(vaultPath, outputDir); + } else { + outDir = path.join(vaultPath, "pdf"); + } + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + return path.join(outDir, fileName); +} + +// src/mermaid.ts +var fs2 = __toESM(require("fs")); +var path2 = __toESM(require("path")); +async function renderMermaidBlock(code, theme, tmpDir, index) { + const mmdcAvailable = await checkCommandExists("mmdc"); + if (!mmdcAvailable) { + console.warn("Obsidian Press: mmdc not found, skipping Mermaid rendering"); + return null; + } + const inputFile = path2.join(tmpDir, `mermaid-input-${index}.mmd`); + const outputFile = path2.join(tmpDir, `mermaid-output-${index}.svg`); + try { + fs2.writeFileSync(inputFile, code, "utf8"); + const cmd = `mmdc -i '${inputFile}' -o '${outputFile}' -t ${theme} -b transparent --quiet`; + const { code: exitCode, stderr } = await runCommand(cmd, { + timeout: 3e4 + }); + if (exitCode === 0 && fs2.existsSync(outputFile)) { + return outputFile; + } + console.warn("Obsidian Press: Mermaid rendering failed:", stderr); + return null; + } catch (err) { + console.error("Obsidian Press: Mermaid rendering error:", err); + return null; + } finally { + try { + if (fs2.existsSync(inputFile)) { + fs2.unlinkSync(inputFile); + } + } catch (e) { + } + } +} + +// src/renderer.ts +var CALLOUT_TYPES = [ + "note", + "tip", + "important", + "warning", + "caution", + "abstract", + "info", + "todo", + "example", + "quote", + "success", + "question", + "failure", + "danger", + "bug" +]; +var CALLOUT_ICONS = { + note: "\u{1F4DD}", + tip: "\u{1F4A1}", + important: "\u2757", + warning: "\u26A0\uFE0F", + caution: "\u26A0\uFE0F", + abstract: "\u{1F4CB}", + info: "\u2139\uFE0F", + todo: "\u2611", + example: "\u{1F4DA}", + quote: "\u275D", + success: "\u2705", + question: "\u2753", + failure: "\u274C", + danger: "\u{1F6A8}", + bug: "\u{1F41B}" +}; +async function renderToPandoc(content, file, app, mermaidPath, mermaidTheme) { + const vaultPath = getVaultPath(app); + const tmpDir = getTmpDir(vaultPath); + const tempFiles = []; + let rendered = stripFrontmatter(content); + rendered = formatFlattenedCodeBlocks(rendered); + rendered = await convertMermaidBlocks( + rendered, + mermaidPath, + mermaidTheme, + tmpDir, + tempFiles + ); + const protectedCode = protectCodeSegments(rendered); + rendered = protectedCode.content; + rendered = convertCallouts(rendered); + rendered = convertWikilinks(rendered, file, app); + rendered = await convertEmbeds(rendered, file, app); + rendered = await inlineNoteEmbeds(rendered, file, app, 0, 5); + rendered = convertHighlights(rendered); + rendered = convertSupSub(rendered); + rendered = stripComments(rendered); + rendered = convertImageSizes(rendered); + rendered = restoreCodeSegments(rendered, protectedCode.segments); + return { content: rendered, tempFiles }; +} +function formatFlattenedCodeBlocks(content) { + return content.replace( + /^(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^\1[ \t]*$/gm, + (match, fence, info, code) => { + var _a; + const language = ((_a = info.trim().split(/\s+/)[0]) == null ? void 0 : _a.toLowerCase()) || ""; + if (!shouldFormatFlattenedCode(language, code)) { + return match; + } + const formatted = formatJavaScriptLikeCode(code); + return `${fence}${info} +${formatted} +${fence}`; + } + ); +} +function shouldFormatFlattenedCode(language, code) { + const supportedLanguages = /* @__PURE__ */ new Set([ + "", + "js", + "javascript", + "jsx", + "ts", + "typescript", + "tsx", + "php" + ]); + if (!supportedLanguages.has(language)) { + return false; + } + const trimmed = code.trim(); + if (trimmed.length < 160) { + return false; + } + const nonEmptyLines = trimmed.split(/\r?\n/).filter((line) => line.trim()); + if (nonEmptyLines.length > 2) { + return false; + } + return /[{};]/.test(trimmed) && /\b(const|let|var|function|class|async|while|if|return|await|new)\b/.test(trimmed); +} +function formatJavaScriptLikeCode(code) { + const { text, literals } = protectStringLiterals(code.trim()); + let formatted = text.replace(/\r?\n/g, " ").replace(/[ \t]{2,}/g, " ").replace(/\}\s*else\s*\{/g, "}\nelse {").replace(/\}\s*catch\s*\(/g, "}\ncatch (").replace(/\}\s*finally\s*\{/g, "}\nfinally {").replace(/\s*\{\s*/g, " {\n").replace(/;\s*/g, ";\n").replace(/,\s*/g, ",\n").replace(/\s*\}\s*/g, "\n}\n").replace(/\)\s*(?=(?:async\s+)?(?:function|class|const|let|var|if|for|while|return|await|new|this\.))/g, ")\n").replace(/\n{2,}/g, "\n"); + formatted = restoreStringLiterals(formatted, literals); + return indentFormattedCode(formatted); +} +function protectStringLiterals(code) { + const literals = []; + let text = ""; + let i = 0; + while (i < code.length) { + const char = code[i]; + if (char !== "'" && char !== '"' && char !== "`") { + text += char; + i++; + continue; + } + const quote = char; + let literal = char; + i++; + while (i < code.length) { + const next = code[i]; + literal += next; + i++; + if (next === "\\") { + if (i < code.length) { + literal += code[i]; + i++; + } + continue; + } + if (next === quote) { + break; + } + } + const token = `__OBSIDIAN_PRESS_LITERAL_${literals.length}__`; + literals.push(literal); + text += token; + } + return { text, literals }; +} +function restoreStringLiterals(text, literals) { + return literals.reduce( + (result, literal, index) => result.split(`__OBSIDIAN_PRESS_LITERAL_${index}__`).join(literal), + text + ); +} +function indentFormattedCode(code) { + const lines = code.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const output = []; + let level = 0; + for (const line of lines) { + if (/^[}\])]/.test(line)) { + level = Math.max(0, level - 1); + } + output.push(`${" ".repeat(level)}${line}`); + const opens = (line.match(/[{\[(]/g) || []).length; + const closes = (line.match(/[}\])]/g) || []).length; + level = Math.max(0, level + opens - closes); + } + return output.join("\n"); +} +function protectCodeSegments(content) { + const segments = /* @__PURE__ */ new Map(); + let index = 0; + const store = (value) => { + const token = `OBSIDIAN_PRESS_CODE_${index++}`; + segments.set(token, value); + return token; + }; + let result = content.replace( + /^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, + (match) => store(match) + ); + result = result.replace(/(`+)([\s\S]*?[^`])\1/g, (match) => store(match)); + return { content: result, segments }; +} +function restoreCodeSegments(content, segments) { + let result = content; + for (const [token, value] of segments) { + result = result.split(token).join(value); + } + return result; +} +function stripFrontmatter(content) { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n?/; + const match = content.match(frontmatterRegex); + if (!match) return content; + const yamlContent = match[1]; + const rest = content.slice(match[0].length); + const titleMatch = yamlContent.match(/^title:\s*(.+)$/m); + if (titleMatch) { + const title = titleMatch[1].replace(/^["']|["']$/g, ""); + return `# ${title} + +${rest}`; + } + return rest; +} +function convertCallouts(content) { + const calloutRegex = /^(>\s*\[!([a-zA-Z]+)\](\+|-)?\s*(.*)?\n(?:>\s*.*\n?)*)/gm; + return content.replace( + calloutRegex, + (match, _full, type, _collapse, title) => { + const calloutType = type.toLowerCase(); + const isValidType = CALLOUT_TYPES.includes(calloutType); + const cssType = isValidType ? calloutType : "note"; + const icon = CALLOUT_ICONS[cssType] || "\u{1F4DD}"; + const displayTitle = (title || cssType).trim(); + const lines = match.split("\n"); + const bodyLines = lines.map((line) => line.replace(/^>\s?/, "")).filter((line, i) => { + if (i === 0) return false; + return true; + }); + const body = bodyLines.join("\n").trim(); + return `
+
+${icon} ${displayTitle} +
+
+ +${body} + +
+
`; + } + ); +} +function convertWikilinks(content, file, app) { + return content.replace( + /\[\[([^\]|]+?)(?:\|([^\]]*?))?\]\]/g, + (_match, target, alias) => { + const displayText = alias || target; + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(target)) { + return displayText; + } + const resolvedFile = app.metadataCache.getFirstLinkpathDest( + target, + file.path + ); + if (resolvedFile) { + const relativePath = getRelativePath(file.path, resolvedFile.path); + return `[${displayText}](${relativePath})`; + } + return `[${displayText}](${target}.md)`; + } + ); +} +async function convertEmbeds(content, file, app) { + const embedRegex = /!\[\[([^\]|]+?)(?:\|(\d+))?\]\]/g; + let result = content; + let match; + while ((match = embedRegex.exec(content)) !== null) { + const [fullMatch, src, size] = match; + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(src)) { + const absPath = resolveAttachmentPath(src, file, app); + const sizeAttr = size ? ` width="${size}"` : ""; + const replacement = `![${src}](${absPath})${sizeAttr ? "{width=" + size + "}" : ""}`; + result = result.replace(fullMatch, replacement); + } + } + return result; +} +async function inlineNoteEmbeds(content, currentFile, app, depth, maxDepth) { + if (depth >= maxDepth) return content; + const embedRegex = /!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\]/g; + let result = content; + let match; + const contentSnapshot = result; + while ((match = embedRegex.exec(contentSnapshot)) !== null) { + const [fullMatch, target] = match; + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(target)) { + continue; + } + const resolvedFile = app.metadataCache.getFirstLinkpathDest( + target, + currentFile.path + ); + if (resolvedFile instanceof import_obsidian2.TFile && resolvedFile.extension === "md") { + const embedContent = await app.vault.read(resolvedFile); + const processed = await inlineNoteEmbeds( + embedContent, + resolvedFile, + app, + depth + 1, + maxDepth + ); + result = result.replace( + fullMatch, + ` + + + +${processed} + + + +` + ); + } + } + return result; +} +function convertHighlights(content) { + return content.replace(/==([^=]+)==/g, "$1"); +} +function convertSupSub(content) { + let result = content.replace(/\^([^\^]+)\^/g, "$1"); + result = result.replace(/~~([^~]+)~~/g, "$1"); + return result; +} +function stripComments(content) { + return content.replace(/%%[\s\S]*?%%/g, ""); +} +function convertImageSizes(content) { + return content.replace( + /!\[([^\]]*?)\|(\d+)\]\(([^)]+)\)/g, + (_match, alt, size, url) => { + return `![${alt}](${url}){width=${size}}`; + } + ); +} +async function convertMermaidBlocks(content, mermaidPath, mermaidTheme, tmpDir, tempFiles) { + const mermaidRegex = /```mermaid\n([\s\S]*?)```/g; + let result = content; + let match; + let index = 0; + const contentSnapshot = content; + while ((match = mermaidRegex.exec(contentSnapshot)) !== null) { + const [fullMatch, code] = match; + index++; + try { + const svgPath = await renderMermaidBlock( + code.trim(), + mermaidTheme, + tmpDir, + index + ); + if (svgPath) { + tempFiles.push(svgPath); + result = result.replace( + fullMatch, + `![Mermaid Diagram ${index}](${svgPath})` + ); + } else { + result = result.replace( + fullMatch, + "```mermaid\n" + code + "```" + ); + } + } catch (e) { + } + } + return result; +} +function getRelativePath(from, to) { + const fromDir = from.substring(0, from.lastIndexOf("/")); + let relative = ""; + const fromParts = fromDir.split("/"); + const toParts = to.split("/"); + let commonLength = 0; + while (commonLength < fromParts.length && commonLength < toParts.length && fromParts[commonLength] === toParts[commonLength]) { + commonLength++; + } + for (let i = commonLength; i < fromParts.length; i++) { + relative += "../"; + } + for (let i = commonLength; i < toParts.length; i++) { + relative += toParts[i]; + if (i < toParts.length - 1) relative += "/"; + } + return relative || to; +} + +// src/pandoc.ts +var path3 = __toESM(require("path")); +var fs3 = __toESM(require("fs")); +var import_child_process2 = require("child_process"); +function buildPandocArgs(options) { + const { + inputPath, + outputPath, + format, + engine, + fontSize, + pageSize, + pageMargin, + codeTheme, + cjkFont, + enableCjk, + customCssPath, + customTemplatePath, + extraArgs + } = options; + const listingsHeaderPath = path3.join(options.tempDir, "obsidian-press-listings.tex"); + const args = [ + inputPath, + "-o", + outputPath, + "--from", + "markdown+fenced_code_blocks+fenced_code_attributes+backtick_code_blocks+pipe_tables+grid_tables+raw_html+tex_math_dollars", + "--to", + format === "pdf" ? "pdf" : format === "docx" ? "docx" : "html5", + "--standalone", + "--toc", + "--toc-depth=3", + `--highlight-style=${codeTheme}`, + "--resource-path", + path3.dirname(inputPath) + ]; + const latexBase = [ + "-V", + `geometry:margin=${pageMargin}mm`, + "-V", + `fontsize=${fontSize}pt`, + "-V", + `papersize=${pageSize.toLowerCase()}` + ]; + switch (engine) { + case "xelatex": + args.push( + "--pdf-engine=xelatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + ...getCjkArgs(engine, enableCjk, cjkFont), + "-V", + "colorlinks=true" + ); + break; + case "pdflatex": + args.push( + "--pdf-engine=pdflatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + "-V", + "colorlinks=true" + ); + break; + case "lualatex": + args.push( + "--pdf-engine=lualatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + ...getCjkArgs(engine, enableCjk, cjkFont), + "-V", + "colorlinks=true" + ); + break; + case "wkhtmltopdf": + args.push( + "--pdf-engine=wkhtmltopdf", + "-V", + `margin-top=${pageMargin}mm`, + "-V", + `margin-bottom=${pageMargin}mm`, + "-V", + `margin-left=${pageMargin}mm`, + "-V", + `margin-right=${pageMargin}mm` + ); + break; + case "weasyprint": + args.push("--pdf-engine=weasyprint", "-V", `margin=${pageMargin}mm`); + break; + case "typst": + args.push( + "--pdf-engine=typst", + "-V", + `font-size=${fontSize}pt`, + "-V", + `page-size=${pageSize.toLowerCase()}` + ); + break; + } + if ((engine === "wkhtmltopdf" || engine === "weasyprint") && customCssPath) { + args.push("--css", customCssPath); + } + if (customTemplatePath) { + args.push("--template", customTemplatePath); + } + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + return args; +} +function normalizeCjkFontName(fontName) { + const trimmed = fontName.trim(); + if (process.platform === "darwin" && trimmed === "PingFang SC") { + return "STHeitiSC-Medium"; + } + return trimmed; +} +function getCjkArgs(engine, enableCjk, cjkFont) { + if (!enableCjk) return []; + const resolved = normalizeCjkFontName(cjkFont); + if (resolved) { + return ["-V", `CJKmainfont=${resolved}`]; + } + if (process.platform === "darwin" && engine === "xelatex") { + return ["-V", "CJKmainfont=STHeitiSC-Medium"]; + } + return []; +} +async function exportWithPandoc(options) { + const startTime = Date.now(); + const pandocPath = options.pandocPath || "pandoc"; + if (!fs3.existsSync(options.inputPath)) { + return { + success: false, + error: `Input file not found: ${options.inputPath}`, + duration: Date.now() - startTime + }; + } + const outputDir = path3.dirname(options.outputPath); + if (!fs3.existsSync(outputDir)) { + fs3.mkdirSync(outputDir, { recursive: true }); + } + if (!fs3.existsSync(options.tempDir)) { + fs3.mkdirSync(options.tempDir, { recursive: true }); + } + writeListingsHeader(options.tempDir); + const texCacheDir = path3.join(options.tempDir, "tex-cache"); + if (!fs3.existsSync(texCacheDir)) { + fs3.mkdirSync(texCacheDir, { recursive: true }); + } + const args = buildPandocArgs(options); + return new Promise((resolve) => { + var _a, _b; + const child = (0, import_child_process2.spawn)(pandocPath, args, { + shell: false, + cwd: options.tempDir, + stdio: "pipe", + timeout: 12e4, + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + TMPDIR: options.tempDir, + TMP: options.tempDir, + TEMP: options.tempDir, + TEXMFVAR: texCacheDir, + TEXMFCACHE: texCacheDir, + LANG: process.env.LANG || "en_US.UTF-8", + LC_ALL: process.env.LC_ALL || "en_US.UTF-8" + } + }); + let stdout = ""; + let stderr = ""; + (_a = child.stdout) == null ? void 0 : _a.on("data", (data) => { + stdout += data.toString(); + }); + (_b = child.stderr) == null ? void 0 : _b.on("data", (data) => { + stderr += data.toString(); + }); + child.on("close", (code) => { + const duration = Date.now() - startTime; + if (code === 0) { + if (fs3.existsSync(options.outputPath)) { + const stats = fs3.statSync(options.outputPath); + if (stats.size > 0) { + resolve({ + success: true, + outputPath: options.outputPath, + duration + }); + return; + } + } + resolve({ + success: false, + error: "Output file was not created or is empty", + duration + }); + return; + } + const errorMsg = parsePandocError(stderr, code); + resolve({ success: false, error: errorMsg, duration }); + }); + child.on("error", (err) => { + resolve({ + success: false, + error: `Failed to run pandoc: ${err.message}`, + duration: Date.now() - startTime + }); + }); + }); +} +function writeListingsHeader(tempDir) { + const headerPath = path3.join(tempDir, "obsidian-press-listings.tex"); + const content = String.raw`\lstset{ + breaklines=true, + breakatwhitespace=false, + columns=fullflexible, + keepspaces=true, + basicstyle=\ttfamily\footnotesize, + frame=single, + framerule=0.3pt, + rulecolor=\color[HTML]{D0D7DE}, + backgroundcolor=\color[HTML]{F6F8FA}, + xleftmargin=0.5em, + xrightmargin=0.5em, + aboveskip=0.8em, + belowskip=0.8em +} +`; + fs3.writeFileSync(headerPath, content, "utf8"); +} +function parsePandocError(stderr, code) { + if (!stderr) return `Pandoc exited with code ${code}`; + const lines = stderr.split("\n").filter((line) => line.trim()); + const latexErrorIndex = lines.findIndex((line) => /^! /.test(line.trim())); + if (latexErrorIndex >= 0) { + return lines.slice(latexErrorIndex, latexErrorIndex + 5).join("\n").substring(0, 800); + } + const errorPatterns = [ + /Package .* Error/, + /LaTeX Error/, + /error:/i, + /not found/i, + /failed/i, + /cannot/i, + /unable/i, + /does not exist/i + ]; + for (const pattern of errorPatterns) { + const errorLine = lines.find((line) => pattern.test(line)); + if (errorLine) return errorLine.trim(); + } + return lines.slice(0, 3).join("; ").substring(0, 500); +} +async function checkPandocAvailable(pandocPath) { + return new Promise((resolve) => { + var _a; + const child = (0, import_child_process2.spawn)(pandocPath, ["--version"], { + shell: false, + stdio: "pipe", + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}` + } + }); + let stdout = ""; + (_a = child.stdout) == null ? void 0 : _a.on("data", (data) => { + stdout += data.toString(); + }); + child.on("close", (code) => { + if (code === 0) { + const versionMatch = stdout.match(/pandoc\s+(\d+\.\d+[\.\d]*)/); + resolve({ + available: true, + version: versionMatch ? versionMatch[1] : "unknown" + }); + } else { + resolve({ available: false }); + } + }); + child.on("error", () => { + resolve({ available: false }); + }); + }); +} + +// src/exporter.ts +async function exportFile(file, app, settings) { + const vaultPath = getVaultPath(app); + const tmpDir = getTmpDir(vaultPath); + try { + const content = await app.vault.read(file); + const effectivePdfEngine = settings.defaultFormat === "pdf" && settings.pdfEngine === "pdflatex" && containsCjk(content) ? "xelatex" : settings.pdfEngine; + const format = settings.defaultFormat; + const outputPath = getOutputPath( + file, + vaultPath, + settings.outputDir, + settings.outputNaming, + format + ); + const rendered = await renderToPandoc( + content, + file, + app, + settings.mermaidPath, + settings.mermaidTheme + ); + if (settings.defaultFormat === "pdf") { + rendered.content = normalizeRemoteImageUrls(rendered.content); + } + if (settings.defaultFormat === "pdf" && effectivePdfEngine === "typst") { + rendered.content = await localizeImagesForTypst( + rendered.content, + file, + vaultPath, + tmpDir, + rendered.tempFiles + ); + } + if (settings.defaultFormat === "pdf" && isLatexEngine(effectivePdfEngine)) { + rendered.content = await convertWebpImagesForLatex( + rendered.content, + file, + vaultPath, + tmpDir, + rendered.tempFiles + ); + } + const tempMdPath = path4.join( + tmpDir, + `press-${Date.now()}-${path4.basename(file.path)}` + ); + fs4.writeFileSync(tempMdPath, rendered.content, "utf8"); + const pandocOptions = { + inputPath: tempMdPath, + outputPath, + format, + engine: effectivePdfEngine, + pandocPath: settings.pandocPath, + tempDir: tmpDir, + fontSize: settings.fontSize, + pageSize: settings.pageSize, + pageMargin: settings.pageMargin, + codeTheme: settings.codeTheme, + cjkFont: settings.cjkFont, + enableCjk: settings.enableCjk, + customCssPath: settings.customCssPath || void 0, + customTemplatePath: settings.customTemplatePath || void 0, + extraArgs: settings.extraArgs ? settings.extraArgs.split(/\s+/).filter(Boolean) : [] + }; + const result = await exportWithPandoc(pandocOptions); + try { + fs4.unlinkSync(tempMdPath); + } catch (e) { + } + for (const tempFile of rendered.tempFiles) { + try { + if (fs4.existsSync(tempFile)) { + fs4.unlinkSync(tempFile); + } + } catch (e) { + } + } + return result; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + duration: 0 + }; + } +} +async function exportFolder(folder, app, settings, onProgress) { + const startTime = Date.now(); + const mdFiles = []; + collectMarkdownFiles(folder, mdFiles); + if (mdFiles.length === 0) { + return { + total: 0, + success: 0, + failed: 0, + errors: [], + outputDir: "", + duration: Date.now() - startTime + }; + } + const result = await exportBatch(mdFiles, app, settings, onProgress); + return { + ...result, + duration: Date.now() - startTime + }; +} +async function exportVault(app, settings, onProgress) { + const startTime = Date.now(); + const mdFiles = app.vault.getMarkdownFiles(); + if (mdFiles.length === 0) { + return { + total: 0, + success: 0, + failed: 0, + errors: [], + outputDir: "", + duration: Date.now() - startTime + }; + } + const result = await exportBatch(mdFiles, app, settings, onProgress); + return { + ...result, + duration: Date.now() - startTime + }; +} +async function exportBatch(files, app, settings, onProgress) { + const total = files.length; + let success = 0; + let failed = 0; + const errors = []; + const semaphore = createSemaphore(settings.concurrency); + let done = 0; + const promises = files.map(async (file) => { + await semaphore.acquire(); + try { + const result = await exportFile(file, app, settings); + done++; + onProgress == null ? void 0 : onProgress(done, total, file.name); + if (result.success) { + success++; + } else { + failed++; + errors.push({ + file: file.path, + error: result.error || "Unknown error" + }); + if (!settings.skipErrors) { + throw new Error(`Export failed for ${file.path}: ${result.error}`); + } + } + } catch (err) { + failed++; + errors.push({ + file: file.path, + error: err instanceof Error ? err.message : String(err) + }); + if (!settings.skipErrors) { + throw err; + } + } finally { + semaphore.release(); + } + }); + if (settings.skipErrors) { + await Promise.allSettled(promises); + } else { + await Promise.all(promises); + } + const vaultPath = getVaultPath(app); + const outputDir = path4.isAbsolute(settings.outputDir) ? settings.outputDir : path4.join(vaultPath, settings.outputDir || "pdf"); + return { + total, + success, + failed, + errors, + outputDir + }; +} +function collectMarkdownFiles(folder, files) { + for (const child of folder.children) { + if (child instanceof import_obsidian3.TFile && child.extension === "md") { + files.push(child); + } else if (child instanceof import_obsidian3.TFolder) { + collectMarkdownFiles(child, files); + } + } +} +function isLatexEngine(engine) { + return engine === "xelatex" || engine === "pdflatex" || engine === "lualatex"; +} +function containsCjk(content) { + return /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/u.test( + content + ); +} +function normalizeRemoteImageUrls(content) { + return content.replace( + /(https?:\/\/[^\s)"'>]+[?&])tp=webp(&?)/gi, + (_match, prefix, suffix) => { + if (suffix) return prefix; + return prefix.endsWith("?") ? prefix.slice(0, -1) : prefix.slice(0, -1); + } + ); +} +async function localizeImagesForTypst(content, file, vaultPath, tmpDir, tempFiles) { + const references = collectImageReferences(content); + const replacements = []; + const localizedBySource = /* @__PURE__ */ new Map(); + let index = 0; + for (const rawPath of references) { + const normalizedPath = stripMarkdownUrlDelimiters(rawPath); + if (/^data:/i.test(normalizedPath)) { + continue; + } + const sourceKey = normalizedPath; + let localPath = localizedBySource.get(sourceKey); + if (!localPath) { + localPath = /^https?:/i.test(normalizedPath) ? await downloadRemoteImageForTypst(normalizedPath, tmpDir, index++) : await copyLocalImageForTypst( + normalizedPath, + file, + vaultPath, + tmpDir, + index++ + ); + if (!localPath) { + continue; + } + localizedBySource.set(sourceKey, localPath); + tempFiles.push(localPath); + } + replacements.push({ + original: rawPath, + converted: `<./${path4.basename(localPath)}>` + }); + } + let result = content; + for (const replacement of replacements) { + result = result.split(replacement.original).join(replacement.converted); + } + return result; +} +async function convertWebpImagesForLatex(content, file, vaultPath, tmpDir, tempFiles) { + const references = collectImageReferences(content); + const replacements = []; + const convertedBySource = /* @__PURE__ */ new Map(); + let index = 0; + for (const rawPath of references) { + const normalizedPath = stripMarkdownUrlDelimiters(rawPath); + if (/^data:/i.test(normalizedPath)) { + continue; + } + const resolvedPath = /^https?:/i.test(normalizedPath) ? await downloadRemoteWebpIfNeeded(normalizedPath, tmpDir, index) : isWebpReference(normalizedPath) ? resolveWebpReference(normalizedPath, file, vaultPath) : null; + if (!resolvedPath) { + continue; + } + if (resolvedPath.startsWith(path4.join(tmpDir, "remote-webp-"))) { + tempFiles.push(resolvedPath); + } + let outputPath = convertedBySource.get(resolvedPath); + if (!outputPath) { + outputPath = path4.join( + tmpDir, + `webp-${Date.now()}-${index++}.png` + ); + await convertWebpToPng(resolvedPath, outputPath); + convertedBySource.set(resolvedPath, outputPath); + tempFiles.push(outputPath); + } + replacements.push({ + original: rawPath, + converted: `<${outputPath}>` + }); + } + let result = content; + for (const replacement of replacements) { + result = result.split(replacement.original).join(replacement.converted); + } + return result; +} +function collectImageReferences(content) { + const references = /* @__PURE__ */ new Set(); + const markdownImageRegex = /!\[[^\]]*\]\(\s*(<[^>]+>|[^)]+?)(?:\s+["'][^"']*["'])?\s*\)(?:\{[^}]*\})?/gi; + const htmlImageRegex = /]*\bsrc=["']([^"']+)["'][^>]*>/gi; + const remoteImageUrlRegex = /https?:\/\/[^\s<>"']+/gi; + let match; + while ((match = markdownImageRegex.exec(content)) !== null) { + const reference = match[1].trim(); + references.add(reference); + } + while ((match = htmlImageRegex.exec(content)) !== null) { + const reference = match[1].trim(); + references.add(reference); + } + while ((match = remoteImageUrlRegex.exec(content)) !== null) { + const reference = trimUrlDelimiters(match[0]); + if (isLikelyImageUrl(reference)) { + references.add(reference); + } + } + return Array.from(references); +} +function trimUrlDelimiters(value) { + return value.replace(/[)\]}|.,;:]+$/g, ""); +} +function isLikelyImageUrl(value) { + const url = stripMarkdownUrlDelimiters(value); + if (/\.(png|jpe?g|gif|svg|webp|bmp|ico)(?:[?#].*)?$/i.test(url)) { + return true; + } + try { + const parsed = new URL(url); + const pathname = parsed.pathname.toLowerCase(); + if (/\.(png|jpe?g|gif|svg|webp|bmp|ico)$/.test(pathname)) { + return true; + } + return Boolean( + parsed.searchParams.get("wx_fmt") || parsed.searchParams.get("tp") === "webp" + ); + } catch (e) { + return false; + } +} +function isWebpReference(reference) { + return /\.webp(?:[?#].*)?$/i.test(stripMarkdownUrlDelimiters(reference)); +} +function resolveWebpReference(rawPath, file, vaultPath) { + return resolveImageReference(rawPath, file, vaultPath); +} +function resolveImageReference(rawPath, file, vaultPath) { + const sourcePath = stripMarkdownUrlDelimiters(rawPath); + const decodedPath = stripUrlQueryAndHash(safeDecodeUri(sourcePath)); + const candidates = path4.isAbsolute(decodedPath) ? [decodedPath] : [ + path4.join(vaultPath, path4.dirname(file.path), decodedPath), + path4.join(vaultPath, decodedPath) + ]; + for (const candidate of candidates) { + if (fs4.existsSync(candidate)) { + return candidate; + } + } + return null; +} +async function downloadRemoteWebpIfNeeded(rawUrl, tmpDir, index) { + const url = stripMarkdownUrlDelimiters(rawUrl); + const isUrlWebp = isWebpReference(url); + let image; + try { + image = await downloadRemoteImage(url); + } catch (err) { + console.warn( + "Obsidian Press: could not inspect remote image before export:", + url, + err + ); + return null; + } + if (!isUrlWebp && !image.contentType.includes("image/webp") && !isWebpBuffer(image.data)) { + return null; + } + const outputPath = path4.join( + tmpDir, + `remote-webp-${Date.now()}-${index}.webp` + ); + fs4.writeFileSync(outputPath, image.data); + return outputPath; +} +async function downloadRemoteImageForTypst(url, tmpDir, index) { + try { + const image = await downloadRemoteImage(url); + const extension = getImageExtension(url, image.contentType, image.data); + const downloadedPath = path4.join( + tmpDir, + `remote-image-${Date.now()}-${index}.${extension}` + ); + fs4.writeFileSync(downloadedPath, image.data); + if (extension === "webp" || isWebpBuffer(image.data)) { + const pngPath = path4.join( + tmpDir, + `remote-image-${Date.now()}-${index}.png` + ); + await convertWebpToPng(downloadedPath, pngPath); + return pngPath; + } + return downloadedPath; + } catch (err) { + console.warn("Obsidian Press: could not download remote image:", url, err); + return createMissingImagePlaceholder(tmpDir, index); + } +} +async function copyLocalImageForTypst(rawPath, file, vaultPath, tmpDir, index) { + const resolvedPath = resolveImageReference(rawPath, file, vaultPath); + if (!resolvedPath) { + return null; + } + if (isWebpReference(resolvedPath)) { + const outputPath2 = path4.join( + tmpDir, + `typst-image-${Date.now()}-${index}.png` + ); + await convertWebpToPng(resolvedPath, outputPath2); + return outputPath2; + } + const extension = getImageExtensionFromPath(resolvedPath); + const outputPath = path4.join( + tmpDir, + `typst-image-${Date.now()}-${index}.${extension}` + ); + fs4.copyFileSync(resolvedPath, outputPath); + return outputPath; +} +async function downloadRemoteImage(url) { + const response = await (0, import_obsidian3.requestUrl)({ + url, + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + } + }); + const contentTypeHeader = response.headers["content-type"] || response.headers["Content-Type"] || ""; + return { + data: Buffer.from(response.arrayBuffer), + contentType: contentTypeHeader.toLowerCase() + }; +} +function getImageExtension(url, contentType, data) { + if (isWebpBuffer(data) || contentType.includes("image/webp")) return "webp"; + if (isPngBuffer(data) || contentType.includes("image/png")) return "png"; + if (isJpegBuffer(data) || contentType.includes("image/jpeg")) return "jpg"; + if (isGifBuffer(data) || contentType.includes("image/gif")) return "gif"; + if (isSvgBuffer(data) || contentType.includes("image/svg")) return "svg"; + const formatFromQuery = getImageFormatFromQuery(url); + if (formatFromQuery) return formatFromQuery; + const pathname = safeUrlPathname(url); + const ext = path4.extname(pathname).replace(".", "").toLowerCase(); + if (["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) { + return ext === "jpeg" ? "jpg" : ext; + } + return "png"; +} +function getImageExtensionFromPath(filePath) { + const ext = path4.extname(stripUrlQueryAndHash(filePath)).replace(".", "").toLowerCase(); + if (ext === "jpeg") return "jpg"; + if (["png", "jpg", "gif", "svg", "webp"].includes(ext)) return ext; + return "png"; +} +function getImageFormatFromQuery(url) { + try { + const parsed = new URL(url); + const value = (parsed.searchParams.get("wx_fmt") || parsed.searchParams.get("format") || "").toLowerCase(); + if (value === "jpeg") return "jpg"; + if (["png", "jpg", "gif", "svg", "webp"].includes(value)) return value; + } catch (e) { + } + return null; +} +function safeUrlPathname(url) { + try { + return new URL(url).pathname; + } catch (e) { + return url; + } +} +function isWebpBuffer(data) { + return data.length >= 12 && data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP"; +} +function isPngBuffer(data) { + return data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71; +} +function isJpegBuffer(data) { + return data.length >= 3 && data[0] === 255 && data[1] === 216; +} +function isGifBuffer(data) { + return data.length >= 6 && (data.toString("ascii", 0, 6) === "GIF87a" || data.toString("ascii", 0, 6) === "GIF89a"); +} +function isSvgBuffer(data) { + return data.toString("utf8", 0, Math.min(data.length, 256)).includes(" + + Remote image unavailable +`, + "utf8" + ); + return outputPath; +} +function stripMarkdownUrlDelimiters(value) { + if (value.startsWith("<") && value.endsWith(">")) { + return value.slice(1, -1); + } + return value; +} +function safeDecodeUri(value) { + try { + return decodeURI(value); + } catch (e) { + return value; + } +} +function stripUrlQueryAndHash(value) { + return value.replace(/[?#].*$/, ""); +} +function convertWebpToPng(inputPath, outputPath) { + return new Promise((resolve, reject) => { + var _a; + const child = (0, import_child_process3.spawn)("dwebp", [inputPath, "-o", outputPath], { + shell: false, + stdio: "pipe", + env: { + ...process.env, + PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}` + } + }); + let stderr = ""; + (_a = child.stderr) == null ? void 0 : _a.on("data", (data) => { + stderr += data.toString(); + }); + child.on("close", (code) => { + if (code === 0 && fs4.existsSync(outputPath)) { + resolve(); + } else { + reject( + new Error( + `Failed to convert WebP image for LaTeX: ${stderr.trim() || `dwebp exited with code ${code}`}` + ) + ); + } + }); + child.on("error", (err) => { + reject( + new Error( + `Failed to run dwebp for WebP image conversion: ${err.message}` + ) + ); + }); + }); +} +async function preflightChecks(settings) { + const errors = []; + const pandocCheck = await checkPandocAvailable(settings.pandocPath); + if (!pandocCheck.available) { + errors.push( + `Pandoc not found at "${settings.pandocPath}". Install: brew install pandoc` + ); + } + if (settings.pdfEngine === "wkhtmltopdf") { + const exists = await checkCommandExists("wkhtmltopdf"); + if (!exists) { + errors.push( + "wkhtmltopdf not found. Install: brew install wkhtmltopdf" + ); + } + } else if (settings.pdfEngine === "weasyprint") { + const exists = await checkCommandExists("weasyprint"); + if (!exists) { + errors.push( + "WeasyPrint not found. Install: pip install weasyprint" + ); + } + } else if (settings.pdfEngine === "xelatex" || settings.pdfEngine === "pdflatex" || settings.pdfEngine === "lualatex") { + const exists = await checkCommandExists(settings.pdfEngine); + if (!exists) { + errors.push( + `${settings.pdfEngine} not found. Install: brew install --cask mactex-no-gui` + ); + } + } + const mermaidExists = await checkCommandExists( + settings.mermaidPath || "mmdc" + ); + if (!mermaidExists) { + console.warn( + "Obsidian Press: mmdc not found. Mermaid diagrams will be exported as code blocks." + ); + } + return { + ok: errors.length === 0, + errors + }; +} + +// src/main.ts +var ExportProgressNotice = class { + constructor(title, detail = "") { + this.notice = new import_obsidian4.Notice("", 0); + this.notice.noticeEl.empty(); + this.notice.noticeEl.addClass("obsidian-press-progress-notice"); + this.titleEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-title", + text: title + }); + this.detailEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-detail", + text: detail + }); + const barEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-bar" + }); + this.fillEl = barEl.createDiv({ + cls: "obsidian-press-progress-bar-fill" + }); + this.setProgress(0); + } + update(title, detail, progress) { + this.titleEl.setText(title); + this.detailEl.setText(detail); + this.setProgress(progress); + } + hide() { + this.notice.hide(); + } + setProgress(progress) { + const normalized = Math.max(0, Math.min(100, progress)); + this.fillEl.style.width = `${normalized}%`; + } +}; +var ObsidianPressPlugin = class extends import_obsidian4.Plugin { + async onload() { + await this.loadSettings(); + this.addRibbonIcon("file-output", "Obsidian Press: Export current note", () => { + this.exportCurrentNote(); + }); + this.addCommand({ + id: "export-current-note-pdf", + name: "Export current note to PDF", + callback: () => this.exportCurrentNote() + }); + this.addCommand({ + id: "export-current-note-pdf-choose-folder", + name: "Export current note to PDF...", + callback: () => this.exportCurrentNoteWithDirectory("pdf") + }); + this.addCommand({ + id: "export-current-note-docx", + name: "Export current note to Word (DOCX)", + callback: () => this.exportCurrentNote("docx") + }); + this.addCommand({ + id: "export-current-note-docx-choose-folder", + name: "Export current note to Word (DOCX)...", + callback: () => this.exportCurrentNoteWithDirectory("docx") + }); + this.addCommand({ + id: "export-current-note-html", + name: "Export current note to HTML", + callback: () => this.exportCurrentNote("html") + }); + this.addCommand({ + id: "export-current-note-html-choose-folder", + name: "Export current note to HTML...", + callback: () => this.exportCurrentNoteWithDirectory("html") + }); + this.addCommand({ + id: "export-folder", + name: "Export all notes in current folder", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (file) { + const folder = file.parent; + if (folder && folder instanceof import_obsidian4.TFolder) { + if (!checking) { + this.exportFolder(folder); + } + return true; + } + } + return false; + } + }); + this.addCommand({ + id: "export-folder-choose-folder", + name: "Export all notes in current folder...", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (file) { + const folder = file.parent; + if (folder && folder instanceof import_obsidian4.TFolder) { + if (!checking) { + this.exportFolderWithDirectory(folder); + } + return true; + } + } + return false; + } + }); + this.addCommand({ + id: "export-vault", + name: "Export entire vault", + callback: () => this.exportEntireVault() + }); + this.addCommand({ + id: "export-vault-choose-folder", + name: "Export entire vault...", + callback: () => this.exportEntireVaultWithDirectory() + }); + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file) => { + if (file instanceof import_obsidian4.TFile && file.extension === "md") { + menu.addItem((item) => { + item.setTitle("\u5BFC\u51FA\u4E3A PDF\uFF08Obsidian Press\uFF09").setIcon("file-output").onClick(() => this.exportSpecificFile(file)); + }); + menu.addItem((item) => { + item.setTitle("\u9009\u62E9\u76EE\u5F55\u5BFC\u51FA\u4E3A PDF\uFF08Obsidian Press\uFF09").setIcon("folder-open").onClick(() => this.exportSpecificFileWithDirectory(file, "pdf")); + }); + } + if (file instanceof import_obsidian4.TFolder) { + menu.addItem((item) => { + item.setTitle("\u5168\u90E8\u5BFC\u51FA\u4E3A PDF\uFF08Obsidian Press\uFF09").setIcon("file-output").onClick(() => this.exportFolder(file)); + }); + menu.addItem((item) => { + item.setTitle("\u9009\u62E9\u76EE\u5F55\u5168\u90E8\u5BFC\u51FA\u4E3A PDF\uFF08Obsidian Press\uFF09").setIcon("folder-open").onClick(() => this.exportFolderWithDirectory(file)); + }); + } + }) + ); + this.addSettingTab(new ObsidianPressSettingTab(this.app, this)); + } + onunload() { + } + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + if (this.settings.pdfEngine === "chromium") { + this.settings.pdfEngine = "xelatex"; + await this.saveSettings(); + } + if (this.settings.codeTheme === "pygments") { + this.settings.codeTheme = "tango"; + await this.saveSettings(); + } + } + async saveSettings() { + await this.saveData(this.settings); + } + // === Export actions === + async exportCurrentNote(forceFormat) { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") { + new import_obsidian4.Notice("No active Markdown file"); + return; + } + const format = forceFormat || this.settings.defaultFormat; + const settings = { ...this.settings, defaultFormat: format }; + await this.exportSpecificFile(file, settings); + } + async exportCurrentNoteWithDirectory(forceFormat) { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") { + new import_obsidian4.Notice("No active Markdown file"); + return; + } + await this.exportSpecificFileWithDirectory( + file, + forceFormat || this.settings.defaultFormat + ); + } + async exportSpecificFileWithDirectory(file, format) { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + await this.exportSpecificFile(file, { + ...this.settings, + defaultFormat: format, + outputDir + }); + } + async exportSpecificFile(file, overrideSettings) { + const settings = overrideSettings || this.settings; + const check = await preflightChecks(settings); + if (!check.ok) { + new import_obsidian4.Notice(`Export failed: +${check.errors.join("\n")}`, 8e3); + return; + } + const formatLabel = settings.defaultFormat.toUpperCase(); + const progress = new ExportProgressNotice( + `\u6B63\u5728\u5BFC\u51FA ${formatLabel}`, + file.name + ); + try { + progress.update(`\u6B63\u5728\u5BFC\u51FA ${formatLabel}`, "\u51C6\u5907\u6587\u4EF6...", 20); + const result = await exportFile(file, this.app, settings); + progress.update(`\u6B63\u5728\u5BFC\u51FA ${formatLabel}`, "\u5199\u5165\u5BFC\u51FA\u6587\u4EF6...", 90); + progress.hide(); + if (result.success) { + new import_obsidian4.Notice( + `Exported: ${result.outputPath} +(${(result.duration / 1e3).toFixed(1)}s)` + ); + if (settings.openAfterExport && result.outputPath) { + window.open(`file://${result.outputPath}`); + } + } else { + new import_obsidian4.Notice(`Export failed: ${result.error}`, 8e3); + } + } catch (err) { + progress.hide(); + new import_obsidian4.Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8e3 + ); + } + } + async exportFolder(folder, overrideSettings) { + const settings = overrideSettings || this.settings; + const check = await preflightChecks(settings); + if (!check.ok) { + new import_obsidian4.Notice(`Export failed: +${check.errors.join("\n")}`, 8e3); + return; + } + const progress = new ExportProgressNotice( + "\u6B63\u5728\u5BFC\u51FA\u6587\u4EF6\u5939", + folder.name + ); + try { + const result = await exportFolder( + folder, + this.app, + settings, + (done, total, current) => { + progress.update( + "\u6B63\u5728\u5BFC\u51FA\u6587\u4EF6\u5939", + `${done}/${total} \xB7 ${current}`, + total > 0 ? done / total * 100 : 0 + ); + } + ); + progress.hide(); + const summary = [ + `Export complete`, + `Total: ${result.total}`, + `Success: ${result.success}`, + `Failed: ${result.failed}`, + `Output: ${result.outputDir}`, + `Time: ${(result.duration / 1e3).toFixed(1)}s` + ].join("\n"); + new import_obsidian4.Notice(summary, 1e4); + if (result.failed > 0 && result.errors.length > 0) { + console.error("Obsidian Press export errors:", result.errors); + } + } catch (err) { + progress.hide(); + new import_obsidian4.Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8e3 + ); + } + } + async exportFolderWithDirectory(folder) { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + await this.exportFolder(folder, { + ...this.settings, + outputDir + }); + } + async exportEntireVault(overrideSettings) { + const settings = overrideSettings || this.settings; + const check = await preflightChecks(settings); + if (!check.ok) { + new import_obsidian4.Notice(`Export failed: +${check.errors.join("\n")}`, 8e3); + return; + } + const progress = new ExportProgressNotice( + "\u6B63\u5728\u5BFC\u51FA\u6574\u4E2A\u4ED3\u5E93", + "\u626B\u63CF Markdown \u6587\u4EF6..." + ); + try { + const result = await exportVault( + this.app, + settings, + (done, total, current) => { + progress.update( + "\u6B63\u5728\u5BFC\u51FA\u6574\u4E2A\u4ED3\u5E93", + `${done}/${total} \xB7 ${current}`, + total > 0 ? done / total * 100 : 0 + ); + } + ); + progress.hide(); + const summary = [ + `Vault export complete`, + `Total: ${result.total}`, + `Success: ${result.success}`, + `Failed: ${result.failed}`, + `Output: ${result.outputDir}`, + `Time: ${(result.duration / 1e3).toFixed(1)}s` + ].join("\n"); + new import_obsidian4.Notice(summary, 1e4); + if (result.failed > 0 && result.errors.length > 0) { + console.error("Obsidian Press export errors:", result.errors); + } + } catch (err) { + progress.hide(); + new import_obsidian4.Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8e3 + ); + } + } + async exportEntireVaultWithDirectory() { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + await this.exportEntireVault({ + ...this.settings, + outputDir + }); + } + getConfiguredOutputDirectory() { + const vaultPath = getVaultPath(this.app); + const outputDir = this.settings.outputDir || "pdf"; + return path5.isAbsolute(outputDir) ? outputDir : path5.join(vaultPath, outputDir); + } + async chooseOutputDirectory() { + var _a, _b; + if (!import_obsidian4.Platform.isDesktopApp) { + new import_obsidian4.Notice("Folder selection is only available in the desktop app", 8e3); + return null; + } + try { + const electron = require("electron"); + const dialog = ((_a = electron.remote) == null ? void 0 : _a.dialog) || electron.dialog; + if (!(dialog == null ? void 0 : dialog.showOpenDialog)) { + new import_obsidian4.Notice("Folder selection is not available in this Obsidian window", 8e3); + return null; + } + const result = await dialog.showOpenDialog({ + title: "Choose export folder", + defaultPath: this.getConfiguredOutputDirectory(), + properties: ["openDirectory", "createDirectory"] + }); + if (result.canceled || !((_b = result.filePaths) == null ? void 0 : _b[0])) { + new import_obsidian4.Notice("Export canceled"); + return null; + } + return result.filePaths[0]; + } catch (err) { + new import_obsidian4.Notice( + `Could not open folder picker: ${err instanceof Error ? err.message : String(err)}`, + 8e3 + ); + return null; + } + } +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0e3a8b2 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "obsidian-press", + "name": "Obsidian Press", + "version": "1.0.0", + "description": "High-fidelity PDF export powered by Pandoc. Supports multiple engines (XeLaTeX, wkhtmltopdf, WeasyPrint), Mermaid diagrams, custom CSS/templates, and batch export.", + "minAppVersion": "0.15.0", + "author": "Obsidian Press Contributors", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5e2f6ef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,611 @@ +{ + "name": "obsidian-press", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-press", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.21.0", + "obsidian": "latest", + "tslib": "^2.6.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ef8ce60 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "obsidian-press", + "version": "1.0.0", + "description": "High-fidelity PDF export plugin for Obsidian, powered by Pandoc", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "typecheck": "tsc --noEmit", + "build": "npm run typecheck && node esbuild.config.mjs production", + "verify:code-export": "node scripts/verify-code-export.mjs", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": ["obsidian", "pandoc", "pdf", "docx", "html", "export", "latex", "typst", "mermaid"], + "license": "MIT", + "files": [ + "main.js", + "manifest.json", + "styles.css", + "README.md", + "LICENSE" + ], + "devDependencies": { + "@types/node": "^20.0.0", + "builtin-modules": "^4.0.0", + "esbuild": "^0.21.0", + "obsidian": "latest", + "tslib": "^2.6.0", + "typescript": "^5.4.0" + } +} diff --git a/scripts/verify-code-export.mjs b/scripts/verify-code-export.mjs new file mode 100644 index 0000000..344f4c0 --- /dev/null +++ b/scripts/verify-code-export.mjs @@ -0,0 +1,127 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const root = path.resolve(import.meta.dirname, ".."); +const outDir = path.join(os.tmpdir(), `obsidian-press-code-export-${Date.now()}`); +const inputPath = path.join(outDir, "code-format.md"); +const pdfPath = path.join(outDir, "code-format.pdf"); +const listingsHeaderPath = path.join(outDir, "listings-wrap.tex"); +const pngPrefix = path.join(outDir, "code-format-page"); +const pngPath = `${pngPrefix}.png`; + +const env = { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + TMPDIR: outDir, + TMP: outDir, + TEMP: outDir, + LANG: process.env.LANG || "en_US.UTF-8", + LC_ALL: process.env.LC_ALL || "en_US.UTF-8", +}; + +const input = `# Code export verification + +The fenced block below must keep Obsidian-like syntax untouched. + +\`\`\`ts +const marker = "[[wikilink]] ==highlight== %%comment%%"; +const longLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +function add(a: number, b: number) { + return a + b; +} +\`\`\` + +Inline \`const inline = "==keep=="\` should remain inline code. +`; + +await mkdir(outDir, { recursive: true }); +await writeFile(inputPath, input, "utf8"); +await writeFile( + listingsHeaderPath, + String.raw`\lstset{ + breaklines=true, + breakatwhitespace=false, + columns=fullflexible, + keepspaces=true, + basicstyle=\ttfamily\footnotesize, + frame=single, + backgroundcolor=\color[HTML]{F6F8FA} +} +`, + "utf8" +); + +await execFileAsync( + "pandoc", + [ + inputPath, + "-o", + pdfPath, + "--from", + "markdown+fenced_code_blocks+fenced_code_attributes+backtick_code_blocks+pipe_tables+grid_tables+raw_html+tex_math_dollars", + "--to", + "pdf", + "--standalone", + "--highlight-style=tango", + "--listings", + "-H", + listingsHeaderPath, + "--pdf-engine=xelatex", + "-V", + "geometry:margin=25mm", + "-V", + "fontsize=11pt", + "-V", + "papersize=a4", + "-V", + "colorlinks=true", + ], + { cwd: root, env } +); + +const { stdout: extractedText } = await execFileAsync( + "pdftotext", + [pdfPath, "-"], + { cwd: root, env } +); + +for (const expected of [ + "[[wikilink]] ==highlight== %%comment%%", + "const longLine =", + "function add(a: number, b: number)", + "const inline = \"==keep==\"", +]) { + if (!extractedText.includes(expected)) { + throw new Error(`PDF text is missing expected code content: ${expected}`); + } +} + +await execFileAsync("pdftoppm", ["-png", "-singlefile", pdfPath, pngPrefix], { + cwd: root, + env, +}); + +const [pdfStats, pngStats] = await Promise.all([stat(pdfPath), stat(pngPath)]); +if (pdfStats.size <= 0 || pngStats.size <= 0) { + throw new Error("PDF or rendered PNG is empty"); +} + +const pngHeader = await readFile(pngPath, { encoding: null }); +if ( + pngHeader.length < 8 || + pngHeader[0] !== 0x89 || + pngHeader[1] !== 0x50 || + pngHeader[2] !== 0x4e || + pngHeader[3] !== 0x47 +) { + throw new Error("Rendered PDF preview is not a PNG file"); +} + +console.log("Code export verification passed"); +console.log(`PDF: ${pdfPath}`); +console.log(`Preview: ${pngPath}`); diff --git a/src/exporter.ts b/src/exporter.ts new file mode 100644 index 0000000..81a987a --- /dev/null +++ b/src/exporter.ts @@ -0,0 +1,848 @@ +import { App, TFile, TFolder, Notice, Vault, requestUrl } from "obsidian"; +import * as path from "path"; +import * as fs from "fs"; +import { spawn } from "child_process"; +import { PluginSettings, ExportResult, BatchResult, PandocOptions } from "./types"; +import { renderToPandoc } from "./renderer"; +import { exportWithPandoc, checkPandocAvailable } from "./pandoc"; +import { + getVaultPath, + getOutputPath, + getTmpDir, + cleanTmpDir, + createSemaphore, + checkCommandExists, +} from "./utils"; + +// === Single file export === + +export async function exportFile( + file: TFile, + app: App, + settings: PluginSettings +): Promise { + const vaultPath = getVaultPath(app); + const tmpDir = getTmpDir(vaultPath); + + try { + // Read file content + const content = await app.vault.read(file); + const effectivePdfEngine = + settings.defaultFormat === "pdf" && + settings.pdfEngine === "pdflatex" && + containsCjk(content) + ? "xelatex" + : settings.pdfEngine; + + // Determine output path + const format = settings.defaultFormat; + const outputPath = getOutputPath( + file, + vaultPath, + settings.outputDir, + settings.outputNaming, + format + ); + + // Pre-process Obsidian markdown + const rendered = await renderToPandoc( + content, + file, + app, + settings.mermaidPath, + settings.mermaidTheme + ); + + if (settings.defaultFormat === "pdf") { + rendered.content = normalizeRemoteImageUrls(rendered.content); + } + + if (settings.defaultFormat === "pdf" && effectivePdfEngine === "typst") { + rendered.content = await localizeImagesForTypst( + rendered.content, + file, + vaultPath, + tmpDir, + rendered.tempFiles + ); + } + + if (settings.defaultFormat === "pdf" && isLatexEngine(effectivePdfEngine)) { + rendered.content = await convertWebpImagesForLatex( + rendered.content, + file, + vaultPath, + tmpDir, + rendered.tempFiles + ); + } + + // Write processed content to temp file + const tempMdPath = path.join( + tmpDir, + `press-${Date.now()}-${path.basename(file.path)}` + ); + fs.writeFileSync(tempMdPath, rendered.content, "utf8"); + + // Build Pandoc options + const pandocOptions: PandocOptions = { + inputPath: tempMdPath, + outputPath, + format: format as any, + engine: effectivePdfEngine, + pandocPath: settings.pandocPath, + tempDir: tmpDir, + fontSize: settings.fontSize, + pageSize: settings.pageSize, + pageMargin: settings.pageMargin, + codeTheme: settings.codeTheme, + cjkFont: settings.cjkFont, + enableCjk: settings.enableCjk, + customCssPath: settings.customCssPath || undefined, + customTemplatePath: settings.customTemplatePath || undefined, + extraArgs: settings.extraArgs + ? settings.extraArgs.split(/\s+/).filter(Boolean) + : [], + }; + + // Run Pandoc + const result = await exportWithPandoc(pandocOptions); + + // Clean up temp files + try { + fs.unlinkSync(tempMdPath); + } catch { + // Ignore + } + + // Clean up mermaid SVG files + for (const tempFile of rendered.tempFiles) { + try { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } catch { + // Ignore + } + } + + return result; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + duration: 0, + }; + } +} + +// === Folder batch export === + +export async function exportFolder( + folder: TFolder, + app: App, + settings: PluginSettings, + onProgress?: (done: number, total: number, current: string) => void +): Promise { + const startTime = Date.now(); + + // Collect all .md files in the folder + const mdFiles: TFile[] = []; + collectMarkdownFiles(folder, mdFiles); + + if (mdFiles.length === 0) { + return { + total: 0, + success: 0, + failed: 0, + errors: [], + outputDir: "", + duration: Date.now() - startTime, + }; + } + + // Run batch export + const result = await exportBatch(mdFiles, app, settings, onProgress); + + return { + ...result, + duration: Date.now() - startTime, + }; +} + +// === Vault export === + +export async function exportVault( + app: App, + settings: PluginSettings, + onProgress?: (done: number, total: number, current: string) => void +): Promise { + const startTime = Date.now(); + + // Collect all .md files in the vault + const mdFiles = app.vault.getMarkdownFiles(); + + if (mdFiles.length === 0) { + return { + total: 0, + success: 0, + failed: 0, + errors: [], + outputDir: "", + duration: Date.now() - startTime, + }; + } + + // Run batch export + const result = await exportBatch(mdFiles, app, settings, onProgress); + + return { + ...result, + duration: Date.now() - startTime, + }; +} + +// === Core batch logic === + +async function exportBatch( + files: TFile[], + app: App, + settings: PluginSettings, + onProgress?: (done: number, total: number, current: string) => void +): Promise> { + const total = files.length; + let success = 0; + let failed = 0; + const errors: Array<{ file: string; error: string }> = []; + + const semaphore = createSemaphore(settings.concurrency); + let done = 0; + + const promises = files.map(async (file) => { + await semaphore.acquire(); + + try { + const result = await exportFile(file, app, settings); + + done++; + onProgress?.(done, total, file.name); + + if (result.success) { + success++; + } else { + failed++; + errors.push({ + file: file.path, + error: result.error || "Unknown error", + }); + + if (!settings.skipErrors) { + throw new Error(`Export failed for ${file.path}: ${result.error}`); + } + } + } catch (err) { + failed++; + errors.push({ + file: file.path, + error: err instanceof Error ? err.message : String(err), + }); + + if (!settings.skipErrors) { + throw err; + } + } finally { + semaphore.release(); + } + }); + + // Wait for all exports to complete (or first error if skipErrors is false) + if (settings.skipErrors) { + await Promise.allSettled(promises); + } else { + await Promise.all(promises); + } + + const vaultPath = getVaultPath(app); + const outputDir = path.isAbsolute(settings.outputDir) + ? settings.outputDir + : path.join(vaultPath, settings.outputDir || "pdf"); + + return { + total, + success, + failed, + errors, + outputDir, + }; +} + +// === Helpers === + +function collectMarkdownFiles(folder: TFolder, files: TFile[]): void { + for (const child of folder.children) { + if (child instanceof TFile && child.extension === "md") { + files.push(child); + } else if (child instanceof TFolder) { + collectMarkdownFiles(child, files); + } + } +} + +function isLatexEngine(engine: string): boolean { + return engine === "xelatex" || engine === "pdflatex" || engine === "lualatex"; +} + +function containsCjk(content: string): boolean { + return /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/u.test( + content + ); +} + +function normalizeRemoteImageUrls(content: string): string { + return content.replace( + /(https?:\/\/[^\s)"'>]+[?&])tp=webp(&?)/gi, + (_match, prefix: string, suffix: string) => { + if (suffix) return prefix; + return prefix.endsWith("?") ? prefix.slice(0, -1) : prefix.slice(0, -1); + } + ); +} + +async function localizeImagesForTypst( + content: string, + file: TFile, + vaultPath: string, + tmpDir: string, + tempFiles: string[] +): Promise { + const references = collectImageReferences(content); + const replacements: Array<{ original: string; converted: string }> = []; + const localizedBySource = new Map(); + let index = 0; + + for (const rawPath of references) { + const normalizedPath = stripMarkdownUrlDelimiters(rawPath); + if (/^data:/i.test(normalizedPath)) { + continue; + } + + const sourceKey = normalizedPath; + let localPath: string | null | undefined = localizedBySource.get(sourceKey); + if (!localPath) { + localPath = /^https?:/i.test(normalizedPath) + ? await downloadRemoteImageForTypst(normalizedPath, tmpDir, index++) + : await copyLocalImageForTypst( + normalizedPath, + file, + vaultPath, + tmpDir, + index++ + ); + if (!localPath) { + continue; + } + localizedBySource.set(sourceKey, localPath); + tempFiles.push(localPath); + } + + replacements.push({ + original: rawPath, + converted: `<./${path.basename(localPath)}>`, + }); + } + + let result = content; + for (const replacement of replacements) { + result = result.split(replacement.original).join(replacement.converted); + } + + return result; +} + +async function convertWebpImagesForLatex( + content: string, + file: TFile, + vaultPath: string, + tmpDir: string, + tempFiles: string[] +): Promise { + const references = collectImageReferences(content); + const replacements: Array<{ original: string; converted: string }> = []; + const convertedBySource = new Map(); + let index = 0; + + for (const rawPath of references) { + const normalizedPath = stripMarkdownUrlDelimiters(rawPath); + if (/^data:/i.test(normalizedPath)) { + continue; + } + + const resolvedPath = /^https?:/i.test(normalizedPath) + ? await downloadRemoteWebpIfNeeded(normalizedPath, tmpDir, index) + : isWebpReference(normalizedPath) + ? resolveWebpReference(normalizedPath, file, vaultPath) + : null; + if (!resolvedPath) { + continue; + } + if (resolvedPath.startsWith(path.join(tmpDir, "remote-webp-"))) { + tempFiles.push(resolvedPath); + } + + let outputPath = convertedBySource.get(resolvedPath); + if (!outputPath) { + outputPath = path.join( + tmpDir, + `webp-${Date.now()}-${index++}.png` + ); + + await convertWebpToPng(resolvedPath, outputPath); + convertedBySource.set(resolvedPath, outputPath); + tempFiles.push(outputPath); + } + replacements.push({ + original: rawPath, + converted: `<${outputPath}>`, + }); + } + + let result = content; + for (const replacement of replacements) { + result = result.split(replacement.original).join(replacement.converted); + } + + return result; +} + +function collectImageReferences(content: string): string[] { + const references = new Set(); + const markdownImageRegex = /!\[[^\]]*\]\(\s*(<[^>]+>|[^)]+?)(?:\s+["'][^"']*["'])?\s*\)(?:\{[^}]*\})?/gi; + const htmlImageRegex = /]*\bsrc=["']([^"']+)["'][^>]*>/gi; + const remoteImageUrlRegex = /https?:\/\/[^\s<>"']+/gi; + let match: RegExpExecArray | null; + + while ((match = markdownImageRegex.exec(content)) !== null) { + const reference = match[1].trim(); + references.add(reference); + } + + while ((match = htmlImageRegex.exec(content)) !== null) { + const reference = match[1].trim(); + references.add(reference); + } + + while ((match = remoteImageUrlRegex.exec(content)) !== null) { + const reference = trimUrlDelimiters(match[0]); + if (isLikelyImageUrl(reference)) { + references.add(reference); + } + } + + return Array.from(references); +} + +function trimUrlDelimiters(value: string): string { + return value.replace(/[)\]}|.,;:]+$/g, ""); +} + +function isLikelyImageUrl(value: string): boolean { + const url = stripMarkdownUrlDelimiters(value); + if (/\.(png|jpe?g|gif|svg|webp|bmp|ico)(?:[?#].*)?$/i.test(url)) { + return true; + } + + try { + const parsed = new URL(url); + const pathname = parsed.pathname.toLowerCase(); + if (/\.(png|jpe?g|gif|svg|webp|bmp|ico)$/.test(pathname)) { + return true; + } + return Boolean( + parsed.searchParams.get("wx_fmt") || + parsed.searchParams.get("tp") === "webp" + ); + } catch { + return false; + } +} + +function isWebpReference(reference: string): boolean { + return /\.webp(?:[?#].*)?$/i.test(stripMarkdownUrlDelimiters(reference)); +} + +function resolveWebpReference( + rawPath: string, + file: TFile, + vaultPath: string +): string | null { + return resolveImageReference(rawPath, file, vaultPath); +} + +function resolveImageReference( + rawPath: string, + file: TFile, + vaultPath: string +): string | null { + const sourcePath = stripMarkdownUrlDelimiters(rawPath); + const decodedPath = stripUrlQueryAndHash(safeDecodeUri(sourcePath)); + const candidates = path.isAbsolute(decodedPath) + ? [decodedPath] + : [ + path.join(vaultPath, path.dirname(file.path), decodedPath), + path.join(vaultPath, decodedPath), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +async function downloadRemoteWebpIfNeeded( + rawUrl: string, + tmpDir: string, + index: number +): Promise { + const url = stripMarkdownUrlDelimiters(rawUrl); + const isUrlWebp = isWebpReference(url); + + let image: RemoteImage; + try { + image = await downloadRemoteImage(url); + } catch (err) { + console.warn( + "Obsidian Press: could not inspect remote image before export:", + url, + err + ); + return null; + } + + if ( + !isUrlWebp && + !image.contentType.includes("image/webp") && + !isWebpBuffer(image.data) + ) { + return null; + } + + const outputPath = path.join( + tmpDir, + `remote-webp-${Date.now()}-${index}.webp` + ); + fs.writeFileSync(outputPath, image.data); + return outputPath; +} + +interface RemoteImage { + data: Buffer; + contentType: string; +} + +async function downloadRemoteImageForTypst( + url: string, + tmpDir: string, + index: number +): Promise { + try { + const image = await downloadRemoteImage(url); + const extension = getImageExtension(url, image.contentType, image.data); + const downloadedPath = path.join( + tmpDir, + `remote-image-${Date.now()}-${index}.${extension}` + ); + fs.writeFileSync(downloadedPath, image.data); + + if (extension === "webp" || isWebpBuffer(image.data)) { + const pngPath = path.join( + tmpDir, + `remote-image-${Date.now()}-${index}.png` + ); + await convertWebpToPng(downloadedPath, pngPath); + return pngPath; + } + + return downloadedPath; + } catch (err) { + console.warn("Obsidian Press: could not download remote image:", url, err); + return createMissingImagePlaceholder(tmpDir, index); + } +} + +async function copyLocalImageForTypst( + rawPath: string, + file: TFile, + vaultPath: string, + tmpDir: string, + index: number +): Promise { + const resolvedPath = resolveImageReference(rawPath, file, vaultPath); + if (!resolvedPath) { + return null; + } + + if (isWebpReference(resolvedPath)) { + const outputPath = path.join( + tmpDir, + `typst-image-${Date.now()}-${index}.png` + ); + await convertWebpToPng(resolvedPath, outputPath); + return outputPath; + } + + const extension = getImageExtensionFromPath(resolvedPath); + const outputPath = path.join( + tmpDir, + `typst-image-${Date.now()}-${index}.${extension}` + ); + fs.copyFileSync(resolvedPath, outputPath); + return outputPath; +} + +async function downloadRemoteImage(url: string): Promise { + const response = await requestUrl({ + url, + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + }, + }); + const contentTypeHeader = + response.headers["content-type"] || response.headers["Content-Type"] || ""; + + return { + data: Buffer.from(response.arrayBuffer), + contentType: contentTypeHeader.toLowerCase(), + }; +} + +function getImageExtension( + url: string, + contentType: string, + data: Buffer +): string { + if (isWebpBuffer(data) || contentType.includes("image/webp")) return "webp"; + if (isPngBuffer(data) || contentType.includes("image/png")) return "png"; + if (isJpegBuffer(data) || contentType.includes("image/jpeg")) return "jpg"; + if (isGifBuffer(data) || contentType.includes("image/gif")) return "gif"; + if (isSvgBuffer(data) || contentType.includes("image/svg")) return "svg"; + + const formatFromQuery = getImageFormatFromQuery(url); + if (formatFromQuery) return formatFromQuery; + + const pathname = safeUrlPathname(url); + const ext = path.extname(pathname).replace(".", "").toLowerCase(); + if (["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) { + return ext === "jpeg" ? "jpg" : ext; + } + + return "png"; +} + +function getImageExtensionFromPath(filePath: string): string { + const ext = path + .extname(stripUrlQueryAndHash(filePath)) + .replace(".", "") + .toLowerCase(); + if (ext === "jpeg") return "jpg"; + if (["png", "jpg", "gif", "svg", "webp"].includes(ext)) return ext; + return "png"; +} + +function getImageFormatFromQuery(url: string): string | null { + try { + const parsed = new URL(url); + const value = ( + parsed.searchParams.get("wx_fmt") || + parsed.searchParams.get("format") || + "" + ).toLowerCase(); + if (value === "jpeg") return "jpg"; + if (["png", "jpg", "gif", "svg", "webp"].includes(value)) return value; + } catch { + // Ignore invalid URLs. + } + return null; +} + +function safeUrlPathname(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +function isWebpBuffer(data: Buffer): boolean { + return ( + data.length >= 12 && + data.toString("ascii", 0, 4) === "RIFF" && + data.toString("ascii", 8, 12) === "WEBP" + ); +} + +function isPngBuffer(data: Buffer): boolean { + return ( + data.length >= 8 && + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 + ); +} + +function isJpegBuffer(data: Buffer): boolean { + return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8; +} + +function isGifBuffer(data: Buffer): boolean { + return ( + data.length >= 6 && + (data.toString("ascii", 0, 6) === "GIF87a" || + data.toString("ascii", 0, 6) === "GIF89a") + ); +} + +function isSvgBuffer(data: Buffer): boolean { + return data.toString("utf8", 0, Math.min(data.length, 256)).includes(" + + Remote image unavailable +`, + "utf8" + ); + return outputPath; +} + +function stripMarkdownUrlDelimiters(value: string): string { + if (value.startsWith("<") && value.endsWith(">")) { + return value.slice(1, -1); + } + return value; +} + +function safeDecodeUri(value: string): string { + try { + return decodeURI(value); + } catch { + return value; + } +} + +function stripUrlQueryAndHash(value: string): string { + return value.replace(/[?#].*$/, ""); +} + +function convertWebpToPng(inputPath: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("dwebp", [inputPath, "-o", outputPath], { + shell: false, + stdio: "pipe", + env: { + ...process.env, + PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + }, + }); + + let stderr = ""; + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + if (code === 0 && fs.existsSync(outputPath)) { + resolve(); + } else { + reject( + new Error( + `Failed to convert WebP image for LaTeX: ${ + stderr.trim() || `dwebp exited with code ${code}` + }` + ) + ); + } + }); + + child.on("error", (err) => { + reject( + new Error( + `Failed to run dwebp for WebP image conversion: ${err.message}` + ) + ); + }); + }); +} + +// === Pre-flight checks === + +export async function preflightChecks( + settings: PluginSettings +): Promise<{ ok: boolean; errors: string[] }> { + const errors: string[] = []; + + // Check Pandoc + const pandocCheck = await checkPandocAvailable(settings.pandocPath); + if (!pandocCheck.available) { + errors.push( + `Pandoc not found at "${settings.pandocPath}". Install: brew install pandoc` + ); + } + + // Check PDF engine (for non-Pandoc-native engines) + if (settings.pdfEngine === "wkhtmltopdf") { + const exists = await checkCommandExists("wkhtmltopdf"); + if (!exists) { + errors.push( + "wkhtmltopdf not found. Install: brew install wkhtmltopdf" + ); + } + } else if (settings.pdfEngine === "weasyprint") { + const exists = await checkCommandExists("weasyprint"); + if (!exists) { + errors.push( + "WeasyPrint not found. Install: pip install weasyprint" + ); + } + } else if ( + settings.pdfEngine === "xelatex" || + settings.pdfEngine === "pdflatex" || + settings.pdfEngine === "lualatex" + ) { + const exists = await checkCommandExists(settings.pdfEngine); + if (!exists) { + errors.push( + `${settings.pdfEngine} not found. Install: brew install --cask mactex-no-gui` + ); + } + } + + // Check Mermaid (optional, warn only) + const mermaidExists = await checkCommandExists( + settings.mermaidPath || "mmdc" + ); + if (!mermaidExists) { + // Not an error, just a warning — mermaid blocks will be kept as code + console.warn( + "Obsidian Press: mmdc not found. Mermaid diagrams will be exported as code blocks." + ); + } + + return { + ok: errors.length === 0, + errors, + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d27772d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,474 @@ +import { Plugin, TFile, TFolder, Notice, Menu, Platform } from "obsidian"; +import * as path from "path"; +import { PluginSettings, DEFAULT_SETTINGS, OutputFormat } from "./types"; +import { ObsidianPressSettingTab } from "./settings"; +import { exportFile, exportFolder, exportVault, preflightChecks } from "./exporter"; +import { getVaultPath } from "./utils"; + +class ExportProgressNotice { + private notice: Notice; + private titleEl: HTMLElement; + private detailEl: HTMLElement; + private fillEl: HTMLElement; + + constructor(title: string, detail = "") { + this.notice = new Notice("", 0); + this.notice.noticeEl.empty(); + this.notice.noticeEl.addClass("obsidian-press-progress-notice"); + + this.titleEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-title", + text: title, + }); + this.detailEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-detail", + text: detail, + }); + + const barEl = this.notice.noticeEl.createDiv({ + cls: "obsidian-press-progress-bar", + }); + this.fillEl = barEl.createDiv({ + cls: "obsidian-press-progress-bar-fill", + }); + this.setProgress(0); + } + + update(title: string, detail: string, progress: number) { + this.titleEl.setText(title); + this.detailEl.setText(detail); + this.setProgress(progress); + } + + hide() { + this.notice.hide(); + } + + private setProgress(progress: number) { + const normalized = Math.max(0, Math.min(100, progress)); + this.fillEl.style.width = `${normalized}%`; + } +} + +export default class ObsidianPressPlugin extends Plugin { + settings: PluginSettings; + + async onload() { + await this.loadSettings(); + + // Ribbon icon — export current note + this.addRibbonIcon("file-output", "Obsidian Press: Export current note", () => { + this.exportCurrentNote(); + }); + + // Commands + this.addCommand({ + id: "export-current-note-pdf", + name: "Export current note to PDF", + callback: () => this.exportCurrentNote(), + }); + + this.addCommand({ + id: "export-current-note-pdf-choose-folder", + name: "Export current note to PDF...", + callback: () => this.exportCurrentNoteWithDirectory("pdf"), + }); + + this.addCommand({ + id: "export-current-note-docx", + name: "Export current note to Word (DOCX)", + callback: () => this.exportCurrentNote("docx"), + }); + + this.addCommand({ + id: "export-current-note-docx-choose-folder", + name: "Export current note to Word (DOCX)...", + callback: () => this.exportCurrentNoteWithDirectory("docx"), + }); + + this.addCommand({ + id: "export-current-note-html", + name: "Export current note to HTML", + callback: () => this.exportCurrentNote("html"), + }); + + this.addCommand({ + id: "export-current-note-html-choose-folder", + name: "Export current note to HTML...", + callback: () => this.exportCurrentNoteWithDirectory("html"), + }); + + this.addCommand({ + id: "export-folder", + name: "Export all notes in current folder", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (file) { + const folder = file.parent; + if (folder && folder instanceof TFolder) { + if (!checking) { + this.exportFolder(folder); + } + return true; + } + } + return false; + }, + }); + + this.addCommand({ + id: "export-folder-choose-folder", + name: "Export all notes in current folder...", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (file) { + const folder = file.parent; + if (folder && folder instanceof TFolder) { + if (!checking) { + this.exportFolderWithDirectory(folder); + } + return true; + } + } + return false; + }, + }); + + this.addCommand({ + id: "export-vault", + name: "Export entire vault", + callback: () => this.exportEntireVault(), + }); + + this.addCommand({ + id: "export-vault-choose-folder", + name: "Export entire vault...", + callback: () => this.exportEntireVaultWithDirectory(), + }); + + // File menu — right-click on file + this.registerEvent( + this.app.workspace.on("file-menu", (menu: Menu, file) => { + if (file instanceof TFile && file.extension === "md") { + menu.addItem((item) => { + item + .setTitle("导出为 PDF(Obsidian Press)") + .setIcon("file-output") + .onClick(() => this.exportSpecificFile(file)); + }); + menu.addItem((item) => { + item + .setTitle("选择目录导出为 PDF(Obsidian Press)") + .setIcon("folder-open") + .onClick(() => this.exportSpecificFileWithDirectory(file, "pdf")); + }); + } + + if (file instanceof TFolder) { + menu.addItem((item) => { + item + .setTitle("全部导出为 PDF(Obsidian Press)") + .setIcon("file-output") + .onClick(() => this.exportFolder(file)); + }); + menu.addItem((item) => { + item + .setTitle("选择目录全部导出为 PDF(Obsidian Press)") + .setIcon("folder-open") + .onClick(() => this.exportFolderWithDirectory(file)); + }); + } + }) + ); + + // Settings tab + this.addSettingTab(new ObsidianPressSettingTab(this.app, this)); + } + + onunload() { + // Cleanup if needed + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + if ((this.settings.pdfEngine as string) === "chromium") { + this.settings.pdfEngine = "xelatex"; + await this.saveSettings(); + } + if (this.settings.codeTheme === "pygments") { + this.settings.codeTheme = "tango"; + await this.saveSettings(); + } + } + + async saveSettings() { + await this.saveData(this.settings); + } + + // === Export actions === + + private async exportCurrentNote(forceFormat?: OutputFormat) { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") { + new Notice("No active Markdown file"); + return; + } + + const format = forceFormat || this.settings.defaultFormat; + const settings = { ...this.settings, defaultFormat: format }; + await this.exportSpecificFile(file, settings); + } + + private async exportCurrentNoteWithDirectory(forceFormat?: OutputFormat) { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") { + new Notice("No active Markdown file"); + return; + } + + await this.exportSpecificFileWithDirectory( + file, + forceFormat || this.settings.defaultFormat + ); + } + + private async exportSpecificFileWithDirectory( + file: TFile, + format: OutputFormat + ) { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + + await this.exportSpecificFile(file, { + ...this.settings, + defaultFormat: format, + outputDir, + }); + } + + private async exportSpecificFile( + file: TFile, + overrideSettings?: PluginSettings + ) { + const settings = overrideSettings || this.settings; + + // Pre-flight check + const check = await preflightChecks(settings); + if (!check.ok) { + new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000); + return; + } + + const formatLabel = settings.defaultFormat.toUpperCase(); + const progress = new ExportProgressNotice( + `正在导出 ${formatLabel}`, + file.name + ); + + try { + progress.update(`正在导出 ${formatLabel}`, "准备文件...", 20); + const result = await exportFile(file, this.app, settings); + + progress.update(`正在导出 ${formatLabel}`, "写入导出文件...", 90); + progress.hide(); + + if (result.success) { + new Notice( + `Exported: ${result.outputPath}\n(${(result.duration / 1000).toFixed(1)}s)` + ); + + // Open the exported file + if (settings.openAfterExport && result.outputPath) { + window.open(`file://${result.outputPath}`); + } + } else { + new Notice(`Export failed: ${result.error}`, 8000); + } + } catch (err) { + progress.hide(); + new Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8000 + ); + } + } + + private async exportFolder(folder: TFolder, overrideSettings?: PluginSettings) { + const settings = overrideSettings || this.settings; + + // Pre-flight check + const check = await preflightChecks(settings); + if (!check.ok) { + new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000); + return; + } + + const progress = new ExportProgressNotice( + "正在导出文件夹", + folder.name + ); + + try { + const result = await exportFolder( + folder, + this.app, + settings, + (done, total, current) => { + progress.update( + "正在导出文件夹", + `${done}/${total} · ${current}`, + total > 0 ? (done / total) * 100 : 0 + ); + } + ); + + progress.hide(); + + const summary = [ + `Export complete`, + `Total: ${result.total}`, + `Success: ${result.success}`, + `Failed: ${result.failed}`, + `Output: ${result.outputDir}`, + `Time: ${(result.duration / 1000).toFixed(1)}s`, + ].join("\n"); + + new Notice(summary, 10000); + + if (result.failed > 0 && result.errors.length > 0) { + console.error("Obsidian Press export errors:", result.errors); + } + } catch (err) { + progress.hide(); + new Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8000 + ); + } + } + + private async exportFolderWithDirectory(folder: TFolder) { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + + await this.exportFolder(folder, { + ...this.settings, + outputDir, + }); + } + + private async exportEntireVault(overrideSettings?: PluginSettings) { + const settings = overrideSettings || this.settings; + + // Pre-flight check + const check = await preflightChecks(settings); + if (!check.ok) { + new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000); + return; + } + + const progress = new ExportProgressNotice( + "正在导出整个仓库", + "扫描 Markdown 文件..." + ); + + try { + const result = await exportVault( + this.app, + settings, + (done, total, current) => { + progress.update( + "正在导出整个仓库", + `${done}/${total} · ${current}`, + total > 0 ? (done / total) * 100 : 0 + ); + } + ); + + progress.hide(); + + const summary = [ + `Vault export complete`, + `Total: ${result.total}`, + `Success: ${result.success}`, + `Failed: ${result.failed}`, + `Output: ${result.outputDir}`, + `Time: ${(result.duration / 1000).toFixed(1)}s`, + ].join("\n"); + + new Notice(summary, 10000); + + if (result.failed > 0 && result.errors.length > 0) { + console.error("Obsidian Press export errors:", result.errors); + } + } catch (err) { + progress.hide(); + new Notice( + `Export error: ${err instanceof Error ? err.message : String(err)}`, + 8000 + ); + } + } + + private async exportEntireVaultWithDirectory() { + const outputDir = await this.chooseOutputDirectory(); + if (!outputDir) { + return; + } + + await this.exportEntireVault({ + ...this.settings, + outputDir, + }); + } + + private getConfiguredOutputDirectory(): string { + const vaultPath = getVaultPath(this.app); + const outputDir = this.settings.outputDir || "pdf"; + return path.isAbsolute(outputDir) + ? outputDir + : path.join(vaultPath, outputDir); + } + + private async chooseOutputDirectory(): Promise { + if (!Platform.isDesktopApp) { + new Notice("Folder selection is only available in the desktop app", 8000); + return null; + } + + try { + const electron = require("electron") as any; + const dialog = electron.remote?.dialog || electron.dialog; + + if (!dialog?.showOpenDialog) { + new Notice("Folder selection is not available in this Obsidian window", 8000); + return null; + } + + const result = await dialog.showOpenDialog({ + title: "Choose export folder", + defaultPath: this.getConfiguredOutputDirectory(), + properties: ["openDirectory", "createDirectory"], + }); + + if (result.canceled || !result.filePaths?.[0]) { + new Notice("Export canceled"); + return null; + } + + return result.filePaths[0]; + } catch (err) { + new Notice( + `Could not open folder picker: ${ + err instanceof Error ? err.message : String(err) + }`, + 8000 + ); + return null; + } + } +} diff --git a/src/mermaid.ts b/src/mermaid.ts new file mode 100644 index 0000000..4ad92e2 --- /dev/null +++ b/src/mermaid.ts @@ -0,0 +1,70 @@ +import * as fs from "fs"; +import * as path from "path"; +import { runCommand, checkCommandExists } from "./utils"; + +/** + * Render a Mermaid diagram to SVG using mmdc (mermaid-cli). + * Returns the SVG file path, or null if mmdc is not available. + */ +export async function renderMermaidBlock( + code: string, + theme: string, + tmpDir: string, + index: number +): Promise { + // Check if mmdc is available + const mmdcAvailable = await checkCommandExists("mmdc"); + if (!mmdcAvailable) { + console.warn("Obsidian Press: mmdc not found, skipping Mermaid rendering"); + return null; + } + + const inputFile = path.join(tmpDir, `mermaid-input-${index}.mmd`); + const outputFile = path.join(tmpDir, `mermaid-output-${index}.svg`); + + try { + // Write mermaid source to temp file + fs.writeFileSync(inputFile, code, "utf8"); + + // Run mmdc via login shell + const cmd = `mmdc -i '${inputFile}' -o '${outputFile}' -t ${theme} -b transparent --quiet`; + const { code: exitCode, stderr } = await runCommand(cmd, { + timeout: 30000, + }); + + // Verify output exists + if (exitCode === 0 && fs.existsSync(outputFile)) { + return outputFile; + } + + console.warn("Obsidian Press: Mermaid rendering failed:", stderr); + return null; + } catch (err) { + console.error("Obsidian Press: Mermaid rendering error:", err); + return null; + } finally { + // Clean up input file + try { + if (fs.existsSync(inputFile)) { + fs.unlinkSync(inputFile); + } + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Clean up SVG output files from tmp directory + */ +export function cleanupMermaidFiles(svgPaths: string[]): void { + for (const svgPath of svgPaths) { + try { + if (fs.existsSync(svgPath)) { + fs.unlinkSync(svgPath); + } + } catch { + // Ignore cleanup errors + } + } +} diff --git a/src/pandoc.ts b/src/pandoc.ts new file mode 100644 index 0000000..f2a70ff --- /dev/null +++ b/src/pandoc.ts @@ -0,0 +1,357 @@ +import * as path from "path"; +import * as fs from "fs"; +import { PandocOptions, ExportResult } from "./types"; +import { spawn } from "child_process"; + +// === Build Pandoc args array (no shell escaping issues) === + +function buildPandocArgs(options: PandocOptions): string[] { + const { + inputPath, + outputPath, + format, + engine, + fontSize, + pageSize, + pageMargin, + codeTheme, + cjkFont, + enableCjk, + customCssPath, + customTemplatePath, + extraArgs, + } = options; + const listingsHeaderPath = path.join(options.tempDir, "obsidian-press-listings.tex"); + + const args: string[] = [ + inputPath, + "-o", + outputPath, + "--from", + "markdown+fenced_code_blocks+fenced_code_attributes+backtick_code_blocks+pipe_tables+grid_tables+raw_html+tex_math_dollars", + "--to", + format === "pdf" ? "pdf" : format === "docx" ? "docx" : "html5", + "--standalone", + "--toc", + "--toc-depth=3", + `--highlight-style=${codeTheme}`, + "--resource-path", + path.dirname(inputPath), + ]; + + // Engine-specific args + const latexBase = [ + "-V", + `geometry:margin=${pageMargin}mm`, + "-V", + `fontsize=${fontSize}pt`, + "-V", + `papersize=${pageSize.toLowerCase()}`, + ]; + + switch (engine) { + case "xelatex": + args.push( + "--pdf-engine=xelatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + ...getCjkArgs(engine, enableCjk, cjkFont), + "-V", + "colorlinks=true" + ); + break; + case "pdflatex": + args.push( + "--pdf-engine=pdflatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + "-V", + "colorlinks=true" + ); + break; + case "lualatex": + args.push( + "--pdf-engine=lualatex", + "--listings", + "-H", + listingsHeaderPath, + ...latexBase, + ...getCjkArgs(engine, enableCjk, cjkFont), + "-V", + "colorlinks=true" + ); + break; + case "wkhtmltopdf": + args.push( + "--pdf-engine=wkhtmltopdf", + "-V", + `margin-top=${pageMargin}mm`, + "-V", + `margin-bottom=${pageMargin}mm`, + "-V", + `margin-left=${pageMargin}mm`, + "-V", + `margin-right=${pageMargin}mm` + ); + break; + case "weasyprint": + args.push("--pdf-engine=weasyprint", "-V", `margin=${pageMargin}mm`); + break; + case "typst": + args.push( + "--pdf-engine=typst", + "-V", + `font-size=${fontSize}pt`, + "-V", + `page-size=${pageSize.toLowerCase()}` + ); + break; + } + + // CSS for HTML-based engines + if ( + (engine === "wkhtmltopdf" || engine === "weasyprint") && + customCssPath + ) { + args.push("--css", customCssPath); + } + + // Template + if (customTemplatePath) { + args.push("--template", customTemplatePath); + } + + // Extra args + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + + return args; +} + +function normalizeCjkFontName(fontName: string): string { + const trimmed = fontName.trim(); + if (process.platform === "darwin" && trimmed === "PingFang SC") { + return "STHeitiSC-Medium"; + } + return trimmed; +} + +function getCjkArgs( + engine: string, + enableCjk: boolean, + cjkFont: string +): string[] { + if (!enableCjk) return []; + + const resolved = normalizeCjkFontName(cjkFont); + if (resolved) { + return ["-V", `CJKmainfont=${resolved}`]; + } + + if (process.platform === "darwin" && engine === "xelatex") { + return ["-V", "CJKmainfont=STHeitiSC-Medium"]; + } + + return []; +} + +// === Export with Pandoc (spawn with array, no shell escaping issues) === + +export async function exportWithPandoc( + options: PandocOptions +): Promise { + const startTime = Date.now(); + const pandocPath = options.pandocPath || "pandoc"; + + // Verify input file exists + if (!fs.existsSync(options.inputPath)) { + return { + success: false, + error: `Input file not found: ${options.inputPath}`, + duration: Date.now() - startTime, + }; + } + + // Ensure output directory exists + const outputDir = path.dirname(options.outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + if (!fs.existsSync(options.tempDir)) { + fs.mkdirSync(options.tempDir, { recursive: true }); + } + writeListingsHeader(options.tempDir); + const texCacheDir = path.join(options.tempDir, "tex-cache"); + if (!fs.existsSync(texCacheDir)) { + fs.mkdirSync(texCacheDir, { recursive: true }); + } + + const args = buildPandocArgs(options); + + return new Promise((resolve) => { + // Use spawn with array args and shell: false — pandocPath is used as the + // executable directly, never appears in the args array. + const child = spawn(pandocPath, args, { + shell: false, + cwd: options.tempDir, + stdio: "pipe", + timeout: 120000, + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + TMPDIR: options.tempDir, + TMP: options.tempDir, + TEMP: options.tempDir, + TEXMFVAR: texCacheDir, + TEXMFCACHE: texCacheDir, + LANG: process.env.LANG || "en_US.UTF-8", + LC_ALL: process.env.LC_ALL || "en_US.UTF-8", + }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + const duration = Date.now() - startTime; + + if (code === 0) { + if (fs.existsSync(options.outputPath)) { + const stats = fs.statSync(options.outputPath); + if (stats.size > 0) { + resolve({ + success: true, + outputPath: options.outputPath, + duration, + }); + return; + } + } + resolve({ + success: false, + error: "Output file was not created or is empty", + duration, + }); + return; + } + + // Parse error + const errorMsg = parsePandocError(stderr, code); + resolve({ success: false, error: errorMsg, duration }); + }); + + child.on("error", (err) => { + resolve({ + success: false, + error: `Failed to run pandoc: ${err.message}`, + duration: Date.now() - startTime, + }); + }); + }); +} + +function writeListingsHeader(tempDir: string): void { + const headerPath = path.join(tempDir, "obsidian-press-listings.tex"); + const content = String.raw`\lstset{ + breaklines=true, + breakatwhitespace=false, + columns=fullflexible, + keepspaces=true, + basicstyle=\ttfamily\footnotesize, + frame=single, + framerule=0.3pt, + rulecolor=\color[HTML]{D0D7DE}, + backgroundcolor=\color[HTML]{F6F8FA}, + xleftmargin=0.5em, + xrightmargin=0.5em, + aboveskip=0.8em, + belowskip=0.8em +} +`; + fs.writeFileSync(headerPath, content, "utf8"); +} + +// === Error parsing === + +function parsePandocError(stderr: string, code: number | null): string { + if (!stderr) return `Pandoc exited with code ${code}`; + + const lines = stderr.split("\n").filter((line) => line.trim()); + const latexErrorIndex = lines.findIndex((line) => /^! /.test(line.trim())); + if (latexErrorIndex >= 0) { + return lines + .slice(latexErrorIndex, latexErrorIndex + 5) + .join("\n") + .substring(0, 800); + } + + const errorPatterns = [ + /Package .* Error/, + /LaTeX Error/, + /error:/i, + /not found/i, + /failed/i, + /cannot/i, + /unable/i, + /does not exist/i, + ]; + + for (const pattern of errorPatterns) { + const errorLine = lines.find((line) => pattern.test(line)); + if (errorLine) return errorLine.trim(); + } + + return lines.slice(0, 3).join("; ").substring(0, 500); +} + +// === Pandoc availability check === + +export async function checkPandocAvailable( + pandocPath: string +): Promise<{ available: boolean; version?: string }> { + return new Promise((resolve) => { + const child = spawn(pandocPath, ["--version"], { + shell: false, + stdio: "pipe", + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + }, + }); + + let stdout = ""; + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.on("close", (code) => { + if (code === 0) { + const versionMatch = stdout.match(/pandoc\s+(\d+\.\d+[\.\d]*)/); + resolve({ + available: true, + version: versionMatch ? versionMatch[1] : "unknown", + }); + } else { + resolve({ available: false }); + } + }); + + child.on("error", () => { + resolve({ available: false }); + }); + }); +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..c5be729 --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,567 @@ +import { App, TFile, Vault } from "obsidian"; +import { RenderResult, CalloutType } from "./types"; +import { resolveAttachmentPath, getVaultPath, getTmpDir } from "./utils"; +import { renderMermaidBlock } from "./mermaid"; + +const CALLOUT_TYPES: CalloutType[] = [ + "note", + "tip", + "important", + "warning", + "caution", + "abstract", + "info", + "todo", + "example", + "quote", + "success", + "question", + "failure", + "danger", + "bug", +]; + +const CALLOUT_ICONS: Record = { + note: "\u{1F4DD}", + tip: "\u{1F4A1}", + important: "\u{2757}", + warning: "\u{26A0}\u{FE0F}", + caution: "\u{26A0}\u{FE0F}", + abstract: "\u{1F4CB}", + info: "\u{2139}\u{FE0F}", + todo: "\u{2611}", + example: "\u{1F4DA}", + quote: "\u{275D}", + success: "\u{2705}", + question: "\u{2753}", + failure: "\u{274C}", + danger: "\u{1F6A8}", + bug: "\u{1F41B}", +}; + +/** + * Full preprocessing pipeline: Obsidian Markdown → Pandoc-compatible Markdown + */ +export async function renderToPandoc( + content: string, + file: TFile, + app: App, + mermaidPath: string, + mermaidTheme: string +): Promise { + const vaultPath = getVaultPath(app); + const tmpDir = getTmpDir(vaultPath); + const tempFiles: string[] = []; + + // Step 1: Strip YAML frontmatter (preserve title as heading) + let rendered = stripFrontmatter(content); + + rendered = formatFlattenedCodeBlocks(rendered); + + // Step 2: Pre-render Mermaid blocks before protecting ordinary code fences + rendered = await convertMermaidBlocks( + rendered, + mermaidPath, + mermaidTheme, + tmpDir, + tempFiles + ); + + // Step 3: Protect code blocks/spans from Obsidian syntax conversions + const protectedCode = protectCodeSegments(rendered); + rendered = protectedCode.content; + + // Step 4: Convert callouts + rendered = convertCallouts(rendered); + + // Step 5: Convert wikilinks to standard links + rendered = convertWikilinks(rendered, file, app); + + // Step 6: Convert embedded images ![[img.png]] → ![img](abs/path) + rendered = await convertEmbeds(rendered, file, app); + + // Step 7: Inline embedded notes ![[other-note]] (limited depth) + rendered = await inlineNoteEmbeds(rendered, file, app, 0, 5); + + // Step 8: Convert ==highlight== → highlight + rendered = convertHighlights(rendered); + + // Step 9: Convert ^sup^ and ~~sub~~ + rendered = convertSupSub(rendered); + + // Step 10: Strip %%comments%% + rendered = stripComments(rendered); + + // Step 11: Convert Obsidian-style images with size ![[img.png|200]] + rendered = convertImageSizes(rendered); + + // Step 12: Restore protected code blocks/spans for Pandoc highlighting + rendered = restoreCodeSegments(rendered, protectedCode.segments); + + return { content: rendered, tempFiles }; +} + +export function formatFlattenedCodeBlocks(content: string): string { + return content.replace( + /^(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^\1[ \t]*$/gm, + (match, fence: string, info: string, code: string) => { + const language = info.trim().split(/\s+/)[0]?.toLowerCase() || ""; + if (!shouldFormatFlattenedCode(language, code)) { + return match; + } + + const formatted = formatJavaScriptLikeCode(code); + return `${fence}${info}\n${formatted}\n${fence}`; + } + ); +} + +function shouldFormatFlattenedCode(language: string, code: string): boolean { + const supportedLanguages = new Set([ + "", + "js", + "javascript", + "jsx", + "ts", + "typescript", + "tsx", + "php", + ]); + if (!supportedLanguages.has(language)) { + return false; + } + + const trimmed = code.trim(); + if (trimmed.length < 160) { + return false; + } + + const nonEmptyLines = trimmed.split(/\r?\n/).filter((line) => line.trim()); + if (nonEmptyLines.length > 2) { + return false; + } + + return /[{};]/.test(trimmed) && /\b(const|let|var|function|class|async|while|if|return|await|new)\b/.test(trimmed); +} + +function formatJavaScriptLikeCode(code: string): string { + const { text, literals } = protectStringLiterals(code.trim()); + + let formatted = text + .replace(/\r?\n/g, " ") + .replace(/[ \t]{2,}/g, " ") + .replace(/\}\s*else\s*\{/g, "}\nelse {") + .replace(/\}\s*catch\s*\(/g, "}\ncatch (") + .replace(/\}\s*finally\s*\{/g, "}\nfinally {") + .replace(/\s*\{\s*/g, " {\n") + .replace(/;\s*/g, ";\n") + .replace(/,\s*/g, ",\n") + .replace(/\s*\}\s*/g, "\n}\n") + .replace(/\)\s*(?=(?:async\s+)?(?:function|class|const|let|var|if|for|while|return|await|new|this\.))/g, ")\n") + .replace(/\n{2,}/g, "\n"); + + formatted = restoreStringLiterals(formatted, literals); + return indentFormattedCode(formatted); +} + +function protectStringLiterals(code: string): { text: string; literals: string[] } { + const literals: string[] = []; + let text = ""; + let i = 0; + + while (i < code.length) { + const char = code[i]; + if (char !== "'" && char !== '"' && char !== "`") { + text += char; + i++; + continue; + } + + const quote = char; + let literal = char; + i++; + + while (i < code.length) { + const next = code[i]; + literal += next; + i++; + + if (next === "\\") { + if (i < code.length) { + literal += code[i]; + i++; + } + continue; + } + + if (next === quote) { + break; + } + } + + const token = `__OBSIDIAN_PRESS_LITERAL_${literals.length}__`; + literals.push(literal); + text += token; + } + + return { text, literals }; +} + +function restoreStringLiterals(text: string, literals: string[]): string { + return literals.reduce( + (result, literal, index) => + result.split(`__OBSIDIAN_PRESS_LITERAL_${index}__`).join(literal), + text + ); +} + +function indentFormattedCode(code: string): string { + const lines = code + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const output: string[] = []; + let level = 0; + + for (const line of lines) { + if (/^[}\])]/.test(line)) { + level = Math.max(0, level - 1); + } + + output.push(`${" ".repeat(level)}${line}`); + + const opens = (line.match(/[{\[(]/g) || []).length; + const closes = (line.match(/[}\])]/g) || []).length; + level = Math.max(0, level + opens - closes); + } + + return output.join("\n"); +} + +// === Code protection === + +interface ProtectedCodeSegments { + content: string; + segments: Map; +} + +function protectCodeSegments(content: string): ProtectedCodeSegments { + const segments = new Map(); + let index = 0; + + const store = (value: string): string => { + const token = `OBSIDIAN_PRESS_CODE_${index++}`; + segments.set(token, value); + return token; + }; + + let result = content.replace( + /^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, + (match) => store(match) + ); + + result = result.replace(/(`+)([\s\S]*?[^`])\1/g, (match) => store(match)); + + return { content: result, segments }; +} + +function restoreCodeSegments( + content: string, + segments: Map +): string { + let result = content; + for (const [token, value] of segments) { + result = result.split(token).join(value); + } + return result; +} + +// === Step 1: YAML Frontmatter === + +function stripFrontmatter(content: string): string { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n?/; + const match = content.match(frontmatterRegex); + + if (!match) return content; + + const yamlContent = match[1]; + const rest = content.slice(match[0].length); + + // Extract title if present + const titleMatch = yamlContent.match(/^title:\s*(.+)$/m); + if (titleMatch) { + const title = titleMatch[1].replace(/^["']|["']$/g, ""); + return `# ${title}\n\n${rest}`; + } + + return rest; +} + +// === Step 2: Callouts === + +function convertCallouts(content: string): string { + // Match callout blocks: > [!type] Title\n> content... + const calloutRegex = + /^(>\s*\[!([a-zA-Z]+)\](\+|-)?\s*(.*)?\n(?:>\s*.*\n?)*)/gm; + + return content.replace( + calloutRegex, + ( + match: string, + _full: string, + type: string, + _collapse: string | undefined, + title: string | undefined + ) => { + const calloutType = type.toLowerCase() as CalloutType; + const isValidType = CALLOUT_TYPES.includes(calloutType); + const cssType = isValidType ? calloutType : "note"; + const icon = CALLOUT_ICONS[cssType] || "\u{1F4DD}"; + const displayTitle = (title || cssType).trim(); + + // Strip leading > from each line + const lines = match.split("\n"); + const bodyLines = lines + .map((line: string) => line.replace(/^>\s?/, "")) + .filter((line: string, i: number) => { + // Remove the first line (the [!type] line) + if (i === 0) return false; + return true; + }); + + const body = bodyLines.join("\n").trim(); + + return `
\n
\n${icon} ${displayTitle}\n
\n
\n\n${body}\n\n
\n
`; + } + ); +} + +// === Step 3: WikiLinks === + +function convertWikilinks(content: string, file: TFile, app: App): string { + // [[target]] or [[target|alias]] + return content.replace( + /\[\[([^\]|]+?)(?:\|([^\]]*?))?\]\]/g, + (_match: string, target: string, alias: string | undefined) => { + const displayText = alias || target; + + // If it looks like an image, don't convert as link + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(target)) { + return displayText; + } + + // Try to resolve as note file + const resolvedFile = app.metadataCache.getFirstLinkpathDest( + target, + file.path + ); + if (resolvedFile) { + const relativePath = getRelativePath(file.path, resolvedFile.path); + return `[${displayText}](${relativePath})`; + } + + // Fallback: create link with .md extension + return `[${displayText}](${target}.md)`; + } + ); +} + +// === Step 4: Embed Images === + +async function convertEmbeds( + content: string, + file: TFile, + app: App +): Promise { + // ![[image.png]] or ![[image.png|size]] + const embedRegex = /!\[\[([^\]|]+?)(?:\|(\d+))?\]\]/g; + + let result = content; + let match: RegExpExecArray | null; + + while ((match = embedRegex.exec(content)) !== null) { + const [fullMatch, src, size] = match; + + // Check if it's an image + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(src)) { + const absPath = resolveAttachmentPath(src, file, app); + const sizeAttr = size ? ` width="${size}"` : ""; + const replacement = `![${src}](${absPath})${sizeAttr ? "{width=" + size + "}" : ""}`; + result = result.replace(fullMatch, replacement); + } + } + + return result; +} + +// === Step 5: Inline Note Embeds === + +async function inlineNoteEmbeds( + content: string, + currentFile: TFile, + app: App, + depth: number, + maxDepth: number +): Promise { + if (depth >= maxDepth) return content; + + // ![[note-name]] (not images) + const embedRegex = /!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\]/g; + let result = content; + let match: RegExpExecArray | null; + + const contentSnapshot = result; + while ((match = embedRegex.exec(contentSnapshot)) !== null) { + const [fullMatch, target] = match; + + // Skip images (handled in step 4) + if (/\.(png|jpg|jpeg|gif|svg|webp|bmp|ico)$/i.test(target)) { + continue; + } + + // Resolve the note + const resolvedFile = app.metadataCache.getFirstLinkpathDest( + target, + currentFile.path + ); + if (resolvedFile instanceof TFile && resolvedFile.extension === "md") { + const embedContent = await app.vault.read(resolvedFile); + + // Recursively process embedded content + const processed = await inlineNoteEmbeds( + embedContent, + resolvedFile, + app, + depth + 1, + maxDepth + ); + + result = result.replace( + fullMatch, + `\n\n\n\n${processed}\n\n\n\n` + ); + } + } + + return result; +} + +// === Step 6: Highlights === + +function convertHighlights(content: string): string { + // ==text== → text + return content.replace(/==([^=]+)==/g, "$1"); +} + +// === Step 7: Superscript / Subscript === + +function convertSupSub(content: string): string { + // ^text^ → text + let result = content.replace(/\^([^\^]+)\^/g, "$1"); + // ~~text~~ → text + result = result.replace(/~~([^~]+)~~/g, "$1"); + return result; +} + +// === Step 8: Strip Comments === + +function stripComments(content: string): string { + // %%comment%% → (removed) + return content.replace(/%%[\s\S]*?%%/g, ""); +} + +// === Step 9: Image Sizes === + +function convertImageSizes(content: string): string { + // ![alt|size](url) → ![alt](url){width=size} + // Already handled in convertEmbeds for ![[ ]] syntax + // Handle standard markdown image with Obsidian size: ![alt|200](url) + return content.replace( + /!\[([^\]]*?)\|(\d+)\]\(([^)]+)\)/g, + (_match, alt, size, url) => { + return `![${alt}](${url}){width=${size}}`; + } + ); +} + +// === Step 10: Mermaid Blocks === + +async function convertMermaidBlocks( + content: string, + mermaidPath: string, + mermaidTheme: string, + tmpDir: string, + tempFiles: string[] +): Promise { + // ```mermaid\n...\n``` + const mermaidRegex = /```mermaid\n([\s\S]*?)```/g; + + let result = content; + let match: RegExpExecArray | null; + let index = 0; + + const contentSnapshot = content; + while ((match = mermaidRegex.exec(contentSnapshot)) !== null) { + const [fullMatch, code] = match; + index++; + + try { + const svgPath = await renderMermaidBlock( + code.trim(), + mermaidTheme, + tmpDir, + index + ); + + if (svgPath) { + tempFiles.push(svgPath); + result = result.replace( + fullMatch, + `![Mermaid Diagram ${index}](${svgPath})` + ); + } else { + // Fallback: keep as code block + result = result.replace( + fullMatch, + "```mermaid\n" + code + "```" + ); + } + } catch { + // Keep as code block on error + } + } + + return result; +} + +// === Helpers === + +function getRelativePath(from: string, to: string): string { + const fromDir = from.substring(0, from.lastIndexOf("/")); + let relative = ""; + const fromParts = fromDir.split("/"); + const toParts = to.split("/"); + + // Find common prefix + let commonLength = 0; + while ( + commonLength < fromParts.length && + commonLength < toParts.length && + fromParts[commonLength] === toParts[commonLength] + ) { + commonLength++; + } + + // Go up from current directory + for (let i = commonLength; i < fromParts.length; i++) { + relative += "../"; + } + + // Go down to target + for (let i = commonLength; i < toParts.length; i++) { + relative += toParts[i]; + if (i < toParts.length - 1) relative += "/"; + } + + return relative || to; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..4293c58 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,308 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import ObsidianPressPlugin from "./main"; +import { PdfEngine, PageSize, CodeTheme, MermaidTheme } from "./types"; + +export class ObsidianPressSettingTab extends PluginSettingTab { + plugin: ObsidianPressPlugin; + + constructor(app: App, plugin: ObsidianPressPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + // === General === + new Setting(containerEl).setName("General").setHeading(); + + new Setting(containerEl) + .setName("Pandoc path") + .setDesc("Path to the pandoc binary") + .addText((text) => + text + .setPlaceholder("/opt/homebrew/bin/pandoc") + .setValue(this.plugin.settings.pandocPath) + .onChange(async (value) => { + this.plugin.settings.pandocPath = value || "/opt/homebrew/bin/pandoc"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("PDF engine") + .setDesc("The PDF rendering engine to use") + .addDropdown((dropdown) => + dropdown + .addOption("xelatex", "XeLaTeX (best quality)") + .addOption("pdflatex", "pdfLaTeX (auto XeLaTeX for CJK)") + .addOption("lualatex", "LuaLaTeX") + .addOption("wkhtmltopdf", "wkhtmltopdf (lightweight)") + .addOption("weasyprint", "WeasyPrint (CSS-based)") + .addOption("typst", "Typst (experimental)") + .setValue(this.plugin.settings.pdfEngine) + .onChange(async (value: string) => { + this.plugin.settings.pdfEngine = value as PdfEngine; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Default format") + .setDesc("Default export format") + .addDropdown((dropdown) => + dropdown + .addOption("pdf", "PDF") + .addOption("docx", "Word (DOCX)") + .addOption("html", "HTML") + .setValue(this.plugin.settings.defaultFormat) + .onChange(async (value: string) => { + this.plugin.settings.defaultFormat = value as any; + await this.plugin.saveSettings(); + }) + ); + + // === Output === + new Setting(containerEl).setName("Output").setHeading(); + + new Setting(containerEl) + .setName("Output directory") + .setDesc( + "Relative to vault root, or absolute path. Leave empty for 'pdf' folder" + ) + .addText((text) => + text + .setPlaceholder("pdf") + .setValue(this.plugin.settings.outputDir) + .onChange(async (value) => { + this.plugin.settings.outputDir = value || "pdf"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("File naming") + .setDesc("How output files are named") + .addDropdown((dropdown) => + dropdown + .addOption("same", "Same as source (note.pdf)") + .addOption("timestamp", "With timestamp (note_2024-01-01T00-00-00.pdf)") + .addOption("suffix", "With suffix (note_export.pdf)") + .setValue(this.plugin.settings.outputNaming) + .onChange(async (value: string) => { + this.plugin.settings.outputNaming = value as any; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Open after export") + .setDesc("Automatically open the exported file after completion") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.openAfterExport) + .onChange(async (value) => { + this.plugin.settings.openAfterExport = value; + await this.plugin.saveSettings(); + }) + ); + + // === Typography === + new Setting(containerEl).setName("Typography").setHeading(); + + new Setting(containerEl) + .setName("Font size") + .setDesc("Base font size in points") + .addText((text) => + text + .setPlaceholder("11") + .setValue(String(this.plugin.settings.fontSize)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0 && num <= 72) { + this.plugin.settings.fontSize = num; + await this.plugin.saveSettings(); + } + }) + ); + + new Setting(containerEl) + .setName("Page size") + .setDesc("PDF page dimensions") + .addDropdown((dropdown) => + dropdown + .addOption("A4", "A4") + .addOption("Letter", "Letter") + .addOption("Legal", "Legal") + .addOption("A3", "A3") + .setValue(this.plugin.settings.pageSize) + .onChange(async (value: string) => { + this.plugin.settings.pageSize = value as PageSize; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Page margin") + .setDesc("Page margin in millimeters") + .addText((text) => + text + .setPlaceholder("25") + .setValue(this.plugin.settings.pageMargin) + .onChange(async (value) => { + this.plugin.settings.pageMargin = value || "25"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Code highlight theme") + .setDesc("Syntax highlighting theme for code blocks") + .addDropdown((dropdown) => + dropdown + .addOption("tango", "Tango (default)") + .addOption("pygments", "Pygments (minimal background)") + .addOption("zenburn", "Zenburn") + .addOption("breezedark", "Breeze Dark") + .addOption("kate", "Kate") + .addOption("monochrome", "Monochrome") + .setValue(this.plugin.settings.codeTheme) + .onChange(async (value: string) => { + this.plugin.settings.codeTheme = value as CodeTheme; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("CJK font") + .setDesc( + "Chinese/Japanese/Korean font name. On macOS, STHeitiSC-Medium is a reliable XeLaTeX choice." + ) + .addText((text) => + text + .setPlaceholder("STHeitiSC-Medium") + .setValue(this.plugin.settings.cjkFont) + .onChange(async (value) => { + this.plugin.settings.cjkFont = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Enable CJK support") + .setDesc("Add CJK font configuration for LaTeX engines") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.enableCjk) + .onChange(async (value) => { + this.plugin.settings.enableCjk = value; + await this.plugin.saveSettings(); + }) + ); + + // === Advanced === + new Setting(containerEl).setName("Advanced").setHeading(); + + new Setting(containerEl) + .setName("Custom CSS file") + .setDesc( + "Path to custom CSS file (relative to vault root, for HTML-based engines)" + ) + .addText((text) => + text + .setPlaceholder("styles/custom-pdf.css") + .setValue(this.plugin.settings.customCssPath) + .onChange(async (value) => { + this.plugin.settings.customCssPath = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Custom Pandoc template") + .setDesc("Path to custom Pandoc template file") + .addText((text) => + text + .setPlaceholder("templates/custom.html") + .setValue(this.plugin.settings.customTemplatePath) + .onChange(async (value) => { + this.plugin.settings.customTemplatePath = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Mermaid CLI path") + .setDesc("Path to mmdc binary for Mermaid diagram rendering") + .addText((text) => + text + .setPlaceholder("mmdc") + .setValue(this.plugin.settings.mermaidPath) + .onChange(async (value) => { + this.plugin.settings.mermaidPath = value || "mmdc"; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Mermaid theme") + .setDesc("Theme for Mermaid diagrams") + .addDropdown((dropdown) => + dropdown + .addOption("default", "Default") + .addOption("dark", "Dark") + .addOption("forest", "Forest") + .addOption("neutral", "Neutral") + .setValue(this.plugin.settings.mermaidTheme) + .onChange(async (value: string) => { + this.plugin.settings.mermaidTheme = value as MermaidTheme; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Extra Pandoc arguments") + .setDesc("Additional command-line arguments passed to Pandoc") + .addText((text) => + text + .setPlaceholder("--pdf-engine-opt=--enable-local-file-access") + .setValue(this.plugin.settings.extraArgs) + .onChange(async (value) => { + this.plugin.settings.extraArgs = value; + await this.plugin.saveSettings(); + }) + ); + + // === Batch === + new Setting(containerEl).setName("Batch Export").setHeading(); + + new Setting(containerEl) + .setName("Concurrency") + .setDesc("Number of files to export in parallel") + .addText((text) => + text + .setPlaceholder("3") + .setValue(String(this.plugin.settings.concurrency)) + .onChange(async (value) => { + const num = parseInt(value, 10); + if (!isNaN(num) && num > 0 && num <= 20) { + this.plugin.settings.concurrency = num; + await this.plugin.saveSettings(); + } + }) + ); + + new Setting(containerEl) + .setName("Skip errors") + .setDesc("Continue exporting remaining files if one fails") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.skipErrors) + .onChange(async (value) => { + this.plugin.settings.skipErrors = value; + await this.plugin.saveSettings(); + }) + ); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a9e2446 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,144 @@ +import { TFile, TFolder } from "obsidian"; + +// === Settings === + +export type PdfEngine = + | "xelatex" + | "wkhtmltopdf" + | "weasyprint" + | "pdflatex" + | "lualatex" + | "typst"; + +export type OutputFormat = "pdf" | "docx" | "html"; +export type OutputNaming = "same" | "timestamp" | "suffix"; +export type PageSize = "A4" | "Letter" | "Legal" | "A3"; +export type CodeTheme = + | "tango" + | "zenburn" + | "breezedark" + | "pygments" + | "kate" + | "monochrome"; +export type MermaidTheme = "default" | "dark" | "forest" | "neutral"; + +export interface PluginSettings { + // Pandoc + pandocPath: string; + pdfEngine: PdfEngine; + enginePath: string; + + // Output + outputDir: string; + outputNaming: OutputNaming; + openAfterExport: boolean; + defaultFormat: OutputFormat; + + // Style & Template + customCssPath: string; + customTemplatePath: string; + fontSize: number; + pageSize: PageSize; + pageMargin: string; + codeTheme: CodeTheme; + + // CJK + cjkFont: string; + enableCjk: boolean; + + // Mermaid + mermaidPath: string; + mermaidTheme: MermaidTheme; + + // Advanced + extraArgs: string; + + // Batch + concurrency: number; + skipErrors: boolean; +} + +export const DEFAULT_SETTINGS: PluginSettings = { + pandocPath: "/opt/homebrew/bin/pandoc", + pdfEngine: "xelatex", + enginePath: "", + outputDir: "pdf", + outputNaming: "same", + openAfterExport: true, + defaultFormat: "pdf", + customCssPath: "", + customTemplatePath: "", + fontSize: 11, + pageSize: "A4", + pageMargin: "25", + codeTheme: "tango", + cjkFont: "", + enableCjk: true, + mermaidPath: "mmdc", + mermaidTheme: "default", + extraArgs: "", + concurrency: 3, + skipErrors: true, +}; + +// === Export === + +export interface ExportResult { + success: boolean; + outputPath?: string; + error?: string; + duration: number; +} + +export interface PandocOptions { + inputPath: string; + outputPath: string; + format: OutputFormat; + engine: PdfEngine; + pandocPath: string; + tempDir: string; + fontSize: number; + pageSize: string; + pageMargin: string; + codeTheme: string; + cjkFont: string; + enableCjk: boolean; + customCssPath?: string; + customTemplatePath?: string; + extraArgs: string[]; +} + +export interface RenderResult { + content: string; + tempFiles: string[]; +} + +// === Callout === + +export type CalloutType = + | "note" + | "tip" + | "important" + | "warning" + | "caution" + | "abstract" + | "info" + | "todo" + | "example" + | "quote" + | "success" + | "question" + | "failure" + | "danger" + | "bug"; + +// === Batch Export === + +export interface BatchResult { + total: number; + success: number; + failed: number; + errors: Array<{ file: string; error: string }>; + outputDir: string; + duration: number; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bb1e883 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,246 @@ +import { App, TFile, TFolder, Vault } from "obsidian"; +import { exec, spawn } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +// === Login Shell Command Execution === +// Electron doesn't inherit the user's shell PATH. +// We use exec() with a login shell wrapper so Homebrew etc. are on PATH. + +export function runCommand( + cmd: string, + options?: { timeout?: number } +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve) => { + exec( + cmd, + { + timeout: options?.timeout || 30000, + maxBuffer: 10 * 1024 * 1024, + // Use login shell so PATH includes Homebrew + shell: "/bin/zsh", + env: { + ...process.env, + PATH: `/Library/TeX/texbin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ""}`, + }, + }, + (error, stdout, stderr) => { + resolve({ + stdout: stdout || "", + stderr: stderr || "", + code: error ? error.code || 1 : 0, + }); + } + ); + }); +} + +// === Vault Path === + +export function getVaultPath(app: App): string { + return (app.vault.adapter as any).basePath; +} + +// === Attachment Path Resolution === + +export function resolveAttachmentPath( + src: string, + currentFile: TFile, + app: App +): string { + const vaultPath = getVaultPath(app); + + // Already absolute + if (path.isAbsolute(src)) { + return src; + } + + // URL — skip + if (src.startsWith("http://") || src.startsWith("https://")) { + return src; + } + + // Data URI — skip + if (src.startsWith("data:")) { + return src; + } + + // Try relative to current file directory + const currentDir = path.dirname(currentFile.path); + const relativePath = path.join(currentDir, src); + const absRelative = path.join(vaultPath, relativePath); + if (fs.existsSync(absRelative)) { + return absRelative; + } + + // Try vault root + const absRoot = path.join(vaultPath, src); + if (fs.existsSync(absRoot)) { + return absRoot; + } + + // Try Obsidian attachmentFolderPath setting + const attachmentFolder = (app.vault as any).getConfig?.("attachmentFolderPath"); + if (attachmentFolder) { + const absAttachment = path.join(vaultPath, attachmentFolder, src); + if (fs.existsSync(absAttachment)) { + return absAttachment; + } + } + + // Fallback: return as-is (relative path, may or may not work) + return src; +} + +// === CJK Font Detection === + +export async function detectCjkFont(): Promise { + const platform = os.platform(); + + if (platform === "darwin") { + const fonts = ["STHeitiSC-Medium", "Heiti SC", "Hiragino Sans", "HiraginoSans-W6"]; + for (const font of fonts) { + const { stdout } = await runCommand(`fc-list ':family=${font}'`); + if (stdout.trim().length > 0) return font; + } + return ""; + } else if (platform === "win32") { + return ""; + } else { + const fonts = ["Noto Sans CJK SC", "WenQuanYi Micro Hei", "Droid Sans Fallback"]; + for (const font of fonts) { + const { stdout } = await runCommand(`fc-list ':family=${font}'`); + if (stdout.trim().length > 0) return font; + } + return ""; + } +} + +// === Command Detection === + +export async function checkCommandExists(cmd: string): Promise { + const platform = os.platform(); + const checkCmd = platform === "win32" ? `where ${cmd}` : `which ${cmd}`; + const { code } = await runCommand(checkCmd); + return code === 0; +} + +// === Temp Directory === + +export function getTmpDir(vaultPath: string): string { + const tmpDir = path.join( + vaultPath, + ".obsidian", + "plugins", + "obsidian-press", + "tmp" + ); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + return tmpDir; +} + +export async function cleanTmpDir(tmpDir: string): Promise { + try { + if (fs.existsSync(tmpDir)) { + const files = fs.readdirSync(tmpDir); + for (const file of files) { + fs.unlinkSync(path.join(tmpDir, file)); + } + } + } catch { + // Ignore cleanup errors + } +} + +// === Semaphore === + +export interface Semaphore { + acquire(): Promise; + release(): void; +} + +export function createSemaphore(limit: number): Semaphore { + let current = 0; + const queue: Array<() => void> = []; + + return { + async acquire(): Promise { + if (current < limit) { + current++; + return; + } + return new Promise((resolve) => { + queue.push(resolve); + }); + }, + release(): void { + current--; + if (queue.length > 0) { + current++; + const next = queue.shift()!; + next(); + } + }, + }; +} + +// === File Helpers === + +export function getOutputPath( + file: TFile, + vaultPath: string, + outputDir: string, + naming: string, + format: string +): string { + const baseName = path.basename(file.path, ".md"); + let fileName: string; + + switch (naming) { + case "timestamp": + const ts = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + fileName = `${baseName}_${ts}.${format}`; + break; + case "suffix": + fileName = `${baseName}_export.${format}`; + break; + default: + fileName = `${baseName}.${format}`; + } + + // If outputDir is relative, resolve relative to the file's directory + let outDir: string; + if (path.isAbsolute(outputDir)) { + outDir = outputDir; + } else if (outputDir) { + outDir = path.join(vaultPath, outputDir); + } else { + outDir = path.join(vaultPath, "pdf"); + } + + // Create directory if needed + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + return path.join(outDir, fileName); +} + +// === Pandoc Version Check === + +export async function getPandocVersion( + pandocPath: string +): Promise { + const { stdout, code } = await runCommand(`${pandocPath} --version`); + if (code === 0) { + const match = stdout.match(/pandoc\s+(\d+\.\d+[\.\d]*)/); + return match ? match[1] : "unknown"; + } + return null; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e65cf78 --- /dev/null +++ b/styles.css @@ -0,0 +1,67 @@ +/* Obsidian Press - Plugin UI Styles */ + +.obsidian-press-progress { + padding: 8px 12px; + margin: 4px 0; + border-radius: 4px; + background: var(--background-secondary); + font-size: 0.9em; +} + +.obsidian-press-progress .progress-bar { + height: 4px; + background: var(--background-modifier-border); + border-radius: 2px; + margin-top: 4px; + overflow: hidden; +} + +.obsidian-press-progress .progress-bar-fill { + height: 100%; + background: var(--interactive-accent); + border-radius: 2px; + transition: width 0.2s ease; +} + +.obsidian-press-success { + color: var(--text-success); +} + +.obsidian-press-error { + color: var(--text-error); +} + +.obsidian-press-progress-notice { + min-width: 260px; +} + +.obsidian-press-progress-title { + font-weight: 600; + margin-bottom: 4px; +} + +.obsidian-press-progress-detail { + color: var(--text-muted); + font-size: 12px; + line-height: 1.4; + margin-bottom: 8px; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.obsidian-press-progress-bar { + background: var(--background-modifier-border); + border-radius: 999px; + height: 6px; + overflow: hidden; +} + +.obsidian-press-progress-bar-fill { + background: var(--interactive-accent); + border-radius: inherit; + height: 100%; + transition: width 180ms ease; + width: 0; +} diff --git a/styles/default.css b/styles/default.css new file mode 100644 index 0000000..68eca04 --- /dev/null +++ b/styles/default.css @@ -0,0 +1,314 @@ +/* Obsidian Press — Default PDF Stylesheet */ + +/* === Base === */ +:root { + --text-color: #1a1a1a; + --bg-color: #ffffff; + --code-bg: #f5f5f5; + --border-color: #e0e0e0; + --accent-color: #7c3aed; +} + +@page { + size: A4; + margin: 25mm; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 11pt; + line-height: 1.6; + color: var(--text-color); + max-width: 100%; +} + +/* === Headings === */ +h1, h2, h3, h4, h5, h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + font-weight: 600; + line-height: 1.3; + page-break-after: avoid; +} + +h1 { + font-size: 2em; + border-bottom: 2px solid var(--accent-color); + padding-bottom: 0.3em; +} + +h2 { + font-size: 1.5em; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.2em; +} + +h3 { font-size: 1.25em; } +h4 { font-size: 1.1em; } +h5 { font-size: 1em; } +h6 { font-size: 0.9em; color: #666; } + +/* === Paragraphs === */ +p { + margin: 0.8em 0; + orphans: 3; + widows: 3; +} + +/* === Links === */ +a { + color: var(--accent-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* === Images === */ +img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + border-radius: 4px; +} + +/* Pandoc-style image attributes */ +img[width] { + display: inline-block; +} + +figure { + margin: 1.5em 0; + text-align: center; + page-break-inside: avoid; +} + +figcaption { + font-size: 0.9em; + color: #666; + margin-top: 0.5em; +} + +/* === Code === */ +code { + font-family: "Fira Code", "JetBrains Mono", "SF Mono", "Cascadia Code", Consolas, monospace; + font-size: 0.85em; + background: var(--code-bg); + padding: 0.15em 0.4em; + border-radius: 3px; +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 1em; + overflow-x: auto; + page-break-inside: avoid; + margin: 1em 0; +} + +pre code { + background: none; + padding: 0; + font-size: 0.85em; + line-height: 1.5; +} + +/* Pandoc code blocks */ +.sourceCode { + background: var(--code-bg); +} + +pre.sourceCode { + border: 1px solid var(--border-color); + border-radius: 6px; +} + +/* === Blockquotes === */ +blockquote { + margin: 1em 0; + padding: 0.5em 1em; + border-left: 4px solid var(--accent-color); + background: #f8f8f8; + color: #555; +} + +blockquote p { + margin: 0.3em 0; +} + +/* === Callouts (Obsidian-style) === */ +.callout { + margin: 1.5em 0; + padding: 1em; + border-radius: 8px; + border-left: 4px solid; + page-break-inside: avoid; +} + +.callout-title { + font-weight: 600; + font-size: 1.05em; + margin-bottom: 0.5em; +} + +.callout-body { + font-size: 0.95em; +} + +.callout-body p { + margin: 0.3em 0; +} + +/* Callout type colors */ +.callout-note { border-color: #448aff; background: #e8f0fe; } +.callout-note .callout-title { color: #1a73e8; } + +.callout-tip { border-color: #00bfa5; background: #e0f7fa; } +.callout-tip .callout-title { color: #00897b; } + +.callout-important { border-color: #7c4dff; background: #ede7f6; } +.callout-important .callout-title { color: #651fff; } + +.callout-warning { border-color: #ff9100; background: #fff3e0; } +.callout-warning .callout-title { color: #e65100; } + +.callout-caution { border-color: #ff5252; background: #ffebee; } +.callout-caution .callout-title { color: #d50000; } + +.callout-abstract { border-color: #448aff; background: #e8eaf6; } +.callout-abstract .callout-title { color: #283593; } + +.callout-info { border-color: #448aff; background: #e3f2fd; } +.callout-info .callout-title { color: #1565c0; } + +.callout-todo { border-color: #448aff; background: #e8f0fe; } +.callout-todo .callout-title { color: #1a73e8; } + +.callout-example { border-color: #7c4dff; background: #f3e5f5; } +.callout-example .callout-title { color: #7b1fa2; } + +.callout-quote { border-color: #9e9e9e; background: #fafafa; } +.callout-quote .callout-title { color: #616161; } + +.callout-success { border-color: #00c853; background: #e8f5e9; } +.callout-success .callout-title { color: #2e7d32; } + +.callout-question { border-color: #ff6d00; background: #fff8e1; } +.callout-question .callout-title { color: #e65100; } + +.callout-failure { border-color: #ff1744; background: #fce4ec; } +.callout-failure .callout-title { color: #c62828; } + +.callout-danger { border-color: #d50000; background: #ffebee; } +.callout-danger .callout-title { color: #b71c1c; } + +.callout-bug { border-color: #f44336; background: #fbe9e7; } +.callout-bug .callout-title { color: #bf360c; } + +/* === Tables === */ +table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + page-break-inside: avoid; +} + +thead { + background: #f0f0f0; +} + +th, td { + padding: 0.5em 0.8em; + border: 1px solid var(--border-color); + text-align: left; +} + +th { + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: #fafafa; +} + +/* === Lists === */ +ul, ol { + margin: 0.5em 0; + padding-left: 2em; +} + +li { + margin: 0.3em 0; +} + +li > ul, li > ol { + margin: 0.2em 0; +} + +/* Task lists */ +li.task-list-item { + list-style: none; + margin-left: -1.5em; +} + +li.task-list-item input[type="checkbox"] { + margin-right: 0.5em; +} + +/* === Horizontal Rule === */ +hr { + border: none; + border-top: 2px solid var(--border-color); + margin: 2em 0; +} + +/* === Math === */ +.math-display { + margin: 1.5em 0; + text-align: center; + overflow-x: auto; +} + +.math-inline { + font-style: italic; +} + +/* === Mark (highlights) === */ +mark { + background: #fff176; + padding: 0.1em 0.3em; + border-radius: 2px; +} + +/* === Superscript / Subscript === */ +sup { font-size: 0.75em; vertical-align: super; } +sub { font-size: 0.75em; vertical-align: sub; } + +/* === Print adjustments === */ +@media print { + body { + font-size: 10pt; + } + + h1, h2, h3 { + page-break-after: avoid; + } + + pre, blockquote, table, .callout, figure { + page-break-inside: avoid; + } + + a { + color: var(--accent-color); + } + + a[href^="http"]::after { + content: " (" attr(href) ")"; + font-size: 0.8em; + color: #999; + } +} diff --git a/templates/default.html b/templates/default.html new file mode 100644 index 0000000..40c56f1 --- /dev/null +++ b/templates/default.html @@ -0,0 +1,114 @@ + + + + + + $if(title)$$title$$endif$ + $for(header-includes)$ + $header-includes$ + $endfor$ + + + + $for(include-before)$ + $include-before$ + $endfor$ + + $if(title)$ +
+

$title$

+ $if(subtitle)$ +

$subtitle$

+ $endif$ + $for(author)$ +

$author$

+ $endfor$ + $if(date)$ +

$date$

+ $endif$ +
+ $endif$ + + $if(toc)$ + + $endif$ + + $body$ + + $for(include-after)$ + $include-after$ + $endfor$ + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..97dc20b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["src/**/*.ts"] +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..fed02de --- /dev/null +++ b/version-bump.mjs @@ -0,0 +1,14 @@ +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +// Update manifest.json +const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + +// Update versions.json +const versions = JSON.parse(readFileSync("versions.json", "utf8")); +versions[targetVersion] = minAppVersion; +writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..af9a39e --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "0.15.0" +}