agent-reader 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,24 @@ Agent Reader 解决这三个问题:**一条命令,输出即交付**。
29
29
  | 图片 → 幻灯片 | 全屏播放、键盘翻页、自动轮播、缩略图导航 |
30
30
  | 幻灯片 → PDF | 每张图片一页,适合存档分享 |
31
31
 
32
+ ## 幻灯片快速上手
33
+
34
+ 如果你有一组图片要现场展示,直接用这一条命令:
35
+
36
+ ```bash
37
+ agent-reader slides ./my-images/
38
+ ```
39
+
40
+ 常用选项:
41
+
42
+ ```bash
43
+ agent-reader slides ./my-images/ --auto 5
44
+ agent-reader slides ./my-images/ --format pdf
45
+ ```
46
+
47
+ 支持格式:`png`、`jpg`、`jpeg`、`gif`、`svg`、`webp`。
48
+ 图片按自然顺序排序(例如 `1`、`2`、`10`,不是 `1`、`10`、`2`)。
49
+
32
50
  ## 两种使用方式
33
51
 
34
52
  **CLI** — 开发者和 Agent 通过命令行调用
@@ -210,3 +228,49 @@ Claude Desktop 配置(`claude_desktop_config.json`):
210
228
  | Node.js 18+ | 是 | 运行环境 |
211
229
  | Puppeteer | 是 | PDF 导出(安装时自动下载 Chromium) |
212
230
  | Pandoc | 否 | Word 导出更好看(没有会自动降级为纯 JS 方案) |
231
+
232
+ ## 云环境部署
233
+
234
+ 在 Docker/CI 中,Agent Reader 会自动检测环境并在需要时为 Puppeteer 关闭沙盒参数(`--no-sandbox` 等)。
235
+
236
+ 手动覆盖方式:
237
+
238
+ ```bash
239
+ # auto | on | off
240
+ AGENT_READER_SANDBOX=off agent-reader export report.md --format pdf
241
+ ```
242
+
243
+ Docker 环境建议预装 Chromium 依赖(示例):
244
+
245
+ ```bash
246
+ apt-get update && apt-get install -y chromium-browser
247
+ ```
248
+
249
+ ## FAQ:没有 Pandoc 怎么办?
250
+
251
+ - 不装 Pandoc 也能导出 DOCX,只是排版会使用基础模式
252
+ - 装 Pandoc 后,代码块和复杂表格的效果更稳定
253
+
254
+ 常见安装命令:
255
+
256
+ ```bash
257
+ # macOS
258
+ brew install pandoc
259
+
260
+ # Linux
261
+ apt-get install pandoc
262
+
263
+ # Windows
264
+ winget install pandoc
265
+ # or: choco install pandoc
266
+ # or: scoop install pandoc
267
+ ```
268
+
269
+ ## Puppeteer 进阶安装
270
+
271
+ 如果你已经有可用浏览器,想减少安装体积:
272
+
273
+ ```bash
274
+ PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm install -g agent-reader
275
+ PUPPETEER_EXECUTABLE_PATH=/path/to/chrome agent-reader export report.md --format pdf
276
+ ```
package/SKILL.md ADDED
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: agent_reader
3
+ description: 把 Markdown 渲染成漂亮网页、导出 Word/PDF、图片做幻灯片。专为 AI Agent 输出设计。
4
+ ---
5
+
6
+ # Agent Reader Skill
7
+
8
+ ## 能力
9
+
10
+ - Markdown 渲染为可阅读网页(目录、代码高亮、表格样式)
11
+ - 导出 PDF / DOCX 文档
12
+ - 图片目录生成幻灯片并支持导出 PDF
13
+
14
+ ## 触发条件
15
+
16
+ - 用户要求把 Markdown 输出整理成可交付文档
17
+ - 用户需要导出 Word 或 PDF
18
+ - 用户希望把图片集合用于展示或汇报
19
+
20
+ ## CLI 调用
21
+
22
+ ```bash
23
+ agent-reader render report.md
24
+ agent-reader export report.md --format pdf
25
+ agent-reader export report.md --format docx
26
+ agent-reader slides ./images --auto 5
27
+ ```
28
+
29
+ ## MCP 调用
30
+
31
+ - `render_markdown`
32
+ - `export_document`
33
+ - `create_slideshow`
34
+ - `open_file`
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ npm install -g agent-reader
40
+ ```
@@ -16,7 +16,7 @@ const program = new Command();
16
16
  program
17
17
  .name('agent-reader')
18
18
  .description('AI Agent output beautifier and slideshow generator')
19
- .version('0.1.0');
19
+ .version('1.1.0');
20
20
 
21
21
  setupCommonCommandOptions(
22
22
  program
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: agent_reader
3
+ description: 把 Markdown 渲染成漂亮网页、导出 Word/PDF、图片做幻灯片。专为 AI Agent 输出设计。
4
+ ---
5
+
6
+ # Agent Reader Skill
7
+
8
+ ## 能力
9
+
10
+ - Markdown 渲染为可阅读网页(目录、代码高亮、表格样式)
11
+ - 导出 PDF / DOCX 文档
12
+ - 图片目录生成幻灯片并支持导出 PDF
13
+
14
+ ## 触发条件
15
+
16
+ - 用户要求把 Markdown 输出整理成可交付文档
17
+ - 用户需要导出 Word 或 PDF
18
+ - 用户希望把图片集合用于展示或汇报
19
+
20
+ ## CLI 调用
21
+
22
+ ```bash
23
+ agent-reader render report.md
24
+ agent-reader export report.md --format pdf
25
+ agent-reader export report.md --format docx
26
+ agent-reader slides ./images --auto 5
27
+ ```
28
+
29
+ ## MCP 调用
30
+
31
+ - `render_markdown`
32
+ - `export_document`
33
+ - `create_slideshow`
34
+ - `open_file`
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ npm install -g agent-reader
40
+ ```
@@ -0,0 +1,197 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "agent-reader-openclaw-skill-schema",
4
+ "type": "object",
5
+ "tools": {
6
+ "render_markdown": {
7
+ "description": "Render markdown text into styled HTML",
8
+ "input": {
9
+ "type": "object",
10
+ "properties": {
11
+ "content": {
12
+ "type": "string",
13
+ "description": "Markdown source content"
14
+ },
15
+ "source_path": {
16
+ "type": "string",
17
+ "description": "Source markdown path for relative images; relative resources must stay under source directory."
18
+ },
19
+ "theme": {
20
+ "type": "string",
21
+ "description": "Theme name"
22
+ },
23
+ "auto_open": {
24
+ "type": "boolean",
25
+ "description": "Open output automatically (ignored in MCP)"
26
+ },
27
+ "return_content": {
28
+ "type": "boolean",
29
+ "description": "Return inline HTML content directly"
30
+ }
31
+ },
32
+ "required": [
33
+ "content"
34
+ ],
35
+ "additionalProperties": false
36
+ },
37
+ "output": {
38
+ "type": "object",
39
+ "properties": {
40
+ "html_path": {
41
+ "type": "string"
42
+ },
43
+ "content_data": {
44
+ "type": "string"
45
+ },
46
+ "format": {
47
+ "type": "string",
48
+ "enum": [
49
+ "html"
50
+ ]
51
+ },
52
+ "size": {
53
+ "type": "number"
54
+ },
55
+ "warnings": {
56
+ "type": "array",
57
+ "items": {
58
+ "type": "string"
59
+ }
60
+ }
61
+ },
62
+ "required": [
63
+ "format",
64
+ "size",
65
+ "warnings"
66
+ ],
67
+ "additionalProperties": true
68
+ }
69
+ },
70
+ "export_document": {
71
+ "description": "Export markdown text into PDF or DOCX",
72
+ "input": {
73
+ "type": "object",
74
+ "properties": {
75
+ "content": {
76
+ "type": "string",
77
+ "description": "Markdown source content"
78
+ },
79
+ "source_path": {
80
+ "type": "string",
81
+ "description": "Source markdown path for relative images; relative resources must stay under source directory."
82
+ },
83
+ "format": {
84
+ "type": "string",
85
+ "enum": [
86
+ "pdf",
87
+ "docx"
88
+ ],
89
+ "description": "Export format"
90
+ },
91
+ "return_content": {
92
+ "type": "boolean",
93
+ "description": "Return file bytes as base64"
94
+ }
95
+ },
96
+ "required": [
97
+ "content",
98
+ "format"
99
+ ],
100
+ "additionalProperties": false
101
+ },
102
+ "output": {
103
+ "type": "object",
104
+ "properties": {
105
+ "file_path": {
106
+ "type": "string"
107
+ },
108
+ "content_data": {
109
+ "type": "string"
110
+ },
111
+ "format": {
112
+ "type": "string",
113
+ "enum": [
114
+ "pdf",
115
+ "docx"
116
+ ]
117
+ },
118
+ "size": {
119
+ "type": "number"
120
+ },
121
+ "warnings": {
122
+ "type": "array",
123
+ "items": {
124
+ "type": "string"
125
+ }
126
+ }
127
+ },
128
+ "required": [
129
+ "format",
130
+ "size",
131
+ "warnings"
132
+ ],
133
+ "additionalProperties": true
134
+ }
135
+ },
136
+ "create_slideshow": {
137
+ "description": "Create slideshow HTML from an image directory",
138
+ "input": {
139
+ "type": "object",
140
+ "properties": {
141
+ "image_dir": {
142
+ "type": "string",
143
+ "description": "Absolute or relative image directory path"
144
+ },
145
+ "auto_play": {
146
+ "type": "number",
147
+ "description": "Autoplay interval in seconds"
148
+ },
149
+ "auto_open": {
150
+ "type": "boolean",
151
+ "description": "Open output automatically (ignored in MCP)"
152
+ },
153
+ "return_content": {
154
+ "type": "boolean",
155
+ "description": "Return inline HTML content directly"
156
+ }
157
+ },
158
+ "required": [
159
+ "image_dir"
160
+ ],
161
+ "additionalProperties": false
162
+ },
163
+ "output": {
164
+ "type": "object",
165
+ "properties": {
166
+ "html_path": {
167
+ "type": "string"
168
+ },
169
+ "content_data": {
170
+ "type": "string"
171
+ },
172
+ "format": {
173
+ "type": "string",
174
+ "enum": [
175
+ "html"
176
+ ]
177
+ },
178
+ "size": {
179
+ "type": "number"
180
+ },
181
+ "warnings": {
182
+ "type": "array",
183
+ "items": {
184
+ "type": "string"
185
+ }
186
+ }
187
+ },
188
+ "required": [
189
+ "format",
190
+ "size",
191
+ "warnings"
192
+ ],
193
+ "additionalProperties": true
194
+ }
195
+ }
196
+ }
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-reader",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "AI Agent 的文档美化引擎 — 一键把 Markdown 变成漂亮网页、Word、PDF 和幻灯片",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -4,7 +4,14 @@ import open from 'open';
4
4
  import { execa } from 'execa';
5
5
  import { renderMarkdown } from '../core/renderer.js';
6
6
  import { createSlideshow } from '../core/slideshow.js';
7
- import { checkPandoc, checkPuppeteer, exportDOCX, exportDOCXFromHTML, exportPDF } from '../core/exporter.js';
7
+ import {
8
+ checkPandoc,
9
+ checkPuppeteer,
10
+ exportDOCX,
11
+ exportDOCXFromHTML,
12
+ exportPDF,
13
+ resolveSandboxMode,
14
+ } from '../core/exporter.js';
8
15
  import { openTarget } from '../core/opener.js';
9
16
  import { cleanOldOutputs, createOutputDir } from '../utils/output.js';
10
17
  import { createLogger } from '../utils/logger.js';
@@ -86,6 +93,10 @@ function dedupeWarnings(warnings) {
86
93
  return [...new Set((warnings || []).filter(Boolean))];
87
94
  }
88
95
 
96
+ function resolveSandboxOption(options = {}) {
97
+ return resolveSandboxMode(options.sandbox, process.env);
98
+ }
99
+
89
100
  async function buildPrintHtml(imageDir) {
90
101
  const absDir = path.resolve(imageDir);
91
102
  const entries = await fs.readdir(absDir, { withFileTypes: true });
@@ -135,9 +146,11 @@ async function maybeServeOutput(options, outputDir, targetPath, logger, jsonOnly
135
146
  return null;
136
147
  }
137
148
 
149
+ const sandbox = resolveSandboxOption(options);
138
150
  const serverHandle = await startStaticServer(outputDir, {
139
151
  host: '127.0.0.1',
140
152
  port: Number(options.port || 3000),
153
+ sandbox,
141
154
  });
142
155
 
143
156
  const rel = path.relative(outputDir, targetPath).split(path.sep).join('/');
@@ -222,6 +235,7 @@ export async function exportCommand(file, options) {
222
235
 
223
236
  try {
224
237
  const format = normalizeFormat(options.format);
238
+ const sandbox = resolveSandboxOption(options);
225
239
  const input = await resolveMarkdownInput(file, options, mode.logger);
226
240
  const outputDir = await createOutputDir(input.name, options.outDir);
227
241
  const warnings = [];
@@ -249,6 +263,7 @@ export async function exportCommand(file, options) {
249
263
  outDir: outputDir,
250
264
  fileName: `${input.name}.pdf`,
251
265
  htmlPath,
266
+ sandbox,
252
267
  });
253
268
  warnings.push(...pdf.warnings);
254
269
  targetPath = pdf.pdfPath;
@@ -322,6 +337,7 @@ export async function slidesCommand(dir, options) {
322
337
  await fs.writeFile(printHtmlPath, result.printHtml, 'utf8');
323
338
 
324
339
  const format = String(options.format || '').toLowerCase();
340
+ const sandbox = resolveSandboxOption(options);
325
341
  const dirName = path.basename(inputDir);
326
342
 
327
343
  if (format === 'pdf') {
@@ -331,6 +347,7 @@ export async function slidesCommand(dir, options) {
331
347
  outDir: outputDir,
332
348
  fileName: `${dirName}.pdf`,
333
349
  htmlPath: printHtmlPath,
350
+ sandbox,
334
351
  });
335
352
 
336
353
  const pdfWarnings = dedupeWarnings([...result.warnings, ...(pdf.warnings || [])]);
@@ -398,6 +415,7 @@ export async function openCommand(target, options) {
398
415
  throw new Error('missing target path');
399
416
  }
400
417
 
418
+ const sandbox = resolveSandboxOption(options);
401
419
  const preferences = await loadPreferences();
402
420
  const requestedMode = normalizeOpenMode(options.as || options.mode || 'auto', 'auto');
403
421
 
@@ -410,6 +428,7 @@ export async function openCommand(target, options) {
410
428
  pageSize: options.pageSize || 'A4',
411
429
  fetchRemote: options.fetchRemote !== false,
412
430
  returnContent: false,
431
+ sandbox,
413
432
  });
414
433
 
415
434
  const canServe = result.format === 'html' && result.path;
@@ -598,5 +617,6 @@ export function setupCommonCommandOptions(command) {
598
617
  .option('--inline-all', 'inline all assets to base64')
599
618
  .option('--no-fetch-remote', 'disable remote image fetching')
600
619
  .option('--serve', 'serve generated files via local HTTP server')
601
- .option('--port <port>', 'port for --serve mode', '3000');
620
+ .option('--port <port>', 'port for --serve mode', '3000')
621
+ .option('--sandbox <mode>', 'Puppeteer sandbox mode: auto|on|off');
602
622
  }
@@ -3,6 +3,7 @@ import dns from 'node:dns/promises';
3
3
  import { promises as fs } from 'node:fs';
4
4
  import net from 'node:net';
5
5
  import path from 'node:path';
6
+ import { assertWithinBase } from '../utils/pathGuard.js';
6
7
 
7
8
  const LOCAL_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']);
8
9
  const DEFAULT_MAX_INLINE_BYTES = 200 * 1024;
@@ -241,6 +242,13 @@ async function processLocalImage(src, {
241
242
  }
242
243
 
243
244
  const resolvedPath = isAbsoluteFile ? cleanSrc : path.resolve(baseDir, cleanSrc);
245
+ if (!isAbsoluteFile) {
246
+ const withinBase = await assertWithinBase(resolvedPath, baseDir);
247
+ if (!withinBase) {
248
+ warnings.push(`path traversal blocked: ${cleanSrc}`);
249
+ return cleanSrc;
250
+ }
251
+ }
244
252
 
245
253
  let fileBuffer;
246
254
  try {
@@ -1,4 +1,4 @@
1
- import { promises as fs } from 'node:fs';
1
+ import { accessSync, constants as fsConstants, promises as fs } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createRequire } from 'node:module';
@@ -7,6 +7,7 @@ import { execa } from 'execa';
7
7
  import MarkdownIt from 'markdown-it';
8
8
  import {
9
9
  Document,
10
+ ExternalHyperlink,
10
11
  HeadingLevel,
11
12
  Packer,
12
13
  Paragraph,
@@ -25,6 +26,109 @@ const LUA_TABLE_FILTER = path.join(__dirname, 'templates', 'docx-table.lua');
25
26
  const POSTPROCESS_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'postprocess-docx.py');
26
27
 
27
28
  const markdownParser = new MarkdownIt({ html: false, linkify: true, typographer: true });
29
+ const SANDBOX_DISABLED_ARGS = ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'];
30
+ const SANDBOX_MODES = new Set(['auto', 'on', 'off']);
31
+
32
+ function isSandboxMode(mode) {
33
+ return SANDBOX_MODES.has(mode);
34
+ }
35
+
36
+ function normalizeSandboxMode(mode) {
37
+ if (typeof mode !== 'string') {
38
+ return null;
39
+ }
40
+ const normalized = mode.trim().toLowerCase();
41
+ return isSandboxMode(normalized) ? normalized : null;
42
+ }
43
+
44
+ function fileExistsSync(filePath) {
45
+ try {
46
+ accessSync(filePath, fsConstants.F_OK);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ export function detectContainerEnv(runtime = {}) {
54
+ const env = runtime.env ?? process.env;
55
+ const fileExists = runtime.fileExists ?? fileExistsSync;
56
+ const getuid = Object.prototype.hasOwnProperty.call(runtime, 'getuid')
57
+ ? runtime.getuid
58
+ : process.getuid;
59
+
60
+ if (fileExists('/.dockerenv')) {
61
+ return true;
62
+ }
63
+ if (fileExists('/run/.containerenv')) {
64
+ return true;
65
+ }
66
+ if (env?.CI === 'true') {
67
+ return true;
68
+ }
69
+
70
+ try {
71
+ if (typeof getuid === 'function' && getuid() === 0) {
72
+ return true;
73
+ }
74
+ } catch {
75
+ return false;
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ export function resolveSandboxMode(requestedMode, env = process.env) {
82
+ const fromExplicit = normalizeSandboxMode(requestedMode);
83
+ if (fromExplicit) {
84
+ return fromExplicit;
85
+ }
86
+ const fromEnv = normalizeSandboxMode(env?.AGENT_READER_SANDBOX);
87
+ if (fromEnv) {
88
+ return fromEnv;
89
+ }
90
+ return 'auto';
91
+ }
92
+
93
+ export function getSandboxArgs(sandboxMode = 'auto', runtime = {}) {
94
+ const mode = normalizeSandboxMode(sandboxMode) || 'auto';
95
+ if (mode === 'on') {
96
+ return [];
97
+ }
98
+ if (mode === 'off') {
99
+ return [...SANDBOX_DISABLED_ARGS];
100
+ }
101
+ return detectContainerEnv(runtime) ? [...SANDBOX_DISABLED_ARGS] : [];
102
+ }
103
+
104
+ function runHasProperty(run, propKey) {
105
+ return run.properties?.root?.some((item) => item.rootKey === propKey);
106
+ }
107
+
108
+ function cloneRunWithStyle(run, { bold, italics }) {
109
+ const baseTextNode = run.root.find((node) => node.rootKey === 'w:t');
110
+ const text = typeof baseTextNode?.root?.[baseTextNode.root.length - 1] === 'string'
111
+ ? baseTextNode.root[baseTextNode.root.length - 1]
112
+ : '';
113
+ const hasCodeStyle = runHasProperty(run, 'w:rFonts');
114
+ const hasShading = runHasProperty(run, 'w:shd');
115
+
116
+ return new TextRun({
117
+ text,
118
+ bold: bold || undefined,
119
+ italics: italics || undefined,
120
+ ...(hasCodeStyle ? { font: 'Consolas' } : {}),
121
+ ...(hasShading
122
+ ? {
123
+ shading: {
124
+ type: ShadingType.CLEAR,
125
+ color: 'auto',
126
+ fill: 'F4F4F5',
127
+ },
128
+ }
129
+ : {}),
130
+ });
131
+ }
28
132
 
29
133
  function paragraphFromText(text) {
30
134
  return new Paragraph({
@@ -65,6 +169,139 @@ function inlineToPlainText(token) {
65
169
  .join('');
66
170
  }
67
171
 
172
+ function createInlineTextRun(text, { bold, italics, code = false }) {
173
+ return new TextRun({
174
+ text,
175
+ bold: bold || undefined,
176
+ italics: italics || undefined,
177
+ ...(code
178
+ ? {
179
+ font: 'Consolas',
180
+ shading: {
181
+ type: ShadingType.CLEAR,
182
+ color: 'auto',
183
+ fill: 'F4F4F5',
184
+ },
185
+ }
186
+ : {}),
187
+ });
188
+ }
189
+
190
+ function pushInlineNode(target, linkStack, node) {
191
+ if (linkStack.length > 0) {
192
+ linkStack[linkStack.length - 1].children.push(node);
193
+ return;
194
+ }
195
+ target.push(node);
196
+ }
197
+
198
+ function closeCurrentLink(target, linkStack, style) {
199
+ const current = linkStack.pop();
200
+ if (!current) {
201
+ return;
202
+ }
203
+
204
+ const href = current.href || '';
205
+ if (!href) {
206
+ const fallbackRuns = current.children.length > 0
207
+ ? current.children
208
+ : [createInlineTextRun('', style)];
209
+ for (const run of fallbackRuns) {
210
+ pushInlineNode(target, linkStack, run);
211
+ }
212
+ return;
213
+ }
214
+
215
+ const hyperlinkChildren = current.children.length > 0
216
+ ? current.children
217
+ : [createInlineTextRun(href, style)];
218
+ const hyperlink = new ExternalHyperlink({
219
+ link: href,
220
+ children: hyperlinkChildren.map((node) => (
221
+ node instanceof TextRun ? cloneRunWithStyle(node, style) : node
222
+ )),
223
+ });
224
+ pushInlineNode(target, linkStack, hyperlink);
225
+ }
226
+
227
+ export function inlineToDocxRuns(token) {
228
+ if (!token?.children?.length) {
229
+ const fallbackText = token?.content || '';
230
+ return fallbackText ? [new TextRun(fallbackText)] : [];
231
+ }
232
+
233
+ const output = [];
234
+ const linkStack = [];
235
+ let boldDepth = 0;
236
+ let italicsDepth = 0;
237
+
238
+ for (const child of token.children) {
239
+ const style = {
240
+ bold: boldDepth > 0,
241
+ italics: italicsDepth > 0,
242
+ };
243
+
244
+ if (child.type === 'strong_open') {
245
+ boldDepth += 1;
246
+ continue;
247
+ }
248
+ if (child.type === 'strong_close') {
249
+ boldDepth = Math.max(0, boldDepth - 1);
250
+ continue;
251
+ }
252
+ if (child.type === 'em_open') {
253
+ italicsDepth += 1;
254
+ continue;
255
+ }
256
+ if (child.type === 'em_close') {
257
+ italicsDepth = Math.max(0, italicsDepth - 1);
258
+ continue;
259
+ }
260
+ if (child.type === 'link_open') {
261
+ linkStack.push({
262
+ href: child.attrGet('href') || '',
263
+ children: [],
264
+ });
265
+ continue;
266
+ }
267
+ if (child.type === 'link_close') {
268
+ closeCurrentLink(output, linkStack, style);
269
+ continue;
270
+ }
271
+ if (child.type === 'text') {
272
+ if (child.content) {
273
+ pushInlineNode(output, linkStack, createInlineTextRun(child.content, style));
274
+ }
275
+ continue;
276
+ }
277
+ if (child.type === 'code_inline') {
278
+ pushInlineNode(output, linkStack, createInlineTextRun(child.content || '', { ...style, code: true }));
279
+ continue;
280
+ }
281
+ if (child.type === 'softbreak' || child.type === 'hardbreak') {
282
+ pushInlineNode(output, linkStack, createInlineTextRun('\n', style));
283
+ continue;
284
+ }
285
+ if (child.type === 'image') {
286
+ const alt = child.content || child.attrGet('alt') || 'image';
287
+ pushInlineNode(output, linkStack, createInlineTextRun(`[${alt}]`, style));
288
+ continue;
289
+ }
290
+ if (child.content) {
291
+ pushInlineNode(output, linkStack, createInlineTextRun(child.content, style));
292
+ }
293
+ }
294
+
295
+ while (linkStack.length > 0) {
296
+ closeCurrentLink(output, linkStack, {
297
+ bold: boldDepth > 0,
298
+ italics: italicsDepth > 0,
299
+ });
300
+ }
301
+
302
+ return output;
303
+ }
304
+
68
305
  function collectTocEntries(tokens) {
69
306
  const entries = [];
70
307
  for (let i = 0; i < tokens.length; i += 1) {
@@ -123,9 +360,11 @@ function parseTable(tokens, startIndex) {
123
360
  rows: rows.map(
124
361
  (items) =>
125
362
  new TableRow({
126
- children: items.map((item) =>
363
+ children: items.map((itemRuns) =>
127
364
  new TableCell({
128
- children: [paragraphFromText(item || '')],
365
+ children: [new Paragraph({
366
+ children: itemRuns.length > 0 ? itemRuns : [new TextRun('')],
367
+ })],
129
368
  }),
130
369
  ),
131
370
  }),
@@ -146,17 +385,17 @@ function parseTable(tokens, startIndex) {
146
385
  }
147
386
 
148
387
  if (token.type === 'th_open' || token.type === 'td_open') {
149
- currentCell = '';
388
+ currentCell = [];
150
389
  continue;
151
390
  }
152
391
 
153
392
  if (token.type === 'inline' && currentCell !== null) {
154
- currentCell += inlineToPlainText(token);
393
+ currentCell.push(...inlineToDocxRuns(token));
155
394
  continue;
156
395
  }
157
396
 
158
397
  if (token.type === 'th_close' || token.type === 'td_close') {
159
- row.push(currentCell || '');
398
+ row.push(currentCell || []);
160
399
  currentCell = null;
161
400
  }
162
401
  }
@@ -167,10 +406,10 @@ function parseTable(tokens, startIndex) {
167
406
  };
168
407
  }
169
408
 
170
- function markdownToDocx(markdown) {
409
+ export function markdownToDocx(markdown) {
171
410
  const tokens = markdownParser.parse(markdown, {});
172
411
  const tocEntries = collectTocEntries(tokens);
173
- const children = [];
412
+ const children = [...buildDocxTocParagraphs(tocEntries)];
174
413
  const listStack = [];
175
414
  let pendingBlock = null;
176
415
 
@@ -237,15 +476,15 @@ function markdownToDocx(markdown) {
237
476
  continue;
238
477
  }
239
478
 
240
- const text = inlineToPlainText(token).trim();
241
- if (!text) {
479
+ const runs = inlineToDocxRuns(token);
480
+ if (runs.length === 0) {
242
481
  continue;
243
482
  }
244
483
 
245
484
  if (pendingBlock?.type === 'heading') {
246
485
  children.push(
247
486
  new Paragraph({
248
- text,
487
+ children: runs,
249
488
  heading: headingLevelFromNumber(pendingBlock.level),
250
489
  }),
251
490
  );
@@ -256,11 +495,13 @@ function markdownToDocx(markdown) {
256
495
  if (listStack.length > 0) {
257
496
  const top = listStack[listStack.length - 1];
258
497
  const prefix = top.type === 'ordered' ? `${top.count}. ` : '• ';
259
- children.push(paragraphFromText(`${prefix}${text}`));
498
+ children.push(new Paragraph({
499
+ children: [new TextRun(prefix), ...runs],
500
+ }));
260
501
  continue;
261
502
  }
262
503
 
263
- children.push(paragraphFromText(text));
504
+ children.push(new Paragraph({ children: runs }));
264
505
  pendingBlock = null;
265
506
  }
266
507
 
@@ -431,6 +672,24 @@ export async function checkPuppeteer() {
431
672
  }
432
673
  }
433
674
 
675
+ export function getPandocInstallHint(platform = process.platform) {
676
+ if (platform === 'darwin') {
677
+ return 'brew install pandoc';
678
+ }
679
+ if (platform === 'win32') {
680
+ return 'winget install pandoc (or choco install pandoc / scoop install pandoc)';
681
+ }
682
+ return 'apt-get install pandoc (or yum install pandoc)';
683
+ }
684
+
685
+ export function createPandocFallbackWarnings(platform = process.platform) {
686
+ const installHint = getPandocInstallHint(platform);
687
+ return [
688
+ 'pandoc_not_found',
689
+ `⚡ 当前使用基础排版模式。想要更精美的代码高亮和智能表格?一行命令解锁:${installHint}`,
690
+ ];
691
+ }
692
+
434
693
  export async function exportPDF(html, options = {}) {
435
694
  const {
436
695
  pageSize = 'A4',
@@ -438,6 +697,7 @@ export async function exportPDF(html, options = {}) {
438
697
  outDir = os.tmpdir(),
439
698
  fileName = 'output.pdf',
440
699
  htmlPath,
700
+ sandbox,
441
701
  } = options;
442
702
 
443
703
  let puppeteer;
@@ -450,10 +710,13 @@ export async function exportPDF(html, options = {}) {
450
710
 
451
711
  const warnings = [];
452
712
  const pdfPath = path.join(path.resolve(outDir), fileName);
713
+ const resolvedSandboxMode = resolveSandboxMode(sandbox);
714
+ const launchArgs = landscape ? ['--allow-file-access-from-files'] : [];
715
+ launchArgs.push(...getSandboxArgs(resolvedSandboxMode));
453
716
 
454
717
  const browser = await puppeteer.launch({
455
718
  headless: true,
456
- args: landscape ? ['--allow-file-access-from-files'] : [],
719
+ args: launchArgs,
457
720
  });
458
721
  try {
459
722
  const page = await browser.newPage();
@@ -587,8 +850,7 @@ export async function exportDOCX(markdownString, options = {}) {
587
850
  await fs.rm(tempInput, { force: true }).catch(() => {});
588
851
  }
589
852
  } else {
590
- const warning = 'Pandoc not found, using docx fallback. Install Pandoc for better results.';
591
- warnings.push(warning);
853
+ warnings.push(...createPandocFallbackWarnings());
592
854
 
593
855
  const document = markdownToDocx(markdownString);
594
856
  const buffer = await Packer.toBuffer(document);
@@ -677,7 +939,7 @@ export async function exportDOCXFromHTML(htmlString, options = {}) {
677
939
  await fs.rm(tempInput, { force: true }).catch(() => {});
678
940
  }
679
941
  } else {
680
- warnings.push('Pandoc not found, using text-only DOCX fallback.');
942
+ warnings.push(...createPandocFallbackWarnings());
681
943
 
682
944
  const plainText = String(htmlString || '')
683
945
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { createOutputDir } from '../utils/output.js';
4
4
  import { normalizeOpenMode } from '../utils/preferences.js';
5
5
  import { renderMarkdown } from './renderer.js';
6
- import { exportDOCX, exportPDF } from './exporter.js';
6
+ import { exportDOCX, exportPDF, resolveSandboxMode } from './exporter.js';
7
7
  import { createSlideshow } from './slideshow.js';
8
8
 
9
9
  const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mdx']);
@@ -94,7 +94,9 @@ export async function openTarget(targetPath, options = {}) {
94
94
  fetchRemote = true,
95
95
  returnContent = false,
96
96
  maxContentBytes = 50 * 1024 * 1024,
97
+ sandbox,
97
98
  } = options;
99
+ const resolvedSandbox = resolveSandboxMode(sandbox, process.env);
98
100
 
99
101
  const requestedMode = normalizeOpenMode(mode, 'auto');
100
102
  const preferredMode = requestedMode === 'auto'
@@ -258,6 +260,7 @@ export async function openTarget(targetPath, options = {}) {
258
260
  outDir: outputDir,
259
261
  fileName: `${name}.pdf`,
260
262
  htmlPath,
263
+ sandbox: resolvedSandbox,
261
264
  });
262
265
  warnings.push(...pdf.warnings);
263
266
 
package/src/mcp/server.js CHANGED
@@ -2,12 +2,12 @@ import path from 'node:path';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import * as z from 'zod/v4';
6
5
  import { renderMarkdown } from '../core/renderer.js';
7
- import { exportDOCX, exportPDF } from '../core/exporter.js';
6
+ import { exportDOCX, exportPDF, resolveSandboxMode } from '../core/exporter.js';
8
7
  import { createSlideshow } from '../core/slideshow.js';
9
8
  import { openTarget } from '../core/opener.js';
10
9
  import { createOutputDir } from '../utils/output.js';
10
+ import { MCP_TOOL_SCHEMAS } from './toolSchemas.js';
11
11
  import {
12
12
  getPreferencesPath,
13
13
  loadPreferences,
@@ -16,6 +16,7 @@ import {
16
16
  } from '../utils/preferences.js';
17
17
 
18
18
  const MAX_CONTENT_BYTES = 50 * 1024 * 1024;
19
+ const MCP_SANDBOX_MODE = resolveSandboxMode(process.env.AGENT_READER_SANDBOX, process.env);
19
20
 
20
21
  function getBaseDirFromSourcePath(sourcePath) {
21
22
  if (!sourcePath) {
@@ -71,21 +72,12 @@ async function saveHtmlResult(html, outputDir, name = 'output') {
71
72
 
72
73
  const server = new McpServer({
73
74
  name: 'agent-reader',
74
- version: '0.2.0',
75
+ version: '1.1.0',
75
76
  });
76
77
 
77
78
  server.registerTool(
78
79
  'render_markdown',
79
- {
80
- description: 'Render markdown text into styled HTML',
81
- inputSchema: {
82
- content: z.string().describe('Markdown source content'),
83
- source_path: z.string().optional().describe('Source markdown path for relative images'),
84
- theme: z.string().optional().describe('Theme name'),
85
- auto_open: z.boolean().optional().describe('Open output automatically (ignored in MCP)'),
86
- return_content: z.boolean().optional().describe('Return inline HTML content directly'),
87
- },
88
- },
80
+ MCP_TOOL_SCHEMAS.render_markdown,
89
81
  async ({ content, source_path, theme, return_content }) => {
90
82
  try {
91
83
  const baseDir = getBaseDirFromSourcePath(source_path);
@@ -142,15 +134,7 @@ server.registerTool(
142
134
 
143
135
  server.registerTool(
144
136
  'export_document',
145
- {
146
- description: 'Export markdown text into PDF or DOCX',
147
- inputSchema: {
148
- content: z.string().describe('Markdown source content'),
149
- source_path: z.string().optional().describe('Source markdown path for relative images'),
150
- format: z.enum(['pdf', 'docx']).describe('Export format'),
151
- return_content: z.boolean().optional().describe('Return file bytes as base64'),
152
- },
153
- },
137
+ MCP_TOOL_SCHEMAS.export_document,
154
138
  async ({ content, source_path, format, return_content }) => {
155
139
  try {
156
140
  const baseDir = getBaseDirFromSourcePath(source_path);
@@ -174,6 +158,7 @@ server.registerTool(
174
158
  outDir: outputDir,
175
159
  fileName: 'export.pdf',
176
160
  htmlPath,
161
+ sandbox: MCP_SANDBOX_MODE,
177
162
  });
178
163
  filePath = pdf.pdfPath;
179
164
  warnings = [...warnings, ...rendered.warnings, ...pdf.warnings];
@@ -223,15 +208,7 @@ server.registerTool(
223
208
 
224
209
  server.registerTool(
225
210
  'create_slideshow',
226
- {
227
- description: 'Create slideshow HTML from an image directory',
228
- inputSchema: {
229
- image_dir: z.string().describe('Absolute or relative image directory path'),
230
- auto_play: z.number().optional().describe('Autoplay interval in seconds'),
231
- auto_open: z.boolean().optional().describe('Open output automatically (ignored in MCP)'),
232
- return_content: z.boolean().optional().describe('Return inline HTML content directly'),
233
- },
234
- },
211
+ MCP_TOOL_SCHEMAS.create_slideshow,
235
212
  async ({ image_dir, auto_play, return_content }) => {
236
213
  try {
237
214
  const outputDir = await createOutputDir('create-slideshow');
@@ -278,16 +255,7 @@ server.registerTool(
278
255
 
279
256
  server.registerTool(
280
257
  'open_file',
281
- {
282
- description: 'Open a local file/path using user preference or explicit mode: web/word/pdf/ppt',
283
- inputSchema: {
284
- file_path: z.string().describe('File path or image directory path'),
285
- open_as: z.string().optional().describe('auto|web|word|pdf|ppt'),
286
- theme: z.string().optional().describe('theme for web rendering'),
287
- auto_play: z.number().optional().describe('auto play seconds for ppt mode'),
288
- return_content: z.boolean().optional().describe('return generated content directly'),
289
- },
290
- },
258
+ MCP_TOOL_SCHEMAS.open_file,
291
259
  async ({ file_path, open_as, theme, auto_play, return_content }) => {
292
260
  try {
293
261
  const preferences = await loadPreferences();
@@ -299,6 +267,7 @@ server.registerTool(
299
267
  autoPlay: auto_play,
300
268
  returnContent: Boolean(return_content),
301
269
  maxContentBytes: MAX_CONTENT_BYTES,
270
+ sandbox: MCP_SANDBOX_MODE,
302
271
  });
303
272
 
304
273
  const payload = {
@@ -319,13 +288,7 @@ server.registerTool(
319
288
 
320
289
  server.registerTool(
321
290
  'configure_user_preferences',
322
- {
323
- description: 'Set default open behavior for novice users',
324
- inputSchema: {
325
- default_open_mode: z.string().optional().describe('web|word|pdf|ppt'),
326
- default_theme: z.string().optional().describe('default web theme'),
327
- },
328
- },
291
+ MCP_TOOL_SCHEMAS.configure_user_preferences,
329
292
  async ({ default_open_mode, default_theme }) => {
330
293
  try {
331
294
  const updates = {};
@@ -352,10 +315,7 @@ server.registerTool(
352
315
 
353
316
  server.registerTool(
354
317
  'get_user_preferences',
355
- {
356
- description: 'Read current user preferences for open behavior',
357
- inputSchema: {},
358
- },
318
+ MCP_TOOL_SCHEMAS.get_user_preferences,
359
319
  async () => {
360
320
  try {
361
321
  const preferences = await loadPreferences();
@@ -0,0 +1,53 @@
1
+ import * as z from 'zod/v4';
2
+
3
+ export const MCP_TOOL_SCHEMAS = {
4
+ render_markdown: {
5
+ description: 'Render markdown text into styled HTML',
6
+ inputSchema: {
7
+ content: z.string().describe('Markdown source content'),
8
+ source_path: z.string().optional().describe('Source markdown path for relative images'),
9
+ theme: z.string().optional().describe('Theme name'),
10
+ auto_open: z.boolean().optional().describe('Open output automatically (ignored in MCP)'),
11
+ return_content: z.boolean().optional().describe('Return inline HTML content directly'),
12
+ },
13
+ },
14
+ export_document: {
15
+ description: 'Export markdown text into PDF or DOCX',
16
+ inputSchema: {
17
+ content: z.string().describe('Markdown source content'),
18
+ source_path: z.string().optional().describe('Source markdown path for relative images'),
19
+ format: z.enum(['pdf', 'docx']).describe('Export format'),
20
+ return_content: z.boolean().optional().describe('Return file bytes as base64'),
21
+ },
22
+ },
23
+ create_slideshow: {
24
+ description: 'Create slideshow HTML from an image directory',
25
+ inputSchema: {
26
+ image_dir: z.string().describe('Absolute or relative image directory path'),
27
+ auto_play: z.number().optional().describe('Autoplay interval in seconds'),
28
+ auto_open: z.boolean().optional().describe('Open output automatically (ignored in MCP)'),
29
+ return_content: z.boolean().optional().describe('Return inline HTML content directly'),
30
+ },
31
+ },
32
+ open_file: {
33
+ description: 'Open a local file/path using user preference or explicit mode: web/word/pdf/ppt',
34
+ inputSchema: {
35
+ file_path: z.string().describe('File path or image directory path'),
36
+ open_as: z.string().optional().describe('auto|web|word|pdf|ppt'),
37
+ theme: z.string().optional().describe('theme for web rendering'),
38
+ auto_play: z.number().optional().describe('auto play seconds for ppt mode'),
39
+ return_content: z.boolean().optional().describe('return generated content directly'),
40
+ },
41
+ },
42
+ configure_user_preferences: {
43
+ description: 'Set default open behavior for novice users',
44
+ inputSchema: {
45
+ default_open_mode: z.string().optional().describe('web|word|pdf|ppt'),
46
+ default_theme: z.string().optional().describe('default web theme'),
47
+ },
48
+ },
49
+ get_user_preferences: {
50
+ description: 'Read current user preferences for open behavior',
51
+ inputSchema: {},
52
+ },
53
+ };
@@ -0,0 +1,62 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ async function resolveWithNearestParent(targetPath) {
5
+ const absoluteTarget = path.resolve(targetPath);
6
+ try {
7
+ const realTarget = await fs.realpath(absoluteTarget);
8
+ return { ok: true, realTarget };
9
+ } catch (error) {
10
+ if (error?.code !== 'ENOENT') {
11
+ return { ok: false };
12
+ }
13
+ }
14
+
15
+ const missingSegments = [];
16
+ let cursor = absoluteTarget;
17
+
18
+ while (true) {
19
+ const parent = path.dirname(cursor);
20
+ if (parent === cursor) {
21
+ return { ok: false };
22
+ }
23
+ missingSegments.push(path.basename(cursor));
24
+ cursor = parent;
25
+
26
+ try {
27
+ const realParent = await fs.realpath(cursor);
28
+ return {
29
+ ok: true,
30
+ realTarget: path.join(realParent, ...missingSegments.reverse()),
31
+ };
32
+ } catch (error) {
33
+ if (error?.code !== 'ENOENT') {
34
+ return { ok: false };
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ export async function assertWithinBase(targetPath, baseDir) {
41
+ if (!baseDir) {
42
+ return false;
43
+ }
44
+
45
+ let realBase;
46
+ try {
47
+ realBase = await fs.realpath(path.resolve(baseDir));
48
+ } catch {
49
+ return false;
50
+ }
51
+
52
+ const resolved = await resolveWithNearestParent(targetPath);
53
+ if (!resolved.ok) {
54
+ return false;
55
+ }
56
+
57
+ const relative = path.relative(realBase, resolved.realTarget);
58
+ if (!relative) {
59
+ return true;
60
+ }
61
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
62
+ }
@@ -2,7 +2,7 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
  import { promises as fs } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { exportDOCXFromHTML, exportPDF } from '../core/exporter.js';
5
+ import { exportDOCXFromHTML, exportPDF, resolveSandboxMode } from '../core/exporter.js';
6
6
 
7
7
  const MIME_TYPES = {
8
8
  '.html': 'text/html; charset=utf-8',
@@ -94,7 +94,7 @@ async function resolveExportSourcePath(req, rootDir, sourceParam) {
94
94
  throw createHttpError(400, 'missing source path');
95
95
  }
96
96
 
97
- async function handleExportRequest(req, res, rootDir, urlObject) {
97
+ async function handleExportRequest(req, res, rootDir, urlObject, sandbox) {
98
98
  if (req.method !== 'GET') {
99
99
  sendJson(res, 405, { error: 'method not allowed' });
100
100
  return;
@@ -131,6 +131,7 @@ async function handleExportRequest(req, res, rootDir, urlObject) {
131
131
  fileName: `${sourceName}.pdf`,
132
132
  htmlPath: sourcePath,
133
133
  landscape: isLandscape,
134
+ sandbox,
134
135
  });
135
136
  outputPath = result.pdfPath;
136
137
  warnings = result.warnings || [];
@@ -140,6 +141,7 @@ async function handleExportRequest(req, res, rootDir, urlObject) {
140
141
  baseDir: sourceDir,
141
142
  outDir: sourceDir,
142
143
  fileName: `${sourceName}.docx`,
144
+ sandbox,
143
145
  });
144
146
  outputPath = result.docxPath;
145
147
  warnings = result.warnings || [];
@@ -190,14 +192,18 @@ async function isPortInUse(host, port) {
190
192
  });
191
193
  }
192
194
 
193
- export async function startStaticServer(rootDir, { host = '127.0.0.1', port = 3000 } = {}) {
195
+ export async function startStaticServer(
196
+ rootDir,
197
+ { host = '127.0.0.1', port = 3000, sandbox } = {},
198
+ ) {
194
199
  const absoluteRoot = path.resolve(rootDir);
200
+ const resolvedSandbox = resolveSandboxMode(sandbox, process.env);
195
201
 
196
202
  const server = http.createServer(async (req, res) => {
197
203
  const urlObject = new URL(req.url || '/', `http://${host}:${port}`);
198
204
  try {
199
205
  if (urlObject.pathname === '/api/export') {
200
- await handleExportRequest(req, res, absoluteRoot, urlObject);
206
+ await handleExportRequest(req, res, absoluteRoot, urlObject, resolvedSandbox);
201
207
  return;
202
208
  }
203
209