dsh-linked-workspace 0.6.0 → 0.6.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 — 2026-11-05
4
+
5
+ - Fixed Markdown/document JSON transport failing with a Windows GBK codec error for characters such as the superscript minus (U+207B); the Python bridge now emits UTF-8/ASCII-safe JSON and configures UTF-8 stdout.
6
+ - PDF files now load lazily: only the current page is rendered; other pages are fetched on demand (`docs.pdfPage`), and a large PDF is downloaded/opened once and cached. This removes the long “加载中” hang on large (30 MB+) PDFs.
7
+ - PDF page navigation, rotation, deletion and metadata remain available.
8
+
9
+ ## 0.6.1 — 2026-11-05
10
+
11
+ - Fixed the linked-workspace directory tab disappearing for a new or blank linked-workspace session: the client session store is now consulted for cwd/workspaceId before falling back to session.info, so the tab and the start-page option are registered immediately when the workspace session opens.
12
+ - Availability also matches the linked record workspaceId, not only the local path.
13
+
3
14
  ## 0.6.0 — 2026-11-05
4
15
 
5
16
  - **Document editing in the content panel** for Markdown, PDF, DOCX, XLSX and PPTX, on both local and remote files.
package/README.en.md CHANGED
@@ -90,8 +90,6 @@ The remote side is not modified by mirroring. The `linked_workspace` tool suppor
90
90
 
91
91
  [![Open a file in the content panel](https://github.com/jiahui-huang1/dsh-linked-workspace/raw/main/docs/screenshots/file-editor.png)](https://github.com/jiahui-huang1/dsh-linked-workspace/blob/main/docs/screenshots/file-editor.png)
92
92
 
93
- [![Read-only remote file with content visible](https://github.com/jiahui-huang1/dsh-linked-workspace/raw/main/docs/screenshots/remote-readonly-view.png)](https://github.com/jiahui-huang1/dsh-linked-workspace/blob/main/docs/screenshots/remote-readonly-view.png)
94
-
95
93
  ## Installation
96
94
 
97
95
  ```bash
@@ -117,6 +115,14 @@ Old “Remote directories” tabs are restored after refresh through hidden lega
117
115
 
118
116
  See [CHANGELOG.md](CHANGELOG.md) for the full version history.
119
117
 
118
+ ### 0.6.2
119
+
120
+ Fix Windows GBK JSON transport errors for Markdown characters such as the superscript minus; PDF pages now load lazily and the source PDF is cached, avoiding long loading hangs on large files.
121
+
122
+ ### 0.6.1
123
+
124
+ Fix the linked-workspace directory tab and start-page entry disappearing in a new blank linked-workspace session by consulting the client session store for cwd and workspaceId first.
125
+
120
126
  ### 0.5.13
121
127
 
122
128
  The directory panel divider is draggable (double-click resets to 300 px; width persists). The local tree refreshes silently every 4 seconds and on window focus, so new local subdirectories appear without a manual refresh.
package/docs/VERIFY.md CHANGED
@@ -97,3 +97,15 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
97
97
  - SSH Tunnel 面板、其他 better-sidebar 标签、旧 `@goodandready` 的 remote_* 工具仍可用。
98
98
  - 安装过程中若再次出现 pnpm 清理旧包,确认 `@goodandready/dsh-remote-workspace` 仍在 profile 依赖中。
99
99
  - 普通(非联动)工作区的行图标仍是文件夹,右侧栏旧文件标签/编辑器不受影响。
100
+
101
+ ## 文档编辑
102
+
103
+ 1. 在目录右键「新建文件」输入 `demo.md` / `demo.docx` / `demo.xlsx` / `demo.pptx` / `demo.pdf`,应创建对应空白文档并在内容面板打开。
104
+ 2. Markdown:编辑文本 → 保存 → 预览 → 导出 PDF;导出的 PDF 与 Markdown 同目录,中文正常。
105
+ 3. DOCX:修改段落/表格文字 → 保存 → 重新打开内容仍在。
106
+ 4. XLSX:切换工作表、修改单元格 → 保存 → 重新打开值仍在。
107
+ 5. PPTX:切换幻灯片、修改文本框 → 保存 → 重新打开文字仍在。
108
+ 6. PDF:翻页、旋转/删除页面、修改标题/作者 → 保存 → 重新打开生效。
109
+ 7. 以上操作在本地目录与远端(联动工作区)文件上都应可用;Office/PDF 需要 Python 3 + python-docx/openpyxl/python-pptx/PyMuPDF。
110
+
111
+ - 新建一个空白联动工作区会话:右侧栏开始页应出现「联动工作区目录」,打开后目录面板正常显示。
package/lib/client.js CHANGED
@@ -1838,6 +1838,7 @@ function fileIconSpec(name, isDir) {
1838
1838
  var TREE_TAB_TYPE = 'dsh-linked-workspace';
1839
1839
  var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
1840
1840
  var linkedLocalKeys = new Set();
1841
+ var linkedWorkspaceKeys = new Set();
1841
1842
  var hasRemoteDirectories = false;
1842
1843
  var linkedTabDisposer = null;
1843
1844
  var remoteTabDisposer = null;
@@ -1847,6 +1848,10 @@ function fileIconSpec(name, isDir) {
1847
1848
  return String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
1848
1849
  }
1849
1850
 
1851
+ function isLinkedWorkspaceId(workspaceId) {
1852
+ return Boolean(workspaceId) && linkedWorkspaceKeys.has(String(workspaceId));
1853
+ }
1854
+
1850
1855
  function isLinkedScope(scope) {
1851
1856
  var key = linkedScopeKey(scope && scope.cwd);
1852
1857
  if (!key) return false;
@@ -1866,8 +1871,14 @@ function fileIconSpec(name, isDir) {
1866
1871
 
1867
1872
  function rememberDirectoryAvailability(links, workspaces) {
1868
1873
  var next = new Set();
1869
- (links || []).forEach(function (link) { var key = linkedScopeKey(link.localPath); if (key) next.add(key); });
1874
+ var workspaceKeys = new Set();
1875
+ (links || []).forEach(function (link) {
1876
+ var key = linkedScopeKey(link.localPath);
1877
+ if (key) next.add(key);
1878
+ if (link.workspaceId) workspaceKeys.add(String(link.workspaceId));
1879
+ });
1870
1880
  linkedLocalKeys = next;
1881
+ linkedWorkspaceKeys = workspaceKeys;
1871
1882
  hasRemoteDirectories = Array.isArray(workspaces) && workspaces.length > 0;
1872
1883
  }
1873
1884
 
@@ -4419,7 +4430,7 @@ function fileIconSpec(name, isDir) {
4419
4430
  var stateRef = useState({
4420
4431
  loading: true, error: '', data: null, text: '', dirty: false, saving: false,
4421
4432
  preview: false, html: '', previewLoading: false, page: 0, sheet: 0, slide: 0,
4422
- updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, notice: '',
4433
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, pdfLoading: false, notice: '',
4423
4434
  });
4424
4435
  var state = stateRef[0];
4425
4436
  var setState = stateRef[1];
@@ -4440,7 +4451,7 @@ function fileIconSpec(name, isDir) {
4440
4451
  loading: false, error: '', data: result,
4441
4452
  text: result.kind === 'markdown' ? String(result.text || '') : '',
4442
4453
  preview: false, html: '', page: 0, sheet: 0, slide: 0,
4443
- updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, dirty: false,
4454
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, pdfLoading: false, dirty: false,
4444
4455
  });
4445
4456
  }).catch(function (error) {
4446
4457
  patch({ loading: false, error: messageOf(error, t, locale) });
@@ -4527,6 +4538,35 @@ function fileIconSpec(name, isDir) {
4527
4538
  patch({ pptxUpdates: updates, dirty: true });
4528
4539
  }
4529
4540
 
4541
+ function goPdfPage(index) {
4542
+ if (index < 0 || index >= (state.data.pageCount || 0)) return;
4543
+ var cached = (state.data.pages || []).filter(function (page) { return page.index === index; })[0];
4544
+ if (cached) {
4545
+ patch({ page: index });
4546
+ return;
4547
+ }
4548
+ patch({ pdfLoading: true, error: '' });
4549
+ apiCall('docs.pdfPage', params({ page: index })).then(function (result) {
4550
+ var pages = (state.data.pages || []).filter(function (page) { return page.index !== result.pages[0].index; }).concat(result.pages);
4551
+ pages.sort(function (left, right) { return left.index - right.index; });
4552
+ patch({ data: Object.assign({}, state.data, { pages: pages }), page: index, pdfLoading: false });
4553
+ }).catch(function (error) {
4554
+ patch({ pdfLoading: false, error: messageOf(error, t, locale) });
4555
+ });
4556
+ }
4557
+
4558
+ function stepPdfPage(delta) {
4559
+ var total = state.data.pageCount || 0;
4560
+ var next = state.page + delta;
4561
+ while (next >= 0 && next < total) {
4562
+ if (!state.deleted[next]) {
4563
+ goPdfPage(next);
4564
+ return;
4565
+ }
4566
+ next += delta;
4567
+ }
4568
+ }
4569
+
4530
4570
  var toolbar = h('div', { className: 'rw-doc-toolbar' },
4531
4571
  h('span', { className: 'rw-chip' }, kind.toUpperCase()),
4532
4572
  h('span', { className: 'rw-hint', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: path }, path),
@@ -4556,9 +4596,8 @@ function fileIconSpec(name, isDir) {
4556
4596
  onChange: function (event) { patch({ text: event.currentTarget.value, dirty: true, html: '' }); },
4557
4597
  }));
4558
4598
  } else if (kind === 'pdf' && state.data) {
4559
- var pages = state.data.pages || [];
4560
- var visible = pages.filter(function (page) { return !state.deleted[page.index]; });
4561
- var current = visible[Math.min(state.page, Math.max(visible.length - 1, 0))] || null;
4599
+ var total = state.data.pageCount || 0;
4600
+ var current = (state.data.pages || []).filter(function (page) { return page.index === state.page; })[0] || null;
4562
4601
  body = h('div', { className: 'rw-doc-body' },
4563
4602
  h('div', { className: 'rw-doc-field' },
4564
4603
  h('label', null, t('docPdfTitle')),
@@ -4567,13 +4606,14 @@ function fileIconSpec(name, isDir) {
4567
4606
  h('input', { className: 'rw-doc-input', style: { flex: '1 1 auto' }, value: (state.data.metadata && state.data.metadata.author) || '', onChange: function (event) { patch({ data: Object.assign({}, state.data, { metadata: Object.assign({}, state.data.metadata, { author: event.currentTarget.value }) }), dirty: true }); } })),
4568
4607
  h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', margin: '6px 0' } },
4569
4608
  h('div', { className: 'rw-inline' },
4570
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { patch({ page: state.page - 1 }); } }, '‹'),
4571
- h('span', { className: 'rw-hint' }, current ? t('docPageOf', { page: current.index + 1, total: state.data.pageCount }) : ''),
4572
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= visible.length - 1, onClick: function () { patch({ page: state.page + 1 }); } }, '›')),
4609
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { stepPdfPage(-1); } }, '‹'),
4610
+ h('span', { className: 'rw-hint' }, t('docPageOf', { page: state.page + 1, total: total })),
4611
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= total - 1, onClick: function () { stepPdfPage(1); } }, '›')),
4573
4612
  h('div', { className: 'rw-inline' },
4574
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: !current, onClick: function () { var rotations = Object.assign({}, state.rotations); rotations[current.index] = ((rotations[current.index] || current.rotation || 0) + 90) % 360; patch({ rotations: rotations, dirty: true }); } }, t('docRotate')),
4575
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled: !current || visible.length <= 1, onClick: function () { var deleted = Object.assign({}, state.deleted); deleted[current.index] = true; patch({ deleted: deleted, page: Math.max(0, state.page - 1), dirty: true }); } }, t('docDeletePage')))),
4576
- current ? h('img', { className: 'rw-doc-pdf-page', src: current.png, style: { transform: 'rotate(' + ((state.rotations[current.index] || current.rotation || 0) % 360) + 'deg)' } }) : h('div', { className: 'rw-empty' }, t('noEntries')));
4613
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: !current, onClick: function () { var rotations = Object.assign({}, state.rotations); rotations[state.page] = ((rotations[state.page] || (current && current.rotation) || 0) + 90) % 360; patch({ rotations: rotations, dirty: true }); } }, t('docRotate')),
4614
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled: total <= 1, onClick: function () { var deleted = Object.assign({}, state.deleted); deleted[state.page] = true; var next = state.page + 1; while (next < total && deleted[next]) next += 1; if (next >= total) { next = state.page - 1; while (next >= 0 && deleted[next]) next -= 1; } var nextPage = next >= 0 ? next : state.page; patch({ deleted: deleted, dirty: true, page: nextPage }); if (next >= 0 && !((state.data.pages || []).some(function (page) { return page.index === next; }))) goPdfPage(next); } }, t('docDeletePage')))),
4615
+ state.pdfLoading ? h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')) : null,
4616
+ current ? h('img', { className: 'rw-doc-pdf-page', src: current.png, style: { transform: 'rotate(' + ((state.rotations[current.index] || current.rotation || 0) % 360) + 'deg)' } }) : null);
4577
4617
  } else if (kind === 'docx' && state.data) {
4578
4618
  var paragraphs = state.data.paragraphs || [];
4579
4619
  var tables = state.data.tables || [];
@@ -6606,6 +6646,25 @@ function fileIconSpec(name, isDir) {
6606
6646
  remoteTabDisposer = null;
6607
6647
  }
6608
6648
 
6649
+ function activeSessionClientInfo(sessionId) {
6650
+ if (!sessionId) return null;
6651
+ try {
6652
+ var sessions = ctx.sessions || (ctx.get && ctx.get('sessions'));
6653
+ var list = sessions && sessions.list;
6654
+ var snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
6655
+ var record = snapshot && snapshot.byId ? snapshot.byId[sessionId] : null;
6656
+ if (!record) return null;
6657
+ var header = record.header || record;
6658
+ return { cwd: header.cwd || record.cwd || '', workspaceId: header.workspaceId || record.workspaceId || '' };
6659
+ } catch (error) {
6660
+ return null;
6661
+ }
6662
+ }
6663
+
6664
+ function isLinkedSessionInfo(info) {
6665
+ return Boolean(info && ((info.cwd && isLinkedScope({ cwd: info.cwd })) || isLinkedWorkspaceId(info.workspaceId)));
6666
+ }
6667
+
6609
6668
  function syncDirectoryTabs() {
6610
6669
  if (syncDirectoryTabsInFlight) return;
6611
6670
  syncDirectoryTabsInFlight = true;
@@ -6618,8 +6677,13 @@ function fileIconSpec(name, isDir) {
6618
6677
  disposeLinkedTab();
6619
6678
  return undefined;
6620
6679
  }
6680
+ var clientInfo = activeSessionClientInfo(sessionId);
6681
+ if (isLinkedSessionInfo(clientInfo)) {
6682
+ installLinkedTab();
6683
+ return undefined;
6684
+ }
6621
6685
  return apiCall('session.info', { sessionId: sessionId }).then(function (info) {
6622
- if (info && info.cwd && isLinkedScope({ cwd: info.cwd })) installLinkedTab();
6686
+ if (isLinkedSessionInfo(info)) installLinkedTab();
6623
6687
  else disposeLinkedTab();
6624
6688
  }).catch(function () { disposeLinkedTab(); });
6625
6689
  }).catch(function () { /* ignore */ }).then(function () { syncDirectoryTabsInFlight = false; });
package/lib/index.js CHANGED
@@ -1505,7 +1505,8 @@ export function apply(ctx) {
1505
1505
  return { data: readFileSync(current.path), mtime: info.mtimeMs, size: info.size }
1506
1506
  }
1507
1507
  const stat = await current.scope.remote.stat(current.path)
1508
- if (stat.type !== 'file') throw new ApiError('FS_NOT_DIR', '只能打开文件')
1508
+ const isFile = target.remote ? stat.type === 'file' : stat.isFile()
1509
+ if (!isFile) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1509
1510
  if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1510
1511
  const read = await current.scope.remote.readFile(current.path, { maxBytes: DOC_MAX_BYTES })
1511
1512
  return { data: read.data, mtime: stat.mtime, size: stat.size }
@@ -1524,6 +1525,44 @@ export function apply(ctx) {
1524
1525
  return { path: target.path, size: result.size, mtime: result.mtime }
1525
1526
  }
1526
1527
 
1528
+ const docFileCache = new Map()
1529
+
1530
+ async function ensureDocCache(target) {
1531
+ let stat
1532
+ try {
1533
+ stat = target.remote ? await target.scope.remote.stat(target.path) : await fsStat(target.path)
1534
+ } catch (error) {
1535
+ if (target.remote) throw error
1536
+ throw mapLocalFsError(error)
1537
+ }
1538
+ const isFile = target.remote ? stat.type === 'file' : stat.isFile()
1539
+ if (!isFile) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1540
+ if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1541
+ const mtime = target.remote ? stat.mtime : stat.mtimeMs
1542
+ const key = (target.remote ? String(target.scope.id) : 'local') + '|' + target.path + '|' + String(mtime)
1543
+ const existing = docFileCache.get(key)
1544
+ if (existing && existsSync(existing)) return { file: existing, size: stat.size, mtime }
1545
+ let data
1546
+ if (target.remote) {
1547
+ const read = await target.scope.remote.readFile(target.path, { maxBytes: DOC_MAX_BYTES })
1548
+ data = read.data
1549
+ } else {
1550
+ data = readFileSync(target.path)
1551
+ }
1552
+ const directory = join(tmpdir(), 'dsh-linked-workspace-docs')
1553
+ mkdirSync(directory, { recursive: true })
1554
+ const file = join(directory, randomUUID() + (extname(target.path) || '.bin'))
1555
+ writeFileSync(file, data)
1556
+ docFileCache.set(key, file)
1557
+ if (docFileCache.size > 20) {
1558
+ const oldestKey = docFileCache.keys().next().value
1559
+ const oldestFile = docFileCache.get(oldestKey)
1560
+ docFileCache.delete(oldestKey)
1561
+ try { rmSync(oldestFile, { force: true }) } catch { /* best effort */ }
1562
+ }
1563
+ return { file, size: stat.size, mtime }
1564
+ }
1565
+
1527
1566
  async function makeDocTemp() {
1528
1567
  const directory = mkdtempSync(join(tmpdir(), 'dsh-doc-'))
1529
1568
  return {
@@ -1733,6 +1772,11 @@ export function apply(ctx) {
1733
1772
  async 'docs.read'(params = {}) {
1734
1773
  const target = await docTarget(params)
1735
1774
  const kind = assertDocKind(target.path)
1775
+ if (kind === 'pdf') {
1776
+ const cached = await ensureDocCache(target)
1777
+ const data = await runDocTool(['read', '--kind', 'pdf', '--file', cached.file])
1778
+ return { ...data, path: target.path, size: cached.size, mtime: cached.mtime, cached: true }
1779
+ }
1736
1780
  const current = await readDocBytes(target, target)
1737
1781
  if (kind === 'markdown') {
1738
1782
  if (looksBinary(current.data)) throw new ApiError('DOC_BINARY', 'Markdown 文件包含二进制内容')
@@ -1749,6 +1793,15 @@ export function apply(ctx) {
1749
1793
  }
1750
1794
  },
1751
1795
 
1796
+ async 'docs.pdfPage'(params = {}) {
1797
+ const target = await docTarget(params)
1798
+ assertDocKind(target.path, 'pdf')
1799
+ const page = Number(params.page)
1800
+ if (!Number.isInteger(page) || page < 0) throw new ApiError('BAD_REQUEST', '页码无效')
1801
+ const cached = await ensureDocCache(target)
1802
+ return await runDocTool(['read', '--kind', 'pdf', '--file', cached.file, '--page', String(page)])
1803
+ },
1804
+
1752
1805
  async 'docs.write'(params = {}) {
1753
1806
  const target = await docTarget(params)
1754
1807
  const kind = assertDocKind(target.path)
@@ -1765,13 +1818,18 @@ export function apply(ctx) {
1765
1818
  const written = await writeDocBytes(target, Buffer.from(data.text, 'utf8'))
1766
1819
  return { kind, ...written }
1767
1820
  }
1768
- const current = await readDocBytes(target, target)
1821
+ let sourceFile
1822
+ if (kind === 'pdf') {
1823
+ sourceFile = (await ensureDocCache(target)).file
1824
+ } else {
1825
+ sourceFile = (await readDocBytes(target, target)).data
1826
+ }
1769
1827
  const temp = await makeDocTemp()
1770
1828
  try {
1771
1829
  const source = temp.file('source.' + kind)
1772
1830
  const spec = temp.file('data.json')
1773
1831
  const output = temp.file('output.' + kind)
1774
- writeFileSync(source, current.data)
1832
+ writeFileSync(source, Buffer.isBuffer(sourceFile) ? sourceFile : readFileSync(sourceFile))
1775
1833
  writeFileSync(spec, JSON.stringify(data))
1776
1834
  await runDocTool(['write', '--kind', kind, '--file', source, '--data', spec, '--out', output])
1777
1835
  const written = await writeDocBytes(target, readFileSync(output))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,6 +13,12 @@ import os
13
13
  import re
14
14
  import sys
15
15
 
16
+ try:
17
+ sys.stdout.reconfigure(encoding="utf-8")
18
+ sys.stderr.reconfigure(encoding="utf-8")
19
+ except Exception:
20
+ pass
21
+
16
22
  MAX_PDF_PAGES = 40
17
23
  MAX_PDF_WIDTH = 1100
18
24
  MAX_SHEET_ROWS = 200
@@ -21,12 +27,12 @@ MAX_PPTX_SLIDES = 80
21
27
 
22
28
 
23
29
  def fail(message):
24
- print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=False))
30
+ print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=True))
25
31
  sys.exit(1)
26
32
 
27
33
 
28
34
  def emit(data):
29
- print(json.dumps({"ok": True, "data": data}, ensure_ascii=False))
35
+ print(json.dumps({"ok": True, "data": data}, ensure_ascii=True))
30
36
 
31
37
 
32
38
  def json_value(value):
@@ -198,14 +204,16 @@ def write_pptx(source, data, out):
198
204
 
199
205
  # ── PDF ───────────────────────────────────────────────────────────────────
200
206
 
201
- def read_pdf(path):
207
+ def read_pdf(path, page_index=None):
202
208
  import fitz
203
209
 
204
210
  doc = fitz.open(path)
205
211
  pages = []
206
- for index, page in enumerate(doc):
207
- if index >= MAX_PDF_PAGES:
208
- break
212
+ indexes = [page_index] if isinstance(page_index, int) else [0]
213
+ for index in indexes:
214
+ if index < 0 or index >= doc.page_count:
215
+ continue
216
+ page = doc[index]
209
217
  rect = page.rect
210
218
  scale = min(1.5, MAX_PDF_WIDTH / max(rect.width, 1))
211
219
  pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
@@ -226,7 +234,7 @@ def read_pdf(path):
226
234
  "subject": metadata.get("subject", ""),
227
235
  },
228
236
  "pages": pages,
229
- "truncated": doc.page_count > MAX_PDF_PAGES,
237
+ "truncated": doc.page_count > 1,
230
238
  })
231
239
 
232
240
 
@@ -347,6 +355,7 @@ def main():
347
355
  read = sub.add_parser("read")
348
356
  read.add_argument("--kind", required=True)
349
357
  read.add_argument("--file", required=True)
358
+ read.add_argument("--page", type=int, default=None)
350
359
 
351
360
  write = sub.add_parser("write")
352
361
  write.add_argument("--kind", required=True)
@@ -375,7 +384,7 @@ def main():
375
384
  elif args.kind == "pptx":
376
385
  read_pptx(args.file)
377
386
  elif args.kind == "pdf":
378
- read_pdf(args.file)
387
+ read_pdf(args.file, args.page)
379
388
  else:
380
389
  raise ValueError(f"unsupported kind: {args.kind}")
381
390
  elif args.command == "write":