freemd 0.0.0-stage → 0.1.0
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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.es.md +95 -0
- package/README.md +94 -2
- package/bin/openmd.js +93 -0
- package/dist/assets/index-C0w_6LCS.css +1 -0
- package/dist/assets/index-CiAQinMf.js +29 -0
- package/dist/index.html +17 -0
- package/package.json +48 -4
- package/server/export/highlight.js +126 -0
- package/server/export/index.js +85 -0
- package/server/export/to-docx.js +403 -0
- package/server/export/to-pdf.js +541 -0
- package/server/index.js +392 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link
|
|
7
|
+
rel="icon"
|
|
8
|
+
href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Crect%20width='100'%20height='100'%20rx='22'%20fill='%23282c34'/%3E%3Ctext%20x='50'%20y='70'%20font-size='54'%20font-family='monospace'%20font-weight='700'%20text-anchor='middle'%20fill='%2398c379'%3EM%3C/text%3E%3C/svg%3E"
|
|
9
|
+
/>
|
|
10
|
+
<title>freemd</title>
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-CiAQinMf.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C0w_6LCS.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="app"></div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freemd",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local markdown notes editor in your browser and export. Run with npx freemd",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"freemd": "bin/openmd.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"server",
|
|
13
|
+
"dist",
|
|
14
|
+
"README.es.md",
|
|
15
|
+
"AGENTS.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "vite",
|
|
22
|
+
"dev:server": "node bin/openmd.js --no-open",
|
|
23
|
+
"build": "vite build",
|
|
24
|
+
"start": "node bin/openmd.js"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"docx": "^9.14.0",
|
|
28
|
+
"express": "^4.21.2",
|
|
29
|
+
"highlight.js": "^11.10.0",
|
|
30
|
+
"image-size": "^2.0.4",
|
|
31
|
+
"markdown-it": "^14.1.0",
|
|
32
|
+
"pdfkit": "^0.20.2"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@sveltejs/vite-plugin-svelte": "^4.0.2",
|
|
36
|
+
"markdown-it-highlightjs": "^4.1.0",
|
|
37
|
+
"svelte": "^5.16.0",
|
|
38
|
+
"vite": "^5.4.11"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"markdown",
|
|
42
|
+
"notes",
|
|
43
|
+
"editor",
|
|
44
|
+
"svelte",
|
|
45
|
+
"self-hosted"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import hljs from 'highlight.js';
|
|
2
|
+
|
|
3
|
+
// Paleta atom-one-light: los exports son en tema claro.
|
|
4
|
+
const HLJS_COLORS = {
|
|
5
|
+
'hljs-comment': 'A0A1A7',
|
|
6
|
+
'hljs-quote': 'A0A1A7',
|
|
7
|
+
'hljs-keyword': 'A626A4',
|
|
8
|
+
'hljs-doctag': 'A626A4',
|
|
9
|
+
'hljs-formula': 'A626A4',
|
|
10
|
+
'hljs-section': 'E45649',
|
|
11
|
+
'hljs-name': 'E45649',
|
|
12
|
+
'hljs-selector-tag': 'E45649',
|
|
13
|
+
'hljs-deletion': 'E45649',
|
|
14
|
+
'hljs-subst': 'E45649',
|
|
15
|
+
'hljs-literal': '0184BB',
|
|
16
|
+
'hljs-string': '50A14F',
|
|
17
|
+
'hljs-regexp': '50A14F',
|
|
18
|
+
'hljs-addition': '50A14F',
|
|
19
|
+
'hljs-attribute': '986801',
|
|
20
|
+
'hljs-variable': '986801',
|
|
21
|
+
'hljs-template-variable': '986801',
|
|
22
|
+
'hljs-type': '986801',
|
|
23
|
+
'hljs-selector-class': '986801',
|
|
24
|
+
'hljs-selector-attr': '986801',
|
|
25
|
+
'hljs-selector-pseudo': '986801',
|
|
26
|
+
'hljs-number': '986801',
|
|
27
|
+
'hljs-symbol': '4078F2',
|
|
28
|
+
'hljs-bullet': '4078F2',
|
|
29
|
+
'hljs-link': '4078F2',
|
|
30
|
+
'hljs-meta': '4078F2',
|
|
31
|
+
'hljs-selector-id': '4078F2',
|
|
32
|
+
'hljs-title': '4078F2',
|
|
33
|
+
'hljs-built_in': 'C18401'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ENTITIES = {
|
|
37
|
+
'&': '&',
|
|
38
|
+
'<': '<',
|
|
39
|
+
'>': '>',
|
|
40
|
+
'"': '"',
|
|
41
|
+
''': "'",
|
|
42
|
+
''': "'",
|
|
43
|
+
' ': ' '
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function decodeEntities(text) {
|
|
47
|
+
return text.replace(/&(?:amp|lt|gt|quot|#39|#x27|nbsp);/g, (entity) => ENTITIES[entity] ?? entity);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function colorForClassName(className) {
|
|
51
|
+
for (const name of className.split(/\s+/)) {
|
|
52
|
+
if (HLJS_COLORS[name]) return HLJS_COLORS[name];
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Convierte el HTML que produce hljs.highlight en segmentos planos
|
|
58
|
+
// { text, color }, resolviendo spans anidados con el color más interno.
|
|
59
|
+
function parseHljsHtml(html) {
|
|
60
|
+
const segments = [];
|
|
61
|
+
const stack = [];
|
|
62
|
+
let text = '';
|
|
63
|
+
const flush = () => {
|
|
64
|
+
if (!text) return;
|
|
65
|
+
let color = null;
|
|
66
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
67
|
+
color = colorForClassName(stack[i]);
|
|
68
|
+
if (color) break;
|
|
69
|
+
}
|
|
70
|
+
segments.push({ text, color });
|
|
71
|
+
text = '';
|
|
72
|
+
};
|
|
73
|
+
const tags = /<span class="([^"]*)">|<\/span>/g;
|
|
74
|
+
let cursor = 0;
|
|
75
|
+
let match;
|
|
76
|
+
while ((match = tags.exec(html))) {
|
|
77
|
+
if (match.index > cursor) text += decodeEntities(html.slice(cursor, match.index));
|
|
78
|
+
if (match[1] !== undefined) {
|
|
79
|
+
flush();
|
|
80
|
+
stack.push(match[1]);
|
|
81
|
+
} else {
|
|
82
|
+
flush();
|
|
83
|
+
stack.pop();
|
|
84
|
+
}
|
|
85
|
+
cursor = tags.lastIndex;
|
|
86
|
+
}
|
|
87
|
+
if (cursor < html.length) text += decodeEntities(html.slice(cursor));
|
|
88
|
+
flush();
|
|
89
|
+
return segments;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function mergeSegments(segments) {
|
|
93
|
+
const merged = [];
|
|
94
|
+
for (const segment of segments) {
|
|
95
|
+
const last = merged[merged.length - 1];
|
|
96
|
+
if (last && last.color === segment.color) last.text += segment.text;
|
|
97
|
+
else merged.push({ ...segment });
|
|
98
|
+
}
|
|
99
|
+
return merged;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Devuelve segmentos coloreados del código; sin lenguaje reconocido, texto plano.
|
|
103
|
+
export function highlightSegments(code, info) {
|
|
104
|
+
const language = String(info ?? '').trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
105
|
+
if (!language || !hljs.getLanguage(language)) {
|
|
106
|
+
return [{ text: code, color: null }];
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return mergeSegments(parseHljsHtml(hljs.highlight(code, { language, ignoreIllegals: true }).value));
|
|
110
|
+
} catch {
|
|
111
|
+
return [{ text: code, color: null }];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parte segmentos en líneas conservando el color de cada tramo.
|
|
116
|
+
export function splitSegmentsByLines(segments) {
|
|
117
|
+
const lines = [[]];
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
const parts = segment.text.split('\n');
|
|
120
|
+
parts.forEach((part, index) => {
|
|
121
|
+
if (index > 0) lines.push([]);
|
|
122
|
+
if (part) lines[lines.length - 1].push({ text: part, color: segment.color });
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import MarkdownIt from 'markdown-it';
|
|
4
|
+
import { imageSize } from 'image-size';
|
|
5
|
+
import { markdownToDocx } from './to-docx.js';
|
|
6
|
+
import { markdownToPdf } from './to-pdf.js';
|
|
7
|
+
|
|
8
|
+
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
|
|
9
|
+
|
|
10
|
+
function collectImageTokens(tokens, acc = []) {
|
|
11
|
+
for (const token of tokens) {
|
|
12
|
+
if (token.type === 'image') acc.push(token);
|
|
13
|
+
if (Array.isArray(token.children)) collectImageTokens(token.children, acc);
|
|
14
|
+
}
|
|
15
|
+
return acc;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeDecode(value) {
|
|
19
|
+
try {
|
|
20
|
+
return decodeURIComponent(value);
|
|
21
|
+
} catch {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readLocalImage(root, baseRel, src) {
|
|
27
|
+
const cleaned = safeDecode(src).replace(/^\.\//, '');
|
|
28
|
+
const candidates = [];
|
|
29
|
+
if (cleaned.startsWith('/')) {
|
|
30
|
+
candidates.push(cleaned.slice(1));
|
|
31
|
+
} else {
|
|
32
|
+
if (baseRel) candidates.push(path.posix.join(baseRel, cleaned));
|
|
33
|
+
candidates.push(cleaned);
|
|
34
|
+
}
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
const abs = path.resolve(root, candidate);
|
|
37
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) continue;
|
|
38
|
+
try {
|
|
39
|
+
return await fsp.readFile(abs);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadImage(root, baseRel, src) {
|
|
46
|
+
try {
|
|
47
|
+
let buffer;
|
|
48
|
+
if (/^data:image\//i.test(src)) {
|
|
49
|
+
buffer = Buffer.from(src.slice(src.indexOf(',') + 1), 'base64');
|
|
50
|
+
} else if (/^https?:\/\//i.test(src)) {
|
|
51
|
+
const response = await fetch(src, { signal: AbortSignal.timeout(15000) });
|
|
52
|
+
if (!response.ok) return null;
|
|
53
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
54
|
+
} else {
|
|
55
|
+
buffer = await readLocalImage(root, baseRel, src);
|
|
56
|
+
}
|
|
57
|
+
if (!buffer?.length) return null;
|
|
58
|
+
const dims = imageSize(buffer);
|
|
59
|
+
if (!dims?.width || !dims?.height) return null;
|
|
60
|
+
return {
|
|
61
|
+
buffer,
|
|
62
|
+
type: dims.type === 'jpeg' ? 'jpg' : dims.type,
|
|
63
|
+
width: dims.width,
|
|
64
|
+
height: dims.height
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function exportMarkdown(markdown, options) {
|
|
72
|
+
const tokens = md.parse(markdown, {});
|
|
73
|
+
const images = new Map();
|
|
74
|
+
await Promise.all(
|
|
75
|
+
collectImageTokens(tokens).map(async (token) => {
|
|
76
|
+
const src = token.attrGet('src') ?? '';
|
|
77
|
+
if (!images.has(src)) {
|
|
78
|
+
images.set(src, await loadImage(options.root, options.baseRel, src));
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
const ctx = { images, title: options.title };
|
|
83
|
+
if (options.format === 'docx') return markdownToDocx(tokens, ctx);
|
|
84
|
+
return markdownToPdf(tokens, ctx);
|
|
85
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlignmentType,
|
|
3
|
+
BorderStyle,
|
|
4
|
+
Document,
|
|
5
|
+
ExternalHyperlink,
|
|
6
|
+
HeadingLevel,
|
|
7
|
+
ImageRun,
|
|
8
|
+
Packer,
|
|
9
|
+
Paragraph,
|
|
10
|
+
ShadingType,
|
|
11
|
+
Table,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableRow,
|
|
14
|
+
TextRun,
|
|
15
|
+
WidthType
|
|
16
|
+
} from 'docx';
|
|
17
|
+
import { highlightSegments, splitSegmentsByLines } from './highlight.js';
|
|
18
|
+
|
|
19
|
+
// Formatos que docx puede incrustar; el resto se exporta como marcador visible.
|
|
20
|
+
const DOCX_TYPES = new Set(['png', 'jpg', 'gif', 'bmp']);
|
|
21
|
+
|
|
22
|
+
const CODE_FONT = 'Courier New';
|
|
23
|
+
const HEADING_LEVELS = {
|
|
24
|
+
1: HeadingLevel.HEADING_1,
|
|
25
|
+
2: HeadingLevel.HEADING_2,
|
|
26
|
+
3: HeadingLevel.HEADING_3,
|
|
27
|
+
4: HeadingLevel.HEADING_4,
|
|
28
|
+
5: HeadingLevel.HEADING_5,
|
|
29
|
+
6: HeadingLevel.HEADING_6
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function findClose(tokens, start, openType, closeType) {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
for (let i = start; i < tokens.length; i++) {
|
|
35
|
+
if (tokens[i].type === openType) depth++;
|
|
36
|
+
else if (tokens[i].type === closeType) {
|
|
37
|
+
depth--;
|
|
38
|
+
if (depth === 0) return i;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return tokens.length - 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function numberingLevels() {
|
|
45
|
+
const lefts = [720, 1440, 2160, 2880, 3600, 4320];
|
|
46
|
+
const formats = ['decimal', 'lowerLetter', 'lowerRoman', 'decimal', 'lowerLetter', 'lowerRoman'];
|
|
47
|
+
return formats.map((format, level) => ({
|
|
48
|
+
level,
|
|
49
|
+
format,
|
|
50
|
+
text: `%${level + 1}.`,
|
|
51
|
+
alignment: AlignmentType.START,
|
|
52
|
+
style: { paragraph: { indent: { left: lefts[level], hanging: 360 } } }
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeRun(text, state) {
|
|
57
|
+
const options = {
|
|
58
|
+
text,
|
|
59
|
+
bold: state.bold || undefined,
|
|
60
|
+
italics: state.italic || undefined,
|
|
61
|
+
color: state.quoteDepth > 0 ? '595959' : undefined
|
|
62
|
+
};
|
|
63
|
+
if (state.href) {
|
|
64
|
+
return new ExternalHyperlink({
|
|
65
|
+
link: state.href,
|
|
66
|
+
children: [new TextRun({ ...options, style: 'Hyperlink' })]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return new TextRun(options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function paragraphOptions(state, spacing = {}) {
|
|
73
|
+
const options = { spacing: { after: 160, ...spacing } };
|
|
74
|
+
if (state.listContext) {
|
|
75
|
+
if (state.listContext.numbering) options.numbering = state.listContext.numbering;
|
|
76
|
+
else options.bullet = state.listContext.bullet;
|
|
77
|
+
}
|
|
78
|
+
if (state.quoteDepth > 0) {
|
|
79
|
+
options.indent = { left: 360 * state.quoteDepth };
|
|
80
|
+
options.border = { left: { style: BorderStyle.SINGLE, size: 12, color: 'BFBFBF', space: 8 } };
|
|
81
|
+
}
|
|
82
|
+
return options;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function imageRun(loaded, alt) {
|
|
86
|
+
if (!loaded?.type || !DOCX_TYPES.has(loaded.type)) return null;
|
|
87
|
+
const maxWidth = 600;
|
|
88
|
+
const maxHeight = 720;
|
|
89
|
+
const scale = Math.min(maxWidth / loaded.width, maxHeight / loaded.height, 1);
|
|
90
|
+
try {
|
|
91
|
+
return new ImageRun({
|
|
92
|
+
type: loaded.type,
|
|
93
|
+
data: loaded.buffer,
|
|
94
|
+
transformation: {
|
|
95
|
+
width: Math.max(1, Math.round(loaded.width * scale)),
|
|
96
|
+
height: Math.max(1, Math.round(loaded.height * scale))
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pushImages(out, images, state) {
|
|
105
|
+
for (const image of images) {
|
|
106
|
+
const loaded = state.images.get(image.src);
|
|
107
|
+
const alt = image.alt || 'imagen';
|
|
108
|
+
const run = loaded && imageRun(loaded, alt);
|
|
109
|
+
if (run) {
|
|
110
|
+
out.push(
|
|
111
|
+
new Paragraph({
|
|
112
|
+
alignment: AlignmentType.CENTER,
|
|
113
|
+
spacing: { before: 120, after: 160 },
|
|
114
|
+
children: [run]
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
out.push(
|
|
119
|
+
new Paragraph({
|
|
120
|
+
spacing: { before: 120, after: 160 },
|
|
121
|
+
children: [
|
|
122
|
+
new TextRun({ text: `[imagen no soportada: ${alt}]`, italics: true, color: '808080' })
|
|
123
|
+
]
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderInline(children, state) {
|
|
131
|
+
const runs = [];
|
|
132
|
+
const images = [];
|
|
133
|
+
for (const child of children) {
|
|
134
|
+
switch (child.type) {
|
|
135
|
+
case 'text':
|
|
136
|
+
if (child.content) runs.push(makeRun(child.content, state));
|
|
137
|
+
break;
|
|
138
|
+
case 'code_inline':
|
|
139
|
+
runs.push(
|
|
140
|
+
new TextRun({
|
|
141
|
+
text: child.content,
|
|
142
|
+
font: CODE_FONT,
|
|
143
|
+
shading: { type: ShadingType.CLEAR, fill: 'EFEFEF' },
|
|
144
|
+
bold: state.bold || undefined,
|
|
145
|
+
italics: state.italic || undefined,
|
|
146
|
+
color: state.quoteDepth > 0 ? '595959' : undefined
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
break;
|
|
150
|
+
case 'strong_open':
|
|
151
|
+
state.bold = true;
|
|
152
|
+
break;
|
|
153
|
+
case 'strong_close':
|
|
154
|
+
state.bold = false;
|
|
155
|
+
break;
|
|
156
|
+
case 'em_open':
|
|
157
|
+
state.italic = true;
|
|
158
|
+
break;
|
|
159
|
+
case 'em_close':
|
|
160
|
+
state.italic = false;
|
|
161
|
+
break;
|
|
162
|
+
case 'link_open':
|
|
163
|
+
state.href = child.attrGet('href');
|
|
164
|
+
break;
|
|
165
|
+
case 'link_close':
|
|
166
|
+
state.href = null;
|
|
167
|
+
break;
|
|
168
|
+
case 'image':
|
|
169
|
+
images.push({ src: child.attrGet('src') ?? '', alt: child.content ?? '' });
|
|
170
|
+
break;
|
|
171
|
+
case 'softbreak':
|
|
172
|
+
case 'hardbreak':
|
|
173
|
+
runs.push(new TextRun({ break: 1 }));
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { runs, images };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function pushInlineBlock(out, children, state, spacing = {}) {
|
|
183
|
+
const result = renderInline(children, state);
|
|
184
|
+
const options = paragraphOptions(state, spacing);
|
|
185
|
+
if (result.images.length) {
|
|
186
|
+
if (result.runs.length) out.push(new Paragraph({ ...options, children: result.runs }));
|
|
187
|
+
pushImages(out, result.images, state);
|
|
188
|
+
} else if (result.runs.length) {
|
|
189
|
+
out.push(new Paragraph({ ...options, children: result.runs }));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Word fusiona los bordes de párrafos contiguos idénticos en un solo recuadro.
|
|
194
|
+
const CODE_BORDER = {
|
|
195
|
+
top: { style: BorderStyle.SINGLE, size: 4, color: 'D0D7DE', space: 4 },
|
|
196
|
+
bottom: { style: BorderStyle.SINGLE, size: 4, color: 'D0D7DE', space: 4 },
|
|
197
|
+
left: { style: BorderStyle.SINGLE, size: 4, color: 'D0D7DE', space: 10 },
|
|
198
|
+
right: { style: BorderStyle.SINGLE, size: 4, color: 'D0D7DE', space: 10 }
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function pushCodeBlock(out, content, info) {
|
|
202
|
+
const segments = highlightSegments(content.replace(/\n$/, ''), info);
|
|
203
|
+
const lines = splitSegmentsByLines(segments);
|
|
204
|
+
lines.forEach((segs, index) => {
|
|
205
|
+
out.push(
|
|
206
|
+
new Paragraph({
|
|
207
|
+
shading: { type: ShadingType.CLEAR, fill: 'F2F2F2' },
|
|
208
|
+
border: CODE_BORDER,
|
|
209
|
+
spacing: {
|
|
210
|
+
before: index === 0 ? 200 : 0,
|
|
211
|
+
after: index === lines.length - 1 ? 200 : 0,
|
|
212
|
+
line: 240
|
|
213
|
+
},
|
|
214
|
+
indent: { left: 120, right: 120 },
|
|
215
|
+
children: segs.length
|
|
216
|
+
? segs.map((seg) => new TextRun({
|
|
217
|
+
text: seg.text,
|
|
218
|
+
font: CODE_FONT,
|
|
219
|
+
size: 18,
|
|
220
|
+
color: seg.color ?? '1F2328'
|
|
221
|
+
}))
|
|
222
|
+
: [new TextRun({ text: ' ', font: CODE_FONT, size: 18, color: '1F2328' })]
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderTable(tokens, state) {
|
|
229
|
+
const rows = [];
|
|
230
|
+
let inHead = false;
|
|
231
|
+
let cells = null;
|
|
232
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
233
|
+
const token = tokens[i];
|
|
234
|
+
switch (token.type) {
|
|
235
|
+
case 'thead_open':
|
|
236
|
+
inHead = true;
|
|
237
|
+
break;
|
|
238
|
+
case 'thead_close':
|
|
239
|
+
inHead = false;
|
|
240
|
+
break;
|
|
241
|
+
case 'tr_open':
|
|
242
|
+
cells = [];
|
|
243
|
+
break;
|
|
244
|
+
case 'tr_close':
|
|
245
|
+
rows.push({ cells, header: inHead });
|
|
246
|
+
cells = null;
|
|
247
|
+
break;
|
|
248
|
+
case 'th_open':
|
|
249
|
+
case 'td_open': {
|
|
250
|
+
const inline = tokens[i + 1];
|
|
251
|
+
cells.push(renderInline(inline?.children ?? [], state).runs);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return new Table({
|
|
259
|
+
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
260
|
+
rows: rows.map((row) =>
|
|
261
|
+
new TableRow({
|
|
262
|
+
tableHeader: row.header || undefined,
|
|
263
|
+
children: row.cells.map((runs) =>
|
|
264
|
+
new TableCell({
|
|
265
|
+
shading: row.header ? { type: ShadingType.CLEAR, fill: 'F2F2F2' } : undefined,
|
|
266
|
+
margins: { top: 60, bottom: 60, left: 110, right: 110 },
|
|
267
|
+
children: [new Paragraph({ children: runs })]
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
})
|
|
271
|
+
)
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderListItems(tokens, state, ordered, instance, level) {
|
|
276
|
+
const out = [];
|
|
277
|
+
let i = 0;
|
|
278
|
+
while (i < tokens.length) {
|
|
279
|
+
if (tokens[i].type !== 'list_item_open') {
|
|
280
|
+
i++;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const close = findClose(tokens, i, 'list_item_open', 'list_item_close');
|
|
284
|
+
const previous = state.listContext;
|
|
285
|
+
state.listContext = ordered
|
|
286
|
+
? { numbering: { reference: 'openmd-ordered', level: Math.min(level, 5), instance } }
|
|
287
|
+
: { bullet: { level: Math.min(level, 8) } };
|
|
288
|
+
out.push(...renderBlocks(tokens.slice(i + 1, close), state));
|
|
289
|
+
state.listContext = previous;
|
|
290
|
+
i = close + 1;
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function renderBlocks(tokens, state) {
|
|
296
|
+
const out = [];
|
|
297
|
+
let i = 0;
|
|
298
|
+
while (i < tokens.length) {
|
|
299
|
+
const token = tokens[i];
|
|
300
|
+
switch (token.type) {
|
|
301
|
+
case 'inline':
|
|
302
|
+
i++;
|
|
303
|
+
pushInlineBlock(out, token.children ?? [], state, { after: 40 });
|
|
304
|
+
break;
|
|
305
|
+
case 'heading_open': {
|
|
306
|
+
const level = Number(token.tag.slice(1));
|
|
307
|
+
const inline = tokens[i + 1];
|
|
308
|
+
i += 3;
|
|
309
|
+
const result = renderInline(inline?.children ?? [], state);
|
|
310
|
+
out.push(
|
|
311
|
+
new Paragraph({
|
|
312
|
+
heading: HEADING_LEVELS[level] ?? HEADING_LEVELS[6],
|
|
313
|
+
children: result.runs
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
if (result.images.length) pushImages(out, result.images, state);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'paragraph_open': {
|
|
320
|
+
const inline = tokens[i + 1];
|
|
321
|
+
i += 3;
|
|
322
|
+
pushInlineBlock(out, inline?.children ?? [], state, token.hidden ? { after: 40 } : {});
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case 'fence':
|
|
326
|
+
case 'code_block':
|
|
327
|
+
i++;
|
|
328
|
+
pushCodeBlock(out, token.content, token.info);
|
|
329
|
+
break;
|
|
330
|
+
case 'blockquote_open': {
|
|
331
|
+
const close = findClose(tokens, i, 'blockquote_open', 'blockquote_close');
|
|
332
|
+
state.quoteDepth++;
|
|
333
|
+
out.push(...renderBlocks(tokens.slice(i + 1, close), state));
|
|
334
|
+
state.quoteDepth--;
|
|
335
|
+
i = close + 1;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case 'bullet_list_open':
|
|
339
|
+
case 'ordered_list_open': {
|
|
340
|
+
const ordered = token.type === 'ordered_list_open';
|
|
341
|
+
const close = findClose(tokens, i, token.type, `${token.type.slice(0, -5)}_close`);
|
|
342
|
+
state.listLevel = (state.listLevel ?? 0) + 1;
|
|
343
|
+
const instance = ordered ? state.orderedInstance++ : null;
|
|
344
|
+
out.push(
|
|
345
|
+
...renderListItems(tokens.slice(i + 1, close), state, ordered, instance, state.listLevel - 1)
|
|
346
|
+
);
|
|
347
|
+
state.listLevel--;
|
|
348
|
+
i = close + 1;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case 'hr':
|
|
352
|
+
i++;
|
|
353
|
+
out.push(
|
|
354
|
+
new Paragraph({
|
|
355
|
+
spacing: { before: 120, after: 240 },
|
|
356
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'D0D0D0', space: 1 } },
|
|
357
|
+
children: []
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
break;
|
|
361
|
+
case 'table_open': {
|
|
362
|
+
const close = findClose(tokens, i, 'table_open', 'table_close');
|
|
363
|
+
out.push(renderTable(tokens.slice(i + 1, close), state));
|
|
364
|
+
i = close + 1;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
default:
|
|
368
|
+
i++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function markdownToDocx(tokens, ctx) {
|
|
375
|
+
const state = {
|
|
376
|
+
bold: false,
|
|
377
|
+
italic: false,
|
|
378
|
+
href: null,
|
|
379
|
+
quoteDepth: 0,
|
|
380
|
+
listLevel: 0,
|
|
381
|
+
listContext: null,
|
|
382
|
+
orderedInstance: 0,
|
|
383
|
+
images: ctx.images
|
|
384
|
+
};
|
|
385
|
+
const doc = new Document({
|
|
386
|
+
title: ctx.title,
|
|
387
|
+
creator: 'openmd',
|
|
388
|
+
styles: {
|
|
389
|
+
default: {
|
|
390
|
+
document: { run: { font: 'Calibri', size: 22, color: '1F2328' } },
|
|
391
|
+
heading1: { run: { size: 40, bold: true, color: '1F2328' }, paragraph: { spacing: { before: 280, after: 160 } } },
|
|
392
|
+
heading2: { run: { size: 32, bold: true, color: '1F2328' }, paragraph: { spacing: { before: 280, after: 140 } } },
|
|
393
|
+
heading3: { run: { size: 26, bold: true, color: '1F2328' }, paragraph: { spacing: { before: 240, after: 120 } } },
|
|
394
|
+
heading4: { run: { size: 24, bold: true, color: '1F2328' }, paragraph: { spacing: { before: 240, after: 120 } } },
|
|
395
|
+
heading5: { run: { size: 22, bold: true, color: '1F2328' }, paragraph: { spacing: { before: 200, after: 120 } } },
|
|
396
|
+
heading6: { run: { size: 22, bold: true, color: '595959' }, paragraph: { spacing: { before: 200, after: 120 } } }
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
numbering: { config: [{ reference: 'openmd-ordered', levels: numberingLevels() }] },
|
|
400
|
+
sections: [{ properties: {}, children: renderBlocks(tokens, state) }]
|
|
401
|
+
});
|
|
402
|
+
return Packer.toBuffer(doc);
|
|
403
|
+
}
|