@mlightcad/cad-simple-viewer 1.7.8 → 1.8.0-preview.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 (64) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/dist/cad-simple-viewer.js +5535 -5280
  4. package/dist/cad-simple-viewer.umd.cjs +72 -72
  5. package/find.ts +7 -7
  6. package/icons.ts +7 -7
  7. package/lib/editor/input/handler/AcEdInputHandler.d.ts +7 -2
  8. package/lib/editor/input/handler/AcEdInputHandler.d.ts.map +1 -1
  9. package/lib/editor/input/handler/AcEdPointHandler.d.ts +8 -7
  10. package/lib/editor/input/handler/AcEdPointHandler.d.ts.map +1 -1
  11. package/lib/editor/input/handler/AcEdPointHandler.js +126 -30
  12. package/lib/editor/input/handler/AcEdPointHandler.js.map +1 -1
  13. package/lib/editor/input/ui/AcEdCommandLine.d.ts +28 -3
  14. package/lib/editor/input/ui/AcEdCommandLine.d.ts.map +1 -1
  15. package/lib/editor/input/ui/AcEdCommandLine.js +92 -26
  16. package/lib/editor/input/ui/AcEdCommandLine.js.map +1 -1
  17. package/lib/editor/input/ui/AcEdFloatingInput.d.ts +6 -0
  18. package/lib/editor/input/ui/AcEdFloatingInput.d.ts.map +1 -1
  19. package/lib/editor/input/ui/AcEdFloatingInput.js +32 -6
  20. package/lib/editor/input/ui/AcEdFloatingInput.js.map +1 -1
  21. package/lib/editor/input/ui/AcEdFloatingInputBoxes.d.ts +19 -2
  22. package/lib/editor/input/ui/AcEdFloatingInputBoxes.d.ts.map +1 -1
  23. package/lib/editor/input/ui/AcEdFloatingInputBoxes.js +23 -5
  24. package/lib/editor/input/ui/AcEdFloatingInputBoxes.js.map +1 -1
  25. package/lib/editor/input/ui/AcEdFloatingInputTypes.d.ts +18 -0
  26. package/lib/editor/input/ui/AcEdFloatingInputTypes.d.ts.map +1 -1
  27. package/lib/editor/input/ui/AcEdInputManager.d.ts +22 -0
  28. package/lib/editor/input/ui/AcEdInputManager.d.ts.map +1 -1
  29. package/lib/editor/input/ui/AcEdInputManager.js +56 -5
  30. package/lib/editor/input/ui/AcEdInputManager.js.map +1 -1
  31. package/lib/editor/input/ui/AcEdKeyboardUtil.d.ts +28 -0
  32. package/lib/editor/input/ui/AcEdKeyboardUtil.d.ts.map +1 -0
  33. package/lib/editor/input/ui/AcEdKeyboardUtil.js +59 -0
  34. package/lib/editor/input/ui/AcEdKeyboardUtil.js.map +1 -0
  35. package/lib/i18n/ar/jig.d.ts +11 -0
  36. package/lib/i18n/ar/jig.d.ts.map +1 -1
  37. package/lib/i18n/ar/jig.js +9 -1
  38. package/lib/i18n/ar/jig.js.map +1 -1
  39. package/lib/i18n/cs/jig.d.ts +11 -0
  40. package/lib/i18n/cs/jig.d.ts.map +1 -1
  41. package/lib/i18n/cs/jig.js +11 -0
  42. package/lib/i18n/cs/jig.js.map +1 -1
  43. package/lib/i18n/en/jig.d.ts +11 -0
  44. package/lib/i18n/en/jig.d.ts.map +1 -1
  45. package/lib/i18n/en/jig.js +11 -0
  46. package/lib/i18n/en/jig.js.map +1 -1
  47. package/lib/i18n/tr/jig.d.ts +11 -0
  48. package/lib/i18n/tr/jig.d.ts.map +1 -1
  49. package/lib/i18n/tr/jig.js +11 -0
  50. package/lib/i18n/tr/jig.js.map +1 -1
  51. package/lib/i18n/zh/jig.d.ts +11 -0
  52. package/lib/i18n/zh/jig.d.ts.map +1 -1
  53. package/lib/i18n/zh/jig.js +11 -0
  54. package/lib/i18n/zh/jig.js.map +1 -1
  55. package/lib/view/AcEdViewKeyHandler.d.ts +17 -0
  56. package/lib/view/AcEdViewKeyHandler.d.ts.map +1 -1
  57. package/lib/view/AcEdViewKeyHandler.js +44 -1
  58. package/lib/view/AcEdViewKeyHandler.js.map +1 -1
  59. package/package.json +5 -5
  60. package/src/find/AcApFindNavigator.ts +190 -190
  61. package/src/find/findDrawing.ts +211 -211
  62. package/src/find/findText.ts +624 -624
  63. package/src/find/index.ts +2 -2
  64. package/src/ui/icons.ts +411 -411
@@ -1,624 +1,624 @@
1
- /**
2
- * Text search over the entities of one layout (FIND).
3
- *
4
- * Shared by every UI (cad-simple-ui-plugin, the full Vue cad-viewer) so that
5
- * both behave identically. Pure TypeScript with no runtime imports, published
6
- * as the `@mlightcad/cad-simple-viewer/find` subpath.
7
- *
8
- * Searched fields:
9
- * - `TEXT` — `textString`
10
- * - `MTEXT` — `contents` with MTEXT formatting codes removed
11
- * - `ATTRIB` — attribute value (`textString`) of every `INSERT` in the layout
12
- * (the tag is reported for display but is not searched)
13
- * - `TEXT` / `MTEXT` / `ATTRIB` inside the block definition of every `INSERT`
14
- * (nested blocks included, anonymous/dynamic blocks too): title blocks,
15
- * legends and labels are usually drawn this way
16
- * - constant `ATTDEF` values inside block definitions (the only ATTDEFs that
17
- * are drawn inside a block; templates get their value from an `ATTRIB`)
18
- * - `DIMENSION` — the text of its dimension block when that block has a
19
- * match, otherwise the override text (`dimensionText`)
20
- * - `MULTILEADER` — its MTEXT contents
21
- * - `ACAD_TABLE` — the cell text (from the table block when present,
22
- * otherwise the cell strings)
23
- *
24
- * Every layout is visited in one synchronous pass per search. A hit inside a
25
- * block, dimension or table selects the top-level entity of the layout that
26
- * contains it, and carries the WCS extents of the matched text so the view
27
- * can zoom to the text itself instead of to the whole block. Xrefs are not
28
- * searched.
29
- *
30
- * The module is structural (no `instanceof`) so it can be unit tested with
31
- * plain objects.
32
- */
33
-
34
- /** Maximum number of hits returned by one search. */
35
- export const ACAP_FIND_MAX_RESULTS = 500
36
-
37
- /** Kind of entity that produced a hit. */
38
- export type AcApFindHitKind =
39
- | 'text'
40
- | 'mtext'
41
- | 'attribute'
42
- /** TEXT / MTEXT inside a block definition (via an `INSERT`). */
43
- | 'block'
44
- | 'dimension'
45
- | 'leader'
46
- | 'table'
47
-
48
- /** Options for {@link acapFindTextInLayout}. */
49
- export interface AcApFindOptions {
50
- /**
51
- * When true the query must match case and accents exactly. When false
52
- * (default) case, accents (diacritics) and runs of whitespace are ignored.
53
- */
54
- matchCase?: boolean
55
- /** Maximum hits to return; defaults to {@link ACAP_FIND_MAX_RESULTS}. */
56
- maxResults?: number
57
- }
58
-
59
- /** One match returned by {@link acapFindTextInLayout}. */
60
- export interface AcApFindHit {
61
- /**
62
- * Top-level entity of the layout that is selected (the `INSERT` for
63
- * attributes and block text, the `DIMENSION` / `ACAD_TABLE` for their text).
64
- */
65
- entityId: string
66
- /**
67
- * Key of the text that matched, unique within the layout: the entity id
68
- * for top-level text, the attribute id for attributes, and
69
- * `<entityId>/<nested id>…` for text found inside a block, dimension or
70
- * table (the same block text appears once per `INSERT`).
71
- */
72
- textEntityId: string
73
- kind: AcApFindHitKind
74
- /** Plain text that matched (formatting codes removed, whitespace collapsed). */
75
- text: string
76
- /** Attribute tag, for `attribute` hits. */
77
- tag?: string
78
- /** Block name of the owning `INSERT`, for `attribute` hits. */
79
- blockName?: string
80
- layer: string
81
- /** Insertion point of the text in WCS. */
82
- position: { x: number; y: number }
83
- /**
84
- * WCS extents of the matched text, when known (text nested in a block,
85
- * dimension or table). Used to zoom to the text itself.
86
- */
87
- extents?: { min: { x: number; y: number }; max: { x: number; y: number } }
88
- }
89
-
90
- /** Result of one search. */
91
- export interface AcApFindResult {
92
- hits: AcApFindHit[]
93
- /** True when more than `maxResults` entities matched. */
94
- truncated: boolean
95
- }
96
-
97
- /* eslint-disable @typescript-eslint/no-explicit-any */
98
- type EntityLike = any
99
- /* eslint-enable @typescript-eslint/no-explicit-any */
100
-
101
- /** Minimal database shape used by the search. */
102
- export interface AcApFindDatabaseLike {
103
- tables: {
104
- blockTable: {
105
- /** Block definition by name; used to search text inside blocks. */
106
- getAt?(name: string): { newIterator(): Iterable<EntityLike> } | undefined
107
- getIdAt(id: string):
108
- | {
109
- newIterator(): Iterable<EntityLike>
110
- /** Used to resolve the matched entity for zoom extents. */
111
- getIdAt?(id: string): EntityLike
112
- }
113
- | undefined
114
- }
115
- layerTable?: {
116
- getAt(name: string): { isOff?: boolean; isFrozen?: boolean } | undefined
117
- }
118
- }
119
- }
120
-
121
- const DIACRITICS = /[̀-ͯ]/g
122
- const WHITESPACE = /\s+/g
123
-
124
- /**
125
- * Normalizes text or a query for comparison.
126
- *
127
- * - always: NFKC, whitespace runs → one space, trimmed
128
- * - unless `matchCase`: accents removed, lower-cased
129
- */
130
- export function acapNormalizeFindText(
131
- value: string,
132
- matchCase = false
133
- ): string {
134
- let result = value.normalize('NFKC').replace(WHITESPACE, ' ').trim()
135
- if (!matchCase) {
136
- result = result.normalize('NFD').replace(DIACRITICS, '').toLowerCase()
137
- }
138
- return result
139
- }
140
-
141
- /**
142
- * Converts MTEXT contents to plain text: removes formatting codes
143
- * (`\f…;`, `\H…;`, `\C…;`, `\L \l \O \o \K \k`, braces), turns `\P` into a
144
- * space, decodes `\~`, `\U+XXXX`, stacked fractions and `%%c %%d %%p`.
145
- */
146
- export function acapMTextToPlainText(contents: string): string {
147
- // Match only fixed-length introducers. Searching for a missing semicolon
148
- // inside a global regex would rescan the suffix at every formatting code.
149
- const codes = /\\(U\+[0-9a-fA-F]{4}|[PX~SfFHWTQCcAaRrpLlOoKk\\{}])|[{}]/g
150
- const parts: string[] = []
151
- let lastIndex = 0
152
- let noSemicolon = false
153
- let match: RegExpExecArray | null
154
- while ((match = codes.exec(contents))) {
155
- parts.push(contents.slice(lastIndex, match.index))
156
- const code = match[1]
157
- let replacement = ''
158
- if (code?.startsWith('U+')) {
159
- replacement = String.fromCharCode(parseInt(code.slice(2), 16))
160
- } else if (code === 'P' || code === 'X' || code === '~') {
161
- replacement = ' '
162
- } else if (code === '\\' || code === '{' || code === '}') {
163
- replacement = code
164
- } else if (code && /[SfFHWTQCcAaRrp]/.test(code)) {
165
- const end = noSemicolon ? -1 : contents.indexOf(';', codes.lastIndex)
166
- if (end === -1) {
167
- // All later positions also lack a terminator. Keep incomplete codes
168
- // literal, without searching the same suffix again.
169
- noSemicolon = true
170
- replacement = match[0]
171
- } else {
172
- if (code === 'S') {
173
- const stack = contents.slice(codes.lastIndex, end)
174
- const separator = stack.search(/[\^#/]/)
175
- if (separator === -1) {
176
- replacement = stack
177
- } else {
178
- const denominator = stack.slice(separator + 1)
179
- replacement =
180
- stack.slice(0, separator) + (denominator ? `/${denominator}` : '')
181
- }
182
- }
183
- // Consume the complete formatting code, including any nested escapes.
184
- codes.lastIndex = end + 1
185
- }
186
- }
187
- parts.push(replacement)
188
- lastIndex = codes.lastIndex
189
- }
190
- parts.push(contents.slice(lastIndex))
191
- return decodeControlCodes(parts.join('')).replace(WHITESPACE, ' ').trim()
192
- }
193
-
194
- /** Decodes `%%c`, `%%d`, `%%p` special-character codes. */
195
- function decodeControlCodes(value: string): string {
196
- return value
197
- .replace(/%%[cC]/g, 'Ø')
198
- .replace(/%%[dD]/g, '°')
199
- .replace(/%%[pP]/g, '±')
200
- }
201
-
202
- function plainText(value: unknown): string {
203
- return typeof value === 'string'
204
- ? decodeControlCodes(value)
205
- .normalize('NFKC')
206
- .replace(WHITESPACE, ' ')
207
- .trim()
208
- : ''
209
- }
210
-
211
- function isLayerSearchable(
212
- db: AcApFindDatabaseLike,
213
- layerName: string,
214
- cache: Map<string, boolean>
215
- ): boolean {
216
- const cached = cache.get(layerName)
217
- if (cached !== undefined) return cached
218
- const layer = db.tables.layerTable?.getAt(layerName)
219
- const searchable = !(layer?.isOff || layer?.isFrozen)
220
- cache.set(layerName, searchable)
221
- return searchable
222
- }
223
-
224
- function pointOf(entity: EntityLike): { x: number; y: number } {
225
- const p = entity.position ??
226
- entity.location ??
227
- entity.textLocation ??
228
- entity.textPosition ?? { x: 0, y: 0 }
229
- return { x: Number(p.x) || 0, y: Number(p.y) || 0 }
230
- }
231
-
232
- /** 4x4 affine matrix, column-major (`AcGeMatrix3d.elements` layout). */
233
- type Matrix = number[]
234
-
235
- /** `a × b` for column-major 4x4 matrices. */
236
- function multiply(a: Matrix, b: Matrix): Matrix {
237
- const out = new Array<number>(16)
238
- for (let col = 0; col < 4; col++) {
239
- for (let row = 0; row < 4; row++) {
240
- let sum = 0
241
- for (let k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k]
242
- out[col * 4 + row] = sum
243
- }
244
- }
245
- return out
246
- }
247
-
248
- function transformPoint(
249
- m: Matrix | undefined,
250
- p: { x: number; y: number; z?: number }
251
- ): { x: number; y: number } {
252
- if (!m) return { x: p.x, y: p.y }
253
- const z = Number(p.z) || 0
254
- return {
255
- x: m[0] * p.x + m[4] * p.y + m[8] * z + m[12],
256
- y: m[1] * p.x + m[5] * p.y + m[9] * z + m[13]
257
- }
258
- }
259
-
260
- function matrixOf(entity: EntityLike): Matrix | undefined {
261
- try {
262
- const elements = entity.blockTransform?.elements
263
- return Array.isArray(elements) && elements.length === 16
264
- ? (elements as number[])
265
- : undefined
266
- } catch {
267
- return undefined
268
- }
269
- }
270
-
271
- /** WCS box of a (possibly nested) entity, or undefined when unknown. */
272
- function extentsOf(
273
- entity: EntityLike,
274
- m: Matrix | undefined
275
- ): AcApFindHit['extents'] {
276
- let box: { min: { x: number; y: number }; max: { x: number; y: number } }
277
- try {
278
- box = entity.geometricExtents
279
- } catch {
280
- return undefined
281
- }
282
- if (!box || !isFinite(box.min?.x) || !isFinite(box.max?.x)) return undefined
283
- if (!m) return { min: { ...box.min }, max: { ...box.max } }
284
- const corners = [
285
- { x: box.min.x, y: box.min.y },
286
- { x: box.max.x, y: box.min.y },
287
- { x: box.min.x, y: box.max.y },
288
- { x: box.max.x, y: box.max.y }
289
- ].map(p => transformPoint(m, p))
290
- return {
291
- min: {
292
- x: Math.min(...corners.map(p => p.x)),
293
- y: Math.min(...corners.map(p => p.y))
294
- },
295
- max: {
296
- x: Math.max(...corners.map(p => p.x)),
297
- y: Math.max(...corners.map(p => p.y))
298
- }
299
- }
300
- }
301
-
302
- /** Maximum nesting of blocks inside blocks that is searched. */
303
- const MAX_BLOCK_DEPTH = 8
304
-
305
- /** Top-level entity of the layout that owns nested text. */
306
- interface Owner {
307
- entity: EntityLike
308
- kind: Extract<AcApFindHitKind, 'block' | 'dimension' | 'table'>
309
- /** Effective layer of the owner (layer `0` entities inherit it). */
310
- layer: string
311
- }
312
-
313
- /**
314
- * Finds text in the entities of one block table record (a layout).
315
- *
316
- * Entities that are invisible or sit on an off/frozen layer are skipped so
317
- * that results always correspond to something the user can see.
318
- *
319
- * @param db - Database owning the layout.
320
- * @param layoutBtrId - Block table record id of the layout (model/paper space).
321
- * @param query - User query; blank queries return no hits.
322
- */
323
- export function acapFindTextInLayout(
324
- db: AcApFindDatabaseLike,
325
- layoutBtrId: string,
326
- query: string,
327
- options: AcApFindOptions = {}
328
- ): AcApFindResult {
329
- const matchCase = options.matchCase === true
330
- const max = options.maxResults ?? ACAP_FIND_MAX_RESULTS
331
- const needle = acapNormalizeFindText(query, matchCase)
332
- const result: AcApFindResult = { hits: [], truncated: false }
333
- if (!needle) return result
334
-
335
- const btr = db.tables.blockTable.getIdAt(layoutBtrId)
336
- if (!btr) return result
337
-
338
- const layers = new Map<string, boolean>()
339
- const matches = (text: string) =>
340
- text !== '' && acapNormalizeFindText(text, matchCase).includes(needle)
341
- /** Set once the result cap is exceeded; stops every loop. */
342
- let full = false
343
-
344
- const push = (hit: AcApFindHit) => {
345
- if (result.hits.length >= max) {
346
- result.truncated = true
347
- full = true
348
- return
349
- }
350
- result.hits.push(hit)
351
- }
352
-
353
- /**
354
- * Hit for text nested in `owner` (block, dimension or table). `path` keeps
355
- * the key unique when the same block is inserted several times.
356
- */
357
- const pushNested = (
358
- owner: Owner,
359
- path: string,
360
- entity: EntityLike,
361
- kind: AcApFindHitKind,
362
- text: string,
363
- m: Matrix | undefined,
364
- extra: Partial<AcApFindHit> = {}
365
- ) => {
366
- const extents = extentsOf(entity, m)
367
- push({
368
- entityId: owner.entity.objectId,
369
- textEntityId: `${path}/${entity.objectId}`,
370
- kind,
371
- text,
372
- layer: owner.layer,
373
- position: transformPoint(m, pointOf(entity)),
374
- ...(extents ? { extents } : {}),
375
- ...extra
376
- })
377
- }
378
-
379
- /** Searches the entities of a block definition drawn by `owner`. */
380
- const visitBlock = (
381
- entities: Iterable<EntityLike>,
382
- owner: Owner,
383
- m: Matrix | undefined,
384
- path: string,
385
- depth: number,
386
- stack: Set<string>
387
- ) => {
388
- for (const entity of entities) {
389
- if (full) return
390
- const type: string | undefined = entity.dxfTypeName
391
- if (
392
- type !== 'TEXT' &&
393
- type !== 'MTEXT' &&
394
- type !== 'INSERT' &&
395
- type !== 'ATTRIB' &&
396
- type !== 'ATTDEF'
397
- ) {
398
- continue
399
- }
400
- if (entity.visibility === false) continue
401
- // Only constant ATTDEFs are drawn inside a block (their default value
402
- // is geometry); the others get their value from an ATTRIB of the INSERT.
403
- if (
404
- type === 'ATTDEF' &&
405
- (entity.isInvisible ||
406
- !(entity.isConst || entity.isConstMTextAttribute))
407
- ) {
408
- continue
409
- }
410
- // Layer 0 inside a block inherits the owner's layer (already checked).
411
- if (
412
- entity.layer &&
413
- entity.layer !== '0' &&
414
- !isLayerSearchable(db, entity.layer, layers)
415
- ) {
416
- continue
417
- }
418
- if (type === 'INSERT') {
419
- if (depth >= MAX_BLOCK_DEPTH) continue
420
- const inner = matrixOf(entity)
421
- const nested = m && inner ? multiply(m, inner) : (inner ?? m)
422
- const nestedPath = `${path}/${entity.objectId}`
423
- for (const attribute of entity.attributeIterator?.() ?? []) {
424
- if (full) return
425
- if (attribute.visibility === false || attribute.isInvisible) continue
426
- const text = plainText(attribute.textString)
427
- if (!matches(text)) continue
428
- // Attributes are stored in the coordinates of the block that holds
429
- // the nested INSERT, like the INSERT itself.
430
- pushNested(owner, nestedPath, attribute, 'block', text, m, {
431
- tag: attribute.tag,
432
- blockName: entity.blockName
433
- })
434
- }
435
- const name: string | undefined = entity.blockName
436
- if (!name || stack.has(name)) continue
437
- const def = db.tables.blockTable.getAt?.(name)
438
- if (!def) continue
439
- stack.add(name)
440
- visitBlock(
441
- def.newIterator(),
442
- owner,
443
- nested,
444
- nestedPath,
445
- depth + 1,
446
- stack
447
- )
448
- stack.delete(name)
449
- continue
450
- }
451
- const isMText =
452
- type === 'MTEXT' ||
453
- (type === 'ATTDEF' && entity.isConstMTextAttribute && entity.mtext)
454
- const text = isMText
455
- ? acapMTextToPlainText(
456
- String((type === 'MTEXT' ? entity : entity.mtext).contents ?? '')
457
- )
458
- : plainText(entity.textString)
459
- if (!matches(text)) continue
460
- pushNested(
461
- owner,
462
- path,
463
- entity,
464
- owner.kind,
465
- text,
466
- m,
467
- type === 'ATTDEF' ? { tag: entity.tag } : {}
468
- )
469
- }
470
- }
471
-
472
- /** Block definition drawn by a DIMENSION / ACAD_TABLE, when present. */
473
- const ownBlock = (entity: EntityLike) => {
474
- try {
475
- // ACAD_TABLE resolves its anonymous block itself.
476
- const table = entity.blockTableRecord
477
- if (table?.newIterator)
478
- return table as { newIterator(): Iterable<EntityLike> }
479
- const id = entity.dimBlockId
480
- const byId = id ? db.tables.blockTable.getIdAt(id) : undefined
481
- if (byId) return byId
482
- const name = entity.blockName
483
- return name ? db.tables.blockTable.getAt?.(name) : undefined
484
- } catch {
485
- return undefined
486
- }
487
- }
488
-
489
- for (const entity of btr.newIterator()) {
490
- if (full) break
491
- const type: string | undefined = entity.dxfTypeName
492
- if (
493
- type !== 'TEXT' &&
494
- type !== 'MTEXT' &&
495
- type !== 'INSERT' &&
496
- type !== 'DIMENSION' &&
497
- type !== 'MULTILEADER' &&
498
- type !== 'ACAD_TABLE'
499
- ) {
500
- continue
501
- }
502
- if (entity.visibility === false) continue
503
- if (!isLayerSearchable(db, entity.layer, layers)) continue
504
-
505
- if (type === 'INSERT') {
506
- for (const attribute of entity.attributeIterator?.() ?? []) {
507
- if (full) break
508
- if (attribute.visibility === false || attribute.isInvisible) continue
509
- const text = plainText(attribute.textString)
510
- if (!matches(text)) continue
511
- push({
512
- entityId: entity.objectId,
513
- textEntityId: attribute.objectId,
514
- kind: 'attribute',
515
- text,
516
- tag: attribute.tag,
517
- blockName: entity.blockName,
518
- layer: entity.layer,
519
- position: pointOf(attribute)
520
- })
521
- }
522
- const name: string | undefined = entity.blockName
523
- const def = name ? db.tables.blockTable.getAt?.(name) : undefined
524
- if (def && !full) {
525
- visitBlock(
526
- def.newIterator(),
527
- { entity, kind: 'block', layer: entity.layer },
528
- matrixOf(entity),
529
- String(entity.objectId),
530
- 1,
531
- new Set([name as string])
532
- )
533
- }
534
- continue
535
- }
536
-
537
- if (type === 'DIMENSION' || type === 'ACAD_TABLE') {
538
- const kind = type === 'DIMENSION' ? 'dimension' : 'table'
539
- const owner: Owner = { entity, kind, layer: entity.layer }
540
- const block = ownBlock(entity)
541
- const before = result.hits.length
542
- if (block) {
543
- // Dimension / table blocks are already in WCS.
544
- visitBlock(
545
- block.newIterator(),
546
- owner,
547
- undefined,
548
- String(entity.objectId),
549
- 1,
550
- new Set()
551
- )
552
- }
553
- if (block && result.hits.length > before) continue
554
- // No block, or a block without a matching text: use the stored strings
555
- // (a dimension override can differ from what the block text shows).
556
- const texts: string[] = []
557
- if (type === 'DIMENSION') {
558
- const override = String(entity.dimensionText ?? '')
559
- if (override && override !== '<>' && override !== ' ') {
560
- texts.push(acapMTextToPlainText(override.replace('<>', '')))
561
- }
562
- } else {
563
- const rows = Number(entity.numRows) || 0
564
- const cols = Number(entity.numColumns) || 0
565
- for (let r = 0; r < rows; r++) {
566
- for (let c = 0; c < cols; c++) {
567
- try {
568
- texts.push(
569
- acapMTextToPlainText(String(entity.textString?.(r, c) ?? ''))
570
- )
571
- } catch {
572
- // Cell without text content.
573
- }
574
- }
575
- }
576
- }
577
- for (const text of texts) {
578
- if (full) break
579
- if (!matches(text)) continue
580
- push({
581
- entityId: entity.objectId,
582
- textEntityId: `${entity.objectId}/${result.hits.length}`,
583
- kind,
584
- text,
585
- layer: entity.layer,
586
- position: pointOf(entity)
587
- })
588
- }
589
- continue
590
- }
591
-
592
- const text =
593
- type === 'MTEXT' || type === 'MULTILEADER'
594
- ? acapMTextToPlainText(String(entity.contents ?? ''))
595
- : plainText(entity.textString)
596
- if (!matches(text)) continue
597
- push({
598
- entityId: entity.objectId,
599
- textEntityId: entity.objectId,
600
- kind:
601
- type === 'MTEXT' ? 'mtext' : type === 'MULTILEADER' ? 'leader' : 'text',
602
- text,
603
- layer: entity.layer,
604
- position: pointOf(entity)
605
- })
606
- }
607
- return result
608
- }
609
-
610
- /** Returns a short excerpt of `text` centred on the first match of `query`. */
611
- export function acapFindExcerpt(
612
- text: string,
613
- query: string,
614
- matchCase = false,
615
- width = 60
616
- ): string {
617
- if (text.length <= width) return text
618
- const needle = acapNormalizeFindText(query, matchCase)
619
- // Normalization may change length (accents, NFKC); fall back to the head.
620
- const index = acapNormalizeFindText(text, matchCase).indexOf(needle)
621
- const start = Math.max(0, Math.min(index - 15, text.length - width))
622
- const slice = text.slice(start, start + width)
623
- return `${start > 0 ? '…' : ''}${slice}${start + width < text.length ? '…' : ''}`
624
- }
1
+ /**
2
+ * Text search over the entities of one layout (FIND).
3
+ *
4
+ * Shared by every UI (cad-simple-ui-plugin, the full Vue cad-viewer) so that
5
+ * both behave identically. Pure TypeScript with no runtime imports, published
6
+ * as the `@mlightcad/cad-simple-viewer/find` subpath.
7
+ *
8
+ * Searched fields:
9
+ * - `TEXT` — `textString`
10
+ * - `MTEXT` — `contents` with MTEXT formatting codes removed
11
+ * - `ATTRIB` — attribute value (`textString`) of every `INSERT` in the layout
12
+ * (the tag is reported for display but is not searched)
13
+ * - `TEXT` / `MTEXT` / `ATTRIB` inside the block definition of every `INSERT`
14
+ * (nested blocks included, anonymous/dynamic blocks too): title blocks,
15
+ * legends and labels are usually drawn this way
16
+ * - constant `ATTDEF` values inside block definitions (the only ATTDEFs that
17
+ * are drawn inside a block; templates get their value from an `ATTRIB`)
18
+ * - `DIMENSION` — the text of its dimension block when that block has a
19
+ * match, otherwise the override text (`dimensionText`)
20
+ * - `MULTILEADER` — its MTEXT contents
21
+ * - `ACAD_TABLE` — the cell text (from the table block when present,
22
+ * otherwise the cell strings)
23
+ *
24
+ * Every layout is visited in one synchronous pass per search. A hit inside a
25
+ * block, dimension or table selects the top-level entity of the layout that
26
+ * contains it, and carries the WCS extents of the matched text so the view
27
+ * can zoom to the text itself instead of to the whole block. Xrefs are not
28
+ * searched.
29
+ *
30
+ * The module is structural (no `instanceof`) so it can be unit tested with
31
+ * plain objects.
32
+ */
33
+
34
+ /** Maximum number of hits returned by one search. */
35
+ export const ACAP_FIND_MAX_RESULTS = 500
36
+
37
+ /** Kind of entity that produced a hit. */
38
+ export type AcApFindHitKind =
39
+ | 'text'
40
+ | 'mtext'
41
+ | 'attribute'
42
+ /** TEXT / MTEXT inside a block definition (via an `INSERT`). */
43
+ | 'block'
44
+ | 'dimension'
45
+ | 'leader'
46
+ | 'table'
47
+
48
+ /** Options for {@link acapFindTextInLayout}. */
49
+ export interface AcApFindOptions {
50
+ /**
51
+ * When true the query must match case and accents exactly. When false
52
+ * (default) case, accents (diacritics) and runs of whitespace are ignored.
53
+ */
54
+ matchCase?: boolean
55
+ /** Maximum hits to return; defaults to {@link ACAP_FIND_MAX_RESULTS}. */
56
+ maxResults?: number
57
+ }
58
+
59
+ /** One match returned by {@link acapFindTextInLayout}. */
60
+ export interface AcApFindHit {
61
+ /**
62
+ * Top-level entity of the layout that is selected (the `INSERT` for
63
+ * attributes and block text, the `DIMENSION` / `ACAD_TABLE` for their text).
64
+ */
65
+ entityId: string
66
+ /**
67
+ * Key of the text that matched, unique within the layout: the entity id
68
+ * for top-level text, the attribute id for attributes, and
69
+ * `<entityId>/<nested id>…` for text found inside a block, dimension or
70
+ * table (the same block text appears once per `INSERT`).
71
+ */
72
+ textEntityId: string
73
+ kind: AcApFindHitKind
74
+ /** Plain text that matched (formatting codes removed, whitespace collapsed). */
75
+ text: string
76
+ /** Attribute tag, for `attribute` hits. */
77
+ tag?: string
78
+ /** Block name of the owning `INSERT`, for `attribute` hits. */
79
+ blockName?: string
80
+ layer: string
81
+ /** Insertion point of the text in WCS. */
82
+ position: { x: number; y: number }
83
+ /**
84
+ * WCS extents of the matched text, when known (text nested in a block,
85
+ * dimension or table). Used to zoom to the text itself.
86
+ */
87
+ extents?: { min: { x: number; y: number }; max: { x: number; y: number } }
88
+ }
89
+
90
+ /** Result of one search. */
91
+ export interface AcApFindResult {
92
+ hits: AcApFindHit[]
93
+ /** True when more than `maxResults` entities matched. */
94
+ truncated: boolean
95
+ }
96
+
97
+ /* eslint-disable @typescript-eslint/no-explicit-any */
98
+ type EntityLike = any
99
+ /* eslint-enable @typescript-eslint/no-explicit-any */
100
+
101
+ /** Minimal database shape used by the search. */
102
+ export interface AcApFindDatabaseLike {
103
+ tables: {
104
+ blockTable: {
105
+ /** Block definition by name; used to search text inside blocks. */
106
+ getAt?(name: string): { newIterator(): Iterable<EntityLike> } | undefined
107
+ getIdAt(id: string):
108
+ | {
109
+ newIterator(): Iterable<EntityLike>
110
+ /** Used to resolve the matched entity for zoom extents. */
111
+ getIdAt?(id: string): EntityLike
112
+ }
113
+ | undefined
114
+ }
115
+ layerTable?: {
116
+ getAt(name: string): { isOff?: boolean; isFrozen?: boolean } | undefined
117
+ }
118
+ }
119
+ }
120
+
121
+ const DIACRITICS = /[̀-ͯ]/g
122
+ const WHITESPACE = /\s+/g
123
+
124
+ /**
125
+ * Normalizes text or a query for comparison.
126
+ *
127
+ * - always: NFKC, whitespace runs → one space, trimmed
128
+ * - unless `matchCase`: accents removed, lower-cased
129
+ */
130
+ export function acapNormalizeFindText(
131
+ value: string,
132
+ matchCase = false
133
+ ): string {
134
+ let result = value.normalize('NFKC').replace(WHITESPACE, ' ').trim()
135
+ if (!matchCase) {
136
+ result = result.normalize('NFD').replace(DIACRITICS, '').toLowerCase()
137
+ }
138
+ return result
139
+ }
140
+
141
+ /**
142
+ * Converts MTEXT contents to plain text: removes formatting codes
143
+ * (`\f…;`, `\H…;`, `\C…;`, `\L \l \O \o \K \k`, braces), turns `\P` into a
144
+ * space, decodes `\~`, `\U+XXXX`, stacked fractions and `%%c %%d %%p`.
145
+ */
146
+ export function acapMTextToPlainText(contents: string): string {
147
+ // Match only fixed-length introducers. Searching for a missing semicolon
148
+ // inside a global regex would rescan the suffix at every formatting code.
149
+ const codes = /\\(U\+[0-9a-fA-F]{4}|[PX~SfFHWTQCcAaRrpLlOoKk\\{}])|[{}]/g
150
+ const parts: string[] = []
151
+ let lastIndex = 0
152
+ let noSemicolon = false
153
+ let match: RegExpExecArray | null
154
+ while ((match = codes.exec(contents))) {
155
+ parts.push(contents.slice(lastIndex, match.index))
156
+ const code = match[1]
157
+ let replacement = ''
158
+ if (code?.startsWith('U+')) {
159
+ replacement = String.fromCharCode(parseInt(code.slice(2), 16))
160
+ } else if (code === 'P' || code === 'X' || code === '~') {
161
+ replacement = ' '
162
+ } else if (code === '\\' || code === '{' || code === '}') {
163
+ replacement = code
164
+ } else if (code && /[SfFHWTQCcAaRrp]/.test(code)) {
165
+ const end = noSemicolon ? -1 : contents.indexOf(';', codes.lastIndex)
166
+ if (end === -1) {
167
+ // All later positions also lack a terminator. Keep incomplete codes
168
+ // literal, without searching the same suffix again.
169
+ noSemicolon = true
170
+ replacement = match[0]
171
+ } else {
172
+ if (code === 'S') {
173
+ const stack = contents.slice(codes.lastIndex, end)
174
+ const separator = stack.search(/[\^#/]/)
175
+ if (separator === -1) {
176
+ replacement = stack
177
+ } else {
178
+ const denominator = stack.slice(separator + 1)
179
+ replacement =
180
+ stack.slice(0, separator) + (denominator ? `/${denominator}` : '')
181
+ }
182
+ }
183
+ // Consume the complete formatting code, including any nested escapes.
184
+ codes.lastIndex = end + 1
185
+ }
186
+ }
187
+ parts.push(replacement)
188
+ lastIndex = codes.lastIndex
189
+ }
190
+ parts.push(contents.slice(lastIndex))
191
+ return decodeControlCodes(parts.join('')).replace(WHITESPACE, ' ').trim()
192
+ }
193
+
194
+ /** Decodes `%%c`, `%%d`, `%%p` special-character codes. */
195
+ function decodeControlCodes(value: string): string {
196
+ return value
197
+ .replace(/%%[cC]/g, 'Ø')
198
+ .replace(/%%[dD]/g, '°')
199
+ .replace(/%%[pP]/g, '±')
200
+ }
201
+
202
+ function plainText(value: unknown): string {
203
+ return typeof value === 'string'
204
+ ? decodeControlCodes(value)
205
+ .normalize('NFKC')
206
+ .replace(WHITESPACE, ' ')
207
+ .trim()
208
+ : ''
209
+ }
210
+
211
+ function isLayerSearchable(
212
+ db: AcApFindDatabaseLike,
213
+ layerName: string,
214
+ cache: Map<string, boolean>
215
+ ): boolean {
216
+ const cached = cache.get(layerName)
217
+ if (cached !== undefined) return cached
218
+ const layer = db.tables.layerTable?.getAt(layerName)
219
+ const searchable = !(layer?.isOff || layer?.isFrozen)
220
+ cache.set(layerName, searchable)
221
+ return searchable
222
+ }
223
+
224
+ function pointOf(entity: EntityLike): { x: number; y: number } {
225
+ const p = entity.position ??
226
+ entity.location ??
227
+ entity.textLocation ??
228
+ entity.textPosition ?? { x: 0, y: 0 }
229
+ return { x: Number(p.x) || 0, y: Number(p.y) || 0 }
230
+ }
231
+
232
+ /** 4x4 affine matrix, column-major (`AcGeMatrix3d.elements` layout). */
233
+ type Matrix = number[]
234
+
235
+ /** `a × b` for column-major 4x4 matrices. */
236
+ function multiply(a: Matrix, b: Matrix): Matrix {
237
+ const out = new Array<number>(16)
238
+ for (let col = 0; col < 4; col++) {
239
+ for (let row = 0; row < 4; row++) {
240
+ let sum = 0
241
+ for (let k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k]
242
+ out[col * 4 + row] = sum
243
+ }
244
+ }
245
+ return out
246
+ }
247
+
248
+ function transformPoint(
249
+ m: Matrix | undefined,
250
+ p: { x: number; y: number; z?: number }
251
+ ): { x: number; y: number } {
252
+ if (!m) return { x: p.x, y: p.y }
253
+ const z = Number(p.z) || 0
254
+ return {
255
+ x: m[0] * p.x + m[4] * p.y + m[8] * z + m[12],
256
+ y: m[1] * p.x + m[5] * p.y + m[9] * z + m[13]
257
+ }
258
+ }
259
+
260
+ function matrixOf(entity: EntityLike): Matrix | undefined {
261
+ try {
262
+ const elements = entity.blockTransform?.elements
263
+ return Array.isArray(elements) && elements.length === 16
264
+ ? (elements as number[])
265
+ : undefined
266
+ } catch {
267
+ return undefined
268
+ }
269
+ }
270
+
271
+ /** WCS box of a (possibly nested) entity, or undefined when unknown. */
272
+ function extentsOf(
273
+ entity: EntityLike,
274
+ m: Matrix | undefined
275
+ ): AcApFindHit['extents'] {
276
+ let box: { min: { x: number; y: number }; max: { x: number; y: number } }
277
+ try {
278
+ box = entity.geometricExtents
279
+ } catch {
280
+ return undefined
281
+ }
282
+ if (!box || !isFinite(box.min?.x) || !isFinite(box.max?.x)) return undefined
283
+ if (!m) return { min: { ...box.min }, max: { ...box.max } }
284
+ const corners = [
285
+ { x: box.min.x, y: box.min.y },
286
+ { x: box.max.x, y: box.min.y },
287
+ { x: box.min.x, y: box.max.y },
288
+ { x: box.max.x, y: box.max.y }
289
+ ].map(p => transformPoint(m, p))
290
+ return {
291
+ min: {
292
+ x: Math.min(...corners.map(p => p.x)),
293
+ y: Math.min(...corners.map(p => p.y))
294
+ },
295
+ max: {
296
+ x: Math.max(...corners.map(p => p.x)),
297
+ y: Math.max(...corners.map(p => p.y))
298
+ }
299
+ }
300
+ }
301
+
302
+ /** Maximum nesting of blocks inside blocks that is searched. */
303
+ const MAX_BLOCK_DEPTH = 8
304
+
305
+ /** Top-level entity of the layout that owns nested text. */
306
+ interface Owner {
307
+ entity: EntityLike
308
+ kind: Extract<AcApFindHitKind, 'block' | 'dimension' | 'table'>
309
+ /** Effective layer of the owner (layer `0` entities inherit it). */
310
+ layer: string
311
+ }
312
+
313
+ /**
314
+ * Finds text in the entities of one block table record (a layout).
315
+ *
316
+ * Entities that are invisible or sit on an off/frozen layer are skipped so
317
+ * that results always correspond to something the user can see.
318
+ *
319
+ * @param db - Database owning the layout.
320
+ * @param layoutBtrId - Block table record id of the layout (model/paper space).
321
+ * @param query - User query; blank queries return no hits.
322
+ */
323
+ export function acapFindTextInLayout(
324
+ db: AcApFindDatabaseLike,
325
+ layoutBtrId: string,
326
+ query: string,
327
+ options: AcApFindOptions = {}
328
+ ): AcApFindResult {
329
+ const matchCase = options.matchCase === true
330
+ const max = options.maxResults ?? ACAP_FIND_MAX_RESULTS
331
+ const needle = acapNormalizeFindText(query, matchCase)
332
+ const result: AcApFindResult = { hits: [], truncated: false }
333
+ if (!needle) return result
334
+
335
+ const btr = db.tables.blockTable.getIdAt(layoutBtrId)
336
+ if (!btr) return result
337
+
338
+ const layers = new Map<string, boolean>()
339
+ const matches = (text: string) =>
340
+ text !== '' && acapNormalizeFindText(text, matchCase).includes(needle)
341
+ /** Set once the result cap is exceeded; stops every loop. */
342
+ let full = false
343
+
344
+ const push = (hit: AcApFindHit) => {
345
+ if (result.hits.length >= max) {
346
+ result.truncated = true
347
+ full = true
348
+ return
349
+ }
350
+ result.hits.push(hit)
351
+ }
352
+
353
+ /**
354
+ * Hit for text nested in `owner` (block, dimension or table). `path` keeps
355
+ * the key unique when the same block is inserted several times.
356
+ */
357
+ const pushNested = (
358
+ owner: Owner,
359
+ path: string,
360
+ entity: EntityLike,
361
+ kind: AcApFindHitKind,
362
+ text: string,
363
+ m: Matrix | undefined,
364
+ extra: Partial<AcApFindHit> = {}
365
+ ) => {
366
+ const extents = extentsOf(entity, m)
367
+ push({
368
+ entityId: owner.entity.objectId,
369
+ textEntityId: `${path}/${entity.objectId}`,
370
+ kind,
371
+ text,
372
+ layer: owner.layer,
373
+ position: transformPoint(m, pointOf(entity)),
374
+ ...(extents ? { extents } : {}),
375
+ ...extra
376
+ })
377
+ }
378
+
379
+ /** Searches the entities of a block definition drawn by `owner`. */
380
+ const visitBlock = (
381
+ entities: Iterable<EntityLike>,
382
+ owner: Owner,
383
+ m: Matrix | undefined,
384
+ path: string,
385
+ depth: number,
386
+ stack: Set<string>
387
+ ) => {
388
+ for (const entity of entities) {
389
+ if (full) return
390
+ const type: string | undefined = entity.dxfTypeName
391
+ if (
392
+ type !== 'TEXT' &&
393
+ type !== 'MTEXT' &&
394
+ type !== 'INSERT' &&
395
+ type !== 'ATTRIB' &&
396
+ type !== 'ATTDEF'
397
+ ) {
398
+ continue
399
+ }
400
+ if (entity.visibility === false) continue
401
+ // Only constant ATTDEFs are drawn inside a block (their default value
402
+ // is geometry); the others get their value from an ATTRIB of the INSERT.
403
+ if (
404
+ type === 'ATTDEF' &&
405
+ (entity.isInvisible ||
406
+ !(entity.isConst || entity.isConstMTextAttribute))
407
+ ) {
408
+ continue
409
+ }
410
+ // Layer 0 inside a block inherits the owner's layer (already checked).
411
+ if (
412
+ entity.layer &&
413
+ entity.layer !== '0' &&
414
+ !isLayerSearchable(db, entity.layer, layers)
415
+ ) {
416
+ continue
417
+ }
418
+ if (type === 'INSERT') {
419
+ if (depth >= MAX_BLOCK_DEPTH) continue
420
+ const inner = matrixOf(entity)
421
+ const nested = m && inner ? multiply(m, inner) : (inner ?? m)
422
+ const nestedPath = `${path}/${entity.objectId}`
423
+ for (const attribute of entity.attributeIterator?.() ?? []) {
424
+ if (full) return
425
+ if (attribute.visibility === false || attribute.isInvisible) continue
426
+ const text = plainText(attribute.textString)
427
+ if (!matches(text)) continue
428
+ // Attributes are stored in the coordinates of the block that holds
429
+ // the nested INSERT, like the INSERT itself.
430
+ pushNested(owner, nestedPath, attribute, 'block', text, m, {
431
+ tag: attribute.tag,
432
+ blockName: entity.blockName
433
+ })
434
+ }
435
+ const name: string | undefined = entity.blockName
436
+ if (!name || stack.has(name)) continue
437
+ const def = db.tables.blockTable.getAt?.(name)
438
+ if (!def) continue
439
+ stack.add(name)
440
+ visitBlock(
441
+ def.newIterator(),
442
+ owner,
443
+ nested,
444
+ nestedPath,
445
+ depth + 1,
446
+ stack
447
+ )
448
+ stack.delete(name)
449
+ continue
450
+ }
451
+ const isMText =
452
+ type === 'MTEXT' ||
453
+ (type === 'ATTDEF' && entity.isConstMTextAttribute && entity.mtext)
454
+ const text = isMText
455
+ ? acapMTextToPlainText(
456
+ String((type === 'MTEXT' ? entity : entity.mtext).contents ?? '')
457
+ )
458
+ : plainText(entity.textString)
459
+ if (!matches(text)) continue
460
+ pushNested(
461
+ owner,
462
+ path,
463
+ entity,
464
+ owner.kind,
465
+ text,
466
+ m,
467
+ type === 'ATTDEF' ? { tag: entity.tag } : {}
468
+ )
469
+ }
470
+ }
471
+
472
+ /** Block definition drawn by a DIMENSION / ACAD_TABLE, when present. */
473
+ const ownBlock = (entity: EntityLike) => {
474
+ try {
475
+ // ACAD_TABLE resolves its anonymous block itself.
476
+ const table = entity.blockTableRecord
477
+ if (table?.newIterator)
478
+ return table as { newIterator(): Iterable<EntityLike> }
479
+ const id = entity.dimBlockId
480
+ const byId = id ? db.tables.blockTable.getIdAt(id) : undefined
481
+ if (byId) return byId
482
+ const name = entity.blockName
483
+ return name ? db.tables.blockTable.getAt?.(name) : undefined
484
+ } catch {
485
+ return undefined
486
+ }
487
+ }
488
+
489
+ for (const entity of btr.newIterator()) {
490
+ if (full) break
491
+ const type: string | undefined = entity.dxfTypeName
492
+ if (
493
+ type !== 'TEXT' &&
494
+ type !== 'MTEXT' &&
495
+ type !== 'INSERT' &&
496
+ type !== 'DIMENSION' &&
497
+ type !== 'MULTILEADER' &&
498
+ type !== 'ACAD_TABLE'
499
+ ) {
500
+ continue
501
+ }
502
+ if (entity.visibility === false) continue
503
+ if (!isLayerSearchable(db, entity.layer, layers)) continue
504
+
505
+ if (type === 'INSERT') {
506
+ for (const attribute of entity.attributeIterator?.() ?? []) {
507
+ if (full) break
508
+ if (attribute.visibility === false || attribute.isInvisible) continue
509
+ const text = plainText(attribute.textString)
510
+ if (!matches(text)) continue
511
+ push({
512
+ entityId: entity.objectId,
513
+ textEntityId: attribute.objectId,
514
+ kind: 'attribute',
515
+ text,
516
+ tag: attribute.tag,
517
+ blockName: entity.blockName,
518
+ layer: entity.layer,
519
+ position: pointOf(attribute)
520
+ })
521
+ }
522
+ const name: string | undefined = entity.blockName
523
+ const def = name ? db.tables.blockTable.getAt?.(name) : undefined
524
+ if (def && !full) {
525
+ visitBlock(
526
+ def.newIterator(),
527
+ { entity, kind: 'block', layer: entity.layer },
528
+ matrixOf(entity),
529
+ String(entity.objectId),
530
+ 1,
531
+ new Set([name as string])
532
+ )
533
+ }
534
+ continue
535
+ }
536
+
537
+ if (type === 'DIMENSION' || type === 'ACAD_TABLE') {
538
+ const kind = type === 'DIMENSION' ? 'dimension' : 'table'
539
+ const owner: Owner = { entity, kind, layer: entity.layer }
540
+ const block = ownBlock(entity)
541
+ const before = result.hits.length
542
+ if (block) {
543
+ // Dimension / table blocks are already in WCS.
544
+ visitBlock(
545
+ block.newIterator(),
546
+ owner,
547
+ undefined,
548
+ String(entity.objectId),
549
+ 1,
550
+ new Set()
551
+ )
552
+ }
553
+ if (block && result.hits.length > before) continue
554
+ // No block, or a block without a matching text: use the stored strings
555
+ // (a dimension override can differ from what the block text shows).
556
+ const texts: string[] = []
557
+ if (type === 'DIMENSION') {
558
+ const override = String(entity.dimensionText ?? '')
559
+ if (override && override !== '<>' && override !== ' ') {
560
+ texts.push(acapMTextToPlainText(override.replace('<>', '')))
561
+ }
562
+ } else {
563
+ const rows = Number(entity.numRows) || 0
564
+ const cols = Number(entity.numColumns) || 0
565
+ for (let r = 0; r < rows; r++) {
566
+ for (let c = 0; c < cols; c++) {
567
+ try {
568
+ texts.push(
569
+ acapMTextToPlainText(String(entity.textString?.(r, c) ?? ''))
570
+ )
571
+ } catch {
572
+ // Cell without text content.
573
+ }
574
+ }
575
+ }
576
+ }
577
+ for (const text of texts) {
578
+ if (full) break
579
+ if (!matches(text)) continue
580
+ push({
581
+ entityId: entity.objectId,
582
+ textEntityId: `${entity.objectId}/${result.hits.length}`,
583
+ kind,
584
+ text,
585
+ layer: entity.layer,
586
+ position: pointOf(entity)
587
+ })
588
+ }
589
+ continue
590
+ }
591
+
592
+ const text =
593
+ type === 'MTEXT' || type === 'MULTILEADER'
594
+ ? acapMTextToPlainText(String(entity.contents ?? ''))
595
+ : plainText(entity.textString)
596
+ if (!matches(text)) continue
597
+ push({
598
+ entityId: entity.objectId,
599
+ textEntityId: entity.objectId,
600
+ kind:
601
+ type === 'MTEXT' ? 'mtext' : type === 'MULTILEADER' ? 'leader' : 'text',
602
+ text,
603
+ layer: entity.layer,
604
+ position: pointOf(entity)
605
+ })
606
+ }
607
+ return result
608
+ }
609
+
610
+ /** Returns a short excerpt of `text` centred on the first match of `query`. */
611
+ export function acapFindExcerpt(
612
+ text: string,
613
+ query: string,
614
+ matchCase = false,
615
+ width = 60
616
+ ): string {
617
+ if (text.length <= width) return text
618
+ const needle = acapNormalizeFindText(query, matchCase)
619
+ // Normalization may change length (accents, NFKC); fall back to the head.
620
+ const index = acapNormalizeFindText(text, matchCase).indexOf(needle)
621
+ const start = Math.max(0, Math.min(index - 15, text.length - width))
622
+ const slice = text.slice(start, start + width)
623
+ return `${start > 0 ? '…' : ''}${slice}${start + width < text.length ? '…' : ''}`
624
+ }