From a874a1789145b5f9e1254cdeaa17fedda16f2f5b Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 15:06:50 +0100 Subject: [PATCH 1/9] Added unit testing --- index.test.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 ++- src/utils.test.ts | 92 ++++++++++++++++++++++++++++++++++++++ src/utils.ts | 3 +- 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 index.test.ts create mode 100644 src/utils.test.ts diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..342eb1c --- /dev/null +++ b/index.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rm: vi.fn(), createReadStream: vi.fn() }; +}); + +vi.mock("pdc-ts", () => ({ + PdcTs: vi.fn().mockImplementation(() => ({ + Execute: vi.fn().mockResolvedValue(""), + })), +})); + +vi.mock("@aws-sdk/client-s3", () => ({ + S3Client: vi.fn().mockImplementation(() => ({ + send: vi.fn().mockResolvedValue({}), + })), + PutObjectCommand: vi.fn().mockImplementation((params: unknown) => params), +})); + +import { schema, handler } from "./index"; +import { PdcTs } from "pdc-ts"; + +describe("schema", () => { + it("validates a minimal valid PDF request", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "# Hello" }]; + expect(schema.safeParse(data).success).toBe(true); + }); + + it("validates a TEX request with implicitFigures", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "TEX", markdown: "text", implicitFigures: true }]; + expect(schema.safeParse(data).success).toBe(true); + }); + + it("rejects request missing userId", () => { + const data = [{ fileName: "doc", typeOfFile: "PDF", markdown: "text" }]; + expect(schema.safeParse(data).success).toBe(false); + }); + + it("rejects invalid typeOfFile value", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "DOCX", markdown: "text" }]; + expect(schema.safeParse(data).success).toBe(false); + }); + + it("rejects request missing markdown", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF" }]; + expect(schema.safeParse(data).success).toBe(false); + }); + + it("rejects a non-array input", () => { + const data = { userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text" }; + expect(schema.safeParse(data).success).toBe(false); + }); +}); + +describe("handler", () => { + beforeEach(() => { + process.env.PUBLIC_S3_BUCKET = "test-bucket"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.createReadStream as any).mockReturnValue({} as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.rm as any).mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(null); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when event is null", async () => { + const result = await handler(null as any); + expect(result.statusCode).toBe(400); + }); + + it("returns 400 when payload does not match schema", async () => { + const result = await handler({ invalid: true } as any); + expect(result.statusCode).toBe(400); + }); + + it("returns 200 with a URL for a valid PDF request", async () => { + const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "PDF", markdown: "# Hello" }]; + const result = await handler(event as any); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body) as { url: string }; + expect(body.url).toContain("test-doc.pdf"); + expect(body.url).toContain("test-bucket"); + }); + + it("returns 200 for a valid TEX request", async () => { + const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "TEX", markdown: "# Hello" }]; + const result = await handler(event as any); + expect(result.statusCode).toBe(200); + }); + + it("returns 500 when Pandoc execution fails", async () => { + vi.mocked(PdcTs).mockImplementationOnce(() => ({ + Execute: vi.fn() + .mockRejectedValueOnce(new Error("Pandoc error l.1 bad token")) + .mockResolvedValueOnce("line1\nline2\nline3"), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any); + + const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "PDF", markdown: "# Hello" }]; + const result = await handler(event as any); + expect(result.statusCode).toBe(500); + }); +}); diff --git a/package.json b/package.json index e50d9f3..a44ab77 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "index.js", "scripts": { "test:types": "tsc", - "test": "yarn test:types", + "test:unit": "vitest run", + "test": "yarn test:types && yarn test:unit", "build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js" }, "keywords": [], @@ -19,6 +20,8 @@ "devDependencies": { "@types/aws-lambda": "^8.10.137", "@types/node": "^20.12.2", - "esbuild": "^0.20.2" + "esbuild": "^0.20.2", + "typescript": "^6.0.3", + "vitest": "^2" } } diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..1e10345 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as fs from "fs"; +import { fixInlineLatex, errorRefiner, deleteFile } from "./utils"; + +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rm: vi.fn() }; +}); + +describe("fixInlineLatex", () => { + it("returns unchanged text when no $ signs are present", () => { + expect(fixInlineLatex("hello world")).toBe("hello world"); + }); + + it("removes trailing whitespace before closing $", () => { + expect(fixInlineLatex("$\\frac{T}{T_0} $")).toBe("$\\frac{T}{T_0}$"); + }); + + it("removes leading whitespace after opening $", () => { + expect(fixInlineLatex("$ \\frac{T}{T_0}$")).toBe("$\\frac{T}{T_0}$"); + }); + + it("replaces \\[...\\] delimiters with $", () => { + expect(fixInlineLatex("\\[x + y\\]")).toBe("$x + y$"); + }); + + it("replaces \\(...\\) delimiters with $", () => { + expect(fixInlineLatex("\\(x\\)")).toBe("$x$"); + }); + + it("fixes both legacy delimiters and adjacent whitespace", () => { + expect(fixInlineLatex("\\[x \\]")).toBe("$x$"); + }); +}); + +describe("errorRefiner", () => { + it("returns fallback message when error contains no line number", () => { + const result = errorRefiner("Pandoc: some undefined error", "\\documentclass{article}"); + expect(result).toContain("Further information could not be ascertained"); + }); + + it("identifies error location as 'start' when no labels exist in TeX", () => { + const texContent = "line1\nline2\nline3\nline4\nline5"; + const result = errorRefiner("! Error\nl.3 bad token", texContent); + expect(result).toContain("Qstart"); + }); + + it("identifies error in labelled section when \\lambdalabel is present", () => { + const texContent = "line1\n\\lambdalabel{Q3}\nline3\nline4\nline5"; + const result = errorRefiner("! Missing $\nl.4 bad token", texContent); + expect(result).toContain("Q3"); + }); + + it("includes debug info in output when showDebug is true", () => { + const result = errorRefiner("! Error\nl.1 bad", "some tex content", true); + expect(result).toContain("errorLine:"); + }); + + it("handles nearBeginning case by incrementing section index", () => { + // Error whose offending string is the label itself — section index should still resolve to Q5 + const texContent = "line1\n\\lambdalabel{Q5}\nline3\nline4\nline5"; + const result = errorRefiner("! Error\nl.2 \\lambdalabel{Q5}", texContent); + expect(result).toContain("Q5"); + }); +}); + +describe("deleteFile", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls fs.rm with the specified file path", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.rm as any).mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(null); + }); + deleteFile("/tmp/test.pdf"); + expect(fs.rm).toHaveBeenCalledWith("/tmp/test.pdf", expect.any(Function)); + }); + + it("logs an error when fs.rm callback receives an error", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.rm as any).mockImplementation((...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === "function") cb(new Error("ENOENT: no such file")); + }); + deleteFile("/tmp/nonexistent.pdf"); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index 042ff79..47a97dc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -68,8 +68,7 @@ export const errorRefiner = ( // Locate the error in the TeX file and identify the section let errorLocationInContents = - contentsPage.findIndex((element) => element[0] >= Number(errorLineString)) - - 1 ?? 0; + contentsPage.findIndex((element) => element[0] >= Number(errorLineString)) - 1; if (errorLocationInContents == -2) { // If the location wasn't found, then plant and return the message in the last row. contentsPage.push([contentsPage.length + 1, "Location not identified."]); From dc3ba020df54e59407979d3c403b1f0e2b34dd10 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 15:20:05 +0100 Subject: [PATCH 2/9] Added integration tests --- src/pandoc.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/pandoc.test.ts diff --git a/src/pandoc.test.ts b/src/pandoc.test.ts new file mode 100644 index 0000000..cf0581e --- /dev/null +++ b/src/pandoc.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { PdcTs } from "pdc-ts"; +import { fixInlineLatex } from "./utils"; + +// Integration tests — require Pandoc to be installed on PATH +const toLatex = (markdown: string, format = "markdown") => + new PdcTs().Execute({ + from: format, + to: "latex", + outputToFile: false, + sourceText: markdown, + }); + +describe("Pandoc markdown → LaTeX output", () => { + describe("markdown constructs", () => { + it("converts a heading to \\section", async () => { + const latex = await toLatex("# My Title"); + expect(latex).toContain("\\section{My Title}"); + }); + + it("converts bold to \\textbf", async () => { + const latex = await toLatex("**bold text**"); + expect(latex).toContain("\\textbf{bold text}"); + }); + + it("converts italic to \\emph", async () => { + const latex = await toLatex("*italic*"); + expect(latex).toContain("\\emph{italic}"); + }); + }); + + describe("math handling", () => { + it("converts inline math to \\(...\\) in LaTeX output", async () => { + const latex = await toLatex("The value is $x^2 + 1$."); + expect(latex).toContain("\\(x^2 + 1\\)"); + }); + + it("converts display math to \\[...\\]", async () => { + const latex = await toLatex("$$\\int_{0}^{1} x\\, dx$$"); + expect(latex).toContain("\\["); + expect(latex).toContain("\\int_{0}^{1}"); + }); + }); + + describe("implicit_figures extension", () => { + const imageMd = "![A caption](image.png)"; + + it("wraps images in a figure environment when enabled", async () => { + const latex = await toLatex(imageMd, "markdown+implicit_figures"); + expect(latex).toContain("\\begin{figure}"); + }); + + it("does not wrap images in a figure environment when disabled", async () => { + const latex = await toLatex(imageMd, "markdown-implicit_figures"); + expect(latex).not.toContain("\\begin{figure}"); + }); + }); + + describe("fixInlineLatex preprocessing", () => { + it("trailing space in math is fixed and Pandoc accepts the result", async () => { + const fixed = fixInlineLatex("$\\frac{T}{T_0} $"); + const latex = await toLatex(fixed); + expect(latex).toContain("\\frac"); + }); + + it("leading space in math is fixed and Pandoc accepts the result", async () => { + const fixed = fixInlineLatex("$ \\frac{T}{T_0}$"); + const latex = await toLatex(fixed); + expect(latex).toContain("\\frac"); + }); + + it("legacy \\[...\\] delimiters become inline math in LaTeX output", async () => { + // fixInlineLatex turns \[...\] into $...$ (inline math in markdown); + // Pandoc 3.x then emits \(...\) in LaTeX + const fixed = fixInlineLatex("\\[x + y\\]"); + const latex = await toLatex(fixed); + expect(latex).toContain("\\(x + y\\)"); + }); + }); +}); From 1116b5d5ab8681d7d712399f75d48e6b0068cd61 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 15:24:02 +0100 Subject: [PATCH 3/9] Expanded integration test to include unicode characters --- src/pandoc.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/pandoc.test.ts b/src/pandoc.test.ts index cf0581e..38ce82b 100644 --- a/src/pandoc.test.ts +++ b/src/pandoc.test.ts @@ -77,4 +77,37 @@ describe("Pandoc markdown → LaTeX output", () => { expect(latex).toContain("\\(x + y\\)"); }); }); + + describe("unicode characters", () => { + it("passes Greek letters in prose through to LaTeX", async () => { + const latex = await toLatex("Lowercase: α, β, γ, Δ, Σ"); + expect(latex).toContain("α"); + expect(latex).toContain("Δ"); + }); + + it("converts en dash to -- in LaTeX output", async () => { + // Pandoc's smart extension normalises the Unicode en dash to -- + const latex = await toLatex("pages 10–20"); + expect(latex).toContain("--"); + }); + + it("converts em dash to --- in LaTeX output", async () => { + // Pandoc's smart extension normalises the Unicode em dash to --- + const latex = await toLatex("remark—here"); + expect(latex).toContain("---"); + }); + + it("preserves Greek math commands in LaTeX output", async () => { + const latex = await toLatex("$\\alpha + \\beta = \\gamma$"); + expect(latex).toContain("\\alpha"); + }); + + it("handles mixed prose Unicode and LaTeX math commands", async () => { + const latex = await toLatex( + "Unicode Greek: α, β. Discriminant $\\Delta = b^2 - 4ac$." + ); + expect(latex).toContain("α"); + expect(latex).toContain("\\Delta"); + }); + }); }); From 3fc98e0e5f135c45a0a3eca14ab995a72122d0dd Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 15:42:30 +0100 Subject: [PATCH 4/9] Expanded integration test to read text from PDF --- src/compile.test.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/compile.test.ts diff --git a/src/compile.test.ts b/src/compile.test.ts new file mode 100644 index 0000000..bd6d01c --- /dev/null +++ b/src/compile.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import { execSync } from "child_process"; +import { PdcTs } from "pdc-ts"; + +// End-to-end compile tests — require xelatex (TeX Live) and Pandoc on PATH. +// These run the full production pipeline: markdown → Pandoc + template.latex → xelatex → PDF. +// They are intentionally slow (~5-15s per compile). + +// Absolute path so Pandoc can locate the template regardless of its working directory +const TEMPLATE = path.resolve(__dirname, "template.latex"); + +const pendingPdfs: string[] = []; + +const compileToPdf = async (markdown: string, id: string) => { + const tmpPath = `/tmp/compile-test-${id}.pdf`; + pendingPdfs.push(tmpPath); + await new PdcTs().Execute({ + from: "markdown", + to: "latex", + pandocArgs: ["--pdf-engine=xelatex", `--template=${TEMPLATE}`], + spawnOpts: { argv0: "+RTS -M512M -RTS" }, + outputToFile: true, + sourceText: markdown, + destFilePath: tmpPath, + }); + return tmpPath; +}; + +const extractText = (pdfPath: string) => + execSync(`pdftotext "${pdfPath}" -`).toString(); + +afterEach(() => { + for (const p of pendingPdfs.splice(0)) { + try { fs.rmSync(p, { force: true }); } catch { /* ignore */ } + } +}); + +describe("PDF compile (end-to-end pipeline)", () => { + it( + "renders heading and paragraph text correctly", + async () => { + const pdf = await compileToPdf( + "# Hello\n\nThis is a test document.", + "basic" + ); + const text = extractText(pdf); + expect(text).toContain("Hello"); + expect(text).toContain("This is a test document"); + }, + { timeout: 60_000 } + ); + + it( + "renders inline and display math without compilation errors", + async () => { + const pdf = await compileToPdf( + "The value is $x^2 + 1$.\n\n$$\\int_0^1 x\\, dx = \\frac{1}{2}$$", + "math" + ); + // pdftotext cannot reliably extract math glyph sequences, so we verify + // the surrounding prose appears and the file is non-trivially sized + const text = extractText(pdf); + expect(text).toContain("The value is"); + expect(fs.statSync(pdf).size).toBeGreaterThan(5000); + }, + { timeout: 60_000 } + ); + + it( + "renders Unicode Greek letters in prose correctly", + async () => { + const pdf = await compileToPdf( + "Unicode Greek: α, β, γ, Δ, Σ.\n\nDiscriminant $\\Delta = b^2 - 4ac$ where $\\alpha, \\beta \\in \\mathbb{R}$.\n\n$$\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}$$", + "unicode" + ); + const text = extractText(pdf); + // Greek letters used as prose text should survive rendering + expect(text).toContain("α"); + expect(text).toContain("β"); + expect(text).toContain("Δ"); + expect(text).toContain("Σ"); + }, + { timeout: 60_000 } + ); +}); \ No newline at end of file From ae6fb163d252d39d99ec26403e5db187f512509d Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 15:46:32 +0100 Subject: [PATCH 5/9] Updated README to include dependencies and testing information --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 218ad57..239030d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,40 @@ body: ``` +## Testing + +### Dependencies + +In addition to the runtime dependencies above, running the full test suite locally requires: + +- [Pandoc](https://pandoc.org/installing.html) — for integration tests that verify markdown → LaTeX conversion +- A TeX Live distribution with **xelatex** — for compile tests that produce real PDFs +- [poppler-utils](https://poppler.freedesktop.org/) (`pdftotext`) — for content verification of compiled PDFs + +On macOS these can be installed via Homebrew: +```bash +brew install pandoc mactex poppler +``` + +### Running tests + +```bash +yarn test # type-check + all tests +yarn test:unit # unit and integration tests only +yarn test:types # TypeScript type-check only +``` + +### Test structure + +| File | Type | What it tests | +|---|---|---| +| `src/utils.test.ts` | Unit | `fixInlineLatex`, `errorRefiner`, `deleteFile` — pure function logic | +| `index.test.ts` | Unit | Zod schema validation and Lambda handler routing (Pandoc mocked) | +| `src/pandoc.test.ts` | Integration | Real Pandoc: markdown → LaTeX fragment output, math, `implicit_figures`, Unicode | +| `src/compile.test.ts` | End-to-end | Full pipeline: Pandoc + `template.latex` + xelatex → PDF; content verified with `pdftotext` | + +The compile tests take ~5–15 seconds each as they invoke xelatex. + ## More information https://github.com/lambda-feedback/technical-documentation/blob/main/docs/pdf_generator/index.md From eb0cf2f868773df293ecc262408c9f059739260c Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 17:23:22 +0100 Subject: [PATCH 6/9] Switched fonts to support other languages --- Dockerfile | 17 ++++++++++++++++- README.md | 1 + index.test.ts | 22 ++++++++++++++++++++++ index.ts | 7 +++++-- src/pandoc.test.ts | 15 +++++++++++++++ src/template.latex | 12 ++++++------ 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index f282451..8a412db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,22 @@ RUN dnf install -y \ texlive-collection-latexrecommended.noarch \ texlive-iftex.noarch \ texlive-braket.noarch \ - texlive-cancel.noarch + texlive-cancel.noarch \ + texlive-xecjk.noarch + +# Install Noto Sans fonts: base (Latin/Greek/Cyrillic) + CJK (Korean/Chinese/Japanese) +# These are the default fonts used by the template for broad Unicode coverage. +RUN curl -fsSL https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSans-v2.013/NotoSans-v2.013.zip \ + -o /tmp/NotoSans.zip \ + && unzip -j /tmp/NotoSans.zip "*/unhinted/ttf/NotoSans-Regular.ttf" \ + "*/unhinted/ttf/NotoSans-Bold.ttf" \ + "*/unhinted/ttf/NotoSans-Italic.ttf" \ + "*/unhinted/ttf/NotoSans-BoldItalic.ttf" \ + -d /usr/share/fonts/noto \ + && rm /tmp/NotoSans.zip \ + && curl -fsSL https://github.com/googlefonts/noto-cjk/releases/download/Sans2.004R/NotoSansCJKkr-Regular.otf \ + -o /usr/share/fonts/noto/NotoSansCJKkr-Regular.otf \ + && fc-cache -fv # Copy the LaTeX template COPY ./src/template.latex template.latex diff --git a/README.md b/README.md index 239030d..2e54277 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ In addition to the runtime dependencies above, running the full test suite local On macOS these can be installed via Homebrew: ```bash brew install pandoc mactex poppler +brew install --cask font-noto-sans font-noto-sans-cjk ``` ### Running tests diff --git a/index.test.ts b/index.test.ts index 342eb1c..4b67f56 100644 --- a/index.test.ts +++ b/index.test.ts @@ -52,6 +52,16 @@ describe("schema", () => { const data = { userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text" }; expect(schema.safeParse(data).success).toBe(false); }); + + it("validates a request with a variables map", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", variables: { lang: "ko", CJKmainfont: "Noto Sans CJK KR" } }]; + expect(schema.safeParse(data).success).toBe(true); + }); + + it("rejects variables with non-string values", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", variables: { lang: 42 } }]; + expect(schema.safeParse(data).success).toBe(false); + }); }); describe("handler", () => { @@ -95,6 +105,18 @@ describe("handler", () => { expect(result.statusCode).toBe(200); }); + it("passes variables as --variable flags to Pandoc", async () => { + const executeMock = vi.fn().mockResolvedValue(""); + vi.mocked(PdcTs).mockImplementationOnce(() => ({ Execute: executeMock }) as any); + + const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "TEX", markdown: "# Hello", variables: { lang: "ko", CJKmainfont: "Noto Sans CJK KR" } }]; + await handler(event as any); + + const calledArgs: string[] = executeMock.mock.calls[0]?.[0]?.pandocArgs ?? []; + expect(calledArgs).toContain("--variable=lang:ko"); + expect(calledArgs).toContain("--variable=CJKmainfont:Noto Sans CJK KR"); + }); + it("returns 500 when Pandoc execution fails", async () => { vi.mocked(PdcTs).mockImplementationOnce(() => ({ Execute: vi.fn() diff --git a/index.ts b/index.ts index e120bf5..5796d72 100644 --- a/index.ts +++ b/index.ts @@ -14,6 +14,7 @@ export const schema = z.array( typeOfFile: TypeOfFileSchema, markdown: z.string(), implicitFigures: z.boolean().optional(), + variables: z.record(z.string(), z.string()).optional(), }) ); @@ -147,13 +148,15 @@ export const handler = async function ( for (let eachRequestData of requestData) { const markdown = eachRequestData.markdown; const implicitFigures = eachRequestData.implicitFigures; + const variableArgs = Object.entries(eachRequestData.variables ?? {}) + .map(([k, v]) => `--variable=${k}:${v}`); switch (eachRequestData.typeOfFile) { case "PDF": const filenamePDF = `${eachRequestData.fileName}.pdf`; const localPathPDF = `/tmp/${filenamePDF}`; const generatePDFResult = await generateFile( - ["--pdf-engine=xelatex", `--template=./template.latex`], + ["--pdf-engine=xelatex", `--template=./template.latex`, ...variableArgs], localPathPDF, markdown, implicitFigures @@ -170,7 +173,7 @@ export const handler = async function ( const filenameTEX = `${eachRequestData.fileName}.tex`; const localPathTEX = `/tmp/${filenameTEX}`; await generateFile( - [`--template=./template.latex`], + [`--template=./template.latex`, ...variableArgs], localPathTEX, markdown, implicitFigures diff --git a/src/pandoc.test.ts b/src/pandoc.test.ts index 38ce82b..53ddc04 100644 --- a/src/pandoc.test.ts +++ b/src/pandoc.test.ts @@ -78,6 +78,21 @@ describe("Pandoc markdown → LaTeX output", () => { }); }); + describe("pandoc variables", () => { + it("accepts a --variable flag without error", async () => { + // Verifies the variable-passing mechanism works; mainfont is a safe no-op variable + // since the template only applies it under XeLaTeX, which isn't invoked here + const latex = await new PdcTs().Execute({ + from: "markdown", + to: "latex", + outputToFile: false, + sourceText: "# Hello", + pandocArgs: ["--variable=mainfont:Latin Modern Roman"], + }); + expect(latex).toContain("\\section{Hello}"); + }); + }); + describe("unicode characters", () => { it("passes Greek letters in prose through to LaTeX", async () => { const latex = await toLatex("Lowercase: α, β, γ, Δ, Σ"); diff --git a/src/template.latex b/src/template.latex index 17ee068..b4f5ae9 100644 --- a/src/template.latex +++ b/src/template.latex @@ -33,6 +33,8 @@ $if(euro)$ $endif$ $if(mainfont)$ \setmainfont[$for(mainfontoptions)$$mainfontoptions$$sep$,$endfor$]{$mainfont$} +$else$ + \setmainfont{Noto Sans} $endif$ $if(sansfont)$ \setsansfont[$for(sansfontoptions)$$sansfontoptions$$sep$,$endfor$]{$sansfont$} @@ -43,10 +45,8 @@ $endif$ $if(mathfont)$ \setmathfont(Digits,Latin,Greek)[$for(mathfontoptions)$$mathfontoptions$$sep$,$endfor$]{$mathfont$} $endif$ -$if(CJKmainfont)$ - \usepackage{xeCJK} - \setCJKmainfont[$for(CJKoptions)$$CJKoptions$$sep$,$endfor$]{$CJKmainfont$} -$endif$ +\usepackage{xeCJK} +\setCJKmainfont[$for(CJKoptions)$$CJKoptions$$sep$,$endfor$]{$if(CJKmainfont)$$CJKmainfont$$else$Noto Sans CJK KR$endif$} \fi % use upquote if available, for straight quotes in verbatim environments \IfFileExists{upquote.sty}{\usepackage{upquote}}{} @@ -260,8 +260,8 @@ $endif$ \usepackage[a4paper,top=2cm,bottom=2cm,left=2cm,right=2cm]{geometry}% -\usepackage{lmodern}% font style must be sans serif to comply with -\renewcommand*\familydefault{\sfdefault}% the college's regulations with respect to disabilities! +% Noto Sans (sans-serif) is set as the default mainfont above, satisfying the +% college's accessibility requirement for sans-serif fonts throughout the document. \begin{document} From e2b3501f3dca21b574898be86d448dbd862cdd99 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 17:41:15 +0100 Subject: [PATCH 7/9] Added unzip to the image --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8a412db..09ac370 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,8 @@ RUN dnf install -y \ texlive-iftex.noarch \ texlive-braket.noarch \ texlive-cancel.noarch \ - texlive-xecjk.noarch + texlive-xecjk.noarch \ + unzip # Install Noto Sans fonts: base (Latin/Greek/Cyrillic) + CJK (Korean/Chinese/Japanese) # These are the default fonts used by the template for broad Unicode coverage. From 37eb09e88927bbd391bd973d4b2b90dad03e576b Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 18:02:05 +0100 Subject: [PATCH 8/9] Fixed font install --- Dockerfile | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 09ac370..56cd5cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,21 +43,12 @@ RUN dnf install -y \ texlive-iftex.noarch \ texlive-braket.noarch \ texlive-cancel.noarch \ - texlive-xecjk.noarch \ - unzip - -# Install Noto Sans fonts: base (Latin/Greek/Cyrillic) + CJK (Korean/Chinese/Japanese) -# These are the default fonts used by the template for broad Unicode coverage. -RUN curl -fsSL https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSans-v2.013/NotoSans-v2.013.zip \ - -o /tmp/NotoSans.zip \ - && unzip -j /tmp/NotoSans.zip "*/unhinted/ttf/NotoSans-Regular.ttf" \ - "*/unhinted/ttf/NotoSans-Bold.ttf" \ - "*/unhinted/ttf/NotoSans-Italic.ttf" \ - "*/unhinted/ttf/NotoSans-BoldItalic.ttf" \ - -d /usr/share/fonts/noto \ - && rm /tmp/NotoSans.zip \ - && curl -fsSL https://github.com/googlefonts/noto-cjk/releases/download/Sans2.004R/NotoSansCJKkr-Regular.otf \ - -o /usr/share/fonts/noto/NotoSansCJKkr-Regular.otf \ + texlive-xecjk.noarch + +# Install Noto Sans fonts for Unicode rendering (Latin/Greek/Cyrillic + CJK) +RUN dnf install -y \ + google-noto-sans-fonts \ + google-noto-sans-cjk-ttc-fonts \ && fc-cache -fv # Copy the LaTeX template From 98ccd3c73712e7a02919f8750544d819c6e7428f Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Tue, 2 Jun 2026 18:50:09 +0100 Subject: [PATCH 9/9] Fixed Dockerfile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 56cd5cc..820f480 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,8 @@ RUN dnf install -y \ texlive-iftex.noarch \ texlive-braket.noarch \ texlive-cancel.noarch \ - texlive-xecjk.noarch + texlive-xecjk.noarch \ + texlive-ctex.noarch # Install Noto Sans fonts for Unicode rendering (Latin/Greek/Cyrillic + CJK) RUN dnf install -y \