@vyaz/core 0.0.1 → 0.0.3
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/index.js +147950 -0
- package/package.json +7 -3
- package/src/compile/DocumentCompiler.ts +51 -9
- package/src/index.ts +9 -5
- package/src/layout/LineBoxValidator.ts +26 -25
- package/src/layout/ParagraphLayoutEngine.ts +17 -17
- package/src/layout/PositioningEngine.ts +56 -46
- package/src/layout/TextFrameLayoutEngine.ts +11 -6
- package/src/measure/FontMetricsProvider.ts +52 -16
- package/src/measure/FontNotFoundError.ts +16 -0
- package/src/measure/SystemFontRegistry.ts +141 -0
- package/src/measure/canvas-polyfill.ts +31 -3
- package/src/types/FontTypes.ts +3 -0
- package/src/types/LayoutTypes.ts +26 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vyaz/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -9,16 +9,20 @@
|
|
|
9
9
|
"access": "public"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"build": "bun build ./src/index.ts --outdir ./dist --target bun"
|
|
12
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
13
|
+
"test": "bun test"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@chenglou/pretext": "^0.0.8",
|
|
16
17
|
"@clean-jsdoc-theme/typedoc": "^5.0.6",
|
|
17
|
-
"@napi-rs/canvas": "^1.0.1",
|
|
18
18
|
"fontkit": "^2.0.4",
|
|
19
|
+
"get-system-fonts": "^2.0.2",
|
|
19
20
|
"js-yaml": "^5.0.0",
|
|
20
21
|
"typedoc": "^0.28.19"
|
|
21
22
|
},
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"@napi-rs/canvas": "^1.0.2"
|
|
25
|
+
},
|
|
22
26
|
"devDependencies": {
|
|
23
27
|
"@types/node": "^26.0.0"
|
|
24
28
|
}
|
|
@@ -9,12 +9,40 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Paragraph, TextRun } from '../types/Document.js';
|
|
12
|
+
import { DEFAULT_TEXT_STYLE } from '../types/Document.js';
|
|
13
|
+
|
|
14
|
+
// ── Font Weight normalization (matching react-pdf convention) ───────────
|
|
15
|
+
|
|
16
|
+
export const FONT_WEIGHTS: Record<string, number> = {
|
|
17
|
+
thin: 100,
|
|
18
|
+
hairline: 100,
|
|
19
|
+
ultralight: 200,
|
|
20
|
+
extralight: 200,
|
|
21
|
+
light: 300,
|
|
22
|
+
normal: 400,
|
|
23
|
+
medium: 500,
|
|
24
|
+
semibold: 600,
|
|
25
|
+
demibold: 600,
|
|
26
|
+
bold: 700,
|
|
27
|
+
ultrabold: 800,
|
|
28
|
+
extrabold: 800,
|
|
29
|
+
heavy: 900,
|
|
30
|
+
black: 900,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Normalize fontWeight to a numeric value (400 by default). */
|
|
34
|
+
export function normalizeFontWeight(weight: number | string | undefined): number {
|
|
35
|
+
if (weight == null) return FONT_WEIGHTS.normal;
|
|
36
|
+
if (typeof weight === 'number') return weight;
|
|
37
|
+
return FONT_WEIGHTS[weight.toLowerCase()] ?? FONT_WEIGHTS.normal;
|
|
38
|
+
}
|
|
12
39
|
|
|
13
40
|
/** Font token for pretext: "${style}_${weight}_${fontSize}_${family}" */
|
|
14
41
|
export function makeFontToken(run: TextRun, effectiveFontSize: number): string {
|
|
15
|
-
const fontStyle = run.fontStyle || 'normal';
|
|
16
|
-
const fontWeight = run.fontWeight
|
|
17
|
-
|
|
42
|
+
const fontStyle = run.fontStyle || DEFAULT_TEXT_STYLE.fontStyle || 'normal';
|
|
43
|
+
const fontWeight = normalizeFontWeight(run.fontWeight ?? DEFAULT_TEXT_STYLE.fontWeight);
|
|
44
|
+
const fontFamily = run.fontFamily || DEFAULT_TEXT_STYLE.fontFamily || 'Arial';
|
|
45
|
+
return `${fontStyle} ${fontWeight} ${effectiveFontSize}px ${fontFamily}`;
|
|
18
46
|
}
|
|
19
47
|
|
|
20
48
|
/** Compilation context (passed to pretext) */
|
|
@@ -47,20 +75,34 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
|
|
|
47
75
|
const run = paragraph.children[i];
|
|
48
76
|
|
|
49
77
|
// Compute effective fontSize and baselineOffset
|
|
50
|
-
|
|
78
|
+
const baseFontSize = run.fontSize ?? DEFAULT_TEXT_STYLE.fontSize ?? 12;
|
|
79
|
+
let effectiveFontSize = baseFontSize;
|
|
51
80
|
let baselineOffset = 0;
|
|
52
81
|
|
|
53
82
|
if (run.script === 'super') {
|
|
54
|
-
effectiveFontSize =
|
|
55
|
-
baselineOffset =
|
|
83
|
+
effectiveFontSize = baseFontSize * SUPER_SUB_SCALE;
|
|
84
|
+
baselineOffset = baseFontSize * SUPER_OFFSET_RATIO;
|
|
56
85
|
} else if (run.script === 'sub') {
|
|
57
|
-
effectiveFontSize =
|
|
58
|
-
baselineOffset =
|
|
86
|
+
effectiveFontSize = baseFontSize * SUPER_SUB_SCALE;
|
|
87
|
+
baselineOffset = baseFontSize * SUB_OFFSET_RATIO;
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
// Inline-box: text → \uFFFC
|
|
62
91
|
const text = run.type === 'inline-box' ? '\uFFFC' : run.text;
|
|
63
92
|
|
|
93
|
+
// Normalize fontWeight to numeric value
|
|
94
|
+
const resolvedFontWeight = normalizeFontWeight(run.fontWeight ?? DEFAULT_TEXT_STYLE.fontWeight);
|
|
95
|
+
|
|
96
|
+
// Fill missing style fields from DEFAULT_TEXT_STYLE
|
|
97
|
+
const resolvedStyle: TextRun = {
|
|
98
|
+
...DEFAULT_TEXT_STYLE,
|
|
99
|
+
...run,
|
|
100
|
+
fontSize: effectiveFontSize,
|
|
101
|
+
fontWeight: resolvedFontWeight,
|
|
102
|
+
text: run.text,
|
|
103
|
+
type: run.type,
|
|
104
|
+
} as TextRun;
|
|
105
|
+
|
|
64
106
|
const item: PreparedRichInlineItem = {
|
|
65
107
|
text,
|
|
66
108
|
font: makeFontToken(run, effectiveFontSize),
|
|
@@ -69,7 +111,7 @@ export function compileParagraph(paragraph: Paragraph): PreparedRichInlineItem[]
|
|
|
69
111
|
originalRunIndex: i,
|
|
70
112
|
baselineOffset,
|
|
71
113
|
effectiveFontSize,
|
|
72
|
-
style:
|
|
114
|
+
style: resolvedStyle,
|
|
73
115
|
inlineWidget: run.inlineWidget,
|
|
74
116
|
},
|
|
75
117
|
};
|
package/src/index.ts
CHANGED
|
@@ -37,9 +37,9 @@ export {
|
|
|
37
37
|
// ── Output types (Physical Box Model) ───────────────────────────────────
|
|
38
38
|
export type {
|
|
39
39
|
ParagraphLayoutResult,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
Line,
|
|
41
|
+
Span,
|
|
42
|
+
SpanFontMetrics,
|
|
43
43
|
SemanticParagraph,
|
|
44
44
|
SemanticLine,
|
|
45
45
|
SemanticFragment,
|
|
@@ -54,8 +54,8 @@ export type {
|
|
|
54
54
|
|
|
55
55
|
// ── Layout Engine ───────────────────────────────────────────────────────
|
|
56
56
|
export { ParagraphLayoutEngine, paragraphLayoutEngine } from './layout/ParagraphLayoutEngine.js';
|
|
57
|
-
export {
|
|
58
|
-
export {
|
|
57
|
+
export { positionLines } from './layout/PositioningEngine.js';
|
|
58
|
+
export { assertLineInvariants, linesToYAML } from './layout/LineBoxValidator.js';
|
|
59
59
|
export type { InvariantError } from './layout/LineBoxValidator.js';
|
|
60
60
|
|
|
61
61
|
// ── TextFrame Layout Engine ──────────────────────────────────────────────
|
|
@@ -73,4 +73,8 @@ export type { PreparedRichInlineItem } from './compile/DocumentCompiler.js';
|
|
|
73
73
|
// ── Font metrics ────────────────────────────────────────────────────────
|
|
74
74
|
export { FontMetricsProvider, fontMetricsProvider } from './measure/FontMetricsProvider.js';
|
|
75
75
|
|
|
76
|
+
// ── System font registry ────────────────────────────────────────────────
|
|
77
|
+
export { SystemFontRegistry, systemFontRegistry } from './measure/SystemFontRegistry.js';
|
|
76
78
|
|
|
79
|
+
// ── Errors ──────────────────────────────────────────────────────────────
|
|
80
|
+
export { FontNotFoundError } from './measure/FontNotFoundError.js';
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { dump } from 'js-yaml';
|
|
11
|
-
import type {
|
|
11
|
+
import type { Line, Span, SemanticParagraph } from '../types/LayoutTypes.js';
|
|
12
12
|
|
|
13
13
|
const EPSILON = 0.5; // subpixel tolerance
|
|
14
14
|
|
|
@@ -21,11 +21,11 @@ export interface InvariantError {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Check all 5 invariants for a
|
|
24
|
+
* Check all 5 invariants for a Line array.
|
|
25
25
|
* Throws on first violation.
|
|
26
26
|
*/
|
|
27
|
-
export function
|
|
28
|
-
lines:
|
|
27
|
+
export function assertLineInvariants(
|
|
28
|
+
lines: Line[],
|
|
29
29
|
originalText: string,
|
|
30
30
|
maxWidth: number,
|
|
31
31
|
): void {
|
|
@@ -78,19 +78,19 @@ export function assertLineBoxInvariants(
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
// 5. BASELINE_EQ: all
|
|
82
|
-
if (line.
|
|
81
|
+
// 5. BASELINE_EQ: all spans in a line have the same baseline
|
|
82
|
+
if (line.spans.length > 1) {
|
|
83
83
|
const firstBaseline = line.baseline;
|
|
84
|
-
for (let j = 0; j < line.
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
if (Math.abs(
|
|
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
88
|
// NOTE: baseline may differ for super/sub — that's normal
|
|
89
89
|
// So we only check that baseline is set
|
|
90
|
-
if (
|
|
90
|
+
if (spanBaseline <= 0) {
|
|
91
91
|
errors.push({
|
|
92
92
|
invariant: 'BASELINE_EQ',
|
|
93
|
-
message: `Line ${i},
|
|
93
|
+
message: `Line ${i}, span ${j}: baseline=${spanBaseline} is invalid`,
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -117,21 +117,21 @@ export function assertLineBoxInvariants(
|
|
|
117
117
|
|
|
118
118
|
// ── YAML serialization ─────────────────────────────────────────────────
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
function
|
|
122
|
-
if (
|
|
123
|
-
const w =
|
|
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
124
|
if (w === 'bold' || w === 700) return 'bold';
|
|
125
125
|
return 'normal';
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Convert
|
|
129
|
+
* Convert Line[] to YAML string for snapshots.
|
|
130
130
|
* Only semantic data: text, x, width, style.
|
|
131
131
|
* No glyphAdvances, fontMetrics (noise), inlineWidget.
|
|
132
132
|
*/
|
|
133
|
-
export function
|
|
134
|
-
lines:
|
|
133
|
+
export function linesToYAML(
|
|
134
|
+
lines: Line[],
|
|
135
135
|
paragraphWidth: number,
|
|
136
136
|
paragraphHeight: number,
|
|
137
137
|
): string {
|
|
@@ -143,11 +143,11 @@ export function lineBoxToYAML(
|
|
|
143
143
|
width: Math.round(line.width * 100) / 100,
|
|
144
144
|
height: Math.round(line.height * 100) / 100,
|
|
145
145
|
baseline: Math.round(line.baseline * 100) / 100,
|
|
146
|
-
fragments: line.
|
|
147
|
-
text:
|
|
148
|
-
x: Math.round(
|
|
149
|
-
width: Math.round(
|
|
150
|
-
...(
|
|
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
151
|
})),
|
|
152
152
|
})),
|
|
153
153
|
};
|
|
@@ -158,4 +158,5 @@ export function lineBoxToYAML(
|
|
|
158
158
|
noRefs: true,
|
|
159
159
|
sortKeys: false,
|
|
160
160
|
});
|
|
161
|
-
}
|
|
161
|
+
}
|
|
162
|
+
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* → compile (DocumentCompiler)
|
|
7
7
|
* → prepareRichInline (pretext)
|
|
8
8
|
* → walkRichInlineLineRanges + materializeRichInlineLineRange (pretext)
|
|
9
|
-
* →
|
|
10
|
-
* →
|
|
9
|
+
* → positionLines (PositioningEngine)
|
|
10
|
+
* → assertLineInvariants (LineInvariants)
|
|
11
11
|
*
|
|
12
12
|
* Supports autofit via AutoFitEngine.findScale.
|
|
13
13
|
* Caches PreparedRichInline per paragraph key (Parley LayoutContext pattern).
|
|
@@ -23,8 +23,8 @@ 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
25
|
import { fontMetricsProvider } from '../measure/FontMetricsProvider.js';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import { positionLines } from './PositioningEngine.js';
|
|
27
|
+
import { assertLineInvariants } from './LineBoxValidator.js';
|
|
28
28
|
|
|
29
29
|
// @ts-ignore
|
|
30
30
|
import { prepareRichInline, materializeRichInlineLineRange, walkRichInlineLineRanges, type PreparedRichInline } from '@chenglou/pretext/rich-inline';
|
|
@@ -52,7 +52,7 @@ export class ParagraphLayoutEngine {
|
|
|
52
52
|
* @param paragraph — input paragraph
|
|
53
53
|
* @param maxWidth — available container width (px)
|
|
54
54
|
* @param fontProvider — optional metrics provider (default: fontMetricsProvider)
|
|
55
|
-
* @returns ParagraphLayoutResult with
|
|
55
|
+
* @returns ParagraphLayoutResult with Line[]
|
|
56
56
|
*/
|
|
57
57
|
layout(
|
|
58
58
|
paragraph: Paragraph,
|
|
@@ -89,7 +89,7 @@ export class ParagraphLayoutEngine {
|
|
|
89
89
|
|
|
90
90
|
// Phase 4: Position
|
|
91
91
|
const renderMode = provider.getMode();
|
|
92
|
-
const { lines, contentWidth } =
|
|
92
|
+
const { lines, contentWidth } = positionLines(
|
|
93
93
|
materializedLines,
|
|
94
94
|
items,
|
|
95
95
|
(item) => {
|
|
@@ -111,7 +111,7 @@ export class ParagraphLayoutEngine {
|
|
|
111
111
|
);
|
|
112
112
|
|
|
113
113
|
// Phase 5: Validate
|
|
114
|
-
|
|
114
|
+
assertLineInvariants(lines, getParagraphText(paragraph), maxWidth);
|
|
115
115
|
|
|
116
116
|
// Phase 6: Compute height
|
|
117
117
|
const totalHeight = lines.length > 0
|
|
@@ -129,8 +129,8 @@ export class ParagraphLayoutEngine {
|
|
|
129
129
|
/**
|
|
130
130
|
* Layout with per-glyph advance widths (for SVG glyph mode).
|
|
131
131
|
*
|
|
132
|
-
* After basic layout, fills
|
|
133
|
-
* via fontkit for each text
|
|
132
|
+
* After basic layout, fills Span.glyphAdvances
|
|
133
|
+
* via fontkit for each text span.
|
|
134
134
|
*
|
|
135
135
|
* @param paragraph — input paragraph
|
|
136
136
|
* @param maxWidth — available container width (px)
|
|
@@ -145,14 +145,14 @@ export class ParagraphLayoutEngine {
|
|
|
145
145
|
const result = this.layout(paragraph, maxWidth, yOffset);
|
|
146
146
|
|
|
147
147
|
for (const line of result.lines) {
|
|
148
|
-
for (const
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
String(
|
|
155
|
-
|
|
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
156
|
);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PositioningEngine.ts — pure X/Y positioning math.
|
|
3
3
|
*
|
|
4
|
-
* Takes pretext output (
|
|
5
|
-
* paragraph style → returns
|
|
4
|
+
* Takes pretext output (spans with fragments) + font metrics +
|
|
5
|
+
* paragraph style → returns Line[] with absolute coordinates.
|
|
6
6
|
*
|
|
7
7
|
* X: alignment (left/center/right/justify) + indent
|
|
8
8
|
* Y: baseline + lineHeight + spaceBefore/After
|
|
9
|
-
* Justify: fragmented approach (each space → separate
|
|
9
|
+
* Justify: fragmented approach (each space → separate Span)
|
|
10
10
|
*
|
|
11
11
|
* Specs:
|
|
12
12
|
* - CSS Text Module Level 3/4 (browser mode)
|
|
13
13
|
* - ISO/IEC 29500 (Office Open XML / DrawingML, office mode)
|
|
14
14
|
* - Parley alignment.rs (conceptually close, but here justify is simpler:
|
|
15
|
-
* slack is divided equally among stretchable space-
|
|
15
|
+
* slack is divided equally among stretchable space-spans,
|
|
16
16
|
* without mutating ClusterData.advance)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { ParagraphStyle } from '../types/Document.js';
|
|
20
20
|
import type { FontMetrics } from '../types/FontTypes.js';
|
|
21
|
-
import type {
|
|
21
|
+
import type { Line, Span, SpanFontMetrics } from '../types/LayoutTypes.js';
|
|
22
22
|
import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
|
|
23
23
|
|
|
24
24
|
// ── Helper types for pretext ───────────────────────────────────────────
|
|
@@ -41,18 +41,18 @@ interface PretextLine {
|
|
|
41
41
|
// ── PositioningEngine ─────────────────────────────────────────────────
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Build
|
|
44
|
+
* Build Line[] from pretext lines with alignment and metrics.
|
|
45
45
|
*
|
|
46
46
|
* @param pretextLines — pretext result (materializeRichInlineLineRange)
|
|
47
47
|
* @param items — original PreparedRichInlineItem[] (for metadata)
|
|
48
|
-
* @param fontMetricsFn — function to get font metrics for a
|
|
48
|
+
* @param fontMetricsFn — function to get font metrics for a span
|
|
49
49
|
* @param style — paragraph style
|
|
50
50
|
* @param maxWidth — available container width
|
|
51
51
|
* @param startY — initial Y position
|
|
52
52
|
* @param mode — metric mode ('browser' | 'office'), affects line height calculation
|
|
53
|
-
* @returns { lines:
|
|
53
|
+
* @returns { lines: Line[], contentWidth: number }
|
|
54
54
|
*/
|
|
55
|
-
export function
|
|
55
|
+
export function positionLines(
|
|
56
56
|
pretextLines: PretextLine[],
|
|
57
57
|
items: PreparedRichInlineItem[],
|
|
58
58
|
fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
|
|
@@ -61,8 +61,8 @@ export function positionLineBoxes(
|
|
|
61
61
|
startY: number = 0,
|
|
62
62
|
mode: 'browser' | 'office' = 'browser',
|
|
63
63
|
paragraphId?: string,
|
|
64
|
-
): { lines:
|
|
65
|
-
const lines:
|
|
64
|
+
): { lines: Line[]; contentWidth: number } {
|
|
65
|
+
const lines: Line[] = [];
|
|
66
66
|
let currentY = startY + style.spaceBefore;
|
|
67
67
|
let charIndex = 0;
|
|
68
68
|
let isFirstLine = true;
|
|
@@ -71,11 +71,11 @@ export function positionLineBoxes(
|
|
|
71
71
|
for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
|
|
72
72
|
const ptLine = pretextLines[lineIdx];
|
|
73
73
|
|
|
74
|
-
// ── Build
|
|
74
|
+
// ── Build Span[] ────────────────────────────
|
|
75
75
|
let maxAscent = 0;
|
|
76
76
|
let maxDescent = 0;
|
|
77
77
|
let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
|
|
78
|
-
const
|
|
78
|
+
const spans: Span[] = [];
|
|
79
79
|
|
|
80
80
|
for (const frag of ptLine.fragments) {
|
|
81
81
|
const item = items[frag.itemIndex];
|
|
@@ -92,7 +92,7 @@ export function positionLineBoxes(
|
|
|
92
92
|
|
|
93
93
|
// pretext: gapBefore — inter-word space BEFORE the word
|
|
94
94
|
// occupiedWidth = gapBefore + textWidth
|
|
95
|
-
// Split into two
|
|
95
|
+
// Split into two Span: space + word
|
|
96
96
|
const gapWidth = frag.gapBefore || 0;
|
|
97
97
|
const textWidth = frag.occupiedWidth;
|
|
98
98
|
|
|
@@ -100,10 +100,11 @@ export function positionLineBoxes(
|
|
|
100
100
|
ascent: metrics.ascent,
|
|
101
101
|
descent: metrics.descent,
|
|
102
102
|
fontSize: item.metadata.effectiveFontSize,
|
|
103
|
+
baselineOffset: item.metadata.baselineOffset || undefined,
|
|
103
104
|
};
|
|
104
105
|
|
|
105
106
|
if (gapWidth > 0) {
|
|
106
|
-
|
|
107
|
+
spans.push({
|
|
107
108
|
x: 0,
|
|
108
109
|
width: gapWidth,
|
|
109
110
|
text: ' ',
|
|
@@ -116,7 +117,7 @@ export function positionLineBoxes(
|
|
|
116
117
|
});
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
// Split leading/trailing spaces from frag.text into separate space
|
|
120
|
+
// Split leading/trailing spaces from frag.text into separate space spans
|
|
120
121
|
// This is needed for SVG rendering to avoid xml:space="preserve" dependency
|
|
121
122
|
const text = frag.text;
|
|
122
123
|
const leadingMatch = text.match(/^(\s+)/);
|
|
@@ -142,9 +143,9 @@ export function positionLineBoxes(
|
|
|
142
143
|
return totalChars > 0 ? (charCount / totalChars) * textWidth : 0;
|
|
143
144
|
};
|
|
144
145
|
|
|
145
|
-
// Leading space
|
|
146
|
+
// Leading space span
|
|
146
147
|
if (leadingSpaceChars > 0) {
|
|
147
|
-
|
|
148
|
+
spans.push({
|
|
148
149
|
x: 0,
|
|
149
150
|
width: computePartialWidth(leadingSpaceChars),
|
|
150
151
|
text: text.slice(0, leadingSpaceChars),
|
|
@@ -157,7 +158,7 @@ export function positionLineBoxes(
|
|
|
157
158
|
});
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
// Text
|
|
161
|
+
// Text span (trimmed)
|
|
161
162
|
if (remainingText.length > 0) {
|
|
162
163
|
const textWidth = computePartialWidth(remainingText.length);
|
|
163
164
|
// Compute per-glyph advances by distributing textWidth equally across glyphs.
|
|
@@ -170,7 +171,7 @@ export function positionLineBoxes(
|
|
|
170
171
|
glyphAdvances.push(perGlyph);
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
|
-
|
|
174
|
+
spans.push({
|
|
174
175
|
x: 0,
|
|
175
176
|
width: textWidth,
|
|
176
177
|
text: remainingText,
|
|
@@ -184,10 +185,10 @@ export function positionLineBoxes(
|
|
|
184
185
|
});
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
// Trailing space
|
|
188
|
+
// Trailing space span
|
|
188
189
|
if (trailingSpaceChars > 0) {
|
|
189
190
|
const trailingStart = leadingSpaceChars + remainingText.length;
|
|
190
|
-
|
|
191
|
+
spans.push({
|
|
191
192
|
x: 0,
|
|
192
193
|
width: computePartialWidth(trailingSpaceChars),
|
|
193
194
|
text: text.slice(trailingStart, trailingStart + trailingSpaceChars),
|
|
@@ -201,34 +202,43 @@ export function positionLineBoxes(
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
// ──
|
|
205
|
+
// ── Inline-box width correction ─────────────────────────
|
|
206
|
+
// When a span has an inlineWidget, override its width
|
|
207
|
+
// to match the widget width (the \uFFFC advance is not included).
|
|
208
|
+
for (const s of spans) {
|
|
209
|
+
if (s.inlineWidget) {
|
|
210
|
+
s.width = s.inlineWidget.width;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Mark trailing whitespace spans ───────────────────
|
|
205
215
|
// CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
|
|
206
216
|
// Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
|
|
207
217
|
//
|
|
208
218
|
// Find the last space(s) at the end of the line and mark them trailing.
|
|
209
|
-
let trailingStartIndex =
|
|
210
|
-
for (let i =
|
|
211
|
-
if (
|
|
219
|
+
let trailingStartIndex = spans.length;
|
|
220
|
+
for (let i = spans.length - 1; i >= 0; i--) {
|
|
221
|
+
if (spans[i].type === 'space') {
|
|
212
222
|
trailingStartIndex = i;
|
|
213
223
|
} else {
|
|
214
224
|
break;
|
|
215
225
|
}
|
|
216
226
|
}
|
|
217
227
|
|
|
218
|
-
const trailingWidth =
|
|
228
|
+
const trailingWidth = spans
|
|
219
229
|
.slice(trailingStartIndex)
|
|
220
230
|
.reduce((sum, f) => sum + f.width, 0);
|
|
221
231
|
|
|
222
|
-
for (let i = trailingStartIndex; i <
|
|
223
|
-
|
|
232
|
+
for (let i = trailingStartIndex; i < spans.length; i++) {
|
|
233
|
+
spans[i].trailing = true;
|
|
224
234
|
}
|
|
225
235
|
|
|
226
236
|
// ── Compute effective line width (excluding trailing whitespace) ─
|
|
227
|
-
// Use sum of actual
|
|
237
|
+
// Use sum of actual span widths instead of ptLine.width, because
|
|
228
238
|
// ptLine.width may not include gapBefore spaces that were split into
|
|
229
|
-
// separate
|
|
230
|
-
const
|
|
231
|
-
const effectiveLineWidth =
|
|
239
|
+
// separate Span (e.g. "AA" + " A" → [AA][ ][A]).
|
|
240
|
+
const totalSpanWidth = spans.reduce((sum, f) => sum + f.width, 0);
|
|
241
|
+
const effectiveLineWidth = totalSpanWidth - trailingWidth;
|
|
232
242
|
|
|
233
243
|
// ── X positioning ───────────────────────────────
|
|
234
244
|
const indent = isFirstLine
|
|
@@ -246,7 +256,7 @@ export function positionLineBoxes(
|
|
|
246
256
|
} else if (style.alignment === 'right') {
|
|
247
257
|
xOffset = indent + slack;
|
|
248
258
|
} else if (style.alignment === 'justify') {
|
|
249
|
-
// Justify: distribute slack evenly among whitespace
|
|
259
|
+
// Justify: distribute slack evenly among whitespace spans
|
|
250
260
|
// (excluding trailing whitespace).
|
|
251
261
|
//
|
|
252
262
|
// CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
|
|
@@ -259,7 +269,7 @@ export function positionLineBoxes(
|
|
|
259
269
|
const isLastLine = lineIdx === pretextLines.length - 1;
|
|
260
270
|
|
|
261
271
|
// Count only "stretchable" spaces: type === 'space' and !trailing
|
|
262
|
-
const stretchableSpaces =
|
|
272
|
+
const stretchableSpaces = spans.filter(
|
|
263
273
|
(f) => f.type === 'space' && !f.trailing,
|
|
264
274
|
);
|
|
265
275
|
const spaceCount = stretchableSpaces.length;
|
|
@@ -278,7 +288,7 @@ export function positionLineBoxes(
|
|
|
278
288
|
|
|
279
289
|
// Assign X positions
|
|
280
290
|
let runX = xOffset;
|
|
281
|
-
for (const frag of
|
|
291
|
+
for (const frag of spans) {
|
|
282
292
|
frag.x = Math.round(runX * 100) / 100;
|
|
283
293
|
runX += frag.width;
|
|
284
294
|
}
|
|
@@ -297,7 +307,7 @@ export function positionLineBoxes(
|
|
|
297
307
|
// Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
|
|
298
308
|
// Baseline = Top + ascent without any half-leading additions.
|
|
299
309
|
// See pixel-perfect-text-layout.md §1 and ECMA-376.
|
|
300
|
-
const maxFontSize =
|
|
310
|
+
const maxFontSize = spans.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
|
|
301
311
|
|
|
302
312
|
const ascentRounded = Math.round(maxAscent);
|
|
303
313
|
const descentRounded = Math.round(maxDescent);
|
|
@@ -345,24 +355,24 @@ export function positionLineBoxes(
|
|
|
345
355
|
|
|
346
356
|
// Count characters in line (for INDEX_CONSIST)
|
|
347
357
|
let lineCharCount = 0;
|
|
348
|
-
for (const frag of
|
|
358
|
+
for (const frag of spans) {
|
|
349
359
|
lineCharCount += frag.text.length;
|
|
350
360
|
}
|
|
351
361
|
const endIdx = startIdx + lineCharCount;
|
|
352
362
|
charIndex = endIdx;
|
|
353
363
|
|
|
354
|
-
// ── Mark break type on the last
|
|
364
|
+
// ── Mark break type on the last span ─────────────
|
|
355
365
|
// 'soft' — line wrap due to width constraint
|
|
356
366
|
// 'hard' — explicit break (\n)
|
|
357
367
|
// 'none'/undefined — not a line end (no break)
|
|
358
|
-
if (
|
|
359
|
-
const
|
|
360
|
-
const
|
|
368
|
+
if (spans.length > 0) {
|
|
369
|
+
const lastSpan = spans[spans.length - 1];
|
|
370
|
+
const lastSpanItem = items[lastSpan.itemIndex];
|
|
361
371
|
// If last character is \n, it's a hard break
|
|
362
|
-
if (
|
|
363
|
-
|
|
372
|
+
if (lastSpanItem && lastSpan.text.endsWith('\n')) {
|
|
373
|
+
lastSpan.breakType = 'hard';
|
|
364
374
|
} else if (lineIdx < pretextLines.length - 1) {
|
|
365
|
-
|
|
375
|
+
lastSpan.breakType = 'soft';
|
|
366
376
|
}
|
|
367
377
|
}
|
|
368
378
|
|
|
@@ -377,7 +387,7 @@ export function positionLineBoxes(
|
|
|
377
387
|
startIndex: startIdx,
|
|
378
388
|
endIndex: endIdx,
|
|
379
389
|
alignment: style.alignment,
|
|
380
|
-
|
|
390
|
+
spans,
|
|
381
391
|
});
|
|
382
392
|
|
|
383
393
|
currentY += lineBoxHeight;
|