@kolkrabbi/kol-foundry 0.4.2 → 0.5.0
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/package.json +4 -4
- package/src/FontPreviewSection.jsx +1 -1
- package/src/GlyphItem.jsx +51 -0
- package/src/GlyphMetricsSection.jsx +1 -1
- package/src/TypefaceStyleSection.jsx +1 -1
- package/src/VariableFontSection.jsx +1 -1
- package/src/engine/FontInfo.js +333 -0
- package/src/engine/FontLoader.js +76 -0
- package/src/engine/FontViewerComponent.jsx +449 -0
- package/src/engine/FontViewerSection.jsx +10 -0
- package/src/engine/GlyphAnimator.js +119 -0
- package/src/engine/MetricsOverlay.js +169 -0
- package/src/engine/Types.js +22 -0
- package/src/engine/UIControls.js +92 -0
- package/src/engine/VariationAxes.js +71 -0
- package/src/index.js +18 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// hyperflip/MetricsOverlay.js
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export class MetricsOverlay {
|
|
6
|
+
constructor(overlayElement) {
|
|
7
|
+
this.overlay = overlayElement;
|
|
8
|
+
this.isVisible = false;
|
|
9
|
+
this.overlay.style.display = 'none'; // Ensure it's hidden initially
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
render(font, glyphElement) {
|
|
13
|
+
if (!this.isVisible || !glyphElement) return;
|
|
14
|
+
|
|
15
|
+
this.overlay.innerHTML = '';
|
|
16
|
+
|
|
17
|
+
const currentChar = glyphElement.textContent;
|
|
18
|
+
if (!currentChar) return;
|
|
19
|
+
|
|
20
|
+
const metrics = this.calculateMetrics(font, glyphElement, currentChar);
|
|
21
|
+
this.renderMetricLines(metrics, font);
|
|
22
|
+
this.renderBearingLines(metrics);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
calculateMetrics(font, glyphElement, currentChar) {
|
|
26
|
+
const computedStyle = getComputedStyle(glyphElement);
|
|
27
|
+
const fontSize = parseInt(computedStyle.fontSize);
|
|
28
|
+
const unitsPerEm = font.unitsPerEm;
|
|
29
|
+
|
|
30
|
+
// Basic metrics scale based on font size
|
|
31
|
+
const metricsScale = fontSize / unitsPerEm;
|
|
32
|
+
|
|
33
|
+
// Get the glyph element's metrics
|
|
34
|
+
const glyphRect = glyphElement.getBoundingClientRect();
|
|
35
|
+
const renderedWidth = glyphRect.width;
|
|
36
|
+
const verticalCenter = glyphRect.top + (glyphRect.height / 2);
|
|
37
|
+
const horizontalCenter = glyphRect.left + (glyphRect.width / 2);
|
|
38
|
+
|
|
39
|
+
// Get glyph info
|
|
40
|
+
const glyphIndex = font.charToGlyphIndex(currentChar);
|
|
41
|
+
const glyph = font.glyphs.get(glyphIndex);
|
|
42
|
+
|
|
43
|
+
// The total height in font units from descender to ascender
|
|
44
|
+
const totalHeight = font.tables.os2.sTypoAscender - font.tables.os2.sTypoDescender;
|
|
45
|
+
|
|
46
|
+
// Calculate how much of the total height is above the baseline (in font units)
|
|
47
|
+
const baselineOffset = font.tables.os2.sTypoAscender;
|
|
48
|
+
|
|
49
|
+
// Calculate where the baseline should be relative to the center
|
|
50
|
+
const baselineRatio = baselineOffset / totalHeight;
|
|
51
|
+
|
|
52
|
+
// Scale the total height to pixels
|
|
53
|
+
const totalPixelHeight = (totalHeight / unitsPerEm) * fontSize;
|
|
54
|
+
|
|
55
|
+
// Position the baseline
|
|
56
|
+
const baseline = verticalCenter - (totalPixelHeight / 2) + (baselineRatio * totalPixelHeight);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
glyph,
|
|
60
|
+
fontSize,
|
|
61
|
+
metricsScale,
|
|
62
|
+
glyphRect,
|
|
63
|
+
containerCenter: horizontalCenter,
|
|
64
|
+
baseline,
|
|
65
|
+
renderedWidth,
|
|
66
|
+
ascender: baseline - (font.tables.os2.sTypoAscender * metricsScale),
|
|
67
|
+
descender: baseline - (font.tables.os2.sTypoDescender * metricsScale),
|
|
68
|
+
capHeight: baseline - (font.tables.os2.sCapHeight * metricsScale),
|
|
69
|
+
xHeight: baseline - (font.tables.os2.sxHeight * metricsScale),
|
|
70
|
+
// Store the original values from the font file
|
|
71
|
+
originalMetrics: {
|
|
72
|
+
ascender: font.tables.os2.sTypoAscender,
|
|
73
|
+
descender: font.tables.os2.sTypoDescender,
|
|
74
|
+
capHeight: font.tables.os2.sCapHeight,
|
|
75
|
+
xHeight: font.tables.os2.sxHeight,
|
|
76
|
+
smallCapHeight: font.tables.os2.sSmallCapHeight
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
renderMetricLines(metrics, font) {
|
|
82
|
+
const lines = [
|
|
83
|
+
{
|
|
84
|
+
pos: metrics.baseline,
|
|
85
|
+
label: 'Baseline',
|
|
86
|
+
value: 0 // Baseline is reference point 0
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
pos: metrics.ascender,
|
|
90
|
+
label: 'Ascender',
|
|
91
|
+
value: metrics.originalMetrics.ascender
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
pos: metrics.descender,
|
|
95
|
+
label: 'Descender',
|
|
96
|
+
value: metrics.originalMetrics.descender
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
pos: metrics.capHeight,
|
|
100
|
+
label: 'Capital height',
|
|
101
|
+
value: metrics.originalMetrics.capHeight
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
pos: metrics.xHeight,
|
|
105
|
+
label: 'x-height',
|
|
106
|
+
value: metrics.originalMetrics.xHeight
|
|
107
|
+
}
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
if (font.tables.os2.sSmallCapHeight) {
|
|
111
|
+
lines.push({
|
|
112
|
+
pos: metrics.baseline - (font.tables.os2.sSmallCapHeight * metrics.metricsScale),
|
|
113
|
+
label: 'Small Caps',
|
|
114
|
+
value: metrics.originalMetrics.smallCapHeight
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.forEach(({ pos, label, value }) => {
|
|
119
|
+
if (isFinite(pos)) {
|
|
120
|
+
const line = document.createElement('div');
|
|
121
|
+
line.className = 'metric-line';
|
|
122
|
+
line.style.top = `${pos}px`;
|
|
123
|
+
|
|
124
|
+
const legend = document.createElement('div');
|
|
125
|
+
legend.className = 'legend';
|
|
126
|
+
legend.style.top = `${pos - 21}px`;
|
|
127
|
+
|
|
128
|
+
// Create the formatted legend content
|
|
129
|
+
const labelText = document.createTextNode(`${label} → `);
|
|
130
|
+
const valueSpan = document.createElement('span');
|
|
131
|
+
valueSpan.className = 'monospaced';
|
|
132
|
+
valueSpan.textContent = `${value}`;
|
|
133
|
+
|
|
134
|
+
legend.appendChild(labelText);
|
|
135
|
+
legend.appendChild(valueSpan);
|
|
136
|
+
|
|
137
|
+
this.overlay.appendChild(line);
|
|
138
|
+
this.overlay.appendChild(legend);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
renderBearingLines(metrics) {
|
|
144
|
+
if (!metrics.glyph) return;
|
|
145
|
+
|
|
146
|
+
// Use the rendered width for sidebearings, which takes into account both
|
|
147
|
+
// font size and any active variation settings
|
|
148
|
+
const leftPos = metrics.containerCenter - (metrics.renderedWidth / 2);
|
|
149
|
+
const rightPos = leftPos + metrics.renderedWidth;
|
|
150
|
+
|
|
151
|
+
[leftPos, rightPos].forEach(pos => {
|
|
152
|
+
if (isFinite(pos)) {
|
|
153
|
+
const line = document.createElement('div');
|
|
154
|
+
line.className = 'side-bearing-line';
|
|
155
|
+
line.style.left = `${pos}px`;
|
|
156
|
+
this.overlay.appendChild(line);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
toggle() {
|
|
162
|
+
this.isVisible = !this.isVisible;
|
|
163
|
+
this.overlay.style.display = this.isVisible ? 'block' : 'none';
|
|
164
|
+
if (!this.isVisible) {
|
|
165
|
+
this.overlay.innerHTML = '';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Types.js - Font variation axis definitions
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export const AXIS_NAMES = {
|
|
6
|
+
'wght': 'Weight',
|
|
7
|
+
'wdth': 'Width',
|
|
8
|
+
'ital': 'Italic',
|
|
9
|
+
'slnt': 'Slant',
|
|
10
|
+
'opsz': 'Optical Size',
|
|
11
|
+
'GRAD': 'Grade',
|
|
12
|
+
'YOPQ': 'Thin Stroke',
|
|
13
|
+
'YTLC': 'Lowercase Height',
|
|
14
|
+
'YTUC': 'Uppercase Height',
|
|
15
|
+
'YTAS': 'Ascender Height',
|
|
16
|
+
'YTDE': 'Descender Depth',
|
|
17
|
+
'YTFI': 'Figure Height',
|
|
18
|
+
'XHGT': 'x-Height',
|
|
19
|
+
'XOPQ': 'Thick Stroke',
|
|
20
|
+
'XTRA': 'Parametric Tracking',
|
|
21
|
+
'YTRA': 'Parametric Tracking'
|
|
22
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// shared/UIControls.js
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export class UIControls {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.isDarkMode = false;
|
|
8
|
+
this.isFullscreen = false;
|
|
9
|
+
this.setupEventListeners();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
setupEventListeners() {
|
|
13
|
+
// Fullscreen change events for different browsers
|
|
14
|
+
document.addEventListener('fullscreenchange', this.handleFullscreenChange.bind(this));
|
|
15
|
+
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange.bind(this));
|
|
16
|
+
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange.bind(this));
|
|
17
|
+
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange.bind(this));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toggleFullscreen() {
|
|
21
|
+
if (this.isFullscreen) {
|
|
22
|
+
this.exitFullscreen();
|
|
23
|
+
} else {
|
|
24
|
+
this.enterFullscreen();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
enterFullscreen() {
|
|
29
|
+
const elem = document.documentElement;
|
|
30
|
+
if (elem.requestFullscreen) {
|
|
31
|
+
elem.requestFullscreen();
|
|
32
|
+
} else if (elem.webkitRequestFullscreen) {
|
|
33
|
+
elem.webkitRequestFullscreen();
|
|
34
|
+
} else if (elem.msRequestFullscreen) {
|
|
35
|
+
elem.msRequestFullscreen();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
exitFullscreen() {
|
|
40
|
+
if (document.exitFullscreen) {
|
|
41
|
+
document.exitFullscreen();
|
|
42
|
+
} else if (document.webkitExitFullscreen) {
|
|
43
|
+
document.webkitExitFullscreen();
|
|
44
|
+
} else if (document.msExitFullscreen) {
|
|
45
|
+
document.msExitFullscreen();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleFullscreenChange() {
|
|
50
|
+
this.isFullscreen = Boolean(
|
|
51
|
+
document.fullscreenElement ||
|
|
52
|
+
document.webkitFullscreenElement ||
|
|
53
|
+
document.mozFullScreenElement ||
|
|
54
|
+
document.msFullscreenElement
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Update button text if needed
|
|
58
|
+
const fullscreenButton = document.querySelector('#fullScreen button');
|
|
59
|
+
if (fullscreenButton) {
|
|
60
|
+
fullscreenButton.textContent = this.isFullscreen ? 'Windowed' : 'Fullscreen';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toggleColorScheme() {
|
|
65
|
+
this.isDarkMode = !this.isDarkMode;
|
|
66
|
+
const wrapper = document.querySelector('.font-viewer-wrapper');
|
|
67
|
+
|
|
68
|
+
if (wrapper) {
|
|
69
|
+
if (this.isDarkMode) {
|
|
70
|
+
wrapper.style.setProperty('--fv-white', 'rgb(0, 0, 0)');
|
|
71
|
+
wrapper.style.setProperty('--fv-black', 'rgb(255, 255, 255)');
|
|
72
|
+
wrapper.style.setProperty('--fv-metrics-color', 'rgba(255, 255, 255, 0.4)');
|
|
73
|
+
wrapper.style.setProperty('--fv-border-color', 'rgba(255, 255, 255, 0.9)');
|
|
74
|
+
wrapper.style.setProperty('--fv-surface', 'rgba(0, 0, 0, 0.82)');
|
|
75
|
+
wrapper.style.setProperty('--fv-panel-bg', 'rgba(8, 8, 8, 0.9)');
|
|
76
|
+
wrapper.style.setProperty('--fv-panel-border', 'rgba(255, 255, 255, 0.9)');
|
|
77
|
+
wrapper.style.setProperty('--fv-panel-shadow', '0 10px 24px rgba(0, 0, 0, 0.6)');
|
|
78
|
+
wrapper.dataset.colorScheme = 'dark'
|
|
79
|
+
} else {
|
|
80
|
+
wrapper.style.setProperty('--fv-white', 'rgb(255, 255, 255)');
|
|
81
|
+
wrapper.style.setProperty('--fv-black', 'rgb(0, 0, 0)');
|
|
82
|
+
wrapper.style.setProperty('--fv-metrics-color', 'rgba(0, 0, 0, 0.4)');
|
|
83
|
+
wrapper.style.setProperty('--fv-border-color', 'var(--fv-black)');
|
|
84
|
+
wrapper.style.setProperty('--fv-surface', 'rgba(255, 255, 255, 0.92)');
|
|
85
|
+
wrapper.style.setProperty('--fv-panel-bg', 'rgba(255, 255, 255, 0.96)');
|
|
86
|
+
wrapper.style.setProperty('--fv-panel-border', 'var(--fv-black)');
|
|
87
|
+
wrapper.style.setProperty('--fv-panel-shadow', '0 10px 24px rgba(0, 0, 0, 0.18)');
|
|
88
|
+
wrapper.dataset.colorScheme = 'light'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// hyperflip/VariationAxes.js
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
export class VariationAxes {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.container = options.container
|
|
8
|
+
this.onChange = options.onChange
|
|
9
|
+
this.currentSettings = {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createAxesControls(axes) {
|
|
13
|
+
if (!this.container) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.container.innerHTML = ''
|
|
18
|
+
this.currentSettings = {}
|
|
19
|
+
|
|
20
|
+
axes.forEach(axis => {
|
|
21
|
+
const sliderGroup = document.createElement('div')
|
|
22
|
+
sliderGroup.className = 'font-viewer-slider-group'
|
|
23
|
+
|
|
24
|
+
const label = document.createElement('label')
|
|
25
|
+
const sliderId = `font-axis-${axis.tag}`
|
|
26
|
+
label.htmlFor = sliderId
|
|
27
|
+
label.textContent = `${axis.name} (${axis.tag})`
|
|
28
|
+
|
|
29
|
+
const slider = document.createElement('input')
|
|
30
|
+
slider.type = 'range'
|
|
31
|
+
slider.id = sliderId
|
|
32
|
+
slider.dataset.tag = axis.tag
|
|
33
|
+
slider.min = axis.min
|
|
34
|
+
slider.max = axis.max
|
|
35
|
+
slider.value = axis.default
|
|
36
|
+
slider.step = 0.1
|
|
37
|
+
|
|
38
|
+
const valueSpan = document.createElement('span')
|
|
39
|
+
valueSpan.textContent = parseFloat(axis.default).toFixed(1)
|
|
40
|
+
|
|
41
|
+
slider.addEventListener('input', (e) => {
|
|
42
|
+
this.updateAxisValue(axis.tag, e.target.value)
|
|
43
|
+
valueSpan.textContent = parseFloat(e.target.value).toFixed(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
sliderGroup.appendChild(label)
|
|
47
|
+
sliderGroup.appendChild(slider)
|
|
48
|
+
sliderGroup.appendChild(valueSpan)
|
|
49
|
+
|
|
50
|
+
this.container.appendChild(sliderGroup)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
updateAxisValue(tag, value) {
|
|
55
|
+
const numValue = parseFloat(value)
|
|
56
|
+
if (isNaN(numValue)) {
|
|
57
|
+
delete this.currentSettings[tag]
|
|
58
|
+
} else {
|
|
59
|
+
this.currentSettings[tag] = numValue
|
|
60
|
+
}
|
|
61
|
+
this.updateVariationSettings()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
updateVariationSettings() {
|
|
65
|
+
const settings = Object.entries(this.currentSettings)
|
|
66
|
+
.filter(([_, val]) => !isNaN(val))
|
|
67
|
+
.map(([tag, val]) => `"${tag}" ${val.toFixed(1)}`)
|
|
68
|
+
.join(', ')
|
|
69
|
+
this.onChange?.(settings || 'normal')
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/index.js
CHANGED
|
@@ -40,3 +40,21 @@ export { default as TypefaceSpecimenPage } from './TypefaceSpecimenPage.jsx'
|
|
|
40
40
|
// data
|
|
41
41
|
export { glyphSets, glyphCategories, SPECIMEN_SAMPLE_TEXT } from './glyphData.js'
|
|
42
42
|
export { typefaceConfig, getTypefaceConfig, getAllTypefaceIds, getAllTypefaces } from './typefaceConfig.js'
|
|
43
|
+
|
|
44
|
+
// fontviewer engine (lobbied from elder @kol/fontviewer 2026-07-16) — the
|
|
45
|
+
// variable-font viewer instrument: canvas glyph stage + parsed-metrics overlay
|
|
46
|
+
// + axis controls. Plain-JS utils are React-free; opentype.js is the optional
|
|
47
|
+
// peer. Chrome CSS lives in kol-theme/kol-components-foundry.css. Font files
|
|
48
|
+
// are consumer-served (never bundled) — pass fontUrl/defaultFontUrl props.
|
|
49
|
+
export { default as FontViewerComponent } from './engine/FontViewerComponent.jsx'
|
|
50
|
+
export { default as FontViewerSection } from './engine/FontViewerSection.jsx'
|
|
51
|
+
export * from './engine/FontLoader.js'
|
|
52
|
+
export * from './engine/FontInfo.js'
|
|
53
|
+
export * from './engine/GlyphAnimator.js'
|
|
54
|
+
export * from './engine/MetricsOverlay.js'
|
|
55
|
+
export * from './engine/Types.js'
|
|
56
|
+
export * from './engine/UIControls.js'
|
|
57
|
+
export * from './engine/VariationAxes.js'
|
|
58
|
+
|
|
59
|
+
// glyph atom (lobbied from elder @kol/ui 2026-07-16) — renders one live glyph
|
|
60
|
+
export { default as GlyphItem } from './GlyphItem.jsx'
|