@mlightcad/cad-simple-viewer 1.7.8-preview.0 → 1.7.8-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +190 -190
- package/dist/cad-simple-viewer.js +10120 -8596
- package/dist/cad-simple-viewer.umd.cjs +82 -82
- package/find.ts +7 -7
- package/icons.ts +7 -7
- package/lib/app/AcApContext.d.ts.map +1 -1
- package/lib/app/AcApContext.js +10 -1
- package/lib/app/AcApContext.js.map +1 -1
- package/lib/app/AcApDocManager.d.ts.map +1 -1
- package/lib/app/AcApDocManager.js +28 -2
- package/lib/app/AcApDocManager.js.map +1 -1
- package/lib/command/AcApObjectScaleCmd.d.ts +12 -0
- package/lib/command/AcApObjectScaleCmd.d.ts.map +1 -0
- package/lib/command/AcApObjectScaleCmd.js +193 -0
- package/lib/command/AcApObjectScaleCmd.js.map +1 -0
- package/lib/command/AcApSysVarCmd.d.ts.map +1 -1
- package/lib/command/AcApSysVarCmd.js +2 -0
- package/lib/command/AcApSysVarCmd.js.map +1 -1
- package/lib/command/draw/AcApDimAlignedCmd.d.ts +21 -0
- package/lib/command/draw/AcApDimAlignedCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimAlignedCmd.js +218 -0
- package/lib/command/draw/AcApDimAlignedCmd.js.map +1 -0
- package/lib/command/draw/AcApDimAngularCmd.d.ts +27 -0
- package/lib/command/draw/AcApDimAngularCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimAngularCmd.js +264 -0
- package/lib/command/draw/AcApDimAngularCmd.js.map +1 -0
- package/lib/command/draw/AcApDimArcCmd.d.ts +26 -0
- package/lib/command/draw/AcApDimArcCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimArcCmd.js +184 -0
- package/lib/command/draw/AcApDimArcCmd.js.map +1 -0
- package/lib/command/draw/AcApDimBlockUtil.d.ts +40 -0
- package/lib/command/draw/AcApDimBlockUtil.d.ts.map +1 -0
- package/lib/command/draw/AcApDimBlockUtil.js +290 -0
- package/lib/command/draw/AcApDimBlockUtil.js.map +1 -0
- package/lib/command/draw/AcApDimChainUtil.d.ts +13 -0
- package/lib/command/draw/AcApDimChainUtil.d.ts.map +1 -0
- package/lib/command/draw/AcApDimChainUtil.js +140 -0
- package/lib/command/draw/AcApDimChainUtil.js.map +1 -0
- package/lib/command/draw/AcApDimCmdUtil.d.ts +22 -0
- package/lib/command/draw/AcApDimCmdUtil.d.ts.map +1 -0
- package/lib/command/draw/AcApDimCmdUtil.js +125 -0
- package/lib/command/draw/AcApDimCmdUtil.js.map +1 -0
- package/lib/command/draw/AcApDimContinueCmd.d.ts +13 -0
- package/lib/command/draw/AcApDimContinueCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimContinueCmd.js +204 -0
- package/lib/command/draw/AcApDimContinueCmd.js.map +1 -0
- package/lib/command/draw/AcApDimDiameterCmd.d.ts +27 -0
- package/lib/command/draw/AcApDimDiameterCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimDiameterCmd.js +204 -0
- package/lib/command/draw/AcApDimDiameterCmd.js.map +1 -0
- package/lib/command/draw/AcApDimLinearCmd.d.ts +10 -13
- package/lib/command/draw/AcApDimLinearCmd.d.ts.map +1 -1
- package/lib/command/draw/AcApDimLinearCmd.js +161 -97
- package/lib/command/draw/AcApDimLinearCmd.js.map +1 -1
- package/lib/command/draw/AcApDimOrdinateCmd.d.ts +22 -0
- package/lib/command/draw/AcApDimOrdinateCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimOrdinateCmd.js +186 -0
- package/lib/command/draw/AcApDimOrdinateCmd.js.map +1 -0
- package/lib/command/draw/AcApDimRadiusCmd.d.ts +28 -0
- package/lib/command/draw/AcApDimRadiusCmd.d.ts.map +1 -0
- package/lib/command/draw/AcApDimRadiusCmd.js +206 -0
- package/lib/command/draw/AcApDimRadiusCmd.js.map +1 -0
- package/lib/command/draw/index.d.ts +9 -0
- package/lib/command/draw/index.d.ts.map +1 -1
- package/lib/command/draw/index.js +9 -0
- package/lib/command/draw/index.js.map +1 -1
- package/lib/command/index.d.ts +1 -0
- package/lib/command/index.d.ts.map +1 -1
- package/lib/command/index.js +1 -0
- package/lib/command/index.js.map +1 -1
- package/lib/i18n/ar/command.d.ts +34 -0
- package/lib/i18n/ar/command.d.ts.map +1 -1
- package/lib/i18n/ar/command.js +1 -1
- package/lib/i18n/ar/command.js.map +1 -1
- package/lib/i18n/ar/jig.d.ts +244 -0
- package/lib/i18n/ar/jig.d.ts.map +1 -1
- package/lib/i18n/ar/jig.js +1 -1
- package/lib/i18n/ar/jig.js.map +1 -1
- package/lib/i18n/cs/command.d.ts +24 -0
- package/lib/i18n/cs/command.d.ts.map +1 -1
- package/lib/i18n/cs/command.js +24 -0
- package/lib/i18n/cs/command.js.map +1 -1
- package/lib/i18n/cs/jig.d.ts +221 -0
- package/lib/i18n/cs/jig.d.ts.map +1 -1
- package/lib/i18n/cs/jig.js +21 -5
- package/lib/i18n/cs/jig.js.map +1 -1
- package/lib/i18n/en/command.d.ts +34 -0
- package/lib/i18n/en/command.d.ts.map +1 -1
- package/lib/i18n/en/command.js +34 -0
- package/lib/i18n/en/command.js.map +1 -1
- package/lib/i18n/en/jig.d.ts +244 -0
- package/lib/i18n/en/jig.d.ts.map +1 -1
- package/lib/i18n/en/jig.js +147 -3
- package/lib/i18n/en/jig.js.map +1 -1
- package/lib/i18n/tr/command.d.ts +24 -0
- package/lib/i18n/tr/command.d.ts.map +1 -1
- package/lib/i18n/tr/command.js +24 -0
- package/lib/i18n/tr/command.js.map +1 -1
- package/lib/i18n/tr/jig.d.ts +221 -0
- package/lib/i18n/tr/jig.d.ts.map +1 -1
- package/lib/i18n/tr/jig.js +21 -5
- package/lib/i18n/tr/jig.js.map +1 -1
- package/lib/i18n/zh/command.d.ts +34 -0
- package/lib/i18n/zh/command.d.ts.map +1 -1
- package/lib/i18n/zh/command.js +34 -0
- package/lib/i18n/zh/command.js.map +1 -1
- package/lib/i18n/zh/jig.d.ts +244 -0
- package/lib/i18n/zh/jig.d.ts.map +1 -1
- package/lib/i18n/zh/jig.js +135 -3
- package/lib/i18n/zh/jig.js.map +1 -1
- package/lib/view/AcTrView2d.d.ts +28 -0
- package/lib/view/AcTrView2d.d.ts.map +1 -1
- package/lib/view/AcTrView2d.js +147 -74
- package/lib/view/AcTrView2d.js.map +1 -1
- package/package.json +5 -5
- package/src/find/AcApFindNavigator.ts +190 -190
- package/src/find/findDrawing.ts +211 -211
- package/src/find/findText.ts +624 -624
- package/src/find/index.ts +2 -2
- package/src/ui/icons.ts +411 -411
package/src/find/findText.ts
CHANGED
|
@@ -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
|
+
}
|