整理完一套微积分笔记后,我想把它们导出成 PDF,方便脱离 Obsidian 阅读和打印。单篇导出可以在应用里完成,换成几十篇,就要反复打开笔记、确认参数和选择保存位置。例题还有自己的子目录,我也希望导出的文件保留这层组织关系。

于是我把这项任务交给 Codex:每篇笔记生成一份 PDF,保留子目录,不修改源笔记;A4 纵向,70% 缩放,不额外插入文件名标题,保留正文中的一级标题和原来的子目录,不修改源笔记。这套笔记已完成批量导出,例题也保留在对应的子目录中。

导出脚本、接口探查和故障处理由 AI 完成。我负责提出输出要求,并在样本和结果审阅时判断版式是否适合阅读。这次实践也逐渐形成了一套工作流:先确认材料和参数,试导出少量样本,再执行整批任务,最后检查输出。以后换一门课程,希望能沿用这个过程。

目前,这套流程已经整理为 export-obsidian-native-pdf skill,并安装到我的 Codex 环境中。参数化、暂停续跑和样本导出已有实践验证。下面给出配置、执行顺序和完整核心代码。想直接复刻的人,可以将全文交给 AI,让它先检查当前环境,再按样本、批处理、验收的顺序执行。

从一项明确的导出任务开始

这次选择沿用 Obsidian 自身的渲染,是因为笔记已经在应用中阅读和维护。材料里有数学公式、普通图片、SVG 和 Obsidian 语法,我希望尽量保留现有内容的呈现方式,减少迁移到另一套转换工具后重新适配的工作。

纸张、缩放和标题处理需要在开始前说明清楚。70% 是这批笔记的版式选择(也是我认为比较适中的缩放比例),换一套材料仍应重新看样本。“不额外插入文件名标题”只关闭导出器添加的标题,笔记正文原有的 H1 继续保留(如果笔记标题没有安插在文件内容中,这个选项可以打开)。输出目录也应与源文件筛选范围分开,避免把生成物混进下一次任务。

在已经安装该 skill 的 Codex 环境中,可以这样提出任务:

请使用 $export-obsidian-native-pdf,将概率统计目录下的笔记分别导出为 PDF,A4 纵向、70% 缩放,不额外插入文件名标题,保留子目录。先预检并导出两篇样本,等我确认后再完成批量导出;不要修改源笔记。遇到同名输出先报告,不直接覆盖。

调用时还要提供明确的 vault、源目录和输出目录。当前工具要求源与输出位于同一 vault 内,是互不包含的独立目录,不能直接选择整个仓库根目录。其他读者需要先取得并安装实现文件;仅复制这段任务描述不会自动获得导出能力。

环境与文件组织

当前适配的是 Windows 桌面端 Obsidian 1.14.4。导出使用运行中应用的原生渲染,外部 Node.js 负责预检,Python 与 PyMuPDF 负责读取 PDF、生成报告和预览。本文代码依赖 Obsidian 的内部对象与 Electron remote;其他版本需要重新探测和适配,不能只修改版本常量就假定兼容。首次使用前检查 obsidian help、obsidian version 和 CLI 的安装器要求。Obsidian CLI 文档

终端中的 Node.js 与 Obsidian 主渲染器是两个执行环境。 预检可以在终端运行;访问 app、应用窗口与原生导出器的代码,必须通过 CLI 的 eval 在应用内运行。直接用外部 node renderer-entry.cjs 不会开始导出。CLI 需要先配置好;应用已在运行时,不应因受限终端查询不到进程就另开实例。

把文末五个文件按下列结构保存到自己选择的工具目录。它们构成可执行核心,不依赖作者机器上的笔记或路径。skill 是调用约束和说明,已有这些文件的其他 AI 也可以按本文操作,不需要先获得作者本地安装的 skill。

native-pdf-tool/
  scripts/
    preflight.cjs
    renderer-entry.cjs
    export-core.cjs
    obsidian-1.14.4-adapter.cjs
    audit-pdfs.py

运行配置与报告另放在任务目录。导出工具目录无需位于 vault 内,但当前实现要求源目录和 PDF 输出目录均在同一个 vault 内,且互不包含。它只处理 Markdown,每篇独立输出,继承源目录下的相对路径,不负责合订。当前审计按 A4 纵向检查;横向或其他版式需要同时调整和验证审计规则。

配置与只读预检

下面的 JSON 是一组样本配置。占位符需要换成真实路径;source、output 相对于 vault,files 中的路径相对于 source,路径写正斜杠。文件名可以包含空格和中文。

{
  "vault": "<Vault绝对路径>",
  "source": "课程/概率统计",
  "output": "导出/概率统计-样本",
  "recursive": true,
  "files": [
    "第一章.md",
    "例题/连续型随机变量.md"
  ],
  "options": {
    "pageSize": "A4",
    "landscape": false,
    "includeName": false,
    "scalePercent": 70,
    "marginsType": 0
  },
  "timeoutMs": 120000
}

includeName=false 不改变正文 H1。scalePercent 使用整数百分数,70 表示 70%,适配器内部再转成原生需要的百分数和比例值。timeoutMs 是每篇的等待上限,示例值不代表复杂材料一定能在这一时间内完成。

提供 files 时只选精确匹配的笔记;省略这个字段才处理源范围内的全部 Markdown。空列表、重复项、范围外或不存在的路径都会失败。处理顺序由清单排序决定,不能用数组位置推断哪篇先导出。源目录中的符号链接和不安全的目录关系也会被拒绝。

先在 PowerShell 中执行预检:

$toolDir = '<工具目录绝对路径>'
$configPath = '<任务配置JSON绝对路径>'
node (Join-Path $toolDir 'scripts/preflight.cjs') $configPath

该命令只向标准输出写入配置、确定清单和源 SHA-256,不连接桌面、不创建输出目录、不触发打印。逐项检查选中的文件、输出路径和 outputExists。发现已有输出,先判断是否具备可核验的续跑状态,不要删除文件来消除冲突。

样本测试完成后,如要从精确列表扩大为全目录,应另建正式配置和输出目录。改变文件选择或参数后,不能沿用原检查点强行续跑。

应用探测、样本与批处理

以下 JavaScript 在 Obsidian 主渲染器中执行。先将代码保存为任务工作目录中的 probe.js,替换工具路径;路径字符串内建议使用正斜杠。

var pdfEntry = require('<工具目录绝对路径>/scripts/renderer-entry.cjs');
var pdfEnv = { app, window, remote: require('electron').remote };
JSON.stringify(pdfEntry.probe(pdfEnv));

通过 PowerShell 把脚本作为一个参数交给 CLI,避免把笔记正文拼进命令。以下写法以已配置可用的 obsidian 命令为前提:

$vaultName = '<Vault名称或ID>'
$probeFile = '<probe.js绝对路径>'
$probeCode = Get-Content -Raw -LiteralPath $probeFile
& obsidian "vault=$vaultName" eval "code=$probeCode"

vault= 位于命令前。此处 PowerShell 只负责传参,JavaScript 中的 app 来自应用。若当前 CLI 的传参或输出行为不同,应先用只读表达式验证,不要把缺少输出解释为成功。

探测结果应核对:version 为受支持版本,vault 路径正确,busy=false,且只有一个可确认的主窗口。入口会从当前窗口标题取得版本证据,并在有 API 版本时交叉检查;调用方还需与 CLI 的 version 输出核对。模块加载、CSS 读取或 remote 不可用时停止,不能伪造探测结果。

确认本轮样本执行后,将下面内容保存为 start-sample.js,仍通过相同的 CLI eval 方式运行:

var pdfEntry = require('<工具目录绝对路径>/scripts/renderer-entry.cjs');
var pdfEnv = { app, window, remote: require('electron').remote };
var pdfConfig = JSON.parse(
  require('node:fs').readFileSync('<配置JSON绝对路径>', 'utf8')
    .replace(/^\uFEFF/, '')
);
JSON.stringify(pdfEntry.start(pdfEnv, pdfConfig, {
  approved: true,
  stopAfter: 1
}));

approved:true 表示调用方已取得本轮执行授权,不会授予系统权限。stopAfter:1 在新导出一篇后于下一笔记边界暂停,复用文件不计入。这里故意让样本跨越暂停与续跑,用来检查进度恢复。入口返回 started 只说明任务已启动,不代表 PDF 已完成。

任务在应用中异步运行。可用以下只读表达式查看活动标记,并读取输出目录中的 .native-pdf-state.json 核对进度:

JSON.stringify(window.__nativePdfRun ? {
  finished: window.__nativePdfRun.finished,
  status: window.__nativePdfRun.status || 'running',
  error: window.__nativePdfRun.error || null
} : { active: false });

活动标记在正常结束后会移除,因此 active:false 也不能单独证明导出成功。检查状态文件、输出和锁;在检查点建立前失败时,还应查看 .native-pdf-last-attempt.json 或入口返回的错误。

看完首篇样本后,在相同应用会话中重新探测,再以相同配置调用 start(pdfEnv, pdfConfig, {approved:true}),省略 stopAfter。已核验的首篇会复用,其余样本继续导出。运行中若要请求暂停,调用:

JSON.stringify(pdfEntry.stop(pdfEnv));

暂停在笔记边界生效,不会立即强杀正在打印的笔记。 样本通过后,为正式范围使用独立配置和目录,先预检,取得确认,再开始整批导出。活动批次中不要重新加载模块、清除 CJS 缓存或替换适配代码。

原生导出怎样接入

核心接口分成三层:renderer-entry.cjs 探测现场并管理调用,export-core.cjs 编排清单与状态,适配器负责原生导出。核心通过以下约定调用适配器:

renderFingerprint(records) -> 当前会话、设置、CSS及受检嵌入依赖的摘要
exportNote(record, options, signal) -> 资源检查结果与证据
abortAndConfirm() -> 是否已确认原生写入任务结束

适配器先从 app.setting 向上查找三级原型,并检查 open、animateOpen;再从 Markdown 视图中寻找 printToPdf。这是 1.14.4 的内部结构,不是稳定 API。它同步替换弹窗原型的 open,只捕获当前目标文件对应的导出实例;调用视图入口后立刻在 finally 中恢复原方法。替换不能跨越 await。

获取实例后,还需包装实例的 print。先等待原生 print(container, component, includeName) 完成内容渲染,再从 container.ownerDocument 取得真正用于导出的文档,做字体与图片检查。最终调用实例的 printToPdf,传入临时输出路径、纸张、缩放和标题选项。

资源检查必须处于原生内容渲染之后、PDF 打印之前。 只在调用导出之前等待主窗口字体,无法保证临时导出文档已经就绪。适配器还单独保留渲染错误:即使底层吞掉异常并写出文件,包装层仍要拒绝将其作为合格结果发布。完整实现见文末的版本适配器。

资源、窗口与输出保护

公式渲染后先触发布局,显式加载相关 MathJax 字体,再等待该文档的 fonts.ready 和全部图片解码,并检查字体状态、图片 complete 与 naturalWidth。短暂稳定时间只用于收尾,不能替代这些条件。字体集合就绪、显式加载字体

导出窗口通过随机标记识别:在导出文档的 defaultView 写入本次 token,再读取本次新增窗口的 webContents,只有标记相同且唯一的窗口才归本任务清理。标题相同不是归属依据。无法确认时停止,保留未知窗口。

打印先写唯一的 *.partial.pdf。原生调用结束、资源就绪且任务未取消后,再用 COPYFILE_EXCL 复制到最终路径。目标存在即失败,防止覆盖。复制并不具备原子性,失败遗留的最终文件需要复核;不能凭非零大小复用。Node.js 文件复制说明

等待超时,不等于原生打印已经终止。 超时触发取消信号,适配器尝试关闭标记确认的任务窗口。若不能证明原生调用已经结束,核心保留磁盘锁并停止下一篇。迟到的打印只能写自己的临时文件,不会通过正常路径转成最终结果。不要仅凭窗口关闭或进程查询为空就清锁重试。

检查点与续跑条件

每篇依次进入 pending → rendering → exported,出错的当前文件标为 needs-review。批次区分 stopped、failed、needs-review 与 exported-awaiting-audit。文件状态和批次状态分开保存,导出完毕不自动标记完整交付。

状态文件为 .native-pdf-state.json,磁盘锁为 .native-pdf-lock.json。每篇记录源路径、相对路径、输出路径、源与输出 SHA-256、字节数、资源证据和完成时间。清单指纹包含文件选择、源哈希及导出参数;渲染指纹包含应用会话、可读取的主文档 CSS、导出设置和递归解析的嵌入文件哈希。

续跑条件:配置、文件选择、源材料、受检渲染依赖和已有输出哈希一致,并且没有仍在执行的旧批次。

源文件与依赖在运行中也要重复检查。应用重启会改变会话标记,当前实现拒绝直接复用;它不保证跨应用重启的恢复。动态插件输出与未声明依赖未被完整覆盖,远程/HTML 嵌入和未就绪的元数据缓存会被拒绝。不要通过删除指纹或修改旧状态绕开一致性检查。

结构审计与页面复核

导出完成只进入待审计状态,结构检查与视觉复核完成后才能交付。 先核对所选文件与实际输出,确认没有未结束任务或待复核临时 PDF,再运行审计。审计脚本递归读取目录中的 PDF,不会自动和导出清单对账,也不会自动排除失败任务的临时 PDF;这些必须先检查。

下面命令在终端执行,要求 Python 环境可导入 PyMuPDF 的 fitz:

$toolDir = '<工具目录绝对路径>'
$pdfDir = '<PDF输出目录绝对路径>'
$auditDir = '<尚不存在的独立审计目录绝对路径>'
python (Join-Path $toolDir 'scripts/audit-pdfs.py') $pdfDir $auditDir --preview

--preview 生成首页和中间页。小样本改用 --all-pages 查看全部页面,每次使用新的报告目录,且报告目录与 PDF 输入目录不能互相包含。

自动检查包括可解析性、页数、A4 纵向尺寸、空页、替代字符、文本越界和 SHA-256。没有位图但有矢量绘图的页面不按空页处理。不过,自动检查仍不能证明所有字形和图形完整,SVG 可能已经转为路径;结合源引用、导出资源证据和预览判断。

审计生成 audit.json,structural-checks-passed 只表示已定义检查项通过。visualReview 保持 not-performed;另存人工复核记录,说明看过哪些页面、检查了什么、仍有哪些问题。首次批量交付做了结构检查和重点页抽查;固化工具后,小规模样本另做过暂停续跑与全页查看。这里只概括验证范围,不以样本数量替代适用性判断。

完整核心代码

以下五个模块取自当前已安装实现,放入前述目录即可组成核心工具。它们没有包含私人材料、任务配置或运行结果。本轮为文章核对和整理代码,未重新执行桌面导出;既有真实验证范围来自工具的验证记录。超时与迟到任务保护做过模拟测试,未在真实应用中故意制造卡死。

preflight.cjs 复用核心的清单逻辑;renderer-entry.cjs 接收真实应用上下文;export-core.cjs 实现状态与调度;适配器负责原生对象;Python 模块独立审计。请整体保留接口约定,避免让 AI 只复制适配器后自行猜测外围行为。

preflight.cjs|只读预检(8 行)
'use strict';
const fs=require('node:fs');
const {manifest}=require('./export-core.cjs');
// Read-only: prints JSON to stdout; creates no files and never connects to Obsidian.
try {
  if(process.argv.length!==3) throw Error('Usage: node preflight.cjs <config.json>');
  console.log(JSON.stringify(manifest(JSON.parse(fs.readFileSync(process.argv[2],'utf8').replace(/^\uFEFF/,''))),null,2));
} catch(e) {console.error(e.message);process.exitCode=1;}
renderer-entry.cjs|应用探测与启动(47 行)
'use strict';
const fs=require('node:fs'),path=require('node:path');
const {manifest,run}=require('./export-core.cjs');
const {createAdapter}=require('./obsidian-1.14.4-adapter.cjs');
// Import is inert. Caller supplies the live renderer and the actual Obsidian module.
function probe({app,window:host,obsidian,remote}) {
  if(!app?.vault || !host)throw Error('Not a live Obsidian context');
  // CLI eval does not necessarily expose require('obsidian'). Read the title
  // of this exact application window, and corroborate with CLI version before use.
  const title=remote.getCurrentWindow?.().getTitle();
  const titleVersion=title?.match(/ - Obsidian (\d+\.\d+\.\d+)$/)?.[1];
  const apiVersion=obsidian?.apiVersion;
  if(apiVersion && titleVersion && apiVersion!==titleVersion)throw Error('Conflicting live version evidence');
  const version=apiVersion || titleVersion;
  if(!version)throw Error('Live application version unavailable');
  const windows=remote.BrowserWindow.getAllWindows().filter(w=>!w.isDestroyed())
    .map(w=>({id:w.id,title:w.getTitle()}));
  const busy=!!(host.__calculusBatchRunning || host.__nativePdfAdapterOwner || host.__nativePdfRun);
  return {version,versionSource:apiVersion?'obsidian-api':'current-window-title',vault:app.vault.adapter.basePath,busy,windows,
    compatible:version==='1.14.4',needsWindowReview:windows.length!==1};
}
function start(env,config,{approved=false,stopAfter=null}={}) {
  const check=probe(env);
  if(!approved)throw Error('Explicit execution approval required');
  if(check.busy || check.needsWindowReview)throw Error('Desktop busy or extra windows need review');
  if(!check.compatible)throw Error('Unsupported live application version');
  const plan=manifest(config);
  if(fs.realpathSync(check.vault)!==plan.config.vault)throw Error('Wrong vault');
  if(stopAfter!==null && (!Number.isInteger(stopAfter)||stopAfter<1))throw Error('Invalid stopAfter');
  const host=env.window,adapter=createAdapter({...env,version:check.version});
  let completed=0;
  const original=adapter.exportNote.bind(adapter);
  adapter.exportNote=async(...args)=>{const result=await original(...args);completed++;return result;};
  const control={stop:false,started:new Date().toISOString(),output:plan.config.output,finished:false};
  host.__nativePdfRun=control;
  control.promise=run(config,adapter,{shouldStop:()=>control.stop||(stopAfter!==null&&completed>=stopAfter)})
    .then(state=>{control.status=state.status;return state;})
    .catch(error=>{control.status='failed-before-checkpoint';control.error=String(error);return {status:control.status,error:control.error};})
    .finally(()=>{control.finished=true;if(host.__nativePdfRun===control)delete host.__nativePdfRun;});
  return {started:true,notes:plan.records.length,output:plan.config.output,stopAfter};
}
function stop(env){
  const control=env.window.__nativePdfRun;
  if(!control)return {requested:false,reason:'No active entry-point batch'};
  control.stop=true;return {requested:true,mode:'next-note-boundary'};
}
module.exports={probe,start,stop};
export-core.cjs|批处理、检查点与续跑(174 行)
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const inside = (parent, child) => {
  const rel = path.relative(parent, child);
  return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
};
function resolved(p) {
  let existing = path.resolve(p), suffix = [];
  while (!fs.existsSync(existing)) {
    const parent = path.dirname(existing);
    if (parent === existing) throw Error('Cannot resolve path');
    suffix.unshift(path.basename(existing)); existing = parent;
  }
  return path.join(fs.realpathSync(existing), ...suffix);
}
function normalizedConfig(input) {
  const vault = resolved(input.vault);
  const source = resolved(path.resolve(vault, input.source));
  const output = resolved(path.resolve(vault, input.output));
  if (!fs.statSync(vault).isDirectory() || !inside(vault, source) || source === vault)
    throw Error('Source must be a named subdirectory of the vault');
  if (!fs.statSync(source).isDirectory()) throw Error('Source is not a directory');
  // This prototype deliberately confines writes to the vault.
  if (!inside(vault, output) || output === vault || inside(source, output) || inside(output, source))
    throw Error('Output must be a separate named directory within the vault');
  const o = input.options;
  if (!o || o.pageSize !== 'A4' || typeof o.landscape !== 'boolean' ||
      typeof o.includeName !== 'boolean' || !Number.isInteger(o.scalePercent) ||
      o.scalePercent < 10 || o.scalePercent > 200 || o.marginsType !== 0)
    throw Error('Explicit options required: A4, booleans, 10..200 scalePercent, marginsType=0');
  const timeoutMs = input.timeoutMs ?? 120000;
  if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 600000)
    throw Error('Invalid timeoutMs');
  return {vault, source, output, recursive: input.recursive !== false,
    options: {pageSize:o.pageSize,landscape:o.landscape,includeName:o.includeName,
      scalePercent:o.scalePercent,marginsType:o.marginsType}, timeoutMs};
}
function manifest(input) {
  const config = normalizedConfig(input), records = [], outputs = new Set();
  function walk(dir) {
    for (const e of fs.readdirSync(dir, {withFileTypes:true}).sort((a,b)=>a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
      const p = path.join(dir, e.name);
      if (e.isSymbolicLink()) throw Error('Source symlinks are not supported: ' + p);
      if (e.isDirectory()) { if (config.recursive) walk(p); continue; }
      if (!e.isFile() || !/\.md$/i.test(e.name)) continue;
      const relative = path.relative(config.source, p);
      const output = path.join(config.output, relative.replace(/\.md$/i, '.pdf'));
      const key = output.toLowerCase();
      if (outputs.has(key)) throw Error('Case-insensitive output collision: ' + relative);
      outputs.add(key);
      const text = fs.readFileSync(p, 'utf8');
      records.push({source:p, relative, output, sourceSha256:hash(p), status:'pending',
        svgEmbeds:(text.match(/!\[\[[^\]\n]*\.svg(?:\|[^\]]*)?\]\]/gi)||[]).length,
        chemistrySyntax:/\\(?:ce|pu)\s*\{|MHC-N/.test(text),
        outputExists:fs.existsSync(output)});
    }
  }
  walk(config.source);
  if(input.files!==undefined) {
    if(!Array.isArray(input.files)||!input.files.length||input.files.some(x=>typeof x!=='string'))
      throw Error('files must be a nonempty array of source-relative Markdown paths');
    const selected=new Set(input.files.map(x=>x.replace(/\\/g,'/')));
    if(selected.size!==input.files.length)throw Error('Duplicate selected file');
    const found=records.filter(r=>selected.has(r.relative.split(path.sep).join('/')));
    if(found.length!==selected.size)throw Error('Selected file not found within source scope');
    records.splice(0,records.length,...found);
  }
  if (!records.length) throw Error('No Markdown notes selected');
  const identity = {source:config.source, output:config.output, recursive:config.recursive,
    options:config.options, records:records.map(r=>({relative:r.relative, sourceSha256:r.sourceSha256}))};
  const fingerprint = crypto.createHash('sha256').update(JSON.stringify(identity)).digest('hex');
  return {schemaVersion:1, config, fingerprint, records};
}
function atomicJson(file, value) {
  const temp = file + '.' + crypto.randomUUID() + '.tmp';
  fs.writeFileSync(temp, JSON.stringify(value, null, 2), {flag:'wx'});
  fs.renameSync(temp, file);
}
function canReuse(record, old) {
  return !!(old && old.status === 'exported' && old.sourceSha256 === record.sourceSha256 &&
    old.output === record.output && old.outputSha256 && old.resourceChecksPassed === true &&
    fs.existsSync(record.output) && fs.statSync(record.output).isFile() &&
    hash(record.output) === old.outputSha256);
}
async function withDeadline(task, ms, onTimeout) {
  let timer;
  try {
    return await Promise.race([Promise.resolve().then(task), new Promise((_, reject)=>{
      timer=setTimeout(()=>{ onTimeout(); reject(Object.assign(Error('NOTE_TIMEOUT'),{code:'NOTE_TIMEOUT'})); },ms);
    })]);
  } finally { clearTimeout(timer); }
}
// Adapter contract: exportNote(record, options, signal) resolves only after the
// native operation has settled; abortAndConfirm() resolves true only when no
// native writer can still touch the output. false/throw retains the disk lock.
async function run(input, adapter, controls = {}) {
  const plan = manifest(input), {config} = plan;
  if (!adapter || typeof adapter.exportNote !== 'function') throw Error('Adapter required');
  // Validate parent realpaths again at the point of write; reject linked output trees.
  for(const r of plan.records) {
    if(resolved(r.output)!==r.output) throw Error('Linked output path is not allowed');
  }
  fs.mkdirSync(config.output,{recursive:true});
  const statePath=path.join(config.output,'.native-pdf-state.json');
  const lockPath=path.join(config.output,'.native-pdf-lock.json');
  const lock=fs.openSync(lockPath,'wx');
  fs.writeFileSync(lock,JSON.stringify({pid:process.pid,started:new Date().toISOString()}));
  fs.closeSync(lock);
  let safeToUnlock=true, checkpointStarted=false;
  const state={...plan,status:'running',started:new Date().toISOString(),adapter:adapter.identity || 'unidentified'};
  const save=()=>atomicJson(statePath,state);
  let active;
  try {
    state.renderFingerprint=adapter.renderFingerprint?await adapter.renderFingerprint(state.records):null;
    const previous=fs.existsSync(statePath)?JSON.parse(fs.readFileSync(statePath,'utf8')):null;
    if(previous && previous.fingerprint !== plan.fingerprint) throw Error('Manifest/settings changed; choose a new output directory');
    if(previous && previous.renderFingerprint!==state.renderFingerprint) throw Error('Rendering dependencies changed; choose a new output directory');
    // Preflight every collision before starting any native work.
    for (const r of state.records) {
      if (!fs.existsSync(r.output)) continue;
      const old=previous?.records.find(x=>x.relative===r.relative);
      if (!canReuse(r,old)) throw Error('Unverified existing output: '+r.relative);
      Object.assign(r,old,{reused:true});
    }
    checkpointStarted=true;save();
    for(const r of state.records) {
      if(controls.shouldStop?.()) {state.status='stopped';break;}
      if(r.status==='exported') continue;
      if(adapter.renderFingerprint && await adapter.renderFingerprint(state.records)!==state.renderFingerprint)
        throw Error('Rendering dependencies changed during batch');
      if(hash(r.source)!==r.sourceSha256) throw Error('Source changed before export');
      active=r; r.status='rendering';save();
      const controller=new AbortController();
      const result=await withDeadline(()=>adapter.exportNote(r,config.options,controller.signal),
        config.timeoutMs,()=>{safeToUnlock=false;controller.abort();});
      if(!result || result.resourceChecksPassed!==true) throw Error('Resource readiness not verified');
      if(hash(r.source)!==r.sourceSha256) throw Error('Source changed during export');
      if(!fs.existsSync(r.output)||fs.statSync(r.output).size===0) throw Error('No output');
      // Only basic signature validation here. Full PDF inspection is a later stage.
      const fd=fs.openSync(r.output,'r'), header=Buffer.alloc(5);
      try {fs.readSync(fd,header,0,5,0);} finally {fs.closeSync(fd);}
      if(header.toString()!=='%PDF-') throw Error('Invalid PDF signature');
      Object.assign(r,{status:'exported',outputSha256:hash(r.output),bytes:fs.statSync(r.output).size,
        resourceChecksPassed:true,resourceEvidence:result.evidence || {},finished:new Date().toISOString()});
      active=null;save();
    }
    if(state.status==='running') state.status='exported-awaiting-audit';
  } catch(e) {
    if(active) active.status='needs-review';
    state.status=e.code==='NOTE_TIMEOUT'?'needs-review':'failed';
    state.error=String(e.stack || e);
    if(!safeToUnlock) {
      try {safeToUnlock=(await withDeadline(()=>adapter.abortAndConfirm?.(),2000,()=>{}))===true;}
      catch {safeToUnlock=false;}
    }
  } finally {
    try {
      state.renderDependenciesUnchanged=!adapter.renderFingerprint ||
        await adapter.renderFingerprint(state.records)===state.renderFingerprint;
    } catch(e) {state.renderDependenciesUnchanged=false;state.cleanupError=String(e);}
    if(!state.renderDependenciesUnchanged)state.status='needs-review';
    state.allSourcesUnchanged=state.records.every(r=>fs.existsSync(r.source)&&hash(r.source)===r.sourceSha256);
    if(!state.allSourcesUnchanged) state.status='needs-review';
    state.finished=new Date().toISOString();state.lockRetained=!safeToUnlock;
    if(checkpointStarted) save();
    else atomicJson(path.join(config.output,'.native-pdf-last-attempt.json'),state);
    if(safeToUnlock) fs.unlinkSync(lockPath);
  }
  return state;
}
module.exports={manifest,run,canReuse,withDeadline,hash};
obsidian-1.14.4-adapter.cjs|原生渲染与输出保护(146 行)
'use strict';
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
const digest=x=>crypto.createHash('sha256').update(x).digest('hex');
function createAdapter(env) {
  const {app,window:host,version,remote}=env;
  if(version!=='1.14.4') throw Error('Unsupported Obsidian version: '+version);
  if(!app?.vault?.adapter?.basePath || !host || !remote?.BrowserWindow) throw Error('Missing desktop context');
  const proto=Object.getPrototypeOf(Object.getPrototypeOf(Object.getPrototypeOf(app.setting)));
  if(typeof proto?.animateOpen!=='function'||typeof proto.open!=='function') throw Error('Modal prototype mismatch');
  const view=app.workspace.getLeavesOfType('markdown').map(l=>l.view).find(v=>typeof v.printToPdf==='function');
  if(!view) throw Error('No compatible Markdown view');
  if(host.__calculusBatchRunning || host.__nativePdfAdapterOwner) throw Error('Another export is active');
  const token=crypto.randomUUID(),ownerKey='__nativePdfAdapterOwner';
  let active=null;
  const root=fs.realpathSync(app.vault.adapter.basePath);
  const windowAlive=w=>!w.isDestroyed();
  const allWindows=()=>remote.BrowserWindow.getAllWindows().filter(windowAlive);
  function fingerprint() {
    // Session identity deliberately forbids reuse after app restart. Dependency and
    // settings hashes additionally detect changes inside the same renderer session.
    if(!host.__nativePdfSessionId) host.__nativePdfSessionId=crypto.randomUUID();
    const sheets=[...host.document.styleSheets].map(s=>[...s.cssRules].map(r=>r.cssText).join('\n'));
    return digest(JSON.stringify({version,session:host.__nativePdfSessionId,sheets,
      pdfSettings:app.vault.getConfig('pdfExportSettings')}));
  }
  // Called by the orchestrator with selected records. Resolve cached embeds
  // transitively; missing cache/links and remote resources fail closed.
  function dependencies(records) {
    const visited=new Set(),rows=[];
    function visit(relative) {
      if(visited.has(relative)) return;visited.add(relative);
      const file=app.vault.getAbstractFileByPath(relative);
      if(!file?.extension) throw Error('Missing embedded file: '+relative);
      const absolute=fs.realpathSync(path.join(root,relative));
      const rel=path.relative(root,absolute);
      if(rel.startsWith('..'+path.sep)||path.isAbsolute(rel)) throw Error('Embedded file outside vault');
      rows.push([relative,digest(fs.readFileSync(absolute))]);
      if(file.extension.toLowerCase()!=='md') return;
      const text=fs.readFileSync(absolute,'utf8');
      if(/<\s*(?:img|iframe|video|audio)\b|!\[[^\]]*\]\(\s*(?:https?:|data:|file:)/i.test(text))
        throw Error('Untracked external/HTML embed requires manual adaptation: '+relative);
      const cache=app.metadataCache.getFileCache(file);
      if(!cache) throw Error('Metadata cache not ready: '+relative);
      for(const embed of cache.embeds||[]) {
        const dest=app.metadataCache.getFirstLinkpathDest(embed.link.split('#')[0],relative);
        if(!dest) throw Error('Unresolved embed: '+embed.link);
        visit(dest.path);
      }
    }
    for(const r of records)visit(path.relative(root,r.source).split(path.sep).join('/'));
    return rows.sort((a,b)=>a[0]<b[0]?-1:a[0]>b[0]?1:0);
  }
  async function findOwned(ctx,doc) {
    doc.defaultView.__nativePdfWindowToken=ctx.token;
    const candidates=allWindows().filter(w=>!ctx.before.has(w.id));
    for(const w of candidates) {
      // Matching a random marker in the rendering document proves identity;
      // basename/title matching is deliberately not used.
      const match=await w.webContents.executeJavaScript('globalThis.__nativePdfWindowToken === '+JSON.stringify(ctx.token));
      if(match) ctx.owned.set(w.id,w);
    }
    if(ctx.owned.size!==1) throw Error('Cannot uniquely identify native print window');
  }
  async function exportNote(record,options,signal) {
    if(active || host.__nativePdfAdapterOwner || host.__calculusBatchRunning) throw Error('Another export is active');
    if(signal?.aborted) throw Error('Aborted before native export');
    const relative=path.relative(root,record.source).split(path.sep).join('/');
    if(relative.startsWith('../')||path.isAbsolute(relative)) throw Error('Source outside vault');
    const file=app.vault.getAbstractFileByPath(relative);
    if(!file || file.extension!=='md') throw Error('Markdown file unavailable');
    if(fs.existsSync(record.output)) throw Error('Output exists');
    const ctx={token:token+'-'+crypto.randomUUID(),before:new Set(allWindows().map(w=>w.id)),
      owned:new Map(),nativeStarted:false,settled:false,ready:false,errors:[],stage:record.output+'.'+crypto.randomUUID()+'.partial.pdf'};
    active=ctx;host[ownerKey]=ctx.token;
    let exporter,renderError;
    const check=()=>{if(signal?.aborted)throw Error('Export aborted');};
    try {
      const originalOpen=proto.open;
      try {
        proto.open=function(...args){
          if(this.file===file && typeof this.printToPdf==='function') {exporter=this;return;}
          return originalOpen.apply(this,args);
        };
        view.printToPdf.call({app,file});
      } finally {proto.open=originalOpen;}
      if(typeof exporter?.print!=='function') throw Error('Native exporter shape mismatch');
      const nativePrint=exporter.print;
      exporter.print=async function(container,component,includeName) {
        try {
          await nativePrint.call(this,container,component,includeName);check();
          const doc=container.ownerDocument;
          await findOwned(ctx,doc);check();
          container.getBoundingClientRect();
          const chemistry=record.chemistrySyntax || !!container.querySelector('.MHC-N');
          const faces=[...doc.fonts].filter(f=>/MJX|MathJax/i.test(f.family) && (f.family!=='MJX-MHC-N'||chemistry));
          await Promise.all(faces.map(f=>f.load()));check();
          await doc.fonts.ready;check();
          const images=[...container.querySelectorAll('img')];
          await Promise.all(images.map(i=>i.decode()));check();
          if(images.some(i=>!i.complete||!i.naturalWidth)) throw Error('Image not ready');
          if(faces.some(f=>f.status!=='loaded')) throw Error('Math font not loaded');
          container.getBoundingClientRect();
          await new Promise(r=>setTimeout(r,1500));check();
          ctx.evidence={fonts:faces.map(f=>({family:f.family,status:f.status})),
            images:images.map(i=>({complete:i.complete,width:i.naturalWidth})),
            mathCount:container.querySelectorAll('mjx-container').length,chemistry,
            windowIds:[...ctx.owned.keys()]};
          ctx.ready=true;
        } catch(e) {renderError=e;throw e;}
      };
      fs.mkdirSync(path.dirname(record.output),{recursive:true});
      try {
        ctx.nativeStarted=true;
        await exporter.printToPdf({includeName:options.includeName,pageSize:options.pageSize,
          landscape:options.landscape,marginsType:options.marginsType,
          scaleFactor:options.scalePercent,scale:options.scalePercent/100,open:false,filepath:ctx.stage});
      } finally {ctx.settled=true;}
      check();
      if(renderError)throw renderError;
      if(!ctx.ready)throw Error('Native renderer readiness hook was not completed');
      if(!fs.existsSync(ctx.stage)||!fs.statSync(ctx.stage).size)throw Error('Native export missing');
      // Publish without overwrite. A late native writer can only reach staging.
      fs.copyFileSync(ctx.stage,record.output,fs.constants.COPYFILE_EXCL);fs.unlinkSync(ctx.stage);
      return {resourceChecksPassed:true,evidence:ctx.evidence};
    } finally {
      // Close only marker-verified task-owned windows. No title-based cleanup.
      for(const w of ctx.owned.values()) if(windowAlive(w))w.destroy();
      if(ctx.settled || !ctx.nativeStarted) {
        if(host[ownerKey]===ctx.token)delete host[ownerKey];
        active=null;
      }
    }
  }
  return {
    identity:'obsidian-native-1.14.4-v1',
    renderFingerprint(records){return digest(JSON.stringify({runtime:fingerprint(),dependencies:dependencies(records)}));},
    exportNote,
    async abortAndConfirm(){
      if(!active)return true;
      for(const w of active.owned.values())if(windowAlive(w))w.destroy();
      // Destroying a window is not enough to prove an asynchronous writer stopped.
      return active.settled && [...active.owned.values()].every(w=>!windowAlive(w));
    }
  };
}
module.exports={createAdapter};
audit-pdfs.py|PDF 结构审计(75 行)
"""Read PDFs without modifying them; write a new audit/preview directory only."""
import argparse
import hashlib
import json
from pathlib import Path
import fitz


def audit(pdf):
    row = {"file": str(pdf), "sha256": hashlib.sha256(pdf.read_bytes()).hexdigest(), "issues": []}
    try:
        with fitz.open(pdf) as doc:
            row["pages"] = len(doc)
            row["bytes"] = pdf.stat().st_size
            if not len(doc):
                row["issues"].append({"type": "no-pages"})
            for p in doc:
                n = p.number + 1
                if abs(p.rect.width - 595.3) > 2 or abs(p.rect.height - 841.9) > 2:
                    row["issues"].append({"page": n, "type": "not-A4-portrait"})
                text = p.get_text()
                if not text.strip() and not p.get_images() and not p.get_drawings():
                    row["issues"].append({"page": n, "type": "blank"})
                if "\ufffd" in text:
                    row["issues"].append({"page": n, "type": "replacement-character"})
                if any(b[0] < -2 or b[1] < -2 or b[2] > p.rect.width + 2 or b[3] > p.rect.height + 2 for b in p.get_text("blocks")):
                    row["issues"].append({"page": n, "type": "out-of-page-text"})
    except Exception as e:
        row["issues"].append({"type": "unreadable", "error": str(e)})
    return row


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("source", type=Path)
    parser.add_argument("report_dir", type=Path)
    parser.add_argument("--preview", action="store_true", help="Render first/middle pages for manual review")
    parser.add_argument("--all-pages", action="store_true", help="Render every page for small-sample visual review")
    args = parser.parse_args()
    source = args.source.resolve(strict=True)
    dest = args.report_dir.resolve()
    if dest == source or source in dest.parents or dest in source.parents:
        parser.error("Report directory must be separate from PDF input")
    files = sorted(source.rglob("*.pdf")) if source.is_dir() else [source]
    if not files:
        parser.error("No PDFs selected")
    if any(p.is_symlink() for p in files):
        parser.error("Symlink inputs not supported")
    dest.mkdir(parents=True, exist_ok=False)
    rows = []
    for i, pdf in enumerate(files, 1):
        row = audit(pdf)
        if (args.preview or args.all_pages) and "pages" in row and row["pages"]:
            try:
                with fitz.open(pdf) as doc:
                    row["previews"] = []
                    for n in (range(len(doc)) if args.all_pages else sorted({0, len(doc) // 2})):
                        out = dest / f"{i:03}-page-{n+1:03}.png"
                        doc[n].get_pixmap(matrix=fitz.Matrix(1.2, 1.2)).save(out)
                        row["previews"].append(str(out))
            except Exception as e:
                row["issues"].append({"type": "preview-failed", "error": str(e)})
        rows.append(row)
    result = {"status": "needs-review" if any(r["issues"] for r in rows) else "structural-checks-passed",
              "files": len(rows), "pages": sum(r.get("pages", 0) for r in rows),
              "bytes": sum(r.get("bytes", 0) for r in rows), "visualReview": "not-performed",
              "limitations": "No source/resource coverage comparison; no proof of complete glyphs or layout. A4 portrait only.",
              "records": rows}
    (dest / "audit.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps({k: v for k, v in result.items() if k != "records"}, ensure_ascii=False))
    return 1 if result["status"] == "needs-review" else 0


if __name__ == "__main__":
    raise SystemExit(main())

排障补充与维护

字体缺字是首次任务中遇到的主要问题。只等待 document.fonts.ready 曾让单篇样本改善,批量材料中仍出现缺字,因此补上了导出文档中的显式字体加载。这里保留具体处理,不据此断言所有缺字都由同一原因造成。

MJX-MHC-N 属于 mhchem 化学扩展。当前实现结合源语法与渲染 DOM 判断是否需要它;对未使用材料的排除不能推广成全局忽略。换用化学笔记、动态插件或不同公式输出时,重新检查所需字形和字体筛选。

首次任务曾调整后台节流以继续推进,但没有足够证据将所有等待归于此原因。当前适配器不修改节流,已有真实样本在这一条件下完成。不要把历史排障动作固定成每次执行的前置操作。

CLI eval 中可能无法 require('obsidian'),所以入口使用当前窗口版本证据,并要求与 CLI 输出核对。受限环境中查不到桌面进程时,状态应为未知;通过已授权的 CLI 探测原应用,避免误开第二实例。

后续维护需要同步处理三类变化:应用升级后重验原生入口、资源等待和窗口清理;扩大版式后同步调整审计;引入新插件或资源后说明如何追踪依赖。工具配置、检查点、日志与人工复核记录应随任务保存,避免依赖聊天上下文恢复现场。