@link-assistant/web-capture 1.7.9 → 1.7.11

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,46 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
8
+ - 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
9
+ fetches for non-Google-Docs URLs (issue #80):
10
+ - `rust/src/main.rs` now passes `--capture browser` through
11
+ `render_html` for markdown, archive, and html output formats so the
12
+ flag actually launches headless Chrome instead of calling
13
+ `fetch_html`.
14
+ - `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
15
+ launches headless Chrome with `--screenshot` and returns real PNG
16
+ bytes.
17
+ - Adds Rust integration tests that run a local HTTP fixture whose DOM
18
+ is mutated by JavaScript after load and verify the rendered HTML,
19
+ the CLI `--capture browser` markdown output, and the PNG signature
20
+ on the screenshot bytes.
21
+
22
+ No JS package behavior changes in this patch; the Rust CLI is the only
23
+ affected surface.
24
+
25
+ ## 1.7.10
26
+
27
+ ### Patch Changes
28
+
29
+ - fcc031f: Fix remaining Google Docs capture gaps from issue #92 in both the JS and
30
+ Rust CLIs:
31
+ - Browser capture now keeps multi-column table rows intact, renders
32
+ ordered lists with sequential `1. 2. 3.` numbering, and joins same-list
33
+ items with a single newline so tight-list markdown matches the source
34
+ document.
35
+ - Archive mode downloads `docs-images-rt/...` image URLs into the
36
+ archive's `images/` directory and rewrites markdown/html references so
37
+ exports are self-contained.
38
+ - API mode (`--capture api`) runs the export HTML through a shared
39
+ preprocessor that hoists inline bold / italic / strikethrough spans to
40
+ semantic tags, strips Google Docs' heading-numbering spans and empty
41
+ anchor wrappers, unwraps `google.com/url?q=` redirects, and normalizes
42
+ non-breaking spaces.
43
+
3
44
  ## 1.7.9
4
45
 
5
46
  ### Patch Changes
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
459
459
  fetchGoogleDoc,
460
460
  fetchGoogleDocAsMarkdown,
461
461
  fetchGoogleDocFromDocsApi,
462
- captureGoogleDocWithBrowser,
462
+ captureGoogleDocWithBrowserOrFallback,
463
463
  selectGoogleDocsCaptureMethod,
464
464
  } = await import('../src/gdocs.js');
465
465
  const { stripBase64Images } = await import('../src/extract-images.js');
@@ -511,24 +511,60 @@ async function captureUrl(url, options) {
511
511
  // Screenshot/PDF/DOCX formats should use the regular browser pipeline below.
512
512
  } else if (gdocsMethod === 'browser-model') {
513
513
  try {
514
- const result = await captureGoogleDocWithBrowser(absoluteUrl, {
515
- engine,
516
- apiToken,
517
- log,
518
- });
514
+ const result = await captureGoogleDocWithBrowserOrFallback(
515
+ absoluteUrl,
516
+ {
517
+ engine,
518
+ apiToken,
519
+ format: normalizedFormat,
520
+ log,
521
+ onFallback: (err) => {
522
+ console.error(
523
+ `Warning: Google Docs browser capture could not read editor content (${err.message}); falling back to Google Docs export API.`
524
+ );
525
+ },
526
+ }
527
+ );
528
+ const resultMethod = result.fallback ? result.method : gdocsMethod;
529
+ const resultCapture = result.capture || {};
519
530
  log.debug(() => ({
520
- event: 'gdocs.capture.browser-model.rendered',
531
+ event: result.fallback
532
+ ? 'gdocs.capture.browser-model.fallback-rendered'
533
+ : 'gdocs.capture.browser-model.rendered',
521
534
  documentId: result.documentId,
522
- blocks: result.capture?.blocks?.length || 0,
523
- tables: result.capture?.tables?.length || 0,
524
- images: result.capture?.images?.length || 0,
535
+ method: resultMethod,
536
+ blocks: resultCapture.blocks?.length || 0,
537
+ tables: resultCapture.tables?.length || 0,
538
+ images: resultCapture.images?.length || result.images?.length || 0,
525
539
  markdownBytes: Buffer.byteLength(result.markdown || ''),
526
540
  htmlBytes: Buffer.byteLength(result.html || ''),
527
541
  textBytes: Buffer.byteLength(result.text || ''),
528
542
  }));
529
543
  if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
544
+ let archiveResult;
545
+ if (result.fallback) {
546
+ archiveResult = result;
547
+ } else {
548
+ const { localizeGoogleDocsModelImages } =
549
+ await import('../src/gdocs.js');
550
+ const localized = await localizeGoogleDocsModelImages(result, {
551
+ log,
552
+ });
553
+ log.debug(() => ({
554
+ event: 'gdocs.capture.browser-model.archive.localized',
555
+ images: localized.images.length,
556
+ markdownBytes: Buffer.byteLength(localized.markdown),
557
+ htmlBytes: Buffer.byteLength(localized.html),
558
+ }));
559
+ archiveResult = {
560
+ ...result,
561
+ markdown: localized.markdown,
562
+ html: localized.html,
563
+ images: localized.images,
564
+ };
565
+ }
530
566
  await writeGoogleDocsArchive({
531
- archiveResult: { ...result, images: [] },
567
+ archiveResult,
532
568
  absoluteUrl,
533
569
  explicitOutput,
534
570
  dataDir,
@@ -538,10 +574,10 @@ async function captureUrl(url, options) {
538
574
  } else {
539
575
  const rendered =
540
576
  normalizedFormat === 'html'
541
- ? result.html
577
+ ? result.html || result.content
542
578
  : normalizedFormat === 'txt' || normalizedFormat === 'text'
543
- ? result.text
544
- : result.markdown;
579
+ ? result.text || result.content
580
+ : result.markdown || result.content;
545
581
  const ext =
546
582
  normalizedFormat === 'html'
547
583
  ? 'html'
@@ -554,7 +590,7 @@ async function captureUrl(url, options) {
554
590
  ext,
555
591
  explicitOutput,
556
592
  dataDir,
557
- label: `Google Doc (${gdocsMethod})`,
593
+ label: `Google Doc (${resultMethod})`,
558
594
  });
559
595
  }
560
596
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,152 @@
1
+ // Google Docs browser-model fallback helpers.
2
+ //
3
+ // When browser-model capture cannot read DOCS_modelChunk data from the editor
4
+ // page, fall back to the public export pipeline so that content and images
5
+ // are preserved (instead of returning an empty or lossy capture).
6
+
7
+ const GDOCS_BROWSER_MODEL_UNAVAILABLE = 'GDOCS_BROWSER_MODEL_UNAVAILABLE';
8
+
9
+ /**
10
+ * Build the sentinel error thrown when browser-model capture finds no chunks.
11
+ *
12
+ * @param {string} message - Human-readable error message
13
+ * @returns {Error} Error carrying the sentinel code
14
+ */
15
+ export function googleDocsBrowserModelUnavailableError(message) {
16
+ const err = new Error(message);
17
+ err.code = GDOCS_BROWSER_MODEL_UNAVAILABLE;
18
+ return err;
19
+ }
20
+
21
+ /**
22
+ * Check whether an error means Google Docs browser-model data was unavailable.
23
+ *
24
+ * @param {Error} err - Error to classify
25
+ * @returns {boolean} True if export fallback is appropriate
26
+ */
27
+ export function isGoogleDocsBrowserModelUnavailableError(err) {
28
+ return (
29
+ err?.code === GDOCS_BROWSER_MODEL_UNAVAILABLE ||
30
+ String(err?.message || '').includes('did not expose DOCS_modelChunk data')
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Fetch a Google Doc through the public export pipeline for a requested output
36
+ * format. This is used as the lossless fallback when browser-model capture
37
+ * cannot read DOCS_modelChunk data from the editor page.
38
+ *
39
+ * @param {Object} deps - Injected dependencies from gdocs.js
40
+ * @param {string} url - Google Docs URL
41
+ * @param {Object} [options] - Options
42
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
43
+ */
44
+ export async function fetchGoogleDocByExportFormat(deps, url, options = {}) {
45
+ const { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive } =
46
+ deps;
47
+ const { format = 'markdown', apiToken, log } = options;
48
+ const normalized = (format || 'markdown').toLowerCase();
49
+
50
+ if (normalized === 'archive' || normalized === 'zip') {
51
+ const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken, log });
52
+ return {
53
+ ...archiveResult,
54
+ content: archiveResult.markdown,
55
+ sourceFormat: 'archive',
56
+ };
57
+ }
58
+
59
+ if (normalized === 'markdown' || normalized === 'md') {
60
+ const result = await fetchGoogleDocAsMarkdown(url, { apiToken, log });
61
+ return {
62
+ ...result,
63
+ content: result.markdown,
64
+ sourceFormat: 'markdown',
65
+ };
66
+ }
67
+
68
+ if (normalized === 'html') {
69
+ const result = await fetchGoogleDoc(url, {
70
+ format: 'html',
71
+ apiToken,
72
+ log,
73
+ });
74
+ return {
75
+ ...result,
76
+ html: result.content,
77
+ sourceFormat: 'html',
78
+ };
79
+ }
80
+
81
+ if (normalized === 'txt' || normalized === 'text') {
82
+ const result = await fetchGoogleDoc(url, {
83
+ format: 'txt',
84
+ apiToken,
85
+ log,
86
+ });
87
+ return {
88
+ ...result,
89
+ text: result.content,
90
+ sourceFormat: 'txt',
91
+ };
92
+ }
93
+
94
+ throw new Error(
95
+ `Unsupported Google Docs export fallback format "${format}".`
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Capture a Google Doc through the browser model, falling back to public export
101
+ * when the editor does not expose model chunks.
102
+ *
103
+ * @param {Object} deps - Injected dependencies from gdocs.js
104
+ * @param {string} url - Google Docs URL
105
+ * @param {Object} [options] - Capture options
106
+ * @returns {Promise<Object>} Browser-model or public-export result
107
+ */
108
+ export async function captureGoogleDocWithBrowserOrFallback(
109
+ deps,
110
+ url,
111
+ options = {}
112
+ ) {
113
+ const { captureGoogleDocWithBrowser } = deps;
114
+ const {
115
+ format = 'markdown',
116
+ fallback = true,
117
+ onFallback,
118
+ ...browserOptions
119
+ } = options;
120
+
121
+ try {
122
+ const result = await captureGoogleDocWithBrowser(url, browserOptions);
123
+ return {
124
+ ...result,
125
+ method: 'browser-model',
126
+ fallback: false,
127
+ };
128
+ } catch (err) {
129
+ if (!fallback || !isGoogleDocsBrowserModelUnavailableError(err)) {
130
+ throw err;
131
+ }
132
+
133
+ onFallback?.(err);
134
+ browserOptions.log?.warn?.(() => ({
135
+ event: 'gdocs.browser-model.fallback-public-export',
136
+ reason: err.message,
137
+ format,
138
+ }));
139
+
140
+ const result = await fetchGoogleDocByExportFormat(deps, url, {
141
+ format,
142
+ apiToken: browserOptions.apiToken,
143
+ log: browserOptions.log,
144
+ });
145
+ return {
146
+ ...result,
147
+ method: 'public-export',
148
+ fallback: true,
149
+ browserError: err.message,
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,130 @@
1
+ // Google Docs editor-model image localization (issue #92 R5).
2
+ //
3
+ // The browser-model capture returns `docs-images-rt/…` URLs inside the
4
+ // markdown/html. Archive mode needs those images copied into `images/` so
5
+ // the archive is self-contained. This module downloads the images and
6
+ // rewrites the captured output accordingly.
7
+
8
+ import fetch from 'node-fetch';
9
+ import { URL } from 'url';
10
+
11
+ const DEFAULT_USER_AGENT =
12
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
13
+
14
+ function getExtensionFromImageUrl(url) {
15
+ try {
16
+ const u = new URL(url);
17
+ const match = u.pathname.match(/\.(png|jpe?g|gif|webp|svg)$/iu);
18
+ if (match) {
19
+ const ext = match[1].toLowerCase();
20
+ return ext === 'jpeg' ? '.jpg' : `.${ext}`;
21
+ }
22
+ } catch {
23
+ // fall through to default
24
+ }
25
+ return '.png';
26
+ }
27
+
28
+ function mimeTypeForExtension(filename) {
29
+ const dot = filename.lastIndexOf('.');
30
+ const ext = dot >= 0 ? filename.slice(dot + 1).toLowerCase() : 'png';
31
+ switch (ext) {
32
+ case 'jpg':
33
+ case 'jpeg':
34
+ return 'image/jpeg';
35
+ case 'gif':
36
+ return 'image/gif';
37
+ case 'webp':
38
+ return 'image/webp';
39
+ case 'svg':
40
+ return 'image/svg+xml';
41
+ default:
42
+ return 'image/png';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Download the `docs-images-rt` URLs captured from an editor-model capture.
48
+ *
49
+ * Produces `{ filename, data, mimeType }` entries compatible with
50
+ * `writeGoogleDocsArchive` and rewrites the markdown/html to reference the
51
+ * local `images/<filename>` paths.
52
+ *
53
+ * @param {{markdown: string, html: string, capture: Object}} modelResult
54
+ * @param {Object} [options] - Options
55
+ * @param {typeof fetch} [options.fetchImpl=fetch] - Injected fetch for tests
56
+ * @param {Object} [options.log] - Verbose logger
57
+ * @returns {Promise<{markdown: string, html: string, images: Array}>}
58
+ */
59
+ export async function localizeGoogleDocsModelImages(modelResult, options = {}) {
60
+ const { fetchImpl = fetch, log } = options;
61
+ const images = [];
62
+ const seen = new Map();
63
+ let nextIndex = 1;
64
+
65
+ const resolveFilename = (url, existingAlt) => {
66
+ if (seen.has(url)) {
67
+ return seen.get(url);
68
+ }
69
+ const ext = getExtensionFromImageUrl(url);
70
+ const filename = `image-${String(nextIndex).padStart(2, '0')}${ext}`;
71
+ nextIndex += 1;
72
+ seen.set(url, filename);
73
+ log?.debug?.(() => ({
74
+ event: 'gdocs.archive.image.scheduled',
75
+ url,
76
+ filename,
77
+ alt: existingAlt || null,
78
+ }));
79
+ return filename;
80
+ };
81
+
82
+ const candidates = (modelResult?.capture?.images || []).filter(
83
+ (img) => img?.url
84
+ );
85
+ for (const img of candidates) {
86
+ const filename = resolveFilename(img.url, img.alt);
87
+ try {
88
+ const response = await fetchImpl(img.url, {
89
+ headers: {
90
+ 'User-Agent': DEFAULT_USER_AGENT,
91
+ Accept: 'image/*,*/*;q=0.8',
92
+ },
93
+ redirect: 'follow',
94
+ });
95
+ if (!response.ok) {
96
+ throw new Error(`HTTP ${response.status}`);
97
+ }
98
+ const arrayBuffer = await response.arrayBuffer();
99
+ const data = Buffer.from(arrayBuffer);
100
+ const mimeType =
101
+ response.headers?.get?.('content-type') ||
102
+ mimeTypeForExtension(filename);
103
+ images.push({ filename, data, mimeType });
104
+ log?.debug?.(() => ({
105
+ event: 'gdocs.archive.image.downloaded',
106
+ url: img.url,
107
+ filename,
108
+ bytes: data.length,
109
+ mimeType,
110
+ }));
111
+ } catch (err) {
112
+ log?.debug?.(() => ({
113
+ event: 'gdocs.archive.image.failed',
114
+ url: img.url,
115
+ filename,
116
+ error: err?.message || String(err),
117
+ }));
118
+ }
119
+ }
120
+
121
+ let markdown = modelResult?.markdown || '';
122
+ let html = modelResult?.html || '';
123
+ for (const [url, filename] of seen.entries()) {
124
+ const localPath = `images/${filename}`;
125
+ markdown = markdown.split(url).join(localPath);
126
+ html = html.split(url).join(localPath);
127
+ }
128
+
129
+ return { markdown, html, images };
130
+ }
@@ -0,0 +1,105 @@
1
+ // Google Docs export HTML preprocessor (issue #92 R6).
2
+ //
3
+ // Google Drive's `/export?format=html` payload serves bold/italic/
4
+ // strikethrough as inline `style` spans, numbers headings with
5
+ // `<a id><span>1. </span>`, and routes every link through a
6
+ // `google.com/url?q=` redirect. The generic Cheerio + Turndown pipeline
7
+ // discards those signals, so the browser-less API path loses the
8
+ // formatting and gains noisy redirect URLs.
9
+ //
10
+ // This module rewrites the export HTML into semantic markup before the
11
+ // generic converter runs.
12
+
13
+ import * as cheerio from 'cheerio';
14
+
15
+ /**
16
+ * Pre-process Google Docs export HTML so the generic Cheerio+Turndown
17
+ * pipeline preserves inline formatting, heading numbers, and link targets.
18
+ *
19
+ * @param {string} html - Raw export HTML returned by Google Docs.
20
+ * @returns {{html: string, hoisted: number, unwrappedLinks: number}}
21
+ */
22
+ export function preprocessGoogleDocsExportHtml(html) {
23
+ if (!html || typeof html !== 'string') {
24
+ return { html: html || '', hoisted: 0, unwrappedLinks: 0 };
25
+ }
26
+
27
+ const $ = cheerio.load(html, { decodeEntities: false });
28
+ let hoisted = 0;
29
+ let unwrappedLinks = 0;
30
+
31
+ $('span[style], p[style]').each(function () {
32
+ const style = String($(this).attr('style') || '');
33
+ const hasBold = /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
34
+ const hasItalic = /font-style\s*:\s*italic/i.test(style);
35
+ const hasStrike = /text-decoration[^;]*\bline-through\b/i.test(style);
36
+ if (!hasBold && !hasItalic && !hasStrike) {
37
+ return;
38
+ }
39
+ const inner = $(this).html() || '';
40
+ let wrapped = inner;
41
+ if (hasStrike) {
42
+ wrapped = `<del>${wrapped}</del>`;
43
+ }
44
+ if (hasItalic) {
45
+ wrapped = `<em>${wrapped}</em>`;
46
+ }
47
+ if (hasBold) {
48
+ wrapped = `<strong>${wrapped}</strong>`;
49
+ }
50
+ $(this).replaceWith(wrapped);
51
+ hoisted += 1;
52
+ });
53
+
54
+ $('h1, h2, h3, h4, h5, h6').each(function () {
55
+ const $h = $(this);
56
+ $h.find('a').each(function () {
57
+ const $a = $(this);
58
+ if (!$a.attr('href') && !$a.text().trim() && !$a.find('img').length) {
59
+ $a.remove();
60
+ }
61
+ });
62
+ $h.find('> span, > strong > span').each(function () {
63
+ const $span = $(this);
64
+ const text = $span.text();
65
+ if (/^\s*\d+(?:\.\d+)*\.?\s*$/.test(text)) {
66
+ $span.remove();
67
+ }
68
+ });
69
+ });
70
+
71
+ $('a[href*="google.com/url?q="]').each(function () {
72
+ const href = String($(this).attr('href') || '');
73
+ const match = href.match(/[?&]q=([^&]+)/);
74
+ if (match) {
75
+ try {
76
+ const decoded = decodeURIComponent(match[1]);
77
+ $(this).attr('href', decoded);
78
+ unwrappedLinks += 1;
79
+ } catch {
80
+ // Leave the original href in place if it can't be decoded.
81
+ }
82
+ }
83
+ });
84
+
85
+ $('*').each(function () {
86
+ if (this.type !== 'tag') {
87
+ return;
88
+ }
89
+ $(this)
90
+ .contents()
91
+ .each(function () {
92
+ if (this.type === 'text' && this.data) {
93
+ const replaced = this.data.replace(/\u00A0/g, ' ');
94
+ if (replaced !== this.data) {
95
+ this.data = replaced;
96
+ }
97
+ }
98
+ });
99
+ });
100
+
101
+ let rewritten = $.html();
102
+ rewritten = rewritten.replace(/&nbsp;/g, ' ');
103
+
104
+ return { html: rewritten, hoisted, unwrappedLinks };
105
+ }
package/src/gdocs.js CHANGED
@@ -11,6 +11,20 @@ import fetch from 'node-fetch';
11
11
  import he from 'he';
12
12
  import { convertHtmlToMarkdown } from './lib.js';
13
13
  import { createBrowser as defaultCreateBrowser } from './browser.js';
14
+ import { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
15
+ import { localizeGoogleDocsModelImages } from './gdocs-images.js';
16
+ import {
17
+ googleDocsBrowserModelUnavailableError,
18
+ isGoogleDocsBrowserModelUnavailableError,
19
+ fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
20
+ captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
21
+ } from './gdocs-fallback.js';
22
+
23
+ export {
24
+ preprocessGoogleDocsExportHtml,
25
+ localizeGoogleDocsModelImages,
26
+ isGoogleDocsBrowserModelUnavailableError,
27
+ };
14
28
 
15
29
  const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
16
30
 
@@ -283,7 +297,14 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
283
297
  log,
284
298
  });
285
299
 
286
- const markdown = convertHtmlToMarkdown(result.content, result.exportUrl);
300
+ const preprocessed = preprocessGoogleDocsExportHtml(result.content);
301
+ log?.debug?.(() => ({
302
+ event: 'gdocs.export.style-hoist',
303
+ documentId: result.documentId,
304
+ hoisted: preprocessed.hoisted,
305
+ unwrappedLinks: preprocessed.unwrappedLinks,
306
+ }));
307
+ const markdown = convertHtmlToMarkdown(preprocessed.html, result.exportUrl);
287
308
  log?.debug?.(() => ({
288
309
  event: 'gdocs.public-export.markdown',
289
310
  documentId: result.documentId,
@@ -297,6 +318,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
297
318
  };
298
319
  }
299
320
 
321
+ /**
322
+ * Fetch a Google Doc through the public export pipeline for a requested output
323
+ * format. Thin wrapper over the fallback module that injects sibling fetchers.
324
+ *
325
+ * @param {string} url - Google Docs URL
326
+ * @param {Object} [options] - Options
327
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
328
+ */
329
+ export function fetchGoogleDocByExportFormat(url, options = {}) {
330
+ return fetchGoogleDocByExportFormatImpl(
331
+ { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
332
+ url,
333
+ options
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Capture a Google Doc through the browser model, falling back to public export
339
+ * when the editor does not expose model chunks. Thin wrapper.
340
+ *
341
+ * @param {string} url - Google Docs URL
342
+ * @param {Object} [options] - Capture options
343
+ * @returns {Promise<Object>} Browser-model or public-export result
344
+ */
345
+ export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
346
+ return captureGoogleDocWithBrowserOrFallbackImpl(
347
+ {
348
+ captureGoogleDocWithBrowser,
349
+ fetchGoogleDoc,
350
+ fetchGoogleDocAsMarkdown,
351
+ fetchGoogleDocAsArchive,
352
+ },
353
+ url,
354
+ options
355
+ );
356
+ }
357
+
300
358
  /**
301
359
  * Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
302
360
  *
@@ -384,7 +442,8 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
384
442
 
385
443
  const capture = parseGoogleDocsModelChunks(
386
444
  modelData.chunks,
387
- modelData.cidUrlMap
445
+ modelData.cidUrlMap,
446
+ { log }
388
447
  );
389
448
  log?.debug?.(() => ({
390
449
  event: 'gdocs.browser-model.parsed',
@@ -397,7 +456,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
397
456
  textBytes: Buffer.byteLength(capture.text || ''),
398
457
  }));
399
458
  if (capture.blocks.length === 0) {
400
- throw new Error(
459
+ throw googleDocsBrowserModelUnavailableError(
401
460
  'Google Docs editor page did not expose DOCS_modelChunk data'
402
461
  );
403
462
  }
@@ -531,9 +590,16 @@ export function extractCidUrlMapFromHtml(html) {
531
590
  *
532
591
  * @param {Array} chunks - Captured DOCS_modelChunk values
533
592
  * @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
593
+ * @param {Object} [options] - Parse options
594
+ * @param {Object} [options.log] - Optional verbose logger
534
595
  * @returns {{blocks: Array, tables: Array, images: Array, text: string}}
535
596
  */
536
- export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
597
+ export function parseGoogleDocsModelChunks(
598
+ chunks = [],
599
+ cidUrlMap = {},
600
+ options = {}
601
+ ) {
602
+ const { log } = options;
537
603
  const items = collectModelItems(chunks);
538
604
  const fullText = items
539
605
  .filter((item) => item.ty === 'is' || item.ty === 'iss')
@@ -581,6 +647,16 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
581
647
  let table = null;
582
648
  let row = null;
583
649
  let cell = null;
650
+ const tableHistograms = [];
651
+ let currentHistogram = null;
652
+
653
+ const bumpHistogram = (code) => {
654
+ if (!currentHistogram) {
655
+ return;
656
+ }
657
+ const key = `0x${code.toString(16).padStart(2, '0')}`;
658
+ currentHistogram[key] = (currentHistogram[key] || 0) + 1;
659
+ };
584
660
 
585
661
  const flushParagraph = (endPos = null) => {
586
662
  const text = contentToText(paragraph).trim();
@@ -594,15 +670,18 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
594
670
  paragraph = [];
595
671
  };
596
672
 
597
- const flushCell = () => {
673
+ const flushCell = ({ dropEmpty = false } = {}) => {
598
674
  if (cell && row) {
599
- row.cells.push(cell);
675
+ const isEmpty = cell.content.length === 0;
676
+ if (!dropEmpty || !isEmpty) {
677
+ row.cells.push(cell);
678
+ }
600
679
  }
601
680
  cell = null;
602
681
  };
603
682
 
604
- const flushRow = () => {
605
- flushCell();
683
+ const flushRow = ({ dropEmptyTrailingCell = false } = {}) => {
684
+ flushCell({ dropEmpty: dropEmptyTrailingCell });
606
685
  if (row && table) {
607
686
  table.rows.push(row);
608
687
  }
@@ -610,11 +689,23 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
610
689
  };
611
690
 
612
691
  const flushTable = () => {
613
- flushRow();
692
+ flushRow({ dropEmptyTrailingCell: true });
614
693
  if (table) {
694
+ // Drop trailing empty rows that Google Docs sometimes emits after the
695
+ // last cell content (from `\n` just before the `0x11` close marker).
696
+ while (
697
+ table.rows.length > 1 &&
698
+ table.rows[table.rows.length - 1].cells.length === 0
699
+ ) {
700
+ table.rows.pop();
701
+ }
615
702
  tables.push(table);
616
703
  blocks.push(table);
617
704
  }
705
+ if (currentHistogram) {
706
+ tableHistograms.push(currentHistogram);
707
+ }
708
+ currentHistogram = null;
618
709
  table = null;
619
710
  };
620
711
 
@@ -636,18 +727,29 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
636
727
  if (code === 0x10) {
637
728
  flushParagraph(i + 1);
638
729
  table = { type: 'table', rows: [] };
730
+ currentHistogram = {
731
+ '0x0a': 0,
732
+ '0x0b': 0,
733
+ '0x10': 1,
734
+ '0x11': 0,
735
+ '0x12': 0,
736
+ '0x1c': 0,
737
+ };
639
738
  continue;
640
739
  }
641
740
  if (code === 0x11) {
741
+ bumpHistogram(code);
642
742
  flushTable();
643
743
  continue;
644
744
  }
645
745
  if (code === 0x12) {
646
- flushRow();
746
+ bumpHistogram(code);
747
+ flushRow({ dropEmptyTrailingCell: true });
647
748
  row = { cells: [] };
648
749
  continue;
649
750
  }
650
751
  if (code === 0x1c) {
752
+ bumpHistogram(code);
651
753
  flushCell();
652
754
  if (!row) {
653
755
  row = { cells: [] };
@@ -656,14 +758,24 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
656
758
  continue;
657
759
  }
658
760
  if (code === 0x0a) {
761
+ bumpHistogram(code);
659
762
  if (table) {
660
- flushRow();
763
+ // Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
764
+ // Row boundaries are communicated via the explicit `0x12` marker and
765
+ // the closing `0x11` marker. Earlier versions collapsed multi-column
766
+ // tables to one column because they flushed the row on `\n`.
767
+ flushCell();
768
+ if (!row) {
769
+ row = { cells: [] };
770
+ }
771
+ cell = { content: [] };
661
772
  } else {
662
773
  flushParagraph(i + 1);
663
774
  }
664
775
  continue;
665
776
  }
666
777
  if (code === 0x0b) {
778
+ bumpHistogram(code);
667
779
  appendText(targetContent(), '\n');
668
780
  continue;
669
781
  }
@@ -684,6 +796,19 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
684
796
  }
685
797
  flushParagraph(fullText.length);
686
798
 
799
+ if (log?.debug && tableHistograms.length > 0) {
800
+ for (let t = 0; t < tableHistograms.length; t++) {
801
+ const tbl = tables[t];
802
+ log.debug(() => ({
803
+ event: 'gdocs.table.histogram',
804
+ tableIndex: t,
805
+ rows: tbl?.rows.length || 0,
806
+ columns: Math.max(...(tbl?.rows || []).map((r) => r.cells.length), 0),
807
+ controls: tableHistograms[t],
808
+ }));
809
+ }
810
+ }
811
+
687
812
  return {
688
813
  blocks,
689
814
  tables,
@@ -981,19 +1106,56 @@ function sameTextStyle(a, b) {
981
1106
  }
982
1107
 
983
1108
  function renderBlocksMarkdown(blocks) {
984
- return blocks
985
- .map((block) => {
986
- if (block.type === 'table') {
987
- return renderTableMarkdown(block);
1109
+ const counters = new Map();
1110
+ const rendered = blocks.map((block) => {
1111
+ if (block.type === 'table') {
1112
+ counters.clear();
1113
+ return { text: renderTableMarkdown(block), list: null };
1114
+ }
1115
+ const counterKey = block.list
1116
+ ? `${block.list.id || ''}\u0000${block.list.level || 0}`
1117
+ : null;
1118
+ if (block.list) {
1119
+ // Reset deeper siblings when we re-enter a shallower level so
1120
+ // nested lists restart from 1 on each new parent.
1121
+ for (const key of [...counters.keys()]) {
1122
+ const level = Number(key.split('\u0000')[1]);
1123
+ if (level > (block.list.level || 0)) {
1124
+ counters.delete(key);
1125
+ }
988
1126
  }
989
- return renderParagraphMarkdown(block);
990
- })
991
- .filter(Boolean)
992
- .join('\n\n')
993
- .trimEnd();
1127
+ const next = (counters.get(counterKey) || 0) + 1;
1128
+ counters.set(counterKey, next);
1129
+ return {
1130
+ text: renderParagraphMarkdown(block, { orderedIndex: next }),
1131
+ list: block.list,
1132
+ };
1133
+ }
1134
+ counters.clear();
1135
+ return { text: renderParagraphMarkdown(block), list: null };
1136
+ });
1137
+
1138
+ const joined = [];
1139
+ for (let i = 0; i < rendered.length; i++) {
1140
+ const cur = rendered[i];
1141
+ if (!cur.text) {
1142
+ continue;
1143
+ }
1144
+ if (joined.length > 0) {
1145
+ const prev = rendered
1146
+ .slice(0, i)
1147
+ .reverse()
1148
+ .find((entry) => entry.text);
1149
+ const sameList =
1150
+ cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
1151
+ joined.push(sameList ? '\n' : '\n\n');
1152
+ }
1153
+ joined.push(cur.text);
1154
+ }
1155
+ return joined.join('').replace(/\s+$/u, '');
994
1156
  }
995
1157
 
996
- function renderParagraphMarkdown(block) {
1158
+ function renderParagraphMarkdown(block, context = {}) {
997
1159
  const text = renderContentMarkdown(block.content).trim();
998
1160
  if (block.horizontalRule) {
999
1161
  return '---';
@@ -1011,7 +1173,8 @@ function renderParagraphMarkdown(block) {
1011
1173
  }
1012
1174
  if (block.list) {
1013
1175
  const indent = ' '.repeat(Math.max(0, block.list.level || 0));
1014
- const marker = block.list.ordered ? '1.' : '-';
1176
+ const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
1177
+ const marker = block.list.ordered ? `${orderedIndex}.` : '-';
1015
1178
  return `${indent}${marker} ${text}`;
1016
1179
  }
1017
1180
  if (block.quote) {
@@ -1277,8 +1440,16 @@ export async function fetchGoogleDocAsArchive(url, options = {}) {
1277
1440
  // Fetch HTML with embedded base64 images
1278
1441
  const result = await fetchGoogleDoc(url, { format: 'html', apiToken, log });
1279
1442
 
1443
+ const preprocessed = preprocessGoogleDocsExportHtml(result.content);
1444
+ log?.debug?.(() => ({
1445
+ event: 'gdocs.export.style-hoist',
1446
+ documentId: result.documentId,
1447
+ hoisted: preprocessed.hoisted,
1448
+ unwrappedLinks: preprocessed.unwrappedLinks,
1449
+ }));
1450
+
1280
1451
  // Extract base64 images from HTML
1281
- const { html: localHtml, images } = extractBase64Images(result.content);
1452
+ const { html: localHtml, images } = extractBase64Images(preprocessed.html);
1282
1453
 
1283
1454
  // Convert the localized HTML to Markdown
1284
1455
  const markdown = convertHtmlToMarkdown(localHtml);
@@ -6,10 +6,14 @@ import {
6
6
  buildEditUrl,
7
7
  buildDocsApiUrl,
8
8
  extractBase64Images,
9
+ captureGoogleDocWithBrowserOrFallback,
10
+ isGoogleDocsBrowserModelUnavailableError,
9
11
  parseGoogleDocsModelChunks,
10
12
  renderGoogleDocsCapture,
11
13
  renderDocsApiDocument,
12
14
  selectGoogleDocsCaptureMethod,
15
+ localizeGoogleDocsModelImages,
16
+ preprocessGoogleDocsExportHtml,
13
17
  GDOCS_EXPORT_FORMATS,
14
18
  } from '../../src/gdocs.js';
15
19
  import {
@@ -20,6 +24,7 @@ import {
20
24
  import fs from 'fs';
21
25
  import os from 'os';
22
26
  import path from 'path';
27
+ import nock from 'nock';
23
28
 
24
29
  jest.setTimeout(30000);
25
30
 
@@ -250,6 +255,115 @@ describe('gdocs', () => {
250
255
  );
251
256
  });
252
257
 
258
+ it('parses multi-column tables when rows are separated by 0x0a (issue #92 R2)', () => {
259
+ // Google Docs sometimes emits cell boundaries as `\n` (0x0a) alongside
260
+ // the 0x12 new-row marker. The parser must keep all cells on the same
261
+ // row rather than collapsing the table to a single column.
262
+ const chunks = [
263
+ {
264
+ chunk: [
265
+ {
266
+ ty: 'is',
267
+ s: `${
268
+ String.fromCharCode(0x10) + String.fromCharCode(0x12)
269
+ }A\nB\nC\n${String.fromCharCode(
270
+ 0x12
271
+ )}D\nE\nF\n${String.fromCharCode(0x11)}`,
272
+ },
273
+ ],
274
+ },
275
+ ];
276
+ const capture = parseGoogleDocsModelChunks(chunks);
277
+
278
+ expect(capture.tables).toHaveLength(1);
279
+ expect(capture.tables[0].rows).toHaveLength(2);
280
+ expect(capture.tables[0].rows[0].cells).toHaveLength(3);
281
+ expect(capture.tables[0].rows[1].cells).toHaveLength(3);
282
+
283
+ const markdown = renderGoogleDocsCapture(capture, 'markdown');
284
+ expect(markdown).toContain('| A | B | C |');
285
+ expect(markdown).toContain('| D | E | F |');
286
+ });
287
+
288
+ it('numbers ordered list items sequentially (issue #92 R3)', () => {
289
+ // Three list items sharing the same list id should render as 1. 2. 3.
290
+ const text = 'First item\nSecond item\nThird item\n';
291
+ const endOfLine = (n) => {
292
+ let idx = -1;
293
+ for (let k = 0; k < n; k++) {
294
+ idx = text.indexOf('\n', idx + 1);
295
+ }
296
+ return idx + 1;
297
+ };
298
+ const chunks = [
299
+ {
300
+ chunk: [
301
+ { ty: 'is', s: text },
302
+ {
303
+ ty: 'as',
304
+ st: 'list',
305
+ si: endOfLine(1),
306
+ ei: endOfLine(1),
307
+ sm: { ls_id: 'kix.list.7' },
308
+ },
309
+ {
310
+ ty: 'as',
311
+ st: 'list',
312
+ si: endOfLine(2),
313
+ ei: endOfLine(2),
314
+ sm: { ls_id: 'kix.list.7' },
315
+ },
316
+ {
317
+ ty: 'as',
318
+ st: 'list',
319
+ si: endOfLine(3),
320
+ ei: endOfLine(3),
321
+ sm: { ls_id: 'kix.list.7' },
322
+ },
323
+ ],
324
+ },
325
+ ];
326
+ const capture = parseGoogleDocsModelChunks(chunks);
327
+ const markdown = renderGoogleDocsCapture(capture, 'markdown');
328
+ const lines = markdown.split('\n');
329
+
330
+ expect(lines[0]).toBe('1. First item');
331
+ expect(lines[1]).toBe('2. Second item');
332
+ expect(lines[2]).toBe('3. Third item');
333
+ });
334
+
335
+ it('joins consecutive list items with a single newline (issue #92 R4)', () => {
336
+ const text = 'First item\nSecond item\n';
337
+ const endFirst = text.indexOf('\n') + 1;
338
+ const endSecond = text.length;
339
+ const chunks = [
340
+ {
341
+ chunk: [
342
+ { ty: 'is', s: text },
343
+ {
344
+ ty: 'as',
345
+ st: 'list',
346
+ si: endFirst,
347
+ ei: endFirst,
348
+ sm: { ls_id: 'kix.list.7' },
349
+ },
350
+ {
351
+ ty: 'as',
352
+ st: 'list',
353
+ si: endSecond,
354
+ ei: endSecond,
355
+ sm: { ls_id: 'kix.list.7' },
356
+ },
357
+ ],
358
+ },
359
+ ];
360
+ const capture = parseGoogleDocsModelChunks(chunks);
361
+ const markdown = renderGoogleDocsCapture(capture, 'markdown');
362
+
363
+ expect(markdown).not.toMatch(/First item\n\n2\. Second item/u);
364
+ expect(markdown).toMatch(/First item\n2\. Second item/u);
365
+ });
366
+
253
367
  it('renders model style records for headings, inline formatting, links, lists, blockquotes, rules, and images', () => {
254
368
  const text = [
255
369
  'Title',
@@ -428,6 +542,49 @@ describe('gdocs', () => {
428
542
  });
429
543
  });
430
544
 
545
+ describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
546
+ afterEach(() => {
547
+ nock.cleanAll();
548
+ });
549
+
550
+ it('falls back to public export archive when the editor exposes no model chunks', async () => {
551
+ const tinyPng =
552
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
553
+ nock('https://docs.google.com')
554
+ .get('/document/d/fallback-doc/export')
555
+ .query({ format: 'html' })
556
+ .reply(
557
+ 200,
558
+ `<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
559
+ { 'content-type': 'text/html; charset=utf-8' }
560
+ );
561
+
562
+ const fallbackErrors = [];
563
+ const result = await captureGoogleDocWithBrowserOrFallback(
564
+ 'https://docs.google.com/document/d/fallback-doc/edit',
565
+ {
566
+ format: 'archive',
567
+ waitMs: 0,
568
+ createBrowser: async () =>
569
+ fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
570
+ onFallback: (err) => fallbackErrors.push(err),
571
+ }
572
+ );
573
+
574
+ expect(result.method).toBe('public-export');
575
+ expect(result.fallback).toBe(true);
576
+ expect(result.markdown).toContain('Fallback Doc');
577
+ expect(result.markdown).toContain('Complete text');
578
+ expect(result.images).toHaveLength(1);
579
+ expect(result.html).toContain('images/image-01.png');
580
+ expect(fallbackErrors).toHaveLength(1);
581
+ expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
582
+ true
583
+ );
584
+ expect(nock.isDone()).toBe(true);
585
+ });
586
+ });
587
+
431
588
  describe('GDOCS_EXPORT_FORMATS', () => {
432
589
  it('contains all expected formats', () => {
433
590
  expect(GDOCS_EXPORT_FORMATS).toEqual({
@@ -442,6 +599,146 @@ describe('gdocs', () => {
442
599
  });
443
600
  });
444
601
 
602
+ describe('localizeGoogleDocsModelImages (issue #92 R5)', () => {
603
+ it('downloads docs-images-rt URLs and rewrites markdown/html', async () => {
604
+ const url =
605
+ 'https://docs.google.com/docs-images-rt/doc-id/cid/example.png';
606
+ const modelResult = {
607
+ markdown: `![pic](${url})`,
608
+ html: `<img src="${url}" alt="pic">`,
609
+ capture: {
610
+ images: [{ type: 'image', url, alt: 'pic', cid: 'cid-abc' }],
611
+ },
612
+ };
613
+ const fakeFetch = jest.fn().mockResolvedValue({
614
+ ok: true,
615
+ status: 200,
616
+ headers: new Map([['content-type', 'image/png']]),
617
+ arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer,
618
+ });
619
+ // The map-based headers need a `get` method in the correct shape:
620
+ fakeFetch.mockResolvedValue({
621
+ ok: true,
622
+ status: 200,
623
+ headers: { get: () => 'image/png' },
624
+ arrayBuffer: async () =>
625
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer,
626
+ });
627
+
628
+ const localized = await localizeGoogleDocsModelImages(modelResult, {
629
+ fetchImpl: fakeFetch,
630
+ });
631
+
632
+ expect(fakeFetch).toHaveBeenCalledTimes(1);
633
+ expect(localized.images).toHaveLength(1);
634
+ expect(localized.images[0].filename).toBe('image-01.png');
635
+ expect(localized.images[0].mimeType).toBe('image/png');
636
+ expect(localized.images[0].data).toBeInstanceOf(Buffer);
637
+ expect(localized.markdown).toBe('![pic](images/image-01.png)');
638
+ expect(localized.html).toContain('images/image-01.png');
639
+ expect(localized.html).not.toContain('docs-images-rt');
640
+ });
641
+
642
+ it('keeps original URL when download fails', async () => {
643
+ const url =
644
+ 'https://docs.google.com/docs-images-rt/doc-id/cid/missing.png';
645
+ const modelResult = {
646
+ markdown: `![pic](${url})`,
647
+ html: `<img src="${url}" alt="pic">`,
648
+ capture: { images: [{ url, alt: 'pic' }] },
649
+ };
650
+ const fakeFetch = jest.fn().mockResolvedValue({
651
+ ok: false,
652
+ status: 404,
653
+ headers: { get: () => null },
654
+ });
655
+
656
+ const localized = await localizeGoogleDocsModelImages(modelResult, {
657
+ fetchImpl: fakeFetch,
658
+ });
659
+
660
+ expect(localized.images).toHaveLength(0);
661
+ // Markdown still gets rewritten (local path) even if download fails —
662
+ // callers may choose to ignore this; the key guarantee is that no
663
+ // exception leaks out.
664
+ expect(localized.markdown).toBe('![pic](images/image-01.png)');
665
+ });
666
+ });
667
+
668
+ describe('preprocessGoogleDocsExportHtml (issue #92 R6)', () => {
669
+ it('hoists font-weight:700 spans into <strong>', () => {
670
+ const html = '<p><span style="font-weight:700">Bold text</span></p>';
671
+ const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
672
+ expect(hoisted).toBe(1);
673
+ expect(out).toContain('<strong>');
674
+ expect(out).toContain('Bold text');
675
+ });
676
+
677
+ it('hoists font-style:italic spans into <em>', () => {
678
+ const html = '<p><span style="font-style:italic">Italic text</span></p>';
679
+ const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
680
+ expect(hoisted).toBe(1);
681
+ expect(out).toContain('<em>');
682
+ });
683
+
684
+ it('hoists text-decoration:line-through spans into <del>', () => {
685
+ const html =
686
+ '<p><span style="text-decoration:line-through">Strike</span></p>';
687
+ const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
688
+ expect(hoisted).toBe(1);
689
+ expect(out).toContain('<del>');
690
+ });
691
+
692
+ it('combines multiple styles on a single span', () => {
693
+ const html =
694
+ '<p><span style="font-weight:700;font-style:italic;text-decoration:line-through">Mixed</span></p>';
695
+ const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
696
+ expect(hoisted).toBe(1);
697
+ // Wrappers appear in <strong><em><del>... order (outer-to-inner).
698
+ expect(out).toContain('<strong>');
699
+ expect(out).toContain('<em>');
700
+ expect(out).toContain('<del>');
701
+ });
702
+
703
+ it('unwraps google.com/url?q= redirect links', () => {
704
+ const html =
705
+ '<p><a href="https://www.google.com/url?q=https://example.com&sa=D&source=editors&usg=ABC">Link</a></p>';
706
+ const { html: out, unwrappedLinks } =
707
+ preprocessGoogleDocsExportHtml(html);
708
+ expect(unwrappedLinks).toBe(1);
709
+ expect(out).toContain('href="https://example.com"');
710
+ expect(out).not.toContain('google.com/url?q=');
711
+ });
712
+
713
+ it('strips leading empty anchors and numbering span inside headings', () => {
714
+ const html = '<h1><a id="anchor-1"></a><span>1. </span>Headings</h1>';
715
+ const { html: out } = preprocessGoogleDocsExportHtml(html);
716
+ expect(out).toContain('<h1>Headings</h1>');
717
+ expect(out).not.toContain('1. ');
718
+ expect(out).not.toContain('<a id=');
719
+ });
720
+
721
+ it('replaces &nbsp; entity and U+00A0 with regular spaces', () => {
722
+ const html = '<p>A\u00A0B&nbsp;C</p>';
723
+ const { html: out } = preprocessGoogleDocsExportHtml(html);
724
+ expect(out).toContain('A B C');
725
+ expect(out).not.toMatch(/\u00A0/);
726
+ expect(out).not.toContain('&nbsp;');
727
+ });
728
+
729
+ it('is a no-op for HTML without Google Docs markers', () => {
730
+ const html = '<p>Plain text with <strong>bold</strong>.</p>';
731
+ const {
732
+ html: out,
733
+ hoisted,
734
+ unwrappedLinks,
735
+ } = preprocessGoogleDocsExportHtml(html);
736
+ expect(hoisted).toBe(0);
737
+ expect(unwrappedLinks).toBe(0);
738
+ expect(out).toContain('<p>Plain text with <strong>bold</strong>.</p>');
739
+ });
740
+ });
741
+
445
742
  describe('extractBase64Images', () => {
446
743
  it('extracts a single PNG data URI image', () => {
447
744
  const html = '<img src="data:image/png;base64,iVBORw0KGgo=" alt="test">';
@@ -555,3 +852,23 @@ describe('gdocs', () => {
555
852
  });
556
853
  });
557
854
  });
855
+
856
+ function fakeBrowserReturningModel(modelData) {
857
+ return {
858
+ async newPage() {
859
+ return {
860
+ async addInitScript() {},
861
+ async setUserAgent() {},
862
+ async setExtraHTTPHeaders() {},
863
+ async setViewport() {},
864
+ async goto() {},
865
+ async waitForTimeout() {},
866
+ async evaluate() {
867
+ return modelData;
868
+ },
869
+ async close() {},
870
+ };
871
+ },
872
+ async close() {},
873
+ };
874
+ }