@dunkinfrunkin/mdcat 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/cli.js +38 -6
- package/src/docx.js +517 -0
- package/src/render.js +12 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dunkinfrunkin/mdcat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "View markdown files beautifully in your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"chalk": "^5.6.2",
|
|
18
18
|
"cli-highlight": "^2.1.11",
|
|
19
|
+
"docx": "^9.6.0",
|
|
19
20
|
"marked": "^14.0.0",
|
|
20
21
|
"wrap-ansi": "^10.0.0"
|
|
21
22
|
},
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { execFileSync } from "child_process";
|
|
|
6
6
|
import { marked } from "marked";
|
|
7
7
|
import { renderTokens } from "./render.js";
|
|
8
8
|
import { launch } from "./tui.js";
|
|
9
|
+
import { toDocx } from "./docx.js";
|
|
9
10
|
|
|
10
11
|
marked.use({ gfm: true });
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ if (args[0] === "--help" || args[0] === "-h") {
|
|
|
27
28
|
console.log(`${bold("Usage:")}`);
|
|
28
29
|
console.log(` mdcat ${dim("<file.md>")}`);
|
|
29
30
|
console.log(` mdcat ${dim("--web <file.md>")} ${dim("# open in browser")}`);
|
|
31
|
+
console.log(` mdcat ${dim("--doc <file.md>")} ${dim("# export to .docx")}`);
|
|
30
32
|
console.log(` cat file.md ${dim("|")} mdcat\n`);
|
|
31
33
|
console.log(`${bold("Keys:")}`);
|
|
32
34
|
console.log(` ${blue("/")} search ${blue("n/N")} next/prev match`);
|
|
@@ -43,14 +45,28 @@ if (args[0] === "--version" || args[0] === "-v") {
|
|
|
43
45
|
|
|
44
46
|
const MAX_COLS = 100;
|
|
45
47
|
|
|
48
|
+
function escapeHtml(s) {
|
|
49
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
function openInBrowser(title, content) {
|
|
47
|
-
|
|
53
|
+
// Use a custom renderer that escapes raw HTML tokens so bare <tag> in
|
|
54
|
+
// markdown text isn't swallowed by the browser.
|
|
55
|
+
const webMarked = new marked.Marked({ gfm: true });
|
|
56
|
+
webMarked.use({
|
|
57
|
+
renderer: {
|
|
58
|
+
html(token) {
|
|
59
|
+
return escapeHtml(token.text);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const html = webMarked.parse(content);
|
|
48
64
|
const page = `<!DOCTYPE html>
|
|
49
65
|
<html lang="en">
|
|
50
66
|
<head>
|
|
51
67
|
<meta charset="UTF-8">
|
|
52
68
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
53
|
-
<title>${title}</title>
|
|
69
|
+
<title>${escapeHtml(title)}</title>
|
|
54
70
|
<style>
|
|
55
71
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
56
72
|
body {
|
|
@@ -102,16 +118,30 @@ function runTUI(title, content) {
|
|
|
102
118
|
launch(title, lines);
|
|
103
119
|
}
|
|
104
120
|
|
|
105
|
-
// --web
|
|
121
|
+
// --web / --doc flags
|
|
106
122
|
const webMode = args[0] === "--web" || args[0] === "-w";
|
|
107
|
-
const
|
|
123
|
+
const docMode = args[0] === "--doc" || args[0] === "-d";
|
|
124
|
+
const fileArgs = (webMode || docMode) ? args.slice(1) : args;
|
|
125
|
+
|
|
126
|
+
async function exportDocx(title, content) {
|
|
127
|
+
const tokens = marked.lexer(content);
|
|
128
|
+
const buf = await toDocx(tokens, title);
|
|
129
|
+
const outName = title.replace(/\.md$/i, "") + ".docx";
|
|
130
|
+
const outPath = resolve(outName);
|
|
131
|
+
writeFileSync(outPath, buf);
|
|
132
|
+
console.log(`${CAT} ${bold("mdcat")} ${dim("→")} ${blue(outPath)}`);
|
|
133
|
+
}
|
|
108
134
|
|
|
109
135
|
// Piped input
|
|
110
136
|
if (!process.stdin.isTTY && fileArgs.length === 0) {
|
|
111
137
|
let input = "";
|
|
112
138
|
process.stdin.setEncoding("utf8");
|
|
113
139
|
process.stdin.on("data", (chunk) => (input += chunk));
|
|
114
|
-
process.stdin.on("end", () =>
|
|
140
|
+
process.stdin.on("end", () => {
|
|
141
|
+
if (docMode) exportDocx("stdin", input);
|
|
142
|
+
else if (webMode) openInBrowser("stdin", input);
|
|
143
|
+
else runTUI("stdin", input);
|
|
144
|
+
});
|
|
115
145
|
} else if (fileArgs.length === 0) {
|
|
116
146
|
console.error("Usage: mdcat <file.md>");
|
|
117
147
|
process.exit(1);
|
|
@@ -125,5 +155,7 @@ if (!process.stdin.isTTY && fileArgs.length === 0) {
|
|
|
125
155
|
process.exit(1);
|
|
126
156
|
}
|
|
127
157
|
const title = basename(filePath);
|
|
128
|
-
|
|
158
|
+
if (docMode) exportDocx(title, content);
|
|
159
|
+
else if (webMode) openInBrowser(title, content);
|
|
160
|
+
else runTUI(title, content);
|
|
129
161
|
}
|
package/src/docx.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
|
3
|
+
Table, TableRow, TableCell, WidthType, BorderStyle,
|
|
4
|
+
AlignmentType, ExternalHyperlink, TabStopPosition, TabStopType,
|
|
5
|
+
ShadingType, TableLayoutType, Footer, Header,
|
|
6
|
+
} from "docx";
|
|
7
|
+
|
|
8
|
+
// ─── Colors (Word-native blues/grays) ──────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const COLOR = {
|
|
11
|
+
heading1: "1F3864", // dark navy
|
|
12
|
+
heading2: "2E74B5", // medium blue
|
|
13
|
+
heading3: "2E74B5",
|
|
14
|
+
heading4: "404040",
|
|
15
|
+
link: "2E74B5",
|
|
16
|
+
code: "D63384", // magenta for inline code
|
|
17
|
+
codeBg: "F6F8FA",
|
|
18
|
+
tableHeader:"2E74B5",
|
|
19
|
+
tableHeaderBg: "D9E2F3",
|
|
20
|
+
tableAltBg: "F2F2F2",
|
|
21
|
+
tableBorder:"BFBFBF",
|
|
22
|
+
blockquoteBorder: "2E74B5",
|
|
23
|
+
blockquoteText: "595959",
|
|
24
|
+
taskGreen: "2D8A4E",
|
|
25
|
+
taskGray: "999999",
|
|
26
|
+
body: "333333",
|
|
27
|
+
muted: "808080",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ─── Inline token → TextRun[] / ExternalHyperlink[] ─────────────────────────
|
|
31
|
+
|
|
32
|
+
function inlineRuns(tokens, opts = {}) {
|
|
33
|
+
if (!tokens?.length) return [];
|
|
34
|
+
const runs = [];
|
|
35
|
+
|
|
36
|
+
for (const tok of tokens) {
|
|
37
|
+
switch (tok.type) {
|
|
38
|
+
case "text":
|
|
39
|
+
if (tok.tokens) {
|
|
40
|
+
runs.push(...inlineRuns(tok.tokens, opts));
|
|
41
|
+
} else {
|
|
42
|
+
runs.push(new TextRun({ text: htmlDecode(tok.text ?? ""), color: COLOR.body, ...opts }));
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case "strong":
|
|
46
|
+
runs.push(...inlineRuns(tok.tokens, { ...opts, bold: true }));
|
|
47
|
+
break;
|
|
48
|
+
case "em":
|
|
49
|
+
runs.push(...inlineRuns(tok.tokens, { ...opts, italics: true }));
|
|
50
|
+
break;
|
|
51
|
+
case "del":
|
|
52
|
+
runs.push(...inlineRuns(tok.tokens, { ...opts, strike: true, color: COLOR.muted }));
|
|
53
|
+
break;
|
|
54
|
+
case "codespan":
|
|
55
|
+
runs.push(new TextRun({
|
|
56
|
+
text: tok.text ?? "",
|
|
57
|
+
font: "Consolas",
|
|
58
|
+
size: 20,
|
|
59
|
+
color: COLOR.code,
|
|
60
|
+
shading: { type: ShadingType.CLEAR, fill: COLOR.codeBg },
|
|
61
|
+
...opts,
|
|
62
|
+
}));
|
|
63
|
+
break;
|
|
64
|
+
case "link": {
|
|
65
|
+
// Recursively get inline runs for the link label to preserve bold/italic/etc
|
|
66
|
+
const labelRuns = tok.tokens?.length
|
|
67
|
+
? inlineRuns(tok.tokens, { ...opts, color: COLOR.link, underline: { type: "single" } })
|
|
68
|
+
: [new TextRun({ text: tok.href ?? "", color: COLOR.link, underline: { type: "single" }, ...opts })];
|
|
69
|
+
runs.push(new ExternalHyperlink({
|
|
70
|
+
children: labelRuns,
|
|
71
|
+
link: tok.href ?? "",
|
|
72
|
+
}));
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "image":
|
|
76
|
+
runs.push(new TextRun({
|
|
77
|
+
text: `[${tok.text || tok.alt || "image"}]`,
|
|
78
|
+
italics: true,
|
|
79
|
+
color: COLOR.muted,
|
|
80
|
+
...opts,
|
|
81
|
+
}));
|
|
82
|
+
break;
|
|
83
|
+
case "br":
|
|
84
|
+
runs.push(new TextRun({ break: 1 }));
|
|
85
|
+
break;
|
|
86
|
+
case "escape":
|
|
87
|
+
runs.push(new TextRun({ text: tok.text ?? "", color: COLOR.body, ...opts }));
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
if (tok.raw) runs.push(new TextRun({ text: tok.raw, color: COLOR.body, ...opts }));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return runs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── HTML entity decoder ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function htmlDecode(s) {
|
|
101
|
+
return s
|
|
102
|
+
.replace(/&/g, "&")
|
|
103
|
+
.replace(/</g, "<")
|
|
104
|
+
.replace(/>/g, ">")
|
|
105
|
+
.replace(/"/g, '"')
|
|
106
|
+
.replace(/'/g, "'")
|
|
107
|
+
.replace(/ /g, " ")
|
|
108
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
109
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Block token → Paragraph[] ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function blockToParagraphs(tok) {
|
|
115
|
+
const out = [];
|
|
116
|
+
|
|
117
|
+
switch (tok.type) {
|
|
118
|
+
case "heading": {
|
|
119
|
+
const headingMap = {
|
|
120
|
+
1: HeadingLevel.HEADING_1,
|
|
121
|
+
2: HeadingLevel.HEADING_2,
|
|
122
|
+
3: HeadingLevel.HEADING_3,
|
|
123
|
+
4: HeadingLevel.HEADING_4,
|
|
124
|
+
5: HeadingLevel.HEADING_5,
|
|
125
|
+
6: HeadingLevel.HEADING_6,
|
|
126
|
+
};
|
|
127
|
+
const colorMap = {
|
|
128
|
+
1: COLOR.heading1,
|
|
129
|
+
2: COLOR.heading2,
|
|
130
|
+
3: COLOR.heading3,
|
|
131
|
+
4: COLOR.heading4,
|
|
132
|
+
5: COLOR.heading4,
|
|
133
|
+
6: COLOR.muted,
|
|
134
|
+
};
|
|
135
|
+
const runs = inlineRuns(tok.tokens).map(r => {
|
|
136
|
+
// Override color for heading runs
|
|
137
|
+
if (r instanceof TextRun) {
|
|
138
|
+
return new TextRun({
|
|
139
|
+
...extractRunProps(r),
|
|
140
|
+
color: colorMap[tok.depth] ?? COLOR.body,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return r;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
out.push(new Paragraph({
|
|
147
|
+
heading: headingMap[tok.depth] ?? HeadingLevel.HEADING_1,
|
|
148
|
+
children: runs,
|
|
149
|
+
spacing: { before: 240, after: 120 },
|
|
150
|
+
...(tok.depth <= 2 ? {
|
|
151
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "D9D9D9", space: 4 } },
|
|
152
|
+
} : {}),
|
|
153
|
+
}));
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case "paragraph":
|
|
158
|
+
out.push(new Paragraph({
|
|
159
|
+
children: inlineRuns(tok.tokens),
|
|
160
|
+
spacing: { after: 160, line: 300 },
|
|
161
|
+
}));
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case "code": {
|
|
165
|
+
const lang = (tok.lang ?? "").split(/\s/)[0].trim();
|
|
166
|
+
const lines = (tok.text ?? "").split("\n");
|
|
167
|
+
|
|
168
|
+
// Language label
|
|
169
|
+
if (lang) {
|
|
170
|
+
out.push(new Paragraph({
|
|
171
|
+
children: [new TextRun({
|
|
172
|
+
text: lang.toUpperCase(),
|
|
173
|
+
font: "Consolas",
|
|
174
|
+
size: 16,
|
|
175
|
+
color: COLOR.muted,
|
|
176
|
+
bold: true,
|
|
177
|
+
})],
|
|
178
|
+
spacing: { before: 200, after: 40 },
|
|
179
|
+
}));
|
|
180
|
+
} else {
|
|
181
|
+
out.push(new Paragraph({ spacing: { before: 120 } }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Code lines in a shaded block
|
|
185
|
+
for (let i = 0; i < lines.length; i++) {
|
|
186
|
+
out.push(new Paragraph({
|
|
187
|
+
children: [new TextRun({
|
|
188
|
+
text: lines[i] || " ",
|
|
189
|
+
font: "Consolas",
|
|
190
|
+
size: 19,
|
|
191
|
+
color: COLOR.body,
|
|
192
|
+
})],
|
|
193
|
+
shading: { type: ShadingType.CLEAR, fill: COLOR.codeBg },
|
|
194
|
+
spacing: { after: 0, line: 260 },
|
|
195
|
+
indent: { left: 240, right: 240 },
|
|
196
|
+
...(i === 0 ? { border: { top: { style: BorderStyle.SINGLE, size: 1, color: "E1E4E8", space: 4 } } } : {}),
|
|
197
|
+
...(i === lines.length - 1 ? { border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "E1E4E8", space: 4 } } } : {}),
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
out.push(new Paragraph({ spacing: { after: 160 } }));
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case "blockquote": {
|
|
205
|
+
for (const t of tok.tokens ?? []) {
|
|
206
|
+
if (t.tokens) {
|
|
207
|
+
const children = inlineRuns(t.tokens).map(r => {
|
|
208
|
+
if (r instanceof TextRun) {
|
|
209
|
+
return new TextRun({ ...extractRunProps(r), color: COLOR.blockquoteText, italics: true });
|
|
210
|
+
}
|
|
211
|
+
return r;
|
|
212
|
+
});
|
|
213
|
+
out.push(new Paragraph({
|
|
214
|
+
children,
|
|
215
|
+
indent: { left: 480 },
|
|
216
|
+
border: { left: { style: BorderStyle.SINGLE, size: 12, color: COLOR.blockquoteBorder, space: 12 } },
|
|
217
|
+
spacing: { after: 80, line: 280 },
|
|
218
|
+
}));
|
|
219
|
+
} else if (t.type === "paragraph" && t.tokens) {
|
|
220
|
+
const children = inlineRuns(t.tokens).map(r => {
|
|
221
|
+
if (r instanceof TextRun) {
|
|
222
|
+
return new TextRun({ ...extractRunProps(r), color: COLOR.blockquoteText, italics: true });
|
|
223
|
+
}
|
|
224
|
+
return r;
|
|
225
|
+
});
|
|
226
|
+
out.push(new Paragraph({
|
|
227
|
+
children,
|
|
228
|
+
indent: { left: 480 },
|
|
229
|
+
border: { left: { style: BorderStyle.SINGLE, size: 12, color: COLOR.blockquoteBorder, space: 12 } },
|
|
230
|
+
spacing: { after: 80, line: 280 },
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
out.push(new Paragraph({ spacing: { after: 80 } }));
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
case "list": {
|
|
239
|
+
const items = tok.items ?? [];
|
|
240
|
+
items.forEach((item, i) => {
|
|
241
|
+
const children = [];
|
|
242
|
+
|
|
243
|
+
if (item.task) {
|
|
244
|
+
const checked = item.checked;
|
|
245
|
+
children.push(new TextRun({
|
|
246
|
+
text: checked ? "✓ " : "○ ",
|
|
247
|
+
font: "Segoe UI Symbol",
|
|
248
|
+
color: checked ? COLOR.taskGreen : COLOR.taskGray,
|
|
249
|
+
bold: checked,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const t of item.tokens) {
|
|
254
|
+
if (t.type === "text") {
|
|
255
|
+
const taskDone = item.task && item.checked;
|
|
256
|
+
children.push(...inlineRuns(
|
|
257
|
+
t.tokens ?? [{ type: "text", text: t.text ?? "" }],
|
|
258
|
+
taskDone ? { strike: true, color: COLOR.muted } : {},
|
|
259
|
+
));
|
|
260
|
+
} else if (t.type === "paragraph") {
|
|
261
|
+
const taskDone = item.task && item.checked;
|
|
262
|
+
children.push(...inlineRuns(
|
|
263
|
+
t.tokens,
|
|
264
|
+
taskDone ? { strike: true, color: COLOR.muted } : {},
|
|
265
|
+
));
|
|
266
|
+
} else if (t.type === "list") {
|
|
267
|
+
// handled below
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const bullet = tok.ordered
|
|
272
|
+
? new TextRun({ text: `${(tok.start ?? 1) + i}. `, color: COLOR.heading2, bold: true })
|
|
273
|
+
: new TextRun({ text: item.task ? "" : "• ", color: COLOR.heading2 });
|
|
274
|
+
|
|
275
|
+
out.push(new Paragraph({
|
|
276
|
+
children: [bullet, ...children],
|
|
277
|
+
indent: { left: 360 },
|
|
278
|
+
spacing: { after: 60, line: 276 },
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
// Nested lists
|
|
282
|
+
for (const t of item.tokens) {
|
|
283
|
+
if (t.type === "list") {
|
|
284
|
+
const nested = blockToParagraphs(t);
|
|
285
|
+
for (const p of nested) {
|
|
286
|
+
out.push(new Paragraph({
|
|
287
|
+
...extractParaProps(p),
|
|
288
|
+
indent: { left: 720 },
|
|
289
|
+
spacing: { after: 60, line: 276 },
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
out.push(new Paragraph({ spacing: { after: 80 } }));
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "table": {
|
|
300
|
+
const headers = tok.header ?? [];
|
|
301
|
+
const rows = tok.rows ?? [];
|
|
302
|
+
const aligns = tok.align ?? [];
|
|
303
|
+
|
|
304
|
+
const alignMap = {
|
|
305
|
+
left: AlignmentType.LEFT,
|
|
306
|
+
right: AlignmentType.RIGHT,
|
|
307
|
+
center: AlignmentType.CENTER,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const cellBorder = {
|
|
311
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
|
|
312
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
|
|
313
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
|
|
314
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Compute column widths from content
|
|
318
|
+
const colWidths = headers.map((h, i) => {
|
|
319
|
+
const hLen = (h.tokens ?? []).map(t => t.text ?? t.raw ?? "").join("").length;
|
|
320
|
+
const maxCell = rows.reduce((m, r) => {
|
|
321
|
+
const cLen = (r[i]?.tokens ?? []).map(t => t.text ?? t.raw ?? "").join("").length;
|
|
322
|
+
return Math.max(m, cLen);
|
|
323
|
+
}, 0);
|
|
324
|
+
return Math.max(hLen, maxCell, 3);
|
|
325
|
+
});
|
|
326
|
+
const totalW = colWidths.reduce((s, w) => s + w, 0) || 1;
|
|
327
|
+
const colPcts = colWidths.map(w => Math.max(8, Math.round((w / totalW) * 100)));
|
|
328
|
+
|
|
329
|
+
// Header row
|
|
330
|
+
const headerCells = headers.map((h, i) => new TableCell({
|
|
331
|
+
children: [new Paragraph({
|
|
332
|
+
children: inlineRuns(h.tokens ?? []).map(r => {
|
|
333
|
+
if (r instanceof TextRun) {
|
|
334
|
+
return new TextRun({ ...extractRunProps(r), bold: true, color: "FFFFFF" });
|
|
335
|
+
}
|
|
336
|
+
return r;
|
|
337
|
+
}),
|
|
338
|
+
alignment: alignMap[aligns[i]] ?? AlignmentType.LEFT,
|
|
339
|
+
spacing: { before: 40, after: 40 },
|
|
340
|
+
})],
|
|
341
|
+
width: { size: colPcts[i], type: WidthType.PERCENTAGE },
|
|
342
|
+
shading: { type: ShadingType.CLEAR, fill: COLOR.tableHeader },
|
|
343
|
+
borders: cellBorder,
|
|
344
|
+
margins: { top: 40, bottom: 40, left: 80, right: 80 },
|
|
345
|
+
}));
|
|
346
|
+
|
|
347
|
+
const tableRows = [new TableRow({ children: headerCells, tableHeader: true })];
|
|
348
|
+
|
|
349
|
+
// Data rows with alternating shading
|
|
350
|
+
rows.forEach((row, rowIdx) => {
|
|
351
|
+
const cells = row.map((cell, i) => new TableCell({
|
|
352
|
+
children: [new Paragraph({
|
|
353
|
+
children: inlineRuns(cell.tokens ?? []),
|
|
354
|
+
alignment: alignMap[aligns[i]] ?? AlignmentType.LEFT,
|
|
355
|
+
spacing: { before: 20, after: 20 },
|
|
356
|
+
})],
|
|
357
|
+
width: { size: colPcts[i], type: WidthType.PERCENTAGE },
|
|
358
|
+
shading: rowIdx % 2 === 1
|
|
359
|
+
? { type: ShadingType.CLEAR, fill: COLOR.tableAltBg }
|
|
360
|
+
: undefined,
|
|
361
|
+
borders: cellBorder,
|
|
362
|
+
margins: { top: 20, bottom: 20, left: 80, right: 80 },
|
|
363
|
+
}));
|
|
364
|
+
tableRows.push(new TableRow({ children: cells }));
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
out.push(new Table({
|
|
368
|
+
rows: tableRows,
|
|
369
|
+
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
370
|
+
layout: TableLayoutType.FIXED,
|
|
371
|
+
columnWidths: colPcts.map(p => Math.round(p * 90)),
|
|
372
|
+
}));
|
|
373
|
+
out.push(new Paragraph({ spacing: { after: 200 } }));
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "hr":
|
|
378
|
+
out.push(new Paragraph({
|
|
379
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "D9D9D9" } },
|
|
380
|
+
spacing: { before: 240, after: 240 },
|
|
381
|
+
}));
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case "space":
|
|
385
|
+
case "html":
|
|
386
|
+
case "def":
|
|
387
|
+
break;
|
|
388
|
+
|
|
389
|
+
default:
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── Helpers to extract properties from existing objects ─────────────────────
|
|
397
|
+
|
|
398
|
+
function extractRunProps(run) {
|
|
399
|
+
// TextRun stores props internally; we reconstruct from the XML root
|
|
400
|
+
const props = {};
|
|
401
|
+
const root = run.root;
|
|
402
|
+
if (!root) return props;
|
|
403
|
+
|
|
404
|
+
// Walk the internal representation to pull out text and formatting
|
|
405
|
+
// This is a simplified extraction — we pass through common props
|
|
406
|
+
for (const child of root) {
|
|
407
|
+
if (typeof child === "string") continue;
|
|
408
|
+
if (child?.rootKey === "w:t") {
|
|
409
|
+
props.text = child.root?.[1] ?? "";
|
|
410
|
+
}
|
|
411
|
+
if (child?.rootKey === "w:rPr") {
|
|
412
|
+
for (const pr of child.root ?? []) {
|
|
413
|
+
if (pr?.rootKey === "w:b") props.bold = true;
|
|
414
|
+
if (pr?.rootKey === "w:i") props.italics = true;
|
|
415
|
+
if (pr?.rootKey === "w:strike") props.strike = true;
|
|
416
|
+
if (pr?.rootKey === "w:color") {
|
|
417
|
+
const val = pr.root?.find(a => a?.rootKey === "w:val" || a?._attr?.["w:val"]);
|
|
418
|
+
if (val?._attr?.["w:val"]) props.color = val._attr["w:val"];
|
|
419
|
+
}
|
|
420
|
+
if (pr?.rootKey === "w:rFonts") {
|
|
421
|
+
const val = pr.root?.find(a => a?._attr?.["w:ascii"]);
|
|
422
|
+
if (val?._attr?.["w:ascii"]) props.font = val._attr["w:ascii"];
|
|
423
|
+
}
|
|
424
|
+
if (pr?.rootKey === "w:sz") {
|
|
425
|
+
const val = pr.root?.find(a => a?._attr?.["w:val"]);
|
|
426
|
+
if (val?._attr?.["w:val"]) props.size = parseInt(val._attr["w:val"]);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return props;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function extractParaProps(para) {
|
|
435
|
+
// Return a minimal representation for re-creating paragraphs
|
|
436
|
+
return {
|
|
437
|
+
children: para.root?.[1]?.root?.filter(r => r instanceof TextRun || r instanceof ExternalHyperlink) ?? [],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Convert marked tokens to a .docx buffer.
|
|
445
|
+
*/
|
|
446
|
+
export async function toDocx(tokens, title = "Document") {
|
|
447
|
+
const children = tokens.flatMap(tok => blockToParagraphs(tok));
|
|
448
|
+
|
|
449
|
+
const doc = new Document({
|
|
450
|
+
title,
|
|
451
|
+
styles: {
|
|
452
|
+
default: {
|
|
453
|
+
document: {
|
|
454
|
+
run: { size: 24, font: "Calibri", color: COLOR.body },
|
|
455
|
+
paragraph: { spacing: { after: 160, line: 300 } },
|
|
456
|
+
},
|
|
457
|
+
heading1: {
|
|
458
|
+
run: { size: 36, font: "Calibri", bold: true, color: COLOR.heading1 },
|
|
459
|
+
paragraph: { spacing: { before: 360, after: 120 } },
|
|
460
|
+
},
|
|
461
|
+
heading2: {
|
|
462
|
+
run: { size: 30, font: "Calibri", bold: true, color: COLOR.heading2 },
|
|
463
|
+
paragraph: { spacing: { before: 280, after: 100 } },
|
|
464
|
+
},
|
|
465
|
+
heading3: {
|
|
466
|
+
run: { size: 26, font: "Calibri", bold: true, color: COLOR.heading3 },
|
|
467
|
+
paragraph: { spacing: { before: 240, after: 80 } },
|
|
468
|
+
},
|
|
469
|
+
heading4: {
|
|
470
|
+
run: { size: 24, font: "Calibri", bold: true, color: COLOR.heading4 },
|
|
471
|
+
paragraph: { spacing: { before: 200, after: 80 } },
|
|
472
|
+
},
|
|
473
|
+
heading5: {
|
|
474
|
+
run: { size: 22, font: "Calibri", italics: true, color: COLOR.heading4 },
|
|
475
|
+
paragraph: { spacing: { before: 160, after: 60 } },
|
|
476
|
+
},
|
|
477
|
+
heading6: {
|
|
478
|
+
run: { size: 22, font: "Calibri", color: COLOR.muted },
|
|
479
|
+
paragraph: { spacing: { before: 160, after: 60 } },
|
|
480
|
+
},
|
|
481
|
+
hyperlink: {
|
|
482
|
+
run: { color: COLOR.link, underline: { type: "single" } },
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
sections: [{
|
|
487
|
+
properties: {
|
|
488
|
+
page: {
|
|
489
|
+
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
footers: {
|
|
493
|
+
default: new Footer({
|
|
494
|
+
children: [new Paragraph({
|
|
495
|
+
alignment: AlignmentType.CENTER,
|
|
496
|
+
children: [
|
|
497
|
+
new TextRun({ text: "Created with ", color: COLOR.muted, size: 16, font: "Calibri" }),
|
|
498
|
+
new ExternalHyperlink({
|
|
499
|
+
children: [new TextRun({
|
|
500
|
+
text: "mdcat",
|
|
501
|
+
color: COLOR.link,
|
|
502
|
+
underline: { type: "single" },
|
|
503
|
+
size: 16,
|
|
504
|
+
font: "Calibri",
|
|
505
|
+
})],
|
|
506
|
+
link: "https://mdcat.frankchan.dev",
|
|
507
|
+
}),
|
|
508
|
+
],
|
|
509
|
+
})],
|
|
510
|
+
}),
|
|
511
|
+
},
|
|
512
|
+
children,
|
|
513
|
+
}],
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
return await Packer.toBuffer(doc);
|
|
517
|
+
}
|
package/src/render.js
CHANGED
|
@@ -95,7 +95,9 @@ function htmlDecode(s) {
|
|
|
95
95
|
.replace(/>/g, ">")
|
|
96
96
|
.replace(/"/g, '"')
|
|
97
97
|
.replace(/'/g, "'")
|
|
98
|
-
.replace(/ /g, " ")
|
|
98
|
+
.replace(/ /g, " ")
|
|
99
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
|
100
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
// ─── Inline renderer ───────────────────────────────────────────────────────────
|
|
@@ -202,11 +204,6 @@ function code(tok, w) {
|
|
|
202
204
|
// Expand tabs to 2 spaces
|
|
203
205
|
const rawText = (tok.text ?? "").replace(/\t/g, " ");
|
|
204
206
|
|
|
205
|
-
// innerW = content width between borders (1 space padding each side)
|
|
206
|
-
const innerW = w - 2;
|
|
207
|
-
// borderW = total inner span for the box (includes the 1px padding each side)
|
|
208
|
-
const borderW = innerW + 2;
|
|
209
|
-
|
|
210
207
|
let rawLines;
|
|
211
208
|
if (rawText === "") {
|
|
212
209
|
rawLines = [""];
|
|
@@ -239,13 +236,21 @@ function code(tok, w) {
|
|
|
239
236
|
// Pad to same length just in case highlight produced different line count
|
|
240
237
|
while (highlighted.length < rawLines.length) highlighted.push("");
|
|
241
238
|
|
|
239
|
+
// Size the box to the longest line (capped at terminal width)
|
|
240
|
+
const maxLineW = highlighted.reduce((m, l) => Math.max(m, vlen(l)), 0);
|
|
241
|
+
const langTagLen = lang ? 1 + lang.length + 1 : 0; // " lang " visual chars
|
|
242
|
+
// innerW = content area between pipes (1 space padding each side)
|
|
243
|
+
// Must fit: longest line, lang tag in top border, and minimum 4 chars
|
|
244
|
+
const innerW = Math.min(w - 2, Math.max(maxLineW, langTagLen + 2, 4));
|
|
245
|
+
// borderW = total inner span for the box (includes the 1-char padding each side)
|
|
246
|
+
const borderW = innerW + 2;
|
|
247
|
+
|
|
242
248
|
const lines = [""];
|
|
243
249
|
|
|
244
250
|
// Top border: ┌─ lang ─────┐ or ┌──────────────┐
|
|
245
251
|
let top;
|
|
246
252
|
if (lang) {
|
|
247
253
|
const langTag = " " + c.codeLang(lang) + " ";
|
|
248
|
-
const langTagLen = 1 + lang.length + 1; // visual chars
|
|
249
254
|
const fill = Math.max(0, borderW - langTagLen - 1); // -1 for leading "─"
|
|
250
255
|
top =
|
|
251
256
|
c.border("┌─") +
|