texlite 0.8.0 → 0.8.1

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.
@@ -1,50 +1,167 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { LATEX_ENGINES } from "./config.js";
2
3
  import { httpError } from "./http.js";
3
4
  import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
4
- export async function assertEnvironment(config) {
5
- const commands = [
6
- { name: "latexmk", command: config.latexmk },
7
- ...config.allowedEngines.map((command) => ({ name: command, command }))
8
- ];
9
- const checked = [];
10
- const seen = new Set();
11
- for (const item of commands) {
12
- if (seen.has(item.command))
5
+ export const MIN_NODE_MAJOR = 24;
6
+ const commandProbeTimeoutMs = 10_000;
7
+ const maxCommandOutputBytes = 64 * 1024;
8
+ /**
9
+ * Probe all tools that are relevant to a TexLite host. Optional tools are
10
+ * included deliberately: their absence must be visible in `doctor`, but does
11
+ * not prevent TexLite from starting.
12
+ */
13
+ export async function inspectHostEnvironment(config) {
14
+ return Promise.all(hostToolDefinitions(config).map((tool) => inspectTool(tool)));
15
+ }
16
+ /**
17
+ * Probe the default commands on PATH without loading a TexLite configuration.
18
+ * This supports pre-installation checks through `texlite requirements`.
19
+ */
20
+ export async function inspectHostRequirements() {
21
+ return Promise.all(hostRequirementToolDefinitions().map((tool) => inspectTool(tool)));
22
+ }
23
+ export function hostRequirementsSatisfied(tools) {
24
+ if (tools.some((tool) => tool.requirement === "required" && tool.status !== "installed"))
25
+ return false;
26
+ const oneOfGroups = new Map();
27
+ for (const tool of tools) {
28
+ if (tool.requirement !== "one-of")
13
29
  continue;
14
- seen.add(item.command);
15
- const result = await commandVersion(item.command, 10_000);
16
- if (result.error || result.status !== 0) {
17
- const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
18
- throw new Error(`Environment check failed: could not run the ${item.name} command "${item.command}" (${detail}). Initialization/startup has been stopped.`);
19
- }
20
- const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
21
- checked.push({ ...item, version });
30
+ const group = tool.requirementGroup ?? tool.id;
31
+ const members = oneOfGroups.get(group) ?? [];
32
+ members.push(tool);
33
+ oneOfGroups.set(group, members);
34
+ }
35
+ return [...oneOfGroups.values()].every((members) => members.some((tool) => tool.status === "installed"));
36
+ }
37
+ export async function assertEnvironment(config) {
38
+ const tools = await Promise.all(requiredToolDefinitions(config).map((tool) => inspectTool(tool)));
39
+ const unavailable = tools.find((tool) => tool.status !== "installed");
40
+ if (unavailable) {
41
+ const detail = unavailable.detail ?? "unknown error";
42
+ throw new Error(`Environment check failed: could not run the ${unavailable.name} command "${unavailable.command}" (${detail}). Initialization/startup has been stopped.`);
22
43
  }
23
- return checked;
44
+ return tools.map(({ name, command, version }) => ({ name, command, version: version ?? "available" }));
24
45
  }
25
46
  export async function assertGitAvailable(config) {
26
- const item = { name: "Git", command: config.git };
27
- const result = await commandVersion(item.command, Math.min(config.gitOperationTimeoutMs, 10_000));
28
- if (result.error || result.status !== 0) {
29
- const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
30
- // Command output can reveal host paths; it remains a startup diagnostic,
31
- // while API clients receive the localized, stable error code.
32
- void detail;
47
+ const tool = await inspectTool(gitToolDefinition(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs)));
48
+ if (tool.status !== "installed")
33
49
  throw httpError(503, "GIT_UNAVAILABLE");
50
+ return { name: tool.name, command: tool.command, version: tool.version ?? "available" };
51
+ }
52
+ function requiredToolDefinitions(config) {
53
+ return [
54
+ { id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
55
+ { id: "latexmk", name: "latexmk", command: config.latexmk, requirement: "required", purpose: "LaTeX compilation" },
56
+ ...config.allowedEngines.map((command) => ({
57
+ id: `engine:${command}`, name: command, command, requirement: "required",
58
+ purpose: "Configured LaTeX engine"
59
+ }))
60
+ ];
61
+ }
62
+ function hostToolDefinitions(config) {
63
+ return [
64
+ ...requiredToolDefinitions(config),
65
+ ...optionalToolDefinitions(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs))
66
+ ];
67
+ }
68
+ function hostRequirementToolDefinitions() {
69
+ return [
70
+ { id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
71
+ { id: "latexmk", name: "latexmk", command: "latexmk", requirement: "required", purpose: "LaTeX compilation" },
72
+ ...LATEX_ENGINES.map((command) => ({
73
+ id: `engine:${command}`, name: command, command, requirement: "one-of",
74
+ requirementGroup: "latex-engine", purpose: "Supported LaTeX engine"
75
+ })),
76
+ ...optionalToolDefinitions("git", commandProbeTimeoutMs)
77
+ ];
78
+ }
79
+ function optionalToolDefinitions(gitCommand, gitTimeoutMs) {
80
+ return [
81
+ gitToolDefinition(gitCommand, gitTimeoutMs),
82
+ { id: "texcount", name: "TeXcount", command: "texcount", requirement: "optional", purpose: "Word and character statistics", versionArgs: ["-version"] },
83
+ { id: "bibtex", name: "BibTeX", command: "bibtex", requirement: "optional", purpose: "BibTeX bibliography builds" },
84
+ { id: "biber", name: "Biber", command: "biber", requirement: "optional", purpose: "Biber bibliography builds" },
85
+ { id: "makeindex", name: "MakeIndex", command: "makeindex", requirement: "optional", purpose: "Index generation", versionArgs: [] },
86
+ { id: "harper-cli", name: "Harper CLI", command: "harper-cli", requirement: "optional", purpose: "TexLite spelling and grammar checks" },
87
+ { id: "harper-ls", name: "Harper language server", command: "harper-ls", requirement: "optional", purpose: "External editor integration (not used by TexLite)" }
88
+ ];
89
+ }
90
+ function gitToolDefinition(command, timeoutMs) {
91
+ return { id: "git", name: "Git", command, requirement: "optional", purpose: "Git and GitHub backup", timeoutMs };
92
+ }
93
+ async function inspectTool(tool) {
94
+ if (tool.id === "node")
95
+ return inspectNode(tool);
96
+ const identity = toolIdentity(tool);
97
+ const result = await commandVersion(tool.command, tool.timeoutMs ?? commandProbeTimeoutMs, tool.versionArgs ?? ["--version"]);
98
+ if (!result.error && result.status === 0) {
99
+ return { ...identity, status: "installed", version: versionFromResult(result), detail: null };
34
100
  }
35
- const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
36
- return { ...item, version };
101
+ const missing = result.errorCode === "ENOENT";
102
+ return {
103
+ ...identity,
104
+ status: missing ? "missing" : "failed",
105
+ version: null,
106
+ detail: commandFailureDetail(result)
107
+ };
108
+ }
109
+ function inspectNode(tool) {
110
+ const identity = toolIdentity(tool);
111
+ const match = /^v(\d+)/.exec(process.version);
112
+ const major = match ? Number(match[1]) : Number.NaN;
113
+ if (Number.isInteger(major) && major >= MIN_NODE_MAJOR) {
114
+ return { ...identity, status: "installed", version: process.version, detail: null };
115
+ }
116
+ return {
117
+ ...identity,
118
+ status: "failed",
119
+ version: process.version || null,
120
+ detail: `TexLite requires Node.js ${MIN_NODE_MAJOR} or newer.`
121
+ };
122
+ }
123
+ function toolIdentity(tool) {
124
+ return {
125
+ id: tool.id,
126
+ name: tool.name,
127
+ command: tool.command,
128
+ requirement: tool.requirement,
129
+ requirementGroup: tool.requirementGroup,
130
+ purpose: tool.purpose
131
+ };
132
+ }
133
+ function versionFromResult(result) {
134
+ const lines = `${result.stdout}\n${result.stderr}`.split(/\r?\n/)
135
+ .map((line) => line.trim())
136
+ .filter((line) => line && !/^warning\b/i.test(line) && !/^possible precedence problem\b/i.test(line));
137
+ const versionLine = lines.find((line) => /\bversion\b|\bv?\d+\.\d+/i.test(line)) ?? lines[0];
138
+ return (versionLine ?? "available").slice(0, 160);
37
139
  }
38
- function commandVersion(command, timeoutMs) {
140
+ function commandFailureDetail(result) {
141
+ const detail = result.error || result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
142
+ return detail.replace(/\s+/g, " ").slice(0, 240);
143
+ }
144
+ function commandVersion(command, timeoutMs, args) {
39
145
  return new Promise((resolve) => {
40
- const child = spawn(command, ["--version"], {
41
- env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
42
- stdio: ["ignore", "pipe", "pipe"]
43
- });
146
+ let child;
147
+ try {
148
+ child = spawn(command, args, {
149
+ env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
150
+ stdio: ["ignore", "pipe", "pipe"]
151
+ });
152
+ }
153
+ catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ const errorCode = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null;
156
+ resolve({ status: null, stdout: "", stderr: "", error: message, errorCode });
157
+ return;
158
+ }
44
159
  const stdout = [];
45
160
  const stderr = [];
161
+ let outputBytes = 0;
46
162
  let settled = false;
47
163
  let timedOut = false;
164
+ let outputExceeded = false;
48
165
  const finish = (result) => {
49
166
  if (settled)
50
167
  return;
@@ -53,11 +170,24 @@ function commandVersion(command, timeoutMs) {
53
170
  resolve({ ...result, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
54
171
  };
55
172
  const timer = setTimeout(() => { timedOut = true; killProcessGroup(child); }, timeoutMs);
56
- child.stdout.on("data", (chunk) => { if (Buffer.concat(stdout).length < 64 * 1024)
57
- stdout.push(chunk); });
58
- child.stderr.on("data", (chunk) => { if (Buffer.concat(stderr).length < 64 * 1024)
59
- stderr.push(chunk); });
60
- child.once("error", (error) => finish({ status: null, error: error.message }));
61
- child.once("close", (status) => finish({ status, error: timedOut ? "command timed out" : null }));
173
+ const collect = (target) => (chunk) => {
174
+ if (settled)
175
+ return;
176
+ outputBytes += chunk.length;
177
+ if (outputBytes > maxCommandOutputBytes) {
178
+ outputExceeded = true;
179
+ killProcessGroup(child);
180
+ return;
181
+ }
182
+ target.push(chunk);
183
+ };
184
+ child.stdout.on("data", collect(stdout));
185
+ child.stderr.on("data", collect(stderr));
186
+ child.once("error", (error) => finish({ status: null, error: error.message, errorCode: error.code ?? null }));
187
+ child.once("close", (status) => finish({
188
+ status,
189
+ error: timedOut ? "command timed out" : outputExceeded ? "command produced too much output" : null,
190
+ errorCode: null
191
+ }));
62
192
  });
63
193
  }
@@ -95,6 +95,10 @@ const enMessages = {
95
95
  UPLOAD_EMPTY: "No file was selected for upload.",
96
96
  USERNAME_INVALID: "The username may contain only letters, numbers, dots, hyphens, or underscores.",
97
97
  USER_NOT_FOUND: "The user does not exist or is disabled.",
98
+ WORD_COUNT_FAILED: "TeXcount could not count this source.",
99
+ WORD_COUNT_SOURCE_INVALID: "Select LaTeX source text to count.",
100
+ WORD_COUNT_SOURCE_TOO_LARGE: "The selected source is too large to count.",
101
+ WORD_COUNT_UNAVAILABLE: "TeXcount is not available on this server. Install it from TeX Live and try again.",
98
102
  ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
99
103
  ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
100
104
  ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
@@ -215,6 +219,10 @@ const zhMessages = {
215
219
  UPLOAD_EMPTY: "没有选择要上传的文件。",
216
220
  USERNAME_INVALID: "用户名只能包含字母、数字、点、横线或下划线。",
217
221
  USER_NOT_FOUND: "用户不存在或已被禁用。",
222
+ WORD_COUNT_FAILED: "TeXcount 无法统计这段源码。",
223
+ WORD_COUNT_SOURCE_INVALID: "请选择 LaTeX 源码后再统计。",
224
+ WORD_COUNT_SOURCE_TOO_LARGE: "所选源码过大,无法统计。",
225
+ WORD_COUNT_UNAVAILABLE: "服务器未安装 TeXcount,请安装 TeX Live 后重试。",
218
226
  ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
219
227
  ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
220
228
  ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",
@@ -0,0 +1,83 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { maxCollaborativeFileBytes } from "../collaboration.js";
3
+ import { compileMainFile } from "../compileArtifacts.js";
4
+ import { assertNoSourceSymlinks, safeRelativePath, sourceRoot } from "../files.js";
5
+ import { apiError, httpError } from "../http.js";
6
+ import { accessibleProject } from "../projects.js";
7
+ import { TexcountExecutionError, TexcountUnavailableError } from "../texcount.js";
8
+ /** Register host TeXcount-backed full-document and editor-selection counts. */
9
+ export function registerWordCountRoutes(app, context) {
10
+ const { config, db, projectMutations, texcount } = context;
11
+ app.post("/api/projects/:id/word-count", async (request, reply) => {
12
+ const user = requireUser(request, reply, db);
13
+ if (!user)
14
+ return;
15
+ const { id } = request.params;
16
+ const project = accessibleProject(db, id, user);
17
+ if (!project)
18
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
19
+ const body = (request.body ?? {});
20
+ const mode = body.mode;
21
+ if (mode !== "full" && mode !== "selection")
22
+ return apiError(reply, 400, "WORD_COUNT_SOURCE_INVALID");
23
+ try {
24
+ if (mode === "selection") {
25
+ return await countSelection(config, id, body, texcount);
26
+ }
27
+ return await countDocument(config, db, projectMutations, texcount, id, user, body.mainFile);
28
+ }
29
+ catch (error) {
30
+ if (error instanceof TexcountUnavailableError)
31
+ return apiError(reply, 503, "WORD_COUNT_UNAVAILABLE");
32
+ if (error instanceof TexcountExecutionError)
33
+ return apiError(reply, 422, "WORD_COUNT_FAILED");
34
+ throw error;
35
+ }
36
+ });
37
+ }
38
+ async function countSelection(config, projectId, body, texcount) {
39
+ if (typeof body.path !== "string" || typeof body.source !== "string") {
40
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
41
+ }
42
+ let filePath;
43
+ try {
44
+ filePath = safeRelativePath(body.path);
45
+ }
46
+ catch {
47
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
48
+ }
49
+ // TeXcount's stdin parser is intended for TeX-like source. Keep BibTeX and
50
+ // binary resources out of this endpoint while allowing .sty/.cls snippets.
51
+ if (!/\.(?:tex|sty|cls)$/i.test(filePath) || !body.source.trim()) {
52
+ throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
53
+ }
54
+ if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
55
+ throw httpError(413, "WORD_COUNT_SOURCE_TOO_LARGE");
56
+ }
57
+ const counts = await texcount.countSource(sourceRoot(config, projectId), body.source);
58
+ return { mode: "selection", path: filePath, ...counts };
59
+ }
60
+ async function countDocument(config, db, projectMutations, texcount, projectId, user, requestedMainFile) {
61
+ const initialProject = accessibleProject(db, projectId, user);
62
+ if (!initialProject)
63
+ throw httpError(404, "PROJECT_NOT_FOUND");
64
+ const mainFile = compileMainFile(config, projectId, initialProject.main_file, requestedMainFile);
65
+ if (!mainFile)
66
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
67
+ return await projectMutations.runConsistentRead(projectId, async () => {
68
+ // TeXcount follows \input/\include paths. Reject links before handing it
69
+ // the source root so a checked-out link cannot expose files outside it.
70
+ assertNoSourceSymlinks(config, projectId);
71
+ const counts = await texcount.countFile(sourceRoot(config, projectId), mainFile);
72
+ return { mode: "full", path: mainFile, ...counts };
73
+ }, {
74
+ preflight: () => {
75
+ const current = accessibleProject(db, projectId, user);
76
+ if (!current)
77
+ throw httpError(404, "PROJECT_NOT_FOUND");
78
+ const selected = compileMainFile(config, projectId, current.main_file, requestedMainFile);
79
+ if (!selected || selected !== mainFile)
80
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,244 @@
1
+ import { spawn } from "node:child_process";
2
+ import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
3
+ /** TeXcount is an optional host command supplied by TeX Live. */
4
+ export const TEXCOUNT_COMMAND = "texcount";
5
+ const TEXLITE_WORD_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_WORDS__";
6
+ const TEXLITE_CHARACTER_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_CHARS__";
7
+ // TeXcount placeholders {1} through {7} are, respectively: words in text,
8
+ // header words, caption words, header count, float count, inline math, and
9
+ // displayed math. Do not parse TeXcount's human-facing prose report.
10
+ const TEXLITE_WORD_REPORT_TEMPLATE = `${TEXLITE_WORD_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
11
+ const TEXLITE_CHARACTER_REPORT_TEMPLATE = `${TEXLITE_CHARACTER_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
12
+ const PROBE_TIMEOUT_MS = 5_000;
13
+ const COUNT_TIMEOUT_MS = 30_000;
14
+ const UNAVAILABLE_RETRY_MS = 15_000;
15
+ const MAX_OUTPUT_BYTES = 512 * 1024;
16
+ export class TexcountUnavailableError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "TexcountUnavailableError";
20
+ }
21
+ }
22
+ export class TexcountExecutionError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "TexcountExecutionError";
26
+ }
27
+ }
28
+ /**
29
+ * Run the optional host TeXcount binary and normalize its explicit report
30
+ * template into stable fields for the browser. The command is intentionally
31
+ * fixed (rather than shell-evaluated) so project sources cannot inject flags.
32
+ */
33
+ export class TexcountService {
34
+ command;
35
+ availability = "unknown";
36
+ lastUnavailableAt = 0;
37
+ probePromise = null;
38
+ activeChildren = new Set();
39
+ disposed = false;
40
+ constructor(command = TEXCOUNT_COMMAND) {
41
+ this.command = command;
42
+ }
43
+ async countFile(cwd, filePath) {
44
+ await this.ensureAvailable();
45
+ return this.count(cwd, ["-inc", "-total"], filePath);
46
+ }
47
+ async countSource(cwd, source) {
48
+ await this.ensureAvailable();
49
+ // A dash tells TeXcount to read one virtual TeX document from stdin. No
50
+ // include traversal is requested for a selection because the selection
51
+ // is not a complete project tree.
52
+ return this.count(cwd, ["-total"], "-", source);
53
+ }
54
+ async dispose() {
55
+ this.disposed = true;
56
+ for (const child of this.activeChildren)
57
+ killProcessGroup(child, "SIGTERM");
58
+ this.activeChildren.clear();
59
+ await this.probePromise?.catch(() => undefined);
60
+ this.probePromise = null;
61
+ }
62
+ async ensureAvailable() {
63
+ if (this.disposed)
64
+ throw new TexcountUnavailableError("TeXcount service stopped.");
65
+ if (this.availability === "available")
66
+ return;
67
+ if (this.availability === "unavailable" && Date.now() - this.lastUnavailableAt < UNAVAILABLE_RETRY_MS) {
68
+ throw new TexcountUnavailableError(`The optional ${this.command} command is not available.`);
69
+ }
70
+ if (this.probePromise)
71
+ return this.probePromise;
72
+ this.probePromise = this.runCommand(["--version"], undefined, undefined, PROBE_TIMEOUT_MS, 64 * 1024)
73
+ .then(({ code, stdout, stderr }) => {
74
+ if (code !== 0)
75
+ throw new Error((stderr || stdout).trim() || `exited with code ${code}`);
76
+ this.availability = "available";
77
+ })
78
+ .catch((error) => {
79
+ this.availability = "unavailable";
80
+ this.lastUnavailableAt = Date.now();
81
+ const detail = error instanceof Error ? error.message : String(error);
82
+ throw new TexcountUnavailableError(`The optional ${this.command} command is unavailable: ${detail}`);
83
+ })
84
+ .finally(() => { this.probePromise = null; });
85
+ return this.probePromise;
86
+ }
87
+ async count(cwd, options, target, input) {
88
+ const [wordResult, characterResult] = await Promise.all([
89
+ this.runCommand([...options, `-template=${TEXLITE_WORD_REPORT_TEMPLATE}`, target], cwd, input),
90
+ this.runCommand([...options, "-char", `-template=${TEXLITE_CHARACTER_REPORT_TEMPLATE}`, target], cwd, input)
91
+ ]);
92
+ const words = this.finishWordCount(wordResult);
93
+ const characters = this.finishCharacterCount(characterResult);
94
+ return {
95
+ ...words,
96
+ totalCharacters: characters.text + characters.headers + characters.captions,
97
+ // TeXcount parses the same source twice. Do not show each syntax issue
98
+ // twice merely because character counting was requested as well.
99
+ parserErrors: Math.max(words.parserErrors, characters.parserErrors)
100
+ };
101
+ }
102
+ finishWordCount(result) {
103
+ if (result.code !== 0) {
104
+ throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
105
+ }
106
+ try {
107
+ return parseTexcountOutput(result.stdout);
108
+ }
109
+ catch (error) {
110
+ throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
111
+ }
112
+ }
113
+ finishCharacterCount(result) {
114
+ if (result.code !== 0) {
115
+ throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
116
+ }
117
+ try {
118
+ return parseTexcountReport(result.stdout, TEXLITE_CHARACTER_REPORT_PREFIX);
119
+ }
120
+ catch (error) {
121
+ throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
122
+ }
123
+ }
124
+ runCommand(args, cwd, input, timeoutMs = COUNT_TIMEOUT_MS, outputLimit = MAX_OUTPUT_BYTES) {
125
+ return new Promise((resolve, reject) => {
126
+ if (this.disposed) {
127
+ reject(new TexcountUnavailableError("TeXcount service stopped."));
128
+ return;
129
+ }
130
+ let child;
131
+ try {
132
+ child = spawn(this.command, args, {
133
+ cwd,
134
+ env: { ...process.env, LC_ALL: "C" },
135
+ shell: false,
136
+ detached: detachedProcessGroup(),
137
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
138
+ });
139
+ }
140
+ catch (error) {
141
+ reject(error);
142
+ return;
143
+ }
144
+ this.activeChildren.add(child);
145
+ const stdout = [];
146
+ const stderr = [];
147
+ let outputBytes = 0;
148
+ let settled = false;
149
+ let timedOut = false;
150
+ let outputTooLarge = false;
151
+ const timer = setTimeout(() => {
152
+ timedOut = true;
153
+ killProcessGroup(child);
154
+ }, timeoutMs);
155
+ const finish = (callback) => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ clearTimeout(timer);
160
+ this.activeChildren.delete(child);
161
+ callback();
162
+ };
163
+ const terminateWithError = (error) => {
164
+ killProcessGroup(child);
165
+ finish(() => reject(error));
166
+ };
167
+ const collect = (target) => (chunk) => {
168
+ outputBytes += chunk.length;
169
+ if (outputBytes > outputLimit) {
170
+ outputTooLarge = true;
171
+ terminateWithError(new TexcountExecutionError(`${this.command} produced too much output.`));
172
+ return;
173
+ }
174
+ target.push(chunk);
175
+ };
176
+ child.stdout?.on("data", collect(stdout));
177
+ child.stderr?.on("data", collect(stderr));
178
+ child.stdin?.on("error", (error) => terminateWithError(error));
179
+ child.once("error", (error) => finish(() => {
180
+ if (timedOut) {
181
+ reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
182
+ return;
183
+ }
184
+ if (error.code === "ENOENT") {
185
+ reject(new TexcountUnavailableError(`The optional ${this.command} command is not available.`));
186
+ return;
187
+ }
188
+ reject(error);
189
+ }));
190
+ child.once("close", (code) => finish(() => {
191
+ if (timedOut) {
192
+ reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
193
+ return;
194
+ }
195
+ if (outputTooLarge) {
196
+ reject(new TexcountExecutionError(`${this.command} produced too much output.`));
197
+ return;
198
+ }
199
+ resolve({
200
+ code,
201
+ stdout: Buffer.concat(stdout).toString("utf8"),
202
+ stderr: Buffer.concat(stderr).toString("utf8")
203
+ });
204
+ }));
205
+ if (input !== undefined)
206
+ child.stdin?.end(input, "utf8");
207
+ });
208
+ }
209
+ }
210
+ /** Parse TexLite's explicit TeXcount template, rather than prose output. */
211
+ export function parseTexcountOutput(output) {
212
+ const report = parseTexcountReport(output, TEXLITE_WORD_REPORT_PREFIX);
213
+ const filesMatch = /Files:\s*(\d+)/i.exec(output);
214
+ return {
215
+ textWords: report.text,
216
+ headerWords: report.headers,
217
+ captionWords: report.captions,
218
+ totalWords: report.text + report.headers + report.captions,
219
+ headers: report.headerCount,
220
+ floats: report.floatCount,
221
+ inlineMath: report.inlineMathCount,
222
+ displayMath: report.displayMathCount,
223
+ files: filesMatch ? Number(filesMatch[1]) : null,
224
+ parserErrors: report.parserErrors
225
+ };
226
+ }
227
+ function parseTexcountReport(output, prefix) {
228
+ const report = new RegExp(`${prefix}\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)`).exec(output);
229
+ if (!report) {
230
+ throw new Error("TeXcount did not return TexLite's expected report; a %TC:newtemplate or %TC:template directive may have overridden it.");
231
+ }
232
+ const [, text, header, caption, headerCount, floatCount, inlineMathCount, displayMathCount] = report;
233
+ const errorsMatch = /\(errors:\s*(\d+)\)/i.exec(output);
234
+ return {
235
+ text: Number(text),
236
+ headers: Number(header),
237
+ captions: Number(caption),
238
+ headerCount: Number(headerCount),
239
+ floatCount: Number(floatCount),
240
+ inlineMathCount: Number(inlineMathCount),
241
+ displayMathCount: Number(displayMathCount),
242
+ parserErrors: errorsMatch ? Number(errorsMatch[1]) : 0
243
+ };
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -28,7 +28,8 @@
28
28
  "DESIGN.md",
29
29
  "NPM_TESTING.md",
30
30
  "LICENSE",
31
- "preview.png",
31
+ "preview-1.png",
32
+ "preview-2.png",
32
33
  "texlite.config.example.json"
33
34
  ],
34
35
  "engines": {
package/preview-1.png ADDED
Binary file
package/preview-2.png ADDED
Binary file