Skip to content

Commit fa2773a

Browse files
committed
Add theming support with --theme CLI option
- Add --theme command line option to specify custom CSS file - Modify PDF generation to apply custom themes - Update tests to support theme parameter - Create comprehensive theme integration test - Add colorful test theme with gradients and vibrant colors - Move theme fixtures to dedicated tests/fixtures/theme/ directory
1 parent a370410 commit fa2773a

8 files changed

Lines changed: 348 additions & 20 deletions

File tree

src/index.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { cleanupTempFiles, createTempDirectory } from "./utils/files";
77
export async function convertMarkdownToPdf(
88
inputPath: string,
99
outputPath: string,
10-
debugHtml: boolean = false
10+
debugHtml: boolean = false,
11+
themePath?: string
1112
): Promise<void> {
1213
try {
1314
const markdown = fs.readFileSync(inputPath, "utf-8");
@@ -17,7 +18,13 @@ export async function convertMarkdownToPdf(
1718
const chunks = parseMarkdownIntoChunks(markdown);
1819

1920
// Process chunks into PDF sources (pass debugHtml to emit intermediate HTML files)
20-
const pdfsToMerge = await processChunksToPdfSources(chunks, inputPath, tempDir, debugHtml);
21+
const pdfsToMerge = await processChunksToPdfSources(
22+
chunks,
23+
inputPath,
24+
tempDir,
25+
debugHtml,
26+
themePath
27+
);
2128

2229
if (pdfsToMerge.length === 0) {
2330
throw new Error("No valid content or PDFs to process.");
@@ -46,25 +53,29 @@ program
4653
.description("Convert markdown files to PDF, resolving relative links and embedding PDFs")
4754
.version("1.0.0")
4855
.option("--debug-html", "Write intermediate HTML files next to generated PDFs")
56+
.option("--theme <path>", "Path to a custom CSS file for styling")
4957
.argument("<input>", "Path to the markdown file")
5058
.argument(
5159
"[output]",
5260
"Path to the output PDF file (optional, defaults to <markdown-name>-generated.pdf)"
5361
)
54-
.action(async (input: string, output?: string, options?: { debugHtml?: boolean }) => {
55-
try {
56-
// If output is not provided, generate default path
57-
if (!output) {
58-
const inputDir = input.substring(0, input.lastIndexOf("/") + 1 || 0);
59-
const inputBasename = input.substring(input.lastIndexOf("/") + 1).replace(/\.md$/, "");
60-
output = `${inputDir}${inputBasename}-generated.pdf`;
62+
.action(
63+
async (input: string, output?: string, options?: { debugHtml?: boolean; theme?: string }) => {
64+
try {
65+
// If output is not provided, generate default path
66+
if (!output) {
67+
const inputDir = input.substring(0, input.lastIndexOf("/") + 1 || 0);
68+
const inputBasename = input.substring(input.lastIndexOf("/") + 1).replace(/\.md$/, "");
69+
output = `${inputDir}${inputBasename}-generated.pdf`;
70+
}
71+
const debugHtml = !!(options && options.debugHtml);
72+
const theme = options && options.theme;
73+
await convertMarkdownToPdf(input, output, debugHtml, theme);
74+
} catch (error) {
75+
console.error("Error:", error);
76+
process.exit(1);
6177
}
62-
const debugHtml = !!(options && options.debugHtml);
63-
await convertMarkdownToPdf(input, output, debugHtml);
64-
} catch (error) {
65-
console.error("Error:", error);
66-
process.exit(1);
6778
}
68-
});
79+
);
6980

7081
program.parse();

src/utils/pdf.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@ describe("pdf utils", () => {
8181
</script>
8282
</body>
8383
</html>`);
84-
readFileMock.mockReturnValueOnce("body { color: red; }");
8584

8685
const html = "<p>Hello</p>";
8786
const outputFilePath = "/path/to/output.pdf";
87+
const css = "body { color: red; }";
8888

89-
await pdf.convertHtmlToPdf(html, outputFilePath);
89+
await pdf.convertHtmlToPdf(html, outputFilePath, css);
9090

9191
expect(puppeteer.launch).toHaveBeenCalled();
9292
expect(mockBrowser.newPage).toHaveBeenCalled();
@@ -203,6 +203,9 @@ describe("pdf utils", () => {
203203
describe("processChunksToPdfSources", () => {
204204
test("should process chunks and return PDF sources", async () => {
205205
(fs.promises.access as Mock<typeof fs.promises.access>).mockResolvedValue();
206+
// Mock readFileSync to return a CSS string
207+
(fs.readFileSync as Mock<typeof fs.readFileSync>).mockReturnValue("body { color: blue; }");
208+
206209
const chunks: Chunk[] = [
207210
{ type: "pdf", path: "1.pdf", pageOptions: { include: [1] } },
208211
{ type: "markdown", content: "## Hello" },
@@ -220,6 +223,9 @@ describe("pdf utils", () => {
220223
(fs.promises.access as Mock<typeof fs.promises.access>).mockRejectedValue(
221224
new Error("File not found")
222225
);
226+
// Mock readFileSync to return a CSS string
227+
(fs.readFileSync as Mock<typeof fs.readFileSync>).mockReturnValue("body { color: blue; }");
228+
223229
const consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
224230

225231
const chunks: Chunk[] = [{ type: "pdf", path: "nonexistent.pdf" }];

src/utils/pdf.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import templateFile from "../templates/base.html" with { type: "file" };
1919
export async function convertHtmlToPdf(
2020
html: string,
2121
outputFilePath: string,
22+
css: string,
2223
debugDumpHtml: boolean = false,
2324
baseDir?: string
2425
): Promise<void> {
@@ -37,7 +38,6 @@ export async function convertHtmlToPdf(
3738

3839
// @ts-expect-error CSS import fails here unfortunately
3940
const template = fs.readFileSync(templateFile, "utf-8");
40-
const css = fs.readFileSync(defaultCssFile, "utf-8");
4141
let finalHtml = template.replace("{{content}}", html).replace("{{css}}", css);
4242

4343
// Determine a base URL for resolving relative resources (images, etc).
@@ -136,11 +136,19 @@ export async function processChunksToPdfSources(
136136
chunks: Chunk[],
137137
inputPath: string,
138138
tmpDir: string,
139-
debugDumpHtml: boolean = false
139+
debugDumpHtml: boolean = false,
140+
themePath?: string
140141
): Promise<PdfSource[]> {
141142
const pdfsToMerge: PdfSource[] = [];
142143
let tempPdfCounter = 0;
143144

145+
let css: string;
146+
if (themePath) {
147+
css = fs.readFileSync(themePath, "utf-8");
148+
} else {
149+
css = fs.readFileSync(defaultCssFile, "utf-8");
150+
}
151+
144152
for (const chunk of chunks) {
145153
if (chunk.type === "pdf") {
146154
const pdfPath = path.resolve(path.dirname(inputPath), chunk.path);
@@ -156,7 +164,7 @@ export async function processChunksToPdfSources(
156164
const resolvedContent = resolveLinks(chunk.content, path.dirname(inputPath));
157165
const html = convertMarkdownToHtml(resolvedContent);
158166
const tempPdfPath = path.join(tmpDir, `temp_${tempPdfCounter++}.pdf`);
159-
await convertHtmlToPdf(html, tempPdfPath, debugDumpHtml, path.dirname(inputPath));
167+
await convertHtmlToPdf(html, tempPdfPath, css, debugDumpHtml, path.dirname(inputPath));
160168
pdfsToMerge.push({ path: tempPdfPath });
161169
}
162170
}
462 KB
Binary file not shown.
96.9 KB
Loading
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
body {
2+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
3+
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif !important;
4+
margin: 40px !important;
5+
padding: 30px !important;
6+
border: 3px solid #ff6b6b !important;
7+
border-radius: 15px !important;
8+
color: #ffffff !important;
9+
}
10+
11+
h1 {
12+
color: #ffd93d !important;
13+
text-decoration: none !important;
14+
font-weight: bold !important;
15+
margin-top: 1.5em !important;
16+
margin-bottom: 0.5em !important;
17+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3) !important;
18+
font-size: 2.5em !important;
19+
}
20+
21+
h2 {
22+
color: #6bcf7f !important;
23+
text-decoration: none !important;
24+
font-weight: bold !important;
25+
margin-top: 1.5em !important;
26+
margin-bottom: 0.5em !important;
27+
border-bottom: 3px solid #4ecdc4 !important;
28+
padding-bottom: 0.3em !important;
29+
}
30+
31+
h3 {
32+
color: #4ecdc4 !important;
33+
text-decoration: none !important;
34+
font-weight: bold !important;
35+
margin-top: 1.5em !important;
36+
margin-bottom: 0.5em !important;
37+
}
38+
39+
h4 {
40+
color: #45b7d1 !important;
41+
text-decoration: none !important;
42+
font-weight: bold !important;
43+
margin-top: 1.5em !important;
44+
margin-bottom: 0.5em !important;
45+
}
46+
47+
h5 {
48+
color: #f9ca24 !important;
49+
text-decoration: none !important;
50+
font-weight: bold !important;
51+
margin-top: 1.5em !important;
52+
margin-bottom: 0.5em !important;
53+
}
54+
55+
h6 {
56+
color: #f0932b !important;
57+
text-decoration: none !important;
58+
font-weight: bold !important;
59+
margin-top: 1.5em !important;
60+
margin-bottom: 0.5em !important;
61+
}
62+
63+
p {
64+
line-height: 1.8 !important;
65+
color: #ffffff !important;
66+
text-align: justify !important;
67+
margin-bottom: 1em !important;
68+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2) !important;
69+
}
70+
71+
code {
72+
background-color: #2d3748 !important;
73+
border: 2px solid #e53e3e !important;
74+
border-radius: 8px !important;
75+
padding: 4px 8px !important;
76+
font-family: "Fira Code", "Courier New", monospace !important;
77+
color: #68d391 !important;
78+
font-weight: bold !important;
79+
}
80+
81+
pre {
82+
background: linear-gradient(45deg, #2d3748, #4a5568) !important;
83+
border: 2px solid #3182ce !important;
84+
border-radius: 10px !important;
85+
padding: 15px !important;
86+
overflow-x: auto !important;
87+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
88+
}
89+
90+
pre code {
91+
background: none !important;
92+
border: none !important;
93+
padding: 0 !important;
94+
color: #9ae6b4 !important;
95+
}
96+
97+
ul,
98+
ol {
99+
margin-left: 20px !important;
100+
line-height: 1.8 !important;
101+
}
102+
103+
li {
104+
margin-bottom: 0.5em !important;
105+
color: #ffffff !important;
106+
}
107+
108+
blockquote {
109+
border-left: 6px solid #ffd93d !important;
110+
padding-left: 15px !important;
111+
font-style: italic !important;
112+
color: #e2e8f0 !important;
113+
background-color: rgba(255, 217, 61, 0.1) !important;
114+
padding: 15px !important;
115+
border-radius: 8px !important;
116+
margin: 1em 0 !important;
117+
}
118+
119+
a {
120+
color: #63b3ed !important;
121+
text-decoration: none !important;
122+
font-weight: bold !important;
123+
border-bottom: 2px solid #63b3ed !important;
124+
transition: all 0.3s ease !important;
125+
}
126+
127+
a:hover {
128+
color: #4299e1 !important;
129+
border-bottom-color: #4299e1 !important;
130+
}
131+
132+
table {
133+
border-collapse: collapse !important;
134+
width: 100% !important;
135+
margin-bottom: 1em !important;
136+
background-color: rgba(255, 255, 255, 0.1) !important;
137+
border-radius: 8px !important;
138+
overflow: hidden !important;
139+
}
140+
141+
th,
142+
td {
143+
border: 1px solid #4a5568 !important;
144+
padding: 12px !important;
145+
text-align: left !important;
146+
color: #ffffff !important;
147+
}
148+
149+
th {
150+
background: linear-gradient(45deg, #667eea, #764ba2) !important;
151+
font-weight: bold !important;
152+
color: #ffffff !important;
153+
text-transform: uppercase !important;
154+
letter-spacing: 1px !important;
155+
}
156+
157+
tr:nth-child(even) {
158+
background-color: rgba(255, 255, 255, 0.05) !important;
159+
}
160+
161+
img {
162+
max-width: 100% !important;
163+
height: auto !important;
164+
border: 3px solid #ffd93d !important;
165+
border-radius: 10px !important;
166+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
167+
}
168+
169+
.markdown-body {
170+
max-width: none !important;
171+
}

0 commit comments

Comments
 (0)