@vyaz/core 0.0.4 → 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.
Files changed (42) hide show
  1. package/README.md +156 -0
  2. package/dist/compile/DocumentCompiler.d.ts +40 -0
  3. package/dist/index.browser.d.ts +28 -0
  4. package/dist/index.browser.js +18 -0
  5. package/dist/index.d.ts +30 -0
  6. package/dist/index.js +11 -147930
  7. package/dist/layout/AutoFitEngine.d.ts +44 -0
  8. package/dist/layout/LineBoxValidator.d.ts +25 -0
  9. package/dist/layout/ParagraphLayoutEngine.d.ts +46 -0
  10. package/dist/layout/PositioningEngine.d.ts +68 -0
  11. package/dist/layout/TextFrameLayoutEngine.d.ts +50 -0
  12. package/dist/layout/estimateWidth.d.ts +36 -0
  13. package/dist/measure/FontEngine.d.ts +47 -0
  14. package/dist/measure/FontMetricsProvider.d.ts +49 -0
  15. package/dist/measure/FontNotFoundError.d.ts +6 -0
  16. package/dist/measure/SystemFontRegistry.d.ts +46 -0
  17. package/dist/measure/canvas-polyfill.d.ts +30 -0
  18. package/dist/types/Document.d.ts +593 -0
  19. package/dist/types/FontTypes.d.ts +61 -0
  20. package/dist/types/LayoutTypes.d.ts +128 -0
  21. package/dist/utils/env.d.ts +11 -0
  22. package/dist/utils/font.d.ts +16 -0
  23. package/dist/utils/groupLinesByParagraph.d.ts +44 -0
  24. package/dist/utils/list.d.ts +41 -0
  25. package/dist/utils/textTransform.d.ts +32 -0
  26. package/package.json +30 -13
  27. package/src/compile/DocumentCompiler.ts +0 -136
  28. package/src/index.ts +0 -80
  29. package/src/layout/AutoFitEngine.ts +0 -101
  30. package/src/layout/LineBoxValidator.ts +0 -162
  31. package/src/layout/ParagraphLayoutEngine.ts +0 -202
  32. package/src/layout/PositioningEngine.ts +0 -401
  33. package/src/layout/TextFrameLayoutEngine.ts +0 -91
  34. package/src/measure/FontMetricsProvider.ts +0 -226
  35. package/src/measure/FontNotFoundError.ts +0 -16
  36. package/src/measure/SystemFontRegistry.ts +0 -151
  37. package/src/measure/canvas-polyfill.d.ts +0 -6
  38. package/src/measure/canvas-polyfill.ts +0 -235
  39. package/src/measure/fontkit.d.ts +0 -44
  40. package/src/types/Document.ts +0 -540
  41. package/src/types/FontTypes.ts +0 -74
  42. package/src/types/LayoutTypes.ts +0 -141
@@ -1,401 +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 } 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
-
24
- // ── Helper types for pretext ───────────────────────────────────────────
25
-
26
- interface PretextFragment {
27
- itemIndex: number;
28
- text: string;
29
- gapBefore: number;
30
- occupiedWidth: number;
31
- start: { segmentIndex: number; graphemeIndex: number };
32
- end: { segmentIndex: number; graphemeIndex: number };
33
- }
34
-
35
- interface PretextLine {
36
- fragments: PretextFragment[];
37
- width: number; // natural width (without stretching)
38
- end: { segmentIndex: number; graphemeIndex: number };
39
- }
40
-
41
- // ── PositioningEngine ─────────────────────────────────────────────────
42
-
43
- /**
44
- * Build Line[] from pretext lines with alignment and metrics.
45
- *
46
- * @param pretextLines — pretext result (materializeRichInlineLineRange)
47
- * @param items — original PreparedRichInlineItem[] (for metadata)
48
- * @param fontMetricsFn — function to get font metrics for a span
49
- * @param style — paragraph style
50
- * @param maxWidth — available container width
51
- * @param startY — initial Y position
52
- * @param mode — metric mode ('browser' | 'office'), affects line height calculation
53
- * @returns { lines: Line[], contentWidth: number }
54
- */
55
- export function positionLines(
56
- pretextLines: PretextLine[],
57
- items: PreparedRichInlineItem[],
58
- fontMetricsFn: (item: PreparedRichInlineItem) => FontMetrics,
59
- style: ParagraphStyle,
60
- maxWidth: number,
61
- startY: number = 0,
62
- mode: 'browser' | 'office' = 'browser',
63
- paragraphId?: string,
64
- ): { lines: Line[]; contentWidth: number } {
65
- const lines: Line[] = [];
66
- let currentY = startY + style.spaceBefore;
67
- let charIndex = 0;
68
- let isFirstLine = true;
69
- let contentWidth = 0;
70
-
71
- for (let lineIdx = 0; lineIdx < pretextLines.length; lineIdx++) {
72
- const ptLine = pretextLines[lineIdx];
73
-
74
- // ── Build Span[] ────────────────────────────
75
- let maxAscent = 0;
76
- let maxDescent = 0;
77
- let maxLineHeightBase = 0; // max(ascent + descent) — for Office mode
78
- const spans: Span[] = [];
79
-
80
- for (const frag of ptLine.fragments) {
81
- const item = items[frag.itemIndex];
82
- if (!item) continue;
83
-
84
- const metrics = fontMetricsFn(item);
85
- const effectiveAscent = metrics.ascent + (item.metadata.baselineOffset || 0);
86
- const effectiveDescent = metrics.descent - (item.metadata.baselineOffset || 0);
87
-
88
- maxAscent = Math.max(maxAscent, effectiveAscent);
89
- maxDescent = Math.max(maxDescent, effectiveDescent);
90
- // Office: line height base = ascent + descent (OS/2 usWinAscent + usWinDescent, scaled)
91
- maxLineHeightBase = Math.max(maxLineHeightBase, metrics.ascent + metrics.descent);
92
-
93
- // pretext: gapBefore — inter-word space BEFORE the word
94
- // occupiedWidth = gapBefore + textWidth
95
- // Split into two Span: space + word
96
- const gapWidth = frag.gapBefore || 0;
97
- const textWidth = frag.occupiedWidth;
98
-
99
- const baseFontMetrics = {
100
- ascent: metrics.ascent,
101
- descent: metrics.descent,
102
- fontSize: item.metadata.effectiveFontSize,
103
- baselineOffset: item.metadata.baselineOffset || undefined,
104
- };
105
-
106
- if (gapWidth > 0) {
107
- spans.push({
108
- x: 0,
109
- width: gapWidth,
110
- text: ' ',
111
- itemIndex: frag.itemIndex,
112
- paragraphId,
113
- fontMetrics: baseFontMetrics,
114
- style: item.metadata.style,
115
- inlineWidget: item.metadata.inlineWidget,
116
- type: 'space',
117
- });
118
- }
119
-
120
- // Split leading/trailing spaces from frag.text into separate space spans
121
- // This is needed for SVG rendering to avoid xml:space="preserve" dependency
122
- const text = frag.text;
123
- const leadingMatch = text.match(/^(\s+)/);
124
-
125
- const totalChars = text.length;
126
- let remainingText = text;
127
- let leadingSpaceChars = 0;
128
- let trailingSpaceChars = 0;
129
-
130
- if (leadingMatch) {
131
- leadingSpaceChars = leadingMatch[1].length;
132
- remainingText = remainingText.slice(leadingSpaceChars);
133
- }
134
- if (remainingText.length > 0) {
135
- const trailMatch = remainingText.match(/(\s+)$/);
136
- if (trailMatch) {
137
- trailingSpaceChars = trailMatch[1].length;
138
- remainingText = remainingText.slice(0, -trailingSpaceChars);
139
- }
140
- }
141
-
142
- const computePartialWidth = (charCount: number) => {
143
- return totalChars > 0 ? (charCount / totalChars) * textWidth : 0;
144
- };
145
-
146
- // Leading space span
147
- if (leadingSpaceChars > 0) {
148
- spans.push({
149
- x: 0,
150
- width: computePartialWidth(leadingSpaceChars),
151
- text: text.slice(0, leadingSpaceChars),
152
- itemIndex: frag.itemIndex,
153
- paragraphId,
154
- fontMetrics: baseFontMetrics,
155
- style: item.metadata.style,
156
- inlineWidget: item.metadata.inlineWidget,
157
- type: 'space',
158
- });
159
- }
160
-
161
- // Text span (trimmed)
162
- if (remainingText.length > 0) {
163
- const textWidth = computePartialWidth(remainingText.length);
164
- // Compute per-glyph advances by distributing textWidth equally across glyphs.
165
- // For monospace fonts this is exact; for proportional it's a linear approximation.
166
- // The FontMetricsProvider can later supply real glyph advances when available.
167
- const glyphAdvances: number[] = [];
168
- if (remainingText.length > 1) {
169
- const perGlyph = textWidth / remainingText.length;
170
- for (let i = 0; i < remainingText.length; i++) {
171
- glyphAdvances.push(perGlyph);
172
- }
173
- }
174
- spans.push({
175
- x: 0,
176
- width: textWidth,
177
- text: remainingText,
178
- itemIndex: frag.itemIndex,
179
- paragraphId,
180
- fontMetrics: baseFontMetrics,
181
- style: item.metadata.style,
182
- inlineWidget: item.metadata.inlineWidget,
183
- type: 'text',
184
- glyphAdvances: glyphAdvances.length > 0 ? glyphAdvances : undefined,
185
- });
186
- }
187
-
188
- // Trailing space span
189
- if (trailingSpaceChars > 0) {
190
- const trailingStart = leadingSpaceChars + remainingText.length;
191
- spans.push({
192
- x: 0,
193
- width: computePartialWidth(trailingSpaceChars),
194
- text: text.slice(trailingStart, trailingStart + trailingSpaceChars),
195
- itemIndex: frag.itemIndex,
196
- paragraphId,
197
- fontMetrics: baseFontMetrics,
198
- style: item.metadata.style,
199
- inlineWidget: item.metadata.inlineWidget,
200
- type: 'space',
201
- });
202
- }
203
- }
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 ───────────────────
215
- // CSS Text §4.1.3: end-of-line spaces have zero measure for line-advance calculations.
216
- // Parley: LineItemData.has_trailing_whitespace → trailing whitespace is excluded from advance.
217
- //
218
- // Find the last space(s) at the end of the line and mark them trailing.
219
- let trailingStartIndex = spans.length;
220
- for (let i = spans.length - 1; i >= 0; i--) {
221
- if (spans[i].type === 'space') {
222
- trailingStartIndex = i;
223
- } else {
224
- break;
225
- }
226
- }
227
-
228
- const trailingWidth = spans
229
- .slice(trailingStartIndex)
230
- .reduce((sum, f) => sum + f.width, 0);
231
-
232
- for (let i = trailingStartIndex; i < spans.length; i++) {
233
- spans[i].trailing = true;
234
- }
235
-
236
- // ── Compute effective line width (excluding trailing whitespace) ─
237
- // Use sum of actual span widths instead of ptLine.width, because
238
- // ptLine.width may not include gapBefore spaces that were split into
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;
242
-
243
- // ── X positioning ───────────────────────────────
244
- const indent = isFirstLine
245
- ? (style.leftIndent || 0) + (style.indent || 0)
246
- : (style.leftIndent || 0);
247
- const rightIndent = style.rightIndent || 0;
248
- const availableWidth = maxWidth - indent - rightIndent;
249
-
250
- const slack = Math.max(0, availableWidth - effectiveLineWidth);
251
-
252
- let xOffset = indent;
253
-
254
- if (style.alignment === 'center') {
255
- xOffset = indent + slack / 2;
256
- } else if (style.alignment === 'right') {
257
- xOffset = indent + slack;
258
- } else if (style.alignment === 'justify') {
259
- // Justify: distribute slack evenly among whitespace spans
260
- // (excluding trailing whitespace).
261
- //
262
- // CSS Text Module Level 3 §4.1.3: trailing spaces do not participate in justify.
263
- // CSS text-align-last: last line is not justify, but start-align.
264
- // OOXML (ISO 29500): last line of paragraph is not stretched.
265
- // Parley alignment.rs: excludes last line (line.break_reason == None/Explicit)
266
- // and lines with num_spaces == 0.
267
- //
268
- // Determine if this is the last line in the paragraph.
269
- const isLastLine = lineIdx === pretextLines.length - 1;
270
-
271
- // Count only "stretchable" spaces: type === 'space' and !trailing
272
- const stretchableSpaces = spans.filter(
273
- (f) => f.type === 'space' && !f.trailing,
274
- );
275
- const spaceCount = stretchableSpaces.length;
276
-
277
- if (!isLastLine && spaceCount > 0 && slack > 0) {
278
- const extraPerSpace = slack / spaceCount;
279
- for (const sf of stretchableSpaces) {
280
- sf.width += extraPerSpace;
281
- }
282
- }
283
-
284
- // If this is the last line or no spaces — fallback to start-align
285
- // (LTR → left, RTL → right — always left for now, Bidi in Phase 2)
286
- xOffset = indent;
287
- }
288
-
289
- // Assign X positions
290
- let runX = xOffset;
291
- for (const frag of spans) {
292
- frag.x = Math.round(runX * 100) / 100;
293
- runX += frag.width;
294
- }
295
-
296
- const lineWidth = runX - xOffset;
297
- contentWidth = Math.max(contentWidth, lineWidth);
298
-
299
- // ── Y positioning ───────────────────────────────
300
- // Line height algorithm depends on mode:
301
- //
302
- // 'browser' (CSS-compatible, parley/Chrome matching):
303
- // 1. lineHeightPx = maxFontSize * style.lineHeight
304
- //
305
- // 'office' (MS Office / DrawingML pixel-perfect):
306
- // PowerPoint has no line-height multiplier for single lines.
307
- // Line height strictly = ascent + descent (OS/2.usWinAscent + usWinDescent).
308
- // Baseline = Top + ascent without any half-leading additions.
309
- // See pixel-perfect-text-layout.md §1 and ECMA-376.
310
- const maxFontSize = spans.reduce((max, f) => Math.max(max, f.fontMetrics.fontSize), 0);
311
-
312
- const ascentRounded = Math.round(maxAscent);
313
- const descentRounded = Math.round(maxDescent);
314
-
315
- let lineBoxHeight: number;
316
- let baseline: number;
317
-
318
- if (mode === 'office') {
319
- // DrawingML: lineHeight = ascent + descent, no lineHeight ×1.15 and no leading.
320
- // DrawingML: base line height = OS/2 (usWinAscent + usWinDescent), without
321
- // sum of rounded ascent/descent — that formula caused pixel-perfect
322
- // mismatch with PowerPoint, so we use maxLineHeightBase.
323
- lineBoxHeight = maxLineHeightBase;
324
- baseline = ascentRounded;
325
- } else {
326
- // Browser: CSS-compatible with leading distribution.
327
- const lineHeightPx = maxFontSize * style.lineHeight;
328
- const ascentDescentRounded = ascentRounded + descentRounded;
329
-
330
- const rawLineBoxHeight = Math.round(lineHeightPx);
331
- lineBoxHeight = Math.max(rawLineBoxHeight, ascentDescentRounded);
332
- const leading = lineBoxHeight - ascentDescentRounded; // always integer
333
-
334
- if (leading <= 0) {
335
- // Negative or zero leading: don't shrink the line
336
- baseline = ascentRounded;
337
- } else {
338
- // Positive leading: distribute as integers with above_leading < below_leading
339
- const ascentDescent = maxAscent + maxDescent;
340
- const aboveLeadingFloat = ascentDescent > 0 ? leading * maxAscent / ascentDescent : leading / 2;
341
- let aboveLeading = Math.round(aboveLeadingFloat);
342
- let belowLeading = leading - aboveLeading;
343
-
344
- // Ensure above_leading < below_leading (parley/Chrome heuristic)
345
- if (aboveLeading >= belowLeading) {
346
- aboveLeading = Math.floor((leading - 1) / 2);
347
- belowLeading = leading - aboveLeading;
348
- }
349
-
350
- baseline = ascentRounded + aboveLeading;
351
- }
352
- }
353
-
354
- const startIdx = charIndex;
355
-
356
- // Count characters in line (for INDEX_CONSIST)
357
- let lineCharCount = 0;
358
- for (const frag of spans) {
359
- lineCharCount += frag.text.length;
360
- }
361
- const endIdx = startIdx + lineCharCount;
362
- charIndex = endIdx;
363
-
364
- // ── Mark break type on the last span ─────────────
365
- // 'soft' — line wrap due to width constraint
366
- // 'hard' — explicit break (\n)
367
- // 'none'/undefined — not a line end (no break)
368
- if (spans.length > 0) {
369
- const lastSpan = spans[spans.length - 1];
370
- const lastSpanItem = items[lastSpan.itemIndex];
371
- // If last character is \n, it's a hard break
372
- if (lastSpanItem && lastSpan.text.endsWith('\n')) {
373
- lastSpan.breakType = 'hard';
374
- } else if (lineIdx < pretextLines.length - 1) {
375
- lastSpan.breakType = 'soft';
376
- }
377
- }
378
-
379
- lines.push({
380
- x: Math.round(xOffset * 100) / 100,
381
- y: Math.round(currentY * 100) / 100,
382
- width: Math.round(lineWidth * 100) / 100,
383
- height: Math.round(lineBoxHeight * 100) / 100,
384
- baseline: Math.round(baseline * 100) / 100,
385
- ascent: Math.round(maxAscent * 100) / 100,
386
- descent: Math.round(maxDescent * 100) / 100,
387
- startIndex: startIdx,
388
- endIndex: endIdx,
389
- alignment: style.alignment,
390
- spans,
391
- });
392
-
393
- currentY += lineBoxHeight;
394
- isFirstLine = false;
395
- }
396
-
397
- // Add spaceAfter to last line height
398
- // (return as is — ParagraphLayoutEngine will add spaceAfter to total height)
399
-
400
- return { lines, contentWidth };
401
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * TextFrameLayoutEngine.ts — Layout a full TextFrame (multi-paragraph).
3
- *
4
- * Pipeline:
5
- * TextFrame → Paragraph[] → paragraphLayoutEngine.layout() each → merge Line[]
6
- *
7
- * Handles:
8
- * - Paragraph stacking with Y offset accumulation
9
- * - Padding (left reduces available width, left shifts X)
10
- * - frame.width/height optional → fitHorizontal/fitVertical flags
11
- */
12
- import type { TextFrame } from '../types/Document.js';
13
- import type { Line } from '../types/LayoutTypes.js';
14
- import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
15
-
16
- /**
17
- * Result of laying out a full TextFrame.
18
- *
19
- * `fitHorizontal` / `fitVertical` tell the renderer which dimension to use:
20
- * - `'frame'` → use `frameWidth` / `frameHeight`
21
- * - `'content'` → use `contentWidth` / `contentHeight`
22
- */
23
- export interface TextFrameLayoutResult {
24
- lines: Line[];
25
- /** Frame width (set when TextFrame.width was provided). */
26
- frameWidth?: number;
27
- /** Frame height (set when TextFrame.height was provided). */
28
- frameHeight?: number;
29
- /** Actual content width (may exceed frameWidth when wrap=false). */
30
- contentWidth: number;
31
- /** Actual content height (may exceed frameHeight). */
32
- contentHeight: number;
33
- /** Whether horizontal dimension should use frame or content size. */
34
- fitHorizontal: 'frame' | 'content';
35
- /** Whether vertical dimension should use frame or content size. */
36
- fitVertical: 'frame' | 'content';
37
- }
38
-
39
- /**
40
- * Layout a full TextFrame by stacking paragraphs with Y offset accumulation.
41
- */
42
- export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
43
- const allLines: Line[] = [];
44
- let yOffset = frame.padding?.top ?? 0;
45
- let contentWidth = 0;
46
-
47
- const leftPad = frame.padding?.left ?? 0;
48
- const rightPad = frame.padding?.right ?? 0;
49
-
50
- for (let i = 0; i < frame.paragraphs.length; i++) {
51
- const p = frame.paragraphs[i];
52
-
53
- // Available width: frame.width minus horizontal padding.
54
- // If width is undefined → Infinite (no wrap constraint).
55
- const maxWidth = frame.width !== undefined
56
- ? frame.width - leftPad - rightPad
57
- : Infinity;
58
-
59
- // If wrap is disabled, force no-wrap on the paragraph
60
- if (frame.wrap === false) {
61
- p.style = { ...p.style, whiteSpace: 'nowrap' };
62
- }
63
-
64
- const result = paragraphLayoutEngine.layout(p, maxWidth, yOffset);
65
-
66
- for (const line of result.lines) {
67
- // Shift lines by left padding
68
- line.x += leftPad;
69
- allLines.push(line);
70
- }
71
-
72
- contentWidth = Math.max(contentWidth, result.contentWidth);
73
- // result.height is absolute (includes yOffset passed in).
74
- // Set, don't accumulate — otherwise yOffset compounds.
75
- yOffset = result.height;
76
- }
77
-
78
- const contentHeight = allLines.length > 0
79
- ? allLines[allLines.length - 1].y + allLines[allLines.length - 1].height
80
- : 0;
81
-
82
- return {
83
- lines: allLines,
84
- frameWidth: frame.width,
85
- frameHeight: frame.height,
86
- contentWidth,
87
- contentHeight,
88
- fitHorizontal: frame.width !== undefined ? 'frame' : 'content',
89
- fitVertical: frame.height !== undefined ? 'frame' : 'content',
90
- };
91
- }