docrev 0.9.16 → 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/dist/lib/build.d.ts +112 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +360 -25
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +25 -10
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/schema.d.ts.map +1 -1
- package/dist/lib/schema.js +37 -0
- package/dist/lib/schema.js.map +1 -1
- package/issues.md +180 -0
- package/lib/build.ts +420 -26
- package/lib/commands/build.ts +32 -10
- package/lib/schema.ts +37 -0
- package/package.json +1 -1
- package/skill/REFERENCE.md +66 -0
- package/skill/SKILL.md +25 -4
- package/.claude/settings.local.json +0 -9
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
|
-
/**
|
|
36
|
-
|
|
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,16 +74,28 @@ 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 {
|
|
75
82
|
reference?: string | null;
|
|
76
83
|
keepComments?: boolean;
|
|
84
|
+
affiliationNewline?: boolean;
|
|
77
85
|
toc?: boolean;
|
|
86
|
+
pandocArgs?: string[];
|
|
87
|
+
/**
|
|
88
|
+
* Auto-translate the common-shape raw `\begin{figure}...\end{figure}` block
|
|
89
|
+
* to portable `{#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;
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
export interface TexConfig {
|
|
81
97
|
standalone?: boolean;
|
|
98
|
+
pandocArgs?: string[];
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
export interface BeamerConfig {
|
|
@@ -90,6 +107,7 @@ export interface BeamerConfig {
|
|
|
90
107
|
section?: boolean;
|
|
91
108
|
notes?: string | false;
|
|
92
109
|
fit_images?: boolean;
|
|
110
|
+
pandocArgs?: string[];
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
export interface PptxConfig {
|
|
@@ -105,6 +123,7 @@ export interface PptxConfig {
|
|
|
105
123
|
accent?: string;
|
|
106
124
|
enabled?: boolean;
|
|
107
125
|
};
|
|
126
|
+
pandocArgs?: string[];
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export interface TablesConfig {
|
|
@@ -142,6 +161,19 @@ export interface BuildConfig {
|
|
|
142
161
|
* behavior).
|
|
143
162
|
*/
|
|
144
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[];
|
|
145
177
|
_configPath?: string | null;
|
|
146
178
|
}
|
|
147
179
|
|
|
@@ -155,8 +187,20 @@ export interface BuildResult {
|
|
|
155
187
|
interface BuildOptions {
|
|
156
188
|
verbose?: boolean;
|
|
157
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
|
+
*/
|
|
158
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;
|
|
159
201
|
crossref?: boolean;
|
|
202
|
+
/** Extra pandoc args from CLI (--pandoc-arg). Appended after config args. */
|
|
203
|
+
pandocArgs?: string[];
|
|
160
204
|
_refsAutoInjected?: boolean;
|
|
161
205
|
_forwardRefsResolved?: number;
|
|
162
206
|
}
|
|
@@ -232,8 +276,10 @@ export const DEFAULT_CONFIG: BuildConfig = {
|
|
|
232
276
|
},
|
|
233
277
|
docx: {
|
|
234
278
|
reference: null,
|
|
235
|
-
keepComments:
|
|
279
|
+
keepComments: false,
|
|
280
|
+
affiliationNewline: true,
|
|
236
281
|
toc: false,
|
|
282
|
+
translateRawFigures: true,
|
|
237
283
|
},
|
|
238
284
|
tex: {
|
|
239
285
|
standalone: true,
|
|
@@ -339,6 +385,20 @@ export function mergeJournalFormatting(config: BuildConfig, formatting: JournalF
|
|
|
339
385
|
return merged;
|
|
340
386
|
}
|
|
341
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
|
+
|
|
342
402
|
/**
|
|
343
403
|
* Load rev.yaml config from directory
|
|
344
404
|
* @param directory - Project directory path
|
|
@@ -361,6 +421,16 @@ export function loadConfig(directory: string): BuildConfig {
|
|
|
361
421
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
362
422
|
const userConfig = YAML.parse(content) || {};
|
|
363
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
|
+
|
|
364
434
|
// Deep merge with defaults
|
|
365
435
|
let config: BuildConfig = {
|
|
366
436
|
...DEFAULT_CONFIG,
|
|
@@ -675,12 +745,16 @@ function generateMarkdownAuthorBlock(config: BuildConfig): string {
|
|
|
675
745
|
lines.push('');
|
|
676
746
|
|
|
677
747
|
// Affiliation lines: ^1^ Department of ...
|
|
678
|
-
|
|
748
|
+
const affiliationEntries = Object.entries(config.affiliations);
|
|
749
|
+
const useLineBreaks = config.docx.affiliationNewline !== false;
|
|
750
|
+
affiliationEntries.forEach(([key, text], idx) => {
|
|
679
751
|
const num = keyToNum.get(key);
|
|
680
752
|
if (num !== undefined) {
|
|
681
|
-
|
|
753
|
+
const isLast = idx === affiliationEntries.length - 1;
|
|
754
|
+
const suffix = useLineBreaks && !isLast ? '\\' : '';
|
|
755
|
+
lines.push(`^${num}^ ${text}${suffix}`);
|
|
682
756
|
}
|
|
683
|
-
}
|
|
757
|
+
});
|
|
684
758
|
|
|
685
759
|
// Corresponding author footnote
|
|
686
760
|
const corresponding = config.authors.find(a => typeof a !== 'string' && a.corresponding) as Author | undefined;
|
|
@@ -824,6 +898,14 @@ export function applyFormatTransforms(
|
|
|
824
898
|
} else if (format === 'docx') {
|
|
825
899
|
content = convertDynamicRefsToDisplay(content, registry);
|
|
826
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
|
+
|
|
827
909
|
if (hasNumberedAffiliations(config)) {
|
|
828
910
|
const mdBlock = generateMarkdownAuthorBlock(config);
|
|
829
911
|
content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
|
|
@@ -891,8 +973,207 @@ function convertDynamicRefsToDisplay(text: string, registry: Registry): string {
|
|
|
891
973
|
return result;
|
|
892
974
|
}
|
|
893
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
|
+
|
|
999
|
+
/**
|
|
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 `{#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 `${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 {#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
|
+
|
|
894
1170
|
/**
|
|
895
|
-
* Build pandoc arguments for format
|
|
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.
|
|
896
1177
|
*/
|
|
897
1178
|
export function buildPandocArgs(format: string, config: BuildConfig, outputPath: string): string[] {
|
|
898
1179
|
const args: string[] = [];
|
|
@@ -1010,6 +1291,30 @@ export function buildPandocArgs(format: string, config: BuildConfig, outputPath:
|
|
|
1010
1291
|
return args;
|
|
1011
1292
|
}
|
|
1012
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
|
+
|
|
1013
1318
|
/**
|
|
1014
1319
|
* Write crossref.yaml if needed
|
|
1015
1320
|
*/
|
|
@@ -1042,6 +1347,92 @@ export function resolveOutputDir(directory: string, config: BuildConfig): string
|
|
|
1042
1347
|
return path.isAbsolute(out) ? out : path.join(directory, out);
|
|
1043
1348
|
}
|
|
1044
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
|
+
|
|
1045
1436
|
/**
|
|
1046
1437
|
* Run pandoc build
|
|
1047
1438
|
*/
|
|
@@ -1052,28 +1443,16 @@ export async function runPandoc(
|
|
|
1052
1443
|
options: BuildOptions = {}
|
|
1053
1444
|
): Promise<PandocResult> {
|
|
1054
1445
|
const directory = path.dirname(inputPath);
|
|
1055
|
-
const baseName = config.title
|
|
1056
|
-
? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
|
|
1057
|
-
: 'paper';
|
|
1058
|
-
|
|
1059
|
-
// Map format to file extension
|
|
1060
|
-
const extMap: Record<string, string> = {
|
|
1061
|
-
tex: '.tex',
|
|
1062
|
-
pdf: '.pdf',
|
|
1063
|
-
docx: '.docx',
|
|
1064
|
-
beamer: '.pdf', // beamer outputs PDF
|
|
1065
|
-
pptx: '.pptx',
|
|
1066
|
-
};
|
|
1067
|
-
const ext = extMap[format] || '.pdf';
|
|
1068
1446
|
|
|
1069
|
-
//
|
|
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.
|
|
1070
1450
|
const suffix = format === 'beamer' ? '-slides' : '';
|
|
1071
|
-
// Allow custom output path via options. Auto-named outputs go through the
|
|
1072
|
-
// configured outputDir (default 'output/'); explicit paths are honored as-is
|
|
1073
|
-
// so callers can route temp/intermediate artefacts where they want.
|
|
1074
1451
|
const outputPath = options.outputPath
|
|
1075
|
-
|
|
1076
|
-
|
|
1452
|
+
?? resolveOutputPath(directory, config, format, {
|
|
1453
|
+
cliOverride: options.output,
|
|
1454
|
+
suffix,
|
|
1455
|
+
});
|
|
1077
1456
|
|
|
1078
1457
|
if (!options.outputPath) {
|
|
1079
1458
|
const outDir = path.dirname(outputPath);
|
|
@@ -1140,9 +1519,18 @@ export async function runPandoc(
|
|
|
1140
1519
|
}
|
|
1141
1520
|
}
|
|
1142
1521
|
|
|
1522
|
+
// Passthrough args go last so they win against built-in defaults.
|
|
1523
|
+
args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
|
|
1524
|
+
|
|
1143
1525
|
// Input file (use basename since we set cwd to directory)
|
|
1144
1526
|
args.push(path.basename(inputPath));
|
|
1145
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
|
+
|
|
1146
1534
|
return new Promise((resolve) => {
|
|
1147
1535
|
const pandoc: ChildProcess = spawn('pandoc', args, {
|
|
1148
1536
|
cwd: directory,
|
|
@@ -1259,6 +1647,12 @@ export async function build(
|
|
|
1259
1647
|
if ((imageReg as any).figures?.length > 0) {
|
|
1260
1648
|
writeImageRegistry(directory, imageReg);
|
|
1261
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);
|
|
1262
1656
|
}
|
|
1263
1657
|
|
|
1264
1658
|
const results: BuildResult[] = [];
|
package/lib/commands/build.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
|
592
|
-
|
|
593
|
-
: '
|
|
594
|
-
|
|
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) {
|