@link-assistant/web-capture 1.7.11 → 1.7.13
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 +14 -0
- package/package.json +1 -1
- package/src/gdocs-preprocess.js +88 -6
- package/src/gdocs-render-markdown.js +173 -0
- package/src/gdocs.js +46 -157
- package/tests/unit/gdocs.test.js +153 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Auto-release unreleased changes
|
|
8
|
+
|
|
9
|
+
## 1.7.12
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- ed70bc8: Fix Google Docs browser-model markdown regressions around table separators,
|
|
14
|
+
blockquote continuation, nested inline markers, final newlines, and
|
|
15
|
+
public-export style preprocessing.
|
|
16
|
+
|
|
3
17
|
## 1.7.11
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/package.json
CHANGED
package/src/gdocs-preprocess.js
CHANGED
|
@@ -25,14 +25,15 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const $ = cheerio.load(html, { decodeEntities: false });
|
|
28
|
+
const classStyles = parseCssClassStyles($);
|
|
28
29
|
let hoisted = 0;
|
|
29
30
|
let unwrappedLinks = 0;
|
|
30
31
|
|
|
31
|
-
$('span
|
|
32
|
-
const style =
|
|
33
|
-
const hasBold =
|
|
34
|
-
const hasItalic =
|
|
35
|
-
const hasStrike =
|
|
32
|
+
$('span, p').each(function () {
|
|
33
|
+
const style = combinedElementStyle($, this, classStyles);
|
|
34
|
+
const hasBold = hasBoldStyle(style);
|
|
35
|
+
const hasItalic = hasItalicStyle(style);
|
|
36
|
+
const hasStrike = hasStrikeStyle(style);
|
|
36
37
|
if (!hasBold && !hasItalic && !hasStrike) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
@@ -47,10 +48,31 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
47
48
|
if (hasBold) {
|
|
48
49
|
wrapped = `<strong>${wrapped}</strong>`;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
if (this.tagName === 'p') {
|
|
52
|
+
$(this).html(wrapped);
|
|
53
|
+
} else {
|
|
54
|
+
$(this).replaceWith(wrapped);
|
|
55
|
+
}
|
|
51
56
|
hoisted += 1;
|
|
52
57
|
});
|
|
53
58
|
|
|
59
|
+
$('p').each(function () {
|
|
60
|
+
const style = combinedElementStyle($, this, classStyles);
|
|
61
|
+
if (!isBlockquoteStyle(style)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
$(this).replaceWith(
|
|
65
|
+
`<blockquote><p>${$(this).html() || ''}</p></blockquote>`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
$('a').each(function () {
|
|
70
|
+
const $a = $(this);
|
|
71
|
+
if (!$a.attr('href') && !$a.text().trim() && !$a.find('img').length) {
|
|
72
|
+
$a.remove();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
54
76
|
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
55
77
|
const $h = $(this);
|
|
56
78
|
$h.find('a').each(function () {
|
|
@@ -103,3 +125,63 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
103
125
|
|
|
104
126
|
return { html: rewritten, hoisted, unwrappedLinks };
|
|
105
127
|
}
|
|
128
|
+
|
|
129
|
+
function parseCssClassStyles($) {
|
|
130
|
+
const classStyles = new Map();
|
|
131
|
+
$('style').each(function () {
|
|
132
|
+
const css = $(this).html() || '';
|
|
133
|
+
const classRule = /\.([A-Za-z0-9_-]+)\s*\{([^{}]*)\}/g;
|
|
134
|
+
let match;
|
|
135
|
+
while ((match = classRule.exec(css)) !== null) {
|
|
136
|
+
const [, className, style] = match;
|
|
137
|
+
classStyles.set(
|
|
138
|
+
className,
|
|
139
|
+
`${classStyles.get(className) || ''};${style}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return classStyles;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function combinedElementStyle($, element, classStyles) {
|
|
147
|
+
const $el = $(element);
|
|
148
|
+
const styles = [String($el.attr('style') || '')];
|
|
149
|
+
const classes = String($el.attr('class') || '')
|
|
150
|
+
.split(/\s+/u)
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
for (const className of classes) {
|
|
153
|
+
if (classStyles.has(className)) {
|
|
154
|
+
styles.push(classStyles.get(className));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return styles.join(';');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasBoldStyle(style) {
|
|
161
|
+
return /font-weight\s*:\s*(?:bold|[6-9]\d{2})/i.test(style);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasItalicStyle(style) {
|
|
165
|
+
return /font-style\s*:\s*italic/i.test(style);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasStrikeStyle(style) {
|
|
169
|
+
return /text-decoration[^;]*\bline-through\b/i.test(style);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isBlockquoteStyle(style) {
|
|
173
|
+
const marginLeft = numericCssPointValue(style, 'margin-left');
|
|
174
|
+
const marginRight = numericCssPointValue(style, 'margin-right');
|
|
175
|
+
return (
|
|
176
|
+
marginLeft > 0 &&
|
|
177
|
+
marginRight > 0 &&
|
|
178
|
+
Math.abs(marginLeft - marginRight) < 0.1
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function numericCssPointValue(style, property) {
|
|
183
|
+
const match = style.match(
|
|
184
|
+
new RegExp(`${property}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)pt`, 'i')
|
|
185
|
+
);
|
|
186
|
+
return match ? Number(match[1]) : 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export function renderBlocksMarkdown(blocks) {
|
|
2
|
+
const counters = new Map();
|
|
3
|
+
const rendered = blocks.map((block) => {
|
|
4
|
+
if (block.type === 'table') {
|
|
5
|
+
counters.clear();
|
|
6
|
+
return { text: renderTableMarkdown(block), list: null, quote: false };
|
|
7
|
+
}
|
|
8
|
+
const counterKey = block.list
|
|
9
|
+
? `${block.list.id || ''}\u0000${block.list.level || 0}`
|
|
10
|
+
: null;
|
|
11
|
+
if (block.list) {
|
|
12
|
+
for (const key of [...counters.keys()]) {
|
|
13
|
+
const level = Number(key.split('\u0000')[1]);
|
|
14
|
+
if (level > (block.list.level || 0)) {
|
|
15
|
+
counters.delete(key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const next = (counters.get(counterKey) || 0) + 1;
|
|
19
|
+
counters.set(counterKey, next);
|
|
20
|
+
return {
|
|
21
|
+
text: renderParagraphMarkdown(block, { orderedIndex: next }),
|
|
22
|
+
list: block.list,
|
|
23
|
+
quote: Boolean(block.quote),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
counters.clear();
|
|
27
|
+
return {
|
|
28
|
+
text: renderParagraphMarkdown(block),
|
|
29
|
+
list: null,
|
|
30
|
+
quote: Boolean(block.quote),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const joined = [];
|
|
35
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
36
|
+
const cur = rendered[i];
|
|
37
|
+
if (!cur.text) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (joined.length > 0) {
|
|
41
|
+
const prev = rendered
|
|
42
|
+
.slice(0, i)
|
|
43
|
+
.reverse()
|
|
44
|
+
.find((entry) => entry.text);
|
|
45
|
+
const sameList =
|
|
46
|
+
cur.list && prev?.list && (cur.list.id || '') === (prev.list.id || '');
|
|
47
|
+
if (sameList) {
|
|
48
|
+
joined.push('\n');
|
|
49
|
+
} else if (cur.quote && prev?.quote) {
|
|
50
|
+
joined.push('\n>\n');
|
|
51
|
+
} else {
|
|
52
|
+
joined.push('\n\n');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
joined.push(cur.text);
|
|
56
|
+
}
|
|
57
|
+
const markdown = joined.join('').trimEnd();
|
|
58
|
+
return markdown ? `${markdown}\n` : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderParagraphMarkdown(block, context = {}) {
|
|
62
|
+
const text = renderContentMarkdown(block.content).trim();
|
|
63
|
+
if (block.horizontalRule) {
|
|
64
|
+
return '---';
|
|
65
|
+
}
|
|
66
|
+
const style = block.style || '';
|
|
67
|
+
const headingMatch = style.match(/^HEADING_(\d)$/u);
|
|
68
|
+
if (headingMatch) {
|
|
69
|
+
return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
|
|
70
|
+
}
|
|
71
|
+
if (style === 'TITLE') {
|
|
72
|
+
return `# ${text}`;
|
|
73
|
+
}
|
|
74
|
+
if (style === 'SUBTITLE') {
|
|
75
|
+
return `## ${text}`;
|
|
76
|
+
}
|
|
77
|
+
if (block.list) {
|
|
78
|
+
const indent = ' '.repeat(Math.max(0, block.list.level || 0));
|
|
79
|
+
const orderedIndex = Math.max(1, Number(context.orderedIndex) || 1);
|
|
80
|
+
const marker = block.list.ordered ? `${orderedIndex}.` : '-';
|
|
81
|
+
return `${indent}${marker} ${text}`;
|
|
82
|
+
}
|
|
83
|
+
if (block.quote) {
|
|
84
|
+
return text
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map((line) => (line ? `> ${line}` : '>'))
|
|
87
|
+
.join('\n');
|
|
88
|
+
}
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderTableMarkdown(table) {
|
|
93
|
+
if (!table.rows.length) {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
|
|
97
|
+
const rows = table.rows.map((row) =>
|
|
98
|
+
Array.from({ length: width }, (_, idx) =>
|
|
99
|
+
escapeMarkdownTableCell(
|
|
100
|
+
renderContentMarkdown(row.cells[idx]?.content || [])
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
);
|
|
104
|
+
const separator = Array.from({ length: width }, () => '---');
|
|
105
|
+
return [rows[0], separator, ...rows.slice(1)]
|
|
106
|
+
.map((row) => `| ${row.join(' | ')} |`)
|
|
107
|
+
.join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderContentMarkdown(content = []) {
|
|
111
|
+
const rendered = [];
|
|
112
|
+
for (let idx = 0; idx < content.length; idx++) {
|
|
113
|
+
const node = content[idx];
|
|
114
|
+
if (node.type === 'image') {
|
|
115
|
+
rendered.push(node.url ? `` : '');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const link = node.link || null;
|
|
120
|
+
const linkNodes = [node];
|
|
121
|
+
while (
|
|
122
|
+
content[idx + 1]?.type === 'text' &&
|
|
123
|
+
(content[idx + 1].link || null) === link
|
|
124
|
+
) {
|
|
125
|
+
linkNodes.push(content[++idx]);
|
|
126
|
+
}
|
|
127
|
+
const label = renderTextRunsMarkdown(linkNodes);
|
|
128
|
+
rendered.push(link ? `[${label}](${link})` : label);
|
|
129
|
+
}
|
|
130
|
+
return rendered.join('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderTextRunsMarkdown(nodes = []) {
|
|
134
|
+
const inactive = { bold: false, italic: false, strike: false };
|
|
135
|
+
let active = { ...inactive };
|
|
136
|
+
let output = '';
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
const next = {
|
|
139
|
+
bold: Boolean(node.bold),
|
|
140
|
+
italic: Boolean(node.italic),
|
|
141
|
+
strike: Boolean(node.strike),
|
|
142
|
+
};
|
|
143
|
+
output += markdownStyleTransition(active, next);
|
|
144
|
+
output += node.text || '';
|
|
145
|
+
active = next;
|
|
146
|
+
}
|
|
147
|
+
output += markdownStyleTransition(active, inactive);
|
|
148
|
+
return output;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function markdownStyleTransition(active, next) {
|
|
152
|
+
const styles = [
|
|
153
|
+
['bold', '**'],
|
|
154
|
+
['italic', '*'],
|
|
155
|
+
['strike', '~~'],
|
|
156
|
+
];
|
|
157
|
+
let markers = '';
|
|
158
|
+
for (const [key, marker] of [...styles].reverse()) {
|
|
159
|
+
if (active[key] && !next[key]) {
|
|
160
|
+
markers += marker;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [key, marker] of styles) {
|
|
164
|
+
if (!active[key] && next[key]) {
|
|
165
|
+
markers += marker;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return markers;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeMarkdownTableCell(value) {
|
|
172
|
+
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
173
|
+
}
|
package/src/gdocs.js
CHANGED
|
@@ -13,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
|
|
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]
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 ? `` : '');
|
|
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
|
*
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -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
|
''
|
|
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 entity and U+00A0 with regular spaces', () => {
|
|
722
875
|
const html = '<p>A\u00A0B C</p>';
|
|
723
876
|
const { html: out } = preprocessGoogleDocsExportHtml(html);
|