@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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Runtime environment detection.
3
+ * Used to guard Node.js–only code paths (fontkit, fs, get-system-fonts)
4
+ * from being executed in the browser.
5
+ *
6
+ * ⚠️ Uses `globalThis.process` instead of bare `process` to avoid
7
+ * triggering bundler static analysis that could externalize the
8
+ * Node.js `process` global for browser targets.
9
+ */
10
+
11
+ const _process: any =
12
+ typeof globalThis !== 'undefined' ? (globalThis as any).process : undefined;
13
+
14
+ /** `true` when running on Node.js or Bun (has `process.versions.node`) */
15
+ export const isNodeLike: boolean =
16
+ _process != null &&
17
+ _process.versions != null &&
18
+ typeof _process.versions.node === 'string';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Font utility helpers.
3
+ *
4
+ * Provides cross‑environment helpers for loading font data in the browser
5
+ * (where there is no local filesystem).
6
+ */
7
+
8
+ /**
9
+ * Download a font file from a URL and return its bytes.
10
+ *
11
+ * Works in browser environments. In Node.js you'd typically read the file
12
+ * via `fs.readFileSync()` instead.
13
+ *
14
+ * @param fontUrl URL of the font file (.ttf, .otf, .woff, .woff2)
15
+ * @returns Font file bytes as an ArrayBuffer
16
+ */
17
+ export async function getFontBuffer(fontUrl: string): Promise<ArrayBuffer> {
18
+ const response = await fetch(fontUrl);
19
+
20
+ if (!response.ok) {
21
+ throw new Error(
22
+ `[vyaz] Failed to download font from "${fontUrl}": ${response.status} ${response.statusText}`,
23
+ );
24
+ }
25
+
26
+ const buffer = await response.arrayBuffer();
27
+ return buffer;
28
+ }
@@ -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
+ }