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

$title$

+ $if(subtitle)$ +

$subtitle$

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

$author$

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

$date$

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