@vyaz/core 0.0.4 → 0.0.5
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/README.md +156 -0
- package/package.json +22 -5
- package/src/compile/DocumentCompiler.ts +11 -3
- package/src/index.browser.ts +90 -0
- package/src/index.ts +17 -0
- package/src/layout/ParagraphLayoutEngine.ts +106 -44
- package/src/layout/PositioningEngine.ts +249 -35
- package/src/layout/TextFrameLayoutEngine.ts +293 -21
- package/src/layout/estimateWidth.ts +74 -0
- package/src/measure/FontEngine.ts +144 -0
- package/src/measure/FontMetricsProvider.ts +74 -76
- package/src/measure/SystemFontRegistry.ts +15 -14
- package/src/measure/canvas-polyfill.ts +17 -9
- package/src/types/Document.ts +129 -3
- package/src/types/LayoutTypes.ts +24 -7
- package/src/utils/env.ts +18 -0
- package/src/utils/font.ts +28 -0
- package/src/utils/groupLinesByParagraph.ts +74 -0
- package/src/utils/list.ts +107 -0
- package/src/utils/textTransform.ts +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @vyaz/core
|
|
2
|
+
|
|
3
|
+
Rich text layout engine — TypeScript, isomorphic (browser + Bun/Node.js), pixel-perfect typography.
|
|
4
|
+
|
|
5
|
+
Part of the [Vyaz](https://github.com/sedrew/vyaz) project. Parses styled text into positioned lines with precise font metrics, supporting both CSS Text and Office (PowerPoint/DrawingML) rendering modes.
|
|
6
|
+
|
|
7
|
+
The engine operates on a **TextFrame → Paragraph → TextRun** hierarchy, following W3C CSS Text, CSS Writing Modes, and CSS Inline Layout specifications.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @vyaz/core
|
|
13
|
+
# or
|
|
14
|
+
npm install @vyaz/core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { layoutTextFrame } from '@vyaz/core';
|
|
21
|
+
import type { TextFrame } from '@vyaz/core';
|
|
22
|
+
|
|
23
|
+
const frame: TextFrame = {
|
|
24
|
+
width: 400,
|
|
25
|
+
wrap: true,
|
|
26
|
+
paragraphs: [
|
|
27
|
+
{
|
|
28
|
+
style: { alignment: 'left', lineHeight: 1.4, spaceBefore: 0, spaceAfter: 0 },
|
|
29
|
+
children: [
|
|
30
|
+
{ text: 'Hello, Vyaz!', fontFamily: 'Arial', fontSize: 16, fontWeight: 'bold', fontStyle: 'normal', color: '#000' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const result = layoutTextFrame(frame);
|
|
37
|
+
console.log(result.lines);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **Text frame layout** — multi-paragraph frames with padding, wrapping, and vertical alignment
|
|
43
|
+
- **Multi-font, multi-style text** — bold, italic, size, color, subscript/superscript, letter-spacing
|
|
44
|
+
- **Text alignment** — left, center, right, justify
|
|
45
|
+
- **Line wrapping** — soft/hard breaks, `white-space` control (normal, nowrap, pre)
|
|
46
|
+
- **Writing modes** — `horizontal-tb`, `vertical-rl`, `vertical-lr` with text orientation
|
|
47
|
+
- **Auto-fit** — scale text proportionally to fit the container (`AutofitConfig`)
|
|
48
|
+
- **Inline widgets** — embedded objects (icons, images) inside the text flow
|
|
49
|
+
- **Office-compatible mode** — `mode: 'office'` for PowerPoint/DrawingML rendering
|
|
50
|
+
- **Font metrics** — system font registry with fontkit-based metric extraction
|
|
51
|
+
- **Compiler** — paragraph compilation with token preparation for external renderers
|
|
52
|
+
|
|
53
|
+
## API Overview
|
|
54
|
+
|
|
55
|
+
### Text Frame Layout
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { layoutTextFrame } from '@vyaz/core';
|
|
59
|
+
import type { TextFrame, TextFrameLayoutResult } from '@vyaz/core';
|
|
60
|
+
|
|
61
|
+
const frame: TextFrame = {
|
|
62
|
+
width: 600,
|
|
63
|
+
height: 400,
|
|
64
|
+
wrap: true,
|
|
65
|
+
padding: { top: 20, right: 20, bottom: 20, left: 20 },
|
|
66
|
+
verticalAlignment: 'top',
|
|
67
|
+
paragraphs: [
|
|
68
|
+
{
|
|
69
|
+
style: { alignment: 'left', lineHeight: 1.4, spaceBefore: 0, spaceAfter: 12 },
|
|
70
|
+
children: [
|
|
71
|
+
{ text: 'First paragraph', fontFamily: 'Arial', fontSize: 16, fontWeight: 'normal', fontStyle: 'normal', color: '#000' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result: TextFrameLayoutResult = layoutTextFrame(frame);
|
|
78
|
+
// → { lines: Line[], frameWidth?, frameHeight?, contentWidth, contentHeight, fitHorizontal, fitVertical }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Autofit
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { applyScale, findScale } from '@vyaz/core';
|
|
85
|
+
import type { AutoFitOptions, AutoFitResult } from '@vyaz/core';
|
|
86
|
+
|
|
87
|
+
const scale: AutoFitResult = findScale(contentWidth, contentHeight, frameWidth, frameHeight);
|
|
88
|
+
const scaledLines = applyScale(result.lines, scale);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Font Registration & Metrics
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import {
|
|
95
|
+
FontMetricsProvider,
|
|
96
|
+
SystemFontRegistry,
|
|
97
|
+
createFontFace,
|
|
98
|
+
getFontBuffer,
|
|
99
|
+
} from '@vyaz/core';
|
|
100
|
+
import type { FontMetrics, IFontMetricsProvider, FontFace } from '@vyaz/core';
|
|
101
|
+
|
|
102
|
+
const provider = new FontMetricsProvider();
|
|
103
|
+
|
|
104
|
+
// Node.js — register from a local file
|
|
105
|
+
import { readFileSync } from 'node:fs';
|
|
106
|
+
const buffer = readFileSync('/path/to/font.ttf');
|
|
107
|
+
await provider.registerFont('MyFont', { weight: 'bold', style: 'normal' }, buffer);
|
|
108
|
+
|
|
109
|
+
// Browser — register from a URL
|
|
110
|
+
await provider.registerFont('MyFont', {}, 'https://example.com/font.woff2');
|
|
111
|
+
|
|
112
|
+
// Get pixel metrics
|
|
113
|
+
const metrics: FontMetrics = provider.getMetrics('MyFont', 16);
|
|
114
|
+
// → { ascent, descent, capHeight, unitsPerEm, sourceTable }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Compiler
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { compileParagraph, getParagraphText, makeFontToken } from '@vyaz/core';
|
|
121
|
+
import type { PreparedRichInlineItem } from '@vyaz/core';
|
|
122
|
+
|
|
123
|
+
const items: PreparedRichInlineItem[] = compileParagraph(paragraph, defaultStyle);
|
|
124
|
+
const text: string = getParagraphText(paragraph);
|
|
125
|
+
const token: string = makeFontToken(fontFamily, fontSize, fontWeight, fontStyle);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Paragraph Layout Engine (low-level)
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { ParagraphLayoutEngine, paragraphLayoutEngine } from '@vyaz/core';
|
|
132
|
+
|
|
133
|
+
const engine = new ParagraphLayoutEngine();
|
|
134
|
+
const result = engine.layout(paragraph, maxWidth, yOffset);
|
|
135
|
+
// → ParagraphLayoutResult { lines: Line[], width, height, contentWidth, contentHeight }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Package Structure
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
src/
|
|
142
|
+
├── compile/ — DocumentCompiler, paragraph→token compilation
|
|
143
|
+
├── layout/ — Layout engines (Paragraph, TextFrame, Positioning, AutoFit)
|
|
144
|
+
├── measure/ — Font metrics, fontkit integration, system font registry
|
|
145
|
+
├── types/ — TypeScript type definitions (Document, Font, Layout)
|
|
146
|
+
└── utils/ — Helpers (font, list, text transform, env detection)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Requirements
|
|
150
|
+
|
|
151
|
+
- **Runtime**: Bun 1.x, Node.js 18+, or modern browser
|
|
152
|
+
- **Optional**: `@napi-rs/canvas`, `fontkit`, `get-system-fonts` for Node.js font metrics
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vyaz/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"files": ["dist", "src"],
|
|
8
|
+
"sideEffects": false,
|
|
8
9
|
"publishConfig": {
|
|
9
10
|
"access": "public"
|
|
10
11
|
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"bun": "./src/index.ts",
|
|
15
|
+
"node": "./src/index.ts",
|
|
16
|
+
"browser": "./src/index.browser.ts",
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"default": "./src/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"browser": {
|
|
22
|
+
"get-system-fonts": false,
|
|
23
|
+
"fs": false,
|
|
24
|
+
"module": false,
|
|
25
|
+
"node:fs": false,
|
|
26
|
+
"child_process": false
|
|
27
|
+
},
|
|
11
28
|
"scripts": {
|
|
12
29
|
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
13
30
|
"test": "bun test"
|
|
@@ -15,15 +32,15 @@
|
|
|
15
32
|
"dependencies": {
|
|
16
33
|
"@chenglou/pretext": "^0.0.8",
|
|
17
34
|
"@clean-jsdoc-theme/typedoc": "^5.0.6",
|
|
18
|
-
"fontkit": "^2.0.4",
|
|
19
|
-
"get-system-fonts": "^2.0.2",
|
|
20
35
|
"js-yaml": "^5.0.0",
|
|
21
36
|
"typedoc": "^0.28.19"
|
|
22
37
|
},
|
|
23
38
|
"optionalDependencies": {
|
|
24
|
-
"@napi-rs/canvas": "^1.0.2"
|
|
39
|
+
"@napi-rs/canvas": "^1.0.2",
|
|
40
|
+
"fontkit": "^2.0.4",
|
|
41
|
+
"get-system-fonts": "^2.0.2"
|
|
25
42
|
},
|
|
26
43
|
"devDependencies": {
|
|
27
44
|
"@types/node": "^26.0.0"
|
|
28
45
|
}
|
|
29
|
-
}
|
|
46
|
+
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* Simple JSON-serialisable format — does not depend on pretext directly.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { Paragraph, TextRun } from '../types/Document.js';
|
|
11
|
+
import type { Paragraph, TextRun, TextTransform } from '../types/Document.js';
|
|
12
12
|
import { DEFAULT_TEXT_STYLE } from '../types/Document.js';
|
|
13
|
+
import { transformText } from '../utils/textTransform.js';
|
|
13
14
|
|
|
14
15
|
// ── Font Weight normalization (matching react-pdf convention) ───────────
|
|
15
16
|
|
|
@@ -52,6 +53,8 @@ export interface PreparedRichInlineItem {
|
|
|
52
53
|
letterSpacing?: number;
|
|
53
54
|
extraWidth?: number; // padding, border for inline-box
|
|
54
55
|
break?: 'normal' | 'never'; // for atomic chips
|
|
56
|
+
/** Original text before text-transform (if transform was applied). Used for copy-paste / round-trip. */
|
|
57
|
+
originalText?: string;
|
|
55
58
|
metadata: {
|
|
56
59
|
originalRunIndex: number;
|
|
57
60
|
baselineOffset: number;
|
|
@@ -63,7 +66,7 @@ export interface PreparedRichInlineItem {
|
|
|
63
66
|
|
|
64
67
|
const SUPER_SUB_SCALE = 0.65;
|
|
65
68
|
const SUPER_OFFSET_RATIO = -0.4;
|
|
66
|
-
const SUB_OFFSET_RATIO = 0.
|
|
69
|
+
const SUB_OFFSET_RATIO = 0.25;
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
72
|
* Compile a paragraph into PreparedRichInlineItem[].
|
|
@@ -88,7 +91,10 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
// Inline-box: text → \uFFFC
|
|
91
|
-
const
|
|
94
|
+
const rawText = run.type === 'inline-box' ? '\uFFFC' : run.text;
|
|
95
|
+
// Apply text-transform (only for text runs, not inline-box)
|
|
96
|
+
const textTransformValue: TextTransform | undefined = run.textTransform;
|
|
97
|
+
const text = transformText(rawText, textTransformValue);
|
|
92
98
|
|
|
93
99
|
// Normalize fontWeight to numeric value
|
|
94
100
|
const resolvedFontWeight = normalizeFontWeight(run.fontWeight ?? DEFAULT_TEXT_STYLE.fontWeight);
|
|
@@ -107,6 +113,8 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
|
|
|
107
113
|
text,
|
|
108
114
|
font: makeFontToken(run, effectiveFontSize),
|
|
109
115
|
letterSpacing: run.letterSpacing,
|
|
116
|
+
// Save original text if transform was applied (for copy-paste / round-trip)
|
|
117
|
+
...(text !== rawText ? { originalText: rawText } : {}),
|
|
110
118
|
metadata: {
|
|
111
119
|
originalRunIndex: i,
|
|
112
120
|
baselineOffset,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vyaz/core — Browser entry point.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports everything from the main index **except** `SystemFontRegistry`
|
|
5
|
+
* and `systemFontRegistry`, which require Node.js built-in modules
|
|
6
|
+
* (`node:fs`, `get-system-fonts`).
|
|
7
|
+
*
|
|
8
|
+
* All server-only code paths are guarded by runtime checks (`isNodeLike`)
|
|
9
|
+
* so tree-shakers can safely eliminate dead branches.
|
|
10
|
+
*
|
|
11
|
+
* ✅ Safe for Vite / webpack / Rollup / esbuild browser builds.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Input types (Logical level) ─────────────────────────────────────────
|
|
15
|
+
export type {
|
|
16
|
+
TextFrame,
|
|
17
|
+
Paragraph,
|
|
18
|
+
ParagraphStyle,
|
|
19
|
+
TextRun,
|
|
20
|
+
InlineWidget,
|
|
21
|
+
AutofitConfig,
|
|
22
|
+
TextAlignment,
|
|
23
|
+
WritingMode,
|
|
24
|
+
TextOrientation,
|
|
25
|
+
VerticalAlignment,
|
|
26
|
+
ScriptType,
|
|
27
|
+
WhiteSpace,
|
|
28
|
+
MultiColumnConfig,
|
|
29
|
+
DominantBaseline,
|
|
30
|
+
LineFitEdge,
|
|
31
|
+
TextAlignLast,
|
|
32
|
+
WordBreak,
|
|
33
|
+
LineBreak,
|
|
34
|
+
OverflowWrap,
|
|
35
|
+
TextDecorationStyle,
|
|
36
|
+
TextTransform,
|
|
37
|
+
ListType,
|
|
38
|
+
NumberFormat,
|
|
39
|
+
ListStylePosition,
|
|
40
|
+
ListStyle,
|
|
41
|
+
} from './types/Document.js';
|
|
42
|
+
export {
|
|
43
|
+
DEFAULT_PARAGRAPH_STYLE,
|
|
44
|
+
DEFAULT_TEXT_STYLE,
|
|
45
|
+
} from './types/Document.js';
|
|
46
|
+
|
|
47
|
+
// ── Output types (Physical Box Model) ───────────────────────────────────
|
|
48
|
+
export type {
|
|
49
|
+
ParagraphLayoutResult,
|
|
50
|
+
Line,
|
|
51
|
+
Span,
|
|
52
|
+
SpanFontMetrics,
|
|
53
|
+
SemanticParagraph,
|
|
54
|
+
SemanticLine,
|
|
55
|
+
SemanticFragment,
|
|
56
|
+
} from './types/LayoutTypes.js';
|
|
57
|
+
|
|
58
|
+
// ── Font types ──────────────────────────────────────────────────────────
|
|
59
|
+
export type {
|
|
60
|
+
FontMetrics,
|
|
61
|
+
IFontMetricsProvider,
|
|
62
|
+
GlyphData,
|
|
63
|
+
} from './types/FontTypes.js';
|
|
64
|
+
|
|
65
|
+
// ── Layout Engine (browser‑safe: uses Canvas 2D measureText fallback) ──
|
|
66
|
+
export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
|
|
67
|
+
export { positionLines } from './layout/PositioningEngine.js';
|
|
68
|
+
export { assertLineInvariants, linesToYAML } from './layout/LineBoxValidator.js';
|
|
69
|
+
export type { InvariantError } from './layout/LineBoxValidator.js';
|
|
70
|
+
|
|
71
|
+
// ── TextFrame Layout Engine ──────────────────────────────────────────────
|
|
72
|
+
export { layoutTextFrame } from './layout/TextFrameLayoutEngine.js';
|
|
73
|
+
export type { TextFrameLayoutResult } from './layout/TextFrameLayoutEngine.js';
|
|
74
|
+
|
|
75
|
+
// ── Autofit ─────────────────────────────────────────────────────────────
|
|
76
|
+
export { applyScale, findScale } from './layout/AutoFitEngine.js';
|
|
77
|
+
export type { AutoFitOptions, AutoFitResult } from './layout/AutoFitEngine.js';
|
|
78
|
+
|
|
79
|
+
// ── Compiler (browser‑safe: FontMetricsProvider falls back to Canvas) ──
|
|
80
|
+
export { compileParagraph, getParagraphText, makeFontToken } from './compile/DocumentCompiler.js';
|
|
81
|
+
export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
|
|
82
|
+
|
|
83
|
+
// ── Font metrics (browser‑safe: canvas-polyfill has runtime guards) ────
|
|
84
|
+
export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
|
|
85
|
+
|
|
86
|
+
// ── Errors ──────────────────────────────────────────────────────────────
|
|
87
|
+
export { FontNotFoundError } from './measure/FontNotFoundError.js';
|
|
88
|
+
|
|
89
|
+
// ❌ NOT exported in browser entry:
|
|
90
|
+
// - SystemFontRegistry / systemFontRegistry — requires `node:fs` + `get-system-fonts`
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,10 @@ export type {
|
|
|
28
28
|
OverflowWrap,
|
|
29
29
|
TextDecorationStyle,
|
|
30
30
|
TextTransform,
|
|
31
|
+
ListType,
|
|
32
|
+
NumberFormat,
|
|
33
|
+
ListStylePosition,
|
|
34
|
+
ListStyle,
|
|
31
35
|
} from './types/Document.js';
|
|
32
36
|
export {
|
|
33
37
|
DEFAULT_PARAGRAPH_STYLE,
|
|
@@ -66,15 +70,28 @@ export type { TextFrameLayoutResult } from './layout/TextFrameLayoutEngine.js';
|
|
|
66
70
|
export { applyScale, findScale } from './layout/AutoFitEngine.js';
|
|
67
71
|
export type { AutoFitOptions, AutoFitResult } from './layout/AutoFitEngine.js';
|
|
68
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
|
+
|
|
69
79
|
// ── Compiler ────────────────────────────────────────────────────────────
|
|
70
80
|
export { compileParagraph, getParagraphText, makeFontToken } from './compile/DocumentCompiler.js';
|
|
71
81
|
export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
|
|
72
82
|
|
|
83
|
+
// ── Font Engine ──────────────────────────────────────────────────────────
|
|
84
|
+
export type { FontFace } from './measure/FontEngine.js';
|
|
85
|
+
export { createFontFace, getGlyphAdvance, computePixelMetrics, isFontEngineAvailable } from './measure/FontEngine.js';
|
|
86
|
+
|
|
73
87
|
// ── Font metrics ────────────────────────────────────────────────────────
|
|
74
88
|
export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
|
|
75
89
|
|
|
76
90
|
// ── System font registry ────────────────────────────────────────────────
|
|
77
91
|
export { SystemFontRegistry, systemFontRegistry } from './measure/SystemFontRegistry.js';
|
|
78
92
|
|
|
93
|
+
// ── Font utilities ───────────────────────────────────────────────────────
|
|
94
|
+
export { getFontBuffer } from './utils/font.js';
|
|
95
|
+
|
|
79
96
|
// ── Errors ──────────────────────────────────────────────────────────────
|
|
80
97
|
export { FontNotFoundError } from './measure/FontNotFoundError.js';
|
|
@@ -16,19 +16,32 @@
|
|
|
16
16
|
// Polyfill OffscreenCanvas for Node.js (node-canvas)
|
|
17
17
|
import '../measure/canvas-polyfill.js';
|
|
18
18
|
|
|
19
|
-
import type { Paragraph } from '../types/Document.js';
|
|
19
|
+
import type { Paragraph, ListStyle } from '../types/Document.js';
|
|
20
20
|
import type { FontMetrics } from '../types/FontTypes.js';
|
|
21
21
|
import type { IFontMetricsProvider } from '../types/FontTypes.js';
|
|
22
22
|
import type { ParagraphLayoutResult } from '../types/LayoutTypes.js';
|
|
23
23
|
import { compileParagraph, getParagraphText } from '../compile/DocumentCompiler.js';
|
|
24
24
|
import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
|
|
25
|
-
import { fontMetricsProvider } from '../measure/FontMetricsProvider.js';
|
|
25
|
+
import { fontMetricsProvider, MISSING_GLYPH_FACTOR } from '../measure/FontMetricsProvider.js';
|
|
26
|
+
import { FontNotFoundError } from '../measure/FontNotFoundError.js';
|
|
26
27
|
import { positionLines } from './PositioningEngine.js';
|
|
27
28
|
import { assertLineInvariants } from './LineBoxValidator.js';
|
|
28
29
|
|
|
29
30
|
// @ts-ignore
|
|
30
31
|
import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
|
|
31
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
|
+
|
|
32
45
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
33
46
|
|
|
34
47
|
/** Get FontMetrics for a PreparedRichInlineItem */
|
|
@@ -47,7 +60,7 @@ export class ParagraphLayoutEngine {
|
|
|
47
60
|
private preparedCache = new Map<string, PreparedRichInline>();
|
|
48
61
|
|
|
49
62
|
/**
|
|
50
|
-
* Layout a single paragraph — basic variant
|
|
63
|
+
* Layout a single paragraph — basic variant.
|
|
51
64
|
*
|
|
52
65
|
* @param paragraph — input paragraph
|
|
53
66
|
* @param maxWidth — available container width (px)
|
|
@@ -59,6 +72,9 @@ export class ParagraphLayoutEngine {
|
|
|
59
72
|
maxWidth: number,
|
|
60
73
|
yOffset: number = 0,
|
|
61
74
|
fontProvider?: IFontMetricsProvider,
|
|
75
|
+
listStyle?: ListStyle,
|
|
76
|
+
listIndex?: number,
|
|
77
|
+
listMarkerWidth?: number,
|
|
62
78
|
): ParagraphLayoutResult {
|
|
63
79
|
const provider = fontProvider || fontMetricsProvider;
|
|
64
80
|
|
|
@@ -74,7 +90,6 @@ export class ParagraphLayoutEngine {
|
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
// Phase 3: Layout — walk lines
|
|
77
|
-
// CSS white-space: nowrap → disable wrapping (infinite width)
|
|
78
93
|
const effectiveMaxWidth = paragraph.style.whiteSpace === 'nowrap' ? Infinity : maxWidth;
|
|
79
94
|
const pretextLines: any[] = [];
|
|
80
95
|
walkRichInlineLineRanges(prepared, effectiveMaxWidth, (range: any) => {
|
|
@@ -89,6 +104,34 @@ export class ParagraphLayoutEngine {
|
|
|
89
104
|
|
|
90
105
|
// Phase 4: Position
|
|
91
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
|
+
|
|
92
135
|
const { lines, contentWidth } = positionLines(
|
|
93
136
|
materializedLines,
|
|
94
137
|
items,
|
|
@@ -107,9 +150,42 @@ export class ParagraphLayoutEngine {
|
|
|
107
150
|
maxWidth,
|
|
108
151
|
yOffset,
|
|
109
152
|
renderMode,
|
|
153
|
+
measureTextFn,
|
|
110
154
|
paragraph.id,
|
|
155
|
+
paragraph.style.listStyle,
|
|
156
|
+
paragraph.style.listStyle ? (listIndex ?? 1) : undefined,
|
|
157
|
+
listMarkerWidth,
|
|
111
158
|
);
|
|
112
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
|
+
|
|
113
189
|
// Phase 5: Validate
|
|
114
190
|
assertLineInvariants(lines, getParagraphText(paragraph), maxWidth);
|
|
115
191
|
|
|
@@ -129,68 +205,54 @@ export class ParagraphLayoutEngine {
|
|
|
129
205
|
/**
|
|
130
206
|
* Layout with per-glyph advance widths (for SVG glyph mode).
|
|
131
207
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* @param paragraph — input paragraph
|
|
136
|
-
* @param maxWidth — available container width (px)
|
|
137
|
-
* @param yOffset — starting Y position
|
|
138
|
-
* @returns ParagraphLayoutResult with glyphAdvances[]
|
|
208
|
+
* glyphAdvances are now filled by layout() automatically, so
|
|
209
|
+
* this method is equivalent to layout(). Kept for API compatibility.
|
|
139
210
|
*/
|
|
140
211
|
layoutGlyph(
|
|
141
212
|
paragraph: Paragraph,
|
|
142
213
|
maxWidth: number,
|
|
143
214
|
yOffset: number = 0,
|
|
144
215
|
): ParagraphLayoutResult {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for (const line of result.lines) {
|
|
148
|
-
for (const span of line.spans) {
|
|
149
|
-
if (span.type === 'text' && span.text.length > 0) {
|
|
150
|
-
span.glyphAdvances = this.computeGlyphAdvances(
|
|
151
|
-
span.text,
|
|
152
|
-
span.style.fontFamily,
|
|
153
|
-
span.fontMetrics.fontSize,
|
|
154
|
-
String(span.style.fontWeight || 400),
|
|
155
|
-
span.style.fontStyle || 'normal',
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return result;
|
|
216
|
+
return this.layout(paragraph, maxWidth, yOffset);
|
|
162
217
|
}
|
|
163
218
|
|
|
164
219
|
/**
|
|
165
|
-
* Compute per-character advance widths via fontkit.
|
|
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.
|
|
166
224
|
*/
|
|
167
225
|
private computeGlyphAdvances(
|
|
168
226
|
text: string,
|
|
169
|
-
fontFamily: string,
|
|
170
227
|
fontSize: number,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
);
|
|
175
237
|
if (!font) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
238
|
+
throw new FontNotFoundError(
|
|
239
|
+
fontFamily || 'Arial',
|
|
240
|
+
fontWeight || '400',
|
|
241
|
+
fontStyle || 'normal',
|
|
242
|
+
);
|
|
179
243
|
}
|
|
180
244
|
|
|
181
245
|
const scale = fontSize / font.unitsPerEm;
|
|
182
|
-
const advances
|
|
246
|
+
const advances = new Float32Array(text.length);
|
|
183
247
|
|
|
184
248
|
for (let i = 0; i < text.length; i++) {
|
|
185
249
|
const codePoint = text.codePointAt(i)!;
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
advances
|
|
250
|
+
const advance = font._raw.glyphForCodePoint(codePoint)?.advanceWidth;
|
|
251
|
+
if (advance != null) {
|
|
252
|
+
advances[i] = advance * scale;
|
|
189
253
|
} else {
|
|
190
|
-
|
|
191
|
-
advances.push(fontSize * 0.5);
|
|
254
|
+
advances[i] = fontSize * MISSING_GLYPH_FACTOR;
|
|
192
255
|
}
|
|
193
|
-
// Skip surrogate pair
|
|
194
256
|
if (codePoint > 0xffff) i++;
|
|
195
257
|
}
|
|
196
258
|
|