@link-assistant/web-capture 1.7.10 → 1.7.12
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 +30 -0
- package/bin/web-capture.js +48 -28
- package/package.json +1 -1
- package/src/gdocs-fallback.js +152 -0
- package/src/gdocs-preprocess.js +88 -6
- package/src/gdocs-render-markdown.js +173 -0
- package/src/gdocs.js +96 -160
- package/tests/unit/gdocs.test.js +219 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ed70bc8: Fix Google Docs browser-model markdown regressions around table separators,
|
|
8
|
+
blockquote continuation, nested inline markers, final newlines, and
|
|
9
|
+
public-export style preprocessing.
|
|
10
|
+
|
|
11
|
+
## 1.7.11
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
|
|
16
|
+
- 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
|
|
17
|
+
fetches for non-Google-Docs URLs (issue #80):
|
|
18
|
+
- `rust/src/main.rs` now passes `--capture browser` through
|
|
19
|
+
`render_html` for markdown, archive, and html output formats so the
|
|
20
|
+
flag actually launches headless Chrome instead of calling
|
|
21
|
+
`fetch_html`.
|
|
22
|
+
- `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
|
|
23
|
+
launches headless Chrome with `--screenshot` and returns real PNG
|
|
24
|
+
bytes.
|
|
25
|
+
- Adds Rust integration tests that run a local HTTP fixture whose DOM
|
|
26
|
+
is mutated by JavaScript after load and verify the rendered HTML,
|
|
27
|
+
the CLI `--capture browser` markdown output, and the PNG signature
|
|
28
|
+
on the screenshot bytes.
|
|
29
|
+
|
|
30
|
+
No JS package behavior changes in this patch; the Rust CLI is the only
|
|
31
|
+
affected surface.
|
|
32
|
+
|
|
3
33
|
## 1.7.10
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
package/bin/web-capture.js
CHANGED
|
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
|
|
|
459
459
|
fetchGoogleDoc,
|
|
460
460
|
fetchGoogleDocAsMarkdown,
|
|
461
461
|
fetchGoogleDocFromDocsApi,
|
|
462
|
-
|
|
462
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
463
463
|
selectGoogleDocsCaptureMethod,
|
|
464
464
|
} = await import('../src/gdocs.js');
|
|
465
465
|
const { stripBase64Images } = await import('../src/extract-images.js');
|
|
@@ -511,40 +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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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:
|
|
531
|
+
event: result.fallback
|
|
532
|
+
? 'gdocs.capture.browser-model.fallback-rendered'
|
|
533
|
+
: 'gdocs.capture.browser-model.rendered',
|
|
521
534
|
documentId: result.documentId,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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') {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 = {
|
|
543
560
|
...result,
|
|
544
561
|
markdown: localized.markdown,
|
|
545
562
|
html: localized.html,
|
|
546
563
|
images: localized.images,
|
|
547
|
-
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
await writeGoogleDocsArchive({
|
|
567
|
+
archiveResult,
|
|
548
568
|
absoluteUrl,
|
|
549
569
|
explicitOutput,
|
|
550
570
|
dataDir,
|
|
@@ -554,10 +574,10 @@ async function captureUrl(url, options) {
|
|
|
554
574
|
} else {
|
|
555
575
|
const rendered =
|
|
556
576
|
normalizedFormat === 'html'
|
|
557
|
-
? result.html
|
|
577
|
+
? result.html || result.content
|
|
558
578
|
: normalizedFormat === 'txt' || normalizedFormat === 'text'
|
|
559
|
-
? result.text
|
|
560
|
-
: result.markdown;
|
|
579
|
+
? result.text || result.content
|
|
580
|
+
: result.markdown || result.content;
|
|
561
581
|
const ext =
|
|
562
582
|
normalizedFormat === 'html'
|
|
563
583
|
? 'html'
|
|
@@ -570,7 +590,7 @@ async function captureUrl(url, options) {
|
|
|
570
590
|
ext,
|
|
571
591
|
explicitOutput,
|
|
572
592
|
dataDir,
|
|
573
|
-
label: `Google Doc (${
|
|
593
|
+
label: `Google Doc (${resultMethod})`,
|
|
574
594
|
});
|
|
575
595
|
}
|
|
576
596
|
return;
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/gdocs-preprocess.js
CHANGED
|
@@ -25,14 +25,15 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const $ = cheerio.load(html, { decodeEntities: false });
|
|
28
|
+
const classStyles = parseCssClassStyles($);
|
|
28
29
|
let hoisted = 0;
|
|
29
30
|
let unwrappedLinks = 0;
|
|
30
31
|
|
|
31
|
-
$('span
|
|
32
|
-
const style =
|
|
33
|
-
const hasBold =
|
|
34
|
-
const hasItalic =
|
|
35
|
-
const hasStrike =
|
|
32
|
+
$('span, p').each(function () {
|
|
33
|
+
const style = combinedElementStyle($, this, classStyles);
|
|
34
|
+
const hasBold = hasBoldStyle(style);
|
|
35
|
+
const hasItalic = hasItalicStyle(style);
|
|
36
|
+
const hasStrike = hasStrikeStyle(style);
|
|
36
37
|
if (!hasBold && !hasItalic && !hasStrike) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
@@ -47,10 +48,31 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
47
48
|
if (hasBold) {
|
|
48
49
|
wrapped = `<strong>${wrapped}</strong>`;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
if (this.tagName === 'p') {
|
|
52
|
+
$(this).html(wrapped);
|
|
53
|
+
} else {
|
|
54
|
+
$(this).replaceWith(wrapped);
|
|
55
|
+
}
|
|
51
56
|
hoisted += 1;
|
|
52
57
|
});
|
|
53
58
|
|
|
59
|
+
$('p').each(function () {
|
|
60
|
+
const style = combinedElementStyle($, this, classStyles);
|
|
61
|
+
if (!isBlockquoteStyle(style)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
$(this).replaceWith(
|
|
65
|
+
`<blockquote><p>${$(this).html() || ''}</p></blockquote>`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
$('a').each(function () {
|
|
70
|
+
const $a = $(this);
|
|
71
|
+
if (!$a.attr('href') && !$a.text().trim() && !$a.find('img').length) {
|
|
72
|
+
$a.remove();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
54
76
|
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
55
77
|
const $h = $(this);
|
|
56
78
|
$h.find('a').each(function () {
|
|
@@ -103,3 +125,63 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
103
125
|
|
|
104
126
|
return { html: rewritten, hoisted, unwrappedLinks };
|
|
105
127
|
}
|
|
128
|
+
|
|
129
|
+
function parseCssClassStyles($) {
|
|
130
|
+
const classStyles = new Map();
|
|
131
|
+
$('style').each(function () {
|
|
132
|
+
const css = $(this).html() || '';
|
|
133
|
+
const classRule = /\.([A-Za-z0-9_-]+)\s*\{([^{}]*)\}/g;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = classRule.exec(css)) !== null) {
|
|
136
|
+
const [, className, style] = match;
|
|
137
|
+
classStyles.set(
|
|
138
|
+
className,
|
|
139
|
+
`${classStyles.get(className) || ''};${style}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return classStyles;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function combinedElementStyle($, element, classStyles) {
|
|
147
|
+
const $el = $(element);
|
|
148
|
+
const styles = [String($el.attr('style') || '')];
|
|
149
|
+
const classes = String($el.attr('class') || '')
|
|
150
|
+
.split(/\s+/u)
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
for (const className of classes) {
|
|
153
|
+
if (classStyles.has(className)) {
|
|
154
|
+
styles.push(classStyles.get(className));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return styles.join(';');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasBoldStyle(style) {
|
|
161
|
+
return /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasItalicStyle(style) {
|
|
165
|
+
return /font-style\s*:\s*italic/i.test(style);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasStrikeStyle(style) {
|
|
169
|
+
return /text-decoration[^;]*\bline-through\b/i.test(style);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isBlockquoteStyle(style) {
|
|
173
|
+
const marginLeft = numericCssPointValue(style, 'margin-left');
|
|
174
|
+
const marginRight = numericCssPointValue(style, 'margin-right');
|
|
175
|
+
return (
|
|
176
|
+
marginLeft > 0 &&
|
|
177
|
+
marginRight > 0 &&
|
|
178
|
+
Math.abs(marginLeft - marginRight) < 0.1
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function numericCssPointValue(style, property) {
|
|
183
|
+
const match = style.match(
|
|
184
|
+
new RegExp(`${property}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)pt`, 'i')
|
|
185
|
+
);
|
|
186
|
+
return match ? Number(match[1]) : 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export function renderBlocksMarkdown(blocks) {
|
|
2
|
+
const counters = new Map();
|
|
3
|
+
const rendered = blocks.map((block) => {
|
|
4
|
+
if (block.type === 'table') {
|
|
5
|
+
counters.clear();
|
|
6
|
+
return { text: renderTableMarkdown(block), list: null, quote: false };
|
|
7
|
+
}
|
|
8
|
+
const counterKey = block.list
|
|
9
|
+
? `${block.list.id || ''}\u0000${block.list.level || 0}`
|
|
10
|
+
: null;
|
|
11
|
+
if (block.list) {
|
|
12
|
+
for (const key of [...counters.keys()]) {
|
|
13
|
+
const level = Number(key.split('\u0000')[1]);
|
|
14
|
+
if (level > (block.list.level || 0)) {
|
|
15
|
+
counters.delete(key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const next = (counters.get(counterKey) || 0) + 1;
|
|
19
|
+
counters.set(counterKey, next);
|
|
20
|
+
return {
|
|
21
|
+
text: renderParagraphMarkdown(block, { orderedIndex: next }),
|
|
22
|
+
list: block.list,
|
|
23
|
+
quote: Boolean(block.quote),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
counters.clear();
|
|
27
|
+
return {
|
|
28
|
+
text: renderParagraphMarkdown(block),
|
|
29
|
+
list: null,
|
|
30
|
+
quote: Boolean(block.quote),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const joined = [];
|
|
35
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
36
|
+
const cur = rendered[i];
|
|
37
|
+
if (!cur.text) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (joined.length > 0) {
|
|
41
|
+
const prev = rendered
|
|
42
|
+
.slice(0, i)
|
|
43
|
+
.reverse()
|
|
44
|
+
.find((entry) => entry.text);
|
|
45
|
+
const sameList =
|
|
46
|
+
cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
|
|
47
|
+
if (sameList) {
|
|
48
|
+
joined.push('\n');
|
|
49
|
+
} else if (cur.quote && prev?.quote) {
|
|
50
|
+
joined.push('\n>\n');
|
|
51
|
+
} else {
|
|
52
|
+
joined.push('\n\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
joined.push(cur.text);
|
|
56
|
+
}
|
|
57
|
+
const markdown = joined.join('').trimEnd();
|
|
58
|
+
return markdown ? `${markdown}\n` : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderParagraphMarkdown(block, context = {}) {
|
|
62
|
+
const text = renderContentMarkdown(block.content).trim();
|
|
63
|
+
if (block.horizontalRule) {
|
|
64
|
+
return '---';
|
|
65
|
+
}
|
|
66
|
+
const style = block.style || '';
|
|
67
|
+
const headingMatch = style.match(/^HEADING_(\d)$/u);
|
|
68
|
+
if (headingMatch) {
|
|
69
|
+
return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
|
|
70
|
+
}
|
|
71
|
+
if (style === 'TITLE') {
|
|
72
|
+
return `# ${text}`;
|
|
73
|
+
}
|
|
74
|
+
if (style === 'SUBTITLE') {
|
|
75
|
+
return `## ${text}`;
|
|
76
|
+
}
|
|
77
|
+
if (block.list) {
|
|
78
|
+
const indent = ' '.repeat(Math.max(0, block.list.level || 0));
|
|
79
|
+
const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
|
|
80
|
+
const marker = block.list.ordered ? `${orderedIndex}.` : '-';
|
|
81
|
+
return `${indent}${marker} ${text}`;
|
|
82
|
+
}
|
|
83
|
+
if (block.quote) {
|
|
84
|
+
return text
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map((line) => (line ? `> ${line}` : '>'))
|
|
87
|
+
.join('\n');
|
|
88
|
+
}
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderTableMarkdown(table) {
|
|
93
|
+
if (!table.rows.length) {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
|
|
97
|
+
const rows = table.rows.map((row) =>
|
|
98
|
+
Array.from({ length: width }, (_, idx) =>
|
|
99
|
+
escapeMarkdownTableCell(
|
|
100
|
+
renderContentMarkdown(row.cells[idx]?.content || [])
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
const separator = Array.from({ length: width }, () => '---');
|
|
105
|
+
return [rows[0], separator, ...rows.slice(1)]
|
|
106
|
+
.map((row) => `| ${row.join(' | ')} |`)
|
|
107
|
+
.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderContentMarkdown(content = []) {
|
|
111
|
+
const rendered = [];
|
|
112
|
+
for (let idx = 0; idx < content.length; idx++) {
|
|
113
|
+
const node = content[idx];
|
|
114
|
+
if (node.type === 'image') {
|
|
115
|
+
rendered.push(node.url ? `` : '');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const link = node.link || null;
|
|
120
|
+
const linkNodes = [node];
|
|
121
|
+
while (
|
|
122
|
+
content[idx + 1]?.type === 'text' &&
|
|
123
|
+
(content[idx + 1].link || null) === link
|
|
124
|
+
) {
|
|
125
|
+
linkNodes.push(content[++idx]);
|
|
126
|
+
}
|
|
127
|
+
const label = renderTextRunsMarkdown(linkNodes);
|
|
128
|
+
rendered.push(link ? `[${label}](${link})` : label);
|
|
129
|
+
}
|
|
130
|
+
return rendered.join('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderTextRunsMarkdown(nodes = []) {
|
|
134
|
+
const inactive = { bold: false, italic: false, strike: false };
|
|
135
|
+
let active = { ...inactive };
|
|
136
|
+
let output = '';
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
const next = {
|
|
139
|
+
bold: Boolean(node.bold),
|
|
140
|
+
italic: Boolean(node.italic),
|
|
141
|
+
strike: Boolean(node.strike),
|
|
142
|
+
};
|
|
143
|
+
output += markdownStyleTransition(active, next);
|
|
144
|
+
output += node.text || '';
|
|
145
|
+
active = next;
|
|
146
|
+
}
|
|
147
|
+
output += markdownStyleTransition(active, inactive);
|
|
148
|
+
return output;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function markdownStyleTransition(active, next) {
|
|
152
|
+
const styles = [
|
|
153
|
+
['bold', '**'],
|
|
154
|
+
['italic', '*'],
|
|
155
|
+
['strike', '~~'],
|
|
156
|
+
];
|
|
157
|
+
let markers = '';
|
|
158
|
+
for (const [key, marker] of [...styles].reverse()) {
|
|
159
|
+
if (active[key] && !next[key]) {
|
|
160
|
+
markers += marker;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [key, marker] of styles) {
|
|
164
|
+
if (!active[key] && next[key]) {
|
|
165
|
+
markers += marker;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return markers;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeMarkdownTableCell(value) {
|
|
172
|
+
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
173
|
+
}
|
package/src/gdocs.js
CHANGED
|
@@ -13,8 +13,19 @@ import { convertHtmlToMarkdown } from './lib.js';
|
|
|
13
13
|
import { createBrowser as defaultCreateBrowser } from './browser.js';
|
|
14
14
|
import { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
|
|
15
15
|
import { localizeGoogleDocsModelImages } from './gdocs-images.js';
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
import { renderBlocksMarkdown } from './gdocs-render-markdown.js';
|
|
17
|
+
import {
|
|
18
|
+
googleDocsBrowserModelUnavailableError,
|
|
19
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
20
|
+
fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
|
|
21
|
+
captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
|
|
22
|
+
} from './gdocs-fallback.js';
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
preprocessGoogleDocsExportHtml,
|
|
26
|
+
localizeGoogleDocsModelImages,
|
|
27
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
28
|
+
};
|
|
18
29
|
|
|
19
30
|
const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
|
|
20
31
|
|
|
@@ -308,6 +319,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
308
319
|
};
|
|
309
320
|
}
|
|
310
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Fetch a Google Doc through the public export pipeline for a requested output
|
|
324
|
+
* format. Thin wrapper over the fallback module that injects sibling fetchers.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} url - Google Docs URL
|
|
327
|
+
* @param {Object} [options] - Options
|
|
328
|
+
* @returns {Promise<Object>} Export result normalized for CLI rendering
|
|
329
|
+
*/
|
|
330
|
+
export function fetchGoogleDocByExportFormat(url, options = {}) {
|
|
331
|
+
return fetchGoogleDocByExportFormatImpl(
|
|
332
|
+
{ fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
|
|
333
|
+
url,
|
|
334
|
+
options
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Capture a Google Doc through the browser model, falling back to public export
|
|
340
|
+
* when the editor does not expose model chunks. Thin wrapper.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} url - Google Docs URL
|
|
343
|
+
* @param {Object} [options] - Capture options
|
|
344
|
+
* @returns {Promise<Object>} Browser-model or public-export result
|
|
345
|
+
*/
|
|
346
|
+
export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
|
|
347
|
+
return captureGoogleDocWithBrowserOrFallbackImpl(
|
|
348
|
+
{
|
|
349
|
+
captureGoogleDocWithBrowser,
|
|
350
|
+
fetchGoogleDoc,
|
|
351
|
+
fetchGoogleDocAsMarkdown,
|
|
352
|
+
fetchGoogleDocAsArchive,
|
|
353
|
+
},
|
|
354
|
+
url,
|
|
355
|
+
options
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
311
359
|
/**
|
|
312
360
|
* Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
|
|
313
361
|
*
|
|
@@ -409,7 +457,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
409
457
|
textBytes: Buffer.byteLength(capture.text || ''),
|
|
410
458
|
}));
|
|
411
459
|
if (capture.blocks.length === 0) {
|
|
412
|
-
throw
|
|
460
|
+
throw googleDocsBrowserModelUnavailableError(
|
|
413
461
|
'Google Docs editor page did not expose DOCS_modelChunk data'
|
|
414
462
|
);
|
|
415
463
|
}
|
|
@@ -602,6 +650,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
602
650
|
let cell = null;
|
|
603
651
|
const tableHistograms = [];
|
|
604
652
|
let currentHistogram = null;
|
|
653
|
+
let previousTableControl = null;
|
|
605
654
|
|
|
606
655
|
const bumpHistogram = (code) => {
|
|
607
656
|
if (!currentHistogram) {
|
|
@@ -625,7 +674,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
625
674
|
|
|
626
675
|
const flushCell = ({ dropEmpty = false } = {}) => {
|
|
627
676
|
if (cell && row) {
|
|
628
|
-
const isEmpty = cell
|
|
677
|
+
const isEmpty = cellIsEmpty(cell);
|
|
629
678
|
if (!dropEmpty || !isEmpty) {
|
|
630
679
|
row.cells.push(cell);
|
|
631
680
|
}
|
|
@@ -648,7 +697,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
648
697
|
// last cell content (from `\n` just before the `0x11` close marker).
|
|
649
698
|
while (
|
|
650
699
|
table.rows.length > 1 &&
|
|
651
|
-
table.rows[table.rows.length - 1]
|
|
700
|
+
rowIsEmpty(table.rows[table.rows.length - 1])
|
|
652
701
|
) {
|
|
653
702
|
table.rows.pop();
|
|
654
703
|
}
|
|
@@ -660,6 +709,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
660
709
|
}
|
|
661
710
|
currentHistogram = null;
|
|
662
711
|
table = null;
|
|
712
|
+
previousTableControl = null;
|
|
663
713
|
};
|
|
664
714
|
|
|
665
715
|
const targetContent = () => {
|
|
@@ -680,6 +730,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
680
730
|
if (code === 0x10) {
|
|
681
731
|
flushParagraph(i + 1);
|
|
682
732
|
table = { type: 'table', rows: [] };
|
|
733
|
+
previousTableControl = code;
|
|
683
734
|
currentHistogram = {
|
|
684
735
|
'0x0a': 0,
|
|
685
736
|
'0x0b': 0,
|
|
@@ -699,6 +750,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
699
750
|
bumpHistogram(code);
|
|
700
751
|
flushRow({ dropEmptyTrailingCell: true });
|
|
701
752
|
row = { cells: [] };
|
|
753
|
+
previousTableControl = code;
|
|
702
754
|
continue;
|
|
703
755
|
}
|
|
704
756
|
if (code === 0x1c) {
|
|
@@ -708,11 +760,19 @@ export function parseGoogleDocsModelChunks(
|
|
|
708
760
|
row = { cells: [] };
|
|
709
761
|
}
|
|
710
762
|
cell = { content: [] };
|
|
763
|
+
previousTableControl = code;
|
|
711
764
|
continue;
|
|
712
765
|
}
|
|
713
766
|
if (code === 0x0a) {
|
|
714
767
|
bumpHistogram(code);
|
|
715
768
|
if (table) {
|
|
769
|
+
if (
|
|
770
|
+
cellIsEmpty(cell) &&
|
|
771
|
+
(previousTableControl === 0x1c || previousTableControl === 0x12)
|
|
772
|
+
) {
|
|
773
|
+
previousTableControl = code;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
716
776
|
// Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
|
|
717
777
|
// Row boundaries are communicated via the explicit `0x12` marker and
|
|
718
778
|
// the closing `0x11` marker. Earlier versions collapsed multi-column
|
|
@@ -722,6 +782,7 @@ export function parseGoogleDocsModelChunks(
|
|
|
722
782
|
row = { cells: [] };
|
|
723
783
|
}
|
|
724
784
|
cell = { content: [] };
|
|
785
|
+
previousTableControl = code;
|
|
725
786
|
} else {
|
|
726
787
|
flushParagraph(i + 1);
|
|
727
788
|
}
|
|
@@ -730,18 +791,21 @@ export function parseGoogleDocsModelChunks(
|
|
|
730
791
|
if (code === 0x0b) {
|
|
731
792
|
bumpHistogram(code);
|
|
732
793
|
appendText(targetContent(), '\n');
|
|
794
|
+
previousTableControl = null;
|
|
733
795
|
continue;
|
|
734
796
|
}
|
|
735
797
|
|
|
736
798
|
const image = imagesByPos.get(i);
|
|
737
799
|
if (image) {
|
|
738
800
|
targetContent().push(image);
|
|
801
|
+
previousTableControl = null;
|
|
739
802
|
if (fullText[i] === '*') {
|
|
740
803
|
continue;
|
|
741
804
|
}
|
|
742
805
|
}
|
|
743
806
|
|
|
744
807
|
appendText(targetContent(), fullText[i], styleMaps.inlineStyles[i]);
|
|
808
|
+
previousTableControl = null;
|
|
745
809
|
}
|
|
746
810
|
|
|
747
811
|
if (table) {
|
|
@@ -770,6 +834,22 @@ export function parseGoogleDocsModelChunks(
|
|
|
770
834
|
};
|
|
771
835
|
}
|
|
772
836
|
|
|
837
|
+
function cellIsEmpty(cell) {
|
|
838
|
+
if (!cell) {
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
return (cell.content || []).every((node) => {
|
|
842
|
+
if (node.type === 'image') {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
return !String(node.text || '').trim();
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function rowIsEmpty(row) {
|
|
850
|
+
return !row || row.cells.length === 0 || row.cells.every(cellIsEmpty);
|
|
851
|
+
}
|
|
852
|
+
|
|
773
853
|
function buildModelStyleMaps(items, textLength) {
|
|
774
854
|
const inlineStyles = Array.from({ length: textLength }, () => ({
|
|
775
855
|
bold: false,
|
|
@@ -821,11 +901,17 @@ function applyInlineTextStyle(inlineStyles, start, end, patch) {
|
|
|
821
901
|
}
|
|
822
902
|
|
|
823
903
|
function textStyleFromModel(item) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
904
|
+
const style = {};
|
|
905
|
+
if (item.sm?.ts_bd) {
|
|
906
|
+
style.bold = true;
|
|
907
|
+
}
|
|
908
|
+
if (item.sm?.ts_it) {
|
|
909
|
+
style.italic = true;
|
|
910
|
+
}
|
|
911
|
+
if (item.sm?.ts_st) {
|
|
912
|
+
style.strike = true;
|
|
913
|
+
}
|
|
914
|
+
return style;
|
|
829
915
|
}
|
|
830
916
|
|
|
831
917
|
function paragraphStyleFromModel(item) {
|
|
@@ -1058,152 +1144,6 @@ function sameTextStyle(a, b) {
|
|
|
1058
1144
|
);
|
|
1059
1145
|
}
|
|
1060
1146
|
|
|
1061
|
-
function renderBlocksMarkdown(blocks) {
|
|
1062
|
-
const counters = new Map();
|
|
1063
|
-
const rendered = blocks.map((block) => {
|
|
1064
|
-
if (block.type === 'table') {
|
|
1065
|
-
counters.clear();
|
|
1066
|
-
return { text: renderTableMarkdown(block), list: null };
|
|
1067
|
-
}
|
|
1068
|
-
const counterKey = block.list
|
|
1069
|
-
? `${block.list.id || ''}\u0000${block.list.level || 0}`
|
|
1070
|
-
: null;
|
|
1071
|
-
if (block.list) {
|
|
1072
|
-
// Reset deeper siblings when we re-enter a shallower level so
|
|
1073
|
-
// nested lists restart from 1 on each new parent.
|
|
1074
|
-
for (const key of [...counters.keys()]) {
|
|
1075
|
-
const level = Number(key.split('\u0000')[1]);
|
|
1076
|
-
if (level > (block.list.level || 0)) {
|
|
1077
|
-
counters.delete(key);
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
const next = (counters.get(counterKey) || 0) + 1;
|
|
1081
|
-
counters.set(counterKey, next);
|
|
1082
|
-
return {
|
|
1083
|
-
text: renderParagraphMarkdown(block, { orderedIndex: next }),
|
|
1084
|
-
list: block.list,
|
|
1085
|
-
};
|
|
1086
|
-
}
|
|
1087
|
-
counters.clear();
|
|
1088
|
-
return { text: renderParagraphMarkdown(block), list: null };
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
const joined = [];
|
|
1092
|
-
for (let i = 0; i < rendered.length; i++) {
|
|
1093
|
-
const cur = rendered[i];
|
|
1094
|
-
if (!cur.text) {
|
|
1095
|
-
continue;
|
|
1096
|
-
}
|
|
1097
|
-
if (joined.length > 0) {
|
|
1098
|
-
const prev = rendered
|
|
1099
|
-
.slice(0, i)
|
|
1100
|
-
.reverse()
|
|
1101
|
-
.find((entry) => entry.text);
|
|
1102
|
-
const sameList =
|
|
1103
|
-
cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
|
|
1104
|
-
joined.push(sameList ? '\n' : '\n\n');
|
|
1105
|
-
}
|
|
1106
|
-
joined.push(cur.text);
|
|
1107
|
-
}
|
|
1108
|
-
return joined.join('').replace(/\s+$/u, '');
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function renderParagraphMarkdown(block, context = {}) {
|
|
1112
|
-
const text = renderContentMarkdown(block.content).trim();
|
|
1113
|
-
if (block.horizontalRule) {
|
|
1114
|
-
return '---';
|
|
1115
|
-
}
|
|
1116
|
-
const style = block.style || '';
|
|
1117
|
-
const headingMatch = style.match(/^HEADING_(\d)$/u);
|
|
1118
|
-
if (headingMatch) {
|
|
1119
|
-
return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
|
|
1120
|
-
}
|
|
1121
|
-
if (style === 'TITLE') {
|
|
1122
|
-
return `# ${text}`;
|
|
1123
|
-
}
|
|
1124
|
-
if (style === 'SUBTITLE') {
|
|
1125
|
-
return `## ${text}`;
|
|
1126
|
-
}
|
|
1127
|
-
if (block.list) {
|
|
1128
|
-
const indent = ' '.repeat(Math.max(0, block.list.level || 0));
|
|
1129
|
-
const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
|
|
1130
|
-
const marker = block.list.ordered ? `${orderedIndex}.` : '-';
|
|
1131
|
-
return `${indent}${marker} ${text}`;
|
|
1132
|
-
}
|
|
1133
|
-
if (block.quote) {
|
|
1134
|
-
return text
|
|
1135
|
-
.split('\n')
|
|
1136
|
-
.map((line) => (line ? `> ${line}` : '>'))
|
|
1137
|
-
.join('\n');
|
|
1138
|
-
}
|
|
1139
|
-
return text;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
function renderTableMarkdown(table) {
|
|
1143
|
-
if (!table.rows.length) {
|
|
1144
|
-
return '';
|
|
1145
|
-
}
|
|
1146
|
-
const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
|
|
1147
|
-
const rows = table.rows.map((row) =>
|
|
1148
|
-
Array.from({ length: width }, (_, idx) =>
|
|
1149
|
-
escapeMarkdownTableCell(
|
|
1150
|
-
renderContentMarkdown(row.cells[idx]?.content || [])
|
|
1151
|
-
)
|
|
1152
|
-
)
|
|
1153
|
-
);
|
|
1154
|
-
const separator = Array.from({ length: width }, () => '---');
|
|
1155
|
-
return [rows[0], separator, ...rows.slice(1)]
|
|
1156
|
-
.map((row) => `| ${row.join(' | ')} |`)
|
|
1157
|
-
.join('\n');
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
function renderContentMarkdown(content = []) {
|
|
1161
|
-
const rendered = [];
|
|
1162
|
-
for (let idx = 0; idx < content.length; idx++) {
|
|
1163
|
-
const node = content[idx];
|
|
1164
|
-
if (node.type === 'image') {
|
|
1165
|
-
rendered.push(node.url ? `` : '');
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1168
|
-
if (!node.link) {
|
|
1169
|
-
rendered.push(renderMarkedText(node.text || '', node));
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const link = node.link;
|
|
1174
|
-
const linkNodes = [node];
|
|
1175
|
-
while (
|
|
1176
|
-
content[idx + 1]?.type === 'text' &&
|
|
1177
|
-
content[idx + 1].link === link
|
|
1178
|
-
) {
|
|
1179
|
-
linkNodes.push(content[++idx]);
|
|
1180
|
-
}
|
|
1181
|
-
const label = linkNodes
|
|
1182
|
-
.map((linkNode) => renderMarkedText(linkNode.text || '', linkNode))
|
|
1183
|
-
.join('');
|
|
1184
|
-
rendered.push(`[${label}](${link})`);
|
|
1185
|
-
}
|
|
1186
|
-
return rendered.join('');
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function renderMarkedText(text, node = {}) {
|
|
1190
|
-
let output = text;
|
|
1191
|
-
if (!output) {
|
|
1192
|
-
return output;
|
|
1193
|
-
}
|
|
1194
|
-
if (node.bold && node.italic) {
|
|
1195
|
-
output = `***${output}***`;
|
|
1196
|
-
} else if (node.bold) {
|
|
1197
|
-
output = `**${output}**`;
|
|
1198
|
-
} else if (node.italic) {
|
|
1199
|
-
output = `*${output}*`;
|
|
1200
|
-
}
|
|
1201
|
-
if (node.strike) {
|
|
1202
|
-
output = `~~${output}~~`;
|
|
1203
|
-
}
|
|
1204
|
-
return output;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
1147
|
function renderBlocksHtml(blocks) {
|
|
1208
1148
|
return `<!doctype html><html><body>${blocks
|
|
1209
1149
|
.map((block) => {
|
|
@@ -1336,10 +1276,6 @@ function escapeHtml(value) {
|
|
|
1336
1276
|
});
|
|
1337
1277
|
}
|
|
1338
1278
|
|
|
1339
|
-
function escapeMarkdownTableCell(value) {
|
|
1340
|
-
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
1279
|
/**
|
|
1344
1280
|
* Extract base64 data URI images from HTML content.
|
|
1345
1281
|
*
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
buildEditUrl,
|
|
7
7
|
buildDocsApiUrl,
|
|
8
8
|
extractBase64Images,
|
|
9
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
10
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
9
11
|
parseGoogleDocsModelChunks,
|
|
10
12
|
renderGoogleDocsCapture,
|
|
11
13
|
renderDocsApiDocument,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
import fs from 'fs';
|
|
23
25
|
import os from 'os';
|
|
24
26
|
import path from 'path';
|
|
27
|
+
import nock from 'nock';
|
|
25
28
|
|
|
26
29
|
jest.setTimeout(30000);
|
|
27
30
|
|
|
@@ -282,6 +285,29 @@ describe('gdocs', () => {
|
|
|
282
285
|
expect(markdown).toContain('| D | E | F |');
|
|
283
286
|
});
|
|
284
287
|
|
|
288
|
+
it('does not create empty columns when Docs emits both 0x1c and 0x0a cell separators (issue #96)', () => {
|
|
289
|
+
const chunks = [
|
|
290
|
+
{
|
|
291
|
+
chunk: [
|
|
292
|
+
{
|
|
293
|
+
ty: 'is',
|
|
294
|
+
s: `${
|
|
295
|
+
String.fromCharCode(0x10) + String.fromCharCode(0x12)
|
|
296
|
+
}A${String.fromCharCode(0x1c)}\nB${String.fromCharCode(
|
|
297
|
+
0x1c
|
|
298
|
+
)}\nC\n${String.fromCharCode(0x11)}`,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
304
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
305
|
+
|
|
306
|
+
expect(capture.tables[0].rows[0].cells).toHaveLength(3);
|
|
307
|
+
expect(markdown).toContain('| A | B | C |');
|
|
308
|
+
expect(markdown).not.toContain('| A | | B |');
|
|
309
|
+
});
|
|
310
|
+
|
|
285
311
|
it('numbers ordered list items sequentially (issue #92 R3)', () => {
|
|
286
312
|
// Three list items sharing the same list id should render as 1. 2. 3.
|
|
287
313
|
const text = 'First item\nSecond item\nThird item\n';
|
|
@@ -467,6 +493,108 @@ describe('gdocs', () => {
|
|
|
467
493
|
''
|
|
468
494
|
);
|
|
469
495
|
});
|
|
496
|
+
|
|
497
|
+
it('keeps nested bold and italic markers balanced (issue #96)', () => {
|
|
498
|
+
const text = 'Bold text with italic inside and back to bold\n';
|
|
499
|
+
const chunks = [
|
|
500
|
+
{
|
|
501
|
+
chunk: [
|
|
502
|
+
{ ty: 'is', s: text },
|
|
503
|
+
{
|
|
504
|
+
ty: 'as',
|
|
505
|
+
st: 'text',
|
|
506
|
+
si: 1,
|
|
507
|
+
ei: text.length - 1,
|
|
508
|
+
sm: { ts_bd: true },
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
ty: 'as',
|
|
512
|
+
st: 'text',
|
|
513
|
+
si: text.indexOf('italic') + 1,
|
|
514
|
+
ei: text.indexOf('italic') + 'italic inside'.length,
|
|
515
|
+
sm: { ts_it: true },
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
];
|
|
520
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
521
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
522
|
+
|
|
523
|
+
expect(markdown).toBe(
|
|
524
|
+
'**Bold text with *italic inside* and back to bold**\n'
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('keeps styled same-target link segments in one link label (issue #96)', () => {
|
|
529
|
+
const text = 'Link with bold text\n';
|
|
530
|
+
const startOf = (needle) => text.indexOf(needle) + 1;
|
|
531
|
+
const endOf = (needle) => startOf(needle) + needle.length - 1;
|
|
532
|
+
const chunks = [
|
|
533
|
+
{
|
|
534
|
+
chunk: [
|
|
535
|
+
{ ty: 'is', s: text },
|
|
536
|
+
{
|
|
537
|
+
ty: 'as',
|
|
538
|
+
st: 'link',
|
|
539
|
+
si: 1,
|
|
540
|
+
ei: text.length - 1,
|
|
541
|
+
sm: { lnks_link: { ulnk_url: 'https://example.com' } },
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
ty: 'as',
|
|
545
|
+
st: 'text',
|
|
546
|
+
si: startOf('bold'),
|
|
547
|
+
ei: endOf('bold'),
|
|
548
|
+
sm: { ts_bd: true },
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
},
|
|
552
|
+
];
|
|
553
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
554
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
555
|
+
|
|
556
|
+
expect(markdown).toBe('[Link with **bold** text](https://example.com)\n');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('keeps consecutive blockquote paragraphs in the same quote block (issue #96)', () => {
|
|
560
|
+
const text = 'Quote paragraph one\nQuote paragraph two\n';
|
|
561
|
+
const lineEnd = (needle) => text.indexOf(needle) + needle.length + 1;
|
|
562
|
+
const chunks = [
|
|
563
|
+
{
|
|
564
|
+
chunk: [
|
|
565
|
+
{ ty: 'is', s: text },
|
|
566
|
+
{
|
|
567
|
+
ty: 'as',
|
|
568
|
+
st: 'paragraph',
|
|
569
|
+
si: lineEnd('Quote paragraph one'),
|
|
570
|
+
ei: lineEnd('Quote paragraph one'),
|
|
571
|
+
sm: { ps_il: 24, ps_ifl: 24 },
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
ty: 'as',
|
|
575
|
+
st: 'paragraph',
|
|
576
|
+
si: lineEnd('Quote paragraph two'),
|
|
577
|
+
ei: lineEnd('Quote paragraph two'),
|
|
578
|
+
sm: { ps_il: 24, ps_ifl: 24 },
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
},
|
|
582
|
+
];
|
|
583
|
+
const capture = parseGoogleDocsModelChunks(chunks);
|
|
584
|
+
const markdown = renderGoogleDocsCapture(capture, 'markdown');
|
|
585
|
+
|
|
586
|
+
expect(markdown).toBe(
|
|
587
|
+
'> Quote paragraph one\n>\n> Quote paragraph two\n'
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('ends rendered markdown with a newline (issue #96)', () => {
|
|
592
|
+
const capture = parseGoogleDocsModelChunks([
|
|
593
|
+
{ ty: 'is', s: 'Last line\n' },
|
|
594
|
+
]);
|
|
595
|
+
|
|
596
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toBe('Last line\n');
|
|
597
|
+
});
|
|
470
598
|
});
|
|
471
599
|
|
|
472
600
|
describe('renderDocsApiDocument (issue #72)', () => {
|
|
@@ -539,6 +667,49 @@ describe('gdocs', () => {
|
|
|
539
667
|
});
|
|
540
668
|
});
|
|
541
669
|
|
|
670
|
+
describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
|
|
671
|
+
afterEach(() => {
|
|
672
|
+
nock.cleanAll();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('falls back to public export archive when the editor exposes no model chunks', async () => {
|
|
676
|
+
const tinyPng =
|
|
677
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
|
678
|
+
nock('https://docs.google.com')
|
|
679
|
+
.get('/document/d/fallback-doc/export')
|
|
680
|
+
.query({ format: 'html' })
|
|
681
|
+
.reply(
|
|
682
|
+
200,
|
|
683
|
+
`<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
|
|
684
|
+
{ 'content-type': 'text/html; charset=utf-8' }
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const fallbackErrors = [];
|
|
688
|
+
const result = await captureGoogleDocWithBrowserOrFallback(
|
|
689
|
+
'https://docs.google.com/document/d/fallback-doc/edit',
|
|
690
|
+
{
|
|
691
|
+
format: 'archive',
|
|
692
|
+
waitMs: 0,
|
|
693
|
+
createBrowser: async () =>
|
|
694
|
+
fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
|
|
695
|
+
onFallback: (err) => fallbackErrors.push(err),
|
|
696
|
+
}
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
expect(result.method).toBe('public-export');
|
|
700
|
+
expect(result.fallback).toBe(true);
|
|
701
|
+
expect(result.markdown).toContain('Fallback Doc');
|
|
702
|
+
expect(result.markdown).toContain('Complete text');
|
|
703
|
+
expect(result.images).toHaveLength(1);
|
|
704
|
+
expect(result.html).toContain('images/image-01.png');
|
|
705
|
+
expect(fallbackErrors).toHaveLength(1);
|
|
706
|
+
expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
|
|
707
|
+
true
|
|
708
|
+
);
|
|
709
|
+
expect(nock.isDone()).toBe(true);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
542
713
|
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
543
714
|
it('contains all expected formats', () => {
|
|
544
715
|
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
@@ -643,6 +814,18 @@ describe('gdocs', () => {
|
|
|
643
814
|
expect(out).toContain('<del>');
|
|
644
815
|
});
|
|
645
816
|
|
|
817
|
+
it('hoists Google Docs CSS class styles into semantic inline tags', () => {
|
|
818
|
+
const html =
|
|
819
|
+
'<style>.c7{font-weight:700}.c19{font-style:italic}.c21{text-decoration:line-through}</style>' +
|
|
820
|
+
'<p><span class="c7">Bold</span> <span class="c19">Italic</span> <span class="c21">Strike</span></p>';
|
|
821
|
+
const { html: out, hoisted } = preprocessGoogleDocsExportHtml(html);
|
|
822
|
+
|
|
823
|
+
expect(hoisted).toBe(3);
|
|
824
|
+
expect(out).toContain('<strong>Bold</strong>');
|
|
825
|
+
expect(out).toContain('<em>Italic</em>');
|
|
826
|
+
expect(out).toContain('<del>Strike</del>');
|
|
827
|
+
});
|
|
828
|
+
|
|
646
829
|
it('combines multiple styles on a single span', () => {
|
|
647
830
|
const html =
|
|
648
831
|
'<p><span style="font-weight:700;font-style:italic;text-decoration:line-through">Mixed</span></p>';
|
|
@@ -672,6 +855,22 @@ describe('gdocs', () => {
|
|
|
672
855
|
expect(out).not.toContain('<a id=');
|
|
673
856
|
});
|
|
674
857
|
|
|
858
|
+
it('strips standalone empty anchors before headings', () => {
|
|
859
|
+
const html = '<a id="anchor-1"></a><h2>Headings</h2>';
|
|
860
|
+
const { html: out } = preprocessGoogleDocsExportHtml(html);
|
|
861
|
+
|
|
862
|
+
expect(out).toContain('<h2>Headings</h2>');
|
|
863
|
+
expect(out).not.toContain('<a id=');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('turns class-indented Google Docs paragraphs into blockquotes', () => {
|
|
867
|
+
const html =
|
|
868
|
+
'<style>.c18{margin-left:24pt;margin-right:24pt}</style><p class="c18">Quote</p>';
|
|
869
|
+
const { html: out } = preprocessGoogleDocsExportHtml(html);
|
|
870
|
+
|
|
871
|
+
expect(out).toContain('<blockquote><p>Quote</p></blockquote>');
|
|
872
|
+
});
|
|
873
|
+
|
|
675
874
|
it('replaces entity and U+00A0 with regular spaces', () => {
|
|
676
875
|
const html = '<p>A\u00A0B C</p>';
|
|
677
876
|
const { html: out } = preprocessGoogleDocsExportHtml(html);
|
|
@@ -806,3 +1005,23 @@ describe('gdocs', () => {
|
|
|
806
1005
|
});
|
|
807
1006
|
});
|
|
808
1007
|
});
|
|
1008
|
+
|
|
1009
|
+
function fakeBrowserReturningModel(modelData) {
|
|
1010
|
+
return {
|
|
1011
|
+
async newPage() {
|
|
1012
|
+
return {
|
|
1013
|
+
async addInitScript() {},
|
|
1014
|
+
async setUserAgent() {},
|
|
1015
|
+
async setExtraHTTPHeaders() {},
|
|
1016
|
+
async setViewport() {},
|
|
1017
|
+
async goto() {},
|
|
1018
|
+
async waitForTimeout() {},
|
|
1019
|
+
async evaluate() {
|
|
1020
|
+
return modelData;
|
|
1021
|
+
},
|
|
1022
|
+
async close() {},
|
|
1023
|
+
};
|
|
1024
|
+
},
|
|
1025
|
+
async close() {},
|
|
1026
|
+
};
|
|
1027
|
+
}
|