Skip to content

Commit f1ebdd2

Browse files
authored
Merge pull request #76 from lambda-feedback/feature/unicode-support
Feature/unicode support
2 parents b2c472b + 98ccd3c commit f1ebdd2

10 files changed

Lines changed: 500 additions & 13 deletions

File tree

Dockerfile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,15 @@ RUN dnf install -y \
4242
texlive-collection-latexrecommended.noarch \
4343
texlive-iftex.noarch \
4444
texlive-braket.noarch \
45-
texlive-cancel.noarch
45+
texlive-cancel.noarch \
46+
texlive-xecjk.noarch \
47+
texlive-ctex.noarch
48+
49+
# Install Noto Sans fonts for Unicode rendering (Latin/Greek/Cyrillic + CJK)
50+
RUN dnf install -y \
51+
google-noto-sans-fonts \
52+
google-noto-sans-cjk-ttc-fonts \
53+
&& fc-cache -fv
4654

4755
# Copy the LaTeX template
4856
COPY ./src/template.latex template.latex

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,41 @@ body:
5252
```
5353

5454

55+
## Testing
56+
57+
### Dependencies
58+
59+
In addition to the runtime dependencies above, running the full test suite locally requires:
60+
61+
- [Pandoc](https://pandoc.org/installing.html) — for integration tests that verify markdown → LaTeX conversion
62+
- A TeX Live distribution with **xelatex** — for compile tests that produce real PDFs
63+
- [poppler-utils](https://poppler.freedesktop.org/) (`pdftotext`) — for content verification of compiled PDFs
64+
65+
On macOS these can be installed via Homebrew:
66+
```bash
67+
brew install pandoc mactex poppler
68+
brew install --cask font-noto-sans font-noto-sans-cjk
69+
```
70+
71+
### Running tests
72+
73+
```bash
74+
yarn test # type-check + all tests
75+
yarn test:unit # unit and integration tests only
76+
yarn test:types # TypeScript type-check only
77+
```
78+
79+
### Test structure
80+
81+
| File | Type | What it tests |
82+
|---|---|---|
83+
| `src/utils.test.ts` | Unit | `fixInlineLatex`, `errorRefiner`, `deleteFile` — pure function logic |
84+
| `index.test.ts` | Unit | Zod schema validation and Lambda handler routing (Pandoc mocked) |
85+
| `src/pandoc.test.ts` | Integration | Real Pandoc: markdown → LaTeX fragment output, math, `implicit_figures`, Unicode |
86+
| `src/compile.test.ts` | End-to-end | Full pipeline: Pandoc + `template.latex` + xelatex → PDF; content verified with `pdftotext` |
87+
88+
The compile tests take ~5–15 seconds each as they invoke xelatex.
89+
5590
## More information
5691

5792
https://github.com/lambda-feedback/technical-documentation/blob/main/docs/pdf_generator/index.md

index.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import * as fs from "fs";
3+
4+
vi.mock("fs", async (importOriginal) => {
5+
const actual = await importOriginal<typeof import("fs")>();
6+
return { ...actual, rm: vi.fn(), createReadStream: vi.fn() };
7+
});
8+
9+
vi.mock("pdc-ts", () => ({
10+
PdcTs: vi.fn().mockImplementation(() => ({
11+
Execute: vi.fn().mockResolvedValue(""),
12+
})),
13+
}));
14+
15+
vi.mock("@aws-sdk/client-s3", () => ({
16+
S3Client: vi.fn().mockImplementation(() => ({
17+
send: vi.fn().mockResolvedValue({}),
18+
})),
19+
PutObjectCommand: vi.fn().mockImplementation((params: unknown) => params),
20+
}));
21+
22+
import { schema, handler } from "./index";
23+
import { PdcTs } from "pdc-ts";
24+
25+
describe("schema", () => {
26+
it("validates a minimal valid PDF request", () => {
27+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "# Hello" }];
28+
expect(schema.safeParse(data).success).toBe(true);
29+
});
30+
31+
it("validates a TEX request with implicitFigures", () => {
32+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "TEX", markdown: "text", implicitFigures: true }];
33+
expect(schema.safeParse(data).success).toBe(true);
34+
});
35+
36+
it("rejects request missing userId", () => {
37+
const data = [{ fileName: "doc", typeOfFile: "PDF", markdown: "text" }];
38+
expect(schema.safeParse(data).success).toBe(false);
39+
});
40+
41+
it("rejects invalid typeOfFile value", () => {
42+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "DOCX", markdown: "text" }];
43+
expect(schema.safeParse(data).success).toBe(false);
44+
});
45+
46+
it("rejects request missing markdown", () => {
47+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF" }];
48+
expect(schema.safeParse(data).success).toBe(false);
49+
});
50+
51+
it("rejects a non-array input", () => {
52+
const data = { userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text" };
53+
expect(schema.safeParse(data).success).toBe(false);
54+
});
55+
56+
it("validates a request with a variables map", () => {
57+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", variables: { lang: "ko", CJKmainfont: "Noto Sans CJK KR" } }];
58+
expect(schema.safeParse(data).success).toBe(true);
59+
});
60+
61+
it("rejects variables with non-string values", () => {
62+
const data = [{ userId: "u1", fileName: "doc", typeOfFile: "PDF", markdown: "text", variables: { lang: 42 } }];
63+
expect(schema.safeParse(data).success).toBe(false);
64+
});
65+
});
66+
67+
describe("handler", () => {
68+
beforeEach(() => {
69+
process.env.PUBLIC_S3_BUCKET = "test-bucket";
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
vi.mocked(fs.createReadStream as any).mockReturnValue({} as any);
72+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73+
vi.mocked(fs.rm as any).mockImplementation((...args: any[]) => {
74+
const cb = args[args.length - 1];
75+
if (typeof cb === "function") cb(null);
76+
});
77+
});
78+
79+
afterEach(() => {
80+
vi.clearAllMocks();
81+
});
82+
83+
it("returns 400 when event is null", async () => {
84+
const result = await handler(null as any);
85+
expect(result.statusCode).toBe(400);
86+
});
87+
88+
it("returns 400 when payload does not match schema", async () => {
89+
const result = await handler({ invalid: true } as any);
90+
expect(result.statusCode).toBe(400);
91+
});
92+
93+
it("returns 200 with a URL for a valid PDF request", async () => {
94+
const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "PDF", markdown: "# Hello" }];
95+
const result = await handler(event as any);
96+
expect(result.statusCode).toBe(200);
97+
const body = JSON.parse(result.body) as { url: string };
98+
expect(body.url).toContain("test-doc.pdf");
99+
expect(body.url).toContain("test-bucket");
100+
});
101+
102+
it("returns 200 for a valid TEX request", async () => {
103+
const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "TEX", markdown: "# Hello" }];
104+
const result = await handler(event as any);
105+
expect(result.statusCode).toBe(200);
106+
});
107+
108+
it("passes variables as --variable flags to Pandoc", async () => {
109+
const executeMock = vi.fn().mockResolvedValue("");
110+
vi.mocked(PdcTs).mockImplementationOnce(() => ({ Execute: executeMock }) as any);
111+
112+
const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "TEX", markdown: "# Hello", variables: { lang: "ko", CJKmainfont: "Noto Sans CJK KR" } }];
113+
await handler(event as any);
114+
115+
const calledArgs: string[] = executeMock.mock.calls[0]?.[0]?.pandocArgs ?? [];
116+
expect(calledArgs).toContain("--variable=lang:ko");
117+
expect(calledArgs).toContain("--variable=CJKmainfont:Noto Sans CJK KR");
118+
});
119+
120+
it("returns 500 when Pandoc execution fails", async () => {
121+
vi.mocked(PdcTs).mockImplementationOnce(() => ({
122+
Execute: vi.fn()
123+
.mockRejectedValueOnce(new Error("Pandoc error l.1 bad token"))
124+
.mockResolvedValueOnce("line1\nline2\nline3"),
125+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
126+
}) as any);
127+
128+
const event = [{ userId: "user1", fileName: "test-doc", typeOfFile: "PDF", markdown: "# Hello" }];
129+
const result = await handler(event as any);
130+
expect(result.statusCode).toBe(500);
131+
});
132+
});

index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const schema = z.array(
1414
typeOfFile: TypeOfFileSchema,
1515
markdown: z.string(),
1616
implicitFigures: z.boolean().optional(),
17+
variables: z.record(z.string(), z.string()).optional(),
1718
})
1819
);
1920

@@ -147,13 +148,15 @@ export const handler = async function (
147148
for (let eachRequestData of requestData) {
148149
const markdown = eachRequestData.markdown;
149150
const implicitFigures = eachRequestData.implicitFigures;
151+
const variableArgs = Object.entries(eachRequestData.variables ?? {})
152+
.map(([k, v]) => `--variable=${k}:${v}`);
150153

151154
switch (eachRequestData.typeOfFile) {
152155
case "PDF":
153156
const filenamePDF = `${eachRequestData.fileName}.pdf`;
154157
const localPathPDF = `/tmp/${filenamePDF}`;
155158
const generatePDFResult = await generateFile(
156-
["--pdf-engine=xelatex", `--template=./template.latex`],
159+
["--pdf-engine=xelatex", `--template=./template.latex`, ...variableArgs],
157160
localPathPDF,
158161
markdown,
159162
implicitFigures
@@ -170,7 +173,7 @@ export const handler = async function (
170173
const filenameTEX = `${eachRequestData.fileName}.tex`;
171174
const localPathTEX = `/tmp/${filenameTEX}`;
172175
await generateFile(
173-
[`--template=./template.latex`],
176+
[`--template=./template.latex`, ...variableArgs],
174177
localPathTEX,
175178
markdown,
176179
implicitFigures

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"main": "index.js",
66
"scripts": {
77
"test:types": "tsc",
8-
"test": "yarn test:types",
8+
"test:unit": "vitest run",
9+
"test": "yarn test:types && yarn test:unit",
910
"build": "esbuild index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js"
1011
},
1112
"keywords": [],
@@ -19,6 +20,8 @@
1920
"devDependencies": {
2021
"@types/aws-lambda": "^8.10.137",
2122
"@types/node": "^20.12.2",
22-
"esbuild": "^0.20.2"
23+
"esbuild": "^0.20.2",
24+
"typescript": "^6.0.3",
25+
"vitest": "^2"
2326
}
2427
}

src/compile.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
import { execSync } from "child_process";
5+
import { PdcTs } from "pdc-ts";
6+
7+
// End-to-end compile tests — require xelatex (TeX Live) and Pandoc on PATH.
8+
// These run the full production pipeline: markdown → Pandoc + template.latex → xelatex → PDF.
9+
// They are intentionally slow (~5-15s per compile).
10+
11+
// Absolute path so Pandoc can locate the template regardless of its working directory
12+
const TEMPLATE = path.resolve(__dirname, "template.latex");
13+
14+
const pendingPdfs: string[] = [];
15+
16+
const compileToPdf = async (markdown: string, id: string) => {
17+
const tmpPath = `/tmp/compile-test-${id}.pdf`;
18+
pendingPdfs.push(tmpPath);
19+
await new PdcTs().Execute({
20+
from: "markdown",
21+
to: "latex",
22+
pandocArgs: ["--pdf-engine=xelatex", `--template=${TEMPLATE}`],
23+
spawnOpts: { argv0: "+RTS -M512M -RTS" },
24+
outputToFile: true,
25+
sourceText: markdown,
26+
destFilePath: tmpPath,
27+
});
28+
return tmpPath;
29+
};
30+
31+
const extractText = (pdfPath: string) =>
32+
execSync(`pdftotext "${pdfPath}" -`).toString();
33+
34+
afterEach(() => {
35+
for (const p of pendingPdfs.splice(0)) {
36+
try { fs.rmSync(p, { force: true }); } catch { /* ignore */ }
37+
}
38+
});
39+
40+
describe("PDF compile (end-to-end pipeline)", () => {
41+
it(
42+
"renders heading and paragraph text correctly",
43+
async () => {
44+
const pdf = await compileToPdf(
45+
"# Hello\n\nThis is a test document.",
46+
"basic"
47+
);
48+
const text = extractText(pdf);
49+
expect(text).toContain("Hello");
50+
expect(text).toContain("This is a test document");
51+
},
52+
{ timeout: 60_000 }
53+
);
54+
55+
it(
56+
"renders inline and display math without compilation errors",
57+
async () => {
58+
const pdf = await compileToPdf(
59+
"The value is $x^2 + 1$.\n\n$$\\int_0^1 x\\, dx = \\frac{1}{2}$$",
60+
"math"
61+
);
62+
// pdftotext cannot reliably extract math glyph sequences, so we verify
63+
// the surrounding prose appears and the file is non-trivially sized
64+
const text = extractText(pdf);
65+
expect(text).toContain("The value is");
66+
expect(fs.statSync(pdf).size).toBeGreaterThan(5000);
67+
},
68+
{ timeout: 60_000 }
69+
);
70+
71+
it(
72+
"renders Unicode Greek letters in prose correctly",
73+
async () => {
74+
const pdf = await compileToPdf(
75+
"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}$$",
76+
"unicode"
77+
);
78+
const text = extractText(pdf);
79+
// Greek letters used as prose text should survive rendering
80+
expect(text).toContain("α");
81+
expect(text).toContain("β");
82+
expect(text).toContain("Δ");
83+
expect(text).toContain("Σ");
84+
},
85+
{ timeout: 60_000 }
86+
);
87+
});

0 commit comments

Comments
 (0)