@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunkinfrunkin/mdcat",
3
- "version": "0.1.8",
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
50
+ }
51
+
46
52
  function openInBrowser(title, content) {
47
- const html = marked.parse(content);
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 flag
121
+ // --web / --doc flags
106
122
  const webMode = args[0] === "--web" || args[0] === "-w";
107
- const fileArgs = webMode ? args.slice(1) : args;
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", () => webMode ? openInBrowser("stdin", input) : runTUI("stdin", input));
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
- webMode ? openInBrowser(title, content) : runTUI(title, content);
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(/&amp;/g, "&")
103
+ .replace(/&lt;/g, "<")
104
+ .replace(/&gt;/g, ">")
105
+ .replace(/&quot;/g, '"')
106
+ .replace(/&#39;/g, "'")
107
+ .replace(/&nbsp;/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(/&gt;/g, ">")
96
96
  .replace(/&quot;/g, '"')
97
97
  .replace(/&#39;/g, "'")
98
- .replace(/&nbsp;/g, " ");
98
+ .replace(/&nbsp;/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("┌─") +