mirror of
https://github.com/taylorchen/obsidian-press.git
synced 2026-07-22 06:52:12 +00:00
first version
This commit is contained in:
commit
76706dc48c
25 changed files with 7026 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
17
CHANGELOG.md
Normal 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
65
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
519
README.md
Normal 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 → **导出为 PDF(Obsidian Press)**
|
||||
- Right-click a `.md` file → **选择目录导出为 PDF(Obsidian Press)**
|
||||
- Right-click a folder → **全部导出为 PDF(Obsidian Press)**
|
||||
- Right-click a folder → **选择目录全部导出为 PDF(Obsidian Press)**
|
||||
|
||||
## Settings
|
||||
|
||||
### General
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Pandoc path | `/opt/homebrew/bin/pandoc` | Path to the Pandoc executable. Required for every export format and every PDF engine |
|
||||
| PDF engine | XeLaTeX | Engine used for PDF output. XeLaTeX is recommended for Chinese/Japanese/Korean notes and high-quality typesetting |
|
||||
| Default format | PDF | Default export format |
|
||||
|
||||
### Output
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Output directory | `pdf` | Relative to vault root, or absolute path |
|
||||
| File naming | Same as source | `same` / `timestamp` / `suffix` |
|
||||
| Open after export | On | Auto-open exported file |
|
||||
|
||||
### Typography
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Font size | 11pt | Base font size |
|
||||
| Page size | A4 | A4, Letter, Legal, A3 |
|
||||
| Page margin | 25mm | Page margin |
|
||||
| Code theme | Tango | Syntax highlight theme. Tango is the default because it gives PDF code blocks a visible background |
|
||||
| CJK font | (auto-detect) | Chinese/Japanese/Korean font. On macOS, XeLaTeX falls back to `STHeitiSC-Medium` when this is empty |
|
||||
| Enable CJK support | On | CJK font config for LaTeX |
|
||||
|
||||
### Advanced
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Custom CSS file | (empty) | Path to custom CSS (HTML engines only) |
|
||||
| Custom Pandoc template | (empty) | Path to custom Pandoc template |
|
||||
| Mermaid CLI path | `mmdc` | Path to mermaid-cli binary |
|
||||
| Mermaid theme | Default | Mermaid diagram theme |
|
||||
| Extra Pandoc arguments | (empty) | Additional CLI args for Pandoc |
|
||||
|
||||
### Batch Export
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Concurrency | 3 | Parallel export count |
|
||||
| Skip errors | On | Continue on failure |
|
||||
|
||||
## Content Fidelity
|
||||
|
||||
Obsidian-specific syntax is pre-processed before passing to Pandoc:
|
||||
|
||||
PDF export is powered by Pandoc engines. XeLaTeX is the recommended default for mixed Chinese/English notes, while Typst, wkhtmltopdf, and WeasyPrint are available for users who prefer those toolchains.
|
||||
|
||||
| Obsidian Syntax | Output |
|
||||
|----------------|--------|
|
||||
| `> [!note] Title` callouts | Styled `<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
46
esbuild.config.mjs
Normal 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();
|
||||
}
|
||||
9
manifest.json
Normal file
9
manifest.json
Normal 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
611
package-lock.json
generated
Normal 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
30
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
127
scripts/verify-code-export.mjs
Normal file
127
scripts/verify-code-export.mjs
Normal 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
848
src/exporter.ts
Normal 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
474
src/main.ts
Normal 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("导出为 PDF(Obsidian Press)")
|
||||
.setIcon("file-output")
|
||||
.onClick(() => this.exportSpecificFile(file));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("选择目录导出为 PDF(Obsidian Press)")
|
||||
.setIcon("folder-open")
|
||||
.onClick(() => this.exportSpecificFileWithDirectory(file, "pdf"));
|
||||
});
|
||||
}
|
||||
|
||||
if (file instanceof TFolder) {
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("全部导出为 PDF(Obsidian Press)")
|
||||
.setIcon("file-output")
|
||||
.onClick(() => this.exportFolder(file));
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("选择目录全部导出为 PDF(Obsidian Press)")
|
||||
.setIcon("folder-open")
|
||||
.onClick(() => this.exportFolderWithDirectory(file));
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Settings tab
|
||||
this.addSettingTab(new ObsidianPressSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
onunload() {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
if ((this.settings.pdfEngine as string) === "chromium") {
|
||||
this.settings.pdfEngine = "xelatex";
|
||||
await this.saveSettings();
|
||||
}
|
||||
if (this.settings.codeTheme === "pygments") {
|
||||
this.settings.codeTheme = "tango";
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
// === Export actions ===
|
||||
|
||||
private async exportCurrentNote(forceFormat?: OutputFormat) {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || file.extension !== "md") {
|
||||
new Notice("No active Markdown file");
|
||||
return;
|
||||
}
|
||||
|
||||
const format = forceFormat || this.settings.defaultFormat;
|
||||
const settings = { ...this.settings, defaultFormat: format };
|
||||
await this.exportSpecificFile(file, settings);
|
||||
}
|
||||
|
||||
private async exportCurrentNoteWithDirectory(forceFormat?: OutputFormat) {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (!file || file.extension !== "md") {
|
||||
new Notice("No active Markdown file");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.exportSpecificFileWithDirectory(
|
||||
file,
|
||||
forceFormat || this.settings.defaultFormat
|
||||
);
|
||||
}
|
||||
|
||||
private async exportSpecificFileWithDirectory(
|
||||
file: TFile,
|
||||
format: OutputFormat
|
||||
) {
|
||||
const outputDir = await this.chooseOutputDirectory();
|
||||
if (!outputDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.exportSpecificFile(file, {
|
||||
...this.settings,
|
||||
defaultFormat: format,
|
||||
outputDir,
|
||||
});
|
||||
}
|
||||
|
||||
private async exportSpecificFile(
|
||||
file: TFile,
|
||||
overrideSettings?: PluginSettings
|
||||
) {
|
||||
const settings = overrideSettings || this.settings;
|
||||
|
||||
// Pre-flight check
|
||||
const check = await preflightChecks(settings);
|
||||
if (!check.ok) {
|
||||
new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000);
|
||||
return;
|
||||
}
|
||||
|
||||
const formatLabel = settings.defaultFormat.toUpperCase();
|
||||
const progress = new ExportProgressNotice(
|
||||
`正在导出 ${formatLabel}`,
|
||||
file.name
|
||||
);
|
||||
|
||||
try {
|
||||
progress.update(`正在导出 ${formatLabel}`, "准备文件...", 20);
|
||||
const result = await exportFile(file, this.app, settings);
|
||||
|
||||
progress.update(`正在导出 ${formatLabel}`, "写入导出文件...", 90);
|
||||
progress.hide();
|
||||
|
||||
if (result.success) {
|
||||
new Notice(
|
||||
`Exported: ${result.outputPath}\n(${(result.duration / 1000).toFixed(1)}s)`
|
||||
);
|
||||
|
||||
// Open the exported file
|
||||
if (settings.openAfterExport && result.outputPath) {
|
||||
window.open(`file://${result.outputPath}`);
|
||||
}
|
||||
} else {
|
||||
new Notice(`Export failed: ${result.error}`, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
progress.hide();
|
||||
new Notice(
|
||||
`Export error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
8000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportFolder(folder: TFolder, overrideSettings?: PluginSettings) {
|
||||
const settings = overrideSettings || this.settings;
|
||||
|
||||
// Pre-flight check
|
||||
const check = await preflightChecks(settings);
|
||||
if (!check.ok) {
|
||||
new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = new ExportProgressNotice(
|
||||
"正在导出文件夹",
|
||||
folder.name
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await exportFolder(
|
||||
folder,
|
||||
this.app,
|
||||
settings,
|
||||
(done, total, current) => {
|
||||
progress.update(
|
||||
"正在导出文件夹",
|
||||
`${done}/${total} · ${current}`,
|
||||
total > 0 ? (done / total) * 100 : 0
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
progress.hide();
|
||||
|
||||
const summary = [
|
||||
`Export complete`,
|
||||
`Total: ${result.total}`,
|
||||
`Success: ${result.success}`,
|
||||
`Failed: ${result.failed}`,
|
||||
`Output: ${result.outputDir}`,
|
||||
`Time: ${(result.duration / 1000).toFixed(1)}s`,
|
||||
].join("\n");
|
||||
|
||||
new Notice(summary, 10000);
|
||||
|
||||
if (result.failed > 0 && result.errors.length > 0) {
|
||||
console.error("Obsidian Press export errors:", result.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
progress.hide();
|
||||
new Notice(
|
||||
`Export error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
8000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportFolderWithDirectory(folder: TFolder) {
|
||||
const outputDir = await this.chooseOutputDirectory();
|
||||
if (!outputDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.exportFolder(folder, {
|
||||
...this.settings,
|
||||
outputDir,
|
||||
});
|
||||
}
|
||||
|
||||
private async exportEntireVault(overrideSettings?: PluginSettings) {
|
||||
const settings = overrideSettings || this.settings;
|
||||
|
||||
// Pre-flight check
|
||||
const check = await preflightChecks(settings);
|
||||
if (!check.ok) {
|
||||
new Notice(`Export failed:\n${check.errors.join("\n")}`, 8000);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = new ExportProgressNotice(
|
||||
"正在导出整个仓库",
|
||||
"扫描 Markdown 文件..."
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await exportVault(
|
||||
this.app,
|
||||
settings,
|
||||
(done, total, current) => {
|
||||
progress.update(
|
||||
"正在导出整个仓库",
|
||||
`${done}/${total} · ${current}`,
|
||||
total > 0 ? (done / total) * 100 : 0
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
progress.hide();
|
||||
|
||||
const summary = [
|
||||
`Vault export complete`,
|
||||
`Total: ${result.total}`,
|
||||
`Success: ${result.success}`,
|
||||
`Failed: ${result.failed}`,
|
||||
`Output: ${result.outputDir}`,
|
||||
`Time: ${(result.duration / 1000).toFixed(1)}s`,
|
||||
].join("\n");
|
||||
|
||||
new Notice(summary, 10000);
|
||||
|
||||
if (result.failed > 0 && result.errors.length > 0) {
|
||||
console.error("Obsidian Press export errors:", result.errors);
|
||||
}
|
||||
} catch (err) {
|
||||
progress.hide();
|
||||
new Notice(
|
||||
`Export error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
8000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async exportEntireVaultWithDirectory() {
|
||||
const outputDir = await this.chooseOutputDirectory();
|
||||
if (!outputDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.exportEntireVault({
|
||||
...this.settings,
|
||||
outputDir,
|
||||
});
|
||||
}
|
||||
|
||||
private getConfiguredOutputDirectory(): string {
|
||||
const vaultPath = getVaultPath(this.app);
|
||||
const outputDir = this.settings.outputDir || "pdf";
|
||||
return path.isAbsolute(outputDir)
|
||||
? outputDir
|
||||
: path.join(vaultPath, outputDir);
|
||||
}
|
||||
|
||||
private async chooseOutputDirectory(): Promise<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
70
src/mermaid.ts
Normal 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
357
src/pandoc.ts
Normal 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
567
src/renderer.ts
Normal 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]] → 
|
||||
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 = `${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 {
|
||||
//  → {width=size}
|
||||
// Already handled in convertEmbeds for ![[ ]] syntax
|
||||
// Handle standard markdown image with Obsidian size: 
|
||||
return content.replace(
|
||||
/!\[([^\]]*?)\|(\d+)\]\(([^)]+)\)/g,
|
||||
(_match, alt, size, url) => {
|
||||
return `{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,
|
||||
``
|
||||
);
|
||||
} 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
308
src/settings.ts
Normal 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
144
src/types.ts
Normal 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
246
src/utils.ts
Normal 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
67
styles.css
Normal 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
314
styles/default.css
Normal 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
114
templates/default.html
Normal 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
17
tsconfig.json
Normal 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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue