Skip to content

Commit f7e7b69

Browse files
authored
feat: surface missed fonts from workers and keep session bookkeeping in sync (#60)
Report fontNotFound from findAndReplaceFont and forward worker misses to the main thread so hosts can drive missing-font UI, while replaceMissedFonts adopts the intersection of worker-filtered maps to avoid stale entries in worker-only mode.
1 parent 8d05e90 commit f7e7b69

8 files changed

Lines changed: 328 additions & 7 deletions

File tree

packages/mtext-renderer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mlightcad/mtext-renderer",
3-
"version": "0.12.6",
3+
"version": "0.12.7",
44
"description": "AutoCAD MText renderer based on Three.js",
55
"license": "MIT",
66
"author": "MLight Lee <mlight.lee@outlook.com>",

packages/mtext-renderer/src/font/fontManager.ts

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -561,29 +561,41 @@ export class FontManager {
561561

562562
/**
563563
* Tries to find the specified font. If not found, uses a replacement font and returns its name.
564+
*
565+
* When the requested face is not loaded, the original name is recorded in
566+
* {@link missedFonts} and {@link events.fontNotFound} is dispatched (once per
567+
* miss cycle) so hosts can surface missing-font UI. Lazy loading still
568+
* schedules {@link requestFont} for the face that will be fetched.
569+
*
564570
* @param fontName - The font name to find
565571
* @returns The original font name if found, or the replacement font name if not found
566572
*/
567573
findAndReplaceFont(fontName: string) {
568574
const requested = fontName == null ? '' : String(fontName)
575+
const missName = this.stripFontFileExtension(requested)
569576
let font = this.loadedFontMap.get(requested.toLowerCase())
577+
if (font == null && missName && missName !== requested) {
578+
font = this.loadedFontMap.get(missName.toLowerCase())
579+
}
570580
if (font == null) {
571581
const mappedFontName = this.fontMapping[requested]
572582
if (mappedFontName) {
573583
font = this.loadedFontMap.get(mappedFontName.toLowerCase())
574584
if (!font && this.lazyFontLoading) {
575585
void this.requestFont(mappedFontName)
576586
}
587+
// Drawing still asked for the original face — report it even when a
588+
// mapping supplies a replacement (do not also request the original).
589+
this.recordMissedFonts(missName, false)
577590
// Prefer the mapped face even while it is still loading (legacy behavior).
578591
return mappedFontName
579592
}
580593
}
581594
if (font) {
582595
return requested
583596
}
584-
if (this.lazyFontLoading && requested) {
585-
void this.requestFont(requested)
586-
}
597+
// Not loaded and no mapping — record miss (schedules lazy load once).
598+
this.recordMissedFonts(missName, true)
587599
// Prefer non-BIGFONT defaults as the primary face. BIGFONT SHX files (hztxt,
588600
// gbcbig, …) map ASCII to GBK fullwidth cells (0xA3xx), which makes Latin
589601
// runs much wider than AutoCAD and triggers false MTEXT wrapping when the
@@ -815,11 +827,96 @@ export class FontManager {
815827
return this.loadedFontMap.has(fontName.toLowerCase())
816828
}
817829

830+
/**
831+
* Applies a missed-font report from another isolate (e.g. a web worker).
832+
* Updates {@link missedFonts} and dispatches {@link events.fontNotFound} on
833+
* first sighting. Does not call {@link requestFont} — the remote isolate owns loading.
834+
*/
835+
applyRemoteFontNotFound(fontName: string, count: number = 1): void {
836+
if (!fontName) {
837+
return
838+
}
839+
const prev = this.missedFonts[fontName] ?? 0
840+
this.missedFonts[fontName] = Math.max(prev, count)
841+
if (prev === 0) {
842+
this.events.fontNotFound.dispatch({
843+
fontName,
844+
count: this.missedFonts[fontName]
845+
})
846+
}
847+
}
848+
849+
/**
850+
* Clears a missed-font entry after a remote isolate reports the font loaded.
851+
* Call before dispatching {@link events.fontLoaded} on the main thread.
852+
*/
853+
applyRemoteFontLoaded(fontName: string): void {
854+
if (!fontName) {
855+
return
856+
}
857+
this.clearMissedFontEntry(fontName)
858+
this.fontRequestFailed.delete(fontName.toLowerCase())
859+
}
860+
861+
/**
862+
* Replaces session-scoped missed-font bookkeeping without dispatching events.
863+
* Entries for faces that are already loaded are dropped.
864+
*/
865+
replaceMissedFonts(fonts: Record<string, number>): void {
866+
const next: Record<string, number> = {}
867+
for (const [name, count] of Object.entries(fonts ?? {})) {
868+
if (!name || this.isFontLoaded(name)) {
869+
continue
870+
}
871+
next[name] = count
872+
}
873+
this.missedFonts = next
874+
}
875+
876+
/**
877+
* Clears {@link missedFonts}. Does not unload faces or cancel in-flight loads.
878+
*/
879+
clearMissedFonts(): void {
880+
this.missedFonts = {}
881+
}
882+
883+
/**
884+
* Strips a trailing `.ttf` / `.otf` / `.woff` / `.shx` extension when present.
885+
* Matches the normalization used by {@link getFontByName}.
886+
*/
887+
private stripFontFileExtension(fontName: string): string {
888+
if (!fontName) {
889+
return fontName
890+
}
891+
const dotIndex = fontName.lastIndexOf('.')
892+
if (
893+
(dotIndex > 0 && dotIndex == fontName.length - 4) ||
894+
dotIndex == fontName.length - 5
895+
) {
896+
return fontName.substring(0, dotIndex)
897+
}
898+
return fontName
899+
}
900+
901+
/**
902+
* Removes {@link missedFonts} entries whose name matches `fontName`
903+
* case-insensitively (CAD style names and loaded face names often differ in case).
904+
*/
905+
private clearMissedFontEntry(fontName: string): void {
906+
const key = fontName.toLowerCase()
907+
for (const name of Object.keys(this.missedFonts)) {
908+
if (name.toLowerCase() === key) {
909+
delete this.missedFonts[name]
910+
}
911+
}
912+
}
913+
818914
/**
819915
* Records a font that was requested but not found
820916
* @param fontName - The name of the font that was not found
917+
* @param scheduleLoad - When true (default), also {@link requestFont} on first miss if lazy loading is on
821918
*/
822-
private recordMissedFonts(fontName: string) {
919+
private recordMissedFonts(fontName: string, scheduleLoad: boolean = true) {
823920
if (fontName) {
824921
if (!this.missedFonts[fontName]) {
825922
this.missedFonts[fontName] = 0
@@ -831,7 +928,7 @@ export class FontManager {
831928
fontName: fontName,
832929
count: this.missedFonts[fontName]
833930
})
834-
if (this.lazyFontLoading) {
931+
if (scheduleLoad && this.lazyFontLoading) {
835932
void this.requestFont(fontName)
836933
}
837934
}
@@ -892,7 +989,7 @@ export class FontManager {
892989
if (epoch !== this.loadEpoch || !this.isFontLoaded(fontName)) {
893990
return
894991
}
895-
delete this.missedFonts[fontName]
992+
this.clearMissedFontEntry(fontName)
896993
this.fontRequestFailed.delete(fontName.toLowerCase())
897994
this.events.fontLoaded.dispatch({
898995
fontName: fontName

packages/mtext-renderer/src/worker/mtextWorker.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface WorkerMessage {
2424
| 'setLazyFontLoading'
2525
| 'setAwaitFontsBeforeDraw'
2626
| 'setFontUrl'
27+
| 'setMissedFonts'
2728
| 'getAvailableFonts'
2829
| 'getMemoryStats'
2930
id: string
@@ -33,6 +34,7 @@ interface WorkerMessage {
3334
colorSettings?: unknown
3435
fonts?: string[]
3536
symbolFonts?: string[]
37+
missedFonts?: Record<string, number>
3638
url?: string
3739
enabled?: boolean
3840
}
@@ -46,9 +48,11 @@ interface WorkerResponse {
4648
| 'setLazyFontLoading'
4749
| 'setAwaitFontsBeforeDraw'
4850
| 'setFontUrl'
51+
| 'setMissedFonts'
4952
| 'getAvailableFonts'
5053
| 'getMemoryStats'
5154
| 'fontLoaded'
55+
| 'fontNotFound'
5256
| 'error'
5357
id: string
5458
success: boolean
@@ -70,6 +74,16 @@ fontManager.events.fontLoaded.addEventListener(payload => {
7074
} as WorkerResponse)
7175
})
7276

77+
// Forward missed-font reports so the main thread can drive status-bar / UI.
78+
fontManager.events.fontNotFound.addEventListener(payload => {
79+
self.postMessage({
80+
type: 'fontNotFound',
81+
id: '',
82+
success: true,
83+
data: { fontName: payload?.fontName, count: payload?.count }
84+
} as WorkerResponse)
85+
})
86+
7387
// Handle messages from main thread
7488
self.addEventListener('message', async (event: MessageEvent<WorkerMessage>) => {
7589
const { type, id, data } = event.data
@@ -192,6 +206,20 @@ self.addEventListener('message', async (event: MessageEvent<WorkerMessage>) => {
192206
break
193207
}
194208

209+
case 'setMissedFonts': {
210+
const { missedFonts } = (data ?? {}) as {
211+
missedFonts?: Record<string, number>
212+
}
213+
fontManager.replaceMissedFonts(missedFonts ?? {})
214+
self.postMessage({
215+
type: 'setMissedFonts',
216+
id,
217+
success: true,
218+
data: { missedFonts: { ...fontManager.missedFonts } }
219+
} as WorkerResponse)
220+
break
221+
}
222+
195223
case 'getAvailableFonts': {
196224
const fonts = await FontManager.instance.getAvailableFonts()
197225

packages/mtext-renderer/src/worker/unifiedRenderer.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,24 @@ export class UnifiedRenderer {
236236
}
237237
}
238238

239+
/**
240+
* Replaces session-scoped missed-font bookkeeping on the main thread and workers.
241+
* When a worker pool is active, {@link WebWorkerRenderer.replaceMissedFonts}
242+
* owns the final main-thread map (intersection of worker-filtered results).
243+
*/
244+
async replaceMissedFonts(fonts: Record<string, number>): Promise<void> {
245+
if (this.webWorkerRenderer) {
246+
await this.webWorkerRenderer.replaceMissedFonts(fonts)
247+
return
248+
}
249+
FontManager.instance.replaceMissedFonts(fonts)
250+
}
251+
252+
/** Clears session-scoped missed-font bookkeeping on the main thread and workers. */
253+
async clearMissedFonts(): Promise<void> {
254+
await this.replaceMissedFonts({})
255+
}
256+
239257
/**
240258
* Returns font names for a predefined default-font preset.
241259
*/

packages/mtext-renderer/src/worker/webWorkerRenderer.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,20 @@ type SetAwaitFontsBeforeDrawMessage = WorkerMessageBase<
111111
}
112112
>
113113

114+
type SetMissedFontsMessage = WorkerMessageBase<
115+
'setMissedFonts',
116+
{
117+
missedFonts: Record<string, number>
118+
}
119+
>
120+
114121
type WorkerMessageTyped =
115122
| RenderMessage
116123
| LoadFontsMessage
117124
| SetDefaultFontsMessage
118125
| SetLazyFontLoadingMessage
119126
| SetAwaitFontsBeforeDrawMessage
127+
| SetMissedFontsMessage
120128
| SetFontUrlMessage
121129
| GetAvailableFontsMessage
122130
| GetMemoryStatsMessage
@@ -169,6 +177,13 @@ type SetAwaitFontsBeforeDrawResponse = WorkerResponseBase<
169177
}
170178
>
171179

180+
type SetMissedFontsResponse = WorkerResponseBase<
181+
'setMissedFonts',
182+
{
183+
missedFonts: Record<string, number>
184+
}
185+
>
186+
172187
/** Push notification from a worker when a font finishes lazy-loading. */
173188
type FontLoadedNotification = WorkerResponseBase<
174189
'fontLoaded',
@@ -177,16 +192,27 @@ type FontLoadedNotification = WorkerResponseBase<
177192
}
178193
>
179194

195+
/** Push notification from a worker when a requested face is first recorded as missing. */
196+
type FontNotFoundNotification = WorkerResponseBase<
197+
'fontNotFound',
198+
{
199+
fontName: string
200+
count?: number
201+
}
202+
>
203+
180204
type WorkerResponseTyped =
181205
| RenderResponse
182206
| LoadFontsResponse
183207
| SetDefaultFontsResponse
184208
| SetLazyFontLoadingResponse
185209
| SetAwaitFontsBeforeDrawResponse
186210
| SetFontUrlResponse
211+
| SetMissedFontsResponse
187212
| GetAvailableFontsResponse
188213
| GetMemoryStatsResponse
189214
| FontLoadedNotification
215+
| FontNotFoundNotification
190216

191217
// Serialized MText data from worker (JSON-based)
192218
interface SerializedMText {
@@ -348,6 +374,7 @@ export class WebWorkerRenderer implements MTextBaseRenderer {
348374
void this.syncFontToWorkerPool(fontName)
349375
.then(shouldDispatch => {
350376
if (shouldDispatch) {
377+
FontManager.instance.applyRemoteFontLoaded(fontName)
351378
FontManager.instance.events.fontLoaded.dispatch({ fontName })
352379
}
353380
})
@@ -361,6 +388,17 @@ export class WebWorkerRenderer implements MTextBaseRenderer {
361388
return
362389
}
363390

391+
if (response.type === 'fontNotFound') {
392+
const fontName = response.data?.fontName
393+
if (fontName) {
394+
FontManager.instance.applyRemoteFontNotFound(
395+
fontName,
396+
response.data?.count ?? 1
397+
)
398+
}
399+
return
400+
}
401+
364402
const { id, success, data, error } = response
365403
const pendingRequest = this.pendingRequests.get(id)
366404

@@ -617,6 +655,45 @@ export class WebWorkerRenderer implements MTextBaseRenderer {
617655
})
618656
}
619657

658+
/**
659+
* Replaces session-scoped {@link FontManager.missedFonts} on every worker,
660+
* then adopts the intersection of worker-filtered maps on the main thread.
661+
*
662+
* Workers drop faces they have already loaded; in worker-only mode the main
663+
* isolate often has an empty {@link FontManager.loadedFontMap}, so the
664+
* authoritative "still missing" set comes from the pool responses.
665+
*/
666+
async replaceMissedFonts(fonts: Record<string, number>): Promise<void> {
667+
const results = await this.sendMessageToAllWorkers<
668+
SetMissedFontsMessage,
669+
SetMissedFontsResponse
670+
>({
671+
type: 'setMissedFonts',
672+
data: { missedFonts: { ...(fonts ?? {}) } }
673+
})
674+
675+
if (results.length === 0) {
676+
FontManager.instance.replaceMissedFonts(fonts ?? {})
677+
return
678+
}
679+
680+
const intersected: Record<string, number> = {}
681+
for (const name of Object.keys(results[0]?.missedFonts ?? {})) {
682+
if (!results.every(r => (r?.missedFonts?.[name] ?? 0) > 0)) {
683+
continue
684+
}
685+
intersected[name] = Math.max(
686+
...results.map(r => r?.missedFonts?.[name] ?? 0)
687+
)
688+
}
689+
FontManager.instance.replaceMissedFonts(intersected)
690+
}
691+
692+
/** Clears {@link FontManager.missedFonts} on the main thread and every worker. */
693+
async clearMissedFonts(): Promise<void> {
694+
await this.replaceMissedFonts({})
695+
}
696+
620697
/**
621698
* Render MText in one worker and return serialized data asynchronously.
622699
*/

0 commit comments

Comments
 (0)