@vyaz/core 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,12 +6,24 @@
6
6
  *
7
7
  * Handles:
8
8
  * - Paragraph stacking with Y offset accumulation
9
+ * - Multi-column layout (CSS multi-column model)
9
10
  * - Padding (left reduces available width, left shifts X)
10
11
  * - frame.width/height optional → fitHorizontal/fitVertical flags
12
+ * - List grouping: consecutive paragraphs with listStyle form a list group.
13
+ * Numbered list indices are auto-incremented within each group.
14
+ * `listRestart: true` breaks a group and restarts numbering.
15
+ *
16
+ * Multi-column algorithm:
17
+ * 1. Calculate colWidth = (frameWidth - (count-1)*gap - padding) / count
18
+ * 2. Layout each paragraph with maxWidth = colWidth (NOT frame.width)
19
+ * 3. Distribute lines column-by-column (column-fill: auto)
20
+ * 4. If frame.height is set, lines overflow to next column when colHeight exceeded
21
+ * 5. If no frame.height, columns are infinite (all lines stay in column 0)
11
22
  */
12
- import type { TextFrame } from '../types/Document.js';
23
+ import type { TextFrame, ListStyle, VerticalAlignment } from '../types/Document.js';
13
24
  import type { Line } from '../types/LayoutTypes.js';
14
25
  import { paragraphLayoutEngine } from './ParagraphLayoutEngine.js';
26
+ import { formatListNumber, defaultBulletChar } from '../utils/list.js';
15
27
 
16
28
  /**
17
29
  * Result of laying out a full TextFrame.
@@ -36,48 +48,308 @@ export interface TextFrameLayoutResult {
36
48
  fitVertical: 'frame' | 'content';
37
49
  }
38
50
 
51
+ /**
52
+ * Resolve the marker text for a list item (needed for width measurement).
53
+ */
54
+ function getMarkerTextHelper(listStyle: ListStyle, listIndex: number): string {
55
+ if (listStyle.type === 'bullet') {
56
+ return listStyle.bulletChar ?? defaultBulletChar(listStyle.level ?? 0);
57
+ }
58
+ if (listStyle.type === 'number') {
59
+ const fmt = listStyle.numberFormat ?? 'decimal';
60
+ return formatListNumber(listIndex, fmt) + '.';
61
+ }
62
+ return '';
63
+ }
64
+
65
+ /**
66
+ * Compute the widest marker across a list group, used to expand bulletIndent
67
+ * when numbered markers have varying widths (e.g. "9." vs "10.").
68
+ */
69
+ function computeMaxMarkerWidth(
70
+ listStyle: ListStyle,
71
+ startIndex: number,
72
+ count: number,
73
+ measureText: (text: string, fontSize: number) => number,
74
+ ): number {
75
+ if (listStyle.type !== 'number') return 0;
76
+ let maxWidth = 0;
77
+ for (let i = 0; i < count; i++) {
78
+ const markerText = getMarkerTextHelper(listStyle, startIndex + i);
79
+ const width = measureText(markerText, 12); // approximate, will be refined by positionLines
80
+ maxWidth = Math.max(maxWidth, width);
81
+ }
82
+ return maxWidth;
83
+ }
84
+
85
+ /**
86
+ * Apply vertical alignment to lines within a column.
87
+ *
88
+ * @param lines — lines belonging to this column (already has correct x/y)
89
+ * @param colHeight — total column height (frame.height or content height)
90
+ */
91
+ function applyVerticalAlignment(
92
+ lines: Line[],
93
+ colHeight: number,
94
+ alignment: VerticalAlignment,
95
+ ): void {
96
+ if (alignment === 'top' || lines.length === 0) return;
97
+
98
+ const firstLineY = lines[0].y;
99
+ const lastLineEnd = lines[lines.length - 1].y + lines[lines.length - 1].height;
100
+ const contentHeight = lastLineEnd - firstLineY;
101
+ const extraSpace = colHeight - contentHeight;
102
+ if (extraSpace <= 0) return;
103
+
104
+ let offset = 0;
105
+ if (alignment === 'middle') {
106
+ offset = extraSpace / 2;
107
+ } else if (alignment === 'bottom') {
108
+ offset = extraSpace;
109
+ }
110
+
111
+ for (const line of lines) {
112
+ line.y += offset;
113
+ }
114
+ }
115
+
39
116
  /**
40
117
  * Layout a full TextFrame by stacking paragraphs with Y offset accumulation.
41
118
  */
42
119
  export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
120
+ // ── Multi-column setup ────────────────────────────────────────────
121
+ const leftPad = frame.padding?.left ?? 0;
122
+ const rightPad = frame.padding?.right ?? 0;
123
+ const topPad = frame.padding?.top ?? 0;
124
+ const bottomPad = frame.padding?.bottom ?? 0;
125
+
126
+ const hasColumns = frame.columns != null && frame.columns.count > 1 && frame.width != null;
127
+ let colWidth: number | undefined;
128
+ let colCount = 1;
129
+ let colGap = 0;
130
+
131
+ if (hasColumns) {
132
+ colCount = frame.columns!.count;
133
+ colGap = frame.columns!.gap;
134
+ const totalPad = leftPad + rightPad + (colCount - 1) * colGap;
135
+ colWidth = (frame.width! - totalPad) / colCount;
136
+ }
137
+
138
+ const colHeight = frame.height != null
139
+ ? frame.height - topPad - bottomPad
140
+ : Infinity;
141
+
142
+ const verticalAlign: VerticalAlignment = frame.verticalAlignment ?? 'top';
143
+
144
+ // ── List grouping pass ──────────────────────────────────────────
145
+ // (identical to before, but uses colWidth for maxWidth later)
146
+ const listIndices: (number | undefined)[] = new Array(frame.paragraphs.length).fill(undefined);
147
+ const listMarkerWidths: (number | undefined)[] = new Array(frame.paragraphs.length).fill(undefined);
148
+
149
+ let i = 0;
150
+ while (i < frame.paragraphs.length) {
151
+ const p = frame.paragraphs[i];
152
+ const ls = p.style.listStyle;
153
+
154
+ if (!ls || ls.type === 'none') {
155
+ i++;
156
+ continue;
157
+ }
158
+
159
+ // Find end of this list group
160
+ let groupStart = i;
161
+ let groupEnd = i + 1;
162
+ while (groupEnd < frame.paragraphs.length) {
163
+ const nextP = frame.paragraphs[groupEnd];
164
+ const nextLs = nextP.style.listStyle;
165
+ if (!nextLs || nextLs.type !== ls.type || nextP.style.listRestart) {
166
+ break;
167
+ }
168
+ // Same nesting level only
169
+ if ((nextLs.level ?? 0) !== (ls.level ?? 0)) {
170
+ break;
171
+ }
172
+ groupEnd++;
173
+ }
174
+
175
+ const groupSize = groupEnd - groupStart;
176
+ const startNumber = ls.startNumber ?? 1;
177
+
178
+ // Assign indices
179
+ for (let j = 0; j < groupSize; j++) {
180
+ listIndices[groupStart + j] = startNumber + j;
181
+ }
182
+
183
+ // Compute max marker width for numbered lists in this group
184
+ const paraFontSize = p.children[0]?.fontSize ?? 12;
185
+ const measureMarkerWidth = (text: string, fontSize: number): number => {
186
+ return text.length * fontSize * 0.6;
187
+ };
188
+ const maxMW = computeMaxMarkerWidth(ls, startNumber, groupSize, measureMarkerWidth);
189
+ for (let j = 0; j < groupSize; j++) {
190
+ listMarkerWidths[groupStart + j] = maxMW;
191
+ }
192
+
193
+ i = groupEnd;
194
+ }
195
+
196
+ // ── Layout pass ─────────────────────────────────────────────────
43
197
  const allLines: Line[] = [];
44
- let yOffset = frame.padding?.top ?? 0;
45
198
  let contentWidth = 0;
46
199
 
47
- const leftPad = frame.padding?.left ?? 0;
48
- const rightPad = frame.padding?.right ?? 0;
200
+ // Helper: push a line and update position
201
+ const currentColY: number[] = new Array(colCount).fill(topPad);
49
202
 
203
+ // For non-column layout, we use a single "virtual column" approach
50
204
  for (let i = 0; i < frame.paragraphs.length; i++) {
51
205
  const p = frame.paragraphs[i];
52
206
 
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;
207
+ // Available width: colWidth if columns, otherwise frame.width minus padding
208
+ const maxWidth = hasColumns
209
+ ? colWidth!
210
+ : frame.width !== undefined
211
+ ? frame.width - leftPad - rightPad
212
+ : Infinity;
58
213
 
59
214
  // If wrap is disabled, force no-wrap on the paragraph
60
215
  if (frame.wrap === false) {
61
216
  p.style = { ...p.style, whiteSpace: 'nowrap' };
62
217
  }
63
218
 
64
- const result = paragraphLayoutEngine.layout(p, maxWidth, yOffset);
219
+ const listIndex = listIndices[i];
220
+ const listMarkerWidth = listMarkerWidths[i];
221
+ const listStyle = p.style.listStyle;
222
+
223
+ // Start paragraph on current column (column 0 initially, or current active column)
224
+ // We layout the paragraph with the full maxWidth — the paragraph's own
225
+ // line-breaking handles wrapping.
226
+ // For multi-column, we use a relative yOffset = 0, then position lines below.
227
+ const result = paragraphLayoutEngine.layout(
228
+ p,
229
+ maxWidth,
230
+ 0, // relative yOffset — we'll position lines ourselves
231
+ undefined,
232
+ listStyle,
233
+ listIndex,
234
+ listMarkerWidth,
235
+ );
236
+
237
+ // Apply paragraph-level spaceBefore (CSS margin-top equivalent).
238
+ // positionLines() already offsets Y by spaceBefore internally, but
239
+ // line.y is overwritten below with the global Y from currentColY.
240
+ // So we must add spaceBefore to currentColY before placing lines.
241
+ if (!hasColumns) {
242
+ currentColY[0] += p.style.spaceBefore;
243
+ }
65
244
 
66
245
  for (const line of result.lines) {
67
- // Shift lines by left padding
68
- line.x += leftPad;
69
- allLines.push(line);
246
+ if (!hasColumns) {
247
+ // Non-column: simple accumulation (existing behavior)
248
+ line.x += leftPad;
249
+ for (const span of line.spans) {
250
+ span.pIdx = i;
251
+ }
252
+ allLines.push(line);
253
+ contentWidth = Math.max(contentWidth, result.contentWidth);
254
+ // Result height already includes the passed yOffset (0), so this is relative.
255
+ // We accumulate absolute y from result.height (which is total paragraph height).
256
+ line.y = currentColY[0];
257
+ currentColY[0] += line.height;
258
+ continue;
259
+ }
260
+
261
+ // ── Multi-column: distribute lines across columns ──────────
262
+ // Try to place the current line in the current column.
263
+ // If it doesn't fit — move to next column.
264
+ let colIdx = 0;
265
+ for (let c = 0; c < colCount; c++) {
266
+ if (currentColY[c] < currentColY[colIdx]) colIdx = c;
267
+ }
268
+
269
+ // Try current column; if line doesn't fit, advance to next.
270
+ // For auto fill: each column fills completely before moving to next.
271
+ // We use a greedy column selection: find the column with smallest Y
272
+ // that has room for this line.
273
+ let placed = false;
274
+ for (let attempt = 0; attempt < colCount; attempt++) {
275
+ if (currentColY[colIdx] + line.height <= colHeight) {
276
+ // Fits in this column
277
+ line.x = colIdx * (colWidth! + colGap) + leftPad;
278
+ line.y = currentColY[colIdx];
279
+ line.columnIndex = colIdx;
280
+ currentColY[colIdx] += line.height;
281
+ for (const span of line.spans) {
282
+ span.pIdx = i;
283
+ }
284
+ allLines.push(line);
285
+ contentWidth = Math.max(contentWidth, line.x + line.width + rightPad);
286
+ placed = true;
287
+ break;
288
+ }
289
+ // Advance to next column
290
+ colIdx = (colIdx + 1) % colCount;
291
+
292
+ // If we've wrapped around, all columns are full — overflow stays in last column
293
+ if (attempt === colCount - 1) {
294
+ // Place in last attempted column even if it overflows
295
+ line.x = colIdx * (colWidth! + colGap) + leftPad;
296
+ line.y = currentColY[colIdx];
297
+ line.columnIndex = colIdx;
298
+ currentColY[colIdx] += line.height;
299
+ for (const span of line.spans) {
300
+ span.pIdx = i;
301
+ }
302
+ allLines.push(line);
303
+ contentWidth = Math.max(contentWidth, line.x + line.width + rightPad);
304
+ placed = true;
305
+ }
306
+ }
307
+
308
+ if (!placed) {
309
+ // Fallback: place in column 0 (shouldn't happen)
310
+ line.x = leftPad;
311
+ line.y = currentColY[0];
312
+ line.columnIndex = 0;
313
+ currentColY[0] += line.height;
314
+ for (const span of line.spans) {
315
+ span.pIdx = i;
316
+ }
317
+ allLines.push(line);
318
+ }
70
319
  }
71
320
 
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;
321
+ // Apply paragraph-level spaceAfter (CSS margin-bottom equivalent).
322
+ // positionLines() in PositioningEngine applies spaceBefore but does NOT
323
+ // add spaceAfterit is the caller's responsibility.
324
+ if (!hasColumns) {
325
+ currentColY[0] += p.style.spaceAfter;
326
+ }
76
327
  }
77
328
 
78
- const contentHeight = allLines.length > 0
79
- ? allLines[allLines.length - 1].y + allLines[allLines.length - 1].height
80
- : 0;
329
+ // ── Apply vertical alignment per column ──────────────────────────
330
+ if (hasColumns && verticalAlign !== 'top' && frame.height != null) {
331
+ const colLines: Line[][] = new Array(colCount).fill(null).map(() => []);
332
+ for (const line of allLines) {
333
+ const ci = line.columnIndex ?? 0;
334
+ colLines[ci].push(line);
335
+ }
336
+ for (let c = 0; c < colCount; c++) {
337
+ applyVerticalAlignment(colLines[c], colHeight, verticalAlign);
338
+ }
339
+ }
340
+
341
+ // ── Compute final content dimensions ─────────────────────────────
342
+ if (hasColumns) {
343
+ // contentWidth = total frame width (includes all columns + gaps + padding)
344
+ contentWidth = frame.width!;
345
+ } else {
346
+ contentWidth += rightPad;
347
+ }
348
+
349
+ const lastLine = allLines.length > 0 ? allLines[allLines.length - 1] : null;
350
+ const contentHeight = lastLine
351
+ ? lastLine.y + lastLine.height + bottomPad
352
+ : bottomPad;
81
353
 
82
354
  return {
83
355
  lines: allLines,
@@ -88,4 +360,4 @@ export function layoutTextFrame(frame: TextFrame): TextFrameLayoutResult {
88
360
  fitHorizontal: frame.width !== undefined ? 'frame' : 'content',
89
361
  fitVertical: frame.height !== undefined ? 'frame' : 'content',
90
362
  };
91
- }
363
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * estimateWidth.ts — per-fragment width resolution.
3
+ *
4
+ * Uses exact measurement via FontMetricsProvider.measureText() (fontkit).
5
+ * After measurement — correctToSumInvariant to preserve line-breaking invariant.
6
+ */
7
+
8
+ // ── Invariant correction ─────────────────────────────────────────────
9
+
10
+ /**
11
+ * Correct fragment widths so that their sum equals occupiedWidth.
12
+ *
13
+ * Why needed: fontkit-based measurement measures each fragment independently,
14
+ * so the sum may drift from occupiedWidth due to:
15
+ * - kerning across fragment boundaries
16
+ * - letterSpacing adjustments from pretext
17
+ * - rounding differences between font parsers
18
+ *
19
+ * The correction distributes delta proportionally to each measured width.
20
+ * For fallback estimates (which already sum to occupiedWidth exactly),
21
+ * this is effectively a no-op.
22
+ */
23
+ export function correctToSumInvariant(
24
+ measured: number[],
25
+ occupiedWidth: number,
26
+ ): number[] {
27
+ const sum = measured.reduce((a, b) => a + b, 0);
28
+ if (sum === 0 || Math.abs(occupiedWidth - sum) < 0.001) {
29
+ // Already within rounding tolerance — return as-is
30
+ return measured;
31
+ }
32
+
33
+ const delta = occupiedWidth - sum;
34
+ // Distribute delta proportionally to each width
35
+ return measured.map(m => m + (m / sum) * delta);
36
+ }
37
+
38
+ // ── Main entry point ─────────────────────────────────────────────────
39
+
40
+ export type MeasureFn = (text: string) => number;
41
+
42
+ /**
43
+ * Resolve widths for fragments of a single text group.
44
+ *
45
+ * Each group consists of pieces split from the same pretext fragment
46
+ * (e.g. leading-space + trimmed-text + trailing-space). Their widths
47
+ * must sum to occupiedWidth (pretext's measurement) to preserve the
48
+ * line-breaking invariant.
49
+ *
50
+ * @param fragments — array of text pieces (e.g. [" ", "between", " form"])
51
+ * @param fullText — concatenation of all fragments (the original pretext fragment text)
52
+ * @param occupiedWidth — total width from pretext (gapBefore + textWidth)
53
+ * @param measureFn — optional callback for exact measurement via font metrics provider
54
+ * @returns widths that sum to occupiedWidth (within floating point tolerance)
55
+ */
56
+ export function resolveFragmentWidths(
57
+ fragments: string[],
58
+ fullText: string,
59
+ occupiedWidth: number,
60
+ measureFn: MeasureFn,
61
+ ): number[] {
62
+ if (fragments.length === 0) return [];
63
+ if (fragments.length === 1) {
64
+ // Single fragment — no distribution needed
65
+ return [occupiedWidth];
66
+ }
67
+
68
+ // Step 1: Measure each fragment exactly via fontkit
69
+ // measureFn throws FontNotFoundError if font not registered
70
+ const measured = fragments.map(f => measureFn(f));
71
+
72
+ // Step 2: Correct to sum invariant
73
+ return correctToSumInvariant(measured, occupiedWidth);
74
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * FontEngine.ts — unified facade over fontkit.
3
+ *
4
+ * Is the single entry point for all fontkit operations:
5
+ * - create(buffer) → font face
6
+ * - getGlyphAdvance(font, codePoint) → per‑glyph advance
7
+ * - getMetrics(font) → structured metric values
8
+ *
9
+ * fontkit works in both Node.js (native addon) and browser (dist/browser-module.mjs).
10
+ * Bundlers pick the correct entry automatically when `package.json` browser map
11
+ * is removed (or when the import is not blocked by stubs).
12
+ */
13
+
14
+ import type { FontMetrics } from '../types/FontTypes.js';
15
+
16
+ // ── Internal font object shape ─────────────────────────────────────────
17
+ // We keep fontkit.Font opaque — users of FontEngine never import fontkit.
18
+
19
+ /** Opaque font face handle returned by FontEngine.create() */
20
+ export interface FontFace {
21
+ /** fontkit font object (private — not meant for direct access) */
22
+ readonly _raw: any;
23
+ /** Cached values extracted once after creation */
24
+ readonly unitsPerEm: number;
25
+ readonly ascent: number;
26
+ readonly descent: number;
27
+ readonly capHeight: number;
28
+ readonly winAscent: number | null;
29
+ readonly winDescent: number | null;
30
+ }
31
+
32
+ // ── FontEngine ─────────────────────────────────────────────────────────
33
+
34
+ let _fontkitModule: any | null = null;
35
+
36
+ /**
37
+ * Lazily import fontkit (avoids top‑level side‑effects in bundlers).
38
+ * fontkit exposes both CJS and ESM browser builds — bundlers resolve
39
+ * the correct entry from package.json exports.
40
+ */
41
+ async function _getFontkit(): Promise<any> {
42
+ if (_fontkitModule) return _fontkitModule;
43
+ const mod = await import('fontkit');
44
+ _fontkitModule = mod.default || mod;
45
+ return _fontkitModule;
46
+ }
47
+
48
+ /**
49
+ * Extract metric values from a raw fontkit font object.
50
+ */
51
+ function _extractMetrics(raw: any): {
52
+ unitsPerEm: number;
53
+ ascent: number;
54
+ descent: number;
55
+ capHeight: number;
56
+ winAscent: number | null;
57
+ winDescent: number | null;
58
+ } {
59
+ const os2 = raw['OS/2'];
60
+ return {
61
+ unitsPerEm: raw.unitsPerEm,
62
+ ascent: raw.ascent,
63
+ descent: raw.descent,
64
+ capHeight: raw.capHeight ?? raw.ascent,
65
+ winAscent: os2?.winAscent ?? null,
66
+ winDescent: os2?.winDescent ?? null,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Get a glyph handle for a code point.
72
+ * Returns null when the glyph is not present (e.g. .notdef).
73
+ */
74
+ function _getGlyph(raw: any, codePoint: number): any | null {
75
+ return raw.glyphForCodePoint(codePoint) ?? null;
76
+ }
77
+
78
+ // ── Public API ─────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Create a font face from a binary buffer.
82
+ *
83
+ * @param buffer Font file bytes (ArrayBuffer in browser, Uint8Array/Buffer in Node.js)
84
+ * @returns Opaque FontFace handle
85
+ */
86
+ export async function createFontFace(buffer: ArrayBuffer | Uint8Array): Promise<FontFace> {
87
+ const fontkit = await _getFontkit();
88
+ const raw = fontkit.create(buffer);
89
+ const metrics = _extractMetrics(raw);
90
+ return {
91
+ _raw: raw,
92
+ ...metrics,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Get the advance width (in font units) for a single code point.
98
+ *
99
+ * @returns advance width in font units, or `null` if the glyph is missing
100
+ */
101
+ export function getGlyphAdvance(font: FontFace, codePoint: number): number | null {
102
+ const glyph = _getGlyph(font._raw, codePoint);
103
+ if (!glyph) return null;
104
+ return glyph.advanceWidth;
105
+ }
106
+
107
+ /**
108
+ * Compute pixel‑scale metrics for a given font size.
109
+ */
110
+ export function computePixelMetrics(font: FontFace, fontSize: number, mode: 'browser' | 'office'): FontMetrics {
111
+ const scale = fontSize / font.unitsPerEm;
112
+
113
+ if (mode === 'office' && font.winAscent != null && font.winDescent != null) {
114
+ return {
115
+ ascent: font.winAscent * scale * 1.078,
116
+ descent: Math.abs(font.winDescent) * scale * 1.078,
117
+ capHeight: (font.capHeight ?? font.ascent) * scale,
118
+ unitsPerEm: font.unitsPerEm,
119
+ sourceTable: 'OS/2',
120
+ };
121
+ }
122
+
123
+ // browser mode (or Office fallback when OS/2 is absent)
124
+ return {
125
+ ascent: font.ascent * scale,
126
+ descent: Math.abs(font.descent) * scale,
127
+ capHeight: (font.capHeight ?? font.ascent) * scale,
128
+ unitsPerEm: font.unitsPerEm,
129
+ sourceTable: 'hhea',
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Whether the fontkit module was successfully loaded.
135
+ * Useful for tests to verify the bundler isn't blocking fontkit.
136
+ */
137
+ export async function isFontEngineAvailable(): Promise<boolean> {
138
+ try {
139
+ const fk = await _getFontkit();
140
+ return typeof fk.create === 'function';
141
+ } catch {
142
+ return false;
143
+ }
144
+ }