@gmickel/gno 1.45.1 → 2.0.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 (236) hide show
  1. package/README.md +1 -1
  2. package/THIRD_PARTY_NOTICES.md +46 -0
  3. package/assets/skill/SKILL.md +7 -6
  4. package/assets/skill/cli-reference.md +14 -6
  5. package/assets/skill/mcp-reference.md +4 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.0.0.zip +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.0.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/chunk-4tc9v0ja.js +74 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/browser-extension/dist/preview.html +1 -1
  12. package/browser-extension/dist/service-worker.js +32 -33
  13. package/bunfig.toml +2 -0
  14. package/package.json +40 -26
  15. package/spec/cli.md +30 -11
  16. package/spec/db/schema.sql +146 -1
  17. package/spec/mcp.md +26 -0
  18. package/src/app/context-runtime-types.ts +3 -0
  19. package/src/app/context-runtime.ts +2 -0
  20. package/src/cli/commands/ask.ts +6 -1
  21. package/src/cli/commands/daemon.ts +21 -8
  22. package/src/cli/commands/embed.ts +77 -41
  23. package/src/cli/commands/mcp/install.ts +20 -0
  24. package/src/cli/commands/mcp/paths.ts +25 -0
  25. package/src/cli/commands/mcp/status.ts +6 -0
  26. package/src/cli/detach.ts +3 -2
  27. package/src/cli/program.ts +6 -0
  28. package/src/config/types.ts +3 -3
  29. package/src/converters/adapters/markitdownTs/adapter.ts +1 -2
  30. package/src/converters/adapters/officeparser/adapter.ts +1 -2
  31. package/src/converters/versions.ts +6 -8
  32. package/src/core/context-evidence.ts +8 -4
  33. package/src/core/job-manager.ts +95 -13
  34. package/src/core/network-boundary-inventory.ts +10 -0
  35. package/src/core/shutdown-budget.ts +45 -0
  36. package/src/embed/backlog.ts +107 -4
  37. package/src/embed/batch.ts +42 -2
  38. package/src/embed/fingerprint.ts +16 -0
  39. package/src/embed/retry.ts +113 -5
  40. package/src/embed/variant-backlog.ts +105 -0
  41. package/src/embed/variant-plan.ts +62 -0
  42. package/src/embed/variant-retry.ts +113 -0
  43. package/src/ingestion/graph-reconciliation.ts +327 -0
  44. package/src/ingestion/sync.ts +9 -272
  45. package/src/llm/http-inference.ts +6 -0
  46. package/src/llm/httpEmbedding.ts +37 -6
  47. package/src/llm/httpGeneration.ts +18 -3
  48. package/src/llm/httpRerank.ts +23 -5
  49. package/src/llm/inference-cancellation.ts +168 -0
  50. package/src/llm/inference-scope.ts +202 -0
  51. package/src/llm/lazy-ports.ts +115 -0
  52. package/src/llm/native-worker/client.ts +541 -0
  53. package/src/llm/native-worker/dispatcher.ts +228 -0
  54. package/src/llm/native-worker/embedding-identity.ts +33 -0
  55. package/src/llm/native-worker/entry.ts +173 -0
  56. package/src/llm/native-worker/errors.ts +32 -0
  57. package/src/llm/native-worker/evaluation.ts +16 -0
  58. package/src/llm/native-worker/owned-exit.ts +108 -0
  59. package/src/llm/native-worker/owner.ts +141 -0
  60. package/src/llm/native-worker/ports.ts +317 -0
  61. package/src/llm/native-worker/protocol.ts +442 -0
  62. package/src/llm/native-worker/runtime-config.ts +92 -0
  63. package/src/llm/nodeLlamaCpp/adapter.ts +77 -20
  64. package/src/llm/nodeLlamaCpp/embedding.ts +130 -46
  65. package/src/llm/nodeLlamaCpp/generation.ts +34 -5
  66. package/src/llm/nodeLlamaCpp/lifecycle-options.ts +99 -0
  67. package/src/llm/nodeLlamaCpp/lifecycle.ts +209 -204
  68. package/src/llm/nodeLlamaCpp/rerank-capacity.ts +111 -0
  69. package/src/llm/nodeLlamaCpp/rerank.ts +118 -27
  70. package/src/llm/nodeLlamaCpp/simulator-handle.ts +73 -0
  71. package/src/llm/nodeLlamaCpp/simulator-install.ts +124 -0
  72. package/src/llm/nodeLlamaCpp/simulator-session.ts +240 -0
  73. package/src/llm/nodeLlamaCpp/simulator-types.ts +80 -0
  74. package/src/llm/types.ts +35 -5
  75. package/src/mcp/context.ts +27 -0
  76. package/src/mcp/http-transport.ts +12 -10
  77. package/src/mcp/server.ts +3 -0
  78. package/src/mcp/tool-profile.ts +30 -8
  79. package/src/mcp/tools/context.ts +8 -11
  80. package/src/mcp/tools/embed.ts +1 -1
  81. package/src/mcp/tools/index-cmd.ts +1 -1
  82. package/src/mcp/tools/index.ts +10 -8
  83. package/src/mcp/tools/query.ts +14 -30
  84. package/src/mcp/tools/vsearch.ts +1 -1
  85. package/src/pipeline/answer.ts +23 -3
  86. package/src/pipeline/claim-verifier.ts +6 -0
  87. package/src/pipeline/expansion.ts +43 -40
  88. package/src/pipeline/explain.ts +6 -2
  89. package/src/pipeline/filters.ts +63 -0
  90. package/src/pipeline/fusion.ts +29 -9
  91. package/src/pipeline/graph-retrieval.ts +29 -9
  92. package/src/pipeline/hybrid.ts +198 -55
  93. package/src/pipeline/hydration.ts +161 -0
  94. package/src/pipeline/owner-fusion.ts +87 -0
  95. package/src/pipeline/rerank.ts +35 -11
  96. package/src/pipeline/search.ts +13 -2
  97. package/src/pipeline/types.ts +5 -3
  98. package/src/pipeline/vsearch.ts +87 -7
  99. package/src/sdk/client.ts +47 -3
  100. package/src/sdk/embed.ts +63 -39
  101. package/src/serve/background-runtime.ts +1 -1
  102. package/src/serve/context.ts +41 -56
  103. package/src/serve/embed-scheduler.ts +58 -35
  104. package/src/serve/public/components/IndexingProgress.tsx +46 -60
  105. package/src/serve/public/globals.built.css +1 -1
  106. package/src/serve/public/lib/shiki-language-ids.ts +14 -0
  107. package/src/serve/resident-admission.ts +36 -36
  108. package/src/serve/resident-background-work.ts +20 -2
  109. package/src/serve/resident-request.ts +11 -5
  110. package/src/serve/resident-runtime.ts +97 -61
  111. package/src/serve/resident-shutdown.ts +153 -0
  112. package/src/serve/routes/api.ts +3 -1
  113. package/src/serve/server.ts +47 -26
  114. package/src/store/migrations/028-vector-variants.ts +54 -0
  115. package/src/store/migrations/029-graph-reference-state.ts +77 -0
  116. package/src/store/migrations/index.ts +4 -0
  117. package/src/store/sqlite/adapter.ts +251 -183
  118. package/src/store/sqlite/eligibility.ts +174 -0
  119. package/src/store/sqlite/graph-edge-application.ts +66 -0
  120. package/src/store/sqlite/graph-reference-state.ts +194 -0
  121. package/src/store/sqlite/legacy-vector-ownership.ts +79 -0
  122. package/src/store/types.ts +80 -12
  123. package/src/store/vector/eligibility.ts +36 -0
  124. package/src/store/vector/freshness.ts +33 -6
  125. package/src/store/vector/lazy.ts +81 -0
  126. package/src/store/vector/sqlite-vec.ts +106 -54
  127. package/src/store/vector/stats.ts +14 -3
  128. package/src/store/vector/types.ts +35 -2
  129. package/src/store/vector/variant-search.ts +192 -0
  130. package/src/store/vector/variants.ts +451 -0
  131. package/vendor/converters/markitdown-ts/LICENSE +21 -0
  132. package/vendor/converters/markitdown-ts/dist/index.cjs +1180 -0
  133. package/vendor/converters/markitdown-ts/dist/index.d.cts +46 -0
  134. package/vendor/converters/markitdown-ts/dist/index.d.mts +46 -0
  135. package/vendor/converters/markitdown-ts/dist/index.d.ts +46 -0
  136. package/vendor/converters/markitdown-ts/dist/index.mjs +1152 -0
  137. package/vendor/converters/markitdown-ts/package.json +77 -0
  138. package/vendor/converters/officeparser/LICENSE +21 -0
  139. package/vendor/converters/officeparser/dist/OfficeConverter.d.ts +47 -0
  140. package/vendor/converters/officeparser/dist/OfficeConverter.js +76 -0
  141. package/vendor/converters/officeparser/dist/OfficeGenerator.d.ts +23 -0
  142. package/vendor/converters/officeparser/dist/OfficeGenerator.js +73 -0
  143. package/vendor/converters/officeparser/dist/OfficeParser.d.ts +106 -0
  144. package/vendor/converters/officeparser/dist/OfficeParser.js +332 -0
  145. package/vendor/converters/officeparser/dist/cli.d.ts +28 -0
  146. package/vendor/converters/officeparser/dist/cli.js +381 -0
  147. package/vendor/converters/officeparser/dist/defaults.d.ts +41 -0
  148. package/vendor/converters/officeparser/dist/defaults.js +218 -0
  149. package/vendor/converters/officeparser/dist/generators/BaseGenerator.d.ts +107 -0
  150. package/vendor/converters/officeparser/dist/generators/BaseGenerator.js +248 -0
  151. package/vendor/converters/officeparser/dist/generators/ChunkingGenerator.d.ts +82 -0
  152. package/vendor/converters/officeparser/dist/generators/ChunkingGenerator.js +797 -0
  153. package/vendor/converters/officeparser/dist/generators/CsvGenerator.d.ts +38 -0
  154. package/vendor/converters/officeparser/dist/generators/CsvGenerator.js +245 -0
  155. package/vendor/converters/officeparser/dist/generators/EpubGenerator.d.ts +43 -0
  156. package/vendor/converters/officeparser/dist/generators/EpubGenerator.js +315 -0
  157. package/vendor/converters/officeparser/dist/generators/HtmlGenerator.d.ts +59 -0
  158. package/vendor/converters/officeparser/dist/generators/HtmlGenerator.js +1942 -0
  159. package/vendor/converters/officeparser/dist/generators/MarkdownGenerator.d.ts +96 -0
  160. package/vendor/converters/officeparser/dist/generators/MarkdownGenerator.js +1175 -0
  161. package/vendor/converters/officeparser/dist/generators/PdfGenerator.d.ts +22 -0
  162. package/vendor/converters/officeparser/dist/generators/PdfGenerator.js +194 -0
  163. package/vendor/converters/officeparser/dist/generators/RtfGenerator.d.ts +29 -0
  164. package/vendor/converters/officeparser/dist/generators/RtfGenerator.js +316 -0
  165. package/vendor/converters/officeparser/dist/generators/TextGenerator.d.ts +13 -0
  166. package/vendor/converters/officeparser/dist/generators/TextGenerator.js +201 -0
  167. package/vendor/converters/officeparser/dist/index.d.ts +60 -0
  168. package/vendor/converters/officeparser/dist/index.js +72 -0
  169. package/vendor/converters/officeparser/dist/index.mjs +18 -0
  170. package/vendor/converters/officeparser/dist/officeparser.browser.d.ts +2621 -0
  171. package/vendor/converters/officeparser/dist/officeparser.browser.iife.js +1336 -0
  172. package/vendor/converters/officeparser/dist/officeparser.browser.mjs +1335 -0
  173. package/vendor/converters/officeparser/dist/officeparser.browser.slim.d.ts +2621 -0
  174. package/vendor/converters/officeparser/dist/officeparser.browser.slim.iife.js +1336 -0
  175. package/vendor/converters/officeparser/dist/officeparser.browser.slim.mjs +1335 -0
  176. package/vendor/converters/officeparser/dist/parsers/CsvParser.d.ts +9 -0
  177. package/vendor/converters/officeparser/dist/parsers/CsvParser.js +115 -0
  178. package/vendor/converters/officeparser/dist/parsers/EpubParser.d.ts +8 -0
  179. package/vendor/converters/officeparser/dist/parsers/EpubParser.js +217 -0
  180. package/vendor/converters/officeparser/dist/parsers/ExcelParser.d.ts +32 -0
  181. package/vendor/converters/officeparser/dist/parsers/ExcelParser.js +736 -0
  182. package/vendor/converters/officeparser/dist/parsers/HtmlParser.d.ts +2 -0
  183. package/vendor/converters/officeparser/dist/parsers/HtmlParser.js +1287 -0
  184. package/vendor/converters/officeparser/dist/parsers/MarkdownParser.d.ts +2 -0
  185. package/vendor/converters/officeparser/dist/parsers/MarkdownParser.js +1272 -0
  186. package/vendor/converters/officeparser/dist/parsers/OpenOfficeParser.d.ts +31 -0
  187. package/vendor/converters/officeparser/dist/parsers/OpenOfficeParser.js +1819 -0
  188. package/vendor/converters/officeparser/dist/parsers/PdfParser.d.ts +67 -0
  189. package/vendor/converters/officeparser/dist/parsers/PdfParser.js +848 -0
  190. package/vendor/converters/officeparser/dist/parsers/PowerPointParser.d.ts +32 -0
  191. package/vendor/converters/officeparser/dist/parsers/PowerPointParser.js +950 -0
  192. package/vendor/converters/officeparser/dist/parsers/RtfParser.d.ts +187 -0
  193. package/vendor/converters/officeparser/dist/parsers/RtfParser.js +1801 -0
  194. package/vendor/converters/officeparser/dist/parsers/WordParser.d.ts +79 -0
  195. package/vendor/converters/officeparser/dist/parsers/WordParser.js +1177 -0
  196. package/vendor/converters/officeparser/dist/sbom.cdx.json +1763 -0
  197. package/vendor/converters/officeparser/dist/types.d.ts +2507 -0
  198. package/vendor/converters/officeparser/dist/types.js +107 -0
  199. package/vendor/converters/officeparser/dist/utils/astUtils.d.ts +16 -0
  200. package/vendor/converters/officeparser/dist/utils/astUtils.js +33 -0
  201. package/vendor/converters/officeparser/dist/utils/chartUtils.d.ts +6 -0
  202. package/vendor/converters/officeparser/dist/utils/chartUtils.js +257 -0
  203. package/vendor/converters/officeparser/dist/utils/configUtils.d.ts +44 -0
  204. package/vendor/converters/officeparser/dist/utils/configUtils.js +315 -0
  205. package/vendor/converters/officeparser/dist/utils/dateUtils.d.ts +17 -0
  206. package/vendor/converters/officeparser/dist/utils/dateUtils.js +69 -0
  207. package/vendor/converters/officeparser/dist/utils/envUtils.d.ts +29 -0
  208. package/vendor/converters/officeparser/dist/utils/envUtils.js +152 -0
  209. package/vendor/converters/officeparser/dist/utils/errorUtils.d.ts +72 -0
  210. package/vendor/converters/officeparser/dist/utils/errorUtils.js +245 -0
  211. package/vendor/converters/officeparser/dist/utils/imageUtils.d.ts +66 -0
  212. package/vendor/converters/officeparser/dist/utils/imageUtils.js +133 -0
  213. package/vendor/converters/officeparser/dist/utils/mathUtils.d.ts +42 -0
  214. package/vendor/converters/officeparser/dist/utils/mathUtils.js +385 -0
  215. package/vendor/converters/officeparser/dist/utils/moduleLoader.d.ts +18 -0
  216. package/vendor/converters/officeparser/dist/utils/moduleLoader.js +106 -0
  217. package/vendor/converters/officeparser/dist/utils/ocrUtils.d.ts +42 -0
  218. package/vendor/converters/officeparser/dist/utils/ocrUtils.js +428 -0
  219. package/vendor/converters/officeparser/dist/utils/sanitize.d.ts +148 -0
  220. package/vendor/converters/officeparser/dist/utils/sanitize.js +344 -0
  221. package/vendor/converters/officeparser/dist/utils/sheetUtils.d.ts +7 -0
  222. package/vendor/converters/officeparser/dist/utils/sheetUtils.js +35 -0
  223. package/vendor/converters/officeparser/dist/utils/styleMapper.d.ts +36 -0
  224. package/vendor/converters/officeparser/dist/utils/styleMapper.js +224 -0
  225. package/vendor/converters/officeparser/dist/utils/xmlUtils.d.ts +163 -0
  226. package/vendor/converters/officeparser/dist/utils/xmlUtils.js +461 -0
  227. package/vendor/converters/officeparser/dist/utils/zipUtils.d.ts +134 -0
  228. package/vendor/converters/officeparser/dist/utils/zipUtils.js +337 -0
  229. package/vendor/converters/officeparser/package.json +147 -0
  230. package/vendor/converters/upstream-manifest.json +124 -0
  231. package/vendor/dependency-fixes/README.md +77 -0
  232. package/vendor/dependency-fixes/vendor-converters.py +83 -0
  233. package/browser-extension/artifacts/gno-browser-clipper-v1.45.1.zip +0 -0
  234. package/browser-extension/artifacts/gno-browser-clipper-v1.45.1.zip.sha256 +0 -1
  235. package/browser-extension/dist/chunk-627emwpj.js +0 -75
  236. /package/browser-extension/dist/{chunk-ydfx5d7p.css → chunk-z74y8n8c.css} +0 -0
@@ -0,0 +1,1801 @@
1
+ "use strict";
2
+ /**
3
+ * RTF (Rich Text Format) Parser
4
+ *
5
+ * **RTF Format Overview:**
6
+ * RTF is a proprietary document format developed by Microsoft in 1987.
7
+ * Unlike OOXML formats (DOCX), RTF is a plain text format using control words and groups.
8
+ *
9
+ * **Basic RTF Structure:**
10
+ * ```rtf
11
+ * {\rtf1\ansi
12
+ * {\fonttbl{\f0 Arial;}{\f1 Times;}}
13
+ * {\colortbl;\red255\green0\blue0;\red0\green0\blue255;}
14
+ * \f0\fs24 This is \b bold\b0 and \i italic\i0 text.\par
15
+ * }
16
+ * ```
17
+ *
18
+ * **RTF Elements:**
19
+ * 1. **Control Words**: Backslash followed by letters and optional parameter (e.g., `\fs24`, `\b`, `\par`)
20
+ * 2. **Control Symbols**: Backslash followed by single special char (e.g., `\'xx` for hex char, `\{`, `\}`, `\\`)
21
+ * 3. **Groups**: Content enclosed in `{...}` - creates formatting scope
22
+ * 4. **Text**: Plain text characters
23
+ *
24
+ * **Key Control Words:**
25
+ * - `\rtf1` - RTF version 1
26
+ * - `\fs24` - Font size in half-points (24 = 12pt)
27
+ * - `\b`,`\i`,`\ul`,`\strike` - Bold, italic, underline, strikethrough
28
+ * - `\par` - Paragraph break
29
+ * - `\f0` - Switch to font 0 from font table
30
+ * - `\cf1` - Text color from color table
31
+ * - `\cb2` - Background color from color table
32
+ * - `\sub`,`\super` - Subscript, superscript
33
+ *
34
+ * **Parser Architecture:**
35
+ * This module uses a two-phase approach:
36
+ * 1. **Lexical Phase** (`SimpleRtfParser`): Tokenizes RTF into groups, control words, and text
37
+ * 2. **Semantic Phase** (`parseRtf`): Traverses the token tree to build the AST
38
+ *
39
+ * @module RtfParser
40
+ * @see https://www.biblioscape.com/rtf15_spec.htm RTF 1.5 Specification
41
+ * @see https://latex2rtf.sourceforge.net/RTF-Spec-1.2.pdf RTF 1.2 Specification
42
+ */
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.parseRtf = exports.SimpleRtfParser = void 0;
45
+ const types_js_1 = require("../types.js");
46
+ const astUtils_js_1 = require("../utils/astUtils.js");
47
+ const errorUtils_js_1 = require("../utils/errorUtils.js");
48
+ const ocrUtils_js_1 = require("../utils/ocrUtils.js");
49
+ /**
50
+ * Lookup table mapping RTF control words to internal formats.
51
+ * Fully typed: if a key maps to an unsupported format, TypeScript throws an error.
52
+ */
53
+ const RTF_BLIP_MAP = {
54
+ // Raster formats
55
+ pngblip: 'png',
56
+ jpegblip: 'jpeg',
57
+ gifblip: 'gif',
58
+ tiffblip: 'tiff',
59
+ dibitmap: 'bmp',
60
+ wbitmap: 'bmp',
61
+ };
62
+ /**
63
+ * Lookup table mapping internal formats to MIME types.
64
+ * Again fully typed: if a format is missing from this map, TS errors.
65
+ */
66
+ const IMAGE_MIME_MAP = {
67
+ png: 'image/png',
68
+ jpeg: 'image/jpeg',
69
+ gif: 'image/gif',
70
+ tiff: 'image/tiff',
71
+ bmp: 'image/bmp',
72
+ };
73
+ /**
74
+ * Low-level RTF tokenizer that parses RTF syntax into a tree structure.
75
+ *
76
+ * This class performs lexical analysis on RTF content, breaking it down into:
77
+ * - Groups (enclosed in braces)
78
+ * - Control words (e.g., `\fs24`, `\b`)
79
+ * - Control symbols (e.g., `\'xx`, `\{`)
80
+ * - Plain text
81
+ *
82
+ * The parser uses a byte-level approach and maintains a stack to track nested groups.
83
+ *
84
+ * @example
85
+ * ```typescript
86
+ * const buffer = Buffer.from('{\\rtf1 Hello \\b world\\b0}');
87
+ * const parser = new SimpleRtfParser(buffer);
88
+ * const tree = parser.parse();
89
+ * // tree.content contains parsed RTF nodes
90
+ * ```
91
+ */
92
+ class SimpleRtfParser {
93
+ /** Current position in the buffer */
94
+ index = 0;
95
+ /** The RTF content as a Buffer */
96
+ buffer;
97
+ /** Current code page for character decoding (default is Windows-1252) */
98
+ codePage = 1252;
99
+ /** Cached TextDecoders for different code pages */
100
+ decoders = {};
101
+ /** Buffer for consecutive text bytes to handle multi-byte encodings and UTF-8 detection */
102
+ pendingBytes = [];
103
+ /** Total length of the buffer */
104
+ length;
105
+ /**
106
+ * Creates a new RTF parser.
107
+ * @param buffer - The RTF file content as a Buffer
108
+ */
109
+ constructor(buffer) {
110
+ this.buffer = buffer;
111
+ this.length = buffer.length;
112
+ }
113
+ parse() {
114
+ const root = { type: 'group', content: [] };
115
+ const stack = [root];
116
+ while (this.index < this.length) {
117
+ const char = this.buffer[this.index];
118
+ const currentGroup = stack[stack.length - 1];
119
+ if (char === 0x7B) { // '{'
120
+ this.index++;
121
+ this.flushPendingText(currentGroup);
122
+ const newGroup = { type: 'group', content: [] };
123
+ currentGroup.content.push(newGroup);
124
+ stack.push(newGroup);
125
+ }
126
+ else if (char === 0x7D) { // '}'
127
+ this.index++;
128
+ this.flushPendingText(currentGroup);
129
+ if (stack.length > 1) {
130
+ stack.pop();
131
+ }
132
+ // If stack is 1 (root), we ignore extra closing braces or just stop?
133
+ // RTF should be balanced, but let's be robust.
134
+ }
135
+ else if (char === 0x5C) { // '\'
136
+ this.index++;
137
+ this.parseControl(currentGroup);
138
+ }
139
+ else if (char === 0x0D || char === 0x0A) { // CR or LF
140
+ this.index++; // Ignore newlines in RTF source
141
+ }
142
+ else {
143
+ this.parseText(currentGroup);
144
+ }
145
+ }
146
+ this.flushPendingText(root);
147
+ return root;
148
+ }
149
+ parseControl(group) {
150
+ if (this.index >= this.length)
151
+ return;
152
+ const char = this.buffer[this.index];
153
+ // Special control symbols
154
+ if (char === 0x7B || char === 0x7D || char === 0x5C) { // \{ \} \\
155
+ this.pendingBytes.push(char);
156
+ this.index++;
157
+ return;
158
+ }
159
+ if (char === 0x27) { // \'xx (hex)
160
+ this.index++;
161
+ if (this.index + 1 < this.length) {
162
+ const hex = String.fromCharCode(this.buffer[this.index], this.buffer[this.index + 1]);
163
+ const code = parseInt(hex, 16);
164
+ if (!isNaN(code)) {
165
+ this.pendingBytes.push(code);
166
+ }
167
+ this.index += 2;
168
+ }
169
+ return;
170
+ }
171
+ this.flushPendingText(group);
172
+ if (char === 0x2A) { // \* (ignorable destination)
173
+ // We treat this as a control word named '*'
174
+ group.content.push({ type: 'control', value: '*' });
175
+ this.index++;
176
+ return;
177
+ }
178
+ // Control word
179
+ let name = '';
180
+ while (this.index < this.length) {
181
+ const c = this.buffer[this.index];
182
+ if ((c >= 0x61 && c <= 0x7A) || (c >= 0x41 && c <= 0x5A)) { // a-z or A-Z
183
+ name += String.fromCharCode(c);
184
+ this.index++;
185
+ }
186
+ else {
187
+ break;
188
+ }
189
+ }
190
+ let param = undefined;
191
+ let hasParam = false;
192
+ let paramStr = '';
193
+ // Check for parameter (digits, potentially negative)
194
+ if (this.index < this.length && this.buffer[this.index] === 0x2D) { // -
195
+ paramStr += '-';
196
+ this.index++;
197
+ }
198
+ while (this.index < this.length) {
199
+ const c = this.buffer[this.index];
200
+ if (c >= 0x30 && c <= 0x39) { // 0-9
201
+ paramStr += String.fromCharCode(c);
202
+ this.index++;
203
+ hasParam = true;
204
+ }
205
+ else {
206
+ break;
207
+ }
208
+ }
209
+ if (hasParam) {
210
+ param = parseInt(paramStr, 10);
211
+ }
212
+ // Space after control word is consumed
213
+ if (name !== '' && this.index < this.length && this.buffer[this.index] === 0x20) {
214
+ this.index++;
215
+ }
216
+ // Handle \binN
217
+ if (name === 'bin' && param !== undefined && param > 0) {
218
+ // Skip N bytes of binary data
219
+ this.index += param;
220
+ // \binN is not added to content as we want to ignore it
221
+ return;
222
+ }
223
+ // Handle encoding control words
224
+ if (name === 'ansicpg' && param !== undefined) {
225
+ this.codePage = param;
226
+ }
227
+ else if (name === 'ansi') {
228
+ this.codePage = 1252;
229
+ }
230
+ else if (name === 'mac') {
231
+ this.codePage = 10000;
232
+ }
233
+ else if (name === 'pc') {
234
+ this.codePage = 437;
235
+ }
236
+ else if (name === 'pca') {
237
+ this.codePage = 850;
238
+ }
239
+ group.content.push({ type: 'control', value: name, param });
240
+ // If this is the first control word in the group, it might be the destination
241
+ if (group.content.length === 1 && group.type === 'group') {
242
+ group.destination = name;
243
+ }
244
+ else if (group.content.length === 2 && group.content[0].type === 'control' && group.content[0].value === '*') {
245
+ // If first was *, second is destination
246
+ group.destination = name;
247
+ }
248
+ }
249
+ parseText(group) {
250
+ while (this.index < this.length) {
251
+ const char = this.buffer[this.index];
252
+ if (char === undefined)
253
+ break;
254
+ if (char === 0x7B || char === 0x7D || char === 0x5C || char === 0x0D || char === 0x0A) {
255
+ break;
256
+ }
257
+ this.pendingBytes.push(char);
258
+ this.index++;
259
+ }
260
+ }
261
+ /**
262
+ * Flushes the pending bytes buffer as a text node to the current group.
263
+ * @param group The group to append the text node to
264
+ */
265
+ flushPendingText(group) {
266
+ if (this.pendingBytes.length > 0) {
267
+ group.content.push({ type: 'text', value: this.decodeBytes(this.pendingBytes, this.codePage) });
268
+ this.pendingBytes = [];
269
+ }
270
+ }
271
+ /**
272
+ * Decodes a byte array using a "UTF-8 first" strategy.
273
+ * If the bytes form valid UTF-8 and contain non-ASCII characters, UTF-8 is preferred.
274
+ * Otherwise, falls back to the specified code page.
275
+ * @param bytes The bytes to decode
276
+ * @param codePage The RTF code page ID
277
+ * @returns The decoded string
278
+ */
279
+ decodeBytes(bytes, codePage) {
280
+ const uint8 = new Uint8Array(bytes);
281
+ // Try UTF-8 first if there are any non-ASCII bytes.
282
+ // Many modern RTF generators (like calibre or web-based tools) dump UTF-8 bytes
283
+ // into the RTF even if the header claims a different code page.
284
+ if (bytes.some(b => b > 127)) {
285
+ try {
286
+ // Use fatal: true to ensure we fall back on invalid UTF-8 sequences
287
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
288
+ return utf8Decoder.decode(uint8);
289
+ }
290
+ catch (e) {
291
+ // Not valid UTF-8, continue to code page fallback
292
+ }
293
+ }
294
+ // Fallback to specified code page
295
+ if (!this.decoders[codePage]) {
296
+ let encoding = `windows-${codePage}`;
297
+ if (codePage === 10000)
298
+ encoding = 'macintosh';
299
+ else if (codePage === 437)
300
+ encoding = 'ibm437';
301
+ else if (codePage === 850)
302
+ encoding = 'ibm850';
303
+ try {
304
+ this.decoders[codePage] = new TextDecoder(encoding);
305
+ }
306
+ catch (e) {
307
+ if (codePage !== 1252) {
308
+ try {
309
+ this.decoders[codePage] = new TextDecoder('windows-1252');
310
+ }
311
+ catch (e2) {
312
+ return String.fromCharCode(...bytes);
313
+ }
314
+ }
315
+ else {
316
+ return String.fromCharCode(...bytes);
317
+ }
318
+ }
319
+ }
320
+ let result = this.decoders[codePage].decode(uint8);
321
+ // Safety override for Windows-1252 0x80-0x9F range if TextDecoder behaves like Latin-1.
322
+ // We replace control characters in the decoded string with their proper 1252 equivalents.
323
+ if (codePage === 1252 && /[\u0080-\u009F]/.test(result)) {
324
+ const map = {
325
+ '\u0080': '€', '\u0082': '‚', '\u0083': 'ƒ', '\u0084': '„', '\u0085': '…',
326
+ '\u0086': '†', '\u0087': '‡', '\u0088': 'ˆ', '\u0089': '‰', '\u008A': 'Š',
327
+ '\u008B': '‹', '\u008C': 'Œ', '\u008E': 'Ž', '\u0091': '‘', '\u0092': '’',
328
+ '\u0093': '“', '\u0094': '”', '\u0095': '•', '\u0096': '–', '\u0097': '—',
329
+ '\u0098': '˜', '\u0099': '™', '\u009A': 'š', '\u009B': '›', '\u009C': 'œ',
330
+ '\u009E': 'ž', '\u009F': 'Ÿ'
331
+ };
332
+ return result.replace(/[\u0080-\u009F]/g, m => map[m] || m);
333
+ }
334
+ return result;
335
+ }
336
+ }
337
+ exports.SimpleRtfParser = SimpleRtfParser;
338
+ /**
339
+ * Parses an RTF file and returns the AST.
340
+ *
341
+ * **RTF Format Limitations:**
342
+ * The following features are NOT supported due to RTF format constraints:
343
+ *
344
+ * 1. **Images/Attachments**: RTF `\pict` contains device-dependent metafile data
345
+ * (WMF/EMF/DIB). Extracting as portable images requires complex metafile parsing.
346
+ *
347
+ * 2. **StyleMap**: RTF uses inline formatting rather than named style definitions.
348
+ * There is no direct equivalent to DOCX's style.xml.
349
+ *
350
+ * **Note Support:**
351
+ * RTF supports both footnotes and endnotes using the `\footnote` group.
352
+ * The `\fet` control word distinguishes between them:
353
+ * - `\fet0` = footnotes only (default)
354
+ * - `\fet1` = endnotes only
355
+ * - `\fet2` = both footnotes and endnotes
356
+ *
357
+ * @param buffer The file buffer.
358
+ * @param config The parser configuration.
359
+ * @returns The parsed AST.
360
+ */
361
+ const parseRtf = async (buffer, config) => {
362
+ (0, errorUtils_js_1.checkAbortSignal)(config.abortSignal);
363
+ const parser = new SimpleRtfParser(buffer);
364
+ const doc = parser.parse();
365
+ // Extract font and color tables
366
+ const fontTable = extractFontTable(doc);
367
+ const colorTable = extractColorTable(doc);
368
+ const content = [];
369
+ const notes = [];
370
+ const attachments = [];
371
+ // State for paragraph construction
372
+ let currentParagraphTextChunks = [];
373
+ let currentParagraphChildren = [];
374
+ let currentParagraphRawChunks = [];
375
+ // State for text run construction
376
+ let currentRunTextChunks = [];
377
+ let currentFormatting = {};
378
+ // Target for content (main body or notes)
379
+ let currentTarget = content;
380
+ // Paragraph-level state
381
+ let paragraphIndent = 0;
382
+ let paragraphAlignment = 'left';
383
+ let isListItem = false;
384
+ let listType;
385
+ let headingLevel;
386
+ let currentListId;
387
+ let currentAnchorIds = [];
388
+ // Persistent list state for listtext/pntext detection
389
+ // These persist across paragraphs to allow list items without explicit \ls
390
+ let lastKnownListId;
391
+ let lastKnownListType;
392
+ let tableStack = [];
393
+ let currentFootnoteId = 0;
394
+ // ═══════════════════════════════════════════════════════════════════
395
+ // Note type tracking (footnotes vs endnotes)
396
+ // ═══════════════════════════════════════════════════════════════════
397
+ // RTF uses \fet to distinguish note types:
398
+ // \fet0 = footnotes only (default)
399
+ // \fet1 = endnotes only
400
+ // \fet2 = both footnotes and endnotes
401
+ let fetValue = 0; // Default to footnotes only
402
+ // Helper to get current table context
403
+ const getCurrentTable = () => tableStack.length > 0 ? tableStack[tableStack.length - 1] : undefined;
404
+ // Helper to ensure a table context exists (for top-level tables)
405
+ const ensureTableContext = () => {
406
+ if (tableStack.length === 0) {
407
+ tableStack.push({
408
+ rows: [],
409
+ currentCells: [],
410
+ currentCellContent: [],
411
+ rowIndex: 0
412
+ });
413
+ }
414
+ };
415
+ let inTable = false;
416
+ let paragraphInTable = false;
417
+ let tableId = 0;
418
+ let rowCellProps = [];
419
+ let currentCellDefinitionProps = { isMergedContinuation: false };
420
+ let cellContentIndex = 0;
421
+ // ═══════════════════════════════════════════════════════════════════
422
+ // List state tracking (Word 97+ uses \ls for list style ID)
423
+ // ═══════════════════════════════════════════════════════════════════
424
+ let listIdCounter = 0;
425
+ const listStyleIdMap = {};
426
+ // List definition state for parsing \listtable
427
+ let parsingListTable = false;
428
+ let parsingListDefinition = false;
429
+ let currentDefinedListId;
430
+ let currentDefinedListType;
431
+ const listTypeMap = {};
432
+ // List override state for parsing \listoverridetable
433
+ let parsingListOverrideTable = false;
434
+ let currentListOverrideListId;
435
+ let currentListOverrideLs;
436
+ const listOverrideMap = {}; // Maps \ls ID to \listid
437
+ // List counters for itemIndex tracking
438
+ // Map: listId -> indentation level -> count
439
+ const listCounters = {};
440
+ // ═══════════════════════════════════════════════════════════════════
441
+ // Hyperlink state (RTF uses \field{\*\fldinst HYPERLINK "url"})
442
+ // ═══════════════════════════════════════════════════════════════════
443
+ let currentLinkUrl;
444
+ // Helper to check if formatting changed
445
+ const formattingChanged = (a, b) => {
446
+ return a.bold !== b.bold ||
447
+ a.italic !== b.italic ||
448
+ a.underline !== b.underline ||
449
+ a.strikethrough !== b.strikethrough ||
450
+ a.size !== b.size ||
451
+ a.font !== b.font ||
452
+ a.color !== b.color ||
453
+ a.backgroundColor !== b.backgroundColor ||
454
+ a.subscript !== b.subscript ||
455
+ a.superscript !== b.superscript;
456
+ };
457
+ // Helper to flush current run to paragraph children
458
+ const flushRun = () => {
459
+ if (currentRunTextChunks.length > 0) {
460
+ const currentRunText = currentRunTextChunks.join('');
461
+ const node = {
462
+ type: 'text',
463
+ text: currentRunText,
464
+ formatting: { ...currentFormatting }
465
+ };
466
+ // Use TextMetadata.link for hyperlinks
467
+ if (currentLinkUrl) {
468
+ node.metadata = {
469
+ link: currentLinkUrl,
470
+ linkType: classifyLinkType(currentLinkUrl)
471
+ };
472
+ }
473
+ currentParagraphChildren.push(node);
474
+ currentParagraphTextChunks.push(currentRunText);
475
+ currentRunTextChunks = [];
476
+ }
477
+ };
478
+ let isFlushingTable = false;
479
+ // Helper to flush current paragraph
480
+ const flushParagraph = () => {
481
+ flushRun(); // Ensure last run is added
482
+ // Check if we need to end the table
483
+ // If we were in a table, but this paragraph is NOT marked as in-table,
484
+ // and we have content, then the table has ended.
485
+ const hasContent = currentParagraphTextChunks.length > 0 || currentParagraphChildren.length > 0;
486
+ if (inTable && !paragraphInTable && !isFlushingTable && hasContent) {
487
+ // CRITICAL: Save current paragraph content before flushing table
488
+ // because flushTable() -> flushRow() -> flushCell() -> flushParagraph()
489
+ // would otherwise process this content during the table flush
490
+ const savedParagraphTextChunks = [...currentParagraphTextChunks];
491
+ const savedParagraphChildren = [...currentParagraphChildren];
492
+ const savedParagraphRawChunks = [...currentParagraphRawChunks];
493
+ // Clear buffers so nested flushParagraph() doesn't process them
494
+ currentParagraphTextChunks = [];
495
+ currentParagraphChildren = [];
496
+ currentParagraphRawChunks = [];
497
+ flushTable();
498
+ // Restore the saved content for processing after the table
499
+ currentParagraphTextChunks = savedParagraphTextChunks;
500
+ currentParagraphChildren = savedParagraphChildren;
501
+ currentParagraphRawChunks = savedParagraphRawChunks;
502
+ }
503
+ if (hasContent) {
504
+ const currentParagraphText = currentParagraphTextChunks.join('');
505
+ let nodeType = 'paragraph';
506
+ let metadata = undefined;
507
+ // Heuristic heading detection if no explicit \s style was found
508
+ if (headingLevel === undefined && currentParagraphChildren.length > 0) {
509
+ // Check if the first child is bold and larger than default (12pt)
510
+ const firstChild = currentParagraphChildren[0];
511
+ if (firstChild.type === 'text' && firstChild.formatting?.bold) {
512
+ const size = parseInt(firstChild.formatting.size || '12');
513
+ if (size >= 14 && currentParagraphText.length < 300) {
514
+ if (size >= 22)
515
+ headingLevel = 1;
516
+ else if (size >= 18)
517
+ headingLevel = 2;
518
+ else if (size >= 16)
519
+ headingLevel = 3;
520
+ else
521
+ headingLevel = 4;
522
+ }
523
+ }
524
+ }
525
+ // Heuristic list detection if no explicit list control words were found
526
+ if (!isListItem && paragraphIndent > 300) { // RTF indents are in twips (1440 = 1 inch)
527
+ const trimmed = currentParagraphText.trim();
528
+ // Check for bullet characters or digits followed by period
529
+ if (/^[\u2022\u00b7\-\u25cf\u25cb]/.test(trimmed) || /^\d+[.\)]/.test(trimmed)) {
530
+ isListItem = true;
531
+ listType = /^\d+[.\)]/.test(trimmed) ? 'ordered' : 'unordered';
532
+ }
533
+ }
534
+ if (headingLevel !== undefined && headingLevel > 0) {
535
+ nodeType = 'heading';
536
+ metadata = { level: headingLevel };
537
+ // Reset list context when we encounter a heading
538
+ lastKnownListId = undefined;
539
+ lastKnownListType = undefined;
540
+ }
541
+ else if (isListItem) {
542
+ nodeType = 'list';
543
+ // Use lastKnownListId if currentListId is not set
544
+ // (happens when list item is detected via listtext/pntext)
545
+ const effectiveListId = currentListId || lastKnownListId;
546
+ const effectiveListType = listType || lastKnownListType || 'unordered';
547
+ // Calculate itemIndex
548
+ let itemIndex = 0;
549
+ if (effectiveListId) {
550
+ if (!listCounters[effectiveListId]) {
551
+ listCounters[effectiveListId] = {};
552
+ }
553
+ // Reset all sub-level counters when returning to a shallower level
554
+ // This ensures that if we go from level 2 to level 0 and back to level 2,
555
+ // the new level 2 sequence starts from 0.
556
+ const levels = Object.keys(listCounters[effectiveListId]).map(l => parseInt(l));
557
+ for (const level of levels) {
558
+ if (level > paragraphIndent) {
559
+ delete listCounters[effectiveListId][level];
560
+ }
561
+ }
562
+ if (listCounters[effectiveListId][paragraphIndent] === undefined) {
563
+ listCounters[effectiveListId][paragraphIndent] = 0;
564
+ }
565
+ else {
566
+ listCounters[effectiveListId][paragraphIndent]++;
567
+ }
568
+ itemIndex = listCounters[effectiveListId][paragraphIndent];
569
+ }
570
+ metadata = {
571
+ listType: effectiveListType,
572
+ indentation: paragraphIndent,
573
+ listId: effectiveListId || '',
574
+ itemIndex: itemIndex,
575
+ alignment: paragraphAlignment,
576
+ };
577
+ // Save for next listtext detection
578
+ if (effectiveListId) {
579
+ lastKnownListId = effectiveListId;
580
+ }
581
+ if (effectiveListType) {
582
+ lastKnownListType = effectiveListType;
583
+ }
584
+ }
585
+ const node = {
586
+ type: nodeType,
587
+ text: currentParagraphText,
588
+ children: currentParagraphChildren,
589
+ formatting: undefined,
590
+ metadata: {
591
+ ...metadata,
592
+ anchorIds: currentAnchorIds.length > 0 ? [...currentAnchorIds] : undefined
593
+ }
594
+ };
595
+ if (config.includeRawContent && currentParagraphRawChunks.length > 0) {
596
+ node.rawContent = currentParagraphRawChunks.join('');
597
+ }
598
+ // If we're building a table, add to current cell
599
+ // but ONLY if this paragraph was actually marked as in-table
600
+ if (inTable && paragraphInTable) {
601
+ ensureTableContext();
602
+ getCurrentTable().currentCellContent.push(node);
603
+ }
604
+ else {
605
+ currentTarget.push(node);
606
+ }
607
+ currentParagraphTextChunks = [];
608
+ currentParagraphChildren = [];
609
+ currentParagraphRawChunks = [];
610
+ // Reset paragraph-level state that should NOT persist
611
+ // Note: list properties (\ls, \ilvl, \li) and alignment (\ql, etc.)
612
+ // persist in RTF until \pard or a new value is set.
613
+ currentAnchorIds = []; // Reset anchors
614
+ }
615
+ };
616
+ // Helper to flush current cell
617
+ const flushCell = (tableCtx) => {
618
+ flushParagraph();
619
+ const ctx = tableCtx || getCurrentTable();
620
+ if (!ctx)
621
+ return undefined;
622
+ // Always return a cell node, even if empty, to preserve table structure (grid)
623
+ const cellNode = {
624
+ type: 'cell',
625
+ text: ctx.currentCellContent.map(c => c.text).join('\n'),
626
+ children: [...ctx.currentCellContent],
627
+ metadata: {
628
+ row: ctx.rowIndex,
629
+ col: ctx.currentCells.length
630
+ }
631
+ };
632
+ ctx.currentCellContent = [];
633
+ return cellNode;
634
+ };
635
+ // Helper to flush current row - creates a row node from collected cells
636
+ const flushRow = (tableCtx) => {
637
+ const ctx = tableCtx || getCurrentTable();
638
+ if (!ctx)
639
+ return;
640
+ const cell = flushCell(ctx);
641
+ // Only add cell if it has content (prevents phantom empty cells during cleanup)
642
+ if (cell && (cell.children && cell.children.length > 0 || cell.text)) {
643
+ ctx.currentCells.push(cell);
644
+ }
645
+ if (ctx.currentCells.length > 0) {
646
+ const rowNode = {
647
+ type: 'row',
648
+ text: ctx.currentCells.map(c => c.text).filter(t => t !== '').join(config.newlineDelimiter),
649
+ children: [...ctx.currentCells]
650
+ };
651
+ ctx.rows.push(rowNode);
652
+ ctx.currentCells = [];
653
+ ctx.rowIndex++;
654
+ }
655
+ };
656
+ // Helper to flush table
657
+ const flushTable = () => {
658
+ if (isFlushingTable)
659
+ return;
660
+ isFlushingTable = true;
661
+ const ctx = getCurrentTable();
662
+ if (!ctx) {
663
+ isFlushingTable = false;
664
+ return;
665
+ }
666
+ flushRow(ctx);
667
+ if (ctx.rows.length > 0) {
668
+ tableId++;
669
+ const tableNode = {
670
+ type: 'table',
671
+ text: ctx.rows.map(r => r.text).join('\n'), // Aggregate text from rows
672
+ children: [...ctx.rows]
673
+ };
674
+ // If we have a parent table, add this table to the parent's current cell
675
+ if (tableStack.length > 1) {
676
+ const parentCtx = tableStack[tableStack.length - 2];
677
+ parentCtx.currentCellContent.push(tableNode);
678
+ }
679
+ else {
680
+ currentTarget.push(tableNode);
681
+ }
682
+ }
683
+ // Pop the table from stack
684
+ tableStack.pop();
685
+ // If stack is empty, we are out of table mode
686
+ if (tableStack.length === 0) {
687
+ inTable = false;
688
+ paragraphInTable = false;
689
+ }
690
+ isFlushingTable = false;
691
+ };
692
+ // Extract hyperlink URL from field instruction group
693
+ // Recursively searches for HYPERLINK "url" pattern in nested groups
694
+ const extractHyperlinkUrl = (group) => {
695
+ let url;
696
+ // Helper to recursively find hyperlink URL
697
+ const findUrl = (node) => {
698
+ if (node.type === 'text') {
699
+ // Check for HYPERLINK "url" pattern
700
+ const text = node.value;
701
+ const match = text.match(/HYPERLINK\s+"([^"]+)"/i);
702
+ if (match) {
703
+ return match[1];
704
+ }
705
+ // Also check for URL after HYPERLINK on same or separate text node
706
+ const urlOnlyMatch = text.match(/"(https?:\/\/[^"]+|mailto:[^"]+|#[^"]+)"/);
707
+ if (urlOnlyMatch) {
708
+ return urlOnlyMatch[1];
709
+ }
710
+ }
711
+ else if (node.type === 'group') {
712
+ // Check if this is a fldinst group (contains field instruction)
713
+ let foundHyperlink = false;
714
+ let foundUrl;
715
+ let isLocal = false;
716
+ for (const child of node.content) {
717
+ if (child.type === 'text') {
718
+ const text = child.value.toUpperCase();
719
+ if (text.includes('HYPERLINK')) {
720
+ foundHyperlink = true;
721
+ }
722
+ if (text.includes('\\L')) {
723
+ isLocal = true;
724
+ }
725
+ // Look for quoted URL/Anchor
726
+ const urlMatch = child.value.match(/"([^"]+)"/);
727
+ if (urlMatch && foundHyperlink) {
728
+ foundUrl = urlMatch[1];
729
+ }
730
+ }
731
+ else if (child.type === 'group') {
732
+ const nestedUrl = findUrl(child);
733
+ if (nestedUrl) {
734
+ foundUrl = nestedUrl;
735
+ }
736
+ }
737
+ }
738
+ if (foundUrl) {
739
+ if (isLocal && !foundUrl.startsWith('#')) {
740
+ return '#' + foundUrl;
741
+ }
742
+ return foundUrl;
743
+ }
744
+ }
745
+ return undefined;
746
+ };
747
+ // Search the field group for fldinst
748
+ for (const child of group.content) {
749
+ if (child.type === 'group') {
750
+ const foundUrl = findUrl(child);
751
+ if (foundUrl)
752
+ return foundUrl;
753
+ }
754
+ }
755
+ return url;
756
+ };
757
+ /**
758
+ * Determines whether a hyperlink URL is internal or external.
759
+ * Internal = bookmark references (no scheme or starts with "#").
760
+ * External = any scheme like http, https, mailto, ftp, file, etc.
761
+ */
762
+ const classifyLinkType = (url) => {
763
+ // Trim whitespace
764
+ const clean = url.trim();
765
+ // Internal pattern 1: starts with "#"
766
+ if (clean.startsWith('#')) {
767
+ return 'internal';
768
+ }
769
+ // Internal pattern 2: no scheme at all (pure bookmark)
770
+ // Detect schemes by checking "something:" prefix
771
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(clean)) {
772
+ return 'internal';
773
+ }
774
+ // Everything else is external
775
+ return 'external';
776
+ };
777
+ // Helper to extract text content from a group (for list marker detection)
778
+ // Recursively collects all text content from a group
779
+ const extractTextFromGroup = (group) => {
780
+ let text = '';
781
+ const collectText = (node) => {
782
+ if (node.type === 'text') {
783
+ text += node.value;
784
+ }
785
+ else if (node.type === 'group') {
786
+ for (const child of node.content) {
787
+ collectText(child);
788
+ }
789
+ }
790
+ };
791
+ for (const child of group.content) {
792
+ collectText(child);
793
+ }
794
+ return text;
795
+ };
796
+ /**
797
+ * Extracts an image attachment from an RTF \pict group.
798
+ * Uses lookup tables for clean and safe handling of all formats.
799
+ *
800
+ * @param pictGroup The RtfGroup node that represents a \pict group.
801
+ * @returns An OfficeAttachment or undefined when unsupported or invalid.
802
+ */
803
+ const extractPictAttachment = (pictGroup) => {
804
+ /** Internal format detected from the RTF pict group */
805
+ let imageFormat;
806
+ /** Hexadecimal string chunks extracted from the pict binary section */
807
+ const hexDataChunks = [];
808
+ // -------------------------------------------------------------
809
+ // Walk through pict group content to detect the blip type and gather hex data
810
+ // -------------------------------------------------------------
811
+ for (const child of pictGroup.content) {
812
+ // If the node is a control word, we try to resolve it from lookup map
813
+ if (child.type === 'control') {
814
+ // Lookup directly instead of if/else
815
+ const mapped = RTF_BLIP_MAP[child.value];
816
+ if (mapped) {
817
+ imageFormat = mapped;
818
+ }
819
+ }
820
+ else if (child.type === 'text') {
821
+ // Append only valid hex characters
822
+ hexDataChunks.push(child.value.replace(/[^0-9a-fA-F]/g, ''));
823
+ }
824
+ else if (child.type === 'group') {
825
+ // Skip nested structures like picprop or blipuid
826
+ }
827
+ }
828
+ // Missing or unknown format → stop
829
+ const hexData = hexDataChunks.join('');
830
+ if (!imageFormat || hexData.length === 0) {
831
+ return undefined;
832
+ }
833
+ // Map internal format to MIME
834
+ const mimeType = IMAGE_MIME_MAP[imageFormat];
835
+ if (!mimeType) {
836
+ return undefined;
837
+ }
838
+ // -------------------------------------------------------------
839
+ // Convert hex → binary and construct the attachment object
840
+ // -------------------------------------------------------------
841
+ try {
842
+ // Convert hex into a raw buffer
843
+ const buffer = Buffer.from(hexData, 'hex');
844
+ // Derive file extension directly from format
845
+ const extension = imageFormat;
846
+ // Generate a stable incremental filename
847
+ const name = `image_${attachments.length + 1}.${extension}`;
848
+ // Build and return final attachment
849
+ return {
850
+ type: 'image',
851
+ mimeType: mimeType,
852
+ data: buffer.toString('base64'),
853
+ name: name,
854
+ extension: extension
855
+ };
856
+ }
857
+ catch {
858
+ // If conversion fails, ignore this image
859
+ return undefined;
860
+ }
861
+ };
862
+ // Helper to serialize RTF control word
863
+ const serializeRtfControl = (node) => {
864
+ // Symbol control words (non-alpha)
865
+ if (!/^[a-zA-Z]/.test(node.value)) {
866
+ return `\\${node.value}`;
867
+ }
868
+ // Alpha control words
869
+ let res = `\\${node.value}`;
870
+ if (node.param !== undefined) {
871
+ res += node.param;
872
+ }
873
+ // Add space delimiter for safety
874
+ res += ' ';
875
+ return res;
876
+ };
877
+ // Helper to extract text from a group (for bookmark names, etc.)
878
+ const extractGroupText = (group) => {
879
+ let text = '';
880
+ for (const item of group.content) {
881
+ if (item.type === 'text') {
882
+ text += item.value;
883
+ }
884
+ else if (item.type === 'group') {
885
+ text += extractGroupText(item);
886
+ }
887
+ }
888
+ return text.trim();
889
+ };
890
+ // Helper to serialize RTF text
891
+ const serializeRtfText = (node) => {
892
+ // Escape special characters: \, {, }
893
+ return node.value.replace(/([\\{}])/g, '\\$1');
894
+ };
895
+ // Recursive function to traverse the RTF tree
896
+ const traverse = (node, formatting, depth = 0) => {
897
+ if (node.type === 'group') {
898
+ const ignoreList = [
899
+ 'fonttbl', 'colortbl', 'stylesheet', 'info', 'macpict',
900
+ 'pmmetafile', 'wmetafile', 'dibitmap', 'bitmap', 'object',
901
+ 'nextGenerator', 'header', 'footer', 'nonshppict', 'xml', 'private',
902
+ 'upnp', 'ud', 'filetbl', 'operator', 'author', 'creatim', 'revtim', 'printim', 'comment',
903
+ 'fldinst', 'listtext', 'pntext' // Ignore list marker text (handled separately)
904
+ ];
905
+ let isIgnored = false;
906
+ let isFootnote = false;
907
+ let isHyperlinkField = false;
908
+ let isPict = false;
909
+ // Add group start to raw content
910
+ // Note: We don't add ignored groups to rawContent to keep it clean
911
+ // But we might want to if we want full fidelity.
912
+ // For now, let's include everything in rawContent except truly skipped stuff?
913
+ // The user asked for "raw content which is probably the rtf group".
914
+ // If we skip 'fonttbl', it's fine as it's not part of the content.
915
+ // But 'listtext' IS part of the content structure even if we parse it separately.
916
+ // Let's stick to the plan: if ignored, we might skip it in rawContent too,
917
+ // OR we include it.
918
+ // If I include it, `currentParagraphRaw` might get huge with font tables if they were inside the paragraph (unlikely).
919
+ // Usually font tables are at document root.
920
+ // `traverse` is called on `doc`.
921
+ // `currentParagraphRaw` is reset on `flushParagraph`.
922
+ // So if we are at root level, `currentParagraphRaw` accumulates everything until the first paragraph ends.
923
+ // This might include the header/fonttbl if they are before the first \par.
924
+ // That seems correct for "raw content" of the first node?
925
+ // Actually, `fonttbl` is usually before any text.
926
+ // If we include it, the first paragraph node will contain the entire font table in its rawContent.
927
+ // That might be annoying.
928
+ // Let's ONLY add to `currentParagraphRaw` if NOT ignored.
929
+ if (node.destination) {
930
+ if (node.destination === 'footnote') {
931
+ isFootnote = true;
932
+ }
933
+ else if (node.destination === 'field') {
934
+ // Check if this is a hyperlink field
935
+ const url = extractHyperlinkUrl(node);
936
+ if (url) {
937
+ // Flush any pending text before starting the link context
938
+ // This prevents previous text from inheriting the link
939
+ flushRun();
940
+ isHyperlinkField = true;
941
+ currentLinkUrl = url;
942
+ }
943
+ }
944
+ else if (node.destination === 'listtable') {
945
+ // We want to parse list definitions
946
+ parsingListTable = true;
947
+ }
948
+ else if (parsingListTable && node.destination === 'list') {
949
+ parsingListDefinition = true;
950
+ currentDefinedListId = undefined;
951
+ currentDefinedListType = undefined;
952
+ }
953
+ else if (node.destination === 'listoverridetable') {
954
+ parsingListOverrideTable = true;
955
+ }
956
+ else if (parsingListOverrideTable && node.destination === 'listoverride') {
957
+ // Reset per override group
958
+ currentListOverrideListId = undefined;
959
+ currentListOverrideLs = undefined;
960
+ }
961
+ else if (node.destination === 'pict') {
962
+ // Handle picture extraction
963
+ if (config.extractAttachments) {
964
+ isPict = true;
965
+ }
966
+ else {
967
+ isIgnored = true;
968
+ }
969
+ }
970
+ else if (ignoreList.includes(node.destination)) {
971
+ isIgnored = true;
972
+ }
973
+ else if (node.content.length > 0 && node.content[0].type === 'control' && node.content[0].value === '*') {
974
+ // Ignorable destination, but allow certain ones for:
975
+ // - fldinst: hyperlinks
976
+ // - nesttableprops: nested tables
977
+ // - shppict: shape pictures (contain pict groups)
978
+ const allowedIgnorable = ['fldinst', 'nesttableprops'];
979
+ if (config.extractAttachments) {
980
+ allowedIgnorable.push('shppict', 'listpicture');
981
+ }
982
+ if (!allowedIgnorable.includes(node.destination || '')) {
983
+ if (node.destination === 'bkmkstart') {
984
+ const name = extractGroupText(node);
985
+ if (name && !currentAnchorIds.includes(name)) {
986
+ currentAnchorIds.push(name);
987
+ }
988
+ isIgnored = true;
989
+ }
990
+ else if (node.destination === 'bkmkend') {
991
+ isIgnored = true;
992
+ }
993
+ else {
994
+ isIgnored = true;
995
+ }
996
+ }
997
+ }
998
+ }
999
+ // ═══════════════════════════════════════════════════════════
1000
+ // Handle listtext and pntext: These indicate the current paragraph
1001
+ // is a list item. We extract list type info before ignoring content.
1002
+ // ═══════════════════════════════════════════════════════════
1003
+ if (node.destination === 'listtext' || node.destination === 'pntext') {
1004
+ // If this is the first list item, reset the indent
1005
+ if (!isListItem)
1006
+ paragraphIndent = 0;
1007
+ isListItem = true;
1008
+ // Try to determine list type from the marker content
1009
+ // Bullets (unordered): '·', '•', 'o', '§', etc.
1010
+ // Numbers (ordered): '1.', '2.', 'i.', 'ii.', 'a.', 'A.', etc.
1011
+ const markerText = extractTextFromGroup(node);
1012
+ if (markerText) {
1013
+ const trimmed = markerText.trim();
1014
+ // Check for common bullet characters
1015
+ const bulletChars = ['·', '•', 'o', '§', '■', '□', '●', '○', '◆', '◇', '►', '▸', '\u00b7', '\u2022', '\u25cf', '\u25cb'];
1016
+ const isBullet = bulletChars.some(b => trimmed.includes(b)) ||
1017
+ // Font symbol bullets often use characters from Symbol font
1018
+ (trimmed.length === 1 && !/[0-9a-zA-Z]/.test(trimmed));
1019
+ if (isBullet) {
1020
+ listType = 'unordered';
1021
+ }
1022
+ else if (/^[0-9ivxlcdm]+[\.\)]/i.test(trimmed) || /^[a-z][\.\)]/i.test(trimmed)) {
1023
+ // Matches: 1., 2), i., ii., a., A), etc.
1024
+ listType = 'ordered';
1025
+ }
1026
+ // If we can't determine, leave listType as is (might be set by \ls/\levelnfc)
1027
+ }
1028
+ // Still mark as ignored to skip the marker text content
1029
+ isIgnored = true;
1030
+ }
1031
+ if (isIgnored)
1032
+ return;
1033
+ // Append group start to raw content
1034
+ currentParagraphRawChunks.push('{');
1035
+ // Handle pict group: extract image and add to content tree
1036
+ if (isPict) {
1037
+ const attachment = extractPictAttachment(node);
1038
+ if (attachment) {
1039
+ attachments.push(attachment);
1040
+ // Only add image node to content if this is NOT a list definition picture
1041
+ // List pictures (bullets) should not appear in content, only as attachments
1042
+ if (!parsingListTable && !parsingListDefinition) {
1043
+ // Also add an image node to the content tree (like DOCX)
1044
+ flushParagraph();
1045
+ currentTarget.push({
1046
+ type: 'image',
1047
+ text: '',
1048
+ metadata: {
1049
+ attachmentName: attachment.name || `image_${attachments.length}`
1050
+ }
1051
+ });
1052
+ }
1053
+ }
1054
+ // We still traverse pict content to reconstruct raw RTF?
1055
+ // No, extractPictAttachment consumes it.
1056
+ // But we want it in rawContent?
1057
+ // If we return here, we miss the closing '}'.
1058
+ // And we miss the content in rawContent.
1059
+ // Let's traverse it purely for rawContent if needed, but `extractPictAttachment` doesn't modify the tree.
1060
+ // But `extractPictAttachment` does not return the raw string.
1061
+ // So we should probably continue traversal but suppress text extraction?
1062
+ // The original code returned here: `return; // Don't traverse pict content as text`
1063
+ // So we should do the same, but we need to append the content to `currentParagraphRaw`.
1064
+ // We can manually serialize the group content here.
1065
+ for (const child of node.content) {
1066
+ if (child.type === 'control')
1067
+ currentParagraphRawChunks.push(serializeRtfControl(child));
1068
+ else if (child.type === 'text')
1069
+ currentParagraphRawChunks.push(serializeRtfText(child));
1070
+ else if (child.type === 'group') {
1071
+ // Recursive serialization for nested groups in pict (e.g. blipuid)
1072
+ // We can't easily recurse `traverse` because it has side effects (text extraction).
1073
+ // We need a pure serializer or just let `traverse` run but with a flag?
1074
+ // Or just ignore the raw content of the image binary data?
1075
+ // Image binary data can be huge.
1076
+ // Maybe we shouldn't include the full hex dump in `rawContent`?
1077
+ // The user said "raw content which is probably the rtf group".
1078
+ // Including 5MB of hex data in the JSON AST might be bad.
1079
+ // But for consistency, it is the raw content.
1080
+ // Let's include it for now.
1081
+ // To do this without side effects, we need a separate serialize function?
1082
+ // Or just call traverse and ensure `isPict` logic prevents text extraction.
1083
+ // Wait, `isPict` is true for this node.
1084
+ // If we recurse, `isPict` will be false for children (unless they are also pict).
1085
+ // But we want to suppress text extraction for children of pict.
1086
+ // The original code did `return`.
1087
+ // So we should manually serialize children here.
1088
+ // Let's define a simple recursive serializer.
1089
+ const serializeGroupContent = (g) => {
1090
+ for (const c of g.content) {
1091
+ if (c.type === 'control')
1092
+ currentParagraphRawChunks.push(serializeRtfControl(c));
1093
+ else if (c.type === 'text')
1094
+ currentParagraphRawChunks.push(serializeRtfText(c));
1095
+ else if (c.type === 'group') {
1096
+ currentParagraphRawChunks.push('{');
1097
+ serializeGroupContent(c);
1098
+ currentParagraphRawChunks.push('}');
1099
+ }
1100
+ }
1101
+ };
1102
+ serializeGroupContent(node);
1103
+ }
1104
+ }
1105
+ currentParagraphRawChunks.push('}');
1106
+ return;
1107
+ }
1108
+ // Handle footnote: switch target to notes
1109
+ const previousTarget = currentTarget;
1110
+ let savedParagraphTextChunks;
1111
+ let savedParagraphChildren;
1112
+ let savedParagraphRawChunks;
1113
+ if (isFootnote) {
1114
+ if (config.ignoreNotes) {
1115
+ return; // Skip footnote content entirely
1116
+ }
1117
+ flushRun();
1118
+ currentFootnoteId++;
1119
+ // Determine note type based on \fet value
1120
+ let noteType = 'footnote';
1121
+ if (fetValue === 1) {
1122
+ // \fet1 means all notes are endnotes
1123
+ noteType = 'endnote';
1124
+ }
1125
+ else if (fetValue === 2) {
1126
+ // \fet2 means both types exist
1127
+ // Check for \ftnalt marker to distinguish endnotes from footnotes
1128
+ // \footnote\ftnalt indicates an endnote
1129
+ const hasFtnalt = node.content.some(child => child.type === 'control' && child.value === 'ftnalt');
1130
+ noteType = hasFtnalt ? 'endnote' : 'footnote';
1131
+ }
1132
+ // fetValue === 0 (default) means footnotes only
1133
+ const noteNode = {
1134
+ type: 'note',
1135
+ children: [],
1136
+ metadata: {
1137
+ noteId: currentFootnoteId.toString(),
1138
+ noteType: noteType
1139
+ }
1140
+ };
1141
+ if (currentParagraphChildren.length > 0) {
1142
+ const precedingNode = currentParagraphChildren[currentParagraphChildren.length - 1];
1143
+ if (!precedingNode.notes)
1144
+ precedingNode.notes = [];
1145
+ precedingNode.notes.push(noteNode);
1146
+ }
1147
+ else {
1148
+ const emptyTextNode = { type: 'text', text: '' };
1149
+ emptyTextNode.notes = [noteNode];
1150
+ currentParagraphChildren.push(emptyTextNode);
1151
+ }
1152
+ currentTarget = noteNode.children;
1153
+ // Save current paragraph state so we don't mix footnote paragraphs with main text
1154
+ savedParagraphTextChunks = [...currentParagraphTextChunks];
1155
+ savedParagraphChildren = [...currentParagraphChildren];
1156
+ savedParagraphRawChunks = [...currentParagraphRawChunks];
1157
+ currentParagraphTextChunks = [];
1158
+ currentParagraphChildren = [];
1159
+ currentParagraphRawChunks = [];
1160
+ }
1161
+ // Create a new formatting context for the group
1162
+ const groupFormatting = { ...formatting };
1163
+ for (const child of node.content) {
1164
+ // Skip fldinst groups (we already extracted the URL)
1165
+ if (child.type === 'group' && child.destination === 'fldinst') {
1166
+ // We still want it in rawContent!
1167
+ // So we should traverse it but suppress text extraction?
1168
+ // Or just serialize it?
1169
+ // `fldinst` contains the URL.
1170
+ // If we skip it in `traverse`, we miss it in `rawContent`.
1171
+ // Let's traverse it but maybe the `fldinst` logic inside `traverse` handles it?
1172
+ // The original code:
1173
+ // if (child.type === 'group' && child.destination === 'fldinst') { continue; }
1174
+ // This skips the child entirely.
1175
+ // So we need to manually serialize it if we want it in rawContent.
1176
+ currentParagraphRawChunks.push('{');
1177
+ // We need to serialize the content of fldinst
1178
+ const serializeGroupContent = (g) => {
1179
+ for (const c of g.content) {
1180
+ if (c.type === 'control')
1181
+ currentParagraphRawChunks.push(serializeRtfControl(c));
1182
+ else if (c.type === 'text')
1183
+ currentParagraphRawChunks.push(serializeRtfText(c));
1184
+ else if (c.type === 'group') {
1185
+ currentParagraphRawChunks.push('{');
1186
+ serializeGroupContent(c);
1187
+ currentParagraphRawChunks.push('}');
1188
+ }
1189
+ }
1190
+ };
1191
+ serializeGroupContent(child);
1192
+ currentParagraphRawChunks.push('}');
1193
+ continue;
1194
+ }
1195
+ traverse(child, groupFormatting, depth + 1);
1196
+ }
1197
+ if (node.destination === 'listtable') {
1198
+ parsingListTable = false;
1199
+ }
1200
+ else if (node.destination === 'list') {
1201
+ parsingListDefinition = false;
1202
+ if (currentDefinedListId !== undefined && currentDefinedListType !== undefined) {
1203
+ listTypeMap[currentDefinedListId] = currentDefinedListType;
1204
+ }
1205
+ }
1206
+ else if (node.destination === 'listoverridetable') {
1207
+ parsingListOverrideTable = false;
1208
+ }
1209
+ else if (parsingListOverrideTable && node.destination === 'listoverride') {
1210
+ // End of listoverride group - populate map
1211
+ if (currentListOverrideLs !== undefined && currentListOverrideListId !== undefined) {
1212
+ listOverrideMap[currentListOverrideLs] = currentListOverrideListId;
1213
+ }
1214
+ }
1215
+ if (isFootnote) {
1216
+ flushParagraph();
1217
+ currentTarget = previousTarget;
1218
+ // Restore the saved paragraph state
1219
+ currentParagraphTextChunks = savedParagraphTextChunks;
1220
+ currentParagraphChildren = savedParagraphChildren;
1221
+ currentParagraphRawChunks = savedParagraphRawChunks;
1222
+ }
1223
+ // Clear link URL after processing the field group
1224
+ if (isHyperlinkField) {
1225
+ flushRun();
1226
+ currentLinkUrl = undefined;
1227
+ }
1228
+ // Append group end to raw content
1229
+ currentParagraphRawChunks.push('}');
1230
+ }
1231
+ else if (node.type === 'text') {
1232
+ if (parsingListTable || parsingListOverrideTable) {
1233
+ // Even if we don't extract text, we might want it in rawContent?
1234
+ // Yes, rawContent should reflect the source.
1235
+ currentParagraphRawChunks.push(serializeRtfText(node));
1236
+ return;
1237
+ }
1238
+ if (formattingChanged(currentFormatting, formatting)) {
1239
+ flushRun();
1240
+ currentFormatting = { ...formatting };
1241
+ }
1242
+ currentRunTextChunks.push(node.value);
1243
+ currentParagraphRawChunks.push(serializeRtfText(node));
1244
+ }
1245
+ else if (node.type === 'control') {
1246
+ // Append control to raw content
1247
+ currentParagraphRawChunks.push(serializeRtfControl(node));
1248
+ // Handle list definition control words
1249
+ if (parsingListTable) {
1250
+ if (node.value === 'listid') {
1251
+ currentDefinedListId = node.param;
1252
+ }
1253
+ else if (node.value === 'levelnfc' || node.value === 'levelnfcn') {
1254
+ // 0 = Arabic, 1 = Upper Roman, 2 = Lower Roman, 3 = Upper Alpha, 4 = Lower Alpha -> Ordered
1255
+ // 23 = Bullet, 255 = None -> Unordered
1256
+ const isOrdered = node.param !== undefined && (node.param === 0 || (node.param >= 0 && node.param <= 4));
1257
+ // Only set if not already set (or prioritize ordered if mixed?)
1258
+ // We'll assume if any level is ordered, it's ordered.
1259
+ // Or if we haven't set it yet.
1260
+ if (!currentDefinedListType || (currentDefinedListType === 'unordered' && isOrdered)) {
1261
+ currentDefinedListType = isOrdered ? 'ordered' : 'unordered';
1262
+ }
1263
+ }
1264
+ return;
1265
+ }
1266
+ // Handle list override control words
1267
+ if (parsingListOverrideTable) {
1268
+ if (node.value === 'listid') {
1269
+ currentListOverrideListId = node.param;
1270
+ }
1271
+ else if (node.value === 'ls') {
1272
+ currentListOverrideLs = node.param;
1273
+ }
1274
+ return;
1275
+ }
1276
+ // Paragraph control words
1277
+ if (node.value === 'par') {
1278
+ flushParagraph();
1279
+ currentFormatting = { ...formatting };
1280
+ }
1281
+ // Table control words
1282
+ else if (node.value === 'trowd') {
1283
+ // Table row definition - start of a new row
1284
+ // Check if we are starting a nested table
1285
+ // If we are already in a table, and we have content in the current cell,
1286
+ // then this trowd implies a nested table start.
1287
+ const ctx = getCurrentTable();
1288
+ if (inTable && ctx && ctx.currentCellContent.length > 0) {
1289
+ // Start nested table
1290
+ ensureTableContext(); // Should already exist if inTable is true
1291
+ // Push new table context
1292
+ tableStack.push({
1293
+ rows: [],
1294
+ currentCells: [],
1295
+ currentCellContent: [],
1296
+ rowIndex: 0
1297
+ });
1298
+ }
1299
+ else {
1300
+ if (!inTable) {
1301
+ inTable = true;
1302
+ ensureTableContext();
1303
+ }
1304
+ }
1305
+ // After \trowd we are inside a table row, so content should go to table cells.
1306
+ // Many RTF files don't use \intbl, relying solely on \trowd...\cell...\row structure.
1307
+ paragraphInTable = true;
1308
+ // Reset cell properties for the new row definition
1309
+ rowCellProps = [];
1310
+ currentCellDefinitionProps = { isMergedContinuation: false };
1311
+ cellContentIndex = 0;
1312
+ }
1313
+ else if (node.value === 'clvmrg') {
1314
+ // Vertical merge continuation
1315
+ currentCellDefinitionProps.isMergedContinuation = true;
1316
+ }
1317
+ else if (node.value === 'clmgf') {
1318
+ // Vertical merge first cell (reset continuation flag if set, though usually mutually exclusive)
1319
+ currentCellDefinitionProps.isMergedContinuation = false;
1320
+ }
1321
+ else if (node.value === 'cellx') {
1322
+ // End of cell definition
1323
+ rowCellProps.push({ ...currentCellDefinitionProps });
1324
+ // Reset for next cell
1325
+ currentCellDefinitionProps = { isMergedContinuation: false };
1326
+ }
1327
+ else if (node.value === 'cell') {
1328
+ // End of cell - add it to current row
1329
+ // Force paragraphInTable = true because \cell implies we are in a table cell
1330
+ paragraphInTable = true;
1331
+ // Check if this cell is a merged continuation
1332
+ let isMergedContinuation = false;
1333
+ if (cellContentIndex < rowCellProps.length) {
1334
+ isMergedContinuation = rowCellProps[cellContentIndex].isMergedContinuation;
1335
+ }
1336
+ cellContentIndex++;
1337
+ const cell = flushCell();
1338
+ // Only add if not a merged continuation
1339
+ if (cell) {
1340
+ if (!isMergedContinuation) {
1341
+ const ctx = getCurrentTable();
1342
+ if (ctx)
1343
+ ctx.currentCells.push(cell);
1344
+ }
1345
+ }
1346
+ currentFormatting = { ...formatting };
1347
+ }
1348
+ else if (node.value === 'nestcell') {
1349
+ // End of cell in outer table (nested context)
1350
+ // If we are in an inner table, we need to close it and return to outer
1351
+ // First, flush the current cell of the inner table (if any pending)
1352
+ // Actually, nestcell ends the OUTER cell.
1353
+ // So the inner table should have been finished by now?
1354
+ // Usually inner table ends with \row.
1355
+ // If we are in a nested table (stack > 1), we should pop until we are at the outer table?
1356
+ // Or maybe just pop one level?
1357
+ if (tableStack.length > 1) {
1358
+ // Flush the inner table if it has pending rows
1359
+ const innerCtx = getCurrentTable();
1360
+ if (innerCtx && (innerCtx.rows.length > 0 || innerCtx.currentCells.length > 0)) {
1361
+ flushTable(); // This pops the stack
1362
+ }
1363
+ }
1364
+ // Now we are (hopefully) at the outer table level
1365
+ // Treat as a regular cell end for the outer table
1366
+ paragraphInTable = true;
1367
+ const cell = flushCell();
1368
+ if (cell) {
1369
+ const ctx = getCurrentTable();
1370
+ if (ctx)
1371
+ ctx.currentCells.push(cell);
1372
+ }
1373
+ currentFormatting = { ...formatting };
1374
+ }
1375
+ else if (node.value === 'row') {
1376
+ // End of row
1377
+ flushRow();
1378
+ currentFormatting = { ...formatting };
1379
+ // Reset content index for safety (though trowd usually does it)
1380
+ cellContentIndex = 0;
1381
+ // Critical: Reset paragraphInTable after row ends.
1382
+ // Subsequent paragraphs must explicitly use \intbl to be part of the table.
1383
+ // Without this, content after the last \row gets incorrectly merged.
1384
+ paragraphInTable = false;
1385
+ }
1386
+ else if (node.value === 'nestrow') {
1387
+ // End of row in outer table
1388
+ // If we are still in inner table context, flush it
1389
+ if (tableStack.length > 1) {
1390
+ flushTable();
1391
+ }
1392
+ flushRow();
1393
+ currentFormatting = { ...formatting };
1394
+ cellContentIndex = 0;
1395
+ }
1396
+ else if (node.value === 'intbl') {
1397
+ // Paragraph is in a table
1398
+ inTable = true;
1399
+ paragraphInTable = true;
1400
+ ensureTableContext();
1401
+ }
1402
+ else if (node.value === 'pard') {
1403
+ // Reset paragraph properties
1404
+ paragraphInTable = false;
1405
+ // Reset other props...
1406
+ paragraphIndent = 0;
1407
+ paragraphAlignment = 'left';
1408
+ isListItem = false;
1409
+ listType = undefined;
1410
+ headingLevel = undefined;
1411
+ currentListId = undefined;
1412
+ // Reset paragraph-level background (cbpat) to prevent leaking to next paragraph
1413
+ formatting.backgroundColor = undefined;
1414
+ }
1415
+ // Text flow control
1416
+ else if (node.value === 'tab') {
1417
+ if (formattingChanged(currentFormatting, formatting)) {
1418
+ flushRun();
1419
+ currentFormatting = { ...formatting };
1420
+ }
1421
+ currentRunTextChunks.push('\t');
1422
+ }
1423
+ else if (node.value === 'line') {
1424
+ if (formattingChanged(currentFormatting, formatting)) {
1425
+ flushRun();
1426
+ currentFormatting = { ...formatting };
1427
+ }
1428
+ currentRunTextChunks.push('\n');
1429
+ }
1430
+ // Quote characters
1431
+ else if (node.value === 'lquote') {
1432
+ // Left single quotation mark (U+2018)
1433
+ if (formattingChanged(currentFormatting, formatting)) {
1434
+ flushRun();
1435
+ currentFormatting = { ...formatting };
1436
+ }
1437
+ currentRunTextChunks.push('\u2018');
1438
+ }
1439
+ else if (node.value === 'rquote') {
1440
+ // Right single quotation mark (U+2019)
1441
+ if (formattingChanged(currentFormatting, formatting)) {
1442
+ flushRun();
1443
+ currentFormatting = { ...formatting };
1444
+ }
1445
+ currentRunTextChunks.push('\u2019');
1446
+ }
1447
+ else if (node.value === 'ldblquote') {
1448
+ // Left double quotation mark (U+201C)
1449
+ if (formattingChanged(currentFormatting, formatting)) {
1450
+ flushRun();
1451
+ currentFormatting = { ...formatting };
1452
+ }
1453
+ currentRunTextChunks.push('\u201C');
1454
+ }
1455
+ else if (node.value === 'rdblquote') {
1456
+ // Right double quotation mark (U+201D)
1457
+ if (formattingChanged(currentFormatting, formatting)) {
1458
+ flushRun();
1459
+ currentFormatting = { ...formatting };
1460
+ }
1461
+ currentRunTextChunks.push('\u201D');
1462
+ }
1463
+ // Unicode character
1464
+ else if (node.value === 'u') {
1465
+ if (node.param !== undefined) {
1466
+ let code = node.param;
1467
+ if (code < 0)
1468
+ code += 65536;
1469
+ if (formattingChanged(currentFormatting, formatting)) {
1470
+ flushRun();
1471
+ currentFormatting = { ...formatting };
1472
+ }
1473
+ currentRunTextChunks.push(String.fromCharCode(code));
1474
+ }
1475
+ }
1476
+ // Character formatting
1477
+ else if (node.value === 'b') {
1478
+ formatting.bold = (node.param !== 0);
1479
+ }
1480
+ else if (node.value === 'i') {
1481
+ formatting.italic = (node.param !== 0);
1482
+ }
1483
+ else if (node.value === 'ul') {
1484
+ formatting.underline = (node.param !== 0);
1485
+ }
1486
+ else if (node.value === 'ulnone') {
1487
+ formatting.underline = false;
1488
+ }
1489
+ else if (node.value === 'strike') {
1490
+ formatting.strikethrough = (node.param !== 0);
1491
+ }
1492
+ else if (node.value === 'plain') {
1493
+ // Reset all character formatting
1494
+ formatting.bold = false;
1495
+ formatting.italic = false;
1496
+ formatting.underline = false;
1497
+ formatting.strikethrough = false;
1498
+ formatting.subscript = false;
1499
+ formatting.superscript = false;
1500
+ formatting.size = undefined;
1501
+ formatting.font = undefined;
1502
+ formatting.color = undefined;
1503
+ formatting.backgroundColor = undefined;
1504
+ }
1505
+ // Font size (\fs - in half-points)
1506
+ else if (node.value === 'fs') {
1507
+ if (node.param !== undefined) {
1508
+ formatting.size = (node.param / 2).toString() + 'pt';
1509
+ }
1510
+ }
1511
+ // Font family (\f)
1512
+ else if (node.value === 'f') {
1513
+ if (node.param !== undefined && fontTable[node.param]) {
1514
+ formatting.font = fontTable[node.param];
1515
+ }
1516
+ }
1517
+ // Text color (\cf)
1518
+ else if (node.value === 'cf') {
1519
+ if (node.param !== undefined && colorTable[node.param]) {
1520
+ formatting.color = colorTable[node.param];
1521
+ }
1522
+ }
1523
+ // Note type (\fet)
1524
+ else if (node.value === 'fet') {
1525
+ // \fet0 = footnotes only (default)
1526
+ // \fet1 = endnotes only
1527
+ // \fet2 = both footnotes and endnotes
1528
+ if (node.param !== undefined) {
1529
+ fetValue = node.param;
1530
+ }
1531
+ }
1532
+ // Background/highlight color (\cb, \highlight, \chcbpat, \cbpat)
1533
+ // \chcbpat = character background pattern color (used for shading)
1534
+ // \cbpat = paragraph background pattern color
1535
+ else if (node.value === 'cb' || node.value === 'highlight' || node.value === 'chcbpat' || node.value === 'cbpat') {
1536
+ if (node.param !== undefined && colorTable[node.param]) {
1537
+ formatting.backgroundColor = colorTable[node.param];
1538
+ }
1539
+ }
1540
+ // Subscript
1541
+ else if (node.value === 'sub') {
1542
+ formatting.subscript = true;
1543
+ formatting.superscript = false;
1544
+ }
1545
+ // Superscript
1546
+ else if (node.value === 'super') {
1547
+ formatting.superscript = true;
1548
+ formatting.subscript = false;
1549
+ }
1550
+ // No subscript/superscript
1551
+ else if (node.value === 'nosupersub') {
1552
+ formatting.subscript = false;
1553
+ formatting.superscript = false;
1554
+ }
1555
+ // ═══════════════════════════════════════════════════════════
1556
+ // List control words
1557
+ // ═══════════════════════════════════════════════════════════
1558
+ // Paragraph indentation (\li - left indent in twips)
1559
+ else if (node.value === 'li') {
1560
+ if (node.param !== undefined) {
1561
+ // Standard level indent is 720 twips (0.5 inch)
1562
+ // Using a slightly more flexible divisor to account for different generators
1563
+ const level = Math.round(node.param / 720);
1564
+ // Only update if not already explicitly set by ilvl (Word 97+)
1565
+ if (!isListItem) {
1566
+ paragraphIndent = level;
1567
+ }
1568
+ }
1569
+ }
1570
+ // List style ID (Word 97+)
1571
+ else if (node.value === 'ls') {
1572
+ if (node.param !== undefined) {
1573
+ // If this is the first list item, reset the indent
1574
+ if (!isListItem)
1575
+ paragraphIndent = 0;
1576
+ isListItem = true;
1577
+ // Generate or retrieve list ID
1578
+ if (!listStyleIdMap[node.param]) {
1579
+ listIdCounter++;
1580
+ listStyleIdMap[node.param] = `rtf-list-${listIdCounter}`;
1581
+ }
1582
+ currentListId = listStyleIdMap[node.param];
1583
+ // Look up type from list definition
1584
+ // First check override map to get real list ID
1585
+ const realListId = listOverrideMap[node.param] !== undefined ? listOverrideMap[node.param] : node.param;
1586
+ if (listTypeMap[realListId]) {
1587
+ listType = listTypeMap[realListId];
1588
+ }
1589
+ }
1590
+ }
1591
+ // List indent level (Word 97+)
1592
+ else if (node.value === 'ilvl') {
1593
+ if (node.param !== undefined) {
1594
+ isListItem = true;
1595
+ paragraphIndent = node.param;
1596
+ }
1597
+ }
1598
+ // List numbering level (\pnlvl)
1599
+ else if (node.value === 'pnlvl') {
1600
+ isListItem = true;
1601
+ if (node.param !== undefined) {
1602
+ paragraphIndent = node.param;
1603
+ }
1604
+ }
1605
+ // List numbering format
1606
+ else if (node.value === 'levelnfc' || node.value === 'pnf') {
1607
+ // 0 = Arabic (1, 2, 3), 1 = Roman upper, 2 = Roman lower,
1608
+ // 3 = Letter upper, 4 = Letter lower, 23 = Bullet
1609
+ if (node.param !== undefined) {
1610
+ isListItem = true;
1611
+ if (node.param === 23) {
1612
+ listType = 'unordered';
1613
+ }
1614
+ else {
1615
+ listType = 'ordered';
1616
+ }
1617
+ }
1618
+ }
1619
+ // Ordered list indicator
1620
+ else if (node.value === 'pndec' || node.value === 'pnord' || node.value === 'pnlcltr' || node.value === 'pnucltr') {
1621
+ // If this is the first list item, reset the indent
1622
+ if (!isListItem)
1623
+ paragraphIndent = 0;
1624
+ isListItem = true;
1625
+ listType = 'ordered';
1626
+ }
1627
+ // Unordered list indicator
1628
+ else if (node.value === 'pnbullet' || node.value === 'pncard') {
1629
+ // If this is the first list item, reset the indent
1630
+ if (!isListItem)
1631
+ paragraphIndent = 0;
1632
+ isListItem = true;
1633
+ listType = 'unordered';
1634
+ }
1635
+ // Style-based heading detection (\s)
1636
+ else if (node.value === 's') {
1637
+ if (node.param !== undefined) {
1638
+ // Common heading styles: s1-s9 (though this varies by document)
1639
+ if (node.param >= 1 && node.param <= 9) {
1640
+ headingLevel = node.param;
1641
+ }
1642
+ }
1643
+ }
1644
+ // Paragraph alignment
1645
+ else if (node.value === 'ql') {
1646
+ paragraphAlignment = 'left';
1647
+ }
1648
+ else if (node.value === 'qc') {
1649
+ paragraphAlignment = 'center';
1650
+ }
1651
+ else if (node.value === 'qr') {
1652
+ paragraphAlignment = 'right';
1653
+ }
1654
+ else if (node.value === 'qj') {
1655
+ paragraphAlignment = 'justify';
1656
+ }
1657
+ }
1658
+ };
1659
+ traverse(doc, {});
1660
+ // Flush any remaining table
1661
+ const finalCtx = getCurrentTable();
1662
+ if (inTable || (finalCtx && (finalCtx.rows.length > 0 || finalCtx.currentCells.length > 0))) {
1663
+ flushTable();
1664
+ }
1665
+ flushParagraph();
1666
+ // Perform OCR if enabled
1667
+ if (config.ocr && config.extractAttachments) {
1668
+ for (const attachment of attachments) {
1669
+ (0, errorUtils_js_1.checkAbortSignal)(config.abortSignal);
1670
+ if (attachment.mimeType.startsWith('image/')) {
1671
+ try {
1672
+ // Convert base64 data back to Buffer for Tesseract.js
1673
+ // Passing base64 string directly would be interpreted as a file path,
1674
+ // causing ENAMETOOLONG error for large images.
1675
+ const imageBuffer = Buffer.from(attachment.data, 'base64');
1676
+ attachment.ocrText = (await (0, ocrUtils_js_1.performOcr)(imageBuffer, { ...config.ocrConfig })).trim();
1677
+ }
1678
+ catch (e) {
1679
+ (0, errorUtils_js_1.logWarning)(types_js_1.OfficeWarningType.OCR_FAILED, config, attachment.name, e);
1680
+ }
1681
+ }
1682
+ }
1683
+ // Link OCR text and altText to image nodes in content
1684
+ const assignOcr = (nodes) => {
1685
+ for (const node of nodes) {
1686
+ if (node.type === 'image' && node.metadata && 'attachmentName' in node.metadata) {
1687
+ const meta = node.metadata;
1688
+ const attachment = attachments.find(a => a.name === meta.attachmentName);
1689
+ if (attachment) {
1690
+ // Propagate OCR text to image node
1691
+ if (attachment.ocrText) {
1692
+ node.text = attachment.ocrText;
1693
+ }
1694
+ // Propagate altText if available
1695
+ if (attachment.altText) {
1696
+ meta.altText = attachment.altText;
1697
+ }
1698
+ }
1699
+ }
1700
+ if (node.children) {
1701
+ assignOcr(node.children);
1702
+ }
1703
+ }
1704
+ };
1705
+ assignOcr(content);
1706
+ }
1707
+ // Final pass to ensure all 'note' nodes have their 'text' property populated
1708
+ // (This supports the simple toText implementation)
1709
+ const populateNoteText = (nodes) => {
1710
+ for (const node of nodes) {
1711
+ if (node.type === 'note' && node.children) {
1712
+ const getText = (n) => {
1713
+ if (n.children && n.children.length > 0)
1714
+ return n.children.map(getText).join('');
1715
+ return n.text || '';
1716
+ };
1717
+ node.text = node.children.map(getText).join('').trim();
1718
+ }
1719
+ if (node.children) {
1720
+ populateNoteText(node.children);
1721
+ }
1722
+ }
1723
+ };
1724
+ populateNoteText(content);
1725
+ populateNoteText(notes);
1726
+ const toTextSync = () => {
1727
+ return content.map(c => c.text).join(config.newlineDelimiter);
1728
+ };
1729
+ const result = (0, astUtils_js_1.createAST)('rtf', {
1730
+ // RTF Limitation: No style map available (RTF uses inline styles)
1731
+ }, content, attachments, // PNG and JPEG images extracted from \\pict groups
1732
+ config, undefined, toTextSync);
1733
+ return result;
1734
+ };
1735
+ exports.parseRtf = parseRtf;
1736
+ // Helper to find an RTF group by destination name
1737
+ function findRtfGroup(group, destination) {
1738
+ for (const node of group.content) {
1739
+ if (node.type === 'group') {
1740
+ if (node.destination === destination)
1741
+ return node;
1742
+ const found = findRtfGroup(node, destination);
1743
+ if (found)
1744
+ return found;
1745
+ }
1746
+ }
1747
+ return null;
1748
+ }
1749
+ // Helper function to extract font table from RTF document
1750
+ function extractFontTable(doc) {
1751
+ const fontTable = {};
1752
+ const tableGroup = findRtfGroup(doc, 'fonttbl');
1753
+ if (tableGroup) {
1754
+ for (const fontNode of tableGroup.content) {
1755
+ if (fontNode.type === 'group') {
1756
+ let fontIndex;
1757
+ let fontName = '';
1758
+ for (const item of fontNode.content) {
1759
+ if (item.type === 'control' && item.value === 'f') {
1760
+ fontIndex = item.param;
1761
+ }
1762
+ else if (item.type === 'text') {
1763
+ fontName += item.value;
1764
+ }
1765
+ }
1766
+ if (fontIndex !== undefined && fontName) {
1767
+ fontTable[fontIndex] = fontName.replace(/;$/, '').trim();
1768
+ }
1769
+ }
1770
+ }
1771
+ }
1772
+ return fontTable;
1773
+ }
1774
+ // Helper function to extract color table from RTF document
1775
+ function extractColorTable(doc) {
1776
+ const colorTable = {};
1777
+ const tableGroup = findRtfGroup(doc, 'colortbl');
1778
+ if (tableGroup) {
1779
+ let colorIndex = 0;
1780
+ let red = 0, green = 0, blue = 0;
1781
+ for (const item of tableGroup.content) {
1782
+ if (item.type === 'control') {
1783
+ if (item.value === 'red' && item.param !== undefined)
1784
+ red = item.param;
1785
+ else if (item.value === 'green' && item.param !== undefined)
1786
+ green = item.param;
1787
+ else if (item.value === 'blue' && item.param !== undefined)
1788
+ blue = item.param;
1789
+ }
1790
+ else if (item.type === 'text' && item.value === ';') {
1791
+ const hex = `#${red.toString(16).padStart(2, '0')}${green.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`;
1792
+ colorTable[colorIndex] = hex;
1793
+ colorIndex++;
1794
+ red = 0;
1795
+ green = 0;
1796
+ blue = 0;
1797
+ }
1798
+ }
1799
+ }
1800
+ return colorTable;
1801
+ }