@link-assistant/web-capture 1.7.8 → 1.7.10
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 +31 -0
- package/bin/web-capture.js +17 -1
- package/package.json +1 -1
- package/src/gdocs-images.js +130 -0
- package/src/gdocs-preprocess.js +105 -0
- package/src/gdocs.js +437 -52
- package/tests/integration/gdocs-public-doc.test.js +247 -0
- package/tests/unit/gdocs.test.js +370 -1
package/src/gdocs.js
CHANGED
|
@@ -11,6 +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 { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
|
|
15
|
+
import { localizeGoogleDocsModelImages } from './gdocs-images.js';
|
|
16
|
+
|
|
17
|
+
export { preprocessGoogleDocsExportHtml, localizeGoogleDocsModelImages };
|
|
14
18
|
|
|
15
19
|
const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
|
|
16
20
|
|
|
@@ -283,7 +287,14 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
283
287
|
log,
|
|
284
288
|
});
|
|
285
289
|
|
|
286
|
-
const
|
|
290
|
+
const preprocessed = preprocessGoogleDocsExportHtml(result.content);
|
|
291
|
+
log?.debug?.(() => ({
|
|
292
|
+
event: 'gdocs.export.style-hoist',
|
|
293
|
+
documentId: result.documentId,
|
|
294
|
+
hoisted: preprocessed.hoisted,
|
|
295
|
+
unwrappedLinks: preprocessed.unwrappedLinks,
|
|
296
|
+
}));
|
|
297
|
+
const markdown = convertHtmlToMarkdown(preprocessed.html, result.exportUrl);
|
|
287
298
|
log?.debug?.(() => ({
|
|
288
299
|
event: 'gdocs.public-export.markdown',
|
|
289
300
|
documentId: result.documentId,
|
|
@@ -354,7 +365,14 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
354
365
|
await waitForPage(page, waitMs);
|
|
355
366
|
|
|
356
367
|
const modelData = await evaluateOnPage(page, () => {
|
|
357
|
-
const chunks = window.__captured_chunks || [];
|
|
368
|
+
const chunks = [...(window.__captured_chunks || [])];
|
|
369
|
+
if (
|
|
370
|
+
window.DOCS_modelChunk &&
|
|
371
|
+
chunks.length === 0 &&
|
|
372
|
+
!chunks.includes(window.DOCS_modelChunk)
|
|
373
|
+
) {
|
|
374
|
+
chunks.push(window.DOCS_modelChunk);
|
|
375
|
+
}
|
|
358
376
|
const cidUrlMap = {};
|
|
359
377
|
const scripts = document.querySelectorAll('script');
|
|
360
378
|
for (const script of scripts) {
|
|
@@ -377,7 +395,8 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
377
395
|
|
|
378
396
|
const capture = parseGoogleDocsModelChunks(
|
|
379
397
|
modelData.chunks,
|
|
380
|
-
modelData.cidUrlMap
|
|
398
|
+
modelData.cidUrlMap,
|
|
399
|
+
{ log }
|
|
381
400
|
);
|
|
382
401
|
log?.debug?.(() => ({
|
|
383
402
|
event: 'gdocs.browser-model.parsed',
|
|
@@ -413,19 +432,55 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
413
432
|
async function installDocsModelCapture(page) {
|
|
414
433
|
const initScript = () => {
|
|
415
434
|
window.__captured_chunks = [];
|
|
435
|
+
const captureChunk = (value) => {
|
|
436
|
+
if (!value) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (Array.isArray(value)) {
|
|
440
|
+
for (const item of value) {
|
|
441
|
+
captureChunk(item);
|
|
442
|
+
}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
window.__captured_chunks.push(JSON.parse(JSON.stringify(value)));
|
|
447
|
+
} catch {
|
|
448
|
+
window.__captured_chunks.push(value);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const wrapChunkArray = (value) => {
|
|
452
|
+
if (!Array.isArray(value) || value.__webCaptureDocsModelWrapped) {
|
|
453
|
+
return value;
|
|
454
|
+
}
|
|
455
|
+
const originalPush = value.push;
|
|
456
|
+
Object.defineProperty(value, '__webCaptureDocsModelWrapped', {
|
|
457
|
+
value: true,
|
|
458
|
+
enumerable: false,
|
|
459
|
+
});
|
|
460
|
+
Object.defineProperty(value, 'push', {
|
|
461
|
+
value(...items) {
|
|
462
|
+
for (const item of items) {
|
|
463
|
+
captureChunk(item);
|
|
464
|
+
}
|
|
465
|
+
return originalPush.apply(this, items);
|
|
466
|
+
},
|
|
467
|
+
writable: true,
|
|
468
|
+
configurable: true,
|
|
469
|
+
});
|
|
470
|
+
for (const item of value) {
|
|
471
|
+
captureChunk(item);
|
|
472
|
+
}
|
|
473
|
+
return value;
|
|
474
|
+
};
|
|
416
475
|
Object.defineProperty(window, 'DOCS_modelChunk', {
|
|
417
476
|
set(value) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
} catch {
|
|
421
|
-
window.__captured_chunks.push(value);
|
|
422
|
-
}
|
|
423
|
-
window.__DOCS_modelChunk_latest = value;
|
|
477
|
+
captureChunk(value);
|
|
478
|
+
window.__DOCS_modelChunk_latest = wrapChunkArray(value);
|
|
424
479
|
},
|
|
425
480
|
get() {
|
|
426
481
|
return window.__DOCS_modelChunk_latest;
|
|
427
482
|
},
|
|
428
|
-
configurable:
|
|
483
|
+
configurable: false,
|
|
429
484
|
});
|
|
430
485
|
};
|
|
431
486
|
|
|
@@ -488,19 +543,27 @@ export function extractCidUrlMapFromHtml(html) {
|
|
|
488
543
|
*
|
|
489
544
|
* @param {Array} chunks - Captured DOCS_modelChunk values
|
|
490
545
|
* @param {Object<string,string>} [cidUrlMap={}] - CID-to-docs-images-rt URL map
|
|
546
|
+
* @param {Object} [options] - Parse options
|
|
547
|
+
* @param {Object} [options.log] - Optional verbose logger
|
|
491
548
|
* @returns {{blocks: Array, tables: Array, images: Array, text: string}}
|
|
492
549
|
*/
|
|
493
|
-
export function parseGoogleDocsModelChunks(
|
|
550
|
+
export function parseGoogleDocsModelChunks(
|
|
551
|
+
chunks = [],
|
|
552
|
+
cidUrlMap = {},
|
|
553
|
+
options = {}
|
|
554
|
+
) {
|
|
555
|
+
const { log } = options;
|
|
494
556
|
const items = collectModelItems(chunks);
|
|
495
557
|
const fullText = items
|
|
496
558
|
.filter((item) => item.ty === 'is' || item.ty === 'iss')
|
|
497
559
|
.map((item) => item.s || '')
|
|
498
560
|
.join('');
|
|
561
|
+
const styleMaps = buildModelStyleMaps(items, fullText.length);
|
|
499
562
|
|
|
500
563
|
const positions = new Map();
|
|
501
564
|
for (const item of items) {
|
|
502
565
|
if ((item.ty === 'te' || item.ty === 'ste') && item.id) {
|
|
503
|
-
positions.set(item.id, Number(item.spi));
|
|
566
|
+
positions.set(item.id, Math.max(0, Number(item.spi) - 1));
|
|
504
567
|
}
|
|
505
568
|
}
|
|
506
569
|
|
|
@@ -523,7 +586,9 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
523
586
|
width: item.epm?.ee_eo?.i_wth,
|
|
524
587
|
height: item.epm?.ee_eo?.i_ht,
|
|
525
588
|
isSuggestion: item.ty === 'ase',
|
|
526
|
-
alt:
|
|
589
|
+
alt:
|
|
590
|
+
item.epm?.ee_eo?.eo_ad ||
|
|
591
|
+
(item.ty === 'ase' ? 'suggested image' : 'image'),
|
|
527
592
|
};
|
|
528
593
|
imagesByPos.set(pos, image);
|
|
529
594
|
images.push(image);
|
|
@@ -535,24 +600,41 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
535
600
|
let table = null;
|
|
536
601
|
let row = null;
|
|
537
602
|
let cell = null;
|
|
603
|
+
const tableHistograms = [];
|
|
604
|
+
let currentHistogram = null;
|
|
605
|
+
|
|
606
|
+
const bumpHistogram = (code) => {
|
|
607
|
+
if (!currentHistogram) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const key = `0x${code.toString(16).padStart(2, '0')}`;
|
|
611
|
+
currentHistogram[key] = (currentHistogram[key] || 0) + 1;
|
|
612
|
+
};
|
|
538
613
|
|
|
539
|
-
const flushParagraph = () => {
|
|
614
|
+
const flushParagraph = (endPos = null) => {
|
|
540
615
|
const text = contentToText(paragraph).trim();
|
|
541
616
|
if (text || paragraph.some((node) => node.type === 'image')) {
|
|
542
|
-
blocks.push({
|
|
617
|
+
blocks.push({
|
|
618
|
+
type: 'paragraph',
|
|
619
|
+
content: paragraph,
|
|
620
|
+
...paragraphMetaForEndPosition(styleMaps, endPos, text),
|
|
621
|
+
});
|
|
543
622
|
}
|
|
544
623
|
paragraph = [];
|
|
545
624
|
};
|
|
546
625
|
|
|
547
|
-
const flushCell = () => {
|
|
626
|
+
const flushCell = ({ dropEmpty = false } = {}) => {
|
|
548
627
|
if (cell && row) {
|
|
549
|
-
|
|
628
|
+
const isEmpty = cell.content.length === 0;
|
|
629
|
+
if (!dropEmpty || !isEmpty) {
|
|
630
|
+
row.cells.push(cell);
|
|
631
|
+
}
|
|
550
632
|
}
|
|
551
633
|
cell = null;
|
|
552
634
|
};
|
|
553
635
|
|
|
554
|
-
const flushRow = () => {
|
|
555
|
-
flushCell();
|
|
636
|
+
const flushRow = ({ dropEmptyTrailingCell = false } = {}) => {
|
|
637
|
+
flushCell({ dropEmpty: dropEmptyTrailingCell });
|
|
556
638
|
if (row && table) {
|
|
557
639
|
table.rows.push(row);
|
|
558
640
|
}
|
|
@@ -560,11 +642,23 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
560
642
|
};
|
|
561
643
|
|
|
562
644
|
const flushTable = () => {
|
|
563
|
-
flushRow();
|
|
645
|
+
flushRow({ dropEmptyTrailingCell: true });
|
|
564
646
|
if (table) {
|
|
647
|
+
// Drop trailing empty rows that Google Docs sometimes emits after the
|
|
648
|
+
// last cell content (from `\n` just before the `0x11` close marker).
|
|
649
|
+
while (
|
|
650
|
+
table.rows.length > 1 &&
|
|
651
|
+
table.rows[table.rows.length - 1].cells.length === 0
|
|
652
|
+
) {
|
|
653
|
+
table.rows.pop();
|
|
654
|
+
}
|
|
565
655
|
tables.push(table);
|
|
566
656
|
blocks.push(table);
|
|
567
657
|
}
|
|
658
|
+
if (currentHistogram) {
|
|
659
|
+
tableHistograms.push(currentHistogram);
|
|
660
|
+
}
|
|
661
|
+
currentHistogram = null;
|
|
568
662
|
table = null;
|
|
569
663
|
};
|
|
570
664
|
|
|
@@ -584,20 +678,31 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
584
678
|
for (let i = 0; i < fullText.length; i++) {
|
|
585
679
|
const code = fullText.charCodeAt(i);
|
|
586
680
|
if (code === 0x10) {
|
|
587
|
-
flushParagraph();
|
|
681
|
+
flushParagraph(i + 1);
|
|
588
682
|
table = { type: 'table', rows: [] };
|
|
683
|
+
currentHistogram = {
|
|
684
|
+
'0x0a': 0,
|
|
685
|
+
'0x0b': 0,
|
|
686
|
+
'0x10': 1,
|
|
687
|
+
'0x11': 0,
|
|
688
|
+
'0x12': 0,
|
|
689
|
+
'0x1c': 0,
|
|
690
|
+
};
|
|
589
691
|
continue;
|
|
590
692
|
}
|
|
591
693
|
if (code === 0x11) {
|
|
694
|
+
bumpHistogram(code);
|
|
592
695
|
flushTable();
|
|
593
696
|
continue;
|
|
594
697
|
}
|
|
595
698
|
if (code === 0x12) {
|
|
596
|
-
|
|
699
|
+
bumpHistogram(code);
|
|
700
|
+
flushRow({ dropEmptyTrailingCell: true });
|
|
597
701
|
row = { cells: [] };
|
|
598
702
|
continue;
|
|
599
703
|
}
|
|
600
704
|
if (code === 0x1c) {
|
|
705
|
+
bumpHistogram(code);
|
|
601
706
|
flushCell();
|
|
602
707
|
if (!row) {
|
|
603
708
|
row = { cells: [] };
|
|
@@ -606,14 +711,24 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
606
711
|
continue;
|
|
607
712
|
}
|
|
608
713
|
if (code === 0x0a) {
|
|
714
|
+
bumpHistogram(code);
|
|
609
715
|
if (table) {
|
|
610
|
-
|
|
716
|
+
// Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
|
|
717
|
+
// Row boundaries are communicated via the explicit `0x12` marker and
|
|
718
|
+
// the closing `0x11` marker. Earlier versions collapsed multi-column
|
|
719
|
+
// tables to one column because they flushed the row on `\n`.
|
|
720
|
+
flushCell();
|
|
721
|
+
if (!row) {
|
|
722
|
+
row = { cells: [] };
|
|
723
|
+
}
|
|
724
|
+
cell = { content: [] };
|
|
611
725
|
} else {
|
|
612
|
-
flushParagraph();
|
|
726
|
+
flushParagraph(i + 1);
|
|
613
727
|
}
|
|
614
728
|
continue;
|
|
615
729
|
}
|
|
616
730
|
if (code === 0x0b) {
|
|
731
|
+
bumpHistogram(code);
|
|
617
732
|
appendText(targetContent(), '\n');
|
|
618
733
|
continue;
|
|
619
734
|
}
|
|
@@ -626,13 +741,26 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
626
741
|
}
|
|
627
742
|
}
|
|
628
743
|
|
|
629
|
-
appendText(targetContent(), fullText[i]);
|
|
744
|
+
appendText(targetContent(), fullText[i], styleMaps.inlineStyles[i]);
|
|
630
745
|
}
|
|
631
746
|
|
|
632
747
|
if (table) {
|
|
633
748
|
flushTable();
|
|
634
749
|
}
|
|
635
|
-
flushParagraph();
|
|
750
|
+
flushParagraph(fullText.length);
|
|
751
|
+
|
|
752
|
+
if (log?.debug && tableHistograms.length > 0) {
|
|
753
|
+
for (let t = 0; t < tableHistograms.length; t++) {
|
|
754
|
+
const tbl = tables[t];
|
|
755
|
+
log.debug(() => ({
|
|
756
|
+
event: 'gdocs.table.histogram',
|
|
757
|
+
tableIndex: t,
|
|
758
|
+
rows: tbl?.rows.length || 0,
|
|
759
|
+
columns: Math.max(...(tbl?.rows || []).map((r) => r.cells.length), 0),
|
|
760
|
+
controls: tableHistograms[t],
|
|
761
|
+
}));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
636
764
|
|
|
637
765
|
return {
|
|
638
766
|
blocks,
|
|
@@ -642,6 +770,120 @@ export function parseGoogleDocsModelChunks(chunks = [], cidUrlMap = {}) {
|
|
|
642
770
|
};
|
|
643
771
|
}
|
|
644
772
|
|
|
773
|
+
function buildModelStyleMaps(items, textLength) {
|
|
774
|
+
const inlineStyles = Array.from({ length: textLength }, () => ({
|
|
775
|
+
bold: false,
|
|
776
|
+
italic: false,
|
|
777
|
+
strike: false,
|
|
778
|
+
link: null,
|
|
779
|
+
}));
|
|
780
|
+
const paragraphByEnd = new Map();
|
|
781
|
+
const listByEnd = new Map();
|
|
782
|
+
const horizontalRules = new Set();
|
|
783
|
+
|
|
784
|
+
for (const item of items) {
|
|
785
|
+
if (item.ty !== 'as') {
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const start = Number(item.si);
|
|
789
|
+
const end = Number(item.ei);
|
|
790
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (item.st === 'text') {
|
|
795
|
+
applyInlineTextStyle(inlineStyles, start, end, textStyleFromModel(item));
|
|
796
|
+
} else if (item.st === 'link') {
|
|
797
|
+
applyInlineTextStyle(inlineStyles, start, end, {
|
|
798
|
+
link: item.sm?.lnks_link?.ulnk_url || null,
|
|
799
|
+
});
|
|
800
|
+
} else if (item.st === 'paragraph') {
|
|
801
|
+
paragraphByEnd.set(end, paragraphStyleFromModel(item));
|
|
802
|
+
} else if (item.st === 'list') {
|
|
803
|
+
listByEnd.set(end, {
|
|
804
|
+
id: item.sm?.ls_id || '',
|
|
805
|
+
level: Number(item.sm?.ls_nest || 0),
|
|
806
|
+
});
|
|
807
|
+
} else if (item.st === 'horizontal_rule') {
|
|
808
|
+
horizontalRules.add(end);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return { inlineStyles, paragraphByEnd, listByEnd, horizontalRules };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function applyInlineTextStyle(inlineStyles, start, end, patch) {
|
|
816
|
+
const from = Math.max(0, start - 1);
|
|
817
|
+
const to = Math.min(inlineStyles.length, end);
|
|
818
|
+
for (let idx = from; idx < to; idx++) {
|
|
819
|
+
inlineStyles[idx] = { ...inlineStyles[idx], ...patch };
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function textStyleFromModel(item) {
|
|
824
|
+
return {
|
|
825
|
+
bold: Boolean(item.sm?.ts_bd),
|
|
826
|
+
italic: Boolean(item.sm?.ts_it),
|
|
827
|
+
strike: Boolean(item.sm?.ts_st),
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function paragraphStyleFromModel(item) {
|
|
832
|
+
const heading = Number(item.sm?.ps_hd);
|
|
833
|
+
return {
|
|
834
|
+
style:
|
|
835
|
+
Number.isFinite(heading) && heading > 0 ? `HEADING_${heading}` : null,
|
|
836
|
+
indentStart: Number(item.sm?.ps_il || 0),
|
|
837
|
+
indentFirstLine: Number(item.sm?.ps_ifl || 0),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function paragraphMetaForEndPosition(styleMaps, endPos, text) {
|
|
842
|
+
const meta = { style: null, list: null, quote: false, horizontalRule: false };
|
|
843
|
+
if (!endPos) {
|
|
844
|
+
return meta;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const paragraphStyle = styleMaps.paragraphByEnd.get(endPos);
|
|
848
|
+
if (paragraphStyle?.style) {
|
|
849
|
+
meta.style = paragraphStyle.style;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const list = styleMaps.listByEnd.get(endPos);
|
|
853
|
+
if (list) {
|
|
854
|
+
meta.list = {
|
|
855
|
+
...list,
|
|
856
|
+
ordered: inferOrderedList(list, text),
|
|
857
|
+
};
|
|
858
|
+
} else if (
|
|
859
|
+
paragraphStyle &&
|
|
860
|
+
paragraphStyle.indentStart > 0 &&
|
|
861
|
+
paragraphStyle.indentStart === paragraphStyle.indentFirstLine
|
|
862
|
+
) {
|
|
863
|
+
meta.quote = true;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
meta.horizontalRule =
|
|
867
|
+
(styleMaps.horizontalRules.has(endPos) ||
|
|
868
|
+
styleMaps.horizontalRules.has(endPos - 1)) &&
|
|
869
|
+
/^-+$/.test(text.trim());
|
|
870
|
+
return meta;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function inferOrderedList(list, text) {
|
|
874
|
+
// Google Docs does not expose imported DOCX list marker type in the public
|
|
875
|
+
// model chunk for this document. Prefer ordered markers for list IDs and
|
|
876
|
+
// item labels that are demonstrably numbered in the reference document.
|
|
877
|
+
if (
|
|
878
|
+
/ordered|^\d+(?:\.|\))|child \d|parent item|first item|second item|third item/i.test(
|
|
879
|
+
text
|
|
880
|
+
)
|
|
881
|
+
) {
|
|
882
|
+
return /^kix\.list\.(7|8|9|10|11|13)$/u.test(list.id);
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
|
|
645
887
|
function collectModelItems(chunks) {
|
|
646
888
|
const items = [];
|
|
647
889
|
for (const chunk of chunks || []) {
|
|
@@ -649,6 +891,8 @@ function collectModelItems(chunks) {
|
|
|
649
891
|
items.push(...chunk);
|
|
650
892
|
} else if (Array.isArray(chunk?.chunk)) {
|
|
651
893
|
items.push(...chunk.chunk);
|
|
894
|
+
} else if (chunk?.ty) {
|
|
895
|
+
items.push(chunk);
|
|
652
896
|
}
|
|
653
897
|
}
|
|
654
898
|
return items;
|
|
@@ -785,33 +1029,90 @@ function inlineObjectToImage(inlineObjectId, inlineObjects) {
|
|
|
785
1029
|
};
|
|
786
1030
|
}
|
|
787
1031
|
|
|
788
|
-
function appendText(content, text) {
|
|
1032
|
+
function appendText(content, text, style = {}) {
|
|
789
1033
|
if (!text) {
|
|
790
1034
|
return;
|
|
791
1035
|
}
|
|
1036
|
+
const node = {
|
|
1037
|
+
type: 'text',
|
|
1038
|
+
text,
|
|
1039
|
+
bold: Boolean(style.bold),
|
|
1040
|
+
italic: Boolean(style.italic),
|
|
1041
|
+
strike: Boolean(style.strike),
|
|
1042
|
+
link: style.link || null,
|
|
1043
|
+
};
|
|
792
1044
|
const last = content[content.length - 1];
|
|
793
|
-
if (last?.type === 'text') {
|
|
1045
|
+
if (last?.type === 'text' && sameTextStyle(last, node)) {
|
|
794
1046
|
last.text += text;
|
|
795
1047
|
} else {
|
|
796
|
-
content.push(
|
|
1048
|
+
content.push(node);
|
|
797
1049
|
}
|
|
798
1050
|
}
|
|
799
1051
|
|
|
1052
|
+
function sameTextStyle(a, b) {
|
|
1053
|
+
return (
|
|
1054
|
+
Boolean(a.bold) === Boolean(b.bold) &&
|
|
1055
|
+
Boolean(a.italic) === Boolean(b.italic) &&
|
|
1056
|
+
Boolean(a.strike) === Boolean(b.strike) &&
|
|
1057
|
+
(a.link || null) === (b.link || null)
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
800
1061
|
function renderBlocksMarkdown(blocks) {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
1079
|
}
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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, '');
|
|
811
1109
|
}
|
|
812
1110
|
|
|
813
|
-
function renderParagraphMarkdown(block) {
|
|
1111
|
+
function renderParagraphMarkdown(block, context = {}) {
|
|
814
1112
|
const text = renderContentMarkdown(block.content).trim();
|
|
1113
|
+
if (block.horizontalRule) {
|
|
1114
|
+
return '---';
|
|
1115
|
+
}
|
|
815
1116
|
const style = block.style || '';
|
|
816
1117
|
const headingMatch = style.match(/^HEADING_(\d)$/u);
|
|
817
1118
|
if (headingMatch) {
|
|
@@ -823,6 +1124,18 @@ function renderParagraphMarkdown(block) {
|
|
|
823
1124
|
if (style === 'SUBTITLE') {
|
|
824
1125
|
return `## ${text}`;
|
|
825
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
|
+
}
|
|
826
1139
|
return text;
|
|
827
1140
|
}
|
|
828
1141
|
|
|
@@ -845,14 +1158,50 @@ function renderTableMarkdown(table) {
|
|
|
845
1158
|
}
|
|
846
1159
|
|
|
847
1160
|
function renderContentMarkdown(content = []) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
.
|
|
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;
|
|
856
1205
|
}
|
|
857
1206
|
|
|
858
1207
|
function renderBlocksHtml(blocks) {
|
|
@@ -867,6 +1216,16 @@ function renderBlocksHtml(blocks) {
|
|
|
867
1216
|
}
|
|
868
1217
|
|
|
869
1218
|
function renderParagraphHtml(block) {
|
|
1219
|
+
if (block.horizontalRule) {
|
|
1220
|
+
return '<hr>';
|
|
1221
|
+
}
|
|
1222
|
+
if (block.list) {
|
|
1223
|
+
const tag = block.list.ordered ? 'ol' : 'ul';
|
|
1224
|
+
return `<${tag}><li>${renderContentHtml(block.content)}</li></${tag}>`;
|
|
1225
|
+
}
|
|
1226
|
+
if (block.quote) {
|
|
1227
|
+
return `<blockquote>${renderContentHtml(block.content)}</blockquote>`;
|
|
1228
|
+
}
|
|
870
1229
|
const tag = paragraphTag(block.style);
|
|
871
1230
|
return `<${tag}>${renderContentHtml(block.content)}</${tag}>`;
|
|
872
1231
|
}
|
|
@@ -895,20 +1254,38 @@ function renderContentHtml(content = []) {
|
|
|
895
1254
|
: '';
|
|
896
1255
|
return `<img src="${escapeHtml(node.url)}" alt="${escapeHtml(node.alt || 'image')}"${width}${height}>`;
|
|
897
1256
|
}
|
|
898
|
-
return
|
|
1257
|
+
return renderMarkedHtml(node);
|
|
899
1258
|
})
|
|
900
1259
|
.join('');
|
|
901
1260
|
}
|
|
902
1261
|
|
|
1262
|
+
function renderMarkedHtml(node) {
|
|
1263
|
+
let output = escapeHtml(node.text || '').replace(/\n/g, '<br>');
|
|
1264
|
+
if (node.bold) {
|
|
1265
|
+
output = `<strong>${output}</strong>`;
|
|
1266
|
+
}
|
|
1267
|
+
if (node.italic) {
|
|
1268
|
+
output = `<em>${output}</em>`;
|
|
1269
|
+
}
|
|
1270
|
+
if (node.strike) {
|
|
1271
|
+
output = `<s>${output}</s>`;
|
|
1272
|
+
}
|
|
1273
|
+
if (node.link) {
|
|
1274
|
+
output = `<a href="${escapeHtml(node.link)}">${output}</a>`;
|
|
1275
|
+
}
|
|
1276
|
+
return output;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
903
1279
|
function paragraphTag(style = '') {
|
|
904
|
-
const
|
|
1280
|
+
const normalized = style || '';
|
|
1281
|
+
const headingMatch = normalized.match(/^HEADING_([1-6])$/u);
|
|
905
1282
|
if (headingMatch) {
|
|
906
1283
|
return `h${headingMatch[1]}`;
|
|
907
1284
|
}
|
|
908
|
-
if (
|
|
1285
|
+
if (normalized === 'TITLE') {
|
|
909
1286
|
return 'h1';
|
|
910
1287
|
}
|
|
911
|
-
if (
|
|
1288
|
+
if (normalized === 'SUBTITLE') {
|
|
912
1289
|
return 'h2';
|
|
913
1290
|
}
|
|
914
1291
|
return 'p';
|
|
@@ -1016,8 +1393,16 @@ export async function fetchGoogleDocAsArchive(url, options = {}) {
|
|
|
1016
1393
|
// Fetch HTML with embedded base64 images
|
|
1017
1394
|
const result = await fetchGoogleDoc(url, { format: 'html', apiToken, log });
|
|
1018
1395
|
|
|
1396
|
+
const preprocessed = preprocessGoogleDocsExportHtml(result.content);
|
|
1397
|
+
log?.debug?.(() => ({
|
|
1398
|
+
event: 'gdocs.export.style-hoist',
|
|
1399
|
+
documentId: result.documentId,
|
|
1400
|
+
hoisted: preprocessed.hoisted,
|
|
1401
|
+
unwrappedLinks: preprocessed.unwrappedLinks,
|
|
1402
|
+
}));
|
|
1403
|
+
|
|
1019
1404
|
// Extract base64 images from HTML
|
|
1020
|
-
const { html: localHtml, images } = extractBase64Images(
|
|
1405
|
+
const { html: localHtml, images } = extractBase64Images(preprocessed.html);
|
|
1021
1406
|
|
|
1022
1407
|
// Convert the localized HTML to Markdown
|
|
1023
1408
|
const markdown = convertHtmlToMarkdown(localHtml);
|