@vyaz/core 0.0.1
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/package.json +25 -0
- package/src/compile/DocumentCompiler.ts +94 -0
- package/src/index.ts +76 -0
- package/src/layout/AutoFitEngine.ts +101 -0
- package/src/layout/LineBoxValidator.ts +161 -0
- package/src/layout/ParagraphLayoutEngine.ts +202 -0
- package/src/layout/PositioningEngine.ts +391 -0
- package/src/layout/TextFrameLayoutEngine.ts +86 -0
- package/src/measure/FontMetricsProvider.ts +190 -0
- package/src/measure/canvas-polyfill.d.ts +6 -0
- package/src/measure/canvas-polyfill.ts +186 -0
- package/src/measure/fontkit.d.ts +44 -0
- package/src/types/Document.ts +540 -0
- package/src/types/FontTypes.ts +71 -0
- package/src/types/LayoutTypes.ts +136 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vyaz/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"files": ["dist", "src"],
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@chenglou/pretext": "^0.0.8",
|
|
16
|
+
"@clean-jsdoc-theme/typedoc": "^5.0.6",
|
|
17
|
+
"@napi-rs/canvas": "^1.0.1",
|
|
18
|
+
"fontkit": "^2.0.4",
|
|
19
|
+
"js-yaml": "^5.0.0",
|
|
20
|
+
"typedoc": "^0.28.19"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^26.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocumentCompiler.ts — compile Paragraph → PreparedRichInlineItem[].
|
|
3
|
+
*
|
|
4
|
+
* Each TextRun becomes a RichInlineItem for pretext.
|
|
5
|
+
* inline-box: text → \uFFFC, dimensions in metadata.inlineWidget.
|
|
6
|
+
* super/sub: fontSize *= 0.65, baselineOffset in metadata.
|
|
7
|
+
*
|
|
8
|
+
* Simple JSON-serialisable format — does not depend on pretext directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Paragraph, TextRun } from '../types/Document.js';
|
|
12
|
+
|
|
13
|
+
/** Font token for pretext: "${style}_${weight}_${fontSize}_${family}" */
|
|
14
|
+
export function makeFontToken(run: TextRun, effectiveFontSize: number): string {
|
|
15
|
+
const fontStyle = run.fontStyle || 'normal';
|
|
16
|
+
const fontWeight = run.fontWeight || 400;
|
|
17
|
+
return `${fontStyle} ${fontWeight} ${effectiveFontSize}px ${run.fontFamily}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Compilation context (passed to pretext) */
|
|
21
|
+
export interface PreparedRichInlineItem {
|
|
22
|
+
text: string;
|
|
23
|
+
font: string;
|
|
24
|
+
letterSpacing?: number;
|
|
25
|
+
extraWidth?: number; // padding, border for inline-box
|
|
26
|
+
break?: 'normal' | 'never'; // for atomic chips
|
|
27
|
+
metadata: {
|
|
28
|
+
originalRunIndex: number;
|
|
29
|
+
baselineOffset: number;
|
|
30
|
+
effectiveFontSize: number;
|
|
31
|
+
style: TextRun;
|
|
32
|
+
inlineWidget?: TextRun['inlineWidget'];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SUPER_SUB_SCALE = 0.65;
|
|
37
|
+
const SUPER_OFFSET_RATIO = -0.4;
|
|
38
|
+
const SUB_OFFSET_RATIO = 0.15;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compile a paragraph into PreparedRichInlineItem[].
|
|
42
|
+
*/
|
|
43
|
+
export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[] {
|
|
44
|
+
const items: PreparedRichInlineItem[] = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < paragraph.children.length; i++) {
|
|
47
|
+
const run = paragraph.children[i];
|
|
48
|
+
|
|
49
|
+
// Compute effective fontSize and baselineOffset
|
|
50
|
+
let effectiveFontSize = run.fontSize;
|
|
51
|
+
let baselineOffset = 0;
|
|
52
|
+
|
|
53
|
+
if (run.script === 'super') {
|
|
54
|
+
effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
|
|
55
|
+
baselineOffset = run.fontSize * SUPER_OFFSET_RATIO;
|
|
56
|
+
} else if (run.script === 'sub') {
|
|
57
|
+
effectiveFontSize = run.fontSize * SUPER_SUB_SCALE;
|
|
58
|
+
baselineOffset = run.fontSize * SUB_OFFSET_RATIO;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Inline-box: text → \uFFFC
|
|
62
|
+
const text = run.type === 'inline-box' ? '\uFFFC' : run.text;
|
|
63
|
+
|
|
64
|
+
const item: PreparedRichInlineItem = {
|
|
65
|
+
text,
|
|
66
|
+
font: makeFontToken(run, effectiveFontSize),
|
|
67
|
+
letterSpacing: run.letterSpacing,
|
|
68
|
+
metadata: {
|
|
69
|
+
originalRunIndex: i,
|
|
70
|
+
baselineOffset,
|
|
71
|
+
effectiveFontSize,
|
|
72
|
+
style: { ...run, fontSize: effectiveFontSize },
|
|
73
|
+
inlineWidget: run.inlineWidget,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Inline-box: add extraWidth and break: 'never'
|
|
78
|
+
if (run.type === 'inline-box' && run.inlineWidget) {
|
|
79
|
+
item.extraWidth = run.inlineWidget.width;
|
|
80
|
+
item.break = 'never';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
items.push(item);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return items;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the full text of a paragraph (for INDEX_CONSIST checks).
|
|
91
|
+
*/
|
|
92
|
+
export function getParagraphText(paragraph: Paragraph): string {
|
|
93
|
+
return paragraph.children.map(r => r.text).join('');
|
|
94
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
} from './types/Document.js';
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_PARAGRAPH_STYLE,
|
|
34
|
+
DEFAULT_TEXT_STYLE,
|
|
35
|
+
} from './types/Document.js';
|
|
36
|
+
|
|
37
|
+
// ── Output types (Physical Box Model) ───────────────────────────────────
|
|
38
|
+
export type {
|
|
39
|
+
ParagraphLayoutResult,
|
|
40
|
+
LineBox,
|
|
41
|
+
FragmentBox,
|
|
42
|
+
FragmentFontMetrics,
|
|
43
|
+
SemanticParagraph,
|
|
44
|
+
SemanticLine,
|
|
45
|
+
SemanticFragment,
|
|
46
|
+
} from './types/LayoutTypes.js';
|
|
47
|
+
|
|
48
|
+
// ── Font types ──────────────────────────────────────────────────────────
|
|
49
|
+
export type {
|
|
50
|
+
FontMetrics,
|
|
51
|
+
IFontMetricsProvider,
|
|
52
|
+
GlyphData,
|
|
53
|
+
} from './types/FontTypes.js';
|
|
54
|
+
|
|
55
|
+
// ── Layout Engine ───────────────────────────────────────────────────────
|
|
56
|
+
export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
|
|
57
|
+
export { positionLineBoxes } from './layout/PositioningEngine.js';
|
|
58
|
+
export { assertLineBoxInvariants, lineBoxToYAML } from './layout/LineBoxValidator.js';
|
|
59
|
+
export type { InvariantError } from './layout/LineBoxValidator.js';
|
|
60
|
+
|
|
61
|
+
// ── TextFrame Layout Engine ──────────────────────────────────────────────
|
|
62
|
+
export { layoutTextFrame } from './layout/TextFrameLayoutEngine.js';
|
|
63
|
+
export type { TextFrameLayoutResult } from './layout/TextFrameLayoutEngine.js';
|
|
64
|
+
|
|
65
|
+
// ── Autofit ─────────────────────────────────────────────────────────────
|
|
66
|
+
export { applyScale, findScale } from './layout/AutoFitEngine.js';
|
|
67
|
+
export type { AutoFitOptions, AutoFitResult } from './layout/AutoFitEngine.js';
|
|
68
|
+
|
|
69
|
+
// ── Compiler ────────────────────────────────────────────────────────────
|
|
70
|
+
export { compileParagraph, getParagraphText, makeFontToken } from './compile/DocumentCompiler.js';
|
|
71
|
+
export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
|
|
72
|
+
|
|
73
|
+
// ── Font metrics ────────────────────────────────────────────────────────
|
|
74
|
+
export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
|
|
75
|
+
|
|
76
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { LineBox, FragmentBox, 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 LineBox array.
|
|
25
|
+
* Throws on first violation.
|
|
26
|
+
*/
|
|
27
|
+
export function assertLineBoxInvariants(
|
|
28
|
+
lines: LineBox[],
|
|
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 fragments in a line have the same baseline
|
|
82
|
+
if (line.fragments.length > 1) {
|
|
83
|
+
const firstBaseline = line.baseline;
|
|
84
|
+
for (let j = 0; j < line.fragments.length; j++) {
|
|
85
|
+
const frag = line.fragments[j];
|
|
86
|
+
const fragBaseline = frag.fontMetrics.ascent;
|
|
87
|
+
if (Math.abs(fragBaseline - firstBaseline) > EPSILON) {
|
|
88
|
+
// NOTE: baseline may differ for super/sub — that's normal
|
|
89
|
+
// So we only check that baseline is set
|
|
90
|
+
if (fragBaseline <= 0) {
|
|
91
|
+
errors.push({
|
|
92
|
+
invariant: 'BASELINE_EQ',
|
|
93
|
+
message: `Line ${i}, fragment ${j}: baseline=${fragBaseline} 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
|
+
/** Fragment style label for snapshot */
|
|
121
|
+
function fragmentStyleLabel(frag: FragmentBox): 'bold' | 'italic' | 'normal' {
|
|
122
|
+
if (frag.style.fontStyle === 'italic') return 'italic';
|
|
123
|
+
const w = frag.style.fontWeight;
|
|
124
|
+
if (w === 'bold' || w === 700) return 'bold';
|
|
125
|
+
return 'normal';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Convert LineBox[] to YAML string for snapshots.
|
|
130
|
+
* Only semantic data: text, x, width, style.
|
|
131
|
+
* No glyphAdvances, fontMetrics (noise), inlineWidget.
|
|
132
|
+
*/
|
|
133
|
+
export function lineBoxToYAML(
|
|
134
|
+
lines: LineBox[],
|
|
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.fragments.map(frag => ({
|
|
147
|
+
text: frag.text,
|
|
148
|
+
x: Math.round(frag.x * 100) / 100,
|
|
149
|
+
width: Math.round(frag.width * 100) / 100,
|
|
150
|
+
...(fragmentStyleLabel(frag) !== 'normal' ? { style: fragmentStyleLabel(frag) } : {}),
|
|
151
|
+
})),
|
|
152
|
+
})),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return dump(obj, {
|
|
156
|
+
indent: 2,
|
|
157
|
+
lineWidth: 120,
|
|
158
|
+
noRefs: true,
|
|
159
|
+
sortKeys: false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParagraphLayoutEngine.ts — main orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline:
|
|
5
|
+
* Paragraph
|
|
6
|
+
* → compile (DocumentCompiler)
|
|
7
|
+
* → prepareRichInline (pretext)
|
|
8
|
+
* → walkRichInlineLineRanges + materializeRichInlineLineRange (pretext)
|
|
9
|
+
* → positionLineBoxes (PositioningEngine)
|
|
10
|
+
* → assertLineBoxInvariants (LineBoxValidator)
|
|
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 } 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 } from '../measure/FontMetricsProvider.js';
|
|
26
|
+
import { positionLineBoxes } from './PositioningEngine.js';
|
|
27
|
+
import { assertLineBoxInvariants } from './LineBoxValidator.js';
|
|
28
|
+
|
|
29
|
+
// @ts-ignore
|
|
30
|
+
import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
|
|
31
|
+
|
|
32
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Get FontMetrics for a PreparedRichInlineItem */
|
|
35
|
+
function getFontMetricsForItem(item: PreparedRichInlineItem): FontMetrics {
|
|
36
|
+
return fontMetricsProvider.getMetrics(
|
|
37
|
+
item.metadata.style.fontFamily,
|
|
38
|
+
item.metadata.effectiveFontSize,
|
|
39
|
+
String(item.metadata.style.fontWeight || 400),
|
|
40
|
+
item.metadata.style.fontStyle || 'normal',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── ParagraphLayoutEngine ─────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class ParagraphLayoutEngine {
|
|
47
|
+
private preparedCache = new Map<string, PreparedRichInline>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Layout a single paragraph — basic variant, no per-glyph data.
|
|
51
|
+
*
|
|
52
|
+
* @param paragraph — input paragraph
|
|
53
|
+
* @param maxWidth — available container width (px)
|
|
54
|
+
* @param fontProvider — optional metrics provider (default: fontMetricsProvider)
|
|
55
|
+
* @returns ParagraphLayoutResult with LineBox[]
|
|
56
|
+
*/
|
|
57
|
+
layout(
|
|
58
|
+
paragraph: Paragraph,
|
|
59
|
+
maxWidth: number,
|
|
60
|
+
yOffset: number = 0,
|
|
61
|
+
fontProvider?: IFontMetricsProvider,
|
|
62
|
+
): ParagraphLayoutResult {
|
|
63
|
+
const provider = fontProvider || fontMetricsProvider;
|
|
64
|
+
|
|
65
|
+
// Phase 1: Compile
|
|
66
|
+
const items = compileParagraph(paragraph);
|
|
67
|
+
|
|
68
|
+
// Phase 2: Prepare (cached)
|
|
69
|
+
const cacheKey = JSON.stringify(paragraph);
|
|
70
|
+
let prepared = this.preparedCache.get(cacheKey);
|
|
71
|
+
if (!prepared) {
|
|
72
|
+
prepared = prepareRichInline(items);
|
|
73
|
+
this.preparedCache.set(cacheKey, prepared);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Phase 3: Layout — walk lines
|
|
77
|
+
// CSS white-space: nowrap → disable wrapping (infinite width)
|
|
78
|
+
const effectiveMaxWidth = paragraph.style.whiteSpace === 'nowrap' ? Infinity : maxWidth;
|
|
79
|
+
const pretextLines: any[] = [];
|
|
80
|
+
walkRichInlineLineRanges(prepared, effectiveMaxWidth, (range: any) => {
|
|
81
|
+
pretextLines.push(range);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Materialize each line
|
|
85
|
+
const materializedLines: any[] = [];
|
|
86
|
+
for (const range of pretextLines) {
|
|
87
|
+
materializedLines.push(materializeRichInlineLineRange(prepared, range));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Phase 4: Position
|
|
91
|
+
const renderMode = provider.getMode();
|
|
92
|
+
const { lines, contentWidth } = positionLineBoxes(
|
|
93
|
+
materializedLines,
|
|
94
|
+
items,
|
|
95
|
+
(item) => {
|
|
96
|
+
if (fontProvider) {
|
|
97
|
+
return fontProvider.getMetrics(
|
|
98
|
+
item.metadata.style.fontFamily,
|
|
99
|
+
item.metadata.effectiveFontSize,
|
|
100
|
+
String(item.metadata.style.fontWeight || 400),
|
|
101
|
+
item.metadata.style.fontStyle || 'normal',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return getFontMetricsForItem(item);
|
|
105
|
+
},
|
|
106
|
+
paragraph.style,
|
|
107
|
+
maxWidth,
|
|
108
|
+
yOffset,
|
|
109
|
+
renderMode,
|
|
110
|
+
paragraph.id,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Phase 5: Validate
|
|
114
|
+
assertLineBoxInvariants(lines, getParagraphText(paragraph), maxWidth);
|
|
115
|
+
|
|
116
|
+
// Phase 6: Compute height
|
|
117
|
+
const totalHeight = lines.length > 0
|
|
118
|
+
? lines[lines.length - 1].y + lines[lines.length - 1].height + (paragraph.style.spaceAfter || 0)
|
|
119
|
+
: 0;
|
|
120
|
+
|
|
121
|
+
// Phase 7: Content BBox
|
|
122
|
+
const contentHeight = lines.length > 0
|
|
123
|
+
? lines[lines.length - 1].y + lines[lines.length - 1].height
|
|
124
|
+
: 0;
|
|
125
|
+
|
|
126
|
+
return { width: maxWidth, height: totalHeight, lines, contentWidth, contentHeight };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Layout with per-glyph advance widths (for SVG glyph mode).
|
|
131
|
+
*
|
|
132
|
+
* After basic layout, fills FragmentBox.glyphAdvances
|
|
133
|
+
* via fontkit for each text fragment.
|
|
134
|
+
*
|
|
135
|
+
* @param paragraph — input paragraph
|
|
136
|
+
* @param maxWidth — available container width (px)
|
|
137
|
+
* @param yOffset — starting Y position
|
|
138
|
+
* @returns ParagraphLayoutResult with glyphAdvances[]
|
|
139
|
+
*/
|
|
140
|
+
layoutGlyph(
|
|
141
|
+
paragraph: Paragraph,
|
|
142
|
+
maxWidth: number,
|
|
143
|
+
yOffset: number = 0,
|
|
144
|
+
): ParagraphLayoutResult {
|
|
145
|
+
const result = this.layout(paragraph, maxWidth, yOffset);
|
|
146
|
+
|
|
147
|
+
for (const line of result.lines) {
|
|
148
|
+
for (const frag of line.fragments) {
|
|
149
|
+
if (frag.type === 'text' && frag.text.length > 0) {
|
|
150
|
+
frag.glyphAdvances = this.computeGlyphAdvances(
|
|
151
|
+
frag.text,
|
|
152
|
+
frag.style.fontFamily,
|
|
153
|
+
frag.fontMetrics.fontSize,
|
|
154
|
+
String(frag.style.fontWeight || 400),
|
|
155
|
+
frag.style.fontStyle || 'normal',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compute per-character advance widths via fontkit.
|
|
166
|
+
*/
|
|
167
|
+
private computeGlyphAdvances(
|
|
168
|
+
text: string,
|
|
169
|
+
fontFamily: string,
|
|
170
|
+
fontSize: number,
|
|
171
|
+
weight: string,
|
|
172
|
+
style: string,
|
|
173
|
+
): number[] {
|
|
174
|
+
const font = fontMetricsProvider.getFont(fontFamily, weight, style);
|
|
175
|
+
if (!font) {
|
|
176
|
+
// Fallback: uniform distribution
|
|
177
|
+
const avgWidth = fontSize * 0.6;
|
|
178
|
+
return Array.from(text).map(() => avgWidth);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const scale = fontSize / font.unitsPerEm;
|
|
182
|
+
const advances: number[] = [];
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < text.length; i++) {
|
|
185
|
+
const codePoint = text.codePointAt(i)!;
|
|
186
|
+
const glyph = font.glyphForCodePoint(codePoint);
|
|
187
|
+
if (glyph) {
|
|
188
|
+
advances.push(glyph.advanceWidth * scale);
|
|
189
|
+
} else {
|
|
190
|
+
// Missing glyph
|
|
191
|
+
advances.push(fontSize * 0.5);
|
|
192
|
+
}
|
|
193
|
+
// Skip surrogate pair
|
|
194
|
+
if (codePoint > 0xffff) i++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return advances;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Singleton */
|
|
202
|
+
export const paragraphLayoutEngine = new ParagraphLayoutEngine();
|