docrev 0.9.17 → 0.9.18

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/lib/build.ts CHANGED
@@ -32,8 +32,13 @@ import { resolveCSL } from './csl.js';
32
32
  /** Supported output formats */
33
33
  const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const;
34
34
 
35
- /** Maximum title length for output filename */
36
- const MAX_TITLE_FILENAME_LENGTH = 50;
35
+ /**
36
+ * Maximum length for slugified-title output filenames. Only used when no
37
+ * explicit `output:` filename is configured. Long titles are truncated at the
38
+ * last `-` boundary at-or-before this length so words stay intact (the old
39
+ * blind `.slice(0, 50)` cut mid-word).
40
+ */
41
+ const MAX_TITLE_FILENAME_LENGTH = 80;
37
42
 
38
43
  // =============================================================================
39
44
  // Interfaces
@@ -69,6 +74,8 @@ export interface PdfConfig {
69
74
  sansfont?: string;
70
75
  /** Monospace font (xelatex/lualatex only). */
71
76
  monofont?: string;
77
+ /** Extra pandoc args appended for this format (after top-level pandocArgs). */
78
+ pandocArgs?: string[];
72
79
  }
73
80
 
74
81
  export interface DocxConfig {
@@ -76,10 +83,19 @@ export interface DocxConfig {
76
83
  keepComments?: boolean;
77
84
  affiliationNewline?: boolean;
78
85
  toc?: boolean;
86
+ pandocArgs?: string[];
87
+ /**
88
+ * Auto-translate the common-shape raw `\begin{figure}...\end{figure}` block
89
+ * to portable `![caption](path){#fig:label width=N%}` markdown so figures
90
+ * survive the docx build (pandoc otherwise drops raw LaTeX silently).
91
+ * Default true. Set false to opt out — blocks then warn and are left alone.
92
+ */
93
+ translateRawFigures?: boolean;
79
94
  }
80
95
 
81
96
  export interface TexConfig {
82
97
  standalone?: boolean;
98
+ pandocArgs?: string[];
83
99
  }
84
100
 
85
101
  export interface BeamerConfig {
@@ -91,6 +107,7 @@ export interface BeamerConfig {
91
107
  section?: boolean;
92
108
  notes?: string | false;
93
109
  fit_images?: boolean;
110
+ pandocArgs?: string[];
94
111
  }
95
112
 
96
113
  export interface PptxConfig {
@@ -106,6 +123,7 @@ export interface PptxConfig {
106
123
  accent?: string;
107
124
  enabled?: boolean;
108
125
  };
126
+ pandocArgs?: string[];
109
127
  }
110
128
 
111
129
  export interface TablesConfig {
@@ -143,6 +161,19 @@ export interface BuildConfig {
143
161
  * behavior).
144
162
  */
145
163
  outputDir?: string | null;
164
+ /**
165
+ * Per-format output filenames. Keys are format names (pdf/docx/tex/beamer/
166
+ * pptx); values are paths. Relative paths resolve under outputDir; absolute
167
+ * paths are honored as-is. Extension is added if missing. CLI `-o` wins
168
+ * over this map.
169
+ */
170
+ output?: Record<string, string>;
171
+ /**
172
+ * Extra pandoc args applied to every format. Format-specific args
173
+ * (e.g. docx.pandocArgs) are appended *after* these, and CLI --pandoc-arg
174
+ * values are appended last.
175
+ */
176
+ pandocArgs?: string[];
146
177
  _configPath?: string | null;
147
178
  }
148
179
 
@@ -156,8 +187,20 @@ export interface BuildResult {
156
187
  interface BuildOptions {
157
188
  verbose?: boolean;
158
189
  config?: BuildConfig;
190
+ /**
191
+ * Internal: forces the exact output path. Used by dual-mode/temp builds that
192
+ * route to specific temp files. Bypasses the `output:` resolver.
193
+ */
159
194
  outputPath?: string;
195
+ /**
196
+ * CLI override (`-o, --output <path>`). Beats `config.output[format]` but
197
+ * loses to `options.outputPath`. Relative paths resolve under outputDir;
198
+ * absolute paths bypass outputDir.
199
+ */
200
+ output?: string;
160
201
  crossref?: boolean;
202
+ /** Extra pandoc args from CLI (--pandoc-arg). Appended after config args. */
203
+ pandocArgs?: string[];
161
204
  _refsAutoInjected?: boolean;
162
205
  _forwardRefsResolved?: number;
163
206
  }
@@ -236,6 +279,7 @@ export const DEFAULT_CONFIG: BuildConfig = {
236
279
  keepComments: false,
237
280
  affiliationNewline: true,
238
281
  toc: false,
282
+ translateRawFigures: true,
239
283
  },
240
284
  tex: {
241
285
  standalone: true,
@@ -341,6 +385,20 @@ export function mergeJournalFormatting(config: BuildConfig, formatting: JournalF
341
385
  return merged;
342
386
  }
343
387
 
388
+ /**
389
+ * In-place: copy `pandoc-args` → `pandocArgs` on an object (if not already set).
390
+ * Idempotent. Coerces a single string into a one-element array.
391
+ */
392
+ function normalizePandocArgsKey(obj: Record<string, unknown>): void {
393
+ if (!obj || typeof obj !== 'object') return;
394
+ const hy = obj['pandoc-args'];
395
+ if (hy === undefined) return;
396
+ if (obj.pandocArgs === undefined) {
397
+ obj.pandocArgs = Array.isArray(hy) ? hy : [hy];
398
+ }
399
+ delete obj['pandoc-args'];
400
+ }
401
+
344
402
  /**
345
403
  * Load rev.yaml config from directory
346
404
  * @param directory - Project directory path
@@ -363,6 +421,16 @@ export function loadConfig(directory: string): BuildConfig {
363
421
  const content = fs.readFileSync(configPath, 'utf-8');
364
422
  const userConfig = YAML.parse(content) || {};
365
423
 
424
+ // Accept hyphenated `pandoc-args` (the form pandoc itself uses) in addition
425
+ // to camelCase `pandocArgs`. Hyphenated is what we document; camelCase is
426
+ // accepted for users who already prefer that convention.
427
+ normalizePandocArgsKey(userConfig);
428
+ for (const fmt of ['pdf', 'docx', 'tex', 'beamer', 'pptx'] as const) {
429
+ if (userConfig[fmt] && typeof userConfig[fmt] === 'object') {
430
+ normalizePandocArgsKey(userConfig[fmt]);
431
+ }
432
+ }
433
+
366
434
  // Deep merge with defaults
367
435
  let config: BuildConfig = {
368
436
  ...DEFAULT_CONFIG,
@@ -830,6 +898,14 @@ export function applyFormatTransforms(
830
898
  } else if (format === 'docx') {
831
899
  content = convertDynamicRefsToDisplay(content, registry);
832
900
 
901
+ // Pandoc strips raw LaTeX in docx output. Translate the common
902
+ // `\begin{figure}...\end{figure}` shape to portable markdown so figures
903
+ // actually appear; exotic blocks are left alone (warned about in build()).
904
+ if (config.docx?.translateRawFigures !== false) {
905
+ const { translated } = translateRawLatexFigures(content);
906
+ content = translated;
907
+ }
908
+
833
909
  if (hasNumberedAffiliations(config)) {
834
910
  const mdBlock = generateMarkdownAuthorBlock(config);
835
911
  content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
@@ -897,8 +973,207 @@ function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
897
973
  return result;
898
974
  }
899
975
 
976
+ // =============================================================================
977
+ // Raw LaTeX figure detection / translation (docx)
978
+ // =============================================================================
979
+
980
+ /**
981
+ * A raw LaTeX `\begin{figure}...\end{figure}` block found in source markdown.
982
+ * `exotic` blocks contain features we don't auto-translate (multiple
983
+ * `\includegraphics`, `\subfloat`, `\rotatebox`, unrecognised width units);
984
+ * pandoc strips raw LaTeX silently in docx output, so users get warned about
985
+ * anything that won't be translated.
986
+ */
987
+ export interface RawLatexFigure {
988
+ file?: string;
989
+ line: number;
990
+ block: string;
991
+ exotic: boolean;
992
+ }
993
+
994
+ /** Match `\begin{figure}` / `\begin{figure*}` … `\end{figure}` blocks. */
995
+ function makeRawFigureRegex(): RegExp {
996
+ return /\\begin\{figure\*?\}(?:\[[^\]]*\])?[\s\S]*?\\end\{figure\*?\}/g;
997
+ }
998
+
900
999
  /**
901
- * Build pandoc arguments for format
1000
+ * Convert a LaTeX width spec to a markdown image attribute value.
1001
+ * - `0.8\textwidth` → `80%`
1002
+ * - `\linewidth` → `100%`
1003
+ * - `8cm`, `2in`, `12pt` → kept verbatim
1004
+ * Returns null for anything we don't translate (block stays "exotic").
1005
+ */
1006
+ function convertLatexWidth(raw: string): string | null {
1007
+ const trimmed = raw.trim();
1008
+ // Coefficient × relative length
1009
+ const rel = trimmed.match(/^([\d.]+)\s*\\(textwidth|linewidth|columnwidth)$/);
1010
+ if (rel) {
1011
+ const pct = Math.round(parseFloat(rel[1]!) * 100);
1012
+ if (!isFinite(pct) || pct <= 0) return null;
1013
+ return `${pct}%`;
1014
+ }
1015
+ // Bare relative length
1016
+ if (/^\\(textwidth|linewidth|columnwidth)$/.test(trimmed)) return '100%';
1017
+ // Absolute units
1018
+ if (/^[\d.]+\s*(cm|mm|in|pt|px|em|ex)$/.test(trimmed)) return trimmed.replace(/\s+/g, '');
1019
+ return null;
1020
+ }
1021
+
1022
+ /** Extract a balanced `{...}` argument that follows `command` in `text`. */
1023
+ function extractBracedArg(text: string, command: string): string | null {
1024
+ const idx = text.indexOf(command);
1025
+ if (idx === -1) return null;
1026
+ let i = idx + command.length;
1027
+ while (i < text.length && /\s/.test(text[i]!)) i++;
1028
+ if (text[i] !== '{') return null;
1029
+ i++;
1030
+ const start = i;
1031
+ let depth = 1;
1032
+ while (i < text.length) {
1033
+ const ch = text[i]!;
1034
+ if (ch === '\\' && i + 1 < text.length) { i += 2; continue; }
1035
+ if (ch === '{') depth++;
1036
+ else if (ch === '}') {
1037
+ depth--;
1038
+ if (depth === 0) return text.slice(start, i);
1039
+ }
1040
+ i++;
1041
+ }
1042
+ return null;
1043
+ }
1044
+
1045
+ /** True if a `\begin{figure}` block contains features we don't auto-translate. */
1046
+ function isExoticFigureBlock(block: string): boolean {
1047
+ if (/\\subfloat\b/.test(block)) return true;
1048
+ if (/\\rotatebox\b/.test(block)) return true;
1049
+ const includes = (block.match(/\\includegraphics\b/g) || []).length;
1050
+ if (includes !== 1) return true;
1051
+ const m = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1052
+ if (!m) return true;
1053
+ const opts = m[1] || '';
1054
+ const widthMatch = opts.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1055
+ if (widthMatch && !convertLatexWidth(widthMatch[1]!)) return true;
1056
+ return false;
1057
+ }
1058
+
1059
+ /**
1060
+ * Find raw LaTeX figure blocks containing `\includegraphics` in markdown.
1061
+ * `file`, if given, is attached to each result. `line` is 1-based within the
1062
+ * supplied content (the line where `\begin{figure}` sits).
1063
+ */
1064
+ export function detectRawLatexFigures(content: string, file?: string): RawLatexFigure[] {
1065
+ const figures: RawLatexFigure[] = [];
1066
+ const re = makeRawFigureRegex();
1067
+ let m: RegExpExecArray | null;
1068
+ while ((m = re.exec(content)) !== null) {
1069
+ const block = m[0];
1070
+ if (!block.includes('\\includegraphics')) continue;
1071
+ const line = content.slice(0, m.index).split(/\r?\n/).length;
1072
+ figures.push({ file, line, block, exotic: isExoticFigureBlock(block) });
1073
+ }
1074
+ return figures;
1075
+ }
1076
+
1077
+ /**
1078
+ * Translate the 80% case: single `\includegraphics` figure with optional
1079
+ * `\caption{...}` and `\label{...}`, wrapped in `\begin{figure}...\end{figure}`,
1080
+ * to portable `![caption](path){#fig:label width=N%}` markdown. Exotic blocks
1081
+ * (see `isExoticFigureBlock`) are left untouched.
1082
+ */
1083
+ export function translateRawLatexFigures(content: string): { translated: string; translatedCount: number } {
1084
+ let translatedCount = 0;
1085
+ const re = makeRawFigureRegex();
1086
+ const translated = content.replace(re, (block) => {
1087
+ if (!block.includes('\\includegraphics')) return block;
1088
+ if (isExoticFigureBlock(block)) return block;
1089
+
1090
+ const inc = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
1091
+ if (!inc) return block;
1092
+ const optsStr = inc[1] || '';
1093
+ const imgPath = inc[2]!.trim();
1094
+
1095
+ let width: string | undefined;
1096
+ const widthMatch = optsStr.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
1097
+ if (widthMatch) {
1098
+ const w = convertLatexWidth(widthMatch[1]!);
1099
+ if (!w) return block; // already filtered by isExoticFigureBlock, defensive
1100
+ width = w;
1101
+ }
1102
+
1103
+ const caption = (extractBracedArg(block, '\\caption') ?? '').trim();
1104
+ const labelRaw = extractBracedArg(block, '\\label');
1105
+
1106
+ const attrs: string[] = [];
1107
+ if (labelRaw) {
1108
+ const label = labelRaw.trim();
1109
+ const labelWithPrefix = /^[a-z]+:/i.test(label) ? label : `fig:${label}`;
1110
+ attrs.push(`#${labelWithPrefix}`);
1111
+ }
1112
+ if (width) attrs.push(`width=${width}`);
1113
+
1114
+ translatedCount++;
1115
+ const attrStr = attrs.length > 0 ? ` {${attrs.join(' ')}}` : '';
1116
+ return `![${caption}](${imgPath})${attrStr}`;
1117
+ });
1118
+ return { translated, translatedCount };
1119
+ }
1120
+
1121
+ /**
1122
+ * Format the warning surfaced for raw LaTeX figure blocks that won't render
1123
+ * in docx. `translateEnabled` reflects whether auto-translate ran (true = the
1124
+ * listed blocks are exotic leftovers; false = no translation was attempted).
1125
+ */
1126
+ function formatRawLatexFigureWarning(figs: RawLatexFigure[], translateEnabled: boolean): string {
1127
+ const reason = translateEnabled ? 'too complex to auto-translate' : 'translateRawFigures: false';
1128
+ const lines: string[] = [
1129
+ `${figs.length} raw LaTeX figure block(s) won't render in docx (${reason}).`,
1130
+ ];
1131
+ for (const f of figs) {
1132
+ const loc = f.file ? `${f.file}:${f.line}` : `line ${f.line}`;
1133
+ const pathMatch = f.block.match(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/);
1134
+ const pathInfo = pathMatch ? ` ${pathMatch[1]!.trim()}` : '';
1135
+ lines.push(` ${loc}${pathInfo}`);
1136
+ }
1137
+ lines.push(' Hint: use ![caption](path){#fig:label width=80%} for format-portable figures,');
1138
+ lines.push(' or pass --pandoc-arg=--lua-filter=<your.lua> to translate them yourself.');
1139
+ return lines.join('\n');
1140
+ }
1141
+
1142
+ /**
1143
+ * Walk section files and gather a warning for any raw LaTeX figure blocks that
1144
+ * won't survive the docx build. Returns null when there's nothing to warn about.
1145
+ */
1146
+ export function collectRawLatexFigureWarning(directory: string, config: BuildConfig): string | null {
1147
+ const translateEnabled = config.docx?.translateRawFigures !== false;
1148
+ const all: RawLatexFigure[] = [];
1149
+ for (const section of findSections(directory, config.sections)) {
1150
+ const sectionPath = path.join(directory, section);
1151
+ if (!fs.existsSync(sectionPath)) continue;
1152
+ try {
1153
+ const content = fs.readFileSync(sectionPath, 'utf-8');
1154
+ const figs = detectRawLatexFigures(content, section);
1155
+ for (const f of figs) {
1156
+ // When auto-translate is on, non-exotic blocks get rewritten cleanly —
1157
+ // only the exotic leftovers need warning. When opted out, everything
1158
+ // is at risk and we warn about every block.
1159
+ if (translateEnabled && !f.exotic) continue;
1160
+ all.push(f);
1161
+ }
1162
+ } catch {
1163
+ // ignore unreadable sections
1164
+ }
1165
+ }
1166
+ if (all.length === 0) return null;
1167
+ return formatRawLatexFigureWarning(all, translateEnabled);
1168
+ }
1169
+
1170
+ /**
1171
+ * Build pandoc arguments for format.
1172
+ *
1173
+ * Returns only the built-in args derived from config. Passthrough args
1174
+ * (config.pandocArgs, config[format].pandocArgs, CLI --pandoc-arg) are
1175
+ * appended later in runPandoc so they win against pptx/crossref defaults
1176
+ * added there.
902
1177
  */
903
1178
  export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
904
1179
  const args: string[] = [];
@@ -1016,6 +1291,30 @@ export function buildPandocArgs(format: string, config: BuildConfig, outputPath:
1016
1291
  return args;
1017
1292
  }
1018
1293
 
1294
+ /**
1295
+ * Collect passthrough pandoc args for a format in the canonical order:
1296
+ * top-level config → format-specific config → CLI extras. Later wins for
1297
+ * repeated flags.
1298
+ */
1299
+ export function collectPandocPassthroughArgs(
1300
+ format: string,
1301
+ config: BuildConfig,
1302
+ extraArgs: string[] = []
1303
+ ): string[] {
1304
+ const out: string[] = [];
1305
+ if (config.pandocArgs && config.pandocArgs.length > 0) {
1306
+ out.push(...config.pandocArgs);
1307
+ }
1308
+ const formatConfig = (config as unknown as Record<string, { pandocArgs?: string[] } | undefined>)[format];
1309
+ if (formatConfig?.pandocArgs && formatConfig.pandocArgs.length > 0) {
1310
+ out.push(...formatConfig.pandocArgs);
1311
+ }
1312
+ if (extraArgs.length > 0) {
1313
+ out.push(...extraArgs);
1314
+ }
1315
+ return out;
1316
+ }
1317
+
1019
1318
  /**
1020
1319
  * Write crossref.yaml if needed
1021
1320
  */
@@ -1048,6 +1347,92 @@ export function resolveOutputDir(directory: string, config: BuildConfig): string
1048
1347
  return path.isAbsolute(out) ? out : path.join(directory, out);
1049
1348
  }
1050
1349
 
1350
+ /** File extension (with leading dot) for each supported pandoc format. */
1351
+ const FORMAT_EXTENSIONS: Record<string, string> = {
1352
+ tex: '.tex',
1353
+ pdf: '.pdf',
1354
+ docx: '.docx',
1355
+ beamer: '.pdf',
1356
+ pptx: '.pptx',
1357
+ };
1358
+
1359
+ /** Get file extension for a format, defaulting to `.pdf`. */
1360
+ export function getFormatExtension(format: string): string {
1361
+ return FORMAT_EXTENSIONS[format] ?? '.pdf';
1362
+ }
1363
+
1364
+ /**
1365
+ * Slugify a title for use as a default output filename. Lowercases, replaces
1366
+ * non-alphanumeric runs with `-`, and truncates at the last `-` boundary
1367
+ * at-or-before MAX_TITLE_FILENAME_LENGTH so words stay whole (the old blind
1368
+ * `.slice` cut mid-word).
1369
+ */
1370
+ export function slugifyTitle(title: string): string {
1371
+ if (!title) return 'paper';
1372
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1373
+ if (!slug) return 'paper';
1374
+ if (slug.length <= MAX_TITLE_FILENAME_LENGTH) return slug;
1375
+ const cut = slug.slice(0, MAX_TITLE_FILENAME_LENGTH);
1376
+ const lastDash = cut.lastIndexOf('-');
1377
+ // Only truncate at a hyphen if it leaves a reasonable amount of content.
1378
+ // Otherwise hard-cut (handles degenerate titles with no spaces at all).
1379
+ if (lastDash >= MAX_TITLE_FILENAME_LENGTH / 2) {
1380
+ return slug.slice(0, lastDash);
1381
+ }
1382
+ return cut;
1383
+ }
1384
+
1385
+ /**
1386
+ * Ensure `name` ends with `ext` (case-insensitive). If the user already supplied
1387
+ * the correct extension, return unchanged; if they supplied none or a different
1388
+ * one, append the format's canonical extension.
1389
+ *
1390
+ * Different-extension case (e.g. `output.docx` when building tex): we append
1391
+ * rather than replace, since stripping looks like an unsafe guess. The result
1392
+ * `output.docx.tex` is loud enough to flag the misconfiguration.
1393
+ */
1394
+ function ensureExtension(name: string, ext: string): string {
1395
+ if (name.toLowerCase().endsWith(ext.toLowerCase())) return name;
1396
+ return name + ext;
1397
+ }
1398
+
1399
+ /**
1400
+ * Resolve the final output path for a build.
1401
+ *
1402
+ * Priority: `options.outputPath` (internal force) > `cliOverride` (-o flag) >
1403
+ * `config.output[format]` > slugified title fallback.
1404
+ *
1405
+ * Relative paths from `cliOverride`/`config.output` resolve under outputDir;
1406
+ * absolute paths bypass outputDir. The fallback path always lives under
1407
+ * outputDir.
1408
+ *
1409
+ * @param suffix - Appended before the extension (e.g. "-changes", "-slides").
1410
+ * Suppressed when user supplied an explicit name via CLI or
1411
+ * config — they pick their own suffix.
1412
+ */
1413
+ export function resolveOutputPath(
1414
+ directory: string,
1415
+ config: BuildConfig,
1416
+ format: string,
1417
+ options: { cliOverride?: string; suffix?: string } = {}
1418
+ ): string {
1419
+ const { cliOverride, suffix = '' } = options;
1420
+ const ext = getFormatExtension(format);
1421
+
1422
+ const explicit = cliOverride ?? config.output?.[format];
1423
+ if (explicit) {
1424
+ const baseDir = path.isAbsolute(explicit)
1425
+ ? path.dirname(explicit)
1426
+ : resolveOutputDir(directory, config);
1427
+ const baseName = path.basename(explicit);
1428
+ const stem = baseName.replace(/\.[^./\\]+$/, '');
1429
+ return path.join(baseDir, ensureExtension(`${stem}${suffix}`, ext));
1430
+ }
1431
+
1432
+ const slug = slugifyTitle(config.title);
1433
+ return path.join(resolveOutputDir(directory, config), `${slug}${suffix}${ext}`);
1434
+ }
1435
+
1051
1436
  /**
1052
1437
  * Run pandoc build
1053
1438
  */
@@ -1058,28 +1443,16 @@ export async function runPandoc(
1058
1443
  options: BuildOptions = {}
1059
1444
  ): Promise<PandocResult> {
1060
1445
  const directory = path.dirname(inputPath);
1061
- const baseName = config.title
1062
- ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
1063
- : 'paper';
1064
-
1065
- // Map format to file extension
1066
- const extMap: Record<string, string> = {
1067
- tex: '.tex',
1068
- pdf: '.pdf',
1069
- docx: '.docx',
1070
- beamer: '.pdf', // beamer outputs PDF
1071
- pptx: '.pptx',
1072
- };
1073
- const ext = extMap[format] || '.pdf';
1074
1446
 
1075
- // For beamer, use -slides suffix to distinguish from regular PDF
1447
+ // outputPath (internal force) wins over the resolver. For beamer, we keep
1448
+ // the `-slides` suffix on the slug fallback to distinguish from a regular
1449
+ // PDF build; when the user supplies an explicit name, they pick their own.
1076
1450
  const suffix = format === 'beamer' ? '-slides' : '';
1077
- // Allow custom output path via options. Auto-named outputs go through the
1078
- // configured outputDir (default 'output/'); explicit paths are honored as-is
1079
- // so callers can route temp/intermediate artefacts where they want.
1080
1451
  const outputPath = options.outputPath
1081
- ? options.outputPath
1082
- : path.join(resolveOutputDir(directory, config), `${baseName}${suffix}${ext}`);
1452
+ ?? resolveOutputPath(directory, config, format, {
1453
+ cliOverride: options.output,
1454
+ suffix,
1455
+ });
1083
1456
 
1084
1457
  if (!options.outputPath) {
1085
1458
  const outDir = path.dirname(outputPath);
@@ -1146,9 +1519,18 @@ export async function runPandoc(
1146
1519
  }
1147
1520
  }
1148
1521
 
1522
+ // Passthrough args go last so they win against built-in defaults.
1523
+ args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
1524
+
1149
1525
  // Input file (use basename since we set cwd to directory)
1150
1526
  args.push(path.basename(inputPath));
1151
1527
 
1528
+ if (options.verbose) {
1529
+ const quoted = args.map(a => /[\s"'$`]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ');
1530
+ console.error(`[pandoc ${format}] (cwd: ${directory})`);
1531
+ console.error(` pandoc ${quoted}`);
1532
+ }
1533
+
1152
1534
  return new Promise((resolve) => {
1153
1535
  const pandoc: ChildProcess = spawn('pandoc', args, {
1154
1536
  cwd: directory,
@@ -1265,6 +1647,12 @@ export async function build(
1265
1647
  if ((imageReg as any).figures?.length > 0) {
1266
1648
  writeImageRegistry(directory, imageReg);
1267
1649
  }
1650
+
1651
+ // Warn about raw LaTeX figure blocks that won't render in docx (pandoc
1652
+ // drops them silently). With auto-translate on (default), this surfaces
1653
+ // only the exotic leftovers; with it off, every block.
1654
+ const rawFigWarning = collectRawLatexFigureWarning(directory, config);
1655
+ if (rawFigWarning) warnings.push(rawFigWarning);
1268
1656
  }
1269
1657
 
1270
1658
  const results: BuildResult[] = [];
@@ -52,6 +52,8 @@ interface BuildOptions {
52
52
  colortheme?: string;
53
53
  aspectratio?: string;
54
54
  verbose?: boolean;
55
+ pandocArg?: string[];
56
+ output?: string;
55
57
  }
56
58
 
57
59
  /**
@@ -487,7 +489,14 @@ export function register(program: Command, pkg?: { version?: string }): void {
487
489
  .option('--theme <name>', 'Beamer theme (default, metropolis, etc.)')
488
490
  .option('--colortheme <name>', 'Beamer color theme')
489
491
  .option('--aspectratio <ratio>', 'Beamer aspect ratio (169, 43)')
490
- .option('--verbose', 'Show detailed output including postprocess scripts')
492
+ .option(
493
+ '--pandoc-arg <arg>',
494
+ 'Extra arg to pass to pandoc (repeatable). Applied to every format being built; appended after rev.yaml pandoc-args so CLI wins.',
495
+ (val: string, prev: string[] = []) => [...prev, val],
496
+ []
497
+ )
498
+ .option('-o, --output <path>', 'Output filename or path. Relative paths resolve under outputDir; absolute paths bypass it. Extension auto-added if missing. Applied to every format being built; overrides rev.yaml output.<format>.')
499
+ .option('--verbose', 'Show detailed output including postprocess scripts and the pandoc invocation')
491
500
  .action(async (formats: string[], options: BuildOptions) => {
492
501
  const dir = path.resolve(options.dir);
493
502
 
@@ -577,7 +586,7 @@ export function register(program: Command, pkg?: { version?: string }): void {
577
586
  process.exit(1);
578
587
  }
579
588
 
580
- const { combineSections, resolveOutputDir } = await import('../build.js');
589
+ const { combineSections, resolveOutputPath } = await import('../build.js');
581
590
  const { buildWithTrackChanges } = await import('../trackchanges.js');
582
591
 
583
592
  const spin = fmt.spinner('Building with track changes...').start();
@@ -588,12 +597,12 @@ export function register(program: Command, pkg?: { version?: string }): void {
588
597
  console.log(chalk.cyan('Combined sections → paper.md'));
589
598
  console.log(chalk.dim(` ${paperPath}\n`));
590
599
 
591
- const baseName = config.title
592
- ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
593
- : 'paper';
594
- const outDir = resolveOutputDir(dir, config);
600
+ const outputPath = resolveOutputPath(dir, config, 'docx', {
601
+ cliOverride: options.output,
602
+ suffix: '-changes',
603
+ });
604
+ const outDir = path.dirname(outputPath);
595
605
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
596
- const outputPath = path.join(outDir, `${baseName}-changes.docx`);
597
606
 
598
607
  const spinTc = fmt.spinner('Applying track changes...').start();
599
608
  const result = await buildWithTrackChanges(paperPath, outputPath, {
@@ -625,10 +634,12 @@ export function register(program: Command, pkg?: { version?: string }): void {
625
634
  const spin = fmt.spinner('Building...').start();
626
635
 
627
636
  try {
628
- const { results, paperPath, forwardRefsResolved, refsAutoInjected } = await build(dir, targetFormats, {
637
+ const { results, paperPath, forwardRefsResolved, refsAutoInjected, warnings } = await build(dir, targetFormats, {
629
638
  crossref: options.crossref,
630
639
  config,
631
640
  verbose: options.verbose,
641
+ pandocArgs: options.pandocArg,
642
+ output: options.output,
632
643
  });
633
644
 
634
645
  spin.stop();
@@ -643,6 +654,17 @@ export function register(program: Command, pkg?: { version?: string }): void {
643
654
  }
644
655
  console.log('');
645
656
 
657
+ if (warnings && warnings.length > 0) {
658
+ for (const w of warnings) {
659
+ // Each warning may span multiple lines — colour the first line as
660
+ // a warning header and pass through the rest unchanged.
661
+ const [head, ...rest] = w.split('\n');
662
+ console.log(chalk.yellow(`Warning: ${head}`));
663
+ for (const line of rest) console.log(chalk.yellow(line));
664
+ }
665
+ console.log('');
666
+ }
667
+
646
668
  console.log(chalk.cyan('Output:'));
647
669
  console.log(formatBuildResults(results));
648
670
 
@@ -703,7 +725,7 @@ export function register(program: Command, pkg?: { version?: string }): void {
703
725
 
704
726
  const spinBuild = fmt.spinner('Building marked DOCX...').start();
705
727
  const markedDocxPath = path.join(dir, '.paper-marked.docx');
706
- const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath });
728
+ const pandocResult = await runPandoc(markedPath, 'docx', config, { ...options, outputPath: markedDocxPath, pandocArgs: options.pandocArg });
707
729
  spinBuild.stop();
708
730
 
709
731
  if (!pandocResult.success) {
@@ -786,7 +808,7 @@ export function register(program: Command, pkg?: { version?: string }): void {
786
808
 
787
809
  const annotatedPdfPath = pdfResult.outputPath!.replace(/\.pdf$/, '_comments.pdf');
788
810
  spinPdf.text = 'Building annotated PDF...';
789
- const pandocResult = await runPandoc(annotatedPath, 'pdf', annotatedConfig, { ...options, outputPath: annotatedPdfPath });
811
+ const pandocResult = await runPandoc(annotatedPath, 'pdf', annotatedConfig, { ...options, outputPath: annotatedPdfPath, pandocArgs: options.pandocArg });
790
812
  spinPdf.stop();
791
813
 
792
814
  if (!process.env.DEBUG) {