From 43b2a43ad5770f9d78a7ac21512562ed1f179149 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 11:58:55 +0100 Subject: [PATCH 1/5] Refactored templates into own dir and created default and cjk template --- src/{template.latex => templates/cjk.latex} | 0 src/templates/default.latex | 319 ++++++++++++++++++++ 2 files changed, 319 insertions(+) rename src/{template.latex => templates/cjk.latex} (100%) create mode 100644 src/templates/default.latex diff --git a/src/template.latex b/src/templates/cjk.latex similarity index 100% rename from src/template.latex rename to src/templates/cjk.latex diff --git a/src/templates/default.latex b/src/templates/default.latex new file mode 100644 index 0000000..17ee068 --- /dev/null +++ b/src/templates/default.latex @@ -0,0 +1,319 @@ +\documentclass[$if(fontsize)$$fontsize$,$endif$$if(lang)$$babel-lang$,$endif$$if(papersize)$$papersize$paper,$endif$$for(classoption)$$classoption$$sep$,$endfor$]{$documentclass$} +$if(fontfamily)$ +\usepackage[$for(fontfamilyoptions)$$fontfamilyoptions$$sep$,$endfor$]{$fontfamily$} +$else$ +\usepackage{lmodern} +$endif$ +$if(linestretch)$ +\usepackage{setspace} +\setstretch{$linestretch$} +$endif$ +\usepackage{braket,cancel} +\usepackage{amssymb,amsmath,mathtools} +\usepackage{ifxetex,ifluatex} +\usepackage{fixltx2e} % provides \textsubscript +\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex + \usepackage[$if(fontenc)$$fontenc$$else$T1$endif$]{fontenc} + \usepackage[utf8]{inputenc} +$if(euro)$ + \usepackage{eurosym} +$endif$ +\else % if luatex or xelatex + \ifxetex + \usepackage{mathspec} + \else + \usepackage{fontspec} + \fi + \defaultfontfeatures{Ligatures=TeX,Scale=MatchLowercase} +$for(fontfamilies)$ + \newfontfamily{$fontfamilies.name$}[$fontfamilies.options$]{$fontfamilies.font$} +$endfor$ +$if(euro)$ + \newcommand{\euro}{€} +$endif$ +$if(mainfont)$ + \setmainfont[$for(mainfontoptions)$$mainfontoptions$$sep$,$endfor$]{$mainfont$} +$endif$ +$if(sansfont)$ + \setsansfont[$for(sansfontoptions)$$sansfontoptions$$sep$,$endfor$]{$sansfont$} +$endif$ +$if(monofont)$ + \setmonofont[Mapping=tex-ansi$if(monofontoptions)$,$for(monofontoptions)$$monofontoptions$$sep$,$endfor$$endif$]{$monofont$} +$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$ +\fi +% use upquote if available, for straight quotes in verbatim environments +\IfFileExists{upquote.sty}{\usepackage{upquote}}{} +% use microtype if available +\IfFileExists{microtype.sty}{% +\usepackage{microtype} +\UseMicrotypeSet[protrusion]{basicmath} % disable protrusion for tt fonts +}{} +$if(geometry)$ +\usepackage[$for(geometry)$$geometry$$sep$,$endfor$]{geometry} +$endif$ +\usepackage[unicode=true]{hyperref} +$if(colorlinks)$ +\PassOptionsToPackage{usenames,dvipsnames}{color} % color is loaded by hyperref +$endif$ +\hypersetup{ +$if(title-meta)$ + pdftitle={$title-meta$}, +$endif$ +$if(author-meta)$ + pdfauthor={$author-meta$}, +$endif$ +$if(keywords)$ + pdfkeywords={$for(keywords)$$keywords$$sep$; $endfor$}, +$endif$ +$if(colorlinks)$ + colorlinks=true, + linkcolor=$if(linkcolor)$$linkcolor$$else$Maroon$endif$, + citecolor=$if(citecolor)$$citecolor$$else$Blue$endif$, + urlcolor=$if(urlcolor)$$urlcolor$$else$Blue$endif$, +$else$ + pdfborder={0 0 0}, +$endif$ + breaklinks=true} +\urlstyle{same} % don't use monospace font for urls +$if(lang)$ +\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex + \usepackage[shorthands=off,$for(babel-otherlangs)$$babel-otherlangs$,$endfor$main=$babel-lang$]{babel} +$if(babel-newcommands)$ + $babel-newcommands$ +$endif$ +\else + \usepackage{polyglossia} + \setmainlanguage[$polyglossia-lang.options$]{$polyglossia-lang.name$} +$for(polyglossia-otherlangs)$ + \setotherlanguage[$polyglossia-otherlangs.options$]{$polyglossia-otherlangs.name$} +$endfor$ +\fi +$endif$ +$if(natbib)$ +\usepackage{natbib} +\bibliographystyle{$if(biblio-style)$$biblio-style$$else$plainnat$endif$} +$endif$ +$if(biblatex)$ +\usepackage[$if(biblio-style)$style=$biblio-style$,$endif$$for(biblatexoptions)$$biblatexoptions$$sep$,$endfor$]{biblatex} +$for(bibliography)$ +\addbibresource{$bibliography$} +$endfor$ +$endif$ +$if(listings)$ +\usepackage{listings} +$endif$ +$if(lhs)$ +\lstnewenvironment{code}{\lstset{language=Haskell,basicstyle=\small\ttfamily}}{} +$endif$ +$if(highlighting-macros)$ +$highlighting-macros$ +$endif$ +$if(verbatim-in-note)$ +\usepackage{fancyvrb} +\VerbatimFootnotes % allows verbatim text in footnotes +$endif$ +$if(tables)$ +\usepackage{longtable,booktabs} +$endif$ +$if(graphics)$ +\usepackage{graphicx,grffile} +\makeatletter + +% Updating our template following changes to Pandoc +% https://github.com/jgm/pandoc/pull/9666/files + +\newsavebox\pandoc@box +\newcommand*\pandocbounded[1]{% scales image to fit in text height/width + \sbox\pandoc@box{#1}% + \Gscale@div\@tempa{\textheight}{\dimexpr\ht\pandoc@box+\dp\pandoc@box\relax}% + \Gscale@div\@tempb{\linewidth}{\wd\pandoc@box}% + \ifdim\@tempb\p@<\@tempa\p@\let\@tempa\@tempb\fi% select the smaller of both + \ifdim\@tempa\p@<\p@\scalebox{\@tempa}{\usebox\pandoc@box}% + \else\usebox{\pandoc@box}% + \fi% +} + +% End update + +$endif$ +$if(links-as-notes)$ +% Make links footnotes instead of hotlinks: +\renewcommand{\href}[2]{#2\footnote{\url{#1}}} +$endif$ +$if(strikeout)$ +\usepackage[normalem]{ulem} +% avoid problems with \sout in headers with hyperref: +\pdfstringdefDisableCommands{\renewcommand{\sout}{}} +$endif$ +$if(indent)$ +$else$ +\IfFileExists{parskip.sty}{% +\usepackage{parskip} +}{% else +\setlength{\parindent}{0pt} +\setlength{\parskip}{6pt plus 2pt minus 1pt} +} +$endif$ +\setlength{\emergencystretch}{3em} % prevent overfull lines +\providecommand{\tightlist}{% + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} +$if(numbersections)$ +\setcounter{secnumdepth}{$if(secnumdepth)$$secnumdepth$$else$5$endif$} +$else$ +\setcounter{secnumdepth}{0} +$endif$ +$if(subparagraph)$ +$else$ +% Redefines (sub)paragraphs to behave more like sections +\ifx\paragraph\undefined\else +\let\oldparagraph\paragraph +\renewcommand{\paragraph}[1]{\oldparagraph{#1}\mbox{}} +\fi +\ifx\subparagraph\undefined\else +\let\oldsubparagraph\subparagraph +\renewcommand{\subparagraph}[1]{\oldsubparagraph{#1}\mbox{}} +\fi +$endif$ +$if(dir)$ +\ifxetex + % load bidi as late as possible as it modifies e.g. graphicx + $if(latex-dir-rtl)$ + \usepackage[RTLdocument]{bidi} + $else$ + \usepackage{bidi} + $endif$ +\fi +\ifnum 0\ifxetex 1\fi\ifluatex 1\fi=0 % if pdftex + \TeXXeTstate=1 + \newcommand{\RL}[1]{\beginR #1\endR} + \newcommand{\LR}[1]{\beginL #1\endL} + \newenvironment{RTL}{\beginR}{\endR} + \newenvironment{LTR}{\beginL}{\endL} +\fi +$endif$ +$for(header-includes)$ +$header-includes$ +$endfor$ + +% Add table-related LaTeX settings +\usepackage{longtable,booktabs,array} +\usepackage{calc} +\renewcommand{\arraystretch}{1.2} % Adjust row height +\setlength{\tabcolsep}{4pt} % Adjust column spacing + +% Define new column types for wrapping text with fixed widths +\newcolumntype{P}[1]{>{\raggedright\arraybackslash}p{#1\textwidth}} % Left-aligned with wrapping + +$if(title)$ +\title{$title$$if(thanks)$\thanks{$thanks$}$endif$} +$endif$ +$if(subtitle)$ +\providecommand{\subtitle}[1]{} +\subtitle{$subtitle$} +$endif$ +$if(author)$ +\author{$for(author)$$author$$sep$ \and $endfor$} +$endif$ +$if(institute)$ +\institute{$for(institute)$$institute$$sep$ \and $endfor$} +$endif$ +\date{$date$} + +% Set images to stay in place +\usepackage{float} +\floatplacement{figure}{H} + +% Remove "Figure X." prefix from captions +\usepackage{caption} +\captionsetup[figure]{labelformat=empty} + +% Lambda customisation + +\let\OldRule\rule +\renewcommand{\rule}[2]{ +\OldRule{\linewidth}{#2}}% Emile Touber's horizontal rule + +\usepackage{xcolor} +\newcommand{\degree}{^\circ} % Katex commands +\newcommand{\infin}{\infty} % Katex commands + +\newcommand{\lambdalabel}[1]{} +\newcommand{\lambdastep}{\ifhmode\\\else\leavevmode\fi\textcolor[rgb]{0.9, 0.9, 0.9}{\rule{\linewidth}{0.5mm}}\par\nobreak} + +% Conditional inclusion of pifont package +\IfFileExists{pifont.sty}{% + \usepackage{pifont} % Include the pifont package for custom commands with five-pointed stars + \newcommand{\skilla}{\ding{72}} + \newcommand{\skillb}{\ding{73}} +}{% else + % Define your custom commands for the five-pointed stars without the package + \newcommand{\skilla}{*} + \newcommand{\skillb}{*} +} + + +\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! + + +\begin{document} +$if(title)$ +\maketitle +$endif$ +$if(abstract)$ +\begin{abstract} +$abstract$ +\end{abstract} +$endif$ + +$for(include-before)$ +$include-before$ + +$endfor$ +$if(toc)$ +{ +$if(colorlinks)$ +\hypersetup{linkcolor=$if(toccolor)$$toccolor$$else$black$endif$} +$endif$ +\setcounter{tocdepth}{$toc-depth$} +\tableofcontents +} +$endif$ +$if(lot)$ +\listoftables +$endif$ +$if(lof)$ +\listoffigures +$endif$ +$body$ + +$if(natbib)$ +$if(bibliography)$ +$if(biblio-title)$ +$if(book-class)$ +\renewcommand\bibname{$biblio-title$} +$else$ +\renewcommand\refname{$biblio-title$} +$endif$ +$endif$ +\bibliography{$for(bibliography)$$bibliography$$sep$,$endfor$} + +$endif$ +$endif$ +$if(biblatex)$ +\printbibliography$if(biblio-title)$[title=$biblio-title$]$endif$ + +$endif$ +$for(include-after)$ +$include-after$ + +$endfor$ +\end{document} From 55e7c14ce9e736831f501df2a9ce69129ab3583f Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 12:00:33 +0100 Subject: [PATCH 2/5] Updated endpoint to support templates --- index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 5796d72..a0ad5bb 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,12 @@ import { deleteFile, errorRefiner } from "./src/utils"; import { z } from "zod"; const TypeOfFileSchema = z.enum(["PDF", "TEX"]); +const TemplateSchema = z.enum(["default", "cjk"]); + +const templatePaths: Record, string> = { + default: "./templates/default.latex", + cjk: "./templates/cjk.latex", +}; export const schema = z.array( z.object({ @@ -15,6 +21,7 @@ export const schema = z.array( markdown: z.string(), implicitFigures: z.boolean().optional(), variables: z.record(z.string(), z.string()).optional(), + template: TemplateSchema.optional(), }) ); @@ -150,13 +157,14 @@ export const handler = async function ( const implicitFigures = eachRequestData.implicitFigures; const variableArgs = Object.entries(eachRequestData.variables ?? {}) .map(([k, v]) => `--variable=${k}:${v}`); + const templatePath = templatePaths[eachRequestData.template ?? "default"]; switch (eachRequestData.typeOfFile) { case "PDF": const filenamePDF = `${eachRequestData.fileName}.pdf`; const localPathPDF = `/tmp/${filenamePDF}`; const generatePDFResult = await generateFile( - ["--pdf-engine=xelatex", `--template=./template.latex`, ...variableArgs], + ["--pdf-engine=xelatex", `--template=${templatePath}`, ...variableArgs], localPathPDF, markdown, implicitFigures @@ -173,7 +181,7 @@ export const handler = async function ( const filenameTEX = `${eachRequestData.fileName}.tex`; const localPathTEX = `/tmp/${filenameTEX}`; await generateFile( - [`--template=./template.latex`, ...variableArgs], + [`--template=${templatePath}`, ...variableArgs], localPathTEX, markdown, implicitFigures From 8da1e84f345dc531dedfcfb3a1914f1416c5db6d Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 12:02:12 +0100 Subject: [PATCH 3/5] Updated Dockerfile to add all LaTeX templates --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 820f480..b6980a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,8 +52,8 @@ RUN dnf install -y \ google-noto-sans-cjk-ttc-fonts \ && fc-cache -fv -# Copy the LaTeX template -COPY ./src/template.latex template.latex +# Copy the LaTeX templates +COPY ./src/templates ./templates # Copy built files from the previous stage COPY --from=builder /app/dist/* ./ From 05b8e4ca386af36df415a23f6beb530a2714e06f Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 12:06:44 +0100 Subject: [PATCH 4/5] Updated tests to support new template parameter --- index.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/compile.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/index.test.ts b/index.test.ts index 4b67f56..5b6982e 100644 --- a/index.test.ts +++ b/index.test.ts @@ -62,6 +62,21 @@ describe("schema", () => { const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", variables: { lang: 42 } }]; expect(schema.safeParse(data).success).toBe(false); }); + + it("validates a request with template \"default\"", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", template: "default" }]; + expect(schema.safeParse(data).success).toBe(true); + }); + + it("validates a request with template \"cjk\"", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", template: "cjk" }]; + expect(schema.safeParse(data).success).toBe(true); + }); + + it("rejects an unknown template value", () => { + const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", template: "korean" }]; + expect(schema.safeParse(data).success).toBe(false); + }); }); describe("handler", () => { @@ -117,6 +132,28 @@ describe("handler", () => { expect(calledArgs).toContain("--variable=CJKmainfont:Noto Sans CJK KR"); }); + it("uses the default template when template is omitted", 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" }]; + await handler(event as any); + + const calledArgs: string[] = executeMock.mock.calls[0]?.[0]?.pandocArgs ?? []; + expect(calledArgs).toContain("--template=./templates/default.latex"); + }); + + it("uses the cjk template when template is \"cjk\"", 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", template: "cjk" }]; + await handler(event as any); + + const calledArgs: string[] = executeMock.mock.calls[0]?.[0]?.pandocArgs ?? []; + expect(calledArgs).toContain("--template=./templates/cjk.latex"); + }); + it("returns 500 when Pandoc execution fails", async () => { vi.mocked(PdcTs).mockImplementationOnce(() => ({ Execute: vi.fn() diff --git a/src/compile.test.ts b/src/compile.test.ts index bd6d01c..fbde4d7 100644 --- a/src/compile.test.ts +++ b/src/compile.test.ts @@ -8,18 +8,19 @@ import { PdcTs } from "pdc-ts"; // 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"); +// Absolute paths so Pandoc can locate the templates regardless of its working directory +const DEFAULT_TEMPLATE = path.resolve(__dirname, "templates/default.latex"); +const CJK_TEMPLATE = path.resolve(__dirname, "templates/cjk.latex"); const pendingPdfs: string[] = []; -const compileToPdf = async (markdown: string, id: string) => { +const compileToPdf = async (markdown: string, id: string, template: string = DEFAULT_TEMPLATE) => { const tmpPath = `/tmp/compile-test-${id}.pdf`; pendingPdfs.push(tmpPath); await new PdcTs().Execute({ from: "markdown", to: "latex", - pandocArgs: ["--pdf-engine=xelatex", `--template=${TEMPLATE}`], + pandocArgs: ["--pdf-engine=xelatex", `--template=${template}`], spawnOpts: { argv0: "+RTS -M512M -RTS" }, outputToFile: true, sourceText: markdown, @@ -84,4 +85,22 @@ describe("PDF compile (end-to-end pipeline)", () => { }, { timeout: 60_000 } ); + + it( + "renders Korean text via the cjk template", + async () => { + const pdf = await compileToPdf( + "# 수학 문서\n\n이차 방정식의 판별식은 $\\Delta = b^2 - 4ac$ 입니다.", + "korean", + CJK_TEMPLATE + ); + const text = extractText(pdf); + // pdftotext collapses inter-word spacing between Hangul syllable blocks, + // so match the individual words rather than the spaced phrase + expect(text).toContain("수학"); + expect(text).toContain("문서"); + expect(text).toContain("판별식"); + }, + { timeout: 60_000 } + ); }); \ No newline at end of file From 74f20466ba2fc0df446fe3c79337ca33b6d1e3b6 Mon Sep 17 00:00:00 2001 From: Marcus Messer Date: Fri, 17 Jul 2026 12:07:46 +0100 Subject: [PATCH 5/5] Updated README --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 2e54277..04691bf 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,37 @@ body: ] ``` +### Request fields + +| Field | Required | Description | +|---|---|---| +| `userId` | Yes | Used to namespace the generated file in S3 | +| `fileName` | Yes | Output file name, without extension | +| `typeOfFile` | Yes | `"PDF"` or `"TEX"` | +| `markdown` | Yes | Source document body | +| `implicitFigures` | No | Enables Pandoc's `implicit_figures` extension | +| `variables` | No | Map of Pandoc template variables, passed as `--variable=key:value` | +| `template` | No | `"default"` or `"cjk"` (see below). Defaults to `"default"` | + +### Templates + +Two LaTeX templates are available via the `template` field: + +- `"default"` (default) — the standard document template, sans-serif `lmodern` font. +- `"cjk"` — adds `xeCJK` support for Chinese/Japanese/Korean typesetting, with `Noto Sans` / `Noto Sans CJK KR` as the default fonts. Use this when the document contains CJK text. `mainfont`/`CJKmainfont` can still be overridden via `variables`. + +```json +[ + { + "userId":"c82da7d4-3295-4c4a-921b-7000d65224b6", + "fileName": "korean_doc", + "typeOfFile":"PDF", + "template":"cjk", + "markdown":"# 수학 문서\n\n이차 방정식의 판별식은 $\\Delta = b^2 - 4ac$ 입니다." + } +] +``` + ## Testing