@helping-ai-workflow/md2doc 2.4.0 → 2.4.2
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/README.md +23 -0
- package/lib/md2doc.js +263 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -75,6 +75,29 @@ also pass `--open`.
|
|
|
75
75
|
| `--version`, `-v` | Print version. |
|
|
76
76
|
| `--help`, `-h` | Print help. |
|
|
77
77
|
|
|
78
|
+
### Images
|
|
79
|
+
|
|
80
|
+
Local images referenced from the markdown are resolved against the **markdown file's own
|
|
81
|
+
directory** and inlined into the output as base64 `data:` URIs, so the rendered HTML / PDF
|
|
82
|
+
stays self-contained wherever it is written (the OS temp dir by default) and however it is
|
|
83
|
+
later copied or mailed.
|
|
84
|
+
|
|
85
|
+
```markdown
|
|
86
|
+
 <!-- inlined -->
|
|
87
|
+
<img src="assets/block.png" width="400"> <!-- inlined, attributes preserved -->
|
|
88
|
+
 <!-- left as a remote URL -->
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`srcset` and `<source>` inside `<picture>` are inlined too. Only known image
|
|
92
|
+
extensions are inlined, so `` is left alone rather than
|
|
93
|
+
base64'd into a document you may be about to share.
|
|
94
|
+
|
|
95
|
+
A reference with no file on disk keeps its original `src` and prints
|
|
96
|
+
`[WARN] image not found, left as-is: ...` on stderr; the render still succeeds.
|
|
97
|
+
|
|
98
|
+
Each reference carries its own copy of the payload, so re-using one large diagram
|
|
99
|
+
in several places grows the HTML accordingly. PDF output is unaffected.
|
|
100
|
+
|
|
78
101
|
### Migration from md2html / md2pdf (v1.x → v2.0.0)
|
|
79
102
|
|
|
80
103
|
| Old | New |
|
package/lib/md2doc.js
CHANGED
|
@@ -129,6 +129,169 @@ function buildKatexStyleTag() {
|
|
|
129
129
|
return `<style data-md2doc-math>${css}</style>`;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// ── Local image assets ───────────────────────────────────────────────────────
|
|
133
|
+
// Image srcs in the markdown are relative to the SOURCE markdown, but the HTML
|
|
134
|
+
// is normally written somewhere else entirely (the OS temp dir by default), so
|
|
135
|
+
// a relative src resolves against the wrong directory and the image silently
|
|
136
|
+
// never loads. Resolve every local reference against the markdown's own
|
|
137
|
+
// directory and inline it as a data: URI — the same self-contained principle
|
|
138
|
+
// the embedded CSS / KaTeX fonts already follow, and the only form that also
|
|
139
|
+
// survives the puppeteer PDF path (which renders from its own temp HTML).
|
|
140
|
+
const SRC_DIR = path.dirname(path.resolve(src));
|
|
141
|
+
const LARGE_ASSET_WARN_BYTES = 4 * 1024 * 1024;
|
|
142
|
+
|
|
143
|
+
// Doubles as the allowlist: a reference whose extension is not an image is left
|
|
144
|
+
// alone. Without that gate, `` would happily base64
|
|
145
|
+
// the file into a document meant to be shared.
|
|
146
|
+
const IMAGE_MIME_BY_EXT = {
|
|
147
|
+
'.png': 'image/png',
|
|
148
|
+
'.jpg': 'image/jpeg',
|
|
149
|
+
'.jpeg': 'image/jpeg',
|
|
150
|
+
'.jfif': 'image/jpeg',
|
|
151
|
+
'.gif': 'image/gif',
|
|
152
|
+
'.svg': 'image/svg+xml',
|
|
153
|
+
'.webp': 'image/webp',
|
|
154
|
+
'.avif': 'image/avif',
|
|
155
|
+
'.bmp': 'image/bmp',
|
|
156
|
+
'.ico': 'image/x-icon',
|
|
157
|
+
'.apng': 'image/apng',
|
|
158
|
+
'.tif': 'image/tiff',
|
|
159
|
+
'.tiff': 'image/tiff',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const inlinedAssetCache = new Map();
|
|
163
|
+
const skippedAssetWarned = new Set();
|
|
164
|
+
|
|
165
|
+
// Only a scheme we actually know is treated as remote — a bare `letter+colon`
|
|
166
|
+
// test would misread both a Windows drive (`C:/img.png`) and a filename that
|
|
167
|
+
// merely contains a colon. `//host/x.png` is protocol-relative.
|
|
168
|
+
const EXTERNAL_REF_RE = /^(?:(?:https?|data|file|blob|ftps?|mailto|tel|about|chrome|chrome-extension|moz-extension):|\/\/)/i;
|
|
169
|
+
|
|
170
|
+
function isExternalRef(href) {
|
|
171
|
+
return EXTERNAL_REF_RE.test(String(href).trim());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function warnOnce(href, message) {
|
|
175
|
+
if (skippedAssetWarned.has(href)) return;
|
|
176
|
+
skippedAssetWarned.add(href);
|
|
177
|
+
console.error(message);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Splits `assets/pic.svg?v=2#gear` into its path and its fragment. The query is
|
|
181
|
+
// dropped (it is cache-busting for a fetch that no longer happens); the
|
|
182
|
+
// fragment is kept and re-attached to the data URI, since it selects a view /
|
|
183
|
+
// symbol inside an SVG rather than addressing the file.
|
|
184
|
+
function splitAssetRef(href) {
|
|
185
|
+
const raw = String(href).trim();
|
|
186
|
+
const hash = raw.indexOf('#');
|
|
187
|
+
const withoutHash = hash === -1 ? raw : raw.slice(0, hash);
|
|
188
|
+
const fragment = hash === -1 ? '' : raw.slice(hash);
|
|
189
|
+
return { filePart: withoutHash.replace(/\?.*$/, ''), fragment };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function resolveAssetPath(filePart) {
|
|
193
|
+
if (!filePart) return null;
|
|
194
|
+
let decoded = filePart;
|
|
195
|
+
try {
|
|
196
|
+
decoded = decodeURIComponent(filePart);
|
|
197
|
+
} catch (_) {
|
|
198
|
+
// Malformed percent-escapes: probe the raw form only.
|
|
199
|
+
}
|
|
200
|
+
const candidates = [];
|
|
201
|
+
for (const c of [decoded, filePart]) {
|
|
202
|
+
candidates.push(path.isAbsolute(c) ? c : path.resolve(SRC_DIR, c));
|
|
203
|
+
}
|
|
204
|
+
return firstExistingPath(candidates);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Returns a data: URI, or null when the reference must be left untouched
|
|
208
|
+
// (remote URL, already a data: URI, non-image extension, or no such file).
|
|
209
|
+
function inlineImageSrc(href) {
|
|
210
|
+
if (!href || isExternalRef(href)) return null;
|
|
211
|
+
const { filePart, fragment } = splitAssetRef(href);
|
|
212
|
+
if (!filePart) return null;
|
|
213
|
+
|
|
214
|
+
const mime = IMAGE_MIME_BY_EXT[path.extname(filePart).toLowerCase()];
|
|
215
|
+
if (!mime) {
|
|
216
|
+
warnOnce(href, `[WARN] not a known image extension, left as-is: ${href}`);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const abs = resolveAssetPath(filePart);
|
|
221
|
+
if (!abs) {
|
|
222
|
+
warnOnce(href, `[WARN] image not found, left as-is: ${href} (resolved against ${SRC_DIR})`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let uri = inlinedAssetCache.get(abs);
|
|
227
|
+
if (uri === undefined) {
|
|
228
|
+
uri = null;
|
|
229
|
+
try {
|
|
230
|
+
const stat = fs.statSync(abs);
|
|
231
|
+
if (!stat.isFile()) throw new Error('not a regular file');
|
|
232
|
+
if (stat.size > LARGE_ASSET_WARN_BYTES) {
|
|
233
|
+
console.error(`[WARN] inlining large image (${(stat.size / 1048576).toFixed(1)} MB): ${href}`);
|
|
234
|
+
}
|
|
235
|
+
uri = `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.error(`[WARN] could not inline image ${href}: ${e.message}`);
|
|
238
|
+
uri = null;
|
|
239
|
+
}
|
|
240
|
+
inlinedAssetCache.set(abs, uri);
|
|
241
|
+
}
|
|
242
|
+
return uri ? uri + fragment : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
|
|
246
|
+
function inlineSrcsetValue(value) {
|
|
247
|
+
const parts = String(value).split(',');
|
|
248
|
+
let changed = false;
|
|
249
|
+
const rebuilt = parts.map((part) => {
|
|
250
|
+
const m = part.match(/^(\s*)(\S+)(\s*.*)$/);
|
|
251
|
+
if (!m) return part;
|
|
252
|
+
const uri = inlineImageSrc(m[2]);
|
|
253
|
+
if (!uri) return part;
|
|
254
|
+
changed = true;
|
|
255
|
+
return m[1] + uri + m[3];
|
|
256
|
+
});
|
|
257
|
+
return changed ? rebuilt.join(',') : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Author-written <img> / <source> tags (specs use them for width= and for
|
|
261
|
+
// <picture>) need the same rewrite. Scoped to markdown-authored HTML chunks
|
|
262
|
+
// only — never run over the whole page, whose bundled mermaid / katex JS also
|
|
263
|
+
// contains '<img' literals.
|
|
264
|
+
// The tag matcher is attribute-aware: a quoted value may legally contain '>',
|
|
265
|
+
// and a plain [^>]* would truncate the tag and miss a src that sits after it.
|
|
266
|
+
const ASSET_TAG_RE = /<(?:img|source)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
267
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
268
|
+
const SRC_ATTR_RE = /(\s(?:src|srcset)\s*=\s*)(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
|
|
269
|
+
|
|
270
|
+
function inlineImagesInTag(tag) {
|
|
271
|
+
return tag.replace(SRC_ATTR_RE, (match, lead, quote, quoted, bare) => {
|
|
272
|
+
const raw = (quoted !== undefined ? quoted : bare) || '';
|
|
273
|
+
const decoded = raw.replace(/&/g, '&');
|
|
274
|
+
const isSrcset = /srcset\s*=\s*$/i.test(lead);
|
|
275
|
+
const value = isSrcset ? inlineSrcsetValue(decoded) : inlineImageSrc(decoded);
|
|
276
|
+
return value ? `${lead}"${value}"` : match;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function inlineImagesInHtmlChunk(chunk) {
|
|
281
|
+
if (!chunk || !/<(?:img|source)\b/i.test(chunk)) return chunk;
|
|
282
|
+
// Step over comments untouched — a commented-out <img> is not displayed, so
|
|
283
|
+
// inlining it would only bloat the output (or smuggle a file into it).
|
|
284
|
+
let out = '';
|
|
285
|
+
let last = 0;
|
|
286
|
+
HTML_COMMENT_RE.lastIndex = 0;
|
|
287
|
+
let m;
|
|
288
|
+
while ((m = HTML_COMMENT_RE.exec(chunk)) !== null) {
|
|
289
|
+
out += chunk.slice(last, m.index).replace(ASSET_TAG_RE, inlineImagesInTag) + m[0];
|
|
290
|
+
last = m.index + m[0].length;
|
|
291
|
+
}
|
|
292
|
+
return out + chunk.slice(last).replace(ASSET_TAG_RE, inlineImagesInTag);
|
|
293
|
+
}
|
|
294
|
+
|
|
132
295
|
let bodyHtml;
|
|
133
296
|
let tocHtml = '';
|
|
134
297
|
let serializedSections = '[]';
|
|
@@ -325,6 +488,29 @@ ${itemsHtml}
|
|
|
325
488
|
.replace(/'/g, ''');
|
|
326
489
|
}
|
|
327
490
|
|
|
491
|
+
// marked's tokenizer already escapes alt / title (its default image renderer
|
|
492
|
+
// interpolates both raw), so this must NOT escape them again. Only the src is
|
|
493
|
+
// ours; everything else — including cleanUrl() on a src we leave alone — stays
|
|
494
|
+
// marked's job, so non-inlined images render exactly as before.
|
|
495
|
+
const baseImage = renderer.image.bind(renderer);
|
|
496
|
+
renderer.image = function(token) {
|
|
497
|
+
const isObj = (token !== null && typeof token === 'object');
|
|
498
|
+
const href = isObj ? (token.href || '') : (token || '');
|
|
499
|
+
const title = isObj ? (token.title || '') : (arguments[1] || '');
|
|
500
|
+
const text = isObj ? (token.text || '') : (arguments[2] || '');
|
|
501
|
+
const uri = inlineImageSrc(href);
|
|
502
|
+
if (!uri) return baseImage.apply(this, arguments);
|
|
503
|
+
// A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
|
|
504
|
+
let out = `<img src="${uri}" alt="${text}"`;
|
|
505
|
+
if (title) out += ` title="${title}"`;
|
|
506
|
+
return out + '>';
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const baseHtml = renderer.html.bind(renderer);
|
|
510
|
+
renderer.html = function(token) {
|
|
511
|
+
return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
|
|
512
|
+
};
|
|
513
|
+
|
|
328
514
|
renderer.code = function(token) {
|
|
329
515
|
// token is either a string (old API) or {text, lang} object (new API)
|
|
330
516
|
const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
|
|
@@ -1682,6 +1868,83 @@ ${mermaidInitTag}` : ''}
|
|
|
1682
1868
|
setSidebarOpen(false);
|
|
1683
1869
|
}
|
|
1684
1870
|
});
|
|
1871
|
+
|
|
1872
|
+
// ── Zoom / resize scroll anchoring ────────────────────────────────────────
|
|
1873
|
+
// Browser zoom (and any window resize) reflows the column but leaves the
|
|
1874
|
+
// pixel scroll offset untouched, so the passage being read slides out of
|
|
1875
|
+
// view. Remember what sat at the top of the reading column and put it back.
|
|
1876
|
+
var ANCHOR_LINE = 80;
|
|
1877
|
+
var contentEl = document.querySelector('main.content');
|
|
1878
|
+
var anchorNode = null;
|
|
1879
|
+
var anchorTop = 0;
|
|
1880
|
+
var anchorFrame = 0;
|
|
1881
|
+
var suppressAnchorCapture = false;
|
|
1882
|
+
var lastLayoutWidth = document.documentElement.clientWidth;
|
|
1883
|
+
var lastPixelRatio = window.devicePixelRatio;
|
|
1884
|
+
|
|
1885
|
+
function captureScrollAnchor() {
|
|
1886
|
+
if (suppressAnchorCapture || !contentEl) return;
|
|
1887
|
+
var rect = contentEl.getBoundingClientRect();
|
|
1888
|
+
var hit = document.elementFromPoint(rect.left + rect.width / 2, ANCHOR_LINE);
|
|
1889
|
+
var node = (hit && hit.closest) ? hit.closest('main.content > *') : null;
|
|
1890
|
+
if (!node) {
|
|
1891
|
+
// Between blocks, or over a gap: fall back to the last heading above the
|
|
1892
|
+
// anchor line. Heading offsets are monotonic in document order, so this
|
|
1893
|
+
// is a binary search rather than a scan of every heading each frame.
|
|
1894
|
+
var lo = 0;
|
|
1895
|
+
var hi = headingNodes.length - 1;
|
|
1896
|
+
while (lo <= hi) {
|
|
1897
|
+
var mid = (lo + hi) >> 1;
|
|
1898
|
+
if (headingNodes[mid].getBoundingClientRect().top <= ANCHOR_LINE) {
|
|
1899
|
+
node = headingNodes[mid];
|
|
1900
|
+
lo = mid + 1;
|
|
1901
|
+
} else {
|
|
1902
|
+
hi = mid - 1;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
if (!node) return;
|
|
1907
|
+
anchorNode = node;
|
|
1908
|
+
anchorTop = node.getBoundingClientRect().top;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function restoreScrollAnchor() {
|
|
1912
|
+
if (!anchorNode || !anchorNode.isConnected) return;
|
|
1913
|
+
var delta = anchorNode.getBoundingClientRect().top - anchorTop;
|
|
1914
|
+
if (!delta) return;
|
|
1915
|
+
window.scrollTo(window.scrollX, window.scrollY + delta);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
window.addEventListener('scroll', function () {
|
|
1919
|
+
if (anchorFrame) return;
|
|
1920
|
+
anchorFrame = window.requestAnimationFrame(function () {
|
|
1921
|
+
anchorFrame = 0;
|
|
1922
|
+
captureScrollAnchor();
|
|
1923
|
+
});
|
|
1924
|
+
}, { passive: true });
|
|
1925
|
+
|
|
1926
|
+
window.addEventListener('resize', function () {
|
|
1927
|
+
var width = document.documentElement.clientWidth;
|
|
1928
|
+
var ratio = window.devicePixelRatio;
|
|
1929
|
+
// A height-only change (a mobile browser hiding its toolbar, a devtools
|
|
1930
|
+
// dock) reflows nothing, so correcting the scroll would only jerk the page.
|
|
1931
|
+
if (width === lastLayoutWidth && ratio === lastPixelRatio) return;
|
|
1932
|
+
lastLayoutWidth = width;
|
|
1933
|
+
lastPixelRatio = ratio;
|
|
1934
|
+
// Hold the anchor across the whole reflow: the scrollTo below fires scroll
|
|
1935
|
+
// events that would otherwise re-capture a mid-reflow position.
|
|
1936
|
+
suppressAnchorCapture = true;
|
|
1937
|
+
restoreScrollAnchor();
|
|
1938
|
+
window.requestAnimationFrame(function () {
|
|
1939
|
+
restoreScrollAnchor();
|
|
1940
|
+
window.requestAnimationFrame(function () {
|
|
1941
|
+
suppressAnchorCapture = false;
|
|
1942
|
+
captureScrollAnchor();
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
captureScrollAnchor();
|
|
1685
1948
|
})();
|
|
1686
1949
|
</script>
|
|
1687
1950
|
</body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helping-ai-workflow/md2doc",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.2",
|
|
4
4
|
"description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"preinstall": "node scripts/preinstall.js",
|
|
39
|
-
"test": "node test/md2doc.test.js && node test/cli.test.js && node test/code-operator.test.js"
|
|
39
|
+
"test": "node test/md2doc.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/cli.test.js && node test/code-operator.test.js"
|
|
40
40
|
},
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|