@link-assistant/web-capture 1.7.14 → 1.7.16
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 +12 -0
- package/package.json +1 -1
- package/src/gdocs-preprocess.js +234 -9
- package/src/gdocs-render-markdown.js +21 -3
- package/src/gdocs.js +22 -4
- package/tests/integration/gdocs-public-doc.test.js +13 -13
- package/tests/unit/gdocs.test.js +126 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.16
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bf7eaec: Keep Google Docs browser-model soft line breaks outside inline mark tags and preserve image dimensions in HTML output.
|
|
8
|
+
|
|
9
|
+
## 1.7.15
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- bf7eaec: Improve Google Docs public-export Markdown fidelity for headings, strikethrough, blockquotes, nested lists, and tables.
|
|
14
|
+
|
|
3
15
|
## 1.7.14
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/gdocs-preprocess.js
CHANGED
|
@@ -30,6 +30,12 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
30
30
|
let unwrappedLinks = 0;
|
|
31
31
|
|
|
32
32
|
$('span, p').each(function () {
|
|
33
|
+
if (
|
|
34
|
+
this.tagName === 'span' &&
|
|
35
|
+
$(this).closest('h1, h2, h3, h4, h5, h6').length
|
|
36
|
+
) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
33
39
|
const style = combinedElementStyle($, this, classStyles);
|
|
34
40
|
const hasBold = hasBoldStyle(style);
|
|
35
41
|
const hasItalic = hasItalicStyle(style);
|
|
@@ -56,15 +62,9 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
56
62
|
hoisted += 1;
|
|
57
63
|
});
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
$(this).replaceWith(
|
|
65
|
-
`<blockquote><p>${$(this).html() || ''}</p></blockquote>`
|
|
66
|
-
);
|
|
67
|
-
});
|
|
65
|
+
groupGoogleDocsBlockquotes($, classStyles);
|
|
66
|
+
nestGoogleDocsLists($, classStyles);
|
|
67
|
+
normalizeGoogleDocsTables($);
|
|
68
68
|
|
|
69
69
|
$('a').each(function () {
|
|
70
70
|
const $a = $(this);
|
|
@@ -126,6 +126,24 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
126
126
|
return { html: rewritten, hoisted, unwrappedLinks };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Normalize Markdown emitted from Google Docs public-export HTML converters.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} markdown - Markdown rendered from preprocessed export HTML.
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function normalizeGoogleDocsExportMarkdown(markdown) {
|
|
136
|
+
if (!markdown || typeof markdown !== 'string') {
|
|
137
|
+
return markdown || '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return markdown
|
|
141
|
+
.replace(/\\([.!()[\]])/g, '$1')
|
|
142
|
+
.replace(/(^|[^\w~])~([^~\n]+)~(?=$|[^\w~])/g, '$1~~$2~~')
|
|
143
|
+
.replace(/\n{3,}(?=> )/g, '\n\n')
|
|
144
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
function parseCssClassStyles($) {
|
|
130
148
|
const classStyles = new Map();
|
|
131
149
|
$('style').each(function () {
|
|
@@ -157,6 +175,213 @@ function combinedElementStyle($, element, classStyles) {
|
|
|
157
175
|
return styles.join(';');
|
|
158
176
|
}
|
|
159
177
|
|
|
178
|
+
function groupGoogleDocsBlockquotes($, classStyles) {
|
|
179
|
+
const processed = new Set();
|
|
180
|
+
|
|
181
|
+
$('p').each(function () {
|
|
182
|
+
if (
|
|
183
|
+
processed.has(this) ||
|
|
184
|
+
!isBlockquoteStyle(combinedElementStyle($, this, classStyles))
|
|
185
|
+
) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const paragraphs = [];
|
|
190
|
+
let current = $(this);
|
|
191
|
+
while (
|
|
192
|
+
current.length &&
|
|
193
|
+
current[0].tagName === 'p' &&
|
|
194
|
+
isBlockquoteStyle(combinedElementStyle($, current[0], classStyles))
|
|
195
|
+
) {
|
|
196
|
+
paragraphs.push(current[0]);
|
|
197
|
+
processed.add(current[0]);
|
|
198
|
+
current = current.next();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const quoteHtml = paragraphs
|
|
202
|
+
.map((paragraph) => `<p>${$(paragraph).html() || ''}</p>`)
|
|
203
|
+
.join('');
|
|
204
|
+
$(paragraphs[0]).before(`<blockquote>${quoteHtml}</blockquote>`);
|
|
205
|
+
for (const paragraph of paragraphs) {
|
|
206
|
+
$(paragraph).remove();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function nestGoogleDocsLists($, classStyles) {
|
|
212
|
+
const processed = new Set();
|
|
213
|
+
|
|
214
|
+
$('body')
|
|
215
|
+
.children('ul, ol')
|
|
216
|
+
.each(function () {
|
|
217
|
+
if (processed.has(this)) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const group = [this];
|
|
222
|
+
processed.add(this);
|
|
223
|
+
let next = $(this).next();
|
|
224
|
+
while (next.length && ['ul', 'ol'].includes(next[0].tagName)) {
|
|
225
|
+
group.push(next[0]);
|
|
226
|
+
processed.add(next[0]);
|
|
227
|
+
next = next.next();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (group.length < 2) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const nested = renderNestedListGroup($, group, classStyles);
|
|
235
|
+
$(group[0]).before(nested);
|
|
236
|
+
for (const list of group) {
|
|
237
|
+
$(list).remove();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderNestedListGroup($, group, classStyles) {
|
|
243
|
+
let html = '';
|
|
244
|
+
let currentLevel = -1;
|
|
245
|
+
const openTags = [];
|
|
246
|
+
const itemOpen = [];
|
|
247
|
+
|
|
248
|
+
const closeItem = (level) => {
|
|
249
|
+
if (itemOpen[level]) {
|
|
250
|
+
html += '</li>';
|
|
251
|
+
itemOpen[level] = false;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const closeList = (level) => {
|
|
256
|
+
closeItem(level);
|
|
257
|
+
html += `</${openTags[level]}>`;
|
|
258
|
+
openTags[level] = undefined;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const openList = (level, tagName) => {
|
|
262
|
+
openTags[level] = tagName;
|
|
263
|
+
itemOpen[level] = false;
|
|
264
|
+
html += `<${tagName}>`;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
for (const list of group) {
|
|
268
|
+
const tagName = list.tagName.toLowerCase();
|
|
269
|
+
$(list)
|
|
270
|
+
.children('li')
|
|
271
|
+
.each(function () {
|
|
272
|
+
const level = googleDocsListItemLevel($, this, classStyles);
|
|
273
|
+
while (currentLevel > level) {
|
|
274
|
+
closeList(currentLevel);
|
|
275
|
+
currentLevel -= 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
while (currentLevel < level) {
|
|
279
|
+
currentLevel += 1;
|
|
280
|
+
openList(currentLevel, tagName);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (openTags[level] && openTags[level] !== tagName) {
|
|
284
|
+
closeList(level);
|
|
285
|
+
openList(level, tagName);
|
|
286
|
+
} else if (!openTags[level]) {
|
|
287
|
+
openList(level, tagName);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
closeItem(level);
|
|
291
|
+
html += `<li>${$(this).html() || ''}`;
|
|
292
|
+
itemOpen[level] = true;
|
|
293
|
+
|
|
294
|
+
for (let deeper = level + 1; deeper < itemOpen.length; deeper += 1) {
|
|
295
|
+
itemOpen[deeper] = false;
|
|
296
|
+
openTags[deeper] = undefined;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
while (currentLevel >= 0) {
|
|
302
|
+
closeList(currentLevel);
|
|
303
|
+
currentLevel -= 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return html;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function googleDocsListItemLevel($, li, classStyles) {
|
|
310
|
+
const style = combinedElementStyle($, li, classStyles);
|
|
311
|
+
const marginLeft = numericCssPointValue(style, 'margin-left');
|
|
312
|
+
if (marginLeft <= 0) {
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
return Math.max(0, Math.round((marginLeft - 36) / 36));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeGoogleDocsTables($) {
|
|
319
|
+
$('table').each(function () {
|
|
320
|
+
const $table = $(this);
|
|
321
|
+
const rows = $table.find('tr').toArray();
|
|
322
|
+
if (!rows.length) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const $cleanTable = $('<table></table>');
|
|
327
|
+
const $thead = $('<thead></thead>');
|
|
328
|
+
const $tbody = $('<tbody></tbody>');
|
|
329
|
+
|
|
330
|
+
rows.forEach((row, rowIndex) => {
|
|
331
|
+
const $tr = $('<tr></tr>');
|
|
332
|
+
$(row)
|
|
333
|
+
.children('td, th')
|
|
334
|
+
.each(function () {
|
|
335
|
+
const tagName = rowIndex === 0 ? 'th' : 'td';
|
|
336
|
+
const $cell = $(`<${tagName}></${tagName}>`);
|
|
337
|
+
$cell.html(inlineTableCellHtml($, this));
|
|
338
|
+
$tr.append($cell);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (!$tr.children().length) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (rowIndex === 0) {
|
|
345
|
+
$thead.append($tr);
|
|
346
|
+
} else {
|
|
347
|
+
$tbody.append($tr);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if ($thead.children().length) {
|
|
352
|
+
$cleanTable.append($thead);
|
|
353
|
+
}
|
|
354
|
+
if ($tbody.children().length) {
|
|
355
|
+
$cleanTable.append($tbody);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
$table.replaceWith($cleanTable);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function inlineTableCellHtml($, cell) {
|
|
363
|
+
const parts = [];
|
|
364
|
+
$(cell)
|
|
365
|
+
.contents()
|
|
366
|
+
.each(function () {
|
|
367
|
+
if (this.type === 'tag' && ['p', 'div'].includes(this.tagName)) {
|
|
368
|
+
const inner = $(this).html() || '';
|
|
369
|
+
if (inner.trim()) {
|
|
370
|
+
parts.push(inner);
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (this.type === 'tag') {
|
|
375
|
+
parts.push($.html(this));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (this.type === 'text' && this.data.trim()) {
|
|
379
|
+
parts.push(this.data);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
return parts.join('<br>');
|
|
383
|
+
}
|
|
384
|
+
|
|
160
385
|
function hasBoldStyle(style) {
|
|
161
386
|
return /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
|
|
162
387
|
}
|
|
@@ -138,9 +138,27 @@ function renderTextRunsMarkdown(nodes = []) {
|
|
|
138
138
|
italic: Boolean(node.italic),
|
|
139
139
|
strike: Boolean(node.strike),
|
|
140
140
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
const text = node.text || '';
|
|
142
|
+
let start = 0;
|
|
143
|
+
for (let idx = 0; idx < text.length; idx++) {
|
|
144
|
+
if (text[idx] !== '\n') {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (idx > start) {
|
|
148
|
+
output += markdownStyleTransition(active, next);
|
|
149
|
+
output += text.slice(start, idx);
|
|
150
|
+
active = next;
|
|
151
|
+
}
|
|
152
|
+
output += markdownStyleTransition(active, inactive);
|
|
153
|
+
output += '\n';
|
|
154
|
+
active = inactive;
|
|
155
|
+
start = idx + 1;
|
|
156
|
+
}
|
|
157
|
+
if (start < text.length) {
|
|
158
|
+
output += markdownStyleTransition(active, next);
|
|
159
|
+
output += text.slice(start);
|
|
160
|
+
active = next;
|
|
161
|
+
}
|
|
144
162
|
}
|
|
145
163
|
output += markdownStyleTransition(active, inactive);
|
|
146
164
|
return output;
|
package/src/gdocs.js
CHANGED
|
@@ -11,7 +11,10 @@ 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 {
|
|
14
|
+
import {
|
|
15
|
+
normalizeGoogleDocsExportMarkdown,
|
|
16
|
+
preprocessGoogleDocsExportHtml,
|
|
17
|
+
} from './gdocs-preprocess.js';
|
|
15
18
|
import { localizeGoogleDocsModelImages } from './gdocs-images.js';
|
|
16
19
|
import { renderBlocksMarkdown } from './gdocs-render-markdown.js';
|
|
17
20
|
import {
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
} from './gdocs-fallback.js';
|
|
23
26
|
|
|
24
27
|
export {
|
|
28
|
+
normalizeGoogleDocsExportMarkdown,
|
|
25
29
|
preprocessGoogleDocsExportHtml,
|
|
26
30
|
localizeGoogleDocsModelImages,
|
|
27
31
|
isGoogleDocsBrowserModelUnavailableError,
|
|
@@ -305,7 +309,9 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
305
309
|
hoisted: preprocessed.hoisted,
|
|
306
310
|
unwrappedLinks: preprocessed.unwrappedLinks,
|
|
307
311
|
}));
|
|
308
|
-
const markdown =
|
|
312
|
+
const markdown = normalizeGoogleDocsExportMarkdown(
|
|
313
|
+
convertHtmlToMarkdown(preprocessed.html, result.exportUrl)
|
|
314
|
+
);
|
|
309
315
|
log?.debug?.(() => ({
|
|
310
316
|
event: 'gdocs.public-export.markdown',
|
|
311
317
|
documentId: result.documentId,
|
|
@@ -1213,7 +1219,17 @@ function renderContentHtml(content = []) {
|
|
|
1213
1219
|
}
|
|
1214
1220
|
|
|
1215
1221
|
function renderMarkedHtml(node) {
|
|
1216
|
-
|
|
1222
|
+
return String(node.text || '')
|
|
1223
|
+
.split('\n')
|
|
1224
|
+
.map((segment) => renderMarkedHtmlSegment(node, segment))
|
|
1225
|
+
.join('<br>');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function renderMarkedHtmlSegment(node, text) {
|
|
1229
|
+
if (!text) {
|
|
1230
|
+
return '';
|
|
1231
|
+
}
|
|
1232
|
+
let output = escapeHtml(text);
|
|
1217
1233
|
if (node.bold) {
|
|
1218
1234
|
output = `<strong>${output}</strong>`;
|
|
1219
1235
|
}
|
|
@@ -1354,7 +1370,9 @@ export async function fetchGoogleDocAsArchive(url, options = {}) {
|
|
|
1354
1370
|
const { html: localHtml, images } = extractBase64Images(preprocessed.html);
|
|
1355
1371
|
|
|
1356
1372
|
// Convert the localized HTML to Markdown
|
|
1357
|
-
const markdown =
|
|
1373
|
+
const markdown = normalizeGoogleDocsExportMarkdown(
|
|
1374
|
+
convertHtmlToMarkdown(localHtml)
|
|
1375
|
+
);
|
|
1358
1376
|
log?.debug?.(() => ({
|
|
1359
1377
|
event: 'gdocs.public-export.archive',
|
|
1360
1378
|
documentId: result.documentId,
|
|
@@ -172,17 +172,6 @@ describe('Google Docs public test document (issue #90)', () => {
|
|
|
172
172
|
// the captured markdown. This is our regression net for any future
|
|
173
173
|
// changes to the public-export or HTML-to-markdown pipeline.
|
|
174
174
|
//
|
|
175
|
-
// The captured markdown from `--capture api` still escapes leading
|
|
176
|
-
// numeric prefixes (e.g. `1\.` instead of `1.`) and strips inline
|
|
177
|
-
// formatting — both are documented as R2 in
|
|
178
|
-
// docs/case-studies/issue-90/README.md and are not yet fixed. For the
|
|
179
|
-
// purposes of this regression test we normalise Turndown's backslash
|
|
180
|
-
// escapes before checking that every section heading is present so the
|
|
181
|
-
// test locks down content preservation rather than format fidelity. The
|
|
182
|
-
// next wave of work (R2) will tighten this matcher once the HTML-to-
|
|
183
|
-
// Markdown pipeline stops escaping the dots.
|
|
184
|
-
const normalizeEscapes = (text) => text.replace(/\\([.!()[\]])/g, '$1');
|
|
185
|
-
|
|
186
175
|
// Google Docs occasionally returns transient 500s on the public-export
|
|
187
176
|
// endpoint. The Habr integration suite has the same flake and solves it
|
|
188
177
|
// with retry + exponential backoff, so we reuse the shared helper here.
|
|
@@ -207,10 +196,21 @@ describe('Google Docs public test document (issue #90)', () => {
|
|
|
207
196
|
expect(exportUrl).toBe(buildExportUrl(PUBLIC_TEST_DOCUMENT.id, 'html'));
|
|
208
197
|
expect(markdown.length).toBeGreaterThan(1000);
|
|
209
198
|
|
|
210
|
-
const normalized = normalizeEscapes(markdown);
|
|
211
199
|
for (const section of PUBLIC_TEST_DOCUMENT.sections) {
|
|
212
|
-
expect(
|
|
200
|
+
expect(markdown).toContain(section);
|
|
213
201
|
}
|
|
202
|
+
expect(markdown).toContain('## 1. Headings');
|
|
203
|
+
expect(markdown).toContain('**This text is bold**');
|
|
204
|
+
expect(markdown).toContain('*This text is italic*');
|
|
205
|
+
expect(markdown).toContain('~~This text has strikethrough~~');
|
|
206
|
+
expect(markdown).toContain('> This is a single-level blockquote');
|
|
207
|
+
expect(markdown).toContain('| Feature | Supported | Notes |');
|
|
208
|
+
expect(markdown).not.toContain('| Feature | | Supported |');
|
|
209
|
+
expect(markdown).toContain('| | x | |');
|
|
210
|
+
expect(markdown).toContain('1. Parent item 1');
|
|
211
|
+
expect(markdown).toContain(' 1. Child item 1.1');
|
|
212
|
+
expect(markdown).toContain(' 1. Grandchild item 1.2.1');
|
|
213
|
+
expect(markdown).not.toContain('- Child item 1.1');
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
it.each(PUBLIC_TEST_DOCUMENT.urlVariations)(
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
renderDocsApiDocument,
|
|
14
14
|
selectGoogleDocsCaptureMethod,
|
|
15
15
|
localizeGoogleDocsModelImages,
|
|
16
|
+
normalizeGoogleDocsExportMarkdown,
|
|
16
17
|
preprocessGoogleDocsExportHtml,
|
|
17
18
|
GDOCS_EXPORT_FORMATS,
|
|
18
19
|
} from '../../src/gdocs.js';
|
|
20
|
+
import { convertHtmlToMarkdown } from '../../src/lib.js';
|
|
19
21
|
import {
|
|
20
22
|
extractAndSaveImages,
|
|
21
23
|
extractBase64ToBuffers,
|
|
@@ -24,10 +26,26 @@ import {
|
|
|
24
26
|
import fs from 'fs';
|
|
25
27
|
import os from 'os';
|
|
26
28
|
import path from 'path';
|
|
29
|
+
import { fileURLToPath } from 'url';
|
|
27
30
|
import nock from 'nock';
|
|
28
31
|
|
|
29
32
|
jest.setTimeout(30000);
|
|
30
33
|
|
|
34
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
35
|
+
const __dirname = path.dirname(__filename);
|
|
36
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
37
|
+
const ISSUE_104_FIXTURE_DIR = path.join(
|
|
38
|
+
REPO_ROOT,
|
|
39
|
+
'docs',
|
|
40
|
+
'case-studies',
|
|
41
|
+
'issue-104',
|
|
42
|
+
'fixtures'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
function readIssue104Fixture(filename) {
|
|
46
|
+
return fs.readFileSync(path.join(ISSUE_104_FIXTURE_DIR, filename), 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
describe('gdocs', () => {
|
|
32
50
|
describe('isGoogleDocsUrl', () => {
|
|
33
51
|
it('returns true for standard Google Docs edit URL', () => {
|
|
@@ -646,6 +664,74 @@ describe('gdocs', () => {
|
|
|
646
664
|
);
|
|
647
665
|
});
|
|
648
666
|
|
|
667
|
+
it('renders soft breaks outside marks and preserves image dimensions (issue #104)', () => {
|
|
668
|
+
const fixture = JSON.parse(
|
|
669
|
+
readIssue104Fixture('multiline-marked-inline-image-model.json')
|
|
670
|
+
);
|
|
671
|
+
const capture = parseGoogleDocsModelChunks(
|
|
672
|
+
fixture.chunks,
|
|
673
|
+
fixture.cidUrlMap
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
expect(capture.blocks[0].content).toEqual([
|
|
677
|
+
expect.objectContaining({
|
|
678
|
+
type: 'text',
|
|
679
|
+
text: 'Line one of bold text.',
|
|
680
|
+
bold: true,
|
|
681
|
+
}),
|
|
682
|
+
expect.objectContaining({ type: 'text', text: '\n', bold: false }),
|
|
683
|
+
expect.objectContaining({
|
|
684
|
+
type: 'text',
|
|
685
|
+
text: 'Line two of bold text.',
|
|
686
|
+
bold: true,
|
|
687
|
+
}),
|
|
688
|
+
expect.objectContaining({
|
|
689
|
+
type: 'image',
|
|
690
|
+
alt: 'Inline diagram',
|
|
691
|
+
width: 320,
|
|
692
|
+
height: 180,
|
|
693
|
+
}),
|
|
694
|
+
expect.objectContaining({ type: 'text', text: '\n\n', bold: false }),
|
|
695
|
+
expect.objectContaining({
|
|
696
|
+
type: 'text',
|
|
697
|
+
text: 'Line three of bold text.',
|
|
698
|
+
bold: true,
|
|
699
|
+
}),
|
|
700
|
+
]);
|
|
701
|
+
expect(renderGoogleDocsCapture(capture, 'html')).toBe(
|
|
702
|
+
readIssue104Fixture(
|
|
703
|
+
'multiline-marked-inline-image.expected.html'
|
|
704
|
+
).trimEnd()
|
|
705
|
+
);
|
|
706
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toBe(
|
|
707
|
+
readIssue104Fixture('multiline-marked-inline-image.expected.md')
|
|
708
|
+
);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('does not keep inline marks open across embedded newlines (issue #104)', () => {
|
|
712
|
+
const capture = {
|
|
713
|
+
blocks: [
|
|
714
|
+
{
|
|
715
|
+
type: 'paragraph',
|
|
716
|
+
content: [
|
|
717
|
+
{
|
|
718
|
+
type: 'text',
|
|
719
|
+
text: 'Alpha\nBeta',
|
|
720
|
+
bold: true,
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
expect(renderGoogleDocsCapture(capture, 'html')).toBe(
|
|
728
|
+
'<!doctype html><html><body><p><strong>Alpha</strong><br><strong>Beta</strong></p></body></html>'
|
|
729
|
+
);
|
|
730
|
+
expect(renderGoogleDocsCapture(capture, 'markdown')).toBe(
|
|
731
|
+
'**Alpha**\n**Beta**\n'
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
|
|
649
735
|
it('keeps nested bold and italic markers balanced (issue #96)', () => {
|
|
650
736
|
const text = 'Bold text with italic inside and back to bold\n';
|
|
651
737
|
const chunks = [
|
|
@@ -1042,6 +1128,46 @@ describe('gdocs', () => {
|
|
|
1042
1128
|
expect(unwrappedLinks).toBe(0);
|
|
1043
1129
|
expect(out).toContain('<p>Plain text with <strong>bold</strong>.</p>');
|
|
1044
1130
|
});
|
|
1131
|
+
|
|
1132
|
+
it('recovers public-export markdown structure for issue #102', () => {
|
|
1133
|
+
const html = `
|
|
1134
|
+
<style>
|
|
1135
|
+
.c5{margin-left:36pt}
|
|
1136
|
+
.c8{margin-left:72pt}
|
|
1137
|
+
.c19{margin-left:108pt}
|
|
1138
|
+
.q{margin-left:24pt;margin-right:24pt}
|
|
1139
|
+
.i{font-style:italic}
|
|
1140
|
+
.s{text-decoration:line-through}
|
|
1141
|
+
</style>
|
|
1142
|
+
<h2><span class="i">1. Headings</span></h2>
|
|
1143
|
+
<p class="q">Quote one.</p>
|
|
1144
|
+
<p class="q">Quote two.</p>
|
|
1145
|
+
<ol><li class="c5">Parent</li></ol>
|
|
1146
|
+
<ol><li class="c8">Child</li></ol>
|
|
1147
|
+
<ol><li class="c19">Grandchild</li></ol>
|
|
1148
|
+
<ol><li class="c5">Parent 2</li></ol>
|
|
1149
|
+
<table>
|
|
1150
|
+
<thead>
|
|
1151
|
+
<tr><td><p>Feature</p></td><td><p>Supported</p></td><tbody></tbody></tr>
|
|
1152
|
+
<tr><td><p><span class="s">Strike</span></p></td><td><p>Yes</p></td></tr>
|
|
1153
|
+
</thead>
|
|
1154
|
+
</table>
|
|
1155
|
+
`;
|
|
1156
|
+
|
|
1157
|
+
const preprocessed = preprocessGoogleDocsExportHtml(html);
|
|
1158
|
+
const markdown = normalizeGoogleDocsExportMarkdown(
|
|
1159
|
+
convertHtmlToMarkdown(preprocessed.html)
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
expect(markdown).toContain('## 1. Headings');
|
|
1163
|
+
expect(markdown).not.toContain('*1. Headings*');
|
|
1164
|
+
expect(markdown).toContain('> Quote one.\n> \n> Quote two.');
|
|
1165
|
+
expect(markdown).toMatch(
|
|
1166
|
+
/1\.\s+Parent\n\s+1\.\s+Child\n\s+1\.\s+Grandchild\n2\.\s+Parent 2/
|
|
1167
|
+
);
|
|
1168
|
+
expect(markdown).toContain('| Feature | Supported |');
|
|
1169
|
+
expect(markdown).toContain('| ~~Strike~~ | Yes |');
|
|
1170
|
+
});
|
|
1045
1171
|
});
|
|
1046
1172
|
|
|
1047
1173
|
describe('extractBase64Images', () => {
|