first version

This commit is contained in:
mk 2026-04-29 07:36:23 +08:00
commit 76706dc48c
25 changed files with 7026 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -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/

17
CHANGELOG.md Normal file
View file

@ -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.

65
CONTRIBUTING.md Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

519
README.md Normal file
View file

@ -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: <https://pandoc.org/installing.html>
- GitHub releases: <https://github.com/jgm/pandoc/releases>
- Homebrew formula: <https://formulae.brew.sh/formula/pandoc>
```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: <https://brew.sh/>.
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: <https://www.tug.org/mactex/>
- BasicTeX packages: <https://www.tug.org/mactex/morepackages.html>
- CTAN MacTeX directory: <https://ctan.org/texarchive/systems/mac/mactex/>
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: <https://www.npmjs.com/package/@mermaid-js/mermaid-cli>
- GitHub repository: <https://github.com/mermaid-js/mermaid-cli>
```bash
npm install -g @mermaid-js/mermaid-cli
```
Verify:
```bash
which mmdc
mmdc --version
```
## Installation
### From Source
```bash
git clone <repo-url>
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 `<vault>/.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 → **导出为 PDFObsidian Press**
- Right-click a `.md` file → **选择目录导出为 PDFObsidian Press**
- Right-click a folder → **全部导出为 PDFObsidian Press**
- Right-click a folder → **选择目录全部导出为 PDFObsidian 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 `<div>` 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==` | `<mark>` HTML tags |
| `^superscript^` | `<sup>` HTML tags |
| `~~subscript~~` | `<sub>` 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).

46
esbuild.config.mjs Normal file
View file

@ -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();
}

2015
main.js Normal file

File diff suppressed because it is too large Load diff

9
manifest.json Normal file
View file

@ -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
}

611
package-lock.json generated Normal file
View file

@ -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
}
}
}

30
package.json Normal file
View file

@ -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"
}
}

View file

@ -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}`);

848
src/exporter.ts Normal file
View file

@ -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<ExportResult> {
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<BatchResult> {
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<BatchResult> {
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<Omit<BatchResult, "duration">> {
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<string> {
const references = collectImageReferences(content);
const replacements: Array<{ original: string; converted: string }> = [];
const localizedBySource = new Map<string, string>();
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<string> {
const references = collectImageReferences(content);
const replacements: Array<{ original: string; converted: string }> = [];
const convertedBySource = new Map<string, string>();
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<string>();
const markdownImageRegex = /!\[[^\]]*\]\(\s*(<[^>]+>|[^)]+?)(?:\s+["'][^"']*["'])?\s*\)(?:\{[^}]*\})?/gi;
const htmlImageRegex = /<img\b[^>]*\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<string | null> {
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<string> {
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<string | null> {
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<RemoteImage> {
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("<svg");
}
function createMissingImagePlaceholder(tmpDir: string, index: number): string {
const outputPath = path.join(
tmpDir,
`remote-image-missing-${Date.now()}-${index}.svg`
);
fs.writeFileSync(
outputPath,
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="160" viewBox="0 0 640 160">
<rect width="640" height="160" fill="#f6f6f6" stroke="#cccccc"/>
<text x="320" y="86" text-anchor="middle" font-family="sans-serif" font-size="20" fill="#666666">Remote image unavailable</text>
</svg>`,
"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<void> {
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,
};
}

474
src/main.ts Normal file
View file

@ -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("导出为 PDFObsidian Press")
.setIcon("file-output")
.onClick(() => this.exportSpecificFile(file));
});
menu.addItem((item) => {
item
.setTitle("选择目录导出为 PDFObsidian Press")
.setIcon("folder-open")
.onClick(() => this.exportSpecificFileWithDirectory(file, "pdf"));
});
}
if (file instanceof TFolder) {
menu.addItem((item) => {
item
.setTitle("全部导出为 PDFObsidian Press")
.setIcon("file-output")
.onClick(() => this.exportFolder(file));
});
menu.addItem((item) => {
item
.setTitle("选择目录全部导出为 PDFObsidian 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<string | null> {
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;
}
}
}

70
src/mermaid.ts Normal file
View file

@ -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<string | null> {
// 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
}
}
}

357
src/pandoc.ts Normal file
View file

@ -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<ExportResult> {
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 });
});
});
}

567
src/renderer.ts Normal file
View file

@ -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<string, string> = {
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<RenderResult> {
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== → <mark>highlight</mark>
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<string, string>;
}
function protectCodeSegments(content: string): ProtectedCodeSegments {
const segments = new Map<string, string>();
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, string>
): 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 `<div class="callout callout-${cssType}">\n<div class="callout-title">\n${icon} ${displayTitle}\n</div>\n<div class="callout-body">\n\n${body}\n\n</div>\n</div>`;
}
);
}
// === 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<string> {
// ![[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<string> {
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<!-- Embedded: ${target} -->\n\n${processed}\n\n<!-- End embed: ${target} -->\n\n`
);
}
}
return result;
}
// === Step 6: Highlights ===
function convertHighlights(content: string): string {
// ==text== → <mark>text</mark>
return content.replace(/==([^=]+)==/g, "<mark>$1</mark>");
}
// === Step 7: Superscript / Subscript ===
function convertSupSub(content: string): string {
// ^text^ → <sup>text</sup>
let result = content.replace(/\^([^\^]+)\^/g, "<sup>$1</sup>");
// ~~text~~ → <sub>text</sub>
result = result.replace(/~~([^~]+)~~/g, "<sub>$1</sub>");
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<string> {
// ```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;
}

308
src/settings.ts Normal file
View file

@ -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();
})
);
}
}

144
src/types.ts Normal file
View file

@ -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;
}

246
src/utils.ts Normal file
View file

@ -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<string> {
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<boolean> {
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<void> {
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<void>;
release(): void;
}
export function createSemaphore(limit: number): Semaphore {
let current = 0;
const queue: Array<() => void> = [];
return {
async acquire(): Promise<void> {
if (current < limit) {
current++;
return;
}
return new Promise<void>((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<string | null> {
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;
}

67
styles.css Normal file
View file

@ -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;
}

314
styles/default.css Normal file
View file

@ -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;
}
}

114
templates/default.html Normal file
View file

@ -0,0 +1,114 @@
<!DOCTYPE html>
<html$if(lang)$ lang="$lang$"$endif$>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$if(title)$$title$$endif$</title>
$for(header-includes)$
$header-includes$
$endfor$
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: ${fontSize:-11}pt;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #1a1a1a;
}
h1 { font-size: 2em; border-bottom: 2px solid #7c3aed; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #e0e0e0; padding-bottom: 0.2em; }
h3 { font-size: 1.25em; }
pre {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 1em;
overflow-x: auto;
}
code {
font-family: "Fira Code", "JetBrains Mono", Consolas, monospace;
font-size: 0.85em;
background: #f5f5f5;
padding: 0.15em 0.4em;
border-radius: 3px;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #7c3aed;
margin: 1em 0;
padding: 0.5em 1em;
background: #f8f8f8;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
th, td {
padding: 0.5em 0.8em;
border: 1px solid #e0e0e0;
text-align: left;
}
thead { background: #f0f0f0; }
img { max-width: 100%; height: auto; }
.callout {
margin: 1.5em 0;
padding: 1em;
border-radius: 8px;
border-left: 4px solid;
}
.callout-title { font-weight: 600; margin-bottom: 0.5em; }
$for(css)$
$css$
$endfor$
</style>
</head>
<body>
$for(include-before)$
$include-before$
$endfor$
$if(title)$
<header id="title-block-header">
<h1 class="title">$title$</h1>
$if(subtitle)$
<p class="subtitle">$subtitle$</p>
$endif$
$for(author)$
<p class="author">$author$</p>
$endfor$
$if(date)$
<p class="date">$date$</p>
$endif$
</header>
$endif$
$if(toc)$
<nav id="TOC">
<h2>Table of Contents</h2>
$table-of-contents$
</nav>
$endif$
$body$
$for(include-after)$
$include-after$
$endfor$
</body>
</html>

17
tsconfig.json Normal file
View file

@ -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"]
}

14
version-bump.mjs Normal file
View file

@ -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"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}