diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8de1f693..0591c922 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,7 +55,8 @@ jobs:
- name: Regenerate handout JSONs
working-directory: backend/src/Tools/MathComps.Cli.Handouts
- run: dotnet run --configuration Release --no-build -- --skip-compile --skip-upload '*.sk.tex' '*.en.tex' '*.cs.tex'
+ # --skip-asy/compile/upload: Hard to ensure in CI, the author better run the CLI manually 😬
+ run: dotnet run --configuration Release --no-build -- --skip-compile --skip-upload --skip-asy '*.sk.tex' '*.en.tex' '*.cs.tex'
- name: Verify handout skeletons and JSONs unchanged (run Handouts CLI locally if this fails)
run: |
diff --git a/backend/src/Shared/MathComps.Shared/ProcessRunner.cs b/backend/src/Shared/MathComps.Shared/ProcessRunner.cs
new file mode 100644
index 00000000..02295597
--- /dev/null
+++ b/backend/src/Shared/MathComps.Shared/ProcessRunner.cs
@@ -0,0 +1,66 @@
+using System.Diagnostics;
+
+namespace MathComps.Shared;
+
+///
+/// Helper for spawning external executables with redirected stdout/stderr.
+/// Drains both pipes before waiting on the child to avoid the classic
+/// "pipe-buffer full, child blocks, parent waits forever" deadlock, and
+/// surfaces the exit code plus captured output so callers can apply their
+/// own failure policy (log file vs. exception, retries, etc.).
+///
+public static class ProcessRunner
+{
+ ///
+ /// The outcome of a single process invocation.
+ ///
+ /// The exit code returned by the child process (0 means success).
+ /// Everything the child wrote to its standard output stream.
+ /// Everything the child wrote to its standard error stream.
+ public record Result(int ExitCode, string Stdout, string Stderr);
+
+ ///
+ /// Runs an external executable to completion and returns its captured output.
+ /// Each entry in becomes one argv entry on the child,
+ /// with .NET handling the platform-specific quoting — pass paths and flags as
+ /// separate elements rather than pre-joining them into a single string.
+ /// Failure to START the process (executable not found, etc.) throws a
+ /// ; non-zero exits from a
+ /// successfully-started process are reported via .
+ ///
+ /// The executable name (resolved against PATH) or absolute path.
+ /// Arguments passed to the child, one argv entry per element.
+ /// The working directory the child inherits.
+ /// The exit code plus everything the process wrote to stdout and stderr.
+ public static Result Run(string fileName, IReadOnlyList arguments, string workingDirectory)
+ {
+ // Configure the spawn: redirect both streams so we can capture them, no console window, no shell.
+ var processInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ // ArgumentList lets the runtime quote each argv entry correctly for the platform
+ foreach (var argument in arguments)
+ processInfo.ArgumentList.Add(argument);
+
+ // Start the child; a null return here is unusual but documented for Process.Start
+ using var process = Process.Start(processInfo)
+ ?? throw new InvalidOperationException($"Failed to start '{fileName}'");
+
+ // Drain output streams BEFORE WaitForExit — otherwise a chatty child fills the pipe buffer and deadlocks
+ var stdout = process.StandardOutput.ReadToEnd();
+ var stderr = process.StandardError.ReadToEnd();
+
+ // Once both streams are fully drained, the child is free to finish
+ process.WaitForExit();
+
+ // Hand back everything the caller needs to decide success vs. failure semantics
+ return new Result(process.ExitCode, stdout, stderr);
+ }
+}
diff --git a/backend/src/Tools/MathComps.Cli.Handouts/BuildCommand.cs b/backend/src/Tools/MathComps.Cli.Handouts/BuildCommand.cs
index a0ade85d..e2e30a8c 100644
--- a/backend/src/Tools/MathComps.Cli.Handouts/BuildCommand.cs
+++ b/backend/src/Tools/MathComps.Cli.Handouts/BuildCommand.cs
@@ -8,7 +8,6 @@
using Spectre.Console.Cli;
using System.Collections.Immutable;
using System.ComponentModel;
-using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
@@ -59,6 +58,26 @@ public class Settings : CommandSettings
[Description("Skip uploading PDFs and images to R2")]
public bool SkipUpload { get; set; }
+ ///
+ /// Whether to skip the Asymptote staleness check and recompilation.
+ /// Useful when the author is confident images are up to date and wants
+ /// to shave the per-handout dependency scan / or in CI where no Asymptote
+ ///
+ [CommandOption("--skip-asy")]
+ [Description("Skip Asymptote staleness check and recompilation")]
+ public bool SkipAsy { get; set; }
+
+ ///
+ /// Whether to unconditionally recompile every Asymptote-backed image, bypassing
+ /// the staleness check. Use after a change to _common.asy that alters
+ /// rendering of existing figures (e.g. palette tweak, modified helper function) —
+ /// such edits are intentionally NOT tracked by the dep graph since most
+ /// _common.asy edits are additive and harmless.
+ ///
+ [CommandOption("--force-asy")]
+ [Description("Recompile every Asymptote-backed image regardless of staleness")]
+ public bool ForceAsy { get; set; }
+
///
/// Path to the error log file for compiler output on failure.
///
@@ -87,6 +106,17 @@ private record HandoutImageResult(Document ProcessedDocument, ImmutableList
private const string HandoutsR2Prefix = "handouts";
+ ///
+ /// File name of the global Asymptote helper module imported by every figure.
+ /// Deliberately excluded from the dep graph used by staleness checks: edits to
+ /// _common.asy are almost always additive helpers that cannot affect
+ /// existing figures, so cascading invalidation across all ~30 figures every time
+ /// a helper is added is pure waste. When a change to _common.asy DOES
+ /// alter rendering of existing figures (palette tweak, modified function), the
+ /// author opts in via --force-asy to recompile everything.
+ ///
+ private const string GlobalAsyDepFileName = "_common.asy";
+
///
/// Derives the language-stripped handout slug from a .tex filename. Handles both
/// main handouts (e.g. "factorization.cs.tex" -> "factorization") and their
@@ -109,22 +139,16 @@ private static string ToHandoutSlug(string texFileName)
///
public override async Task ExecuteAsync(CommandContext context, Settings settings)
{
- // Fixed paths relative to the tool's project directory.
+ // We take handout .tex sources from here
var inputDirectory = new DirectoryInfo("../../../../data/handouts");
- var jsonOutputDirectory = new DirectoryInfo("../../../../web/src/content/handouts");
- // Validate the input directory exists.
- if (!inputDirectory.Exists)
- {
- AnsiConsole.MarkupLine($"[red]Error:[/] Input directory not found at '[yellow]{Markup.Escape(inputDirectory.FullName)}[/]'");
- return 1;
- }
+ // ..And their images from here
+ var imagesDirectory = new DirectoryInfo(Path.Combine(inputDirectory.FullName, "Images"));
- // Ensure the JSON output directory exists.
- if (!jsonOutputDirectory.Exists)
- jsonOutputDirectory.Create();
+ // ..And output jsons for the web rendered here
+ var jsonOutputDirectory = new DirectoryInfo("../../../../web/src/content/handouts");
- // Resolve the uploader
+ // Resolve the uploader...We don't need it if we're skipping uploads.
IFileUploader? uploader = null;
if (!settings.SkipUpload)
{
@@ -140,7 +164,7 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
List inputFiles = [..
settings.Patterns
.SelectMany(pattern => inputDirectory.GetFiles(pattern, SearchOption.TopDirectoryOnly))
- .Where(file => !file.Name.Contains("-skeleton", StringComparison.OrdinalIgnoreCase))
+ .Where(file => !file.Name.Contains("-skeleton"))
];
// Check if any files were found.
@@ -163,19 +187,40 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
// Track which files failed for the final report.
var failedFiles = new List();
+ // .asy filenames already recompiled by an earlier handout in this run — language
+ // variants of the same handout reference the same figures, so without this every
+ // variant would re-run the same asy+inkscape pipeline.
+ var asyAlreadyRecompiled = new HashSet(StringComparer.OrdinalIgnoreCase);
+
// Process each discovered handout file.
foreach (var inputFile in inputFiles)
{
+ // Log file
AnsiConsole.MarkupLine($"[aqua]━━━ {Markup.Escape(inputFile.Name)} ━━━[/]");
try
{
- // Step 1: Generate skeleton TeX
- var skeletonFile = GenerateSkeleton(inputFile, inputDirectory, rules);
+ // Read the entire content of the .tex file into a string.
+ var texContent = File.ReadAllText(inputFile.FullName);
+
+ // Parse the TeX content into the structured Document object model.
+ var (document, unknownCommands) = TexStringParser.ParseDocument(texContent, rules);
- // Step 2: Compile TeX files (main + skeleton)
+ // Ensure every .asy-backed image is fresh on disk before anything downstream
+ // reads the compiled PDF or SVG. --force-asy bypasses the staleness check and
+ // recompiles every figure unconditionally.
+ if (!settings.SkipAsy)
+ EnsureAsyImagesFresh(document, imagesDirectory, settings.ForceAsy, asyAlreadyRecompiled);
+ else
+ AnsiConsole.MarkupLine(" [yellow]⚠ Asy compile skipped (--skip-asy)[/]");
+
+ // Generate the skeleton and compile both TeX files.
+ FileInfo? skeletonFile = null;
if (!settings.SkipCompile)
{
+ // Generate the skeleton .tex
+ skeletonFile = GenerateSkeleton(inputFile, inputDirectory, rules);
+
// Compile the main handout
CompileTexFile(inputFile, inputDirectory, settings.Compiler, settings.ErrorLog);
@@ -183,27 +228,19 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
CompileTexFile(skeletonFile, inputDirectory, settings.Compiler, settings.ErrorLog);
}
- // Step 3: Parse TeX to JSON and process images
+ // Process images and prepare uploads
var pendingImageUploads = new List();
- // Read the entire content of the .tex file into a string.
- var texContent = File.ReadAllText(inputFile.FullName);
-
- // Parse the TeX content into the structured Document object model.
- var (document, unknownCommands) = TexStringParser.ParseDocument(texContent, rules);
-
// Process images in the document content and collect their metadata
var imageResult = ProcessHandoutImages(document, inputFile.Name, settings.SkipUpload ? null : pendingImageUploads);
- // Create a wrapper object containing both document and images
- var handoutData = new
+ // Serialize the handout data (document + images) to an indented JSON string
+ var jsonString = new
{
Document = imageResult.ProcessedDocument,
Images = imageResult.DiscoveredImages
- };
-
- // Serialize the handout data (document + images) to an indented JSON string
- var jsonString = handoutData.ToJson();
+ }
+ .ToJson();
// Normalize line endings to LF (Unix-style) for Git compatibility
var normalizedContent = jsonString.Replace("\r\n", "\n") + "\n";
@@ -215,24 +252,33 @@ public override async Task ExecuteAsync(CommandContext context, Settings se
// Write the JSON output
File.WriteAllText(outputFilePath, normalizedContent);
+ // Report success
AnsiConsole.MarkupLine($" [green]✓ Parsed:[/] {Markup.Escape(outputFileName)}");
// If unknown commands were found, add them to our report dictionary.
if (!unknownCommands.IsEmpty)
allUnknownCommands[inputFile.Name] = unknownCommands;
- // Step 4: Upload images and PDFs to R2 (unless skipped)
+ // Upload images and PDFs to R2 (unless skipped)
if (uploader is not null)
{
- // Upload queued images asynchronously
+ // Upload queued images, logging each so the publish run shows what landed in R2
foreach (var upload in pendingImageUploads)
+ {
await uploader.UploadAsync(upload.SourcePath, upload.R2Key);
-
- // Upload PDFs
- await UploadHandoutPdfAsync(inputFile, inputDirectory, uploader);
-
- // Upload the skeleton PDF
- await UploadHandoutPdfAsync(skeletonFile, inputDirectory, uploader);
+ AnsiConsole.MarkupLine($" [green]✓ Image uploaded:[/] {Markup.Escape(Path.GetFileName(upload.SourcePath))}");
+ }
+
+ // PDFs only when we actually compiled them; otherwise the on-disk copies are
+ // from an earlier run and we'd risk publishing binaries that don't match the source.
+ if (skeletonFile is not null)
+ {
+ // Upload the main handout PDF
+ await UploadHandoutPdfAsync(inputFile, inputDirectory, uploader);
+
+ // Upload the skeleton PDF
+ await UploadHandoutPdfAsync(skeletonFile, inputDirectory, uploader);
+ }
}
}
catch (Exception exception)
@@ -382,6 +428,7 @@ private static FileInfo GenerateSkeleton(FileInfo inputFile, DirectoryInfo outpu
var skeletonPath = Path.Combine(outputDirectory.FullName, skeletonName);
File.WriteAllText(skeletonPath, builder.ToString());
+ // Success message
AnsiConsole.MarkupLine($" [green]✓ Skeleton:[/] {Markup.Escape(skeletonName)} ({allStatements.Count} statements)");
// Return the generated skeleton file
@@ -405,48 +452,32 @@ private static void CompileTexFile(
// Run two passes as required by TeX for cross-references
for (var pass = 1; pass <= 2; pass++)
{
+ // Status message
AnsiConsole.MarkupLine($" [dim]Compiling {Markup.Escape(texFile.Name)} (pass {pass}/2)...[/]");
- // Split the compiler string into executable and any extra flags
- var compilerParts = compiler.Split(' ', 2);
+ // Split the configured compiler string into executable + flags (whitespace-separated, no quoted args)
+ var compilerParts = compiler.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var compilerExecutable = compilerParts[0];
- var compilerFlags = compilerParts.Length > 1 ? $"{compilerParts[1]} " : "";
- // Configure the compiler process
- var processInfo = new ProcessStartInfo
- {
- FileName = compilerExecutable,
- Arguments = $"{compilerFlags}{texFile.Name}",
- WorkingDirectory = workingDirectory.FullName,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- UseShellExecute = false,
- CreateNoWindow = true
- };
-
- // Start the compiler process
- using var process = Process.Start(processInfo)
- ?? throw new InvalidOperationException($"Failed to start '{compiler}'");
-
- // Read output to prevent deadlocks on redirected streams
- var standardOutput = process.StandardOutput.ReadToEnd();
- var standardError = process.StandardError.ReadToEnd();
-
- // Wait for the process to finish
- process.WaitForExit();
+ // Pass each flag as its own argv entry plus the input .tex filename at the end
+ string[] arguments = [.. compilerParts.Skip(1), texFile.Name];
+
+ // Run the compiler; ProcessRunner drains stdout/stderr and reports the exit code
+ var result = ProcessRunner.Run(compilerExecutable, arguments, workingDirectory.FullName);
// Check if compilation failed
- if (process.ExitCode != 0)
+ if (result.ExitCode != 0)
{
// Append the full compiler output to the error log for debugging
- File.AppendAllText(errorLog, $"=== {texFile.Name} (pass {pass}) ===\n{standardOutput}\n{standardError}\n");
+ File.AppendAllText(errorLog, $"=== {texFile.Name} (pass {pass}) ===\n{result.Stdout}\n{result.Stderr}\n");
// Throw with context about which file and pass failed
throw new InvalidOperationException(
- $"Compilation of '{texFile.Name}' failed on pass {pass} (exit code {process.ExitCode}). See errors.log for details.");
+ $"Compilation of '{texFile.Name}' failed on pass {pass} (exit code {result.ExitCode}). See errors.log for details.");
}
}
+ // Success message
AnsiConsole.MarkupLine($" [green]✓ Compiled:[/] {Markup.Escape(Path.ChangeExtension(texFile.Name, ".pdf"))}");
}
@@ -473,7 +504,11 @@ private static async Task UploadHandoutPdfAsync(FileInfo texFile, DirectoryInfo
// All handout PDFs share the flat handouts/pdfs/ folder
var r2Key = ToHandoutR2Key($"pdfs/{pdfFileName}");
+
+ // Do the upload
await fileUploader.UploadAsync(sourcePdfPath, r2Key);
+
+ // Log success
AnsiConsole.MarkupLine($" [green]✓ PDF uploaded:[/] {Markup.Escape(pdfFileName)}");
}
@@ -540,5 +575,249 @@ private static HandoutImageResult ProcessHandoutImages(Document document, string
);
}
+ ///
+ /// Walks the parsed and returns the set of distinct
+ /// image identifiers it references. For handouts these are <name>.pdf
+ /// strings — the same ids that would later
+ /// resolve to SVGs.
+ ///
+ /// The parsed document to scan.
+ /// The distinct image ids referenced anywhere in the document.
+ private static ImmutableHashSet CollectImageIds(Document document)
+ {
+ // Accumulator for image ids discovered during the traversal
+ var idsBuilder = ImmutableHashSet.CreateBuilder();
+
+ // Walk every section's content tree; the side-effecting closure captures image ids
+ foreach (var section in document.Sections)
+ {
+ // ContentTree.Map recurses into every nested container (paragraphs, exercises,
+ // problem hints, theorem proofs, list items, etc.) — same coverage as the image processor
+ ContentTree.Map(section.Text.Content, node =>
+ {
+ // Image nodes are the only nodes we care about; everything else passes through
+ if (node is Image image)
+ idsBuilder.Add(image.Id);
+
+ // Return the same reference to signal "no transformation"
+ return node;
+ });
+ }
+
+ // Frozen result for staleness lookups
+ return idsBuilder.ToImmutable();
+ }
+
+ ///
+ /// Resolves the transitive set of source files that a given .asy figure
+ /// depends on. Walks import <name>; and include "<name.asy>";
+ /// directives, resolving each to a sibling file in .
+ /// Built-in Asymptote modules (e.g. three) resolve to no file and are ignored.
+ ///
+ /// Absolute path to the .asy file to scan.
+ /// Directory containing handout figure sources.
+ /// Shared cache across the batch so shared files (_common.asy, family-shared files) are scanned once.
+ /// The .asy file itself plus every transitively resolved dependency.
+ private static ImmutableHashSet ResolveAsyDeps(
+ string asyPath,
+ DirectoryInfo imagesDir,
+ Dictionary> memo)
+ {
+ // Memo hit — shared file already scanned by a sibling figure
+ if (memo.TryGetValue(asyPath, out var cached))
+ return cached;
+
+ // Seed with the file itself; transitive deps grow the set below
+ var depsBuilder = ImmutableHashSet.CreateBuilder();
+ depsBuilder.Add(asyPath);
+
+ // Read once for both import and include scans
+ var content = File.ReadAllText(asyPath);
+
+ // Resolve a candidate dependency: relative to imagesDir and only if the file exists.
+ // Unresolved candidates (built-in asy modules like `three`) are silently dropped.
+ void TryAddDep(string fileName)
+ {
+ // Exclude the global helper module from the dep graph (see GlobalAsyDepFileName remarks)
+ if (string.Equals(fileName, GlobalAsyDepFileName, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ // Combine with the figures directory; absolute path keeps the memo key stable
+ var candidate = Path.Combine(imagesDir.FullName, fileName);
+
+ // Skip anything that doesn't map to a real .asy in our figures dir
+ if (!File.Exists(candidate))
+ return;
+
+ // Recursively pull in the candidate's own deps and union them in
+ foreach (var dep in ResolveAsyDeps(candidate, imagesDir, memo))
+ depsBuilder.Add(dep);
+ }
+
+ // `import ;` — bare module name, treat as `.asy` in the figures dir
+ foreach (Match match in Regex.Matches(content, @"^\s*import\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", RegexOptions.Multiline))
+ TryAddDep(match.Groups[1].Value + ".asy");
+
+ // `include "";` — quoted file path; the extension is optional in Asymptote so normalise it
+ foreach (Match match in Regex.Matches(content, @"^\s*include\s+""([^""]+)""\s*;", RegexOptions.Multiline))
+ {
+ // Preserve caller-supplied extension; otherwise normalise to .asy
+ var raw = match.Groups[1].Value;
+ var fileName = raw.EndsWith(".asy", StringComparison.OrdinalIgnoreCase) ? raw : raw + ".asy";
+ TryAddDep(fileName);
+ }
+
+ // `include ;` — unquoted module form, same resolution as the bare `import`
+ foreach (Match match in Regex.Matches(content, @"^\s*include\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", RegexOptions.Multiline))
+ TryAddDep(match.Groups[1].Value + ".asy");
+
+ // Freeze and memoize for future lookups
+ var result = depsBuilder.ToImmutable();
+ memo[asyPath] = result;
+ return result;
+ }
+
+ ///
+ /// Determines whether a figure's compiled outputs are stale relative to its source(s).
+ /// A figure is stale when either output (PDF or SVG) is missing, or when any source
+ /// in has a newer mtime than the OLDER of the two outputs.
+ ///
+ /// The source files that contribute to the compiled outputs (the .asy plus its transitive includes/imports).
+ /// Absolute path to the figure's compiled PDF.
+ /// Absolute path to the figure's compiled SVG.
+ /// true if a recompile is required; false if both outputs are up to date.
+ private static bool IsImageStale(ImmutableHashSet sources, string pdfPath, string svgPath)
+ {
+ // A missing output unambiguously means the figure must be (re)compiled
+ if (!File.Exists(pdfPath) || !File.Exists(svgPath))
+ return true;
+
+ // Compare against the OLDER output — if either is behind the source, we need to rebuild
+ var pdfMtime = File.GetLastWriteTimeUtc(pdfPath);
+ var svgMtime = File.GetLastWriteTimeUtc(svgPath);
+ var oldestOutput = pdfMtime < svgMtime ? pdfMtime : svgMtime;
+
+ // Any source newer than the older output means the outputs are out of sync with the source
+ return sources.Any(source => File.GetLastWriteTimeUtc(source) > oldestOutput);
+ }
+
+ ///
+ /// Invokes _Export-Asy.ps1 via PowerShell 7+ (pwsh) with an explicit list
+ /// of stale .asy files. The script handles both the asy→PDF render and the
+ /// Inkscape PDF→SVG conversion.
+ ///
+ /// Filenames (not absolute paths) of stale .asy files inside .
+ /// Directory containing the .asy files and the export script.
+ private static void RunAsyExportScript(IReadOnlyList staleAsyFileNames, DirectoryInfo imagesDir)
+ {
+ // Locate the export script next to the .asy sources
+ var scriptPath = Path.Combine(imagesDir.FullName, "_Export-Asy.ps1");
+
+ // Build the pwsh argv: hardening flags, then the script path, then the script's $Path varargs.
+ // -NoProfile skips $PROFILE (faster, deterministic); -ExecutionPolicy Bypass avoids signing issues.
+ string[] arguments = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, .. staleAsyFileNames];
+
+ // Run the export script under PowerShell 7+
+ var result = ProcessRunner.Run("pwsh", arguments, imagesDir.FullName);
+
+ // Non-zero exit means at least one .asy failed — surface everything for debugging
+ if (result.ExitCode != 0)
+ throw new InvalidOperationException(
+ $"Asymptote export script failed (exit {result.ExitCode}).\n--- stdout ---\n{result.Stdout}\n--- stderr ---\n{result.Stderr}");
+ }
+
+ ///
+ /// Checks every .asy-backed image referenced by the document for staleness
+ /// (against its own source plus its transitive include/import deps,
+ /// excluding the global _common.asy) and batch-recompiles only the stale
+ /// ones via _Export-Asy.ps1. Images that have no sibling .asy (raster
+ /// or externally-authored PDFs) are silently skipped — those are not produced by
+ /// the asy pipeline.
+ ///
+ /// The parsed handout document whose images should be ensured fresh.
+ /// Directory containing the .asy sources, compiled PDFs/SVGs, and the export script.
+ /// When true, every .asy-backed image is recompiled regardless of staleness — used after a semantic _common.asy edit.
+ /// Cross-handout set of .asy filenames already recompiled in this CLI run; entries here are treated as fresh and not requeued. Updated with each batch.
+ private static void EnsureAsyImagesFresh(Document document, DirectoryInfo imagesDir, bool forceRecompile, HashSet alreadyRecompiled)
+ {
+ // Collect the set of image ids referenced anywhere in the document
+ var imageIds = CollectImageIds(document);
+
+ // Shared dep memo across this handout — family-shared files are scanned once even if every figure in the family imports them
+ var depMemo = new Dictionary>();
+ // Stale .asy filenames to batch-compile; populated as we walk the image set
+ var staleFileNames = new List();
+
+ // Count of asy-backed images that turned out to already be fresh — purely for the summary line
+ var freshCount = 0;
+
+ // Walk every distinct image id; partition into asy-backed-fresh, asy-backed-stale, or non-asy (skipped)
+ foreach (var imageId in imageIds)
+ {
+ // Image.Id is ".pdf" for handouts; the backing source has the same stem with a .asy extension
+ var asyFileName = Path.ChangeExtension(imageId, ".asy");
+ var asyPath = Path.Combine(imagesDir.FullName, asyFileName);
+
+ // No sibling .asy means this image is externally authored (raster, hand-drawn PDF, etc.) — leave it alone
+ if (!File.Exists(asyPath))
+ continue;
+
+ // An earlier handout in this run already recompiled this figure — count it as fresh, don't requeue
+ if (alreadyRecompiled.Contains(asyFileName))
+ {
+ freshCount++;
+ continue;
+ }
+
+ // Outputs live next to the source under the same stem
+ var pdfPath = Path.Combine(imagesDir.FullName, imageId);
+ var svgPath = Path.Combine(imagesDir.FullName, Path.ChangeExtension(imageId, ".svg"));
+
+ // Is this stale?
+ bool stale;
+
+ // Force flag short-circuits the dep walk entirely;
+ if (forceRecompile)
+ {
+ stale = true;
+ }
+ else
+ {
+ // Otherwise compare source mtimes against output mtimes for the file and its deps
+ var deps = ResolveAsyDeps(asyPath, imagesDir, depMemo);
+ stale = IsImageStale(deps, pdfPath, svgPath);
+ }
+
+ // Stale ⇒ queue for batch compile;
+ if (stale)
+ {
+ AnsiConsole.MarkupLine($" [yellow]↻ Asy:[/] recompiling {Markup.Escape(asyFileName)}");
+ staleFileNames.Add(asyFileName);
+ }
+ // fresh ⇒ bump the count for the summary
+ else
+ {
+ freshCount++;
+ }
+ }
+
+ // Nothing to recompile...
+ if (staleFileNames.Count == 0)
+ {
+ // Emit a summary if any asy-backed images were inspected
+ if (freshCount > 0)
+ AnsiConsole.MarkupLine($" [green]✓ Asy:[/] all {freshCount} image(s) fresh");
+
+ return;
+ }
+
+ // One pwsh invocation for the whole batch — _Export-Asy.ps1 iterates internally
+ RunAsyExportScript(staleFileNames, imagesDir);
+ AnsiConsole.MarkupLine($" [green]✓ Asy:[/] {staleFileNames.Count} image(s) recompiled");
+
+ // Record what we just compiled so later handouts in this run don't redo the same work
+ foreach (var fileName in staleFileNames)
+ alreadyRecompiled.Add(fileName);
+ }
}