@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.
Files changed (3) hide show
  1. package/README.md +23 -0
  2. package/lib/md2doc.js +263 -0
  3. 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
+ ![block diagram](assets/block.png) <!-- inlined -->
87
+ <img src="assets/block.png" width="400"> <!-- inlined, attributes preserved -->
88
+ ![remote](https://example.com/x.png) <!-- left as a remote URL -->
89
+ ```
90
+
91
+ `srcset` and `<source>` inside `<picture>` are inlined too. Only known image
92
+ extensions are inlined, so `![x](../../id_rsa)` 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, `![x](../../../.ssh/id_rsa)` 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(/&amp;/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, '&#39;');
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.0",
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",