@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/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 markdown = convertHtmlToMarkdown(result.content, result.exportUrl);
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
- try {
419
- window.__captured_chunks.push(JSON.parse(JSON.stringify(value)));
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: true,
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(chunks = [], cidUrlMap = {}) {
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: item.ty === 'ase' ? 'suggested image' : 'image',
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({ type: 'paragraph', content: paragraph });
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
- row.cells.push(cell);
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
- flushRow();
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
- flushRow();
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({ type: 'text', text });
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
- return blocks
802
- .map((block) => {
803
- if (block.type === 'table') {
804
- return renderTableMarkdown(block);
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
- return renderParagraphMarkdown(block);
807
- })
808
- .filter(Boolean)
809
- .join('\n\n')
810
- .trimEnd();
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
- return content
849
- .map((node) => {
850
- if (node.type === 'image') {
851
- return node.url ? `![${node.alt || 'image'}](${node.url})` : '';
852
- }
853
- return node.text || '';
854
- })
855
- .join('');
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 ? `![${node.alt || 'image'}](${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 escapeHtml(node.text || '').replace(/\n/g, '<br>');
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 headingMatch = style.match(/^HEADING_([1-6])$/u);
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 (style === 'TITLE') {
1285
+ if (normalized === 'TITLE') {
909
1286
  return 'h1';
910
1287
  }
911
- if (style === 'SUBTITLE') {
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(result.content);
1405
+ const { html: localHtml, images } = extractBase64Images(preprocessed.html);
1021
1406
 
1022
1407
  // Convert the localized HTML to Markdown
1023
1408
  const markdown = convertHtmlToMarkdown(localHtml);