quote-api / utils /quote-generate /text-layout.js
Quote API Deploy
feat: deploy quote-api to Hugging Face Spaces
2413e0a unverified
Raw
History Blame Contribute Delete
12.8 kB
// utils/quote-generate/text-layout.js
// Layout phase: line breaking, positioning, shrink-wrap.
// Pure arithmetic — no canvas calls, no async. Can be called many times cheaply.
const { RTL_REGEX, NEUTRAL_REGEX } = require('./constants')
/**
* Determine text direction for a line starting at segmentIndex.
* Scans ahead until a strong directional character is found.
*/
function getLineDirection (segments, startIndex) {
for (let i = startIndex; i < segments.length; i++) {
const seg = segments[i]
if (seg.kind === 'space' || seg.kind === 'break') continue
if (seg.text.match(RTL_REGEX)) return 'rtl'
if (!seg.text.match(NEUTRAL_REGEX)) return 'ltr'
}
return 'ltr'
}
/**
* Layout text segments into lines within maxWidth/maxHeight constraints.
*
* Ported algorithms from pretext:
* - Trailing space hanging (spaces at line end don't count for width)
* - Pending-break tracking (rewind to last valid break point on overflow)
* - Overflow-wrap at grapheme boundaries
*/
function layoutText (prepared, maxWidth, maxHeight) {
const { segments, fontSize, lineHeight, emojiSize } = prepared
// Font em-box metrics: line geometry must be a constant of the font, not
// of the glyphs drawn (no ink-dependent heights). Fallback to the legacy
// fontSize bounds when a caller passes a hand-built `prepared`.
const ascent = Number.isFinite(prepared.ascent) ? prepared.ascent : fontSize
const descent = Number.isFinite(prepared.descent) ? prepared.descent : fontSize
if (segments.length === 0) {
return { lines: [], width: 0, height: 0, lineCount: 0, truncated: false }
}
// Clamp max dimensions
if (maxWidth > 10000) maxWidth = 10000
if (maxHeight > 10000) maxHeight = 10000
const lines = []
let currentLine = { segments: [], width: 0, direction: 'ltr' }
let lineY = ascent // First baseline sits at the font ascent
let maxLineWidth = 0
let truncated = false
// Pending break tracking (from pretext)
// lineWidth excludes trailing space — matches hanging-space semantics
let pendingBreak = null // { segIndex, lineWidth, lineSegCount }
currentLine.direction = getLineDirection(segments, 0)
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
// Hard break
if (seg.kind === 'break') {
// Only truncate if there is actual content after this break
const hasMoreContent = i + 1 < segments.length
if (hasMoreContent && maxHeight > 0 && lineY + lineHeight > maxHeight) {
truncated = true
applyTruncation(currentLine, prepared, maxWidth)
break
}
// Finalize current line
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
pendingBreak = null
// Start new line
currentLine = { segments: [], width: 0, direction: getLineDirection(segments, i + 1) }
continue
}
const segWidth = seg.kind === 'emoji' ? emojiSize : seg.width
// Trailing space hanging (from pretext): spaces past the edge don't trigger wrap
if (seg.kind === 'space' && currentLine.width + segWidth > maxWidth) {
// Hang the space — add to line but don't count in width
currentLine.segments.push({ index: i, width: segWidth })
continue
}
// Handle overwide segment on an empty line — loop-split across lines
if (currentLine.segments.length === 0 && segWidth > maxWidth && seg.kind === 'text' && prepared.computeGraphemeWidths) {
var gData = prepared.computeGraphemeWidths(seg)
if (gData && gData.widths.length > 1) {
var cursor = 0
while (cursor < gData.texts.length) {
var fitCount = 0
var fitWidth = 0
for (var g = cursor; g < gData.widths.length; g++) {
if (fitWidth + gData.widths[g] > maxWidth && fitCount > 0) break
fitWidth += gData.widths[g]
fitCount++
}
if (fitCount === 0) { fitCount = 1; fitWidth = gData.widths[cursor] }
var isLast = cursor + fitCount >= gData.texts.length
currentLine.segments.push({
index: i,
width: fitWidth,
graphemeStart: cursor > 0 ? cursor : undefined,
graphemeEnd: isLast ? undefined : cursor + fitCount
})
currentLine.width += fitWidth
if (!isLast) {
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
currentLine = { segments: [], width: 0, direction: currentLine.direction }
}
cursor += fitCount
}
continue
}
}
// Check if segment fits
if (currentLine.width + segWidth > maxWidth && currentLine.segments.length > 0) {
// Overflow — need to break
// Check if next line would exceed maxHeight
if (maxHeight > 0 && lineY + lineHeight > maxHeight) {
truncated = true
applyTruncation(currentLine, prepared, maxWidth)
break
}
if (pendingBreak) {
// Rewind to last break opportunity (from pretext)
const { segIndex, lineWidth, lineSegCount } = pendingBreak
// Trim current line back to pending break point
currentLine.segments.length = lineSegCount
currentLine.width = lineWidth
// Finalize line
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
pendingBreak = null
// Start new line, replay from segment after the break
const newDirection = getLineDirection(segments, segIndex + 1)
currentLine = { segments: [], width: 0, direction: newDirection }
i = segIndex // loop will i++ to segIndex + 1
continue
}
// No pending break — try overflow-wrap at grapheme boundaries
if (prepared.computeGraphemeWidths) {
const remainingWidth = currentLine.width === 0 ? maxWidth : maxWidth - currentLine.width
const gData = prepared.computeGraphemeWidths(seg)
if (gData && gData.widths.length > 1) {
let fitCount = 0
let fitWidth = 0
for (let g = 0; g < gData.widths.length; g++) {
if (fitWidth + gData.widths[g] > remainingWidth && fitCount > 0) break
fitWidth += gData.widths[g]
fitCount++
}
if (fitCount > 0 && fitCount < gData.texts.length) {
// Split segment: put fitCount graphemes on current line
currentLine.segments.push({ index: i, width: fitWidth, graphemeEnd: fitCount })
currentLine.width += fitWidth
// Finalize current line
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
// Loop-split remainder across as many lines as needed
var remCursor = fitCount
var remDirection = getLineDirection(segments, i)
currentLine = { segments: [], width: 0, direction: remDirection }
while (remCursor < gData.texts.length) {
var rFit = 0
var rWidth = 0
for (var rg = remCursor; rg < gData.widths.length; rg++) {
if (rWidth + gData.widths[rg] > maxWidth && rFit > 0) break
rWidth += gData.widths[rg]
rFit++
}
if (rFit === 0) { rFit = 1; rWidth = gData.widths[remCursor] }
var remIsLast = remCursor + rFit >= gData.texts.length
currentLine.segments.push({
index: i,
width: rWidth,
graphemeStart: remCursor > 0 ? remCursor : undefined,
graphemeEnd: remIsLast ? undefined : remCursor + rFit
})
currentLine.width += rWidth
if (!remIsLast) {
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
currentLine = { segments: [], width: 0, direction: remDirection }
}
remCursor += rFit
}
continue
}
}
}
// Finalize current line (no split happened)
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
lineY += lineHeight
// Start new line with current segment
const newDirection = getLineDirection(segments, i)
currentLine = { segments: [], width: 0, direction: newDirection }
if (seg.kind !== 'space') {
currentLine.segments.push({ index: i, width: segWidth })
currentLine.width += segWidth
}
pendingBreak = null
continue
}
// Track break opportunities (spaces)
if (seg.kind === 'space') {
pendingBreak = {
segIndex: i,
lineWidth: currentLine.width, // Exclude space width (hanging-space semantics)
lineSegCount: currentLine.segments.length
}
}
// Add segment to current line
currentLine.segments.push({ index: i, width: segWidth })
currentLine.width += segWidth
}
// Finalize last line
if (currentLine.segments.length > 0) {
if (currentLine.width > maxLineWidth) maxLineWidth = currentLine.width
currentLine.y = lineY
lines.push(currentLine)
}
// Compute x positions for each segment within its line
for (const line of lines) {
let x = 0
// Also compute actual content width (excluding trailing spaces)
let contentWidth = 0
for (const seg of line.segments) {
seg.x = x
x += seg.width
const srcSeg = segments[seg.index]
if (srcSeg && srcSeg.kind !== 'space') {
contentWidth = x
}
}
line.contentWidth = contentWidth || line.width
}
const totalHeight = lines.length > 0 ? lines[lines.length - 1].y + descent : 0
return {
lines,
width: maxLineWidth,
height: totalHeight,
lineCount: lines.length,
truncated
}
}
/**
* Trim segments from line to fit within maxWidth and mark as truncated.
*/
function applyTruncation (line, prepared, maxWidth) {
// Empty line (e.g. truncation hit at a \n\n boundary) — nothing to trim
if (line.segments.length === 0) {
line.width = 0
line.truncated = true
return
}
const ctx = require('./text-prepare').getMeasureCtx()
const ellipsis = '\u2026'
// Set font to last segment's font so ellipsis is measured at the correct size
const lastLayoutSeg = line.segments[line.segments.length - 1]
if (lastLayoutSeg) {
const lastSeg = prepared.segments[lastLayoutSeg.index]
if (lastSeg) ctx.font = lastSeg.font
}
const ellipsisWidth = ctx.measureText(ellipsis).width
const targetWidth = maxWidth - ellipsisWidth
// Trim segments until total width fits within targetWidth
let totalWidth = 0
let trimIndex = line.segments.length
for (let s = 0; s < line.segments.length; s++) {
if (totalWidth + line.segments[s].width > targetWidth) {
trimIndex = s
break
}
totalWidth += line.segments[s].width
}
line.segments.length = Math.max(1, trimIndex)
if (totalWidth === 0) {
totalWidth = line.segments[0].width
}
line.width = totalWidth
line.truncated = true
}
/**
* Find the tightest container width without increasing line count.
* Binary search over maxWidth (from pretext's walkLineRanges concept).
*/
function shrinkWrap (prepared, maxWidth, maxHeight) {
const initial = layoutText(prepared, maxWidth, maxHeight)
// Single line, empty, or already truncated — don't shrink further
if (initial.lineCount <= 1 || initial.truncated) return initial
// Find the widest segment (absolute minimum width)
let maxSegWidth = 0
for (const seg of prepared.segments) {
if (seg.width > maxSegWidth) maxSegWidth = seg.width
}
// Binary search for tightest width
let lo = Math.max(maxSegWidth, initial.width / initial.lineCount * 0.7)
let hi = initial.width
const targetLineCount = initial.lineCount
// ~10 iterations for 2px precision on typical widths
while (hi - lo > 2) {
const mid = (lo + hi) / 2
const trial = layoutText(prepared, mid, maxHeight)
if (trial.lineCount <= targetLineCount && !trial.truncated) {
hi = mid // fits — try narrower
} else {
lo = mid // too narrow — widen
}
}
return layoutText(prepared, Math.ceil(hi), maxHeight)
}
module.exports = { layoutText, shrinkWrap, getLineDirection }