@link-assistant/web-capture 1.7.23 → 1.7.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.25
4
+
5
+ ### Patch Changes
6
+
7
+ - 024cb45: Fix `extractAndSaveImages` / `extractBase64ToBuffers` silently dropping base64 images when the markdown image is followed by a title attribute (e.g. `![](data:image/png;base64,... "")`). The base64 payload regex used `[^)]+`, which greedily included the trailing ` ""` and made `Buffer.from()` produce a decoded buffer that no longer matched the original image. The pattern now restricts the payload to base64 alphabet characters and matches the optional trailing markdown title separately so the title cannot leak into the payload.
8
+
9
+ ## 1.7.24
10
+
11
+ ### Patch Changes
12
+
13
+ - 52ccb91: Fix structurally broken bold markup in `--capture api` output. Adjacent `<span style="font-weight:700">` siblings that the Google Docs export emits for headings, list items, and notes used to be wrapped in independent `<strong>...</strong>` pairs, producing split runs (`**13.1** **First subsection**`), stray `****` between adjacent bold blocks, and bold ranges that leaked across `<br>`/`<img>` boundaries (`**Caption A:![](images/x.png)Caption B:**`). The Google Docs preprocessor now closes `<strong>` runs at `<br>`/`<img>` boundaries, splits the wrapping `<p>` into separate paragraphs at those boundaries, drops empty `<strong></strong>` pairs, and coalesces adjacent bold siblings into a single run. Renderers can now interpret the output as valid CommonMark again.
14
+
3
15
  ## 1.7.23
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.23",
3
+ "version": "1.7.25",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,8 +2,11 @@ import { createHash } from 'crypto';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
 
5
+ // Capture groups: alt text, mime subtype, base64 payload. A trailing
6
+ // markdown title attribute (e.g. `![](src "")`) is matched but discarded so
7
+ // the empty title cannot leak into the base64 payload.
5
8
  const BASE64_IMAGE_REGEX =
6
- /!\[([^\]]*)\]\(data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([^)]+)\)/gi;
9
+ /!\[([^\]]*)\]\(data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([A-Za-z0-9+/=]+)(?:\s+"[^"]*")?\)/gi;
7
10
 
8
11
  /**
9
12
  * @param {string} markdown - Markdown content with data URI images
@@ -62,6 +62,11 @@ export function preprocessGoogleDocsExportHtml(html) {
62
62
  hoisted += 1;
63
63
  });
64
64
 
65
+ splitStrongAtBlockBoundaries($);
66
+ splitParagraphsAtBoldBoundaries($);
67
+ removeEmptyStrong($);
68
+ coalesceAdjacentStrong($);
69
+
65
70
  groupGoogleDocsBlockquotes($, classStyles);
66
71
  nestGoogleDocsLists($, classStyles);
67
72
  normalizeGoogleDocsTables($);
@@ -144,6 +149,172 @@ export function normalizeGoogleDocsExportMarkdown(markdown) {
144
149
  .replace(/\n{3,}/g, '\n\n');
145
150
  }
146
151
 
152
+ // Issue #120: a `<strong>` whose closing tag would land in a different block
153
+ // boundary leaks bolding across paragraphs and images. Split such a strong
154
+ // into multiple bold runs that each stop at the boundary.
155
+ function splitStrongAtBlockBoundaries($) {
156
+ let changed = true;
157
+ while (changed) {
158
+ changed = false;
159
+ const offenders = $('strong').filter(function () {
160
+ return $(this).find('br, img, p, div, li').length > 0;
161
+ });
162
+ if (!offenders.length) {
163
+ break;
164
+ }
165
+ offenders.each(function () {
166
+ const $strong = $(this);
167
+ const segments = [];
168
+ let current = [];
169
+ const flush = () => {
170
+ if (current.length) {
171
+ segments.push({ type: 'inline', nodes: current });
172
+ current = [];
173
+ }
174
+ };
175
+ $strong.contents().each(function () {
176
+ if (this.type === 'tag' && this.tagName === 'br') {
177
+ flush();
178
+ segments.push({ type: 'br' });
179
+ } else if (this.type === 'tag' && this.tagName === 'img') {
180
+ flush();
181
+ segments.push({ type: 'img', node: this });
182
+ } else if (
183
+ this.type === 'tag' &&
184
+ ['p', 'div', 'li'].includes(this.tagName)
185
+ ) {
186
+ flush();
187
+ segments.push({ type: 'block', node: this });
188
+ } else {
189
+ current.push(this);
190
+ }
191
+ });
192
+ flush();
193
+
194
+ const pieces = segments.map((segment) => {
195
+ if (segment.type === 'br') {
196
+ return '<br>';
197
+ }
198
+ if (segment.type === 'img') {
199
+ return $.html(segment.node);
200
+ }
201
+ if (segment.type === 'block') {
202
+ const inner = $(segment.node).html() || '';
203
+ const tag = segment.node.tagName;
204
+ const innerWrapped = inner.trim() ? `<strong>${inner}</strong>` : '';
205
+ return `<${tag}>${innerWrapped}</${tag}>`;
206
+ }
207
+ const inner = segment.nodes.map((node) => $.html(node)).join('');
208
+ if (!inner.trim()) {
209
+ return inner;
210
+ }
211
+ return `<strong>${inner}</strong>`;
212
+ });
213
+ $strong.replaceWith(pieces.join(''));
214
+ changed = true;
215
+ });
216
+ }
217
+ }
218
+
219
+ // Issue #120: when a `<p>` contains a `<br>` adjacent to a `<strong>` or an
220
+ // `<img>`, split it into multiple paragraphs so each bold run and image
221
+ // becomes its own block. Without this, the markdown converter renders
222
+ // `**Caption** \n![](x)` (inline) instead of separate blocks.
223
+ function splitParagraphsAtBoldBoundaries($) {
224
+ $('p').each(function () {
225
+ const $p = $(this);
226
+ const contents = $p.contents().toArray();
227
+ if (!contents.some((n) => n.type === 'tag' && n.tagName === 'br')) {
228
+ return;
229
+ }
230
+ const hasStrongOrImg = contents.some(
231
+ (n) => n.type === 'tag' && (n.tagName === 'strong' || n.tagName === 'img')
232
+ );
233
+ if (!hasStrongOrImg) {
234
+ return;
235
+ }
236
+ const segments = [];
237
+ let current = [];
238
+ const flush = () => {
239
+ const html = current
240
+ .map((node) => $.html(node))
241
+ .join('')
242
+ .trim();
243
+ if (html) {
244
+ segments.push(html);
245
+ }
246
+ current = [];
247
+ };
248
+ for (const node of contents) {
249
+ if (node.type === 'tag' && node.tagName === 'br') {
250
+ flush();
251
+ } else if (node.type === 'tag' && node.tagName === 'img') {
252
+ flush();
253
+ segments.push($.html(node));
254
+ } else {
255
+ current.push(node);
256
+ }
257
+ }
258
+ flush();
259
+ if (segments.length <= 1) {
260
+ return;
261
+ }
262
+ const replacement = segments.map((html) => `<p>${html}</p>`).join('');
263
+ $p.replaceWith(replacement);
264
+ });
265
+ }
266
+
267
+ // Issue #120: drop empty `<strong></strong>` so they cannot pair with a
268
+ // neighbour and emit `****`.
269
+ function removeEmptyStrong($) {
270
+ $('strong').each(function () {
271
+ const $strong = $(this);
272
+ if (!$strong.html() || !$strong.text().trim()) {
273
+ if (!$strong.find('img').length) {
274
+ $strong.remove();
275
+ }
276
+ }
277
+ });
278
+ }
279
+
280
+ // Issue #120: merge adjacent `<strong>` siblings (optionally separated by
281
+ // whitespace text nodes) into a single `<strong>` so the converter emits
282
+ // `**a b**` instead of `**a** **b**`.
283
+ function coalesceAdjacentStrong($) {
284
+ let changed = true;
285
+ while (changed) {
286
+ changed = false;
287
+ $('strong').each(function () {
288
+ const $strong = $(this);
289
+ if (!$strong.parent().length) {
290
+ return;
291
+ }
292
+ let sibling = this.next;
293
+ const between = [];
294
+ while (sibling) {
295
+ if (sibling.type === 'text' && /^\s*$/.test(sibling.data || '')) {
296
+ between.push(sibling);
297
+ sibling = sibling.next;
298
+ continue;
299
+ }
300
+ if (sibling.type === 'tag' && sibling.tagName === 'strong') {
301
+ const innerSelf = $strong.html() || '';
302
+ const innerNext = $(sibling).html() || '';
303
+ const joiner = between.map((node) => node.data || '').join('');
304
+ $strong.html(`${innerSelf}${joiner}${innerNext}`);
305
+ for (const node of between) {
306
+ $(node).remove();
307
+ }
308
+ $(sibling).remove();
309
+ changed = true;
310
+ return;
311
+ }
312
+ break;
313
+ }
314
+ });
315
+ }
316
+ }
317
+
147
318
  function parseCssClassStyles($) {
148
319
  const classStyles = new Map();
149
320
  $('style').each(function () {
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <!-- Symptom A: two adjacent bold spans -->
5
+ <p>
6
+ <span style="font-weight: 700">13.1 </span
7
+ ><span style="font-weight: 700">First subsection</span>
8
+ </p>
9
+
10
+ <!-- Symptom B: outer bold ends, inner bold begins, no separator -->
11
+ <ol>
12
+ <li><span style="font-weight: 700">Item header.</span></li>
13
+ </ol>
14
+ <p><span style="font-weight: 700">Note:</span> body text.</p>
15
+
16
+ <!-- Symptom C: bold range spans across <br> and <img> -->
17
+ <p>
18
+ <span style="font-weight: 700"
19
+ >Caption A:<img
20
+ alt=""
21
+ src="data:image/png;base64,iVBORw0KGgo=" /><br />Caption B:<img
22
+ alt=""
23
+ src="data:image/png;base64,iVBORw0KGgo="
24
+ /></span>
25
+ </p>
26
+ </body>
27
+ </html>
@@ -0,0 +1,54 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import {
5
+ normalizeGoogleDocsExportMarkdown,
6
+ preprocessGoogleDocsExportHtml,
7
+ } from '../../src/gdocs.js';
8
+ import { convertHtmlToMarkdown } from '../../src/lib.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const HTML = fs.readFileSync(
13
+ path.join(__dirname, '..', 'fixtures', 'bold-coalesce-and-leakage.html'),
14
+ 'utf8'
15
+ );
16
+
17
+ describe('bold-run coalescing and block boundary handling (issue #120)', () => {
18
+ let md;
19
+ beforeAll(() => {
20
+ const preprocessed = preprocessGoogleDocsExportHtml(HTML);
21
+ md = normalizeGoogleDocsExportMarkdown(
22
+ convertHtmlToMarkdown(preprocessed.html)
23
+ );
24
+ });
25
+
26
+ it('A: coalesces adjacent bold spans into a single bold run', () => {
27
+ expect(md).toMatch(/\*\*13\.1 First subsection\*\*/);
28
+ expect(md).not.toMatch(/\*\*13\.1\*\*\s+\*\*First subsection\*\*/);
29
+ });
30
+
31
+ it('B: never emits an empty "****" pair between adjacent bold runs', () => {
32
+ expect(md).not.toMatch(/\*{4}/);
33
+ });
34
+
35
+ it('C: closes bold at block-level boundaries (<br>, <img>)', () => {
36
+ // No single bold run may contain an image. CommonMark bold cannot span
37
+ // a blank line (`\n\n`), so the inner content is restricted accordingly.
38
+ const strongRe = /\*\*((?:(?!\*\*|\n\n)[\s\S])+?)\*\*/g;
39
+ const imageRe = /!\[[^\]]*\]\([^)]+\)/;
40
+ for (const match of md.matchAll(strongRe)) {
41
+ expect(imageRe.test(match[1])).toBe(false);
42
+ }
43
+ expect(md).toMatch(/\*\*Caption A:\*\*/);
44
+ expect(md).toMatch(/\*\*Caption B:\*\*/);
45
+ });
46
+
47
+ it('D: every "**" opener has a matching closer (balanced asterisks)', () => {
48
+ const stripped = md
49
+ .replace(/`[^`]*`/g, '')
50
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '');
51
+ const count = (stripped.match(/\*\*/g) || []).length;
52
+ expect(count % 2).toBe(0);
53
+ });
54
+ });
@@ -130,6 +130,26 @@ describe('extract-images module', () => {
130
130
  );
131
131
  });
132
132
 
133
+ it('extracts a base64 image even when followed by an empty markdown title', () => {
134
+ // Google Docs HTML exports embed `<img title="" alt="" src="...">`, and
135
+ // the html2md pipeline can render that as `![](src "")`. The base64
136
+ // extractor must still pull the image out cleanly so the rendered
137
+ // markdown does not carry multi-megabyte data URIs.
138
+ const md = `Hello.\n\n![](data:image/png;base64,${TINY_PNG} "")\n\nEnd.\n`;
139
+ const result = extractAndSaveImages(md, tmpDir);
140
+ expect(result.extracted).toBe(1);
141
+ expect(result.markdown).toMatch(/images\/image-[a-f0-9]+\.png/);
142
+ expect(result.markdown).not.toContain('data:image');
143
+ });
144
+
145
+ it('extracts a base64 image with a non-empty markdown title', () => {
146
+ const md = `Hello.\n\n![alt](data:image/png;base64,${TINY_PNG} "caption")\n\nEnd.\n`;
147
+ const result = extractAndSaveImages(md, tmpDir);
148
+ expect(result.extracted).toBe(1);
149
+ expect(result.markdown).toMatch(/images\/image-[a-f0-9]+\.png/);
150
+ expect(result.markdown).not.toContain('data:image');
151
+ });
152
+
133
153
  it('handles SVG data URIs', () => {
134
154
  const svgContent =
135
155
  '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect fill="red" width="1" height="1"/></svg>';