@vyaz/core 0.0.4 → 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.
- package/README.md +156 -0
- package/package.json +22 -5
- package/src/compile/DocumentCompiler.ts +11 -3
- package/src/index.browser.ts +90 -0
- package/src/index.ts +17 -0
- package/src/layout/ParagraphLayoutEngine.ts +106 -44
- package/src/layout/PositioningEngine.ts +249 -35
- package/src/layout/TextFrameLayoutEngine.ts +293 -21
- package/src/layout/estimateWidth.ts +74 -0
- package/src/measure/FontEngine.ts +144 -0
- package/src/measure/FontMetricsProvider.ts +74 -76
- package/src/measure/SystemFontRegistry.ts +15 -14
- package/src/measure/canvas-polyfill.ts +17 -9
- package/src/types/Document.ts +129 -3
- package/src/types/LayoutTypes.ts +24 -7
- package/src/utils/env.ts +18 -0
- package/src/utils/font.ts +28 -0
- package/src/utils/groupLinesByParagraph.ts +74 -0
- package/src/utils/list.ts +107 -0
- package/src/utils/textTransform.ts +96 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groupLinesByParagraph.ts — Group lines by paragraph index.
|
|
3
|
+
*
|
|
4
|
+
* Collects all lines with the same `pIdx` into one group,
|
|
5
|
+
* regardless of their order in the array (handles multi-column
|
|
6
|
+
* where lines of the same paragraph may be split across columns).
|
|
7
|
+
*
|
|
8
|
+
* @see {@link https://www.w3.org/TR/css-text-3/ | CSS Text Module Level 3}
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Line } from '../types/LayoutTypes.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A group of lines belonging to one paragraph.
|
|
15
|
+
*/
|
|
16
|
+
export interface ParagraphGroup {
|
|
17
|
+
/** Lines in this paragraph. */
|
|
18
|
+
lines: Line[];
|
|
19
|
+
/** Paragraph index in TextFrame.paragraphs[]. */
|
|
20
|
+
pIdx: number;
|
|
21
|
+
/** Optional user-assigned tag (from Paragraph.id). */
|
|
22
|
+
tag?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Group lines by `pIdx` (paragraph index).
|
|
27
|
+
*
|
|
28
|
+
* Collects **all** lines with the same `pIdx` into one group,
|
|
29
|
+
* even if they are not consecutive in the array (multi-column layout
|
|
30
|
+
* may interleave lines of different paragraphs across columns).
|
|
31
|
+
*
|
|
32
|
+
* Returns groups sorted by `pIdx` in document order.
|
|
33
|
+
* Lines without a valid `pIdx` (e.g. `pIdx === undefined`) are grouped
|
|
34
|
+
* as index `-1`.
|
|
35
|
+
*
|
|
36
|
+
* @param lines — flat array of lines from `layoutTextFrame`
|
|
37
|
+
* @returns groups of lines grouped by paragraph
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const result = layoutTextFrame(frame);
|
|
42
|
+
* const groups = groupLinesByParagraph(result.lines);
|
|
43
|
+
* // groups[0].lines → all lines for paragraph 0 (across all columns)
|
|
44
|
+
* // groups[0].pIdx → 0
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function groupLinesByParagraph(lines: Line[]): ParagraphGroup[] {
|
|
48
|
+
// Collect lines per paragraph using a Map
|
|
49
|
+
const map = new Map<number, { lines: Line[]; tag?: string }>();
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const idx = line.spans[0]?.pIdx ?? -1;
|
|
53
|
+
let entry = map.get(idx);
|
|
54
|
+
if (!entry) {
|
|
55
|
+
entry = { lines: [] };
|
|
56
|
+
map.set(idx, entry);
|
|
57
|
+
}
|
|
58
|
+
entry.lines.push(line);
|
|
59
|
+
if (!entry.tag) {
|
|
60
|
+
entry.tag = line.spans[0]?.tag;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Convert to array sorted by pIdx
|
|
65
|
+
const groups: ParagraphGroup[] = [];
|
|
66
|
+
const sortedKeys = Array.from(map.keys()).sort((a, b) => a - b);
|
|
67
|
+
for (const key of sortedKeys) {
|
|
68
|
+
if (key === -1) continue; // skip invalid
|
|
69
|
+
const entry = map.get(key)!;
|
|
70
|
+
groups.push({ lines: entry.lines, pIdx: key, tag: entry.tag });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return groups;
|
|
74
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* list.ts — helper utilities for list marker generation.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - `formatListNumber()` — format a number according to `NumberFormat`
|
|
6
|
+
* - `defaultBulletChar()` — pick a bullet character based on nesting level
|
|
7
|
+
* - `BULLET_CHARACTERS` — the Unicode characters for disc/circle/square
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { NumberFormat } from '../types/Document.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unicode characters used for CSS `disc`, `circle`, `square` list-style-type
|
|
14
|
+
* values at different nesting levels.
|
|
15
|
+
*
|
|
16
|
+
* @see {@link https://www.w3.org/TR/css-lists-3/#ua-stylesheet | CSS Lists UA defaults}
|
|
17
|
+
*/
|
|
18
|
+
export const BULLET_CHARACTERS: Record<number, string> = {
|
|
19
|
+
0: '\u2022', // • BULLET (disc)
|
|
20
|
+
1: '\u25CB', // ○ WHITE CIRCLE (circle)
|
|
21
|
+
2: '\u25AA', // ▪ BLACK SMALL SQUARE (square)
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Fallback for deeper levels — same as level 2 */
|
|
25
|
+
const FALLBACK_BULLET = '\u25AA';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the default bullet character for a given nesting level.
|
|
29
|
+
*
|
|
30
|
+
* CSS UA stylesheet uses:
|
|
31
|
+
* - Level 0 (ul): disc (•)
|
|
32
|
+
* - Level 1 (ul ul): circle (○)
|
|
33
|
+
* - Level 2+ (ul ul ul): square (▪)
|
|
34
|
+
*/
|
|
35
|
+
export function defaultBulletChar(level: number): string {
|
|
36
|
+
return BULLET_CHARACTERS[level] ?? FALLBACK_BULLET;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format a number according to the given numbering format.
|
|
41
|
+
*
|
|
42
|
+
* Supports the same formats as CSS `list-style-type`:
|
|
43
|
+
* - `decimal`: 1, 2, 3, …
|
|
44
|
+
* - `upper-roman`: I, II, III, …
|
|
45
|
+
* - `lower-roman`: i, ii, iii, …
|
|
46
|
+
* - `upper-alpha`: A, B, C, …
|
|
47
|
+
* - `lower-alpha`: a, b, c, …
|
|
48
|
+
*
|
|
49
|
+
* Follows CSS Counter Styles Level 3 algorithms.
|
|
50
|
+
* Roman numerals support the range 1–3999.
|
|
51
|
+
*
|
|
52
|
+
* @see {@link https://www.w3.org/TR/css-counter-styles-3/ | CSS Counter Styles Level 3}
|
|
53
|
+
*/
|
|
54
|
+
export function formatListNumber(n: number, format: NumberFormat): string {
|
|
55
|
+
switch (format) {
|
|
56
|
+
case 'decimal':
|
|
57
|
+
return String(n);
|
|
58
|
+
case 'upper-roman':
|
|
59
|
+
return toRoman(n).toUpperCase();
|
|
60
|
+
case 'lower-roman':
|
|
61
|
+
return toRoman(n).toLowerCase();
|
|
62
|
+
case 'upper-alpha':
|
|
63
|
+
return toAlpha(n).toUpperCase();
|
|
64
|
+
case 'lower-alpha':
|
|
65
|
+
return toAlpha(n).toLowerCase();
|
|
66
|
+
default:
|
|
67
|
+
return String(n);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert 1-based integer to lowercase roman numeral.
|
|
73
|
+
* Supports 1–3999.
|
|
74
|
+
*/
|
|
75
|
+
function toRoman(n: number): string {
|
|
76
|
+
if (n < 1 || n > 3999) return String(n);
|
|
77
|
+
const romanMap: [number, string][] = [
|
|
78
|
+
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
|
79
|
+
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
|
80
|
+
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
|
81
|
+
];
|
|
82
|
+
let result = '';
|
|
83
|
+
for (const [value, symbol] of romanMap) {
|
|
84
|
+
while (n >= value) {
|
|
85
|
+
result += symbol;
|
|
86
|
+
n -= value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert 1-based integer to alpha format (a, b, c, …, z, aa, ab, …).
|
|
94
|
+
* Uses the same algorithm as CSS `lower-alpha` (CSS Counter Styles §3.1.2).
|
|
95
|
+
*/
|
|
96
|
+
function toAlpha(n: number): string {
|
|
97
|
+
if (n < 1) return String(n);
|
|
98
|
+
const base = 26;
|
|
99
|
+
const offset = 'a'.charCodeAt(0);
|
|
100
|
+
let result = '';
|
|
101
|
+
while (n > 0) {
|
|
102
|
+
n--;
|
|
103
|
+
result = String.fromCharCode(offset + (n % base)) + result;
|
|
104
|
+
n = Math.floor(n / base);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* textTransform.ts — pure text transformation functions.
|
|
3
|
+
*
|
|
4
|
+
* Implements CSS `text-transform` for: none, uppercase, lowercase, capitalize.
|
|
5
|
+
*
|
|
6
|
+
* Explicit capitalization contract (edge cases):
|
|
7
|
+
* - Apostrophe (') is NOT a word boundary: "don't stop" → "Don't Stop"
|
|
8
|
+
* - Hyphen (-) IS a word boundary: "hello-world" → "Hello-World"
|
|
9
|
+
* - Leading whitespace is preserved: " hello" → " Hello"
|
|
10
|
+
* - Numbers are not capitalized: "3d model" → "3d Model"
|
|
11
|
+
* - Unicode full-range: first `\p{L}` in each word segment gets .toUpperCase()
|
|
12
|
+
* - Empty string → ""
|
|
13
|
+
* - No letters → unchanged: "123" → "123"
|
|
14
|
+
*
|
|
15
|
+
* @see {@link https://www.w3.org/TR/css-text-3/#text-transform-property | CSS Text: text-transform}
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { TextTransform } from '../types/Document.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Apply text-transform to a string.
|
|
22
|
+
*
|
|
23
|
+
* @param text — input text
|
|
24
|
+
* @param transform — transform type ('none' | 'uppercase' | 'lowercase' | 'capitalize')
|
|
25
|
+
* @returns transformed text
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* transformText("hello world", 'uppercase') // → "HELLO WORLD"
|
|
30
|
+
* transformText("HELLO", 'lowercase') // → "hello"
|
|
31
|
+
* transformText("don't stop", 'capitalize') // → "Don't Stop"
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function transformText(text: string, transform?: TextTransform): string {
|
|
35
|
+
if (!transform || transform === 'none' || !text) {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
switch (transform) {
|
|
40
|
+
case 'uppercase':
|
|
41
|
+
return text.toUpperCase();
|
|
42
|
+
|
|
43
|
+
case 'lowercase':
|
|
44
|
+
return text.toLowerCase();
|
|
45
|
+
|
|
46
|
+
case 'capitalize': {
|
|
47
|
+
// Capitalize first letter of each word.
|
|
48
|
+
// Word = sequence of Unicode letters (\p{L}).
|
|
49
|
+
// Non-letter characters (including hyphen, but NOT apostrophe) are delimiters.
|
|
50
|
+
//
|
|
51
|
+
// Strategy: split on non-letter sequences (\P{L}+), but preserve delimiters
|
|
52
|
+
// by using a capturing group. Then for each letter-segment, capitalize its first \p{L}.
|
|
53
|
+
// Non-letter segments pass through unchanged.
|
|
54
|
+
//
|
|
55
|
+
// The regex splits on non-letters (excluding apostrophe) but keeps delimiters.
|
|
56
|
+
// Apostrophe (') is NOT a word boundary: "don't stop" → ["don't", " ", "stop", ""]
|
|
57
|
+
// Hyphen IS: "hello-world" → ["hello", "-", "world", ""]
|
|
58
|
+
const segments = text.split(/([^\p{L}']+)/u);
|
|
59
|
+
|
|
60
|
+
// Pre-allocate with same length
|
|
61
|
+
const result: string[] = new Array(segments.length);
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < segments.length; i++) {
|
|
64
|
+
const seg = segments[i];
|
|
65
|
+
if (!seg) {
|
|
66
|
+
result[i] = seg;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If this segment is a non-letter (excluding apostrophe) delimiter — pass through unchanged
|
|
71
|
+
if (/^[^\p{L}']+$/u.test(seg)) {
|
|
72
|
+
result[i] = seg;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// This is a letter-segment. Capitalize its first letter.
|
|
77
|
+
const firstLetter = seg.match(/\p{L}/u);
|
|
78
|
+
if (!firstLetter) {
|
|
79
|
+
result[i] = seg;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const idx = firstLetter.index!;
|
|
84
|
+
const before = seg.slice(0, idx);
|
|
85
|
+
const letter = seg[idx].toUpperCase();
|
|
86
|
+
const after = seg.slice(idx + 1);
|
|
87
|
+
result[i] = `${before}${letter}${after}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return result.join('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
default:
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
}
|