@bendyline/squisq-formats 2.1.0 → 2.3.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +19 -0
  3. package/README.md +2 -2
  4. package/dist/{chunk-NNHKUXKA.js → chunk-26ISNJ7Y.js} +85 -65
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-KURGXM4I.js → chunk-4V3KCHAP.js} +3 -4
  7. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  8. package/dist/{chunk-EW54IRRS.js → chunk-6S6GU3ZG.js} +5 -6
  9. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  10. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  11. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  12. package/dist/{chunk-SC67HYQJ.js → chunk-EJTNGKEA.js} +5 -8
  13. package/dist/{chunk-WQSHGBLN.js → chunk-G6J326JW.js} +193 -42
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-GVS2XXV6.js → chunk-GYIVES2E.js} +485 -59
  16. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  17. package/dist/{chunk-RLU7UFYU.js → chunk-IPN56VLW.js} +83 -58
  18. package/dist/{chunk-DTDF6QDP.js → chunk-JE6LSIHE.js} +81 -20
  19. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  20. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  21. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  22. package/dist/chunk-OGS5VCGJ.js +446 -0
  23. package/dist/{chunk-74GO3FVS.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-QFLDYKCR.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-7ARKUCQT.js → chunk-X2DEAXNK.js} +54 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -11
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +10 -4
  61. package/dist/chunk-4VUWTSGM.js.map +0 -1
  62. package/dist/chunk-6M7Z25LA.js +0 -46
  63. package/dist/chunk-6M7Z25LA.js.map +0 -1
  64. package/dist/chunk-74GO3FVS.js.map +0 -1
  65. package/dist/chunk-7ARKUCQT.js.map +0 -1
  66. package/dist/chunk-DTDF6QDP.js.map +0 -1
  67. package/dist/chunk-EW54IRRS.js.map +0 -1
  68. package/dist/chunk-FE6OJV6O.js.map +0 -1
  69. package/dist/chunk-GVS2XXV6.js.map +0 -1
  70. package/dist/chunk-KURGXM4I.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-NNHKUXKA.js.map +0 -1
  74. package/dist/chunk-O3GVVND4.js.map +0 -1
  75. package/dist/chunk-ODL3SSPT.js.map +0 -1
  76. package/dist/chunk-PN52A5AA.js.map +0 -1
  77. package/dist/chunk-QFLDYKCR.js.map +0 -1
  78. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  79. package/dist/chunk-RLU7UFYU.js.map +0 -1
  80. package/dist/chunk-SC67HYQJ.js.map +0 -1
  81. package/dist/chunk-SSUPBUF5.js.map +0 -1
  82. package/dist/chunk-U4MRIFKL.js.map +0 -1
  83. package/dist/chunk-UGYF5AZE.js +0 -275
  84. package/dist/chunk-UGYF5AZE.js.map +0 -1
  85. package/dist/chunk-WQSHGBLN.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-OJ7ZQV6P.js +0 -15
  93. package/dist/extract-OJ7ZQV6P.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-5VDIRPIJ.js +0 -12
  100. package/dist/layouts-5VDIRPIJ.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -491
  111. package/src/__tests__/docxImport.test.ts +0 -531
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -439
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1375
  143. package/src/docx/import.ts +0 -1250
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -217
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
package/src/pdf/import.ts DELETED
@@ -1,1164 +0,0 @@
1
- /**
2
- * PDF Import
3
- *
4
- * Parses a PDF file and converts its content into a squisq
5
- * MarkdownDocument (or Doc) using heuristic detection of headings,
6
- * lists, code blocks, tables, blockquotes, and hyperlinks.
7
- *
8
- * Uses pdfjs-dist (Mozilla pdf.js) for text extraction — a battle-tested,
9
- * browser-compatible PDF parser. Since PDFs encode positioned glyphs
10
- * rather than semantic structure, all structure detection is inherently
11
- * heuristic and works best on simply-formatted documents.
12
- *
13
- * @example
14
- * ```ts
15
- * import { pdfToMarkdownDoc } from '@bendyline/squisq-formats/pdf';
16
- *
17
- * const response = await fetch('document.pdf');
18
- * const data = await response.arrayBuffer();
19
- * const doc = await pdfToMarkdownDoc(data);
20
- * ```
21
- */
22
-
23
- import type { Doc } from '@bendyline/squisq/schemas';
24
- import { markdownToDoc } from '@bendyline/squisq/doc';
25
- import { stringifyMarkdown } from '@bendyline/squisq/markdown';
26
- import type {
27
- MarkdownDocument,
28
- MarkdownBlockNode,
29
- MarkdownInlineNode,
30
- MarkdownHeading,
31
- MarkdownParagraph,
32
- MarkdownBlockquote,
33
- MarkdownList,
34
- MarkdownListItem,
35
- MarkdownCodeBlock,
36
- MarkdownTable,
37
- MarkdownTableRow,
38
- MarkdownTableCell,
39
- MarkdownText,
40
- MarkdownEmphasis,
41
- MarkdownStrong,
42
- MarkdownInlineCode,
43
- MarkdownLink,
44
- MarkdownImage,
45
- } from '@bendyline/squisq/markdown';
46
-
47
- import type { ContentContainer } from '@bendyline/squisq/storage';
48
- import { buildContainer } from '../shared/container.js';
49
-
50
- import {
51
- DEFAULT_FONT_SIZE,
52
- IMPORT_HEADING_MIN_SIZE,
53
- IMPORT_HEADING_SIZE_RANGES,
54
- IMPORT_PARAGRAPH_GAP,
55
- IMPORT_BULLET_CHARS,
56
- IMPORT_ORDERED_PREFIX,
57
- IMPORT_COLUMN_TOLERANCE,
58
- IMPORT_TABLE_MIN_ROWS,
59
- IMPORT_URL_PATTERN,
60
- } from './styles.js';
61
-
62
- // ============================================
63
- // Public API
64
- // ============================================
65
-
66
- /**
67
- * Options for PDF import.
68
- */
69
- export interface PdfImportOptions {
70
- /**
71
- * Hint for the body font size used in the PDF (in points).
72
- * Text items larger than this are considered headings.
73
- * If not provided, the importer detects the most common font size.
74
- */
75
- bodyFontSize?: number;
76
-
77
- /** Whether to detect tables from column-aligned text. Default: true. */
78
- detectTables?: boolean;
79
-
80
- /** Whether to detect code blocks from monospace fonts. Default: true. */
81
- detectCodeBlocks?: boolean;
82
-
83
- /** Whether to detect blockquotes from indentation. Default: true. */
84
- detectBlockquotes?: boolean;
85
-
86
- /** Whether to detect URLs in text and convert to links. Default: true. */
87
- detectLinks?: boolean;
88
- }
89
-
90
- /**
91
- * Convert a PDF file to a MarkdownDocument.
92
- *
93
- * Structure detection is heuristic — results are best-effort.
94
- *
95
- * @param data - The raw PDF file as ArrayBuffer, Uint8Array, or Blob
96
- * @param options - Import options
97
- * @returns A MarkdownDocument representing the detected content
98
- */
99
- export async function pdfToMarkdownDoc(
100
- data: ArrayBuffer | Uint8Array | Blob,
101
- options: PdfImportOptions = {},
102
- ): Promise<MarkdownDocument> {
103
- const bytes =
104
- data instanceof Blob
105
- ? new Uint8Array(await data.arrayBuffer())
106
- : data instanceof ArrayBuffer
107
- ? new Uint8Array(data)
108
- : data;
109
-
110
- const loaded = await loadPdfDocument(bytes);
111
- try {
112
- const textLines = await extractTextLines(loaded.pdf);
113
-
114
- if (textLines.length === 0) {
115
- return { type: 'document', children: [] };
116
- }
117
-
118
- const bodySize = options.bodyFontSize ?? detectBodyFontSize(textLines);
119
- const blocks = classifyLines(textLines, bodySize, options);
120
-
121
- return { type: 'document', children: blocks };
122
- } finally {
123
- await loaded.pdf.destroy?.();
124
- }
125
- }
126
-
127
- /**
128
- * Convert a PDF file to a squisq Doc.
129
- *
130
- * Convenience wrapper: PDF → MarkdownDocument → Doc.
131
- */
132
- export async function pdfToDoc(
133
- data: ArrayBuffer | Uint8Array | Blob,
134
- options: PdfImportOptions = {},
135
- ): Promise<Doc> {
136
- const markdownDoc = await pdfToMarkdownDoc(data, options);
137
- return markdownToDoc(markdownDoc);
138
- }
139
-
140
- /**
141
- * Convert a PDF file to a ContentContainer with markdown + extracted images.
142
- *
143
- * The container will contain:
144
- * - The primary markdown document (index.md)
145
- * - Any embedded images under images/ (e.g., images/image1.png)
146
- *
147
- * Image extraction uses pdfjs-dist's operator list API and requires a browser
148
- * environment (canvas is used to encode pixel data to PNG).
149
- *
150
- * @param data - The raw PDF file as ArrayBuffer, Uint8Array, or Blob
151
- * @param options - Import options
152
- * @returns A ContentContainer with the document and its media
153
- */
154
- export async function pdfToContainer(
155
- data: ArrayBuffer | Uint8Array | Blob,
156
- options: PdfImportOptions = {},
157
- ): Promise<ContentContainer> {
158
- const bytes =
159
- data instanceof Blob
160
- ? new Uint8Array(await data.arrayBuffer())
161
- : data instanceof ArrayBuffer
162
- ? new Uint8Array(data)
163
- : data;
164
-
165
- const loaded = await loadPdfDocument(bytes);
166
- let textLines: TextLine[];
167
- let images: ExtractedImage[];
168
- try {
169
- [textLines, images] = await Promise.all([extractTextLines(loaded.pdf), extractImages(loaded)]);
170
- } finally {
171
- await loaded.pdf.destroy?.();
172
- }
173
-
174
- // Under Node there is no DOM/canvas, so extractImages() returns [] via its
175
- // environment guard and embedded images can't be decoded. The registry's
176
- // `importContainer` wrapper surfaces this (typeof document === 'undefined')
177
- // as a ConversionResult warning; direct callers of pdfToContainer get no
178
- // signal, which is an accepted trade-off for the degraded Node path.
179
-
180
- const bodySize = options.bodyFontSize ?? detectBodyFontSize(textLines);
181
-
182
- // Classify text into blocks, capturing the originating page of each block
183
- // (parallel array) so images can be placed on their own page rather than
184
- // dumped at the document end.
185
- const blockPages: number[] = [];
186
- let blocks = classifyLines(textLines, bodySize, options, blockPages);
187
- if (images.length > 0) {
188
- blocks = insertImageBlocks(blocks, blockPages, images);
189
- }
190
-
191
- const markdownDoc: MarkdownDocument = { type: 'document', children: blocks };
192
-
193
- // pdf image extraction only ever produces PNG (canvas re-encode), so every
194
- // entry gets image/png.
195
- return buildContainer(
196
- stringifyMarkdown(markdownDoc),
197
- images.map((img) => [img.path, { data: img.data, mimeType: 'image/png' }] as const),
198
- );
199
- }
200
-
201
- /**
202
- * Extracted image with position info for placement.
203
- *
204
- * `page` is captured reliably from the operator-list walk. `y` is currently
205
- * always 0 — recovering a real y within the page would require tracking the
206
- * current transformation matrix (CTM) during the paint operator, which is out
207
- * of scope here (see the placement note in `insertImageBlocks`).
208
- *
209
- * Exported for direct unit testing of `insertImageBlocks`.
210
- */
211
- export interface ExtractedImage {
212
- path: string;
213
- data: ArrayBuffer;
214
- page: number;
215
- y: number;
216
- }
217
-
218
- /**
219
- * Extract embedded images from a PDF using pdfjs-dist operator list API.
220
- * Requires browser canvas for PNG encoding.
221
- */
222
- async function extractImages(loaded: LoadedPdf): Promise<ExtractedImage[]> {
223
- // Canvas is required for PNG encoding — skip in non-browser environments
224
- if (typeof document === 'undefined') return [];
225
-
226
- const { pdfjsLib, pdf } = loaded;
227
-
228
- const OPS_paintImageXObject = pdfjsLib.OPS?.paintImageXObject ?? 85;
229
-
230
- const images: ExtractedImage[] = [];
231
- let counter = 0;
232
-
233
- for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
234
- const page = (await pdf.getPage(pageNum)) as PdfjsPageFull;
235
- if (!page.getOperatorList) continue;
236
-
237
- const opList = await page.getOperatorList();
238
- const seen = new Set<string>();
239
-
240
- for (let i = 0; i < opList.fnArray.length; i++) {
241
- if (opList.fnArray[i] !== OPS_paintImageXObject) continue;
242
-
243
- const imgName = opList.argsArray[i]?.[0];
244
- if (!imgName || typeof imgName !== 'string' || seen.has(imgName)) continue;
245
- seen.add(imgName);
246
-
247
- try {
248
- const imgData = page.objs?.get(imgName) as PdfjsImageData | null;
249
- if (!imgData?.data || !imgData.width || !imgData.height) continue;
250
-
251
- const pngData = imageDataToPng(imgData);
252
- if (!pngData) continue;
253
-
254
- counter++;
255
- images.push({
256
- path: `images/image${counter}.png`,
257
- data: pngData,
258
- page: pageNum - 1,
259
- y: 0,
260
- });
261
- } catch {
262
- // Skip images that fail to extract
263
- }
264
- }
265
- }
266
-
267
- return images;
268
- }
269
-
270
- /** Minimal pdfjs image data shape. */
271
- interface PdfjsImageData {
272
- width: number;
273
- height: number;
274
- data: Uint8ClampedArray;
275
- kind?: number;
276
- }
277
-
278
- /** Extended PdfjsPage with operator list and objs access. */
279
- interface PdfjsPageFull extends PdfjsPage {
280
- getOperatorList(): Promise<{ fnArray: number[]; argsArray: unknown[][] }>;
281
- objs?: { get(name: string): unknown; has?(name: string): boolean };
282
- }
283
-
284
- /** Encode pdfjs image data to PNG using a canvas element. */
285
- function imageDataToPng(img: PdfjsImageData): ArrayBuffer | null {
286
- try {
287
- const canvas = document.createElement('canvas');
288
- canvas.width = img.width;
289
- canvas.height = img.height;
290
- const ctx = canvas.getContext('2d');
291
- if (!ctx) return null;
292
-
293
- // pdfjs kind=1 is GRAYSCALE, kind=2 is RGB, kind=3 is RGBA
294
- let imageData: ImageData;
295
- if (img.kind === 3 || img.data.length === img.width * img.height * 4) {
296
- // RGBA — use directly
297
- imageData = new ImageData(new Uint8ClampedArray(img.data), img.width, img.height);
298
- } else if (img.kind === 2 || img.data.length === img.width * img.height * 3) {
299
- // RGB — expand to RGBA
300
- const rgba = new Uint8ClampedArray(img.width * img.height * 4);
301
- for (let j = 0, k = 0; j < img.data.length; j += 3, k += 4) {
302
- rgba[k] = img.data[j];
303
- rgba[k + 1] = img.data[j + 1];
304
- rgba[k + 2] = img.data[j + 2];
305
- rgba[k + 3] = 255;
306
- }
307
- imageData = new ImageData(rgba, img.width, img.height);
308
- } else if (img.kind === 1 || img.data.length === img.width * img.height) {
309
- // Grayscale — expand to RGBA
310
- const rgba = new Uint8ClampedArray(img.width * img.height * 4);
311
- for (let j = 0, k = 0; j < img.data.length; j++, k += 4) {
312
- rgba[k] = img.data[j];
313
- rgba[k + 1] = img.data[j];
314
- rgba[k + 2] = img.data[j];
315
- rgba[k + 3] = 255;
316
- }
317
- imageData = new ImageData(rgba, img.width, img.height);
318
- } else {
319
- return null;
320
- }
321
-
322
- ctx.putImageData(imageData, 0, 0);
323
-
324
- // Convert canvas to PNG ArrayBuffer
325
- const dataUrl = canvas.toDataURL('image/png');
326
- const base64 = dataUrl.split(',')[1];
327
- const binaryStr = atob(base64);
328
- const bytes = new Uint8Array(binaryStr.length);
329
- for (let i = 0; i < binaryStr.length; i++) {
330
- bytes[i] = binaryStr.charCodeAt(i);
331
- }
332
- return bytes.buffer;
333
- } catch {
334
- return null;
335
- }
336
- }
337
-
338
- /** Wrap an extracted image as a paragraph containing an image node. */
339
- function imageParagraph(img: ExtractedImage): MarkdownParagraph {
340
- const imgNode: MarkdownImage = {
341
- type: 'image',
342
- url: img.path,
343
- alt: `Image ${img.path.replace('images/image', '').replace('.png', '')}`,
344
- };
345
- return { type: 'paragraph', children: [imgNode] };
346
- }
347
-
348
- /**
349
- * Insert image reference blocks among the text blocks, page by page.
350
- *
351
- * Each image is placed immediately after the LAST content block that
352
- * originated from the same page (per the parallel `blockPages` array).
353
- * Images on a page that produced no text blocks (e.g. an image-only page)
354
- * fall back to the last block of the nearest preceding page that did; if no
355
- * such page exists, they are appended at the document end.
356
- *
357
- * Placement is intentionally page-level only. Ordering *within* a page follows
358
- * the extraction order of the images (roughly the paint-operator order) — real
359
- * vertical (y) ordering within a page is future work, since `ExtractedImage.y`
360
- * is not yet populated (it needs CTM tracking during the paint operator).
361
- *
362
- * @param blocks - The classified content blocks, in document order.
363
- * @param blockPages - Parallel array: `blockPages[i]` is the 0-based page that
364
- * `blocks[i]` came from. Must be the same length as `blocks`.
365
- * @param images - Extracted images with a reliable `page` field.
366
- */
367
- export function insertImageBlocks(
368
- blocks: MarkdownBlockNode[],
369
- blockPages: number[],
370
- images: ExtractedImage[],
371
- ): MarkdownBlockNode[] {
372
- if (images.length === 0) return blocks;
373
-
374
- // No text blocks at all → every image simply appends in order.
375
- if (blocks.length === 0) {
376
- return images.map(imageParagraph);
377
- }
378
-
379
- // Map each page → index of its LAST block, and remember which pages have
380
- // blocks (sorted ascending) for the image-only-page fallback lookup.
381
- const lastBlockIndexByPage = new Map<number, number>();
382
- for (let i = 0; i < blocks.length; i++) {
383
- lastBlockIndexByPage.set(blockPages[i], i);
384
- }
385
- const pagesWithBlocks = [...lastBlockIndexByPage.keys()].sort((a, b) => a - b);
386
-
387
- const lastIndex = blocks.length - 1;
388
-
389
- /** Resolve the block index after which an image on `page` should be inserted. */
390
- const anchorFor = (page: number): number => {
391
- const direct = lastBlockIndexByPage.get(page);
392
- if (direct !== undefined) return direct;
393
- // Image-only page: fall back to the nearest preceding page with blocks.
394
- let anchor = -1;
395
- for (const p of pagesWithBlocks) {
396
- if (p < page) anchor = lastBlockIndexByPage.get(p)!;
397
- else break;
398
- }
399
- // No preceding page with blocks → append at the document end.
400
- return anchor === -1 ? lastIndex : anchor;
401
- };
402
-
403
- // Group image paragraphs by the block index they should follow, preserving
404
- // image order within each group.
405
- const insertAfter = new Map<number, MarkdownParagraph[]>();
406
- for (const img of images) {
407
- const anchor = anchorFor(img.page);
408
- const group = insertAfter.get(anchor);
409
- if (group) group.push(imageParagraph(img));
410
- else insertAfter.set(anchor, [imageParagraph(img)]);
411
- }
412
-
413
- const result: MarkdownBlockNode[] = [];
414
- for (let i = 0; i < blocks.length; i++) {
415
- result.push(blocks[i]);
416
- const imgs = insertAfter.get(i);
417
- if (imgs) result.push(...imgs);
418
- }
419
- return result;
420
- }
421
-
422
- // ============================================
423
- // Internal Types
424
- // ============================================
425
-
426
- /** A single text item extracted from pdfjs. */
427
- interface TextItem {
428
- str: string;
429
- x: number;
430
- y: number;
431
- width: number;
432
- height: number;
433
- /** Internal font ID from pdfjs (e.g. "g_d0_f1") */
434
- fontName: string;
435
- /** Resolved font family from pdfjs styles (e.g. "sans-serif", "monospace") */
436
- fontFamily: string;
437
- }
438
-
439
- /** A logical line: text items at roughly the same y-coordinate. */
440
- interface TextLine {
441
- items: TextItem[];
442
- y: number;
443
- /** The page this line is on (0-based). */
444
- page: number;
445
- /** The predominant font size on this line. */
446
- fontSize: number;
447
- /** The predominant font family on this line. */
448
- fontFamily: string;
449
- /** The predominant font ID on this line (may contain bold/italic hints for embedded fonts). */
450
- fontName: string;
451
- /** The minimum x position (left edge). */
452
- minX: number;
453
- /** Full concatenated text. */
454
- text: string;
455
- }
456
-
457
- // ============================================
458
- // PDF Text Extraction (pdfjs-dist)
459
- // ============================================
460
-
461
- /**
462
- * Configure the pdfjs-dist PDF worker source URL.
463
- *
464
- * pdfjs-dist requires a worker for PDF parsing. In the **browser**, bundlers
465
- * (Vite, webpack) typically handle this automatically, or you can point to a
466
- * CDN-hosted worker script. In **Node.js / SSR / test** environments, call
467
- * this with a `file://` URL to the worker module **before** any import call.
468
- *
469
- * @example
470
- * ```ts
471
- * // Browser — CDN
472
- * configurePdfWorker('https://cdn.jsdelivr.net/npm/pdfjs-dist@4/legacy/build/pdf.worker.min.mjs');
473
- *
474
- * // Node / vitest — file URL
475
- * import { pathToFileURL } from 'url';
476
- * configurePdfWorker(pathToFileURL(require.resolve('pdfjs-dist/legacy/build/pdf.worker.mjs')).href);
477
- * ```
478
- */
479
- export function configurePdfWorker(workerSrc: string): void {
480
- _workerSrc = workerSrc;
481
- }
482
-
483
- /** Module-level storage for the worker source URL. */
484
- let _workerSrc: string | undefined;
485
-
486
- /** Minimal typed surface of the pdfjs-dist library used by the import path. */
487
- interface PdfjsLib {
488
- GlobalWorkerOptions?: { workerSrc?: string };
489
- getDocument(params: { data: Uint8Array; isEvalSupported?: boolean; useSystemFonts?: boolean }): {
490
- promise: Promise<PdfjsDocument>;
491
- };
492
- }
493
-
494
- interface PdfjsDocument {
495
- numPages: number;
496
- getPage(pageNum: number): Promise<PdfjsPage>;
497
- destroy?(): Promise<void>;
498
- }
499
-
500
- interface LoadedPdf {
501
- pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
502
- pdf: PdfjsDocument;
503
- }
504
-
505
- interface PdfjsPage {
506
- getTextContent(): Promise<{
507
- items: Array<{
508
- str: string;
509
- transform: number[];
510
- height: number;
511
- width?: number;
512
- fontName?: string;
513
- }>;
514
- styles?: Record<string, { fontFamily?: string }>;
515
- }>;
516
- }
517
-
518
- async function applyWorkerConfig(pdfjsLib: PdfjsLib): Promise<void> {
519
- if (!pdfjsLib.GlobalWorkerOptions) return;
520
- if (pdfjsLib.GlobalWorkerOptions.workerSrc) return;
521
-
522
- if (_workerSrc) {
523
- pdfjsLib.GlobalWorkerOptions.workerSrc = _workerSrc;
524
- }
525
- // If no workerSrc is set, pdfjs-dist's legacy build will attempt its
526
- // built-in fake-worker fallback. In browsers this usually works; in
527
- // Node.js the caller must have called configurePdfWorker() first.
528
- }
529
-
530
- async function loadPdfDocument(data: Uint8Array): Promise<LoadedPdf> {
531
- // Dynamic import — the legacy build bundles a fake-worker fallback
532
- // that avoids a real Web Worker in environments that don't support it.
533
- let pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
534
- try {
535
- pdfjsLib = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as unknown as PdfjsLib & {
536
- OPS?: Record<string, number>;
537
- };
538
- } catch {
539
- pdfjsLib = (await import('pdfjs-dist')) as unknown as PdfjsLib & {
540
- OPS?: Record<string, number>;
541
- };
542
- }
543
-
544
- await applyWorkerConfig(pdfjsLib);
545
-
546
- const loadingTask = pdfjsLib.getDocument({
547
- data,
548
- isEvalSupported: false,
549
- useSystemFonts: true,
550
- });
551
-
552
- return { pdfjsLib, pdf: await loadingTask.promise };
553
- }
554
-
555
- async function extractTextLines(pdf: PdfjsDocument): Promise<TextLine[]> {
556
- const allLines: TextLine[] = [];
557
-
558
- for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
559
- const page = await pdf.getPage(pageNum);
560
- const content = await page.getTextContent();
561
-
562
- // Build a fontName → fontFamily lookup from pdfjs styles
563
- const styleMap = content.styles || {};
564
-
565
- // Group text items into lines by y-coordinate
566
- const items: TextItem[] = [];
567
- for (const item of content.items) {
568
- if (!item.str || item.str.trim().length === 0) continue;
569
- const transform = item.transform || [1, 0, 0, 1, 0, 0];
570
- const x = transform[4];
571
- const y = transform[5];
572
- const height = Math.abs(transform[3]) || item.height || 12;
573
- const width = item.width || 0;
574
- const fontName = item.fontName || '';
575
- const fontFamily = styleMap[fontName]?.fontFamily || '';
576
- items.push({ str: item.str, x, y, width, height, fontName, fontFamily });
577
- }
578
-
579
- // Group into lines (items within 2pt of same y are same line)
580
- const lineMap = new Map<number, TextItem[]>();
581
- for (const item of items) {
582
- const roundedY = Math.round(item.y * 2) / 2;
583
- let foundKey: number | undefined;
584
- for (const key of lineMap.keys()) {
585
- if (Math.abs(key - roundedY) < 2) {
586
- foundKey = key;
587
- break;
588
- }
589
- }
590
- if (foundKey !== undefined) {
591
- lineMap.get(foundKey)!.push(item);
592
- } else {
593
- lineMap.set(roundedY, [item]);
594
- }
595
- }
596
-
597
- // Sort lines top-to-bottom (highest y first), items left-to-right
598
- const sortedKeys = [...lineMap.keys()].sort((a, b) => b - a);
599
- for (const key of sortedKeys) {
600
- const lineItems = lineMap.get(key)!.sort((a, b) => a.x - b.x);
601
-
602
- const fontSizes = lineItems.map((i) => i.height);
603
- const fontSize = mode(fontSizes) || 12;
604
- const fontFamilies = lineItems.map((i) => i.fontFamily);
605
- const fontFamily = modeStr(fontFamilies) || '';
606
- const fontNames = lineItems.map((i) => i.fontName);
607
- const fontName = modeStr(fontNames) || '';
608
- const minX = Math.min(...lineItems.map((i) => i.x));
609
- const text = lineItems.map((i) => i.str).join(' ');
610
-
611
- allLines.push({
612
- items: lineItems,
613
- y: key,
614
- page: pageNum - 1,
615
- fontSize,
616
- fontFamily,
617
- fontName,
618
- minX,
619
- text,
620
- });
621
- }
622
- }
623
-
624
- return allLines;
625
- }
626
-
627
- // ============================================
628
- // Font Size Detection
629
- // ============================================
630
-
631
- function detectBodyFontSize(lines: TextLine[]): number {
632
- const sizes = lines.map((l) => Math.round(l.fontSize * 2) / 2);
633
- return mode(sizes) || DEFAULT_FONT_SIZE;
634
- }
635
-
636
- function mode(arr: number[]): number {
637
- const freq = new Map<number, number>();
638
- for (const v of arr) freq.set(v, (freq.get(v) || 0) + 1);
639
- let maxCount = 0;
640
- let maxVal = 0;
641
- for (const [v, c] of freq) {
642
- if (c > maxCount) {
643
- maxCount = c;
644
- maxVal = v;
645
- }
646
- }
647
- return maxVal;
648
- }
649
-
650
- function modeStr(arr: string[]): string {
651
- const freq = new Map<string, number>();
652
- for (const v of arr) freq.set(v, (freq.get(v) || 0) + 1);
653
- let maxCount = 0;
654
- let maxVal = '';
655
- for (const [v, c] of freq) {
656
- if (c > maxCount) {
657
- maxCount = c;
658
- maxVal = v;
659
- }
660
- }
661
- return maxVal;
662
- }
663
-
664
- // ============================================
665
- // Line Classification → MarkdownBlockNode[]
666
- // ============================================
667
-
668
- function classifyLines(
669
- lines: TextLine[],
670
- bodySize: number,
671
- options: PdfImportOptions,
672
- /**
673
- * Optional out-parameter: when provided, receives one entry per produced
674
- * block giving the 0-based page that block originated from. Kept parallel
675
- * to the returned block array so image placement can be page-aware without
676
- * mutating the (strictly-typed) block nodes themselves.
677
- */
678
- blockPages?: number[],
679
- ): MarkdownBlockNode[] {
680
- const blocks: MarkdownBlockNode[] = [];
681
- /** Push a block and record its originating page in the parallel array. */
682
- const pushBlock = (block: MarkdownBlockNode, page: number): void => {
683
- blocks.push(block);
684
- if (blockPages) blockPages.push(page);
685
- };
686
- const detectTables = options.detectTables !== false;
687
- const detectCodeBlocks = options.detectCodeBlocks !== false;
688
- const detectBlockquotes = options.detectBlockquotes !== false;
689
- const _detectLinks = options.detectLinks !== false;
690
-
691
- // Determine typical left margin (most common minX)
692
- const leftMargins = lines.map((l) => Math.round(l.minX));
693
- const typicalLeftMargin = mode(leftMargins) || 72;
694
-
695
- let i = 0;
696
- while (i < lines.length) {
697
- const line = lines[i];
698
-
699
- // --- Heading detection ---
700
- if (line.fontSize >= IMPORT_HEADING_MIN_SIZE && line.fontSize > bodySize + 1) {
701
- const depth = sizeToHeadingDepth(line.fontSize);
702
- pushBlock(
703
- {
704
- type: 'heading',
705
- depth,
706
- children: buildInlineNodes(line, options),
707
- } as MarkdownHeading,
708
- line.page,
709
- );
710
- i++;
711
- continue;
712
- }
713
-
714
- // --- Code block detection (monospace font runs) ---
715
- if (detectCodeBlocks && isMonospaceLine(line)) {
716
- const codeLines: string[] = [];
717
- while (i < lines.length && isMonospaceLine(lines[i])) {
718
- codeLines.push(lines[i].text);
719
- i++;
720
- }
721
- pushBlock(
722
- {
723
- type: 'code',
724
- value: codeLines.join('\n'),
725
- } as MarkdownCodeBlock,
726
- line.page,
727
- );
728
- continue;
729
- }
730
-
731
- // --- Table detection (column-aligned consecutive lines) ---
732
- if (detectTables && i + 1 < lines.length) {
733
- const tableLines = tryDetectTable(lines, i, typicalLeftMargin);
734
- if (tableLines > 0) {
735
- const table = buildTable(lines.slice(i, i + tableLines), options);
736
- if (table) {
737
- pushBlock(table, line.page);
738
- i += tableLines;
739
- continue;
740
- }
741
- }
742
- }
743
-
744
- // --- List detection ---
745
- const bulletMatch = tryMatchBullet(line.text);
746
- const orderedMatch = line.text.match(IMPORT_ORDERED_PREFIX);
747
- if (bulletMatch || orderedMatch) {
748
- const listResult = consumeList(lines, i, typicalLeftMargin, bodySize, options);
749
- pushBlock(listResult.list, line.page);
750
- i = listResult.nextIndex;
751
- continue;
752
- }
753
-
754
- // --- Blockquote detection (indented text) ---
755
- if (detectBlockquotes && line.minX > typicalLeftMargin + 20) {
756
- const quoteLines: TextLine[] = [];
757
- while (
758
- i < lines.length &&
759
- lines[i].minX > typicalLeftMargin + 20 &&
760
- !isMonospaceLine(lines[i]) &&
761
- lines[i].fontSize <= bodySize + 1
762
- ) {
763
- quoteLines.push(lines[i]);
764
- i++;
765
- }
766
- const quoteBlocks: MarkdownBlockNode[] = quoteLines.map(
767
- (ql) =>
768
- ({
769
- type: 'paragraph',
770
- children: buildInlineNodes(ql, options),
771
- }) as MarkdownParagraph,
772
- );
773
- pushBlock(
774
- {
775
- type: 'blockquote',
776
- children: quoteBlocks,
777
- } as MarkdownBlockquote,
778
- line.page,
779
- );
780
- continue;
781
- }
782
-
783
- // --- Regular paragraph ---
784
- // Merge consecutive body-sized lines on the same page with small y-gaps
785
- const paraLines: TextLine[] = [line];
786
- i++;
787
- while (i < lines.length) {
788
- const next = lines[i];
789
- // Same page, same-ish font size, close y (within line-height gap), not bullet/heading
790
- if (
791
- next.page === line.page &&
792
- Math.abs(next.fontSize - bodySize) <= 1 &&
793
- !isMonospaceLine(next) &&
794
- next.minX <= typicalLeftMargin + 15 &&
795
- !tryMatchBullet(next.text) &&
796
- !next.text.match(IMPORT_ORDERED_PREFIX)
797
- ) {
798
- // Check y-gap: lines are sorted top-to-bottom so y decreases
799
- const yGap = paraLines[paraLines.length - 1].y - next.y;
800
- const lineHeight = bodySize * 1.6;
801
- if (yGap > 0 && yGap < lineHeight + IMPORT_PARAGRAPH_GAP) {
802
- paraLines.push(next);
803
- i++;
804
- } else {
805
- break;
806
- }
807
- } else {
808
- break;
809
- }
810
- }
811
-
812
- // Build paragraph from merged lines
813
- const allInlines: MarkdownInlineNode[] = [];
814
- for (let j = 0; j < paraLines.length; j++) {
815
- if (j > 0) {
816
- allInlines.push({ type: 'text', value: ' ' } as MarkdownText);
817
- }
818
- allInlines.push(...buildInlineNodes(paraLines[j], options));
819
- }
820
-
821
- if (allInlines.length > 0) {
822
- pushBlock(
823
- {
824
- type: 'paragraph',
825
- children: mergeAdjacentText(allInlines),
826
- } as MarkdownParagraph,
827
- line.page,
828
- );
829
- }
830
- }
831
-
832
- return blocks;
833
- }
834
-
835
- // ============================================
836
- // Heading Depth Mapping
837
- // ============================================
838
-
839
- function sizeToHeadingDepth(fontSize: number): 1 | 2 | 3 | 4 | 5 | 6 {
840
- for (const range of IMPORT_HEADING_SIZE_RANGES) {
841
- if (fontSize >= range.min) return range.depth as 1 | 2 | 3 | 4 | 5 | 6;
842
- }
843
- return 6;
844
- }
845
-
846
- // ============================================
847
- // Font Heuristics
848
- // ============================================
849
-
850
- /**
851
- * Check if a line is predominantly monospace.
852
- * Uses the resolved fontFamily from pdfjs styles first,
853
- * falls back to fontName pattern matching for embedded fonts.
854
- */
855
- function isMonospaceLine(line: TextLine): boolean {
856
- return isMonospaceFamily(line.fontFamily) || isMonospaceName(line.fontName);
857
- }
858
-
859
- /**
860
- * Check if a text item is monospace.
861
- */
862
- function isMonospaceItem(item: TextItem): boolean {
863
- return isMonospaceFamily(item.fontFamily) || isMonospaceName(item.fontName);
864
- }
865
-
866
- function isMonospaceFamily(fontFamily: string): boolean {
867
- const lower = fontFamily.toLowerCase();
868
- return lower === 'monospace' || lower.includes('monospace');
869
- }
870
-
871
- function isMonospaceName(fontName: string): boolean {
872
- const lower = fontName.toLowerCase();
873
- return (
874
- lower.includes('courier') ||
875
- lower.includes('mono') ||
876
- lower.includes('consolas') ||
877
- lower.includes('menlo') ||
878
- lower.includes('inconsolata') ||
879
- lower.includes('firacode') ||
880
- lower.includes('source code') ||
881
- lower.includes('dejavu sans mono')
882
- );
883
- }
884
-
885
- function isBoldFont(fontName: string): boolean {
886
- const lower = fontName.toLowerCase();
887
- return lower.includes('bold') || lower.includes('black') || lower.includes('heavy');
888
- }
889
-
890
- function isItalicFont(fontName: string): boolean {
891
- const lower = fontName.toLowerCase();
892
- return lower.includes('italic') || lower.includes('oblique') || lower.includes('slanted');
893
- }
894
-
895
- // ============================================
896
- // Inline Node Construction
897
- // ============================================
898
-
899
- function buildInlineNodes(line: TextLine, options: PdfImportOptions): MarkdownInlineNode[] {
900
- const nodes: MarkdownInlineNode[] = [];
901
- const detectLinksOpt = options.detectLinks !== false;
902
-
903
- for (const item of line.items) {
904
- const text = item.str;
905
- if (!text || text.trim().length === 0) continue;
906
-
907
- const bold = isBoldFont(item.fontName);
908
- const italic = isItalicFont(item.fontName);
909
- const mono = isMonospaceItem(item);
910
-
911
- let inlineNodes: MarkdownInlineNode[];
912
-
913
- if (mono) {
914
- inlineNodes = [{ type: 'inlineCode', value: text } as MarkdownInlineCode];
915
- } else if (detectLinksOpt) {
916
- inlineNodes = splitTextWithLinks(text);
917
- } else {
918
- inlineNodes = [{ type: 'text', value: text } as MarkdownText];
919
- }
920
-
921
- // Wrap in formatting
922
- for (const node of inlineNodes) {
923
- let wrapped: MarkdownInlineNode = node;
924
- if (italic) {
925
- wrapped = { type: 'emphasis', children: [wrapped] } as MarkdownEmphasis;
926
- }
927
- if (bold) {
928
- wrapped = { type: 'strong', children: [wrapped] } as MarkdownStrong;
929
- }
930
- nodes.push(wrapped);
931
- }
932
- }
933
-
934
- return nodes;
935
- }
936
-
937
- /**
938
- * Split a text string into text nodes and link nodes wherever
939
- * URL patterns are found.
940
- */
941
- function splitTextWithLinks(text: string): MarkdownInlineNode[] {
942
- const nodes: MarkdownInlineNode[] = [];
943
- let lastIndex = 0;
944
-
945
- // Reset regex state
946
- IMPORT_URL_PATTERN.lastIndex = 0;
947
- let match: RegExpExecArray | null;
948
-
949
- while ((match = IMPORT_URL_PATTERN.exec(text)) !== null) {
950
- // Text before URL
951
- if (match.index > lastIndex) {
952
- nodes.push({ type: 'text', value: text.slice(lastIndex, match.index) } as MarkdownText);
953
- }
954
- // URL as link
955
- const url = match[0];
956
- nodes.push({
957
- type: 'link',
958
- url,
959
- children: [{ type: 'text', value: url } as MarkdownText],
960
- } as MarkdownLink);
961
- lastIndex = match.index + url.length;
962
- }
963
-
964
- // Remaining text
965
- if (lastIndex < text.length) {
966
- nodes.push({ type: 'text', value: text.slice(lastIndex) } as MarkdownText);
967
- }
968
-
969
- return nodes.length > 0 ? nodes : [{ type: 'text', value: text } as MarkdownText];
970
- }
971
-
972
- // ============================================
973
- // List Detection
974
- // ============================================
975
-
976
- function tryMatchBullet(text: string): boolean {
977
- if (text.length === 0) return false;
978
- return IMPORT_BULLET_CHARS.has(text[0]) || IMPORT_BULLET_CHARS.has(text.trimStart()[0]);
979
- }
980
-
981
- function stripBullet(text: string): string {
982
- const trimmed = text.trimStart();
983
- if (IMPORT_BULLET_CHARS.has(trimmed[0])) {
984
- return trimmed.slice(1).trimStart();
985
- }
986
- return text;
987
- }
988
-
989
- function stripOrderedPrefix(text: string): string {
990
- return text.replace(IMPORT_ORDERED_PREFIX, '');
991
- }
992
-
993
- interface ListResult {
994
- list: MarkdownList;
995
- nextIndex: number;
996
- }
997
-
998
- function consumeList(
999
- lines: TextLine[],
1000
- startIdx: number,
1001
- _typicalLeftMargin: number,
1002
- _bodySize: number,
1003
- _options: PdfImportOptions,
1004
- ): ListResult {
1005
- const firstLine = lines[startIdx];
1006
- const isOrdered = !!firstLine.text.match(IMPORT_ORDERED_PREFIX);
1007
- const items: MarkdownListItem[] = [];
1008
- let i = startIdx;
1009
-
1010
- while (i < lines.length) {
1011
- const line = lines[i];
1012
- const isBullet = tryMatchBullet(line.text);
1013
- const isOrd = !!line.text.match(IMPORT_ORDERED_PREFIX);
1014
-
1015
- if (!isBullet && !isOrd) break;
1016
- // All items in one list should be same type
1017
- if (isOrdered && !isOrd) break;
1018
- if (!isOrdered && !isBullet) break;
1019
-
1020
- const cleanText = isOrdered ? stripOrderedPrefix(line.text) : stripBullet(line.text);
1021
- const para: MarkdownParagraph = {
1022
- type: 'paragraph',
1023
- children: splitTextWithLinks(cleanText),
1024
- };
1025
- items.push({
1026
- type: 'listItem',
1027
- children: [para],
1028
- } as MarkdownListItem);
1029
- i++;
1030
- }
1031
-
1032
- return {
1033
- list: {
1034
- type: 'list',
1035
- ordered: isOrdered,
1036
- children: items,
1037
- } as MarkdownList,
1038
- nextIndex: i,
1039
- };
1040
- }
1041
-
1042
- // ============================================
1043
- // Table Detection
1044
- // ============================================
1045
-
1046
- /**
1047
- * Look ahead from index `start` and return the number of consecutive
1048
- * lines that form an aligned table, or 0 if no table detected.
1049
- */
1050
- function tryDetectTable(lines: TextLine[], start: number, _typicalLeftMargin: number): number {
1051
- // A table needs multiple items per line (columns) on consecutive lines
1052
- // with roughly the same x-alignment pattern.
1053
-
1054
- const firstLine = lines[start];
1055
- if (firstLine.items.length < 2) return 0;
1056
-
1057
- const cols = getColumnPositions(firstLine);
1058
- if (cols.length < 2) return 0;
1059
-
1060
- let count = 1;
1061
- for (let i = start + 1; i < lines.length; i++) {
1062
- const line = lines[i];
1063
- if (line.items.length < 2) break;
1064
-
1065
- // Check if this line's columns align with the first line's
1066
- const lineCols = getColumnPositions(line);
1067
- if (lineCols.length !== cols.length) break;
1068
-
1069
- let aligned = true;
1070
- for (let c = 0; c < cols.length; c++) {
1071
- if (Math.abs(lineCols[c] - cols[c]) > IMPORT_COLUMN_TOLERANCE) {
1072
- aligned = false;
1073
- break;
1074
- }
1075
- }
1076
- if (!aligned) break;
1077
- count++;
1078
- }
1079
-
1080
- return count >= IMPORT_TABLE_MIN_ROWS ? count : 0;
1081
- }
1082
-
1083
- function getColumnPositions(line: TextLine): number[] {
1084
- // Cluster item x-positions
1085
- const positions: number[] = [];
1086
- for (const item of line.items) {
1087
- const x = Math.round(item.x);
1088
- // Check if this x is close to an existing column
1089
- let found = false;
1090
- for (const p of positions) {
1091
- if (Math.abs(p - x) < IMPORT_COLUMN_TOLERANCE) {
1092
- found = true;
1093
- break;
1094
- }
1095
- }
1096
- if (!found) positions.push(x);
1097
- }
1098
- return positions.sort((a, b) => a - b);
1099
- }
1100
-
1101
- function buildTable(lines: TextLine[], _options: PdfImportOptions): MarkdownTable | null {
1102
- if (lines.length === 0) return null;
1103
-
1104
- // Use the first line's column positions as anchors
1105
- const cols = getColumnPositions(lines[0]);
1106
- if (cols.length < 2) return null;
1107
-
1108
- const rows: MarkdownTableRow[] = [];
1109
-
1110
- for (let ri = 0; ri < lines.length; ri++) {
1111
- const line = lines[ri];
1112
- const cells: MarkdownTableCell[] = [];
1113
-
1114
- for (let ci = 0; ci < cols.length; ci++) {
1115
- const colLeft = cols[ci] - IMPORT_COLUMN_TOLERANCE;
1116
- const colRight = ci + 1 < cols.length ? cols[ci + 1] - IMPORT_COLUMN_TOLERANCE : Infinity;
1117
-
1118
- // Collect items in this column
1119
- const cellItems = line.items.filter((item) => item.x >= colLeft && item.x < colRight);
1120
- const text = cellItems
1121
- .map((i) => i.str)
1122
- .join(' ')
1123
- .trim();
1124
-
1125
- cells.push({
1126
- type: 'tableCell',
1127
- isHeader: ri === 0,
1128
- children: text.length > 0 ? [{ type: 'text', value: text } as MarkdownText] : [],
1129
- } as MarkdownTableCell);
1130
- }
1131
-
1132
- rows.push({
1133
- type: 'tableRow',
1134
- children: cells,
1135
- } as MarkdownTableRow);
1136
- }
1137
-
1138
- return {
1139
- type: 'table',
1140
- children: rows,
1141
- } as MarkdownTable;
1142
- }
1143
-
1144
- // ============================================
1145
- // Text Merging
1146
- // ============================================
1147
-
1148
- /**
1149
- * Merge adjacent text nodes to reduce fragmentation.
1150
- */
1151
- function mergeAdjacentText(nodes: MarkdownInlineNode[]): MarkdownInlineNode[] {
1152
- if (nodes.length <= 1) return nodes;
1153
-
1154
- const result: MarkdownInlineNode[] = [];
1155
- for (const node of nodes) {
1156
- const prev = result[result.length - 1];
1157
- if (prev && prev.type === 'text' && node.type === 'text') {
1158
- (prev as MarkdownText).value += (node as MarkdownText).value;
1159
- } else {
1160
- result.push(node);
1161
- }
1162
- }
1163
- return result;
1164
- }