Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ namespace MathComps.TexParser.Images;
/// Configuration for <see cref="TexImageProcessor"/> that specifies how to resolve, name, and store images.
/// </summary>
/// <param name="ImageSourceResolver">Function that resolves a TeX image ID to an absolute source file path. Returns null if not found.</param>
/// <param name="FileNamePrefix">Prefix for generated image file names (e.g., "algebra-1-rozklady" or "50-a-i-1").</param>
/// <param name="OutputFileName">Function producing the output file name for a discovered image. Receives the TeX image ID and the running counter (1-based, advances per unique image).</param>
/// <param name="PersistImage">Delegate that persists a discovered image. Receives the source file path and the destination file name.</param>
/// <param name="OnMissingImage">Optional callback invoked when an image source cannot be resolved. Receives the TeX image ID.</param>
public record ImageProcessingConfig(
Func<string, string?> ImageSourceResolver,
string FileNamePrefix,
Func<string, int, string> OutputFileName,
Action<string, string> PersistImage,
Action<string>? OnMissingImage = null
);
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ ImageProcessingConfig config
return new(image with { Id = existingContentId }, state);
}

// Build a deterministic file name for stable URLs.
var newFileName = $"{config.FileNamePrefix}-{state.Counter}.svg";
// Build a file name for using the caller's strategy.
var newFileName = config.OutputFileName(image.Id, state.Counter);

// Persist the image using the configured strategy (local copy, R2 upload, etc.)
config.PersistImage(sourcePath, newFileName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface IFileUploader
/// Uploads a local file to remote storage at the specified key.
/// </summary>
/// <param name="localFilePath">Absolute path to the local file to upload.</param>
/// <param name="key">The storage key (e.g., "handouts/pdfs/factorization.sk.pdf").</param>
/// <param name="key">The storage key (e.g., "handouts/factorization/factorization.sk.pdf").</param>
/// <returns>A task representing the asynchronous upload operation.</returns>
Task UploadAsync(string localFilePath, string key);
}
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ await ProgressHelper.ExecuteWithProgressAsync(
// Configure image processing for this problem
var imageConfig = new ImageProcessingConfig(
ImageSourceResolver: imageId => SkmoImageHelper.FindImageSourcePath(imageId, parsedProblem.RawProblem.OlympiadYear),
FileNamePrefix: problemSlug,
OutputFileName: (_, counter) => $"{problemSlug}-{counter}.svg",
PersistImage: (sourcePath, destinationFileName) =>
{
// Ensure the output directory exists
Expand Down
58 changes: 44 additions & 14 deletions backend/src/Tools/MathComps.Cli.Handouts/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,30 @@ private record PendingUpload(string SourcePath, string R2Key);
/// <param name="DiscoveredImages">All image metadata discovered during processing.</param>
private record HandoutImageResult(Document ProcessedDocument, ImmutableList<ImageData> DiscoveredImages);

/// <summary>
/// The R2 prefix under which every handout artefact (PDFs, images) lives.
/// </summary>
private const string HandoutsR2Prefix = "handouts";

/// <summary>
/// Derives the language-stripped handout slug from a .tex filename. Handles both
/// main handouts (e.g. "factorization.cs.tex" -> "factorization") and their
/// skeleton variants (e.g. "factorization.cs-skeleton.tex" -> "factorization").
/// </summary>
/// <param name="texFileName">The .tex filename (with extension).</param>
/// <returns>The language-stripped handout slug.</returns>
private static string ToHandoutSlug(string texFileName)
=> Regex.Replace(texFileName, @"\.([a-z]{2})(-skeleton)?\.tex$", "", RegexOptions.IgnoreCase);

/// <summary>
/// Builds the full R2 key for a handout asset by prefixing its slug-relative path
/// with <see cref="HandoutsR2Prefix"/>. Centralises the prefix so PDF and image
/// upload paths stay in sync.
/// </summary>
/// <param name="slugRelativeKey">The slug-prefixed asset path (e.g. "factorization/box.svg").</param>
/// <returns>The full R2 key (e.g. "handouts/factorization/box.svg").</returns>
private static string ToHandoutR2Key(string slugRelativeKey) => $"{HandoutsR2Prefix}/{slugRelativeKey}";

/// <inheritdoc/>
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
Expand Down Expand Up @@ -205,10 +229,10 @@ public override async Task<int> ExecuteAsync(CommandContext context, Settings se
await uploader.UploadAsync(upload.SourcePath, upload.R2Key);

// Upload PDFs
await UploadPdfToR2Async(inputFile, inputDirectory, uploader);
await UploadHandoutPdfAsync(inputFile, inputDirectory, uploader);

// Upload the skeleton PDF
await UploadPdfToR2Async(skeletonFile, inputDirectory, uploader);
await UploadHandoutPdfAsync(skeletonFile, inputDirectory, uploader);
}
}
catch (Exception exception)
Expand Down Expand Up @@ -427,13 +451,14 @@ private static void CompileTexFile(
}

/// <summary>
/// Uploads a compiled PDF from the source directory to Cloudflare R2.
/// Uploads a compiled handout PDF (main or skeleton) to remote storage, nesting it
/// under the handout's slug folder so every artefact for one handout sits together.
/// </summary>
/// <param name="texFile">The .tex file whose corresponding PDF should be uploaded.</param>
/// <param name="sourceDirectory">The directory containing the compiled PDFs.</param>
/// <param name="fileUploader">The file uploader instance.</param>
/// <returns>A task representing the asynchronous upload operation.</returns>
private static async Task UploadPdfToR2Async(FileInfo texFile, DirectoryInfo sourceDirectory, IFileUploader fileUploader)
private static async Task UploadHandoutPdfAsync(FileInfo texFile, DirectoryInfo sourceDirectory, IFileUploader fileUploader)
{
// Determine the PDF filename from the TeX filename
var pdfFileName = Path.ChangeExtension(texFile.Name, ".pdf");
Expand All @@ -446,8 +471,8 @@ private static async Task UploadPdfToR2Async(FileInfo texFile, DirectoryInfo sou
return;
}

// Upload the PDF to R2 under the handouts/pdfs/ prefix
var r2Key = $"handouts/pdfs/{pdfFileName}";
// Build the R2 key so every artefact for one handout lands in the same folder
var r2Key = ToHandoutR2Key($"{ToHandoutSlug(texFile.Name)}/{pdfFileName}");
await fileUploader.UploadAsync(sourcePdfPath, r2Key);
AnsiConsole.MarkupLine($" [green]✓ PDF uploaded:[/] {Markup.Escape(pdfFileName)}");
}
Expand All @@ -457,27 +482,32 @@ private static async Task UploadPdfToR2Async(FileInfo texFile, DirectoryInfo sou
/// Discovered images are queued for async upload after processing completes.
/// </summary>
/// <param name="document">The parsed <see cref="Document"/>.</param>
/// <param name="sourceFileName">The source .tex file name (e.g., "algebra-1-rozklady-sk.tex").</param>
/// <param name="sourceFileName">The source .tex file name (e.g., "factorization.cs.tex").</param>
/// <param name="pendingUploads">List to collect image uploads for async execution. Null when uploads are skipped.</param>
/// <returns>An <see cref="HandoutImageResult"/> containing the processed document and discovered images.</returns>
private static HandoutImageResult ProcessHandoutImages(Document document, string sourceFileName, List<PendingUpload>? pendingUploads)
{
// Extract the handout identifier from the filename (e.g., "algebra-1-rozklady-sk.tex" -> "algebra-1-rozklady-sk")
var handoutId = Path.GetFileNameWithoutExtension(sourceFileName);
// Language-stripped handout slug shared by every language variant of this handout.
var handoutSlug = ToHandoutSlug(sourceFileName);

// Source directory for handout images
var handoutsDirectory = "../../../../data/handouts/Images";

// In .tex sources images are referenced as "<name>.pdf" because pdfcsplain embeds
// PDFs. The web frontend wants SVGs, which sit alongside the PDFs on disk
// This swaps the extension so callers can read it as "the SVG counterpart of <pdfId>".
static string ToSvgName(string pdfImageId) => $"{pdfImageId.RemoveEnd(".pdf")}.svg";

// Configure the image processor for this handout
var config = new ImageProcessingConfig(
ImageSourceResolver: imageId => Path.Combine(handoutsDirectory, $"{imageId.RemoveEnd(".pdf")}.svg"),
FileNamePrefix: handoutId,
PersistImage: (sourcePath, destinationFileName) =>
ImageSourceResolver: imageId => Path.Combine(handoutsDirectory, ToSvgName(imageId)),
OutputFileName: (imageId, _) => $"{handoutSlug}/{ToSvgName(imageId)}",
PersistImage: (sourcePath, contentId) =>
{
// Queue the image upload for async execution after processing completes
pendingUploads?.Add(new PendingUpload(sourcePath, $"handouts/images/{destinationFileName}"));
pendingUploads?.Add(new PendingUpload(sourcePath, ToHandoutR2Key(contentId)));
},
OnMissingImage: imageId => AnsiConsole.MarkupLine($"[yellow]Warning:[/] Handout [yellow]{handoutId}[/] has a missing image: {imageId}")
OnMissingImage: imageId => AnsiConsole.MarkupLine($"[yellow]Warning:[/] Handout [yellow]{handoutSlug}[/] has a missing image: {imageId}")
);

// Collect all discovered images from all sections
Expand Down
4 changes: 2 additions & 2 deletions backend/src/Tools/MathComps.Cli.Handouts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ For each matched `.tex` file, the tool runs these steps in order:
1. **Generate skeleton** — strips solutions/proofs/hints, produces a `-skeleton.tex` worksheet
2. **Compile TeX** — runs the configured compiler (2 passes) on both main + skeleton files
3. **Parse to JSON** — converts the TeX document structure into `RawContentBlock[]` JSON (saved locally to `web/src/content/handouts/`)
4. **Upload images** — processes SVG images and uploads them to R2 (`handouts/images/`)
5. **Upload PDFs** — uploads compiled PDFs to R2 (`handouts/pdfs/`)
4. **Upload images** — processes SVG images and uploads them to R2 under `handouts/<slug>/<image>.svg`, where `<slug>` is the language-stripped handout id (so all language variants share one image set)
5. **Upload PDFs** — uploads compiled main + skeleton PDFs to R2 under `handouts/<slug>/<file>.pdf` (same folder as the images)

## Prerequisites

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,24 @@ export type ImageType = 'problems' | 'handouts'
export function getProblemImageUrl(contentId: string, type: ImageType): string {
switch (type) {
case 'handouts':
return `${getR2BaseUrl()}/handouts/images/${contentId}`
return `${getR2BaseUrl()}/handouts/${contentId}`
case 'problems':
return `${getApiBaseUrl()}/images/${type}/${contentId}`
}
}

/**
* Builds a public URL to a handout PDF by its filename.
* PDFs are served from Cloudflare R2.
* Builds a public URL to a handout PDF by its filename. The handout's
* language-stripped slug is derived from the filename — both `<slug>.<lang>.pdf`
* and `<slug>.<lang>-skeleton.pdf` collapse to the same slug so every artefact
* lives in one folder on R2.
*
* @param filename - The PDF filename (e.g., "factorization.sk.pdf")
* @returns The public URL to the PDF on R2
*/
export function getHandoutPdfUrl(filename: string): string {
return `${getR2BaseUrl()}/handouts/pdfs/${filename}`
const slug = filename.replace(/\.[a-z]{2}(-skeleton)?\.pdf$/i, '')
return `${getR2BaseUrl()}/handouts/${slug}/${filename}`
}

/**
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/shared/utils/__tests__/media-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,13 @@ describe('resolveMarkdownImageUrl', () => {
// Handouts use a different host than problems but the same contentId shape
it('routes a bare contentId to the R2 handouts endpoint', () => {
expect(resolveMarkdownImageUrl('media:fig-7', 'handouts')).toBe(
'https://r2.example.test/handouts/images/fig-7'
'https://r2.example.test/handouts/fig-7'
)
})

it('preserves a trailing query string after resolution', () => {
expect(resolveMarkdownImageUrl('media:fig-7?width=400&height=300', 'handouts')).toBe(
'https://r2.example.test/handouts/images/fig-7?width=400&height=300'
'https://r2.example.test/handouts/fig-7?width=400&height=300'
)
})
})
Expand Down
Loading
Loading