docrev 0.9.17 → 0.10.0

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.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
+ import { fileURLToPath } from 'url';
12
13
  import { spawn } from 'child_process';
13
14
  import YAML from 'yaml';
14
15
  import { stripAnnotations } from './annotations.js';
@@ -22,13 +23,19 @@ import { hasPandoc, hasPandocCrossref, hasLatex } from './dependencies.js';
22
23
  import { buildImageRegistry, writeImageRegistry } from './image-registry.js';
23
24
  import { getJournalProfile } from './journals.js';
24
25
  import { resolveCSL } from './csl.js';
26
+ import { mergeMacros, generateLatexPreamble, writeMacrosSidecar, getMacroFilterPath, } from './macros.js';
25
27
  // =============================================================================
26
28
  // Constants
27
29
  // =============================================================================
28
30
  /** Supported output formats */
29
31
  const SUPPORTED_FORMATS = ['pdf', 'docx', 'tex', 'beamer', 'pptx'];
30
- /** Maximum title length for output filename */
31
- const MAX_TITLE_FILENAME_LENGTH = 50;
32
+ /**
33
+ * Maximum length for slugified-title output filenames. Only used when no
34
+ * explicit `output:` filename is configured. Long titles are truncated at the
35
+ * last `-` boundary at-or-before this length so words stay intact (the old
36
+ * blind `.slice(0, 50)` cut mid-word).
37
+ */
38
+ const MAX_TITLE_FILENAME_LENGTH = 80;
32
39
  /**
33
40
  * Default rev.yaml configuration
34
41
  */
@@ -61,6 +68,7 @@ export const DEFAULT_CONFIG = {
61
68
  keepComments: false,
62
69
  affiliationNewline: true,
63
70
  toc: false,
71
+ translateRawFigures: true,
64
72
  },
65
73
  tex: {
66
74
  standalone: true,
@@ -94,6 +102,9 @@ export const DEFAULT_CONFIG = {
94
102
  beamer: null,
95
103
  all: null, // Runs after any format
96
104
  },
105
+ // Placeholder/highlight macros. Defaults are the built-ins from
106
+ // lib/macros.ts; users append their own here.
107
+ macros: [],
97
108
  // Final outputs land here (created on demand). Set to null or '' to keep
98
109
  // outputs in the project root.
99
110
  outputDir: 'output',
@@ -158,6 +169,21 @@ export function mergeJournalFormatting(config, formatting, directory) {
158
169
  }
159
170
  return merged;
160
171
  }
172
+ /**
173
+ * In-place: copy `pandoc-args` → `pandocArgs` on an object (if not already set).
174
+ * Idempotent. Coerces a single string into a one-element array.
175
+ */
176
+ function normalizePandocArgsKey(obj) {
177
+ if (!obj || typeof obj !== 'object')
178
+ return;
179
+ const hy = obj['pandoc-args'];
180
+ if (hy === undefined)
181
+ return;
182
+ if (obj.pandocArgs === undefined) {
183
+ obj.pandocArgs = Array.isArray(hy) ? hy : [hy];
184
+ }
185
+ delete obj['pandoc-args'];
186
+ }
161
187
  /**
162
188
  * Load rev.yaml config from directory
163
189
  * @param directory - Project directory path
@@ -176,6 +202,15 @@ export function loadConfig(directory) {
176
202
  try {
177
203
  const content = fs.readFileSync(configPath, 'utf-8');
178
204
  const userConfig = YAML.parse(content) || {};
205
+ // Accept hyphenated `pandoc-args` (the form pandoc itself uses) in addition
206
+ // to camelCase `pandocArgs`. Hyphenated is what we document; camelCase is
207
+ // accepted for users who already prefer that convention.
208
+ normalizePandocArgsKey(userConfig);
209
+ for (const fmt of ['pdf', 'docx', 'tex', 'beamer', 'pptx']) {
210
+ if (userConfig[fmt] && typeof userConfig[fmt] === 'object') {
211
+ normalizePandocArgsKey(userConfig[fmt]);
212
+ }
213
+ }
179
214
  // Deep merge with defaults
180
215
  let config = {
181
216
  ...DEFAULT_CONFIG,
@@ -576,6 +611,13 @@ export function applyFormatTransforms(content, format, config, registry) {
576
611
  }
577
612
  else if (format === 'docx') {
578
613
  content = convertDynamicRefsToDisplay(content, registry);
614
+ // Pandoc strips raw LaTeX in docx output. Translate the common
615
+ // `\begin{figure}...\end{figure}` shape to portable markdown so figures
616
+ // actually appear; exotic blocks are left alone (warned about in build()).
617
+ if (config.docx?.translateRawFigures !== false) {
618
+ const { translated } = translateRawLatexFigures(content);
619
+ content = translated;
620
+ }
579
621
  if (hasNumberedAffiliations(config)) {
580
622
  const mdBlock = generateMarkdownAuthorBlock(config);
581
623
  content = content.replace(/^(---\r?\n[\s\S]*?---\r?\n)/, `$1\n${mdBlock}\n`);
@@ -629,8 +671,202 @@ function convertDynamicRefsToDisplay(text, registry) {
629
671
  }
630
672
  return result;
631
673
  }
674
+ /** Match `\begin{figure}` / `\begin{figure*}` … `\end{figure}` blocks. */
675
+ function makeRawFigureRegex() {
676
+ return /\\begin\{figure\*?\}(?:\[[^\]]*\])?[\s\S]*?\\end\{figure\*?\}/g;
677
+ }
678
+ /**
679
+ * Convert a LaTeX width spec to a markdown image attribute value.
680
+ * - `0.8\textwidth` → `80%`
681
+ * - `\linewidth` → `100%`
682
+ * - `8cm`, `2in`, `12pt` → kept verbatim
683
+ * Returns null for anything we don't translate (block stays "exotic").
684
+ */
685
+ function convertLatexWidth(raw) {
686
+ const trimmed = raw.trim();
687
+ // Coefficient × relative length
688
+ const rel = trimmed.match(/^([\d.]+)\s*\\(textwidth|linewidth|columnwidth)$/);
689
+ if (rel) {
690
+ const pct = Math.round(parseFloat(rel[1]) * 100);
691
+ if (!isFinite(pct) || pct <= 0)
692
+ return null;
693
+ return `${pct}%`;
694
+ }
695
+ // Bare relative length
696
+ if (/^\\(textwidth|linewidth|columnwidth)$/.test(trimmed))
697
+ return '100%';
698
+ // Absolute units
699
+ if (/^[\d.]+\s*(cm|mm|in|pt|px|em|ex)$/.test(trimmed))
700
+ return trimmed.replace(/\s+/g, '');
701
+ return null;
702
+ }
703
+ /** Extract a balanced `{...}` argument that follows `command` in `text`. */
704
+ function extractBracedArg(text, command) {
705
+ const idx = text.indexOf(command);
706
+ if (idx === -1)
707
+ return null;
708
+ let i = idx + command.length;
709
+ while (i < text.length && /\s/.test(text[i]))
710
+ i++;
711
+ if (text[i] !== '{')
712
+ return null;
713
+ i++;
714
+ const start = i;
715
+ let depth = 1;
716
+ while (i < text.length) {
717
+ const ch = text[i];
718
+ if (ch === '\\' && i + 1 < text.length) {
719
+ i += 2;
720
+ continue;
721
+ }
722
+ if (ch === '{')
723
+ depth++;
724
+ else if (ch === '}') {
725
+ depth--;
726
+ if (depth === 0)
727
+ return text.slice(start, i);
728
+ }
729
+ i++;
730
+ }
731
+ return null;
732
+ }
733
+ /** True if a `\begin{figure}` block contains features we don't auto-translate. */
734
+ function isExoticFigureBlock(block) {
735
+ if (/\\subfloat\b/.test(block))
736
+ return true;
737
+ if (/\\rotatebox\b/.test(block))
738
+ return true;
739
+ const includes = (block.match(/\\includegraphics\b/g) || []).length;
740
+ if (includes !== 1)
741
+ return true;
742
+ const m = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
743
+ if (!m)
744
+ return true;
745
+ const opts = m[1] || '';
746
+ const widthMatch = opts.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
747
+ if (widthMatch && !convertLatexWidth(widthMatch[1]))
748
+ return true;
749
+ return false;
750
+ }
751
+ /**
752
+ * Find raw LaTeX figure blocks containing `\includegraphics` in markdown.
753
+ * `file`, if given, is attached to each result. `line` is 1-based within the
754
+ * supplied content (the line where `\begin{figure}` sits).
755
+ */
756
+ export function detectRawLatexFigures(content, file) {
757
+ const figures = [];
758
+ const re = makeRawFigureRegex();
759
+ let m;
760
+ while ((m = re.exec(content)) !== null) {
761
+ const block = m[0];
762
+ if (!block.includes('\\includegraphics'))
763
+ continue;
764
+ const line = content.slice(0, m.index).split(/\r?\n/).length;
765
+ figures.push({ file, line, block, exotic: isExoticFigureBlock(block) });
766
+ }
767
+ return figures;
768
+ }
769
+ /**
770
+ * Translate the 80% case: single `\includegraphics` figure with optional
771
+ * `\caption{...}` and `\label{...}`, wrapped in `\begin{figure}...\end{figure}`,
772
+ * to portable `![caption](path){#fig:label width=N%}` markdown. Exotic blocks
773
+ * (see `isExoticFigureBlock`) are left untouched.
774
+ */
775
+ export function translateRawLatexFigures(content) {
776
+ let translatedCount = 0;
777
+ const re = makeRawFigureRegex();
778
+ const translated = content.replace(re, (block) => {
779
+ if (!block.includes('\\includegraphics'))
780
+ return block;
781
+ if (isExoticFigureBlock(block))
782
+ return block;
783
+ const inc = block.match(/\\includegraphics\s*(?:\[([^\]]*)\])?\s*\{([^}]+)\}/);
784
+ if (!inc)
785
+ return block;
786
+ const optsStr = inc[1] || '';
787
+ const imgPath = inc[2].trim();
788
+ let width;
789
+ const widthMatch = optsStr.match(/(?:^|,)\s*width\s*=\s*([^,]+)/);
790
+ if (widthMatch) {
791
+ const w = convertLatexWidth(widthMatch[1]);
792
+ if (!w)
793
+ return block; // already filtered by isExoticFigureBlock, defensive
794
+ width = w;
795
+ }
796
+ const caption = (extractBracedArg(block, '\\caption') ?? '').trim();
797
+ const labelRaw = extractBracedArg(block, '\\label');
798
+ const attrs = [];
799
+ if (labelRaw) {
800
+ const label = labelRaw.trim();
801
+ const labelWithPrefix = /^[a-z]+:/i.test(label) ? label : `fig:${label}`;
802
+ attrs.push(`#${labelWithPrefix}`);
803
+ }
804
+ if (width)
805
+ attrs.push(`width=${width}`);
806
+ translatedCount++;
807
+ const attrStr = attrs.length > 0 ? ` {${attrs.join(' ')}}` : '';
808
+ return `![${caption}](${imgPath})${attrStr}`;
809
+ });
810
+ return { translated, translatedCount };
811
+ }
632
812
  /**
633
- * Build pandoc arguments for format
813
+ * Format the warning surfaced for raw LaTeX figure blocks that won't render
814
+ * in docx. `translateEnabled` reflects whether auto-translate ran (true = the
815
+ * listed blocks are exotic leftovers; false = no translation was attempted).
816
+ */
817
+ function formatRawLatexFigureWarning(figs, translateEnabled) {
818
+ const reason = translateEnabled ? 'too complex to auto-translate' : 'translateRawFigures: false';
819
+ const lines = [
820
+ `${figs.length} raw LaTeX figure block(s) won't render in docx (${reason}).`,
821
+ ];
822
+ for (const f of figs) {
823
+ const loc = f.file ? `${f.file}:${f.line}` : `line ${f.line}`;
824
+ const pathMatch = f.block.match(/\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/);
825
+ const pathInfo = pathMatch ? ` ${pathMatch[1].trim()}` : '';
826
+ lines.push(` ${loc}${pathInfo}`);
827
+ }
828
+ lines.push(' Hint: use ![caption](path){#fig:label width=80%} for format-portable figures,');
829
+ lines.push(' or pass --pandoc-arg=--lua-filter=<your.lua> to translate them yourself.');
830
+ return lines.join('\n');
831
+ }
832
+ /**
833
+ * Walk section files and gather a warning for any raw LaTeX figure blocks that
834
+ * won't survive the docx build. Returns null when there's nothing to warn about.
835
+ */
836
+ export function collectRawLatexFigureWarning(directory, config) {
837
+ const translateEnabled = config.docx?.translateRawFigures !== false;
838
+ const all = [];
839
+ for (const section of findSections(directory, config.sections)) {
840
+ const sectionPath = path.join(directory, section);
841
+ if (!fs.existsSync(sectionPath))
842
+ continue;
843
+ try {
844
+ const content = fs.readFileSync(sectionPath, 'utf-8');
845
+ const figs = detectRawLatexFigures(content, section);
846
+ for (const f of figs) {
847
+ // When auto-translate is on, non-exotic blocks get rewritten cleanly —
848
+ // only the exotic leftovers need warning. When opted out, everything
849
+ // is at risk and we warn about every block.
850
+ if (translateEnabled && !f.exotic)
851
+ continue;
852
+ all.push(f);
853
+ }
854
+ }
855
+ catch {
856
+ // ignore unreadable sections
857
+ }
858
+ }
859
+ if (all.length === 0)
860
+ return null;
861
+ return formatRawLatexFigureWarning(all, translateEnabled);
862
+ }
863
+ /**
864
+ * Build pandoc arguments for format.
865
+ *
866
+ * Returns only the built-in args derived from config. Passthrough args
867
+ * (config.pandocArgs, config[format].pandocArgs, CLI --pandoc-arg) are
868
+ * appended later in runPandoc so they win against pptx/crossref defaults
869
+ * added there.
634
870
  */
635
871
  export function buildPandocArgs(format, config, outputPath) {
636
872
  const args = [];
@@ -748,6 +984,25 @@ export function buildPandocArgs(format, config, outputPath) {
748
984
  }
749
985
  return args;
750
986
  }
987
+ /**
988
+ * Collect passthrough pandoc args for a format in the canonical order:
989
+ * top-level config → format-specific config → CLI extras. Later wins for
990
+ * repeated flags.
991
+ */
992
+ export function collectPandocPassthroughArgs(format, config, extraArgs = []) {
993
+ const out = [];
994
+ if (config.pandocArgs && config.pandocArgs.length > 0) {
995
+ out.push(...config.pandocArgs);
996
+ }
997
+ const formatConfig = config[format];
998
+ if (formatConfig?.pandocArgs && formatConfig.pandocArgs.length > 0) {
999
+ out.push(...formatConfig.pandocArgs);
1000
+ }
1001
+ if (extraArgs.length > 0) {
1002
+ out.push(...extraArgs);
1003
+ }
1004
+ return out;
1005
+ }
751
1006
  /**
752
1007
  * Write crossref.yaml if needed
753
1008
  */
@@ -777,31 +1032,98 @@ export function resolveOutputDir(directory, config) {
777
1032
  return directory;
778
1033
  return path.isAbsolute(out) ? out : path.join(directory, out);
779
1034
  }
1035
+ /** File extension (with leading dot) for each supported pandoc format. */
1036
+ const FORMAT_EXTENSIONS = {
1037
+ tex: '.tex',
1038
+ pdf: '.pdf',
1039
+ docx: '.docx',
1040
+ beamer: '.pdf',
1041
+ pptx: '.pptx',
1042
+ };
1043
+ /** Get file extension for a format, defaulting to `.pdf`. */
1044
+ export function getFormatExtension(format) {
1045
+ return FORMAT_EXTENSIONS[format] ?? '.pdf';
1046
+ }
1047
+ /**
1048
+ * Slugify a title for use as a default output filename. Lowercases, replaces
1049
+ * non-alphanumeric runs with `-`, and truncates at the last `-` boundary
1050
+ * at-or-before MAX_TITLE_FILENAME_LENGTH so words stay whole (the old blind
1051
+ * `.slice` cut mid-word).
1052
+ */
1053
+ export function slugifyTitle(title) {
1054
+ if (!title)
1055
+ return 'paper';
1056
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1057
+ if (!slug)
1058
+ return 'paper';
1059
+ if (slug.length <= MAX_TITLE_FILENAME_LENGTH)
1060
+ return slug;
1061
+ const cut = slug.slice(0, MAX_TITLE_FILENAME_LENGTH);
1062
+ const lastDash = cut.lastIndexOf('-');
1063
+ // Only truncate at a hyphen if it leaves a reasonable amount of content.
1064
+ // Otherwise hard-cut (handles degenerate titles with no spaces at all).
1065
+ if (lastDash >= MAX_TITLE_FILENAME_LENGTH / 2) {
1066
+ return slug.slice(0, lastDash);
1067
+ }
1068
+ return cut;
1069
+ }
1070
+ /**
1071
+ * Ensure `name` ends with `ext` (case-insensitive). If the user already supplied
1072
+ * the correct extension, return unchanged; if they supplied none or a different
1073
+ * one, append the format's canonical extension.
1074
+ *
1075
+ * Different-extension case (e.g. `output.docx` when building tex): we append
1076
+ * rather than replace, since stripping looks like an unsafe guess. The result
1077
+ * `output.docx.tex` is loud enough to flag the misconfiguration.
1078
+ */
1079
+ function ensureExtension(name, ext) {
1080
+ if (name.toLowerCase().endsWith(ext.toLowerCase()))
1081
+ return name;
1082
+ return name + ext;
1083
+ }
1084
+ /**
1085
+ * Resolve the final output path for a build.
1086
+ *
1087
+ * Priority: `options.outputPath` (internal force) > `cliOverride` (-o flag) >
1088
+ * `config.output[format]` > slugified title fallback.
1089
+ *
1090
+ * Relative paths from `cliOverride`/`config.output` resolve under outputDir;
1091
+ * absolute paths bypass outputDir. The fallback path always lives under
1092
+ * outputDir.
1093
+ *
1094
+ * @param suffix - Appended before the extension (e.g. "-changes", "-slides").
1095
+ * Suppressed when user supplied an explicit name via CLI or
1096
+ * config — they pick their own suffix.
1097
+ */
1098
+ export function resolveOutputPath(directory, config, format, options = {}) {
1099
+ const { cliOverride, suffix = '' } = options;
1100
+ const ext = getFormatExtension(format);
1101
+ const explicit = cliOverride ?? config.output?.[format];
1102
+ if (explicit) {
1103
+ const baseDir = path.isAbsolute(explicit)
1104
+ ? path.dirname(explicit)
1105
+ : resolveOutputDir(directory, config);
1106
+ const baseName = path.basename(explicit);
1107
+ const stem = baseName.replace(/\.[^./\\]+$/, '');
1108
+ return path.join(baseDir, ensureExtension(`${stem}${suffix}`, ext));
1109
+ }
1110
+ const slug = slugifyTitle(config.title);
1111
+ return path.join(resolveOutputDir(directory, config), `${slug}${suffix}${ext}`);
1112
+ }
780
1113
  /**
781
1114
  * Run pandoc build
782
1115
  */
783
1116
  export async function runPandoc(inputPath, format, config, options = {}) {
784
1117
  const directory = path.dirname(inputPath);
785
- const baseName = config.title
786
- ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
787
- : 'paper';
788
- // Map format to file extension
789
- const extMap = {
790
- tex: '.tex',
791
- pdf: '.pdf',
792
- docx: '.docx',
793
- beamer: '.pdf', // beamer outputs PDF
794
- pptx: '.pptx',
795
- };
796
- const ext = extMap[format] || '.pdf';
797
- // For beamer, use -slides suffix to distinguish from regular PDF
1118
+ // outputPath (internal force) wins over the resolver. For beamer, we keep
1119
+ // the `-slides` suffix on the slug fallback to distinguish from a regular
1120
+ // PDF build; when the user supplies an explicit name, they pick their own.
798
1121
  const suffix = format === 'beamer' ? '-slides' : '';
799
- // Allow custom output path via options. Auto-named outputs go through the
800
- // configured outputDir (default 'output/'); explicit paths are honored as-is
801
- // so callers can route temp/intermediate artefacts where they want.
802
1122
  const outputPath = options.outputPath
803
- ? options.outputPath
804
- : path.join(resolveOutputDir(directory, config), `${baseName}${suffix}${ext}`);
1123
+ ?? resolveOutputPath(directory, config, format, {
1124
+ cliOverride: options.output,
1125
+ suffix,
1126
+ });
805
1127
  if (!options.outputPath) {
806
1128
  const outDir = path.dirname(outputPath);
807
1129
  if (!fs.existsSync(outDir)) {
@@ -847,12 +1169,46 @@ export async function runPandoc(inputPath, format, config, options = {}) {
847
1169
  if (referenceDoc) {
848
1170
  args.push('--reference-doc', referenceDoc);
849
1171
  }
850
- // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax)
851
- const colorFilterPath = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')), 'pptx-color-filter.lua');
1172
+ // Add color filter for PPTX (handles [text]{color=#RRGGBB} syntax).
1173
+ // fileURLToPath handles Windows paths with spaces — the old
1174
+ // `new URL(...).pathname` returned URL-encoded `%20` and fs.existsSync
1175
+ // silently failed.
1176
+ const colorFilterPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'pptx-color-filter.lua');
852
1177
  if (fs.existsSync(colorFilterPath)) {
853
1178
  args.push('--lua-filter', colorFilterPath);
854
1179
  }
855
1180
  }
1181
+ // Wire placeholder macros (built-in \tofill plus user-declared entries).
1182
+ // - docx/html: lua filter expands \name{X} to format-specific raw runs.
1183
+ // - pdf/tex/beamer: inject a \providecommand preamble so LaTeX renders it
1184
+ // directly. `\providecommand` is non-clobbering, so a user who already
1185
+ // has `\providecommand{\tofill}{...}` in their own header keeps theirs.
1186
+ //
1187
+ // Sidecar path is passed to the lua filter via DOCREV_MACROS_FILE in the
1188
+ // child env (not pandoc metadata) because pandoc walks RawInline/RawBlock
1189
+ // BEFORE Meta — by the time a Meta handler could read the path, the inline
1190
+ // expansion has already happened.
1191
+ const macroTempFiles = [];
1192
+ let macroEnvFile = null;
1193
+ const macros = mergeMacros(config.macros);
1194
+ if (macros.length > 0) {
1195
+ if (format === 'docx' || format === 'html' || format === 'html5' || format === 'html4') {
1196
+ const sidecarPath = writeMacrosSidecar(directory, macros);
1197
+ macroTempFiles.push(sidecarPath);
1198
+ macroEnvFile = sidecarPath;
1199
+ const filterPath = getMacroFilterPath();
1200
+ if (fs.existsSync(filterPath)) {
1201
+ args.push('--lua-filter', filterPath);
1202
+ }
1203
+ }
1204
+ else if (format === 'pdf' || format === 'tex' || format === 'beamer') {
1205
+ const preamble = generateLatexPreamble(macros);
1206
+ const preamblePath = path.join(directory, '.macros.tex');
1207
+ fs.writeFileSync(preamblePath, preamble, 'utf-8');
1208
+ macroTempFiles.push(preamblePath);
1209
+ args.push('-H', path.basename(preamblePath));
1210
+ }
1211
+ }
856
1212
  // Add crossref metadata file if exists (skip for slides - they don't use crossref)
857
1213
  if (format !== 'beamer' && format !== 'pptx') {
858
1214
  const crossrefPath = path.join(directory, 'crossref.yaml');
@@ -861,18 +1217,41 @@ export async function runPandoc(inputPath, format, config, options = {}) {
861
1217
  args.push('--metadata-file', 'crossref.yaml');
862
1218
  }
863
1219
  }
1220
+ // Passthrough args go last so they win against built-in defaults.
1221
+ args.push(...collectPandocPassthroughArgs(format, config, options.pandocArgs));
864
1222
  // Input file (use basename since we set cwd to directory)
865
1223
  args.push(path.basename(inputPath));
1224
+ if (options.verbose) {
1225
+ const quoted = args.map(a => /[\s"'$`]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ');
1226
+ console.error(`[pandoc ${format}] (cwd: ${directory})`);
1227
+ console.error(` pandoc ${quoted}`);
1228
+ }
866
1229
  return new Promise((resolve) => {
1230
+ const pandocEnv = { ...process.env };
1231
+ if (macroEnvFile) {
1232
+ pandocEnv.DOCREV_MACROS_FILE = macroEnvFile;
1233
+ }
867
1234
  const pandoc = spawn('pandoc', args, {
868
1235
  cwd: directory,
869
1236
  stdio: ['ignore', 'pipe', 'pipe'],
1237
+ env: pandocEnv,
870
1238
  });
871
1239
  let stderr = '';
872
1240
  pandoc.stderr?.on('data', (data) => {
873
1241
  stderr += data.toString();
874
1242
  });
1243
+ const cleanupMacroTempFiles = () => {
1244
+ for (const tmp of macroTempFiles) {
1245
+ try {
1246
+ fs.unlinkSync(tmp);
1247
+ }
1248
+ catch {
1249
+ // ignore — best-effort cleanup
1250
+ }
1251
+ }
1252
+ };
875
1253
  pandoc.on('close', async (code) => {
1254
+ cleanupMacroTempFiles();
876
1255
  if (code === 0) {
877
1256
  // For PPTX, post-process to add slide numbers, buildup colors, and logos
878
1257
  if (format === 'pptx') {
@@ -923,6 +1302,7 @@ export async function runPandoc(inputPath, format, config, options = {}) {
923
1302
  }
924
1303
  });
925
1304
  pandoc.on('error', (err) => {
1305
+ cleanupMacroTempFiles();
926
1306
  resolve({ outputPath, success: false, error: err.message });
927
1307
  });
928
1308
  });
@@ -966,6 +1346,12 @@ export async function build(directory, formats = ['pdf', 'docx'], options = {})
966
1346
  if (imageReg.figures?.length > 0) {
967
1347
  writeImageRegistry(directory, imageReg);
968
1348
  }
1349
+ // Warn about raw LaTeX figure blocks that won't render in docx (pandoc
1350
+ // drops them silently). With auto-translate on (default), this surfaces
1351
+ // only the exotic leftovers; with it off, every block.
1352
+ const rawFigWarning = collectRawLatexFigureWarning(directory, config);
1353
+ if (rawFigWarning)
1354
+ warnings.push(rawFigWarning);
969
1355
  }
970
1356
  const results = [];
971
1357
  for (const format of formats) {