@vyaz/core 0.0.5 → 0.0.6
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 +40 -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 +11 -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 +593 -0
- package/dist/types/FontTypes.d.ts +61 -0
- package/dist/types/LayoutTypes.d.ts +128 -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
|
@@ -1,615 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PositioningEngine.ts — pure X/Y positioning math.
|
|
3
|
-
*
|
|
4
|
-
* Takes pretext output (spans with fragments) + font metrics +
|
|
5
|
-
* paragraph style → returns Line[] with absolute coordinates.
|
|
6
|
-
*
|
|
7
|
-
* X: alignment (left/center/right/justify) + indent
|
|
8
|
-
* Y: baseline + lineHeight + spaceBefore/After
|
|
9
|
-
* Justify: fragmented approach (each space → separate Span)
|
|
10
|
-
*
|
|
11
|
-
* Specs:
|
|
12
|
-
* - CSS Text Module Level 3/4 (browser mode)
|
|
13
|
-
* - ISO/IEC 29500 (Office Open XML / DrawingML, office mode)
|
|
14
|
-
* - Parley alignment.rs (conceptually close, but here justify is simpler:
|
|
15
|
-
* slack is divided equally among stretchable space-spans,
|
|
16
|
-
* without mutating ClusterData.advance)
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { ParagraphStyle, ListStyle, NumberFormat } from '../types/Document.js';
|
|
20
|
-
import type { FontMetrics } from '../types/FontTypes.js';
|
|
21
|
-
import type { Line, Span, SpanFontMetrics } from '../types/LayoutTypes.js';
|
|
22
|
-
import type { PreparedRichInlineItem } from '../compile/DocumentCompiler.js';
|
|
23
|
-
import type { MeasureFn } from './estimateWidth.js';
|
|
24
|
-
import { resolveFragmentWidths } from './estimateWidth.js';
|
|
25
|
-
import { formatListNumber, defaultBulletChar } from '../utils/list.js';
|
|
26
|
-
|
|
27
|
-
// ── Helper types for pretext ───────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
interface PretextFragment {
|
|
30
|
-
itemIndex: number;
|
|
31
|
-
text: string;
|
|
32
|
-
gapBefore: number;
|
|
33
|
-
occupiedWidth: number;
|
|
34
|
-
start: { segmentIndex: number; graphemeIndex: number };
|
|
35
|
-
end: { segmentIndex: number; graphemeIndex: number };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface PretextLine {
|
|
39
|
-
fragments: PretextFragment[];
|
|
40
|
-
width: number; // natural width (without stretching)
|
|
41
|
-
end: { segmentIndex: number; graphemeIndex: number };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── List marker helpers ─────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Resolve the marker text for a list item.
|
|
48
|
-
*/
|
|
49
|
-
function getMarkerText(listStyle: ListStyle, listIndex: number): string {
|
|
50
|
-
if (listStyle.type === 'bullet') {
|
|
51
|
-
return listStyle.bulletChar ?? defaultBulletChar(listStyle.level ?? 0);
|
|
52
|
-
}
|
|
53
|
-
if (listStyle.type === 'number') {
|
|
54
|
-
const fmt = listStyle.numberFormat ?? 'decimal';
|
|
55
|
-
return formatListNumber(listIndex, fmt) + '.';
|
|
56
|
-
}
|
|
57
|
-
return '';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Resolve the paragraph-level font size for marker sizing.
|
|
62
|
-
* Uses the first child run's fontSize, or DEFAULT_TEXT_STYLE.fontSize.
|
|
63
|
-
*/
|
|
64
|
-
function getParagraphFontSize(items: PreparedRichInlineItem[]): number {
|
|
65
|
-
if (items.length === 0) return 12;
|
|
66
|
-
return items[0].metadata.style.fontSize ?? 12;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Resolve the effective bulletIndent for a paragraph.
|
|
71
|
-
* Uses listStyle.indents[level] if available, otherwise default * (level + 1).
|
|
72
|
-
*/
|
|
73
|
-
function resolveBulletIndent(
|
|
74
|
-
listStyle: ListStyle,
|
|
75
|
-
paraFontSize: number,
|
|
76
|
-
): number {
|
|
77
|
-
const level = listStyle.level ?? 0;
|
|
78
|
-
const indents = listStyle.indents;
|
|
79
|
-
if (indents && indents[level] !== undefined) {
|
|
80
|
-
return indents[level];
|
|
81
|
-
}
|
|
82
|
-
const defaultIndent = listStyle.bulletIndent ?? (paraFontSize * 1.5);
|
|
83
|
-
return defaultIndent * (level + 1);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── PositioningEngine ─────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Build Line[] from pretext lines with alignment and metrics.
|
|
90
|
-
*
|
|
91
|
-
* @param pretextLines — pretext result (materializeRichInlineLineRange)
|
|
92
|
-
* @param items — original PreparedRichInlineItem[] (for metadata)
|
|
93
|
-
* @param fontMetricsFn — function to get font metrics for a span
|
|
94
|
-
* @param style — paragraph style
|
|
95
|
-
* @param maxWidth — available container width
|
|
96
|
-
* @param startY — initial Y position
|
|
97
|
-
* @param mode — metric mode ('browser' | 'office'), affects line height calculation
|
|
98
|
-
* @param tag — optional tag for debugging
|
|
99
|
-
* @param measureText — function to measure text width accurately via fontkit.
|
|
100
|
-
* The function accepts (text, fontSize, fontFamily, fontWeight, fontStyle)
|
|
101
|
-
* and returns width in px. Throws FontNotFoundError if font not registered.
|
|
102
|
-
* @param listStyle — optional list configuration (bullet / numbered)
|
|
103
|
-
* @param listIndex — current index in the list (for numbered lists). 1-based.
|
|
104
|
-
* @param listMarkerWidth — pre-computed width of the widest marker in the list group.
|
|
105
|
-
* When provided, bulletIndent is expanded to this value if needed.
|
|
106
|
-
* @returns { lines: Line[], contentWidth: number }
|
|
107
|
-
*/
|
|
108
|
-
export function positionLines(
|
|
109
|
-
pretextLines: PretextLine[],
|
|
110
|
-
items: PreparedRichInlineItem[],
|
|
111
|
-
fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
|
|
112
|
-
style: ParagraphStyle,
|
|
113
|
-
maxWidth: number,
|
|
114
|
-
startY: number = 0,
|
|
115
|
-
mode: 'browser' | 'office' = 'browser',
|
|
116
|
-
measureText: (text: string, fontSize: number, fontFamily?: string, fontWeight?: string, fontStyle?: string) => number,
|
|
117
|
-
tag?: string,
|
|
118
|
-
listStyle?: ListStyle,
|
|
119
|
-
listIndex?: number,
|
|
120
|
-
listMarkerWidth?: number,
|
|
121
|
-
): { lines: Line[]; contentWidth: number } {
|
|
122
|
-
const lines: Line[] = [];
|
|
123
|
-
let currentY = startY + style.spaceBefore;
|
|
124
|
-
let charIndex = 0;
|
|
125
|
-
let isFirstLine = true;
|
|
126
|
-
let contentWidth = 0;
|
|
127
|
-
|
|
128
|
-
// ── Pre-compute marker-related values ──────────────────────────
|
|
129
|
-
const isListItem = listStyle && listStyle.type !== 'none' && listIndex !== undefined;
|
|
130
|
-
let markerText = '';
|
|
131
|
-
let markerWidth = 0;
|
|
132
|
-
let bulletZoneIndent = 0;
|
|
133
|
-
let effectiveLeftIndent = style.leftIndent ?? 0;
|
|
134
|
-
|
|
135
|
-
if (isListItem) {
|
|
136
|
-
markerText = getMarkerText(listStyle!, listIndex!);
|
|
137
|
-
const paraFontSize = getParagraphFontSize(items);
|
|
138
|
-
bulletZoneIndent = resolveBulletIndent(listStyle!, paraFontSize);
|
|
139
|
-
|
|
140
|
-
// Expand bulletIndent to fit the widest marker in the list group
|
|
141
|
-
if (listMarkerWidth !== undefined && listMarkerWidth > bulletZoneIndent) {
|
|
142
|
-
bulletZoneIndent = listMarkerWidth;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Measure marker width using paragraph's first run font
|
|
146
|
-
const firstRunFontFamily = items[0]?.metadata.style.fontFamily ?? 'Arial';
|
|
147
|
-
const firstRunFontWeight = String(items[0]?.metadata.style.fontWeight ?? 400);
|
|
148
|
-
const firstRunFontStyle = items[0]?.metadata.style.fontStyle ?? 'normal';
|
|
149
|
-
|
|
150
|
-
// Marker font-size: same as paragraph font size for numbered,
|
|
151
|
-
// slightly smaller (0.9x) for bullets (PowerPoint convention)
|
|
152
|
-
const markerFontSize = listStyle!.type === 'bullet'
|
|
153
|
-
? paraFontSize * 0.9
|
|
154
|
-
: paraFontSize;
|
|
155
|
-
markerWidth = measureText(markerText, markerFontSize, firstRunFontFamily, firstRunFontWeight, firstRunFontStyle);
|
|
156
|
-
|
|
157
|
-
// For 'outside': text block is shifted right by bulletZoneIndent
|
|
158
|
-
if (listStyle!.position !== 'inside') {
|
|
159
|
-
effectiveLeftIndent += bulletZoneIndent;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
|
|
164
|
-
const ptLine = pretextLines[lineIdx];
|
|
165
|
-
|
|
166
|
-
// ── Build Span[] ────────────────────────────
|
|
167
|
-
let maxAscent = 0;
|
|
168
|
-
let maxDescent = 0;
|
|
169
|
-
let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
|
|
170
|
-
const spans: Span[] = [];
|
|
171
|
-
|
|
172
|
-
for (const frag of ptLine.fragments) {
|
|
173
|
-
const item = items[frag.itemIndex];
|
|
174
|
-
if (!item) continue;
|
|
175
|
-
|
|
176
|
-
const metrics = fontMetricsFn(item);
|
|
177
|
-
// baselineOffset: superscript → negative (glyph moves up).
|
|
178
|
-
// ascent must increase (glyph above baseline), descent must decrease (less overhang below).
|
|
179
|
-
// subscript → positive (glyph moves down).
|
|
180
|
-
// ascent must decrease, descent must increase.
|
|
181
|
-
const effectiveAscent = metrics.ascent - (item.metadata.baselineOffset || 0);
|
|
182
|
-
const effectiveDescent = metrics.descent + (item.metadata.baselineOffset || 0);
|
|
183
|
-
|
|
184
|
-
maxAscent = Math.max(maxAscent, effectiveAscent);
|
|
185
|
-
maxDescent = Math.max(maxDescent, effectiveDescent);
|
|
186
|
-
// Office: line height base = ascent + descent (OS/2 usWinAscent + usWinDescent, scaled)
|
|
187
|
-
maxLineHeightBase = Math.max(maxLineHeightBase, metrics.ascent + metrics.descent);
|
|
188
|
-
|
|
189
|
-
// pretext: gapBefore — inter-word space BEFORE the word
|
|
190
|
-
// occupiedWidth = gapBefore + textWidth
|
|
191
|
-
// Split into two Span: space + word
|
|
192
|
-
const gapWidth = frag.gapBefore || 0;
|
|
193
|
-
const textWidth = frag.occupiedWidth;
|
|
194
|
-
|
|
195
|
-
const baseFontMetrics = {
|
|
196
|
-
ascent: metrics.ascent,
|
|
197
|
-
descent: metrics.descent,
|
|
198
|
-
fontSize: item.metadata.effectiveFontSize,
|
|
199
|
-
baselineOffset: item.metadata.baselineOffset || undefined,
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
if (gapWidth > 0) {
|
|
203
|
-
spans.push({
|
|
204
|
-
x: 0,
|
|
205
|
-
width: gapWidth,
|
|
206
|
-
text: ' ',
|
|
207
|
-
itemIndex: frag.itemIndex,
|
|
208
|
-
pIdx: 0,
|
|
209
|
-
tag,
|
|
210
|
-
fontMetrics: baseFontMetrics,
|
|
211
|
-
style: item.metadata.style,
|
|
212
|
-
inlineWidget: item.metadata.inlineWidget,
|
|
213
|
-
type: 'space',
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Split leading/trailing spaces from frag.text into separate space spans
|
|
218
|
-
// This is needed for SVG rendering to avoid xml:space="preserve" dependency
|
|
219
|
-
const text = frag.text;
|
|
220
|
-
const leadingMatch = text.match(/^(\s+)/);
|
|
221
|
-
|
|
222
|
-
let remainingText = text;
|
|
223
|
-
let leadingSpaceChars = 0;
|
|
224
|
-
let trailingSpaceChars = 0;
|
|
225
|
-
|
|
226
|
-
if (leadingMatch) {
|
|
227
|
-
leadingSpaceChars = leadingMatch[1].length;
|
|
228
|
-
remainingText = remainingText.slice(leadingSpaceChars);
|
|
229
|
-
}
|
|
230
|
-
if (remainingText.length > 0) {
|
|
231
|
-
const trailMatch = remainingText.match(/(\s+)$/);
|
|
232
|
-
if (trailMatch) {
|
|
233
|
-
trailingSpaceChars = trailMatch[1].length;
|
|
234
|
-
remainingText = remainingText.slice(0, -trailingSpaceChars);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Resolve widths via resolveFragmentWidths:
|
|
239
|
-
// exact measurement (measureText) when available,
|
|
240
|
-
// weight-based fallback otherwise,
|
|
241
|
-
// then correctToSumInvariant to preserve line-breaking invariant.
|
|
242
|
-
const fragments: string[] = [];
|
|
243
|
-
if (leadingSpaceChars > 0) fragments.push(text.slice(0, leadingSpaceChars));
|
|
244
|
-
if (remainingText.length > 0) fragments.push(remainingText);
|
|
245
|
-
if (trailingSpaceChars > 0) fragments.push(text.slice(leadingSpaceChars + remainingText.length));
|
|
246
|
-
|
|
247
|
-
// Build measure function with font parameters baked in
|
|
248
|
-
const { fontFamily, fontWeight, fontStyle } = item.metadata.style;
|
|
249
|
-
const fsWeight = String(fontWeight || 400);
|
|
250
|
-
const fsStyle = fontStyle || 'normal';
|
|
251
|
-
const fragmentMeasureFn: MeasureFn = (t: string) => measureText(t, baseFontMetrics.fontSize, fontFamily, fsWeight, fsStyle);
|
|
252
|
-
|
|
253
|
-
const resolvedWidths = resolveFragmentWidths(fragments, text, textWidth, fragmentMeasureFn);
|
|
254
|
-
let resolvedIdx = 0;
|
|
255
|
-
const leadingWidth = leadingSpaceChars > 0 ? resolvedWidths[resolvedIdx++] : 0;
|
|
256
|
-
const trimmedWidth = remainingText.length > 0 ? resolvedWidths[resolvedIdx++] : 0;
|
|
257
|
-
const trailingWidthVal = trailingSpaceChars > 0 ? resolvedWidths[resolvedIdx++] : 0;
|
|
258
|
-
|
|
259
|
-
// Leading space span
|
|
260
|
-
if (leadingSpaceChars > 0) {
|
|
261
|
-
const leadingText = text.slice(0, leadingSpaceChars);
|
|
262
|
-
spans.push({
|
|
263
|
-
x: 0,
|
|
264
|
-
width: leadingWidth,
|
|
265
|
-
text: leadingText,
|
|
266
|
-
itemIndex: frag.itemIndex,
|
|
267
|
-
pIdx: 0,
|
|
268
|
-
tag,
|
|
269
|
-
fontMetrics: baseFontMetrics,
|
|
270
|
-
style: item.metadata.style,
|
|
271
|
-
inlineWidget: item.metadata.inlineWidget,
|
|
272
|
-
type: 'space',
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Text span (trimmed)
|
|
277
|
-
if (remainingText.length > 0) {
|
|
278
|
-
const actualTextWidth = trimmedWidth;
|
|
279
|
-
spans.push({
|
|
280
|
-
x: 0,
|
|
281
|
-
width: actualTextWidth,
|
|
282
|
-
text: remainingText,
|
|
283
|
-
itemIndex: frag.itemIndex,
|
|
284
|
-
pIdx: 0,
|
|
285
|
-
tag,
|
|
286
|
-
fontMetrics: baseFontMetrics,
|
|
287
|
-
style: item.metadata.style,
|
|
288
|
-
inlineWidget: item.metadata.inlineWidget,
|
|
289
|
-
type: 'text',
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Trailing space span
|
|
294
|
-
if (trailingSpaceChars > 0) {
|
|
295
|
-
const trailingStart = leadingSpaceChars + remainingText.length;
|
|
296
|
-
const trailingText = text.slice(trailingStart, trailingStart + trailingSpaceChars);
|
|
297
|
-
spans.push({
|
|
298
|
-
x: 0,
|
|
299
|
-
width: trailingWidthVal,
|
|
300
|
-
text: trailingText,
|
|
301
|
-
itemIndex: frag.itemIndex,
|
|
302
|
-
pIdx: 0,
|
|
303
|
-
tag,
|
|
304
|
-
fontMetrics: baseFontMetrics,
|
|
305
|
-
style: item.metadata.style,
|
|
306
|
-
inlineWidget: item.metadata.inlineWidget,
|
|
307
|
-
type: 'space',
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// ── Inline-box width correction ─────────────────────────
|
|
313
|
-
// When a span has an inlineWidget, override its width
|
|
314
|
-
// to match the widget width (the \uFFFC advance is not included).
|
|
315
|
-
for (const s of spans) {
|
|
316
|
-
if (s.inlineWidget) {
|
|
317
|
-
s.width = s.inlineWidget.width;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ── Prepare marker data (only on first line) ──────────────────
|
|
322
|
-
// For 'inside': marker is inserted into the span flow before X positioning.
|
|
323
|
-
// For 'outside': marker is added after X positioning with a custom x in the bullet zone.
|
|
324
|
-
let outsideMarkerSpan: Span | null = null;
|
|
325
|
-
|
|
326
|
-
if (isListItem && isFirstLine) {
|
|
327
|
-
const paraFontSize = getParagraphFontSize(items);
|
|
328
|
-
const firstRun = items[0];
|
|
329
|
-
const markerFontSize = listStyle!.type === 'bullet' ? paraFontSize * 0.9 : paraFontSize;
|
|
330
|
-
const markerAscent = markerFontSize * 0.8; // approximate ascent for marker
|
|
331
|
-
const markerDescent = markerFontSize * 0.2;
|
|
332
|
-
|
|
333
|
-
maxAscent = Math.max(maxAscent, markerAscent);
|
|
334
|
-
maxDescent = Math.max(maxDescent, markerDescent);
|
|
335
|
-
maxLineHeightBase = Math.max(maxLineHeightBase, markerAscent + markerDescent);
|
|
336
|
-
|
|
337
|
-
// Create a style object for the marker span
|
|
338
|
-
const markerStyle = {
|
|
339
|
-
...(firstRun?.metadata.style ?? {
|
|
340
|
-
fontFamily: 'Arial',
|
|
341
|
-
fontSize: markerFontSize,
|
|
342
|
-
fontWeight: 400,
|
|
343
|
-
fontStyle: 'normal' as const,
|
|
344
|
-
color: '#000000',
|
|
345
|
-
type: 'text' as const,
|
|
346
|
-
text: markerText,
|
|
347
|
-
}),
|
|
348
|
-
fontSize: markerFontSize,
|
|
349
|
-
text: markerText,
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
const markerSpanBase: Span = {
|
|
353
|
-
x: 0,
|
|
354
|
-
width: markerWidth,
|
|
355
|
-
text: markerText,
|
|
356
|
-
itemIndex: 0,
|
|
357
|
-
pIdx: 0,
|
|
358
|
-
tag,
|
|
359
|
-
fontMetrics: {
|
|
360
|
-
ascent: markerAscent,
|
|
361
|
-
descent: markerDescent,
|
|
362
|
-
fontSize: markerFontSize,
|
|
363
|
-
},
|
|
364
|
-
style: markerStyle,
|
|
365
|
-
type: 'marker',
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
// For 'inside': marker is part of text flow — insert at beginning of spans
|
|
369
|
-
// For 'outside': store for later (after X positioning)
|
|
370
|
-
if (listStyle!.position === 'inside') {
|
|
371
|
-
spans.unshift(markerSpanBase);
|
|
372
|
-
// Add a small gap after the marker (0.5em)
|
|
373
|
-
const gapWidth = markerFontSize * 0.5;
|
|
374
|
-
spans.splice(1, 0, {
|
|
375
|
-
x: 0,
|
|
376
|
-
width: gapWidth,
|
|
377
|
-
text: ' ',
|
|
378
|
-
itemIndex: 0,
|
|
379
|
-
pIdx: 0,
|
|
380
|
-
tag,
|
|
381
|
-
fontMetrics: {
|
|
382
|
-
ascent: 0,
|
|
383
|
-
descent: 0,
|
|
384
|
-
fontSize: markerFontSize,
|
|
385
|
-
},
|
|
386
|
-
style: markerStyle,
|
|
387
|
-
type: 'space',
|
|
388
|
-
});
|
|
389
|
-
} else {
|
|
390
|
-
// 'outside' (default): marker sits in the bullet zone, left of text
|
|
391
|
-
outsideMarkerSpan = markerSpanBase;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ── Mark trailing whitespace spans ───────────────────
|
|
396
|
-
// CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
|
|
397
|
-
// Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
|
|
398
|
-
//
|
|
399
|
-
// Find the last space(s) at the end of the line and mark them trailing.
|
|
400
|
-
let trailingStartIndex = spans.length;
|
|
401
|
-
for (let i = spans.length - 1; i >= 0; i--) {
|
|
402
|
-
if (spans[i].type === 'space') {
|
|
403
|
-
trailingStartIndex = i;
|
|
404
|
-
} else {
|
|
405
|
-
break;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const trailingWidth = spans
|
|
410
|
-
.slice(trailingStartIndex)
|
|
411
|
-
.reduce((sum, f) => sum + f.width, 0);
|
|
412
|
-
|
|
413
|
-
for (let i = trailingStartIndex; i < spans.length; i++) {
|
|
414
|
-
spans[i].trailing = true;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ── Compute effective line width (excluding trailing whitespace) ─
|
|
418
|
-
// Use sum of actual span widths instead of ptLine.width, because
|
|
419
|
-
// ptLine.width may not include gapBefore spaces that were split into
|
|
420
|
-
// separate Span (e.g. "AA" + " A" → [AA][ ][A]).
|
|
421
|
-
const totalSpanWidth = spans.reduce((sum, f) => sum + f.width, 0);
|
|
422
|
-
const effectiveLineWidth = totalSpanWidth - trailingWidth;
|
|
423
|
-
|
|
424
|
-
// ── X positioning ───────────────────────────────
|
|
425
|
-
const indent = isFirstLine
|
|
426
|
-
? effectiveLeftIndent + (style.indent || 0)
|
|
427
|
-
: effectiveLeftIndent;
|
|
428
|
-
const rightIndent = style.rightIndent || 0;
|
|
429
|
-
const availableWidth = maxWidth - indent - rightIndent;
|
|
430
|
-
|
|
431
|
-
const slack = Math.max(0, availableWidth - effectiveLineWidth);
|
|
432
|
-
|
|
433
|
-
let xOffset = indent;
|
|
434
|
-
|
|
435
|
-
if (style.alignment === 'center') {
|
|
436
|
-
xOffset = indent + slack / 2;
|
|
437
|
-
} else if (style.alignment === 'right') {
|
|
438
|
-
xOffset = indent + slack;
|
|
439
|
-
} else if (style.alignment === 'justify') {
|
|
440
|
-
// Justify: distribute slack evenly among whitespace spans
|
|
441
|
-
// (excluding trailing whitespace).
|
|
442
|
-
//
|
|
443
|
-
// CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
|
|
444
|
-
// CSS text-align-last: last line is not justify, but start-align.
|
|
445
|
-
// OOXML (ISO 29500): last line of paragraph is not stretched.
|
|
446
|
-
// Parley alignment.rs: excludes last line (line.break_reason == None/Explicit)
|
|
447
|
-
// and lines with num_spaces == 0.
|
|
448
|
-
//
|
|
449
|
-
// Determine if this is the last line in the paragraph.
|
|
450
|
-
const isLastLine = lineIdx === pretextLines.length - 1;
|
|
451
|
-
|
|
452
|
-
// Count only "stretchable" spaces: type === 'space' and !trailing
|
|
453
|
-
const stretchableSpaces = spans.filter(
|
|
454
|
-
(f) => f.type === 'space' && !f.trailing,
|
|
455
|
-
);
|
|
456
|
-
const spaceCount = stretchableSpaces.length;
|
|
457
|
-
|
|
458
|
-
if (!isLastLine && spaceCount > 0 && slack > 0) {
|
|
459
|
-
const extraPerSpace = slack / spaceCount;
|
|
460
|
-
for (const sf of stretchableSpaces) {
|
|
461
|
-
sf.width += extraPerSpace;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// If this is the last line or no spaces — fallback to start-align
|
|
466
|
-
// (LTR → left, RTL → right — always left for now, Bidi in Phase 2)
|
|
467
|
-
xOffset = indent;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Assign X positions
|
|
471
|
-
let runX = xOffset;
|
|
472
|
-
for (const frag of spans) {
|
|
473
|
-
frag.x = Math.round(runX * 100) / 100;
|
|
474
|
-
runX += frag.width;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// ── Outside marker: place in the bullet zone (left of text) ──
|
|
478
|
-
// Marker is right-aligned within the bullet zone so multi-digit
|
|
479
|
-
// numbers ("9." vs "10.") share the same right edge.
|
|
480
|
-
// CSS list-style-position: outside — marker is outside the principal box.
|
|
481
|
-
// OOXML a:buFont / a:buChar — bullet sits in the indent zone.
|
|
482
|
-
if (outsideMarkerSpan) {
|
|
483
|
-
// Right edge of the bullet zone = xOffset (start of text)
|
|
484
|
-
// Marker right-aligned: x = xOffset - markerWidth - small gap
|
|
485
|
-
const gap = outsideMarkerSpan.fontMetrics.fontSize * 0.25; // 0.25em gap
|
|
486
|
-
outsideMarkerSpan.x = Math.round((xOffset - outsideMarkerSpan.width - gap) * 100) / 100;
|
|
487
|
-
// Prepend so it appears first in the spans array
|
|
488
|
-
spans.unshift(outsideMarkerSpan);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Line box must cover ALL spans, including outside markers that sit
|
|
492
|
-
// left of the principal text box. Otherwise contentWidth / computeBBox
|
|
493
|
-
// miss the marker and content sizing clips it.
|
|
494
|
-
let lineX = xOffset;
|
|
495
|
-
let lineWidth = runX - xOffset;
|
|
496
|
-
if (spans.length > 0) {
|
|
497
|
-
const minSpanX = Math.min(...spans.map(s => s.x));
|
|
498
|
-
const maxSpanRight = Math.max(...spans.map(s => s.x + s.width));
|
|
499
|
-
lineX = minSpanX;
|
|
500
|
-
lineWidth = maxSpanRight - minSpanX;
|
|
501
|
-
}
|
|
502
|
-
// contentWidth is the absolute right edge of content (not just line.width).
|
|
503
|
-
// When line.x > 0 (outside marker, indent, center/right), consumers that
|
|
504
|
-
// size the canvas as contentWidth with viewBox origin 0 need this value.
|
|
505
|
-
contentWidth = Math.max(contentWidth, lineX + lineWidth);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
// ── Y positioning ───────────────────────────────
|
|
510
|
-
// Line height algorithm depends on mode:
|
|
511
|
-
//
|
|
512
|
-
// 'browser' (CSS-compatible, parley/Chrome matching):
|
|
513
|
-
// 1. lineHeightPx = maxFontSize * style.lineHeight
|
|
514
|
-
//
|
|
515
|
-
// 'office' (MS Office / DrawingML pixel-perfect):
|
|
516
|
-
// PowerPoint has no line-height multiplier for single lines.
|
|
517
|
-
// Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
|
|
518
|
-
// Baseline = Top + ascent without any half-leading additions.
|
|
519
|
-
// See pixel-perfect-text-layout.md §1 and ECMA-376.
|
|
520
|
-
const maxFontSize = spans.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
|
|
521
|
-
|
|
522
|
-
const ascentRounded = Math.round(maxAscent);
|
|
523
|
-
const descentRounded = Math.round(maxDescent);
|
|
524
|
-
|
|
525
|
-
let lineBoxHeight: number;
|
|
526
|
-
let baseline: number;
|
|
527
|
-
|
|
528
|
-
if (mode === 'office') {
|
|
529
|
-
// DrawingML: lineHeight = ascent + descent, no lineHeight ×1.15 and no leading.
|
|
530
|
-
// DrawingML: base line height = OS/2 (usWinAscent + usWinDescent), without
|
|
531
|
-
// sum of rounded ascent/descent — that formula caused pixel-perfect
|
|
532
|
-
// mismatch with PowerPoint, so we use maxLineHeightBase.
|
|
533
|
-
lineBoxHeight = maxLineHeightBase;
|
|
534
|
-
baseline = ascentRounded;
|
|
535
|
-
} else {
|
|
536
|
-
// Browser: CSS-compatible with leading distribution.
|
|
537
|
-
const lineHeightPx = maxFontSize * style.lineHeight;
|
|
538
|
-
const ascentDescentRounded = ascentRounded + descentRounded;
|
|
539
|
-
|
|
540
|
-
const rawLineBoxHeight = Math.round(lineHeightPx);
|
|
541
|
-
lineBoxHeight = Math.max(rawLineBoxHeight, ascentDescentRounded);
|
|
542
|
-
const leading = lineBoxHeight - ascentDescentRounded; // always integer
|
|
543
|
-
|
|
544
|
-
if (leading <= 0) {
|
|
545
|
-
// Negative or zero leading: don't shrink the line
|
|
546
|
-
baseline = ascentRounded;
|
|
547
|
-
} else {
|
|
548
|
-
// Positive leading: distribute as integers with above_leading < below_leading
|
|
549
|
-
const ascentDescent = maxAscent + maxDescent;
|
|
550
|
-
const aboveLeadingFloat = ascentDescent > 0 ? leading * maxAscent / ascentDescent : leading / 2;
|
|
551
|
-
let aboveLeading = Math.round(aboveLeadingFloat);
|
|
552
|
-
let belowLeading = leading - aboveLeading;
|
|
553
|
-
|
|
554
|
-
// Ensure above_leading < below_leading (parley/Chrome heuristic)
|
|
555
|
-
if (aboveLeading >= belowLeading) {
|
|
556
|
-
aboveLeading = Math.floor((leading - 1) / 2);
|
|
557
|
-
belowLeading = leading - aboveLeading;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
baseline = ascentRounded + aboveLeading;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const startIdx = charIndex;
|
|
565
|
-
|
|
566
|
-
// Count characters in line (for INDEX_CONSIST)
|
|
567
|
-
let lineCharCount = 0;
|
|
568
|
-
for (const frag of spans) {
|
|
569
|
-
// Marker spans don't count toward the paragraph's character index
|
|
570
|
-
if (frag.type !== 'marker') {
|
|
571
|
-
lineCharCount += frag.text.length;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
const endIdx = startIdx + lineCharCount;
|
|
575
|
-
charIndex = endIdx;
|
|
576
|
-
|
|
577
|
-
// ── Mark break type on the last span ─────────────
|
|
578
|
-
// 'soft' — line wrap due to width constraint
|
|
579
|
-
// 'hard' — explicit break (\n)
|
|
580
|
-
// 'none'/undefined — not a line end (no break)
|
|
581
|
-
if (spans.length > 0) {
|
|
582
|
-
const lastSpan = spans[spans.length - 1];
|
|
583
|
-
const lastSpanItem = items[lastSpan.itemIndex];
|
|
584
|
-
// If last character is \n, it's a hard break
|
|
585
|
-
if (lastSpanItem && lastSpan.text.endsWith('\n')) {
|
|
586
|
-
lastSpan.breakType = 'hard';
|
|
587
|
-
} else if (lineIdx < pretextLines.length - 1) {
|
|
588
|
-
lastSpan.breakType = 'soft';
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
lines.push({
|
|
593
|
-
x: Math.round(lineX * 100) / 100,
|
|
594
|
-
y: Math.round(currentY * 100) / 100,
|
|
595
|
-
width: Math.round(lineWidth * 100) / 100,
|
|
596
|
-
height: Math.round(lineBoxHeight * 100) / 100,
|
|
597
|
-
baseline: Math.round(baseline * 100) / 100,
|
|
598
|
-
ascent: Math.round(maxAscent * 100) / 100,
|
|
599
|
-
descent: Math.round(maxDescent * 100) / 100,
|
|
600
|
-
startIndex: startIdx,
|
|
601
|
-
endIndex: endIdx,
|
|
602
|
-
alignment: style.alignment,
|
|
603
|
-
spans,
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
currentY += lineBoxHeight;
|
|
608
|
-
isFirstLine = false;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// Add spaceAfter to last line height
|
|
612
|
-
// (return as is — ParagraphLayoutEngine will add spaceAfter to total height)
|
|
613
|
-
|
|
614
|
-
return { lines, contentWidth };
|
|
615
|
-
}
|