Skip to content

Commit bad0d05

Browse files
authored
fix: use em-square scale for CJK mesh fonts to stop false MTEXT wraps (#61)
1 parent f7e7b69 commit bad0d05

7 files changed

Lines changed: 307 additions & 14 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.7",
3+
"version": "0.12.8",
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/meshFont.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
MESH_PARSED_FONT_OVERHEAD} from '../memory/types'
99
import { BaseFont } from './baseFont'
1010
import { FontData } from './font'
11+
import { computeMeshFontScaleFactor } from './meshFontScaleFactor'
1112
import { MeshTextShape } from './meshTextShape'
1213
import { ThreeFont } from './threeFont'
1314

@@ -114,11 +115,7 @@ export class MeshFont extends BaseFont {
114115
const font = parse(data)
115116
const round = Math.round
116117

117-
// Use character 'A' to calculate scale factor
118-
const scaleGlyph = font.charToGlyph('A')
119-
const scaleFactor = scaleGlyph
120-
? font.unitsPerEm / (scaleGlyph.yMax || font.unitsPerEm)
121-
: 1
118+
const scaleFactor = computeMeshFontScaleFactor(font)
122119

123120
const meshData: MeshFontData = {
124121
glyphs: {}, // Lazy loaded later

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { parse } from 'opentype.js'
22

33
import { MeshFontData } from './meshFont'
4+
import { computeMeshFontScaleFactor } from './meshFontScaleFactor'
45

56
/**
67
* Parses a mesh font from raw binary data.
@@ -19,12 +20,7 @@ export function parseMeshFont(data: ArrayBuffer) {
1920
const glyphIndexMap = font.encoding.cmap.glyphIndexMap
2021
const unicodes = Object.keys(glyphIndexMap)
2122

22-
// Use character 'A' to calculate scale factor
23-
const scaleFactorCharGlyph = font.glyphs.glyphs[glyphIndexMap[65]]
24-
let scaleFactor = 1
25-
if (scaleFactorCharGlyph) {
26-
scaleFactor = font.unitsPerEm / scaleFactorCharGlyph.yMax
27-
}
23+
const scaleFactor = computeMeshFontScaleFactor(font)
2824

2925
for (let i = 0; i < unicodes.length; i++) {
3026
const unicode = unicodes[i]
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Minimal opentype glyph fields used when deriving a mesh-font scale factor.
3+
*/
4+
export interface MeshFontScaleGlyph {
5+
yMax?: number
6+
advanceWidth?: number
7+
}
8+
9+
/**
10+
* Minimal opentype font surface needed to compute {@link computeMeshFontScaleFactor}.
11+
*
12+
* Prefer {@link MeshFontScaleSource.charToGlyphIndex} over relying on
13+
* {@link MeshFontScaleSource.charToGlyph} alone: opentype.js returns the
14+
* `.notdef` glyph (index 0) for missing code points, so a truthy glyph object
15+
* does not mean the face actually contains that character.
16+
*/
17+
export interface MeshFontScaleSource {
18+
unitsPerEm: number
19+
charToGlyph: (char: string) => MeshFontScaleGlyph | undefined
20+
charToGlyphIndex: (char: string) => number | null | undefined
21+
}
22+
23+
/**
24+
* Threshold above which a Latin-'A'-based scale is treated as unsafe for fonts
25+
* that also contain full-em CJK ideographs (SimSun / SimFang / etc.).
26+
*/
27+
export const CJK_LATIN_SCALE_INFLATION_THRESHOLD = 1.15
28+
29+
/**
30+
* Fraction of the em square at which an ideograph advance is treated as a
31+
* full-cell CJK design glyph.
32+
*/
33+
export const CJK_FULL_EM_ADVANCE_RATIO = 0.9
34+
35+
const CJK_PROBE_CHARS = ['国', '中', '永'] as const
36+
37+
function hasRealGlyph(
38+
font: MeshFontScaleSource,
39+
char: string
40+
): boolean {
41+
const index = font.charToGlyphIndex(char)
42+
return index != null && index > 0
43+
}
44+
45+
/**
46+
* Computes the mesh-font scale that maps CAD text height onto glyph outlines.
47+
*
48+
* AutoCAD maps TrueType text height to the font design size. For Western faces,
49+
* capital {@code A} height is a good proxy (`unitsPerEm / A.yMax`). For CJK
50+
* faces, ideographs occupy the full em while Latin capitals are much shorter —
51+
* using {@code A.yMax} then inflates both glyph size and advance (~1.4× for
52+
* SimFang/SimSun), which falsely wraps MTEXT that AutoCAD keeps on one line.
53+
*
54+
* When the font has full-em ideographs and the Latin-based scale would inflate
55+
* advances past {@link CJK_LATIN_SCALE_INFLATION_THRESHOLD}, return {@code 1}
56+
* so text height maps to the em square (AutoCAD CJK TrueType behavior).
57+
*/
58+
export function computeMeshFontScaleFactor(font: MeshFontScaleSource): number {
59+
const unitsPerEm = font.unitsPerEm || 1000
60+
if (!hasRealGlyph(font, 'A')) {
61+
return 1
62+
}
63+
64+
const latin = font.charToGlyph('A')
65+
const latinYMax = latin?.yMax
66+
if (!latinYMax || latinYMax <= 0) {
67+
return 1
68+
}
69+
70+
const latinScale = unitsPerEm / latinYMax
71+
let cjkAdvance = 0
72+
for (const char of CJK_PROBE_CHARS) {
73+
if (!hasRealGlyph(font, char)) continue
74+
cjkAdvance = font.charToGlyph(char)?.advanceWidth ?? 0
75+
break
76+
}
77+
if (
78+
cjkAdvance >= unitsPerEm * CJK_FULL_EM_ADVANCE_RATIO &&
79+
latinScale > CJK_LATIN_SCALE_INFLATION_THRESHOLD
80+
) {
81+
return 1
82+
}
83+
84+
return latinScale
85+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
CJK_LATIN_SCALE_INFLATION_THRESHOLD,
5+
computeMeshFontScaleFactor
6+
} from '../../src/font/meshFontScaleFactor'
7+
8+
describe('computeMeshFontScaleFactor', () => {
9+
it('uses Latin capital height for Western-only fonts', () => {
10+
const scale = computeMeshFontScaleFactor({
11+
unitsPerEm: 1000,
12+
charToGlyphIndex: (char: string) => (char === 'A' ? 36 : 0),
13+
charToGlyph: (char: string) => {
14+
if (char === 'A') return { yMax: 700, advanceWidth: 600 }
15+
// opentype.js returns .notdef (index 0) for missing glyphs
16+
return { yMax: 800, advanceWidth: 1000 }
17+
}
18+
})
19+
expect(scale).toBeCloseTo(1000 / 700)
20+
})
21+
22+
it('ignores .notdef CJK probes on Western fonts with full-em notdef advance', () => {
23+
const latinScale = 1000 / 700
24+
expect(latinScale).toBeGreaterThan(CJK_LATIN_SCALE_INFLATION_THRESHOLD)
25+
const scale = computeMeshFontScaleFactor({
26+
unitsPerEm: 1000,
27+
charToGlyphIndex: (char: string) => (char === 'A' ? 36 : 0),
28+
charToGlyph: (char: string) => {
29+
if (char === 'A') return { yMax: 700, advanceWidth: 600 }
30+
return { yMax: 800, advanceWidth: 1000 }
31+
}
32+
})
33+
expect(scale).toBeCloseTo(latinScale)
34+
})
35+
36+
it('returns 1 for CJK fonts where Latin scale would inflate advances', () => {
37+
const latinScale = 1000 / 683
38+
expect(latinScale).toBeGreaterThan(CJK_LATIN_SCALE_INFLATION_THRESHOLD)
39+
const scale = computeMeshFontScaleFactor({
40+
unitsPerEm: 1000,
41+
charToGlyphIndex: (char: string) => {
42+
if (char === 'A') return 36
43+
if (char === '国') return 9726
44+
return 0
45+
},
46+
charToGlyph: (char: string) => {
47+
if (char === 'A') return { yMax: 683, advanceWidth: 600 }
48+
if (char === '国') return { yMax: 880, advanceWidth: 1000 }
49+
return undefined
50+
}
51+
})
52+
expect(scale).toBe(1)
53+
})
54+
})

packages/mtext-renderer/test/renderer/mtext.missing-font-latin-wrap.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,13 @@ describe('missing style font Latin wrap width', () => {
7474
await loadFont('simsun', 'simsun.woff')
7575

7676
expect(FontManager.instance.findAndReplaceFont('标准')).toBe('simsun')
77+
// CJK TrueType faces use em-square scale (not Latin-A inflation).
78+
expect(FontManager.instance.getFontScaleFactor('simsun')).toBe(1)
7779

7880
const wrapWidth = 31.945271
79-
const autocadExtents = 29.527606
81+
// Measured under em-square SimSun scale. The previous ~29.5 value assumed
82+
// Latin-A inflation (~1.43×) and no longer matches AutoCAD CJK TrueType.
83+
const expectedExtents = 20.646255
8084

8185
const unconstrained = new MText(
8286
{
@@ -95,7 +99,7 @@ describe('missing style font Latin wrap width', () => {
9599
unconstrained.syncDraw()
96100
const contentWidth =
97101
unconstrained.box.max.x - unconstrained.box.min.x
98-
expect(contentWidth).toBeCloseTo(autocadExtents, 1)
102+
expect(contentWidth).toBeCloseTo(expectedExtents, 1)
99103
expect(contentWidth).toBeLessThan(wrapWidth)
100104

101105
const wrapped = new MText(
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import * as THREE from 'three'
2+
import { afterEach, describe, expect, it } from 'vitest'
3+
import fs from 'node:fs'
4+
import path from 'node:path'
5+
6+
import { FontData } from '../../src/font/font'
7+
import { FontFactory } from '../../src/font/fontFactory'
8+
import { FontManager } from '../../src/font/fontManager'
9+
import { MText } from '../../src/renderer/mtext'
10+
import {
11+
createDefaultColorSettings,
12+
MTextAttachmentPoint,
13+
TextStyle
14+
} from '../../src/renderer/types'
15+
16+
const FONT_BASE = 'https://cdn.jsdelivr.net/gh/mlightcad/cad-data/fonts/'
17+
const LOCAL_SIMFANG = path.resolve('D:/code/cad-data/fonts/simfang.woff')
18+
19+
async function loadSimfangFont(): Promise<void> {
20+
let data: ArrayBuffer
21+
if (fs.existsSync(LOCAL_SIMFANG)) {
22+
const buffer = fs.readFileSync(LOCAL_SIMFANG)
23+
data = buffer.buffer.slice(
24+
buffer.byteOffset,
25+
buffer.byteOffset + buffer.byteLength
26+
)
27+
} else {
28+
const response = await fetch(FONT_BASE + 'simfang.woff')
29+
if (!response.ok) throw new Error(`Failed to fetch simfang.woff`)
30+
data = await response.arrayBuffer()
31+
}
32+
33+
const aliases = ['simfang', 'SIMFANG', '仿宋体', '仿宋']
34+
const fontData: FontData = {
35+
name: 'simfang',
36+
type: 'mesh',
37+
data,
38+
alias: aliases
39+
}
40+
const font = FontFactory.instance.createFont(fontData)
41+
for (const alias of aliases) {
42+
font.names.add(alias)
43+
;(
44+
FontManager.instance as unknown as {
45+
loadedFontMap: Map<string, unknown>
46+
}
47+
).loadedFontMap.set(alias.toLowerCase(), font)
48+
}
49+
}
50+
51+
const styleManager = {
52+
unsupportedTextStyles: {},
53+
getMeshBasicMaterial: () => new THREE.MeshBasicMaterial(),
54+
getLineBasicMaterial: () => new THREE.LineBasicMaterial()
55+
}
56+
57+
/**
58+
* Regression for title-block MTEXT that AutoCAD keeps as two explicit \\P lines
59+
* when the style uses a CJK TrueType face (SimFang). Latin-'A'-based mesh scale
60+
* previously inflated advances (~1.4×) and forced soft wraps inside the defined
61+
* width.
62+
*/
63+
describe('title-block MTEXT wrap (问题四)', () => {
64+
const style: TextStyle = {
65+
name: '仿宋体',
66+
standardFlag: 0,
67+
fixedTextHeight: 0,
68+
widthFactor: 1,
69+
obliqueAngle: 0,
70+
textGenerationFlag: 0,
71+
lastHeight: 67.5,
72+
font: 'SIMFANG',
73+
bigFont: ''
74+
}
75+
76+
afterEach(() => {
77+
FontManager.instance.release()
78+
FontManager.instance.enableFontCache = true
79+
})
80+
81+
it(
82+
'keeps each explicit paragraph line unwrapped inside AutoCAD defined width',
83+
async () => {
84+
FontManager.instance.release()
85+
FontManager.instance.enableFontCache = false
86+
FontManager.instance.setDefaultFonts('modern')
87+
await loadSimfangFont()
88+
89+
expect(FontManager.instance.getFontScaleFactor('SIMFANG')).toBe(1)
90+
91+
const text =
92+
'{\\T1.45; 熊集镇赵庙等6个村高标准农田建设项目规划图\\P(枣阳市2017年农业综合开发高标准农田建设项目)}'
93+
const width = 2252.7342659142396
94+
const height = 67.5
95+
96+
const unconstrained = new MText(
97+
{
98+
text,
99+
height,
100+
width: 0,
101+
position: { x: 0, y: 0, z: 0 },
102+
attachmentPoint: MTextAttachmentPoint.TopLeft,
103+
collectCharBoxes: true
104+
},
105+
style,
106+
styleManager as any,
107+
FontManager.instance as any,
108+
createDefaultColorSettings()
109+
)
110+
unconstrained.syncDraw()
111+
112+
const wrapped = new MText(
113+
{
114+
text,
115+
height,
116+
width,
117+
position: { x: 0, y: 0, z: 0 },
118+
attachmentPoint: MTextAttachmentPoint.TopLeft,
119+
collectCharBoxes: true
120+
},
121+
style,
122+
styleManager as any,
123+
FontManager.instance as any,
124+
createDefaultColorSettings()
125+
)
126+
wrapped.syncDraw()
127+
128+
const softBreaks: number[] = []
129+
let lineCount = 0
130+
wrapped.traverse(obj => {
131+
const lines = obj.userData?.lineLayouts as
132+
| Array<{ breakIndex?: number }>
133+
| undefined
134+
if (!lines?.length) return
135+
lineCount = Math.max(lineCount, lines.length)
136+
lines.forEach(line => {
137+
if (line.breakIndex != null) softBreaks.push(line.breakIndex)
138+
})
139+
})
140+
141+
const uBox = unconstrained.box
142+
const wBox = wrapped.box
143+
const unconstrainedWidth = uBox.max.x - uBox.min.x
144+
const wrappedHeight = wBox.max.y - wBox.min.y
145+
const unconstrainedHeight = uBox.max.y - uBox.min.y
146+
147+
expect(FontManager.instance.getFontScaleFactor('SIMFANG')).toBe(1)
148+
expect(unconstrainedWidth).toBeLessThanOrEqual(width + 1)
149+
// Two explicit \\P lines only — soft wrap would add more height/lines.
150+
expect(lineCount).toBe(2)
151+
expect(wrappedHeight).toBeLessThanOrEqual(unconstrainedHeight + 1)
152+
// One breakIndex for the explicit \\P between the two visual lines.
153+
expect(softBreaks.length).toBe(1)
154+
},
155+
120_000
156+
)
157+
})

0 commit comments

Comments
 (0)