Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ on:

jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'

- name: JS 语法检查
shell: bash
run: |
for file in src/js/main.js src/js/workspace.js src/js/project-model.js src/js/project-archive.js src/js/preview.js src/js/visual-math.js src/js/visual-references.js src/js/visual-references-ui.js vendor/mathlive/mathlive.min.js server/compiler.mjs server/pdf-preview.mjs server/server.mjs; do
node --check "$file"
Expand Down
9 changes: 8 additions & 1 deletion server/compiler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,14 @@ export async function compileProject(project, options = {}) {
}
if (/\.eps$/i.test(mainFile)) args.push('-dEPSCrop');
args.push('-f', portable(mainFile));
await run(engine.executable, args);
// Ghostscript @files avoid the Windows command-line limit without
// widening the per-file grants. Its parser accepts double quotes and
// escaped quotes, not JSON/shell backslash escapes. All paths above
// use forward slashes; finish each quoted argument with whitespace.
const argumentFile = path.join(jobDirectory, 'ghostscript.args');
await writeFile(argumentFile, args.map(arg => '"' + arg.replace(/"/g, '\\"') + '"\n').join(''),
{encoding: 'utf8', flag: 'wx'});
await run(engine.executable, ['@' + portable(argumentFile)]);
} else if (engine.engine === 'tectonic') {
output = path.join(outputDirectory, path.parse(mainFile).name + '.pdf');
let tectonicError;
Expand Down
25 changes: 18 additions & 7 deletions server/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,15 @@ export function createWorkspaceServer({
jobs.delete(job.id);
}
function sweep() {
for (const job of jobs.values()) if (!job.active && now() - job.finishedAt >= jobTTL) discard(job);
// Completed results expire after inactivity, so reading a long document
// does not lose its pages merely because compilation finished earlier.
for (const job of jobs.values()) if (!job.active && now() - (job.lastAccessAt ?? job.finishedAt) >= jobTTL) discard(job);
}
async function previewOperation(job, res, operation) {
if (previewActive) throw error('已有页面预览正在生成,请稍后重试。', 409, 'PREVIEW_BUSY');
const active = {job, controller: new AbortController()};
previewActive = active;
job.lastAccessAt = now();
const disconnected = () => { if (!res.writableEnded) active.controller.abort(); };
res.once('close', disconnected);
try {
Expand Down Expand Up @@ -162,7 +165,7 @@ export function createWorkspaceServer({
if (!delivered && job.active) { job.state = 'cancelled'; job.controller.abort(); }
});
json(202, {id: job.id});
Promise.resolve().then(async () => {
job.completion = Promise.resolve().then(async () => {
const engines = await getEngines();
if (job.controller.signal.aborted) return;
const result = await compiler(payload.project, {kind: payload.kind, mainPath: payload.mainPath, signal: job.controller.signal, engines});
Expand All @@ -175,7 +178,7 @@ export function createWorkspaceServer({
if (job.controller.signal.aborted) return;
Object.assign(job, {state: 'failed', error: problem.message || '编译失败', code: problem.code || 'COMPILE_FAILED',
log: String(problem.log || '').slice(-200_000)});
}).finally(() => { job.active = false; job.finishedAt = now(); });
}).finally(() => { job.active = false; job.lastAccessAt = job.finishedAt = now(); });
return;
}
const match = /^\/api\/jobs\/([0-9a-f-]+)(\/pdf|\/preview(?:\/([0-9]+))?)?$/.exec(path);
Expand All @@ -185,21 +188,25 @@ export function createWorkspaceServer({
if (previewActive?.job === job) previewActive.controller.abort();
if (job.active) {
job.state = 'cancelled'; job.controller.abort();
// Acknowledging cancellation also means the compiler slot is
// free; process termination and temporary-file cleanup can lag abort.
await job.completion;
}
json(200, {id: job.id, state: job.state});
return;
}
if (req.method !== 'GET') throw error('不支持的操作。', 405);
if (match[2] === '/pdf') {
if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY');
job.lastAccessAt = now();
res.writeHead(200, {'Content-Type': 'application/pdf', 'Content-Length': job.pdf.length,
'Content-Disposition': 'inline; filename="preview.pdf"'});
res.end(job.pdf);
} else if (match[2]?.startsWith('/preview')) {
if (job.state !== 'succeeded') throw error('PDF 尚未生成。', 409, 'PDF_NOT_READY');
const info = job.previewInfo || await previewOperation(job, res, options => previewInfo(job.pdf, options));
if (info.available) job.previewInfo = info;
if (!match[3]) { json(200, info); return; }
if (!match[3]) { job.lastAccessAt = now(); json(200, info); return; }
if (!info.available) throw error(info.reason || '没有可用的预览引擎,仍可下载 PDF。', 503, 'PREVIEW_ENGINE_MISSING');
const page = Number(match[3]);
if (!Number.isSafeInteger(page) || page < 1 || page > Math.min(info.pageCount, info.maxPages)) {
Expand All @@ -209,12 +216,16 @@ export function createWorkspaceServer({
: await previewOperation(job, res, options => previewRenderer(job.pdf, page, {...options, pageCount: info.pageCount}));
job.previewCache = {page, png};
if (res.destroyed || res.writableEnded) return;
job.lastAccessAt = now();
res.writeHead(200, {'Content-Type': 'image/png', 'Content-Length': png.length,
'X-Preview-Page': String(page), 'X-Preview-Page-Count': String(info.pageCount)});
res.end(png);
} else json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code,
engine: job.engine, mainPath: job.mainPath,
...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})});
} else {
job.lastAccessAt = now();
json(200, {id: job.id, state: job.state, log: job.log, error: job.error, code: job.code,
engine: job.engine, mainPath: job.mainPath,
...(job.state === 'succeeded' ? {pdfUrl: '/api/jobs/' + job.id + '/pdf'} : {})});
}
return;
}
if (!['GET', 'HEAD'].includes(req.method)) throw error('不支持的操作。', 405);
Expand Down
33 changes: 29 additions & 4 deletions src/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ function insertBlockFormula() {
openFormulaModal('block');
}

// 标签是引用标识符,不能用正文转义规则改写下划线等合法字符。
function normalizeImageLabel(value) {
const label = String(value ?? '').trim();
if (/[\\{}\[\],%#$&^~*\s\u0000-\u001f\u007f]/.test(label)) {
throw new Error('图片标签不能包含空白、括号、反斜杠或 LaTeX 控制字符');
}
return label;
}

// 插入图片
function insertImage(options) {
const supplied = options && typeof options.imagePath === 'string';
Expand All @@ -237,8 +246,10 @@ function insertImage(options) {
if (!imagePath) return;
const caption = supplied ? options.caption : prompt('请输入图片标题:', '图片标题');
if (caption === null) return;
const label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example');
let label = supplied ? options.label : prompt('请输入图片标签(用于引用):', 'fig:example');
if (label === null) return;
try { label = normalizeImageLabel(label); }
catch (error) { updateStatus(error.message); return; }

const img = document.createElement('img');
img.src = imagePath;
Expand Down Expand Up @@ -1002,7 +1013,8 @@ function convertNodeToLatex(node) {
case 'img': {
const imgPath = node.getAttribute('src') || '';
const caption = node.getAttribute('data-caption');
const label = node.getAttribute('data-label');
const rawLabel = node.getAttribute('data-label');
const label = node.getAttribute('data-label-tex') === 'true' ? rawLabel : normalizeImageLabel(rawLabel);
const width = node.getAttribute('data-width');
const placement = node.getAttribute('data-placement');
latex = '\\begin{figure}' + (placement === '' ? '' : '[' + (placement || 'htbp') + ']') + '\n';
Expand All @@ -1012,7 +1024,7 @@ function convertNodeToLatex(node) {
if (caption || node.getAttribute('data-has-caption') === 'true') {
latex += ' \\caption{' + (node.getAttribute('data-caption-tex') === 'true' ? caption || '' : escapeLatex(caption || '')) + '}\n';
}
if (label) latex += ' \\label{' + (node.getAttribute('data-label-tex') === 'true' ? label : escapeLatex(label)) + '}\n';
if (label) latex += ' \\label{' + label + '}\n';
latex += '\\end{figure}' + separator;
break;
}
Expand All @@ -1031,13 +1043,26 @@ function convertNodeToLatex(node) {
}

function convertInlineNodes(node) {
const children = Array.from(node.childNodes);
const content = children.filter(child => child.nodeType !== Node.TEXT_NODE || child.textContent !== '');
// contenteditable keeps an unsourced <br> in an emptied paragraph so the
// caret remains visible. Imported breaks and breaks beside text are real.
if (/^(p|div)$/i.test(node.tagName) && content.length === 1 &&
content[0].nodeType === Node.ELEMENT_NODE && content[0].tagName.toLowerCase() === 'br' &&
content[0].getAttribute('data-tex-source') === null) return '';
let latex = '';
let pendingComment = false;
Array.from(node.childNodes).forEach(function(child) {
children.forEach(function(child) {
const part = convertNodeToLatex(child);
if (!part) return;
// 段尾注释的换行可能保存在外部空白节点中;追加文字时必须先结束注释。
if (pendingComment && !/^[ \t]*[\r\n]/.test(part)) latex += '\n';
else if (/^[A-Za-z@]/.test(part)) {
// A removed space must not turn separate atoms into \LaTeXbook.
// An even run of backslashes ends in a control symbol, not a word.
const command = /(\\+)[A-Za-z@]+$/.exec(latex);
if (command && command[1].length % 2 === 1) latex += '{}';
}
latex += part;
pendingComment = child.nodeType === Node.ELEMENT_NODE && child.classList.contains('tex-cmd') &&
child.getAttribute('hidden') !== null && part.startsWith('%') && !/[\r\n]/.test(part);
Expand Down
Loading
Loading