@ansonlai/docx-redline-js 0.8.3 → 0.9.0

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.
Files changed (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,173 +1,197 @@
1
- import {
2
- getParagraphText,
3
- normalizeWhitespaceForTargeting
4
- } from '../core/paragraph-targeting.js';
5
- import {
6
- getParagraphListInfo,
7
- stripRedundantLeadingListMarkers
8
- } from '../core/list-targeting.js';
9
-
10
- const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
11
- const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
12
-
13
- function parseOutlineLevelFromMarker(marker) {
14
- const normalized = String(marker || '').trim();
15
- if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
16
- const parts = normalized.replace(/\.$/, '').split('.');
17
- return Math.max(0, parts.length - 1);
18
- }
19
-
20
- function parseModifiedListLines(modifiedText) {
21
- const lines = String(modifiedText || '')
22
- .split(/\r?\n/g)
23
- .map(line => line.trimEnd())
24
- .filter(line => line.trim().length > 0);
25
- if (lines.length < 2) return null;
26
-
27
- const parsed = [];
28
- for (const line of lines) {
29
- const markerMatch = line.match(LIST_LINE_REGEX);
30
- if (!markerMatch) return null;
31
- const marker = markerMatch[2];
32
- const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
33
- parsed.push({
34
- marker,
35
- markerType,
36
- level: Math.floor((markerMatch[1] || '').length / 2),
37
- outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
38
- text: stripRedundantLeadingListMarkers(markerMatch[3])
39
- });
40
- }
41
- return parsed.length >= 2 ? parsed : null;
42
- }
43
-
44
- export function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
45
- if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
46
- const parsedLines = parseModifiedListLines(modifiedText);
47
- if (!parsedLines) return null;
48
-
49
- const originalTexts = explicitRangeParagraphs.map(paragraph =>
50
- normalizeWhitespaceForTargeting(getParagraphText(paragraph))
51
- );
52
- const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
53
- if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
54
-
55
- const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
56
- if (listInfos.some(info => !info || !info.numId)) return null;
57
- const baselineNumId = String(listInfos[0].numId);
58
- if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
59
-
60
- const matchedPairs = [];
61
- let originalIndex = 0;
62
- for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
63
- if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
64
- matchedPairs.push({ originalIndex, modifiedIndex });
65
- originalIndex += 1;
66
- }
67
- }
68
- if (originalIndex !== originalTexts.length) return null;
69
-
70
- const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
71
- const insertedIndexes = [];
72
- for (let idx = 0; idx < parsedLines.length; idx += 1) {
73
- if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
74
- }
75
- if (insertedIndexes.length === 0) return null;
76
-
77
- const baseIndentLevel = parsedLines[0]?.level || 0;
78
- return insertedIndexes.map(modifiedIndex => {
79
- const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
80
- const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
81
- const referenceMatch = nextMatch || prevMatch;
82
- if (!referenceMatch) return null;
83
- const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
84
- if (!referenceListInfo) return null;
85
-
86
- const entry = parsedLines[modifiedIndex];
87
- const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
88
- const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
89
- return {
90
- text: entry.text,
91
- markerType: entry.markerType,
92
- ilvl: explicitOutlineLevel != null
93
- ? explicitOutlineLevel
94
- : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
95
- numId: String(referenceListInfo.numId),
96
- insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
97
- };
98
- }).filter(Boolean);
99
- }
100
-
101
- function countWords(text) {
102
- return String(text || '').trim().split(/\s+/).filter(Boolean).length;
103
- }
104
-
105
- function hasMultipleInlineListMarkers(text) {
106
- const source = String(text || '');
107
- if (!source) return false;
108
- let count = 0;
109
- const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
110
- while (regex.exec(source)) {
111
- count += 1;
112
- if (count >= 2) return true;
113
- }
114
- return false;
115
- }
116
-
117
- export function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
118
- const rawCurrent = String(currentParagraphText || '').trim();
119
- const rawModified = String(modifiedText || '').trim();
120
- if (!rawCurrent || !rawModified || rawModified === rawCurrent || rawModified.includes('\n')) return null;
121
- const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
122
- const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
123
- const buildCandidate = (position, text) => {
124
- const cleanedText = sanitizeCandidate(text);
125
- const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
126
- if (!cleanedText || cleanedText === rawCurrent || countWords(cleanedText) < 6) return null;
127
- if (hasMultipleInlineListMarkers(cleanedText)) return null;
128
- if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
129
- return { position, text: cleanedText };
130
- };
131
-
132
- if (rawModified.endsWith(rawCurrent)) {
133
- const candidate = buildCandidate('before', rawModified.slice(0, -rawCurrent.length));
134
- if (candidate) return candidate;
135
- }
136
- if (rawModified.startsWith(rawCurrent)) {
137
- const candidate = buildCandidate('after', rawModified.slice(rawCurrent.length));
138
- if (candidate) return candidate;
139
- }
140
-
141
- const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
142
- if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
143
- if (normalizedModified.endsWith(normalizedCurrent)) {
144
- const candidate = buildCandidate('before', normalizedModified.slice(0, -normalizedCurrent.length));
145
- if (candidate) return candidate;
146
- }
147
- if (normalizedModified.startsWith(normalizedCurrent)) {
148
- const candidate = buildCandidate('after', normalizedModified.slice(normalizedCurrent.length));
149
- if (candidate) return candidate;
150
- }
151
- return null;
152
- }
153
-
154
- export function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
155
- const rawCurrent = String(currentParagraphText || '').trim();
156
- const rawModified = String(modifiedText || '');
157
- if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
158
-
159
- const lines = rawModified.split(/\r?\n/g).map(line => String(line || '').trim()).filter(Boolean);
160
- if (lines.length < 2) return null;
161
- const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
162
- const normalizedCurrent = normalize(rawCurrent);
163
-
164
- if (normalize(lines[lines.length - 1]) === normalizedCurrent) {
165
- const paragraphs = lines.slice(0, -1).filter(Boolean);
166
- if (paragraphs.length > 0) return { position: 'before', paragraphs };
167
- }
168
- if (normalize(lines[0]) === normalizedCurrent) {
169
- const paragraphs = lines.slice(1).filter(Boolean);
170
- if (paragraphs.length > 0) return { position: 'after', paragraphs };
171
- }
172
- return null;
173
- }
1
+ import {
2
+ getParagraphText,
3
+ normalizeWhitespaceForTargeting
4
+ } from '../core/paragraph-targeting.js';
5
+ import {
6
+ getParagraphListInfo,
7
+ stripRedundantLeadingListMarkers
8
+ } from '../core/list-targeting.js';
9
+
10
+ const LIST_LINE_REGEX = /^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/;
11
+ const INLINE_LIST_MARKER_REGEX = /(?:^|\s)(?:\d+(?:\.\d+)*\.?|[A-Za-z]\.|[ivxlcIVXLC]+\.)\s+/g;
12
+
13
+ function parseOutlineLevelFromMarker(marker) {
14
+ const normalized = String(marker || '').trim();
15
+ if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
16
+ const parts = normalized.replace(/\.$/, '').split('.');
17
+ return Math.max(0, parts.length - 1);
18
+ }
19
+
20
+ function parseModifiedListLines(modifiedText) {
21
+ const lines = String(modifiedText || '')
22
+ .split(/\r?\n/g)
23
+ .map(line => line.trimEnd())
24
+ .filter(line => line.trim().length > 0);
25
+ if (lines.length < 2) return null;
26
+
27
+ const parsed = [];
28
+ for (const line of lines) {
29
+ const markerMatch = line.match(LIST_LINE_REGEX);
30
+ if (!markerMatch) return null;
31
+ const marker = markerMatch[2];
32
+ const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
33
+ parsed.push({
34
+ marker,
35
+ markerType,
36
+ level: Math.floor((markerMatch[1] || '').length / 2),
37
+ outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
38
+ text: stripRedundantLeadingListMarkers(markerMatch[3])
39
+ });
40
+ }
41
+ return parsed.length >= 2 ? parsed : null;
42
+ }
43
+
44
+ export function buildExplicitRangeInsertionEntries(explicitRangeParagraphs, modifiedText) {
45
+ if (!Array.isArray(explicitRangeParagraphs) || explicitRangeParagraphs.length === 0) return null;
46
+ const parsedLines = parseModifiedListLines(modifiedText);
47
+ if (!parsedLines) return null;
48
+
49
+ const originalTexts = explicitRangeParagraphs.map(paragraph =>
50
+ normalizeWhitespaceForTargeting(getParagraphText(paragraph))
51
+ );
52
+ const modifiedTexts = parsedLines.map(item => normalizeWhitespaceForTargeting(item.text));
53
+ if (originalTexts.some(text => !text) || modifiedTexts.some(text => !text)) return null;
54
+
55
+ const listInfos = explicitRangeParagraphs.map(paragraph => getParagraphListInfo(paragraph));
56
+ if (listInfos.some(info => !info || !info.numId)) return null;
57
+ const baselineNumId = String(listInfos[0].numId);
58
+ if (listInfos.some(info => String(info.numId) !== baselineNumId)) return null;
59
+
60
+ const matchedPairs = [];
61
+ let originalIndex = 0;
62
+ for (let modifiedIndex = 0; modifiedIndex < modifiedTexts.length && originalIndex < originalTexts.length; modifiedIndex += 1) {
63
+ if (modifiedTexts[modifiedIndex] === originalTexts[originalIndex]) {
64
+ matchedPairs.push({ originalIndex, modifiedIndex });
65
+ originalIndex += 1;
66
+ }
67
+ }
68
+ if (originalIndex !== originalTexts.length) return null;
69
+
70
+ const matchedModifiedIndexes = new Set(matchedPairs.map(pair => pair.modifiedIndex));
71
+ const insertedIndexes = [];
72
+ for (let idx = 0; idx < parsedLines.length; idx += 1) {
73
+ if (!matchedModifiedIndexes.has(idx)) insertedIndexes.push(idx);
74
+ }
75
+ if (insertedIndexes.length === 0) return null;
76
+
77
+ const baseIndentLevel = parsedLines[0]?.level || 0;
78
+ return insertedIndexes.map(modifiedIndex => {
79
+ const nextMatch = matchedPairs.find(pair => pair.modifiedIndex > modifiedIndex) || null;
80
+ const prevMatch = [...matchedPairs].reverse().find(pair => pair.modifiedIndex < modifiedIndex) || null;
81
+ const referenceMatch = nextMatch || prevMatch;
82
+ if (!referenceMatch) return null;
83
+ const referenceListInfo = listInfos[referenceMatch.originalIndex] || listInfos[0];
84
+ if (!referenceListInfo) return null;
85
+
86
+ const entry = parsedLines[modifiedIndex];
87
+ const relativeLevel = Math.max(0, entry.level - baseIndentLevel);
88
+ const explicitOutlineLevel = Number.isInteger(entry.outlineLevel) ? entry.outlineLevel : null;
89
+ return {
90
+ text: entry.text,
91
+ markerType: entry.markerType,
92
+ ilvl: explicitOutlineLevel != null
93
+ ? explicitOutlineLevel
94
+ : Math.max(0, (referenceListInfo.ilvl || 0) + relativeLevel),
95
+ numId: String(referenceListInfo.numId),
96
+ insertBeforeOriginalIndex: nextMatch ? nextMatch.originalIndex : null
97
+ };
98
+ }).filter(Boolean);
99
+ }
100
+
101
+ function countWords(text) {
102
+ return String(text || '').trim().split(/\s+/).filter(Boolean).length;
103
+ }
104
+
105
+ function hasMultipleInlineListMarkers(text) {
106
+ const source = String(text || '');
107
+ if (!source) return false;
108
+ let count = 0;
109
+ const regex = new RegExp(INLINE_LIST_MARKER_REGEX.source, INLINE_LIST_MARKER_REGEX.flags);
110
+ while (regex.exec(source)) {
111
+ count += 1;
112
+ if (count >= 2) return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ export function deriveSingleParagraphListAdjacencyInsertion(currentParagraphText, modifiedText) {
118
+ const rawCurrent = String(currentParagraphText || '').trim();
119
+ const rawModified = String(modifiedText || '').trim();
120
+ if (!rawCurrent || !rawModified || rawModified === rawCurrent || rawModified.includes('\n')) return null;
121
+ const normalizedCurrent = normalizeWhitespaceForTargeting(rawCurrent);
122
+ const sanitizeCandidate = text => stripRedundantLeadingListMarkers(String(text || '').trim()).trim();
123
+ const buildCandidate = (position, text) => {
124
+ const cleanedText = sanitizeCandidate(text);
125
+ const cleanedNormalized = normalizeWhitespaceForTargeting(cleanedText);
126
+ if (!cleanedText || cleanedText === rawCurrent || countWords(cleanedText) < 6) return null;
127
+ if (hasMultipleInlineListMarkers(cleanedText)) return null;
128
+ if (normalizedCurrent && cleanedNormalized.includes(normalizedCurrent)) return null;
129
+ return { position, text: cleanedText };
130
+ };
131
+
132
+ if (rawModified.endsWith(rawCurrent)) {
133
+ const candidate = buildCandidate('before', rawModified.slice(0, -rawCurrent.length));
134
+ if (candidate) return candidate;
135
+ }
136
+ if (rawModified.startsWith(rawCurrent)) {
137
+ const candidate = buildCandidate('after', rawModified.slice(rawCurrent.length));
138
+ if (candidate) return candidate;
139
+ }
140
+
141
+ const normalizedModified = normalizeWhitespaceForTargeting(rawModified);
142
+ if (!normalizedCurrent || normalizedCurrent === normalizedModified) return null;
143
+ if (normalizedModified.endsWith(normalizedCurrent)) {
144
+ const candidate = buildCandidate('before', normalizedModified.slice(0, -normalizedCurrent.length));
145
+ if (candidate) return candidate;
146
+ }
147
+ if (normalizedModified.startsWith(normalizedCurrent)) {
148
+ const candidate = buildCandidate('after', normalizedModified.slice(normalizedCurrent.length));
149
+ if (candidate) return candidate;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export function deriveSingleParagraphPlainAdjacencyInsertion(currentParagraphText, modifiedText) {
155
+ const rawCurrent = String(currentParagraphText || '').trim();
156
+ const rawModified = String(modifiedText || '');
157
+ if (!rawCurrent || !rawModified || !rawModified.includes('\n')) return null;
158
+
159
+ const lines = rawModified.split(/\r?\n/g).map(line => String(line || '').trim()).filter(Boolean);
160
+ if (lines.length < 2) return null;
161
+ const normalize = value => normalizeWhitespaceForTargeting(String(value || ''));
162
+ const normalizedCurrent = normalize(rawCurrent);
163
+
164
+ if (normalize(lines[lines.length - 1]) === normalizedCurrent) {
165
+ const paragraphs = lines.slice(0, -1).filter(Boolean);
166
+ if (paragraphs.length > 0) return { position: 'before', paragraphs };
167
+ }
168
+ if (normalize(lines[0]) === normalizedCurrent) {
169
+ const paragraphs = lines.slice(1).filter(Boolean);
170
+ if (paragraphs.length > 0) return { position: 'after', paragraphs };
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Detects "retained paragraph followed by one Markdown table": a single leading
177
+ * paragraph line (inline Markdown allowed) then a contiguous pipe table with a
178
+ * separator row. The caller verifies that the paragraph line's plain text equals
179
+ * the target paragraph.
180
+ *
181
+ * @param {string} modifiedText - Requested replacement text
182
+ * @returns {{ paragraphMarkdown: string, tableText: string }|null}
183
+ */
184
+ export function deriveParagraphThenTableAppend(modifiedText) {
185
+ const lines = String(modifiedText || '')
186
+ .split(/\r?\n/g)
187
+ .map(line => line.trimEnd())
188
+ .filter(line => line.trim().length > 0);
189
+ if (lines.length < 3) return null;
190
+ const tableStart = lines.findIndex(line => line.trim().startsWith('|'));
191
+ if (tableStart !== 1) return null;
192
+ const tableLines = lines.slice(1);
193
+ if (!tableLines.every(line => line.trim().startsWith('|'))) return null;
194
+ const separator = tableLines[1].replace(/\s+/g, '');
195
+ if (!/^\|:?-{3,}:?(\|:?-{3,}:?)+\|?$/.test(separator)) return null;
196
+ return { paragraphMarkdown: lines[0].trim(), tableText: tableLines.join('\n') };
197
+ }
@@ -38,6 +38,7 @@ function normalizedError(error, context = {}) {
38
38
  }
39
39
 
40
40
  function operationNeedsNumbering(operation) {
41
+ if (operation.operationKind === 'list-format') return true;
41
42
  if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
42
43
  return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
43
44
  }