@link-assistant/web-capture 1.7.10 → 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,35 @@
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
+
11
+ ## 1.7.11
12
+
13
+ ### Patch Changes
14
+
15
+ - 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
16
+ - 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
17
+ fetches for non-Google-Docs URLs (issue #80):
18
+ - `rust/src/main.rs` now passes `--capture browser` through
19
+ `render_html` for markdown, archive, and html output formats so the
20
+ flag actually launches headless Chrome instead of calling
21
+ `fetch_html`.
22
+ - `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
23
+ launches headless Chrome with `--screenshot` and returns real PNG
24
+ bytes.
25
+ - Adds Rust integration tests that run a local HTTP fixture whose DOM
26
+ is mutated by JavaScript after load and verify the rendered HTML,
27
+ the CLI `--capture browser` markdown output, and the PNG signature
28
+ on the screenshot bytes.
29
+
30
+ No JS package behavior changes in this patch; the Rust CLI is the only
31
+ affected surface.
32
+
3
33
  ## 1.7.10
4
34
 
5
35
  ### Patch Changes
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
459
459
  fetchGoogleDoc,
460
460
  fetchGoogleDocAsMarkdown,
461
461
  fetchGoogleDocFromDocsApi,
462
- captureGoogleDocWithBrowser,
462
+ captureGoogleDocWithBrowserOrFallback,
463
463
  selectGoogleDocsCaptureMethod,
464
464
  } = await import('../src/gdocs.js');
465
465
  const { stripBase64Images } = await import('../src/extract-images.js');
@@ -511,40 +511,60 @@ async function captureUrl(url, options) {
511
511
  // Screenshot/PDF/DOCX formats should use the regular browser pipeline below.
512
512
  } else if (gdocsMethod === 'browser-model') {
513
513
  try {
514
- const result = await captureGoogleDocWithBrowser(absoluteUrl, {
515
- engine,
516
- apiToken,
517
- log,
518
- });
514
+ const result = await captureGoogleDocWithBrowserOrFallback(
515
+ absoluteUrl,
516
+ {
517
+ engine,
518
+ apiToken,
519
+ format: normalizedFormat,
520
+ log,
521
+ onFallback: (err) => {
522
+ console.error(
523
+ `Warning: Google Docs browser capture could not read editor content (${err.message}); falling back to Google Docs export API.`
524
+ );
525
+ },
526
+ }
527
+ );
528
+ const resultMethod = result.fallback ? result.method : gdocsMethod;
529
+ const resultCapture = result.capture || {};
519
530
  log.debug(() => ({
520
- event: 'gdocs.capture.browser-model.rendered',
531
+ event: result.fallback
532
+ ? 'gdocs.capture.browser-model.fallback-rendered'
533
+ : 'gdocs.capture.browser-model.rendered',
521
534
  documentId: result.documentId,
522
- blocks: result.capture?.blocks?.length || 0,
523
- tables: result.capture?.tables?.length || 0,
524
- images: result.capture?.images?.length || 0,
535
+ method: resultMethod,
536
+ blocks: resultCapture.blocks?.length || 0,
537
+ tables: resultCapture.tables?.length || 0,
538
+ images: resultCapture.images?.length || result.images?.length || 0,
525
539
  markdownBytes: Buffer.byteLength(result.markdown || ''),
526
540
  htmlBytes: Buffer.byteLength(result.html || ''),
527
541
  textBytes: Buffer.byteLength(result.text || ''),
528
542
  }));
529
543
  if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
530
- const { localizeGoogleDocsModelImages } =
531
- await import('../src/gdocs.js');
532
- const localized = await localizeGoogleDocsModelImages(result, {
533
- log,
534
- });
535
- log.debug(() => ({
536
- event: 'gdocs.capture.browser-model.archive.localized',
537
- images: localized.images.length,
538
- markdownBytes: Buffer.byteLength(localized.markdown),
539
- htmlBytes: Buffer.byteLength(localized.html),
540
- }));
541
- await writeGoogleDocsArchive({
542
- archiveResult: {
544
+ let archiveResult;
545
+ if (result.fallback) {
546
+ archiveResult = result;
547
+ } else {
548
+ const { localizeGoogleDocsModelImages } =
549
+ await import('../src/gdocs.js');
550
+ const localized = await localizeGoogleDocsModelImages(result, {
551
+ log,
552
+ });
553
+ log.debug(() => ({
554
+ event: 'gdocs.capture.browser-model.archive.localized',
555
+ images: localized.images.length,
556
+ markdownBytes: Buffer.byteLength(localized.markdown),
557
+ htmlBytes: Buffer.byteLength(localized.html),
558
+ }));
559
+ archiveResult = {
543
560
  ...result,
544
561
  markdown: localized.markdown,
545
562
  html: localized.html,
546
563
  images: localized.images,
547
- },
564
+ };
565
+ }
566
+ await writeGoogleDocsArchive({
567
+ archiveResult,
548
568
  absoluteUrl,
549
569
  explicitOutput,
550
570
  dataDir,
@@ -554,10 +574,10 @@ async function captureUrl(url, options) {
554
574
  } else {
555
575
  const rendered =
556
576
  normalizedFormat === 'html'
557
- ? result.html
577
+ ? result.html || result.content
558
578
  : normalizedFormat === 'txt' || normalizedFormat === 'text'
559
- ? result.text
560
- : result.markdown;
579
+ ? result.text || result.content
580
+ : result.markdown || result.content;
561
581
  const ext =
562
582
  normalizedFormat === 'html'
563
583
  ? 'html'
@@ -570,7 +590,7 @@ async function captureUrl(url, options) {
570
590
  ext,
571
591
  explicitOutput,
572
592
  dataDir,
573
- label: `Google Doc (${gdocsMethod})`,
593
+ label: `Google Doc (${resultMethod})`,
574
594
  });
575
595
  }
576
596
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.10",
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": {
@@ -0,0 +1,152 @@
1
+ // Google Docs browser-model fallback helpers.
2
+ //
3
+ // When browser-model capture cannot read DOCS_modelChunk data from the editor
4
+ // page, fall back to the public export pipeline so that content and images
5
+ // are preserved (instead of returning an empty or lossy capture).
6
+
7
+ const GDOCS_BROWSER_MODEL_UNAVAILABLE = 'GDOCS_BROWSER_MODEL_UNAVAILABLE';
8
+
9
+ /**
10
+ * Build the sentinel error thrown when browser-model capture finds no chunks.
11
+ *
12
+ * @param {string} message - Human-readable error message
13
+ * @returns {Error} Error carrying the sentinel code
14
+ */
15
+ export function googleDocsBrowserModelUnavailableError(message) {
16
+ const err = new Error(message);
17
+ err.code = GDOCS_BROWSER_MODEL_UNAVAILABLE;
18
+ return err;
19
+ }
20
+
21
+ /**
22
+ * Check whether an error means Google Docs browser-model data was unavailable.
23
+ *
24
+ * @param {Error} err - Error to classify
25
+ * @returns {boolean} True if export fallback is appropriate
26
+ */
27
+ export function isGoogleDocsBrowserModelUnavailableError(err) {
28
+ return (
29
+ err?.code === GDOCS_BROWSER_MODEL_UNAVAILABLE ||
30
+ String(err?.message || '').includes('did not expose DOCS_modelChunk data')
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Fetch a Google Doc through the public export pipeline for a requested output
36
+ * format. This is used as the lossless fallback when browser-model capture
37
+ * cannot read DOCS_modelChunk data from the editor page.
38
+ *
39
+ * @param {Object} deps - Injected dependencies from gdocs.js
40
+ * @param {string} url - Google Docs URL
41
+ * @param {Object} [options] - Options
42
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
43
+ */
44
+ export async function fetchGoogleDocByExportFormat(deps, url, options = {}) {
45
+ const { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive } =
46
+ deps;
47
+ const { format = 'markdown', apiToken, log } = options;
48
+ const normalized = (format || 'markdown').toLowerCase();
49
+
50
+ if (normalized === 'archive' || normalized === 'zip') {
51
+ const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken, log });
52
+ return {
53
+ ...archiveResult,
54
+ content: archiveResult.markdown,
55
+ sourceFormat: 'archive',
56
+ };
57
+ }
58
+
59
+ if (normalized === 'markdown' || normalized === 'md') {
60
+ const result = await fetchGoogleDocAsMarkdown(url, { apiToken, log });
61
+ return {
62
+ ...result,
63
+ content: result.markdown,
64
+ sourceFormat: 'markdown',
65
+ };
66
+ }
67
+
68
+ if (normalized === 'html') {
69
+ const result = await fetchGoogleDoc(url, {
70
+ format: 'html',
71
+ apiToken,
72
+ log,
73
+ });
74
+ return {
75
+ ...result,
76
+ html: result.content,
77
+ sourceFormat: 'html',
78
+ };
79
+ }
80
+
81
+ if (normalized === 'txt' || normalized === 'text') {
82
+ const result = await fetchGoogleDoc(url, {
83
+ format: 'txt',
84
+ apiToken,
85
+ log,
86
+ });
87
+ return {
88
+ ...result,
89
+ text: result.content,
90
+ sourceFormat: 'txt',
91
+ };
92
+ }
93
+
94
+ throw new Error(
95
+ `Unsupported Google Docs export fallback format "${format}".`
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Capture a Google Doc through the browser model, falling back to public export
101
+ * when the editor does not expose model chunks.
102
+ *
103
+ * @param {Object} deps - Injected dependencies from gdocs.js
104
+ * @param {string} url - Google Docs URL
105
+ * @param {Object} [options] - Capture options
106
+ * @returns {Promise<Object>} Browser-model or public-export result
107
+ */
108
+ export async function captureGoogleDocWithBrowserOrFallback(
109
+ deps,
110
+ url,
111
+ options = {}
112
+ ) {
113
+ const { captureGoogleDocWithBrowser } = deps;
114
+ const {
115
+ format = 'markdown',
116
+ fallback = true,
117
+ onFallback,
118
+ ...browserOptions
119
+ } = options;
120
+
121
+ try {
122
+ const result = await captureGoogleDocWithBrowser(url, browserOptions);
123
+ return {
124
+ ...result,
125
+ method: 'browser-model',
126
+ fallback: false,
127
+ };
128
+ } catch (err) {
129
+ if (!fallback || !isGoogleDocsBrowserModelUnavailableError(err)) {
130
+ throw err;
131
+ }
132
+
133
+ onFallback?.(err);
134
+ browserOptions.log?.warn?.(() => ({
135
+ event: 'gdocs.browser-model.fallback-public-export',
136
+ reason: err.message,
137
+ format,
138
+ }));
139
+
140
+ const result = await fetchGoogleDocByExportFormat(deps, url, {
141
+ format,
142
+ apiToken: browserOptions.apiToken,
143
+ log: browserOptions.log,
144
+ });
145
+ return {
146
+ ...result,
147
+ method: 'public-export',
148
+ fallback: true,
149
+ browserError: err.message,
150
+ };
151
+ }
152
+ }
@@ -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,8 +13,19 @@ 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
-
17
- export { preprocessGoogleDocsExportHtml, localizeGoogleDocsModelImages };
16
+ import { renderBlocksMarkdown } from './gdocs-render-markdown.js';
17
+ import {
18
+ googleDocsBrowserModelUnavailableError,
19
+ isGoogleDocsBrowserModelUnavailableError,
20
+ fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
21
+ captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
22
+ } from './gdocs-fallback.js';
23
+
24
+ export {
25
+ preprocessGoogleDocsExportHtml,
26
+ localizeGoogleDocsModelImages,
27
+ isGoogleDocsBrowserModelUnavailableError,
28
+ };
18
29
 
19
30
  const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
20
31
 
@@ -308,6 +319,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
308
319
  };
309
320
  }
310
321
 
322
+ /**
323
+ * Fetch a Google Doc through the public export pipeline for a requested output
324
+ * format. Thin wrapper over the fallback module that injects sibling fetchers.
325
+ *
326
+ * @param {string} url - Google Docs URL
327
+ * @param {Object} [options] - Options
328
+ * @returns {Promise<Object>} Export result normalized for CLI rendering
329
+ */
330
+ export function fetchGoogleDocByExportFormat(url, options = {}) {
331
+ return fetchGoogleDocByExportFormatImpl(
332
+ { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
333
+ url,
334
+ options
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Capture a Google Doc through the browser model, falling back to public export
340
+ * when the editor does not expose model chunks. Thin wrapper.
341
+ *
342
+ * @param {string} url - Google Docs URL
343
+ * @param {Object} [options] - Capture options
344
+ * @returns {Promise<Object>} Browser-model or public-export result
345
+ */
346
+ export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
347
+ return captureGoogleDocWithBrowserOrFallbackImpl(
348
+ {
349
+ captureGoogleDocWithBrowser,
350
+ fetchGoogleDoc,
351
+ fetchGoogleDocAsMarkdown,
352
+ fetchGoogleDocAsArchive,
353
+ },
354
+ url,
355
+ options
356
+ );
357
+ }
358
+
311
359
  /**
312
360
  * Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
313
361
  *
@@ -409,7 +457,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
409
457
  textBytes: Buffer.byteLength(capture.text || ''),
410
458
  }));
411
459
  if (capture.blocks.length === 0) {
412
- throw new Error(
460
+ throw googleDocsBrowserModelUnavailableError(
413
461
  'Google Docs editor page did not expose DOCS_modelChunk data'
414
462
  );
415
463
  }
@@ -602,6 +650,7 @@ export function parseGoogleDocsModelChunks(
602
650
  let cell = null;
603
651
  const tableHistograms = [];
604
652
  let currentHistogram = null;
653
+ let previousTableControl = null;
605
654
 
606
655
  const bumpHistogram = (code) => {
607
656
  if (!currentHistogram) {
@@ -625,7 +674,7 @@ export function parseGoogleDocsModelChunks(
625
674
 
626
675
  const flushCell = ({ dropEmpty = false } = {}) => {
627
676
  if (cell && row) {
628
- const isEmpty = cell.content.length === 0;
677
+ const isEmpty = cellIsEmpty(cell);
629
678
  if (!dropEmpty || !isEmpty) {
630
679
  row.cells.push(cell);
631
680
  }
@@ -648,7 +697,7 @@ export function parseGoogleDocsModelChunks(
648
697
  // last cell content (from `\n` just before the `0x11` close marker).
649
698
  while (
650
699
  table.rows.length > 1 &&
651
- table.rows[table.rows.length - 1].cells.length === 0
700
+ rowIsEmpty(table.rows[table.rows.length - 1])
652
701
  ) {
653
702
  table.rows.pop();
654
703
  }
@@ -660,6 +709,7 @@ export function parseGoogleDocsModelChunks(
660
709
  }
661
710
  currentHistogram = null;
662
711
  table = null;
712
+ previousTableControl = null;
663
713
  };
664
714
 
665
715
  const targetContent = () => {
@@ -680,6 +730,7 @@ export function parseGoogleDocsModelChunks(
680
730
  if (code === 0x10) {
681
731
  flushParagraph(i + 1);
682
732
  table = { type: 'table', rows: [] };
733
+ previousTableControl = code;
683
734
  currentHistogram = {
684
735
  '0x0a': 0,
685
736
  '0x0b': 0,
@@ -699,6 +750,7 @@ export function parseGoogleDocsModelChunks(
699
750
  bumpHistogram(code);
700
751
  flushRow({ dropEmptyTrailingCell: true });
701
752
  row = { cells: [] };
753
+ previousTableControl = code;
702
754
  continue;
703
755
  }
704
756
  if (code === 0x1c) {
@@ -708,11 +760,19 @@ export function parseGoogleDocsModelChunks(
708
760
  row = { cells: [] };
709
761
  }
710
762
  cell = { content: [] };
763
+ previousTableControl = code;
711
764
  continue;
712
765
  }
713
766
  if (code === 0x0a) {
714
767
  bumpHistogram(code);
715
768
  if (table) {
769
+ if (
770
+ cellIsEmpty(cell) &&
771
+ (previousTableControl === 0x1c || previousTableControl === 0x12)
772
+ ) {
773
+ previousTableControl = code;
774
+ continue;
775
+ }
716
776
  // Inside a Google Docs table, `\n` (0x0a) separates CELLS, not rows.
717
777
  // Row boundaries are communicated via the explicit `0x12` marker and
718
778
  // the closing `0x11` marker. Earlier versions collapsed multi-column
@@ -722,6 +782,7 @@ export function parseGoogleDocsModelChunks(
722
782
  row = { cells: [] };
723
783
  }
724
784
  cell = { content: [] };
785
+ previousTableControl = code;
725
786
  } else {
726
787
  flushParagraph(i + 1);
727
788
  }
@@ -730,18 +791,21 @@ export function parseGoogleDocsModelChunks(
730
791
  if (code === 0x0b) {
731
792
  bumpHistogram(code);
732
793
  appendText(targetContent(), '\n');
794
+ previousTableControl = null;
733
795
  continue;
734
796
  }
735
797
 
736
798
  const image = imagesByPos.get(i);
737
799
  if (image) {
738
800
  targetContent().push(image);
801
+ previousTableControl = null;
739
802
  if (fullText[i] === '*') {
740
803
  continue;
741
804
  }
742
805
  }
743
806
 
744
807
  appendText(targetContent(), fullText[i], styleMaps.inlineStyles[i]);
808
+ previousTableControl = null;
745
809
  }
746
810
 
747
811
  if (table) {
@@ -770,6 +834,22 @@ export function parseGoogleDocsModelChunks(
770
834
  };
771
835
  }
772
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
+
773
853
  function buildModelStyleMaps(items, textLength) {
774
854
  const inlineStyles = Array.from({ length: textLength }, () => ({
775
855
  bold: false,
@@ -821,11 +901,17 @@ function applyInlineTextStyle(inlineStyles, start, end, patch) {
821
901
  }
822
902
 
823
903
  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
- };
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;
829
915
  }
830
916
 
831
917
  function paragraphStyleFromModel(item) {
@@ -1058,152 +1144,6 @@ function sameTextStyle(a, b) {
1058
1144
  );
1059
1145
  }
1060
1146
 
1061
- function renderBlocksMarkdown(blocks) {
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
- }
1079
- }
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, '');
1109
- }
1110
-
1111
- function renderParagraphMarkdown(block, context = {}) {
1112
- const text = renderContentMarkdown(block.content).trim();
1113
- if (block.horizontalRule) {
1114
- return '---';
1115
- }
1116
- const style = block.style || '';
1117
- const headingMatch = style.match(/^HEADING_(\d)$/u);
1118
- if (headingMatch) {
1119
- return `${'#'.repeat(Number(headingMatch[1]))} ${text}`;
1120
- }
1121
- if (style === 'TITLE') {
1122
- return `# ${text}`;
1123
- }
1124
- if (style === 'SUBTITLE') {
1125
- return `## ${text}`;
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
- }
1139
- return text;
1140
- }
1141
-
1142
- function renderTableMarkdown(table) {
1143
- if (!table.rows.length) {
1144
- return '';
1145
- }
1146
- const width = Math.max(...table.rows.map((row) => row.cells.length), 1);
1147
- const rows = table.rows.map((row) =>
1148
- Array.from({ length: width }, (_, idx) =>
1149
- escapeMarkdownTableCell(
1150
- renderContentMarkdown(row.cells[idx]?.content || [])
1151
- )
1152
- )
1153
- );
1154
- const separator = Array.from({ length: width }, () => '---');
1155
- return [rows[0], separator, ...rows.slice(1)]
1156
- .map((row) => `| ${row.join(' | ')} |`)
1157
- .join('\n');
1158
- }
1159
-
1160
- function renderContentMarkdown(content = []) {
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;
1205
- }
1206
-
1207
1147
  function renderBlocksHtml(blocks) {
1208
1148
  return `<!doctype html><html><body>${blocks
1209
1149
  .map((block) => {
@@ -1336,10 +1276,6 @@ function escapeHtml(value) {
1336
1276
  });
1337
1277
  }
1338
1278
 
1339
- function escapeMarkdownTableCell(value) {
1340
- return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
1341
- }
1342
-
1343
1279
  /**
1344
1280
  * Extract base64 data URI images from HTML content.
1345
1281
  *
@@ -6,6 +6,8 @@ import {
6
6
  buildEditUrl,
7
7
  buildDocsApiUrl,
8
8
  extractBase64Images,
9
+ captureGoogleDocWithBrowserOrFallback,
10
+ isGoogleDocsBrowserModelUnavailableError,
9
11
  parseGoogleDocsModelChunks,
10
12
  renderGoogleDocsCapture,
11
13
  renderDocsApiDocument,
@@ -22,6 +24,7 @@ import {
22
24
  import fs from 'fs';
23
25
  import os from 'os';
24
26
  import path from 'path';
27
+ import nock from 'nock';
25
28
 
26
29
  jest.setTimeout(30000);
27
30
 
@@ -282,6 +285,29 @@ describe('gdocs', () => {
282
285
  expect(markdown).toContain('| D | E | F |');
283
286
  });
284
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
+
285
311
  it('numbers ordered list items sequentially (issue #92 R3)', () => {
286
312
  // Three list items sharing the same list id should render as 1. 2. 3.
287
313
  const text = 'First item\nSecond item\nThird item\n';
@@ -467,6 +493,108 @@ describe('gdocs', () => {
467
493
  '![Blue rectangle](https://docs.google.com/docs-images-rt/image-id)'
468
494
  );
469
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
+ });
470
598
  });
471
599
 
472
600
  describe('renderDocsApiDocument (issue #72)', () => {
@@ -539,6 +667,49 @@ describe('gdocs', () => {
539
667
  });
540
668
  });
541
669
 
670
+ describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
671
+ afterEach(() => {
672
+ nock.cleanAll();
673
+ });
674
+
675
+ it('falls back to public export archive when the editor exposes no model chunks', async () => {
676
+ const tinyPng =
677
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
678
+ nock('https://docs.google.com')
679
+ .get('/document/d/fallback-doc/export')
680
+ .query({ format: 'html' })
681
+ .reply(
682
+ 200,
683
+ `<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
684
+ { 'content-type': 'text/html; charset=utf-8' }
685
+ );
686
+
687
+ const fallbackErrors = [];
688
+ const result = await captureGoogleDocWithBrowserOrFallback(
689
+ 'https://docs.google.com/document/d/fallback-doc/edit',
690
+ {
691
+ format: 'archive',
692
+ waitMs: 0,
693
+ createBrowser: async () =>
694
+ fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
695
+ onFallback: (err) => fallbackErrors.push(err),
696
+ }
697
+ );
698
+
699
+ expect(result.method).toBe('public-export');
700
+ expect(result.fallback).toBe(true);
701
+ expect(result.markdown).toContain('Fallback Doc');
702
+ expect(result.markdown).toContain('Complete text');
703
+ expect(result.images).toHaveLength(1);
704
+ expect(result.html).toContain('images/image-01.png');
705
+ expect(fallbackErrors).toHaveLength(1);
706
+ expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
707
+ true
708
+ );
709
+ expect(nock.isDone()).toBe(true);
710
+ });
711
+ });
712
+
542
713
  describe('GDOCS_EXPORT_FORMATS', () => {
543
714
  it('contains all expected formats', () => {
544
715
  expect(GDOCS_EXPORT_FORMATS).toEqual({
@@ -643,6 +814,18 @@ describe('gdocs', () => {
643
814
  expect(out).toContain('<del>');
644
815
  });
645
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
+
646
829
  it('combines multiple styles on a single span', () => {
647
830
  const html =
648
831
  '<p><span style="font-weight:700;font-style:italic;text-decoration:line-through">Mixed</span></p>';
@@ -672,6 +855,22 @@ describe('gdocs', () => {
672
855
  expect(out).not.toContain('<a id=');
673
856
  });
674
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
+
675
874
  it('replaces &nbsp; entity and U+00A0 with regular spaces', () => {
676
875
  const html = '<p>A\u00A0B&nbsp;C</p>';
677
876
  const { html: out } = preprocessGoogleDocsExportHtml(html);
@@ -806,3 +1005,23 @@ describe('gdocs', () => {
806
1005
  });
807
1006
  });
808
1007
  });
1008
+
1009
+ function fakeBrowserReturningModel(modelData) {
1010
+ return {
1011
+ async newPage() {
1012
+ return {
1013
+ async addInitScript() {},
1014
+ async setUserAgent() {},
1015
+ async setExtraHTTPHeaders() {},
1016
+ async setViewport() {},
1017
+ async goto() {},
1018
+ async waitForTimeout() {},
1019
+ async evaluate() {
1020
+ return modelData;
1021
+ },
1022
+ async close() {},
1023
+ };
1024
+ },
1025
+ async close() {},
1026
+ };
1027
+ }