@link-assistant/web-capture 1.7.11 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 1.7.11
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.11",
3
+ "version": "1.7.12",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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[style], p[style]').each(function () {
32
- const style = String($(this).attr('style') || '');
33
- const hasBold = /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
34
- const hasItalic = /font-style\s*:\s*italic/i.test(style);
35
- const hasStrike = /text-decoration[^;]*\bline-through\b/i.test(style);
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
- $(this).replaceWith(wrapped);
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 ? `![${node.alt || 'image'}](${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,6 +13,7 @@ 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
+ import { renderBlocksMarkdown } from './gdocs-render-markdown.js';
16
17
  import {
17
18
  googleDocsBrowserModelUnavailableError,
18
19
  isGoogleDocsBrowserModelUnavailableError,
@@ -649,6 +650,7 @@ export function parseGoogleDocsModelChunks(
649
650
  let cell = null;
650
651
  const tableHistograms = [];
651
652
  let currentHistogram = null;
653
+ let previousTableControl = null;
652
654
 
653
655
  const bumpHistogram = (code) => {
654
656
  if (!currentHistogram) {
@@ -672,7 +674,7 @@ export function parseGoogleDocsModelChunks(
672
674
 
673
675
  const flushCell = ({ dropEmpty = false } = {}) => {
674
676
  if (cell && row) {
675
- const isEmpty = cell.content.length === 0;
677
+ const isEmpty = cellIsEmpty(cell);
676
678
  if (!dropEmpty || !isEmpty) {
677
679
  row.cells.push(cell);
678
680
  }
@@ -695,7 +697,7 @@ export function parseGoogleDocsModelChunks(
695
697
  // last cell content (from `\n` just before the `0x11` close marker).
696
698
  while (
697
699
  table.rows.length > 1 &&
698
- table.rows[table.rows.length - 1].cells.length === 0
700
+ rowIsEmpty(table.rows[table.rows.length - 1])
699
701
  ) {
700
702
  table.rows.pop();
701
703
  }
@@ -707,6 +709,7 @@ export function parseGoogleDocsModelChunks(
707
709
  }
708
710
  currentHistogram = null;
709
711
  table = null;
712
+ previousTableControl = null;
710
713
  };
711
714
 
712
715
  const targetContent = () => {
@@ -727,6 +730,7 @@ export function parseGoogleDocsModelChunks(
727
730
  if (code === 0x10) {
728
731
  flushParagraph(i + 1);
729
732
  table = { type: 'table', rows: [] };
733
+ previousTableControl = code;
730
734
  currentHistogram = {
731
735
  '0x0a': 0,
732
736
  '0x0b': 0,
@@ -746,6 +750,7 @@ export function parseGoogleDocsModelChunks(
746
750
  bumpHistogram(code);
747
751
  flushRow({ dropEmptyTrailingCell: true });
748
752
  row = { cells: [] };
753
+ previousTableControl = code;
749
754
  continue;
750
755
  }
751
756
  if (code === 0x1c) {
@@ -755,11 +760,19 @@ export function parseGoogleDocsModelChunks(
755
760
  row = { cells: [] };
756
761
  }
757
762
  cell = { content: [] };
763
+ previousTableControl = code;
758
764
  continue;
759
765
  }
760
766
  if (code === 0x0a) {
761
767
  bumpHistogram(code);
762
768
  if (table) {
769
+ if (
770
+ cellIsEmpty(cell) &&
771
+ (previousTableControl === 0x1c || previousTableControl === 0x12)
772
+ ) {
773
+ previousTableControl = code;
774
+ continue;
775
+ }
763
776
  // Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
764
777
  // Row boundaries are communicated via the explicit `0x12` marker and
765
778
  // the closing `0x11` marker. Earlier versions collapsed multi-column
@@ -769,6 +782,7 @@ export function parseGoogleDocsModelChunks(
769
782
  row = { cells: [] };
770
783
  }
771
784
  cell = { content: [] };
785
+ previousTableControl = code;
772
786
  } else {
773
787
  flushParagraph(i + 1);
774
788
  }
@@ -777,18 +791,21 @@ export function parseGoogleDocsModelChunks(
777
791
  if (code === 0x0b) {
778
792
  bumpHistogram(code);
779
793
  appendText(targetContent(), '\n');
794
+ previousTableControl = null;
780
795
  continue;
781
796
  }
782
797
 
783
798
  const image = imagesByPos.get(i);
784
799
  if (image) {
785
800
  targetContent().push(image);
801
+ previousTableControl = null;
786
802
  if (fullText[i] === '*') {
787
803
  continue;
788
804
  }
789
805
  }
790
806
 
791
807
  appendText(targetContent(), fullText[i], styleMaps.inlineStyles[i]);
808
+ previousTableControl = null;
792
809
  }
793
810
 
794
811
  if (table) {
@@ -817,6 +834,22 @@ export function parseGoogleDocsModelChunks(
817
834
  };
818
835
  }
819
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
+
820
853
  function buildModelStyleMaps(items, textLength) {
821
854
  const inlineStyles = Array.from({ length: textLength }, () => ({
822
855
  bold: false,
@@ -868,11 +901,17 @@ function applyInlineTextStyle(inlineStyles, start, end, patch) {
868
901
  }
869
902
 
870
903
  function textStyleFromModel(item) {
871
- return {
872
- bold: Boolean(item.sm?.ts_bd),
873
- italic: Boolean(item.sm?.ts_it),
874
- strike: Boolean(item.sm?.ts_st),
875
- };
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;
876
915
  }
877
916
 
878
917
  function paragraphStyleFromModel(item) {
@@ -1105,152 +1144,6 @@ function sameTextStyle(a, b) {
1105
1144
  );
1106
1145
  }
1107
1146
 
1108
- function renderBlocksMarkdown(blocks) {
1109
- const counters = new Map();
1110
- const rendered = blocks.map((block) => {
1111
- if (block.type === 'table') {
1112
- counters.clear();
1113
- return { text: renderTableMarkdown(block), list: null };
1114
- }
1115
- const counterKey = block.list
1116
- ? `${block.list.id || ''}\u0000${block.list.level || 0}`
1117
- : null;
1118
- if (block.list) {
1119
- // Reset deeper siblings when we re-enter a shallower level so
1120
- // nested lists restart from 1 on each new parent.
1121
- for (const key of [...counters.keys()]) {
1122
- const level = Number(key.split('\u0000')[1]);
1123
- if (level > (block.list.level || 0)) {
1124
- counters.delete(key);
1125
- }
1126
- }
1127
- const next = (counters.get(counterKey) || 0) + 1;
1128
- counters.set(counterKey, next);
1129
- return {
1130
- text: renderParagraphMarkdown(block, { orderedIndex: next }),
1131
- list: block.list,
1132
- };
1133
- }
1134
- counters.clear();
1135
- return { text: renderParagraphMarkdown(block), list: null };
1136
- });
1137
-
1138
- const joined = [];
1139
- for (let i = 0; i < rendered.length; i++) {
1140
- const cur = rendered[i];
1141
- if (!cur.text) {
1142
- continue;
1143
- }
1144
- if (joined.length > 0) {
1145
- const prev = rendered
1146
- .slice(0, i)
1147
- .reverse()
1148
- .find((entry) => entry.text);
1149
- const sameList =
1150
- cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
1151
- joined.push(sameList ? '\n' : '\n\n');
1152
- }
1153
- joined.push(cur.text);
1154
- }
1155
- return joined.join('').replace(/\s+$/u, '');
1156
- }
1157
-
1158
- function renderParagraphMarkdown(block, context = {}) {
1159
- const text = renderContentMarkdown(block.content).trim();
1160
- if (block.horizontalRule) {
1161
- return '---';
1162
- }
1163
- const style = block.style || '';
1164
- const headingMatch = style.match(/^HEADING_(\d)$/u);
1165
- if (headingMatch) {
1166
- return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
1167
- }
1168
- if (style === 'TITLE') {
1169
- return `# ${text}`;
1170
- }
1171
- if (style === 'SUBTITLE') {
1172
- return `## ${text}`;
1173
- }
1174
- if (block.list) {
1175
- const indent = ' '.repeat(Math.max(0, block.list.level || 0));
1176
- const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
1177
- const marker = block.list.ordered ? `${orderedIndex}.` : '-';
1178
- return `${indent}${marker} ${text}`;
1179
- }
1180
- if (block.quote) {
1181
- return text
1182
- .split('\n')
1183
- .map((line) => (line ? `> ${line}` : '>'))
1184
- .join('\n');
1185
- }
1186
- return text;
1187
- }
1188
-
1189
- function renderTableMarkdown(table) {
1190
- if (!table.rows.length) {
1191
- return '';
1192
- }
1193
- const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
1194
- const rows = table.rows.map((row) =>
1195
- Array.from({ length: width }, (_, idx) =>
1196
- escapeMarkdownTableCell(
1197
- renderContentMarkdown(row.cells[idx]?.content || [])
1198
- )
1199
- )
1200
- );
1201
- const separator = Array.from({ length: width }, () => '---');
1202
- return [rows[0], separator, ...rows.slice(1)]
1203
- .map((row) => `| ${row.join(' | ')} |`)
1204
- .join('\n');
1205
- }
1206
-
1207
- function renderContentMarkdown(content = []) {
1208
- const rendered = [];
1209
- for (let idx = 0; idx < content.length; idx++) {
1210
- const node = content[idx];
1211
- if (node.type === 'image') {
1212
- rendered.push(node.url ? `![${node.alt || 'image'}](${node.url})` : '');
1213
- continue;
1214
- }
1215
- if (!node.link) {
1216
- rendered.push(renderMarkedText(node.text || '', node));
1217
- continue;
1218
- }
1219
-
1220
- const link = node.link;
1221
- const linkNodes = [node];
1222
- while (
1223
- content[idx + 1]?.type === 'text' &&
1224
- content[idx + 1].link === link
1225
- ) {
1226
- linkNodes.push(content[++idx]);
1227
- }
1228
- const label = linkNodes
1229
- .map((linkNode) => renderMarkedText(linkNode.text || '', linkNode))
1230
- .join('');
1231
- rendered.push(`[${label}](${link})`);
1232
- }
1233
- return rendered.join('');
1234
- }
1235
-
1236
- function renderMarkedText(text, node = {}) {
1237
- let output = text;
1238
- if (!output) {
1239
- return output;
1240
- }
1241
- if (node.bold && node.italic) {
1242
- output = `***${output}***`;
1243
- } else if (node.bold) {
1244
- output = `**${output}**`;
1245
- } else if (node.italic) {
1246
- output = `*${output}*`;
1247
- }
1248
- if (node.strike) {
1249
- output = `~~${output}~~`;
1250
- }
1251
- return output;
1252
- }
1253
-
1254
1147
  function renderBlocksHtml(blocks) {
1255
1148
  return `<!doctype html><html><body>${blocks
1256
1149
  .map((block) => {
@@ -1383,10 +1276,6 @@ function escapeHtml(value) {
1383
1276
  });
1384
1277
  }
1385
1278
 
1386
- function escapeMarkdownTableCell(value) {
1387
- return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
1388
- }
1389
-
1390
1279
  /**
1391
1280
  * Extract base64 data URI images from HTML content.
1392
1281
  *
@@ -285,6 +285,29 @@ describe('gdocs', () => {
285
285
  expect(markdown).toContain('| D | E | F |');
286
286
  });
287
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
+
288
311
  it('numbers ordered list items sequentially (issue #92 R3)', () => {
289
312
  // Three list items sharing the same list id should render as 1. 2. 3.
290
313
  const text = 'First item\nSecond item\nThird item\n';
@@ -470,6 +493,108 @@ describe('gdocs', () => {
470
493
  '![Blue rectangle](https://docs.google.com/docs-images-rt/image-id)'
471
494
  );
472
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
+ });
473
598
  });
474
599
 
475
600
  describe('renderDocsApiDocument (issue #72)', () => {
@@ -689,6 +814,18 @@ describe('gdocs', () => {
689
814
  expect(out).toContain('<del>');
690
815
  });
691
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
+
692
829
  it('combines multiple styles on a single span', () => {
693
830
  const html =
694
831
  '<p><span style="font-weight:700;font-style:italic;text-decoration:line-through">Mixed</span></p>';
@@ -718,6 +855,22 @@ describe('gdocs', () => {
718
855
  expect(out).not.toContain('<a id=');
719
856
  });
720
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
+
721
874
  it('replaces &nbsp; entity and U+00A0 with regular spaces', () => {
722
875
  const html = '<p>A\u00A0B&nbsp;C</p>';
723
876
  const { html: out } = preprocessGoogleDocsExportHtml(html);