Checkpoint current UI and ink integration state

This commit is contained in:
2026-05-18 02:46:02 +02:00
parent 2c54498ee2
commit d7bb175167
384 changed files with 922883 additions and 764 deletions
+203 -4
View File
@@ -450,6 +450,7 @@ class SentenceQueueModule extends BaseModule {
dropCap: Boolean(metadata.dropCap),
addTopSpace: Boolean(metadata.addTopSpace),
cueMarkers: metadata.cueMarkers || [],
deferredTags: Array.isArray(metadata.deferredTags) ? metadata.deferredTags : [],
status: 'ready',
tts: {
duration: ttsData.duration,
@@ -513,9 +514,12 @@ class SentenceQueueModule extends BaseModule {
// first-line indent on following paragraphs.
const isHeading = metadata.type === 'heading' || metadata.role === 'chapter-heading' || metadata.role === 'section-heading';
const dropCapLines = metadata.dropCap ? 2 : 0;
const dropCapWidth = metadata.dropCap ? lineHeight * 1.45 : 0;
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
const layoutText = metadata.layoutText || text;
const dropCapText = metadata.dropCap ? this.getDropCapText(layoutText) : '';
const dropCapWidth = metadata.dropCap
? this.measureDropCapReservation(storyElement, dropCapText, lineHeight)
: 0;
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
? metadata.measures
@@ -550,13 +554,16 @@ class SentenceQueueModule extends BaseModule {
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
const layoutOptions = {
measures,
fontSize: `${fontSize}px`,
fontFamily,
lineHeight: lineHeight / fontSize,
lineHeightPx: lineHeight
});
};
const layout = metadata.dropCap
? this.calculateDropCapLayout(paragraphLayout, layoutPlainText, measures, lineOffsets, layoutOptions)
: paragraphLayout.calculateLayout(layoutPlainText, layoutOptions);
if (!layout) {
throw new Error('Paragraph layout calculation failed');
@@ -565,6 +572,7 @@ class SentenceQueueModule extends BaseModule {
return {
breaks: layout.breaks,
nodes: layout.nodes,
lines: layout.lines || null,
processedText: layout.processedText || text,
sourceLayoutText: layoutText,
measures,
@@ -572,7 +580,8 @@ class SentenceQueueModule extends BaseModule {
indentWidth,
imageWrap: metadata.imageWrap || null,
dropCap: Boolean(metadata.dropCap),
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
dropCapText,
dropCapWidth,
dropCapLines,
addTopSpace: Boolean(metadata.addTopSpace),
role: metadata.role || (isHeading ? 'chapter-heading' : 'body'),
@@ -826,6 +835,196 @@ class SentenceQueueModule extends BaseModule {
return String(text).replace(dropCap, '').trimStart();
}
measureDropCapReservation(container, dropCapText, lineHeight) {
if (!container || !dropCapText) {
return lineHeight * 1.34;
}
const probeParagraph = document.createElement('p');
const probe = document.createElement('span');
Object.assign(probeParagraph.style, {
position: 'absolute',
visibility: 'hidden',
left: '-8000px',
top: '-8000px',
margin: '0',
padding: '0',
lineHeight: `${lineHeight}px`
});
probe.className = 'drop-cap story-drop-cap';
probe.textContent = dropCapText;
probe.style.position = 'static';
probe.style.display = 'inline-block';
probeParagraph.appendChild(probe);
container.appendChild(probeParagraph);
const rect = probe.getBoundingClientRect();
const computed = window.getComputedStyle(probe);
let inkRight = 0;
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = [
computed.fontStyle,
computed.fontVariant,
computed.fontWeight,
computed.fontSize,
computed.fontFamily
].filter(Boolean).join(' ');
const metrics = context.measureText(dropCapText);
inkRight = Math.max(
metrics.width || 0,
metrics.actualBoundingBoxRight || 0
);
}
} catch (error) {
console.warn('SentenceQueue: Could not measure drop-cap canvas ink bounds', error);
}
probeParagraph.remove();
const measuredAdvance = Math.max(
Number.isFinite(rect.width) && rect.width > 0 ? rect.width : 0,
Number.isFinite(probe.offsetWidth) && probe.offsetWidth > 0 ? probe.offsetWidth : 0,
Number.isFinite(probe.scrollWidth) && probe.scrollWidth > 0 ? probe.scrollWidth : 0,
inkRight
);
const glyphAdvance = measuredAdvance > 0 ? measuredAdvance : lineHeight * 1.34;
return glyphAdvance + this.measureNormalTextGap(container, lineHeight);
}
measureNormalTextGap(container, lineHeight) {
const story = container?.closest?.('#story') || document.getElementById('story') || container;
const computed = window.getComputedStyle(story);
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context) {
context.font = [
computed.fontStyle,
computed.fontVariant,
computed.fontWeight,
computed.fontSize,
computed.fontFamily
].filter(Boolean).join(' ');
const gap = context.measureText('\u2002').width;
if (Number.isFinite(gap) && gap > 0) {
return gap;
}
}
} catch (error) {
console.warn('SentenceQueue: Could not measure normal text gap', error);
}
return lineHeight / 2;
}
calculateDropCapLayout(paragraphLayout, text, measures, lineOffsets, layoutOptions) {
const firstLineOptions = {
...layoutOptions,
measures: [measures[0], Math.max(measures[0] * 20, 10000)],
fontVariantCaps: 'all-small-caps',
fontFeatureSettings: '"smcp" on, "c2sc" on, "kern" on, "liga" on, "onum" on, "pnum" on'
};
const firstLayout = paragraphLayout.calculateLayout(text, firstLineOptions);
if (!firstLayout?.breaks || firstLayout.breaks.length < 2) {
return paragraphLayout.calculateLayout(text, layoutOptions);
}
const firstLine = this.extractLayoutLine(firstLayout, 0, {
measure: measures[0],
offset: lineOffsets[0],
styleClass: 'story-dropcap-first-line'
});
const remainingText = this.extractRemainingLayoutText(firstLayout, firstLayout.breaks[1].position);
const remainingLayout = paragraphLayout.calculateLayout(remainingText, {
...layoutOptions,
measures: [measures[1], ...measures.slice(2)]
});
const remainingLines = [];
if (remainingLayout?.breaks?.length > 1) {
for (let lineIndex = 0; lineIndex < remainingLayout.breaks.length - 1; lineIndex += 1) {
remainingLines.push(this.extractLayoutLine(remainingLayout, lineIndex, {
measure: measures[Math.min(lineIndex + 1, measures.length - 1)],
offset: lineOffsets[Math.min(lineIndex + 1, lineOffsets.length - 1)] || 0,
styleClass: ''
}));
}
}
const lines = [firstLine, ...remainingLines].filter(Boolean);
return {
breaks: this.breaksFromLines(lines),
nodes: lines.flatMap(line => line.nodes),
lines,
originalText: text,
processedText: text,
width: layoutOptions.width,
lineHeight: layoutOptions.lineHeight,
lineHeightPx: layoutOptions.lineHeightPx,
fontSize: layoutOptions.fontSize,
fontFamily: layoutOptions.fontFamily
};
}
extractLayoutLine(layout, lineIndex, metadata = {}) {
const startBreak = layout.breaks[lineIndex];
const endBreak = layout.breaks[lineIndex + 1];
if (!startBreak || !endBreak || !Array.isArray(layout.nodes)) {
return null;
}
const nodes = [];
for (let index = startBreak.position; index <= endBreak.position; index += 1) {
const node = layout.nodes[index];
if (!node) continue;
if (node.type === 'glue' && (index === startBreak.position || index === endBreak.position)) {
continue;
}
const forcedBreak = window.linebreak?.infinity ? -window.linebreak.infinity : -100000;
if (node.type === 'penalty' && node.penalty <= forcedBreak) {
continue;
}
nodes.push({ ...node });
}
const endNode = layout.nodes[endBreak.position];
return {
nodes,
ratio: endBreak.ratio || 0,
measure: metadata.measure,
offset: metadata.offset || 0,
styleClass: metadata.styleClass || '',
hyphenated: endNode?.type === 'penalty' && endNode.penalty === 100
};
}
extractRemainingLayoutText(layout, breakPosition) {
if (!Array.isArray(layout.nodes)) return '';
const fragments = [];
for (let index = breakPosition + 1; index < layout.nodes.length; index += 1) {
const node = layout.nodes[index];
if (!node) continue;
if (node.type === 'box' || node.type === 'tag') {
fragments.push(node.value || '');
} else if (node.type === 'glue' && node.width > 0) {
fragments.push(' ');
} else if (node.type === 'penalty' && node.penalty === 100) {
fragments.push('|');
}
}
return fragments.join('').replace(/\s+/g, ' ').trimStart();
}
breaksFromLines(lines) {
const breaks = [{ position: 0, ratio: 0 }];
let position = 0;
for (const line of lines) {
position += Math.max(0, line.nodes.length - 1);
breaks.push({ position, ratio: line.ratio || 0 });
position += 1;
}
return breaks;
}
/**
* Calculate animation timing based on TTS duration
* @param {Array<string>} words - Array of words to animate