@vyaz/core 0.0.5 → 0.0.7
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/dist/compile/DocumentCompiler.d.ts +83 -0
- package/dist/index.browser.d.ts +28 -0
- package/dist/index.browser.js +18 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +13 -147930
- package/dist/layout/AutoFitEngine.d.ts +44 -0
- package/dist/layout/LineBoxValidator.d.ts +25 -0
- package/dist/layout/ParagraphLayoutEngine.d.ts +46 -0
- package/dist/layout/PositioningEngine.d.ts +68 -0
- package/dist/layout/TextFrameLayoutEngine.d.ts +50 -0
- package/{src/layout/estimateWidth.ts → dist/layout/estimateWidth.d.ts} +2 -40
- package/dist/measure/FontEngine.d.ts +47 -0
- package/dist/measure/FontMetricsProvider.d.ts +49 -0
- package/dist/measure/FontNotFoundError.d.ts +6 -0
- package/dist/measure/SystemFontRegistry.d.ts +46 -0
- package/dist/measure/canvas-polyfill.d.ts +30 -0
- package/dist/types/Document.d.ts +599 -0
- package/dist/types/FontTypes.d.ts +61 -0
- package/dist/types/LayoutTypes.d.ts +137 -0
- package/{src/utils/env.ts → dist/utils/env.d.ts} +1 -8
- package/{src/utils/font.ts → dist/utils/font.d.ts} +1 -13
- package/{src/utils/groupLinesByParagraph.ts → dist/utils/groupLinesByParagraph.d.ts} +7 -37
- package/dist/utils/list.d.ts +41 -0
- package/dist/utils/textTransform.d.ts +32 -0
- package/package.json +13 -13
- package/src/compile/DocumentCompiler.ts +0 -144
- package/src/index.browser.ts +0 -90
- package/src/index.ts +0 -97
- package/src/layout/AutoFitEngine.ts +0 -101
- package/src/layout/LineBoxValidator.ts +0 -162
- package/src/layout/ParagraphLayoutEngine.ts +0 -264
- package/src/layout/PositioningEngine.ts +0 -615
- package/src/layout/TextFrameLayoutEngine.ts +0 -363
- package/src/measure/FontEngine.ts +0 -144
- package/src/measure/FontMetricsProvider.ts +0 -224
- package/src/measure/FontNotFoundError.ts +0 -16
- package/src/measure/SystemFontRegistry.ts +0 -152
- package/src/measure/canvas-polyfill.d.ts +0 -6
- package/src/measure/canvas-polyfill.ts +0 -243
- package/src/measure/fontkit.d.ts +0 -44
- package/src/types/Document.ts +0 -666
- package/src/types/FontTypes.ts +0 -74
- package/src/types/LayoutTypes.ts +0 -158
- package/src/utils/list.ts +0 -107
- package/src/utils/textTransform.ts +0 -96
package/src/index.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @vyaz/core — Public API.
|
|
3
|
-
*
|
|
4
|
-
* Exports input types (Logical level), output types (Physical Box Model),
|
|
5
|
-
* layout engines, font metric providers, renderers, and utilities.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// ── Input types (Logical level) ─────────────────────────────────────────
|
|
9
|
-
export type {
|
|
10
|
-
TextFrame,
|
|
11
|
-
Paragraph,
|
|
12
|
-
ParagraphStyle,
|
|
13
|
-
TextRun,
|
|
14
|
-
InlineWidget,
|
|
15
|
-
AutofitConfig,
|
|
16
|
-
TextAlignment,
|
|
17
|
-
WritingMode,
|
|
18
|
-
TextOrientation,
|
|
19
|
-
VerticalAlignment,
|
|
20
|
-
ScriptType,
|
|
21
|
-
WhiteSpace,
|
|
22
|
-
MultiColumnConfig,
|
|
23
|
-
DominantBaseline,
|
|
24
|
-
LineFitEdge,
|
|
25
|
-
TextAlignLast,
|
|
26
|
-
WordBreak,
|
|
27
|
-
LineBreak,
|
|
28
|
-
OverflowWrap,
|
|
29
|
-
TextDecorationStyle,
|
|
30
|
-
TextTransform,
|
|
31
|
-
ListType,
|
|
32
|
-
NumberFormat,
|
|
33
|
-
ListStylePosition,
|
|
34
|
-
ListStyle,
|
|
35
|
-
} from './types/Document.js';
|
|
36
|
-
export {
|
|
37
|
-
DEFAULT_PARAGRAPH_STYLE,
|
|
38
|
-
DEFAULT_TEXT_STYLE,
|
|
39
|
-
} from './types/Document.js';
|
|
40
|
-
|
|
41
|
-
// ── Output types (Physical Box Model) ───────────────────────────────────
|
|
42
|
-
export type {
|
|
43
|
-
ParagraphLayoutResult,
|
|
44
|
-
Line,
|
|
45
|
-
Span,
|
|
46
|
-
SpanFontMetrics,
|
|
47
|
-
SemanticParagraph,
|
|
48
|
-
SemanticLine,
|
|
49
|
-
SemanticFragment,
|
|
50
|
-
} from './types/LayoutTypes.js';
|
|
51
|
-
|
|
52
|
-
// ── Font types ──────────────────────────────────────────────────────────
|
|
53
|
-
export type {
|
|
54
|
-
FontMetrics,
|
|
55
|
-
IFontMetricsProvider,
|
|
56
|
-
GlyphData,
|
|
57
|
-
} from './types/FontTypes.js';
|
|
58
|
-
|
|
59
|
-
// ── Layout Engine ───────────────────────────────────────────────────────
|
|
60
|
-
export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
|
|
61
|
-
export { positionLines } from './layout/PositioningEngine.js';
|
|
62
|
-
export { assertLineInvariants, linesToYAML } from './layout/LineBoxValidator.js';
|
|
63
|
-
export type { InvariantError } from './layout/LineBoxValidator.js';
|
|
64
|
-
|
|
65
|
-
// ── TextFrame Layout Engine ──────────────────────────────────────────────
|
|
66
|
-
export { layoutTextFrame } from './layout/TextFrameLayoutEngine.js';
|
|
67
|
-
export type { TextFrameLayoutResult } from './layout/TextFrameLayoutEngine.js';
|
|
68
|
-
|
|
69
|
-
// ── Autofit ─────────────────────────────────────────────────────────────
|
|
70
|
-
export { applyScale, findScale } from './layout/AutoFitEngine.js';
|
|
71
|
-
export type { AutoFitOptions, AutoFitResult } from './layout/AutoFitEngine.js';
|
|
72
|
-
|
|
73
|
-
// ── Utils ────────────────────────────────────────────────────────────────
|
|
74
|
-
export { groupLinesByParagraph } from './utils/groupLinesByParagraph.js';
|
|
75
|
-
export type { ParagraphGroup } from './utils/groupLinesByParagraph.js';
|
|
76
|
-
export { transformText } from './utils/textTransform.js';
|
|
77
|
-
export { formatListNumber, defaultBulletChar, BULLET_CHARACTERS } from './utils/list.js';
|
|
78
|
-
|
|
79
|
-
// ── Compiler ────────────────────────────────────────────────────────────
|
|
80
|
-
export { compileParagraph, getParagraphText, makeFontToken } from './compile/DocumentCompiler.js';
|
|
81
|
-
export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
|
|
82
|
-
|
|
83
|
-
// ── Font Engine ──────────────────────────────────────────────────────────
|
|
84
|
-
export type { FontFace } from './measure/FontEngine.js';
|
|
85
|
-
export { createFontFace, getGlyphAdvance, computePixelMetrics, isFontEngineAvailable } from './measure/FontEngine.js';
|
|
86
|
-
|
|
87
|
-
// ── Font metrics ────────────────────────────────────────────────────────
|
|
88
|
-
export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
|
|
89
|
-
|
|
90
|
-
// ── System font registry ────────────────────────────────────────────────
|
|
91
|
-
export { SystemFontRegistry, systemFontRegistry } from './measure/SystemFontRegistry.js';
|
|
92
|
-
|
|
93
|
-
// ── Font utilities ───────────────────────────────────────────────────────
|
|
94
|
-
export { getFontBuffer } from './utils/font.js';
|
|
95
|
-
|
|
96
|
-
// ── Errors ──────────────────────────────────────────────────────────────
|
|
97
|
-
export { FontNotFoundError } from './measure/FontNotFoundError.js';
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AutoFitEngine.ts — proportional font-size scaling (autofit).
|
|
3
|
-
*
|
|
4
|
-
* Algorithm: binary search for scale in [0.0, 1.0].
|
|
5
|
-
* For each candidate scale: temporarily scale fontSize,
|
|
6
|
-
* run full layout, check whether the result fits in
|
|
7
|
-
* maxWidth × maxHeight.
|
|
8
|
-
*
|
|
9
|
-
* Proportions are preserved: every TextRun's fontSize is multiplied
|
|
10
|
-
* by the same scale, the visual hierarchy is unchanged.
|
|
11
|
-
* inlineWidget dimensions are NOT scaled (images keep their size).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { TextFrame } from '../types/Document.js';
|
|
15
|
-
|
|
16
|
-
/** Autofit options */
|
|
17
|
-
export interface AutoFitOptions {
|
|
18
|
-
minScale?: number; // minimum scale (default 0.1)
|
|
19
|
-
tolerance?: number; // binary search tolerance (default 0.01)
|
|
20
|
-
maxIterations?: number; // max iterations (default 50)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Autofit result */
|
|
24
|
-
export interface AutoFitResult {
|
|
25
|
-
scaleFactor: number; // 0.0 … 1.0
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Apply a scale factor to all fontSize values in the document.
|
|
30
|
-
* inlineWidget dimensions are NOT scaled.
|
|
31
|
-
* Returns a NEW document (does not mutate the original).
|
|
32
|
-
*/
|
|
33
|
-
export function applyScale(
|
|
34
|
-
doc: TextFrame,
|
|
35
|
-
scale: number,
|
|
36
|
-
): TextFrame {
|
|
37
|
-
const clone = JSON.parse(JSON.stringify(doc)) as TextFrame;
|
|
38
|
-
|
|
39
|
-
for (const paragraph of clone.paragraphs) {
|
|
40
|
-
for (const run of paragraph.children) {
|
|
41
|
-
run.fontSize = Math.round(run.fontSize * scale * 100) / 100;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (clone.defaultStyle?.fontSize) {
|
|
46
|
-
clone.defaultStyle.fontSize = Math.round(clone.defaultStyle.fontSize * scale * 100) / 100;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return clone;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Find the optimal scale factor for a document.
|
|
54
|
-
*
|
|
55
|
-
* @param doc — source document
|
|
56
|
-
* @param layoutFn — layout(doc) → { height: number; width: number }
|
|
57
|
-
* @param config — autofit maxWidth/maxHeight
|
|
58
|
-
* @param options — search precision
|
|
59
|
-
*/
|
|
60
|
-
export function findScale(
|
|
61
|
-
doc: TextFrame,
|
|
62
|
-
layoutFn: (scaledDoc: TextFrame) => { height: number; width: number },
|
|
63
|
-
config: { maxWidth: number; maxHeight: number },
|
|
64
|
-
options?: AutoFitOptions,
|
|
65
|
-
): AutoFitResult {
|
|
66
|
-
const minScale = options?.minScale ?? 0.1;
|
|
67
|
-
const tolerance = options?.tolerance ?? 0.01;
|
|
68
|
-
const maxIterations = options?.maxIterations ?? 50;
|
|
69
|
-
const maxHeight = config.maxHeight;
|
|
70
|
-
const maxWidth = config.maxWidth;
|
|
71
|
-
|
|
72
|
-
// Check original size
|
|
73
|
-
const origResult = layoutFn(doc);
|
|
74
|
-
if (origResult.height <= maxHeight && origResult.width <= maxWidth) {
|
|
75
|
-
return { scaleFactor: 1 };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Binary search
|
|
79
|
-
let lo = minScale;
|
|
80
|
-
let hi = 1.0;
|
|
81
|
-
let best = minScale;
|
|
82
|
-
|
|
83
|
-
for (let iter = 0; iter < maxIterations; iter++) {
|
|
84
|
-
const mid = (lo + hi) / 2;
|
|
85
|
-
const scaledDoc = applyScale(doc, mid);
|
|
86
|
-
const result = layoutFn(scaledDoc);
|
|
87
|
-
|
|
88
|
-
if (result.height <= maxHeight && result.width <= maxWidth) {
|
|
89
|
-
// scale is valid — try larger
|
|
90
|
-
best = mid;
|
|
91
|
-
lo = mid + tolerance / 2;
|
|
92
|
-
} else {
|
|
93
|
-
// scale is invalid — try smaller
|
|
94
|
-
hi = mid - tolerance / 2;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (hi - lo < tolerance) break;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return { scaleFactor: Math.round(best * 100) / 100 };
|
|
101
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LineBoxValidator.ts — invariant checks and YAML serialization.
|
|
3
|
-
*
|
|
4
|
-
* Parley-inspired invariant checks:
|
|
5
|
-
* NO_OVERLAP, MONOTONIC_Y, INDEX_CONSIST, WIDTH_FIT, BASELINE_EQ
|
|
6
|
-
*
|
|
7
|
-
* YAML snapshots: semantic data only (no metric noise).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { dump } from 'js-yaml';
|
|
11
|
-
import type { Line, Span, SemanticParagraph } from '../types/LayoutTypes.js';
|
|
12
|
-
|
|
13
|
-
const EPSILON = 0.5; // subpixel tolerance
|
|
14
|
-
|
|
15
|
-
// ── Invariant guard ────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
export interface InvariantError {
|
|
18
|
-
invariant: string;
|
|
19
|
-
message: string;
|
|
20
|
-
details?: any;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Check all 5 invariants for a Line array.
|
|
25
|
-
* Throws on first violation.
|
|
26
|
-
*/
|
|
27
|
-
export function assertLineInvariants(
|
|
28
|
-
lines: Line[],
|
|
29
|
-
originalText: string,
|
|
30
|
-
maxWidth: number,
|
|
31
|
-
): void {
|
|
32
|
-
if (lines.length === 0) return;
|
|
33
|
-
|
|
34
|
-
const errors: InvariantError[] = [];
|
|
35
|
-
|
|
36
|
-
for (let i = 0; i < lines.length; i++) {
|
|
37
|
-
const line = lines[i];
|
|
38
|
-
|
|
39
|
-
// 1. NO_OVERLAP: lines[i+1].y >= lines[i].y + lines[i].height
|
|
40
|
-
if (i > 0) {
|
|
41
|
-
const prev = lines[i - 1];
|
|
42
|
-
if (line.y < prev.y + prev.height - EPSILON) {
|
|
43
|
-
errors.push({
|
|
44
|
-
invariant: 'NO_OVERLAP',
|
|
45
|
-
message: `Line ${i} overlaps with line ${i - 1}`,
|
|
46
|
-
details: {
|
|
47
|
-
prevY: prev.y,
|
|
48
|
-
prevHeight: prev.height,
|
|
49
|
-
prevBottom: prev.y + prev.height,
|
|
50
|
-
currentY: line.y,
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 2. MONOTONIC_Y: lines[i+1].y > lines[i].y
|
|
57
|
-
if (i > 0 && line.y <= lines[i - 1].y) {
|
|
58
|
-
errors.push({
|
|
59
|
-
invariant: 'MONOTONIC_Y',
|
|
60
|
-
message: `Line ${i} has Y=${line.y} not > prev Y=${lines[i - 1].y}`,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 3. INDEX_CONSIST: checked below (sum of all lengths)
|
|
65
|
-
if (line.endIndex <= line.startIndex) {
|
|
66
|
-
errors.push({
|
|
67
|
-
invariant: 'INDEX_CONSIST',
|
|
68
|
-
message: `Line ${i}: endIndex=${line.endIndex} <= startIndex=${line.startIndex}`,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 4. WIDTH_FIT: line.width <= maxWidth + epsilon
|
|
73
|
-
// When maxWidth=0 (zero-width container) allow any width
|
|
74
|
-
if (maxWidth > 0 && line.width > maxWidth + EPSILON) {
|
|
75
|
-
errors.push({
|
|
76
|
-
invariant: 'WIDTH_FIT',
|
|
77
|
-
message: `Line ${i}: width=${line.width} > maxWidth=${maxWidth}`,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 5. BASELINE_EQ: all spans in a line have the same baseline
|
|
82
|
-
if (line.spans.length > 1) {
|
|
83
|
-
const firstBaseline = line.baseline;
|
|
84
|
-
for (let j = 0; j < line.spans.length; j++) {
|
|
85
|
-
const span = line.spans[j];
|
|
86
|
-
const spanBaseline = span.fontMetrics.ascent;
|
|
87
|
-
if (Math.abs(spanBaseline - firstBaseline) > EPSILON) {
|
|
88
|
-
// NOTE: baseline may differ for super/sub — that's normal
|
|
89
|
-
// So we only check that baseline is set
|
|
90
|
-
if (spanBaseline <= 0) {
|
|
91
|
-
errors.push({
|
|
92
|
-
invariant: 'BASELINE_EQ',
|
|
93
|
-
message: `Line ${i}, span ${j}: baseline=${spanBaseline} is invalid`,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// 3. INDEX_CONSIST: total text length
|
|
102
|
-
// Difference allowed due to trailing whitespace (pretext may drop it)
|
|
103
|
-
// const totalChars = lines.reduce((sum, l) => sum + (l.endIndex - l.startIndex), 0);
|
|
104
|
-
// if (totalChars > originalText.length + EPSILON) {
|
|
105
|
-
// errors.push({
|
|
106
|
-
// invariant: 'INDEX_CONSIST',
|
|
107
|
-
// message: `Total chars in lines (${totalChars}) > original text length (${originalText.length})`,
|
|
108
|
-
// details: { totalChars, originalLength: originalText.length },
|
|
109
|
-
// });
|
|
110
|
-
// }
|
|
111
|
-
|
|
112
|
-
// if (errors.length > 0) {
|
|
113
|
-
// const msg = errors.map(e => `[${e.invariant}] ${e.message}`).join('\n');
|
|
114
|
-
// throw new Error(`LineBox invariants violated:\n${msg}`);
|
|
115
|
-
// }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ── YAML serialization ─────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
/** Span style label for snapshot */
|
|
121
|
-
function spanStyleLabel(span: Span): 'bold' | 'italic' | 'normal' {
|
|
122
|
-
if (span.style.fontStyle === 'italic') return 'italic';
|
|
123
|
-
const w = span.style.fontWeight;
|
|
124
|
-
if (w === 'bold' || w === 700) return 'bold';
|
|
125
|
-
return 'normal';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Convert Line[] to YAML string for snapshots.
|
|
130
|
-
* Only semantic data: text, x, width, style.
|
|
131
|
-
* No glyphAdvances, fontMetrics (noise), inlineWidget.
|
|
132
|
-
*/
|
|
133
|
-
export function linesToYAML(
|
|
134
|
-
lines: Line[],
|
|
135
|
-
paragraphWidth: number,
|
|
136
|
-
paragraphHeight: number,
|
|
137
|
-
): string {
|
|
138
|
-
const obj: SemanticParagraph = {
|
|
139
|
-
width: paragraphWidth,
|
|
140
|
-
height: paragraphHeight,
|
|
141
|
-
lines: lines.map(line => ({
|
|
142
|
-
y: Math.round(line.y * 100) / 100,
|
|
143
|
-
width: Math.round(line.width * 100) / 100,
|
|
144
|
-
height: Math.round(line.height * 100) / 100,
|
|
145
|
-
baseline: Math.round(line.baseline * 100) / 100,
|
|
146
|
-
fragments: line.spans.map(span => ({
|
|
147
|
-
text: span.text,
|
|
148
|
-
x: Math.round(span.x * 100) / 100,
|
|
149
|
-
width: Math.round(span.width * 100) / 100,
|
|
150
|
-
...(spanStyleLabel(span) !== 'normal' ? { style: spanStyleLabel(span) } : {}),
|
|
151
|
-
})),
|
|
152
|
-
})),
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
return dump(obj, {
|
|
156
|
-
indent: 2,
|
|
157
|
-
lineWidth: 120,
|
|
158
|
-
noRefs: true,
|
|
159
|
-
sortKeys: false,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ParagraphLayoutEngine.ts — main orchestrator.
|
|
3
|
-
*
|
|
4
|
-
* Pipeline:
|
|
5
|
-
* Paragraph
|
|
6
|
-
* → compile (DocumentCompiler)
|
|
7
|
-
* → prepareRichInline (pretext)
|
|
8
|
-
* → walkRichInlineLineRanges + materializeRichInlineLineRange (pretext)
|
|
9
|
-
* → positionLines (PositioningEngine)
|
|
10
|
-
* → assertLineInvariants (LineInvariants)
|
|
11
|
-
*
|
|
12
|
-
* Supports autofit via AutoFitEngine.findScale.
|
|
13
|
-
* Caches PreparedRichInline per paragraph key (Parley LayoutContext pattern).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
// Polyfill OffscreenCanvas for Node.js (node-canvas)
|
|
17
|
-
import '../measure/canvas-polyfill.js';
|
|
18
|
-
|
|
19
|
-
import type { Paragraph, ListStyle } from '../types/Document.js';
|
|
20
|
-
import type { FontMetrics } from '../types/FontTypes.js';
|
|
21
|
-
import type { IFontMetricsProvider } from '../types/FontTypes.js';
|
|
22
|
-
import type { ParagraphLayoutResult } from '../types/LayoutTypes.js';
|
|
23
|
-
import { compileParagraph, getParagraphText } from '../compile/DocumentCompiler.js';
|
|
24
|
-
import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
|
|
25
|
-
import { fontMetricsProvider, MISSING_GLYPH_FACTOR } from '../measure/FontMetricsProvider.js';
|
|
26
|
-
import { FontNotFoundError } from '../measure/FontNotFoundError.js';
|
|
27
|
-
import { positionLines } from './PositioningEngine.js';
|
|
28
|
-
import { assertLineInvariants } from './LineBoxValidator.js';
|
|
29
|
-
|
|
30
|
-
// @ts-ignore
|
|
31
|
-
import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
|
|
32
|
-
|
|
33
|
-
// ── Cache key builder ─────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
function glyphCacheKey(
|
|
36
|
-
text: string,
|
|
37
|
-
fontSize: number,
|
|
38
|
-
fontFamily?: string,
|
|
39
|
-
fontWeight?: string,
|
|
40
|
-
fontStyle?: string,
|
|
41
|
-
): string {
|
|
42
|
-
return `${fontSize}_${fontFamily || ''}_${fontWeight || ''}_${fontStyle || ''}_${text}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
/** Get FontMetrics for a PreparedRichInlineItem */
|
|
48
|
-
function getFontMetricsForItem(item: PreparedRichInlineItem): FontMetrics {
|
|
49
|
-
return fontMetricsProvider.getMetrics(
|
|
50
|
-
item.metadata.style.fontFamily,
|
|
51
|
-
item.metadata.effectiveFontSize,
|
|
52
|
-
String(item.metadata.style.fontWeight || 400),
|
|
53
|
-
item.metadata.style.fontStyle || 'normal',
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── ParagraphLayoutEngine ─────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
export class ParagraphLayoutEngine {
|
|
60
|
-
private preparedCache = new Map<string, PreparedRichInline>();
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Layout a single paragraph — basic variant.
|
|
64
|
-
*
|
|
65
|
-
* @param paragraph — input paragraph
|
|
66
|
-
* @param maxWidth — available container width (px)
|
|
67
|
-
* @param fontProvider — optional metrics provider (default: fontMetricsProvider)
|
|
68
|
-
* @returns ParagraphLayoutResult with Line[]
|
|
69
|
-
*/
|
|
70
|
-
layout(
|
|
71
|
-
paragraph: Paragraph,
|
|
72
|
-
maxWidth: number,
|
|
73
|
-
yOffset: number = 0,
|
|
74
|
-
fontProvider?: IFontMetricsProvider,
|
|
75
|
-
listStyle?: ListStyle,
|
|
76
|
-
listIndex?: number,
|
|
77
|
-
listMarkerWidth?: number,
|
|
78
|
-
): ParagraphLayoutResult {
|
|
79
|
-
const provider = fontProvider || fontMetricsProvider;
|
|
80
|
-
|
|
81
|
-
// Phase 1: Compile
|
|
82
|
-
const items = compileParagraph(paragraph);
|
|
83
|
-
|
|
84
|
-
// Phase 2: Prepare (cached)
|
|
85
|
-
const cacheKey = JSON.stringify(paragraph);
|
|
86
|
-
let prepared = this.preparedCache.get(cacheKey);
|
|
87
|
-
if (!prepared) {
|
|
88
|
-
prepared = prepareRichInline(items);
|
|
89
|
-
this.preparedCache.set(cacheKey, prepared);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Phase 3: Layout — walk lines
|
|
93
|
-
const effectiveMaxWidth = paragraph.style.whiteSpace === 'nowrap' ? Infinity : maxWidth;
|
|
94
|
-
const pretextLines: any[] = [];
|
|
95
|
-
walkRichInlineLineRanges(prepared, effectiveMaxWidth, (range: any) => {
|
|
96
|
-
pretextLines.push(range);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Materialize each line
|
|
100
|
-
const materializedLines: any[] = [];
|
|
101
|
-
for (const range of pretextLines) {
|
|
102
|
-
materializedLines.push(materializeRichInlineLineRange(prepared, range));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Phase 4: Position
|
|
106
|
-
const renderMode = provider.getMode();
|
|
107
|
-
|
|
108
|
-
// Per-layout glyph cache: map<text+font+size, Float32Array>
|
|
109
|
-
// Lives only for the duration of one layout() call.
|
|
110
|
-
const glyphCache = new Map<string, Float32Array>();
|
|
111
|
-
|
|
112
|
-
// Build measureText callback: single fontkit pass, caches advances.
|
|
113
|
-
const measureTextFn = (
|
|
114
|
-
text: string,
|
|
115
|
-
fontSize: number,
|
|
116
|
-
fontFamily?: string,
|
|
117
|
-
fontWeight?: string,
|
|
118
|
-
fontStyle?: string,
|
|
119
|
-
): number => {
|
|
120
|
-
if (!text) return 0;
|
|
121
|
-
const key = glyphCacheKey(text, fontSize, fontFamily, fontWeight, fontStyle);
|
|
122
|
-
|
|
123
|
-
// Check cache first — same text+font may appear across multiple fragments
|
|
124
|
-
let advances = glyphCache.get(key);
|
|
125
|
-
if (!advances) {
|
|
126
|
-
advances = this.computeGlyphAdvances(text, fontSize, fontFamily, fontWeight, fontStyle);
|
|
127
|
-
glyphCache.set(key, advances);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
let total = 0;
|
|
131
|
-
for (let i = 0; i < advances.length; i++) total += advances[i];
|
|
132
|
-
return Math.round(total * 100) / 100;
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const { lines, contentWidth } = positionLines(
|
|
136
|
-
materializedLines,
|
|
137
|
-
items,
|
|
138
|
-
(item) => {
|
|
139
|
-
if (fontProvider) {
|
|
140
|
-
return fontProvider.getMetrics(
|
|
141
|
-
item.metadata.style.fontFamily,
|
|
142
|
-
item.metadata.effectiveFontSize,
|
|
143
|
-
String(item.metadata.style.fontWeight || 400),
|
|
144
|
-
item.metadata.style.fontStyle || 'normal',
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
return getFontMetricsForItem(item);
|
|
148
|
-
},
|
|
149
|
-
paragraph.style,
|
|
150
|
-
maxWidth,
|
|
151
|
-
yOffset,
|
|
152
|
-
renderMode,
|
|
153
|
-
measureTextFn,
|
|
154
|
-
paragraph.id,
|
|
155
|
-
paragraph.style.listStyle,
|
|
156
|
-
paragraph.style.listStyle ? (listIndex ?? 1) : undefined,
|
|
157
|
-
listMarkerWidth,
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Phase 4b: Fill per-glyph advances — pull from cache or compute if miss
|
|
161
|
-
for (const line of lines) {
|
|
162
|
-
for (const span of line.spans) {
|
|
163
|
-
if (span.type === 'text' && span.text.length > 0 && !span.inlineWidget && !span.glyphAdvances) {
|
|
164
|
-
const key = glyphCacheKey(
|
|
165
|
-
span.text,
|
|
166
|
-
span.fontMetrics.fontSize,
|
|
167
|
-
span.style.fontFamily,
|
|
168
|
-
String(span.style.fontWeight || 400),
|
|
169
|
-
span.style.fontStyle || 'normal',
|
|
170
|
-
);
|
|
171
|
-
const cached = glyphCache.get(key);
|
|
172
|
-
if (cached) {
|
|
173
|
-
span.glyphAdvances = Array.from(cached);
|
|
174
|
-
} else {
|
|
175
|
-
// Cache miss (single-fragment spans skip resolveFragmentWidths),
|
|
176
|
-
// compute directly.
|
|
177
|
-
span.glyphAdvances = Array.from(this.computeGlyphAdvances(
|
|
178
|
-
span.text,
|
|
179
|
-
span.fontMetrics.fontSize,
|
|
180
|
-
span.style.fontFamily,
|
|
181
|
-
String(span.style.fontWeight || 400),
|
|
182
|
-
span.style.fontStyle || 'normal',
|
|
183
|
-
));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Phase 5: Validate
|
|
190
|
-
assertLineInvariants(lines, getParagraphText(paragraph), maxWidth);
|
|
191
|
-
|
|
192
|
-
// Phase 6: Compute height
|
|
193
|
-
const totalHeight = lines.length > 0
|
|
194
|
-
? lines[lines.length - 1].y + lines[lines.length - 1].height + (paragraph.style.spaceAfter || 0)
|
|
195
|
-
: 0;
|
|
196
|
-
|
|
197
|
-
// Phase 7: Content BBox
|
|
198
|
-
const contentHeight = lines.length > 0
|
|
199
|
-
? lines[lines.length - 1].y + lines[lines.length - 1].height
|
|
200
|
-
: 0;
|
|
201
|
-
|
|
202
|
-
return { width: maxWidth, height: totalHeight, lines, contentWidth, contentHeight };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Layout with per-glyph advance widths (for SVG glyph mode).
|
|
207
|
-
*
|
|
208
|
-
* glyphAdvances are now filled by layout() automatically, so
|
|
209
|
-
* this method is equivalent to layout(). Kept for API compatibility.
|
|
210
|
-
*/
|
|
211
|
-
layoutGlyph(
|
|
212
|
-
paragraph: Paragraph,
|
|
213
|
-
maxWidth: number,
|
|
214
|
-
yOffset: number = 0,
|
|
215
|
-
): ParagraphLayoutResult {
|
|
216
|
-
return this.layout(paragraph, maxWidth, yOffset);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Compute per-character advance widths via FontEngine (fontkit).
|
|
221
|
-
* Returns Float32Array for memory efficiency and faster iteration.
|
|
222
|
-
*
|
|
223
|
-
* Throws FontNotFoundError if the font is not registered.
|
|
224
|
-
*/
|
|
225
|
-
private computeGlyphAdvances(
|
|
226
|
-
text: string,
|
|
227
|
-
fontSize: number,
|
|
228
|
-
fontFamily?: string,
|
|
229
|
-
fontWeight?: string,
|
|
230
|
-
fontStyle?: string,
|
|
231
|
-
): Float32Array {
|
|
232
|
-
const font = fontMetricsProvider.getFont(
|
|
233
|
-
fontFamily || 'Arial',
|
|
234
|
-
fontWeight || '400',
|
|
235
|
-
fontStyle || 'normal',
|
|
236
|
-
);
|
|
237
|
-
if (!font) {
|
|
238
|
-
throw new FontNotFoundError(
|
|
239
|
-
fontFamily || 'Arial',
|
|
240
|
-
fontWeight || '400',
|
|
241
|
-
fontStyle || 'normal',
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const scale = fontSize / font.unitsPerEm;
|
|
246
|
-
const advances = new Float32Array(text.length);
|
|
247
|
-
|
|
248
|
-
for (let i = 0; i < text.length; i++) {
|
|
249
|
-
const codePoint = text.codePointAt(i)!;
|
|
250
|
-
const advance = font._raw.glyphForCodePoint(codePoint)?.advanceWidth;
|
|
251
|
-
if (advance != null) {
|
|
252
|
-
advances[i] = advance * scale;
|
|
253
|
-
} else {
|
|
254
|
-
advances[i] = fontSize * MISSING_GLYPH_FACTOR;
|
|
255
|
-
}
|
|
256
|
-
if (codePoint > 0xffff) i++;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return advances;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Singleton */
|
|
264
|
-
export const paragraphLayoutEngine = new ParagraphLayoutEngine();
|