smithflix-roku/components/captionTask.bs
2026-08-12 21:02:32 -06:00

767 lines
30 KiB
Text

import "pkg:/components/captionParser.bs"
import "pkg:/components/captionShadow.bs"
import "pkg:/source/api/baserequest.bs"
import "pkg:/source/enums/CaptionMoveDirection.bs"
import "pkg:/source/enums/MediaPlaybackState.bs"
import "pkg:/source/enums/String.bs"
import "pkg:/source/utils/config.bs"
const SCREEN_PADDING = 60
const LOWEST_CAPTION = 1080 - SCREEN_PADDING
const VIEWABLE_AREA = 1080 - 2 * SCREEN_PADDING
' Maximum width allowed before caption text wraps to the next line
const SAFE_TEXT_WIDTH = Int(1920 * 0.75)
sub init()
m.top.playerState = MediaPlaybackState.STOPPED
m.top.observeField("url", "fetchCaption")
m.top.currentCaption = []
m.top.currentPos = 0
m.captionTimer = m.top.findNode("captionTimer")
m.captionTimer.ObserveField("fire", "updateCaption")
m.captionList = []
m.reader = createObject("roUrlTransfer")
m.fontColor = CreateObject("roRegex", "color\s*=\s*(""[^""]*""|\'[^\']*\'|[^\s]+)", "i")
' Caption Style
m.fontSizeDict = { "Default": 60, "Large": 60, "Extra Large": 70, "Medium": 50, "Small": 40, "Extra Small": 30 }
m.percentageDict = { "Default": "FF", "100%": "FF", "75%": "BF", "50%": "7F", "25%": "4B", "Off": "00" }
m.textColorDict = { "Default": "0xCCCCCC", "Bright White": "0xFFFFFF", "White": "0xCCCCCC", "Black": "0x000000", "Red": "0xFF0000", "Green": "0x008000", "Blue": "0x0000FF", "Yellow": "0xFFFF00", "Magenta": "0xFF00FF", "Cyan": "0x00FFFF" }
m.bgColorDict = { "Default": "0x000000", "Bright White": "0xFFFFFF", "White": "0xCCCCCC", "Black": "0x000000", "Red": "0xFF0000", "Green": "0x008000", "Blue": "0x0000FF", "Yellow": "0xFFFF00", "Magenta": "0xFF00FF", "Cyan": "0x00FFFF" }
deviceInfo = CreateObject("roDeviceInfo")
m.fontSize = chainLookupReturn(m.fontSizeDict, deviceInfo.GetCaptionsOption("Text/Size"), m.fontSizeDict.Default)
textColor = chainLookupReturn(m.textColorDict, deviceInfo.GetCaptionsOption("Text/Color"), m.textColorDict.Default)
m.textOpac = chainLookupReturn(m.percentageDict, deviceInfo.GetCaptionsOption("Text/Opacity"), m.percentageDict.Default)
m.textColor = `${textColor}${m.textOpac}`
deviceBGColor = chainLookupReturn(m.bgColorDict, deviceInfo.GetCaptionsOption("Background/Color"), m.bgColorDict.Default)
deviceBGOpacity = chainLookupReturn(m.percentageDict, deviceInfo.GetCaptionsOption("Background/Opacity"), m.percentageDict.Default)
m.bgColor = `${deviceBGColor}${deviceBGOpacity}`
m.textEffect = deviceInfo.GetCaptionsOption("Text/Effect")
m.windowPercentageDict = { "Default": "00", "100%": "FF", "75%": "BF", "50%": "7F", "25%": "4B", "Off": "00" }
deviceWindowColor = chainLookupReturn(m.bgColorDict, deviceInfo.GetCaptionsOption("Window/Color"), m.bgColorDict.Default)
deviceWindowOpacity = chainLookupReturn(m.windowPercentageDict, deviceInfo.GetCaptionsOption("Window/Opacity"), "00")
m.windowBgColor = `${deviceWindowColor}${deviceWindowOpacity}`
if isValidAndNotEmpty(m.global.fallbackFont)
m.fonts = m.global.fallbackFont
else
m.fonts = {
"regular": "font:LargeSystemFont",
"bold": "font:LargeBoldSystemFont",
"italic": "font:LargeSystemFont",
"bolditalic": "font:LargeBoldSystemFont"
}
end if
' Calculate and store the max line height for all font variants (used for per-line backgrounds)
m.lineHeights = getLineHeights()
' Hidden label used to measure caption text for word wrapping.
createMeasureLabel()
end sub
sub fetchCaption()
m.captionTimer.control = "stop"
re = CreateObject("roRegex", "(http.*?\.vtt)", "s")
url = re.match(m.top.url)[0]
if url <> invalid
m.reader.setUrl(url)
text = m.reader.GetToString()
m.captionList = parseVTT(text)
m.captionTimer.control = "start"
else
m.captionTimer.control = "stop"
end if
end sub
function newLayoutGroup(label)
newlg = CreateObject("roSGNode", "LayoutGroup")
newlg.appendchild(label)
newlg.layoutDirection = "vert"
newlg.horizalignment = "left"
newlg.vertalignment = "top"
newlg.itemSpacings = "[]"
return newlg
end function
function newRect(lg, id as string, styledText = invalid, drawingStyles = invalid)
rect = CreateObject("roSGNode", "Rectangle")
rect.addreplace("id", id)
' Attach lg to measure its dimensions
rect.appendchild(lg)
lg.translation = [5, 5]
rectxy = lg.BoundingRect()
' rect is transparent — window background and per-line cue backgrounds are drawn separately
rect.color = "0x00000000"
rect.width = rectxy.width + 10
rect.height = rectxy.height + 10
if lg.getchildCount() = 0
rect.width = 0
rect.height = 0
end if
' Draw per-line cue background
createCueBackgroundBox(rect, lg, styledText, drawingStyles, m.bgColor, m.lineHeights)
' Draw outline labels
createShadowLabels(rect, styledText, drawingStyles, m.textEffect, m.bgColor)
' Add main label last so it renders on top of backgrounds and shadows
rect.appendchild(lg)
return rect
end function
' Creates a hidden MultiStyleLabel used to measure rendered text width for
' caption word wrapping. It remains attached so boundingRect() returns
' valid dimensions.
sub createMeasureLabel()
m.measureGroup = CreateObject("roSGNode", "Group")
m.measureLabel = CreateObject("roSGNode", "MultiStyleLabel")
m.measureLabel.drawingStyles = {
"default": { "fontUri": m.fonts.regular, "fontSize": m.fontSize, "color": m.textColor }
}
m.measureGroup.appendChild(m.measureLabel)
m.top.appendChild(m.measureGroup)
end sub
' Wraps caption text so no visual line exceeds maxWidth pixels while
' preserving inline style tags across inserted line breaks.
' Balances per original line so wrapping never merges separate VTT lines
' (e.g. dialogue turns) together.
function wrapLongLines(text as string, maxWidth as integer) as string
lines = text.split(Chr(10))
wrapped = []
for each line in lines
singleWrapped = wrapSingleLine(line, maxWidth)
wrapped.push(balanceCaptionLines(singleWrapped, maxWidth))
end for
return wrapped.join(Chr(10))
end function
' Maximum number of visual lines a caption may be balanced across.
const MAX_BALANCED_LINES = 3
' Balances wrapped caption lines by redistributing words across up to
' MAX_BALANCED_LINES lines. Originally this only balanced when exactly 2
' lines were produced; that missed cases where 2 lines were both close to
' maxWidth and a 3-line split would read better. This version searches for
' the minimum line count that fits, then balances within that count.
function balanceCaptionLines(text as string, maxWidth as integer) as string
lines = text.split(Chr(10))
' Nothing to balance for single-line captions
if lines.count() < 2
return text
end if
words = []
for each line in lines
for each word in line.split(" ")
if word.Len() > 0 then words.push(word)
end for
end for
if words.count() < 3
return text
end if
' Find the smallest line count (2 or 3) that fits every word under maxWidth,
' probed with a simple greedy wrap.
targetLineCount = 0
for candidateCount = 2 to MAX_BALANCED_LINES
if candidateCount >= words.count()
exit for
end if
if canFitInLines(words, candidateCount, maxWidth)
targetLineCount = candidateCount
exit for
end if
end for
' No arrangement fits within MAX_BALANCED_LINES; keep the original wrap.
if targetLineCount = 0
return text
end if
bestSplit = findBestSplit(words, targetLineCount, maxWidth)
if bestSplit = invalid
return text
end if
return bestSplit.join(Chr(10))
end function
' Checks whether words can be greedily wrapped into lineCount lines (or fewer)
' without exceeding maxWidth on any line.
function canFitInLines(words as object, lineCount as integer, maxWidth as integer) as boolean
linesUsed = 1
currentLine = ""
for each word in words
candidate = currentLine
if candidate.Len() > 0 then candidate += " "
candidate += word
if measureCaptionWidth(candidate) > maxWidth and currentLine.Len() > 0
linesUsed++
if linesUsed > lineCount then return false
currentLine = word
if measureCaptionWidth(currentLine) > maxWidth then return false
else
currentLine = candidate
end if
end for
return true
end function
' Finds the split of words into exactly lineCount lines that minimizes the
' gap between the widest and narrowest line, keeping every line under
' maxWidth. Brute-forced over split points; cheap given typical caption
' word counts.
function findBestSplit(words as object, lineCount as integer, maxWidth as integer) as object
splitCombinations = generateSplitCombinations(words.count(), lineCount)
bestLines = invalid
bestScore = invalid
for each split in splitCombinations
candidateLines = []
widths = []
start = 0
valid = true
for each cut in split
segment = words.slice(start, cut).join(" ")
width = measureCaptionWidth(segment)
if width > maxWidth
valid = false
exit for
end if
candidateLines.push(segment)
widths.push(width)
start = cut
end for
if not valid then continue for
maxW = widths[0]
minW = widths[0]
for each w in widths
if w > maxW then maxW = w
if w < minW then minW = w
end for
score = maxW - minW
if bestScore = invalid or score < bestScore
bestScore = score
bestLines = candidateLines
end if
end for
return bestLines
end function
' Generates every way to place (lineCount - 1) internal cut points across
' wordCount words. E.g. 3 lines / 5 words -> [[1,2,5],[1,3,5],[1,4,5],[2,3,5]...]
function generateSplitCombinations(wordCount as integer, lineCount as integer) as object
cutsNeeded = lineCount - 1
combinations = []
if cutsNeeded = 1
for i = 1 to wordCount - 1
combinations.push([i, wordCount])
end for
else if cutsNeeded = 2
for i = 1 to wordCount - 2
for j = i + 1 to wordCount - 1
combinations.push([i, j, wordCount])
end for
end for
end if
return combinations
end function
function measureCaptionWidth(text as string) as integer
m.measureLabel.text = text
return m.measureLabel.boundingRect().width
end function
function wrapSingleLine(line as string, maxWidth as integer) as string
' Tokenize into tags vs. plain text
tokens = []
position = 0
lineLen = line.Len()
while position < lineLen
if line.Mid(position, 1) = "<"
tagEnd = line.Instr(position, ">")
if tagEnd = -1
tokens.push({ type: "text", value: line.Mid(position) })
position = lineLen
else
tokens.push({ type: "tag", value: line.Mid(position, tagEnd - position + 1) })
position = tagEnd + 1
end if
else
nextTag = line.Instr(position, "<")
if nextTag = -1
tokens.push({ type: "text", value: line.Mid(position) })
position = lineLen
else
tokens.push({ type: "text", value: line.Mid(position, nextTag - position) })
position = nextTag
end if
end if
end while
' Expand plain-text tokens into words/spaces so we can break between them
expanded = []
for each token in tokens
if token.type = "tag"
expanded.push(token)
else
words = token.value.split(" ")
for i = 0 to words.count() - 1
if words[i].Len() > 0
expanded.push({ type: "word", value: words[i] })
end if
if i < words.count() - 1
expanded.push({ type: "space" })
end if
end for
end if
end for
openTags = []
output = ""
currentPlainText = ""
for each token in expanded
if token.type = "tag"
output += token.value
if token.value.StartsWith("</")
if openTags.count() > 0 then openTags.pop()
else
openTags.push(token.value)
end if
else if token.type = "space"
output += " "
currentPlainText += " "
else
candidateText = currentPlainText + token.value
m.measureLabel.text = candidateText
candidateWidth = m.measureLabel.boundingRect().width
if candidateWidth > maxWidth and currentPlainText.Trim().Len() > 0
' Close currently open tags, break the line, then reopen
' them so styling continues on the next visual line
for i = openTags.count() - 1 to 0 step -1
tagName = openTags[i].Mid(1, openTags[i].Len() - 2)
spacePos = tagName.Instr(0, " ")
if spacePos <> -1 then tagName = tagName.Left(spacePos)
output += `</${tagName}>`
end for
output += Chr(10)
for each tag in openTags
output += tag
end for
currentPlainText = token.value
output += token.value
else
currentPlainText = candidateText
output += token.value
end if
end if
end for
return output
end function
sub updateCaption()
if LCase(m.top.playerState.right(1)) = "w"
m.top.playerState = m.top.playerState.left(len (m.top.playerState) - 1)
return
end if
' Keep last captions visible while paused instead of clearing them.
if isStringEqual(m.top.playerState, "pausedon") then return
m.top.currentCaption = []
if not isStringEqual(m.top.playerState, "playingon") then return
m.top.currentPos = m.top.currentPos + 100
captions = []
for each entry in m.captionList
if entry["text"].count() = 0 then continue for ' Guard against empty text blocks
if entry["start"] <= m.top.currentPos and m.top.currentPos < entry["end"]
fullText = entry["text"].Join(Chr(10))
captionPosition = chainLookupReturn(entry["styles"], "position", string.EMPTY)
' Only wrap captions without explicit position cues.
' Balancing now happens inside wrapLongLines.
if isStringEqual(captionPosition, string.EMPTY)
fullText = wrapLongLines(fullText, SAFE_TEXT_WIDTH)
end if
' look for style tags
currentStyle = {
"fontUri": m.fonts.regular,
"fontSize": m.fontSize,
"color": m.textColor
}
drawingStyles = {
"default": currentStyle
}
openTags = []
tagNumber = 0
styledText = string.EMPTY
parts = fullText.Split("<")
firstIter = true
for each part in parts
if firstIter
' first part will either be "" if it starts with a tag,
' or plain text before the first tag, so just add it and continue
styledText += part
firstIter = false
continue for
end if
tagEnd = part.Instr(0, ">")
nextNewLine = part.Instr(0, Chr(10))
if tagEnd = -1 or (nextNewLine <> -1 and nextNewLine < tagEnd)
' no end bracket on the same line - treat as literal less-than sign
styledText += `<${part}`
continue for
end if
tagValue = LCase(part.Mid(0, tagEnd).Trim())
replaceTagWith = string.EMPTY
if tagValue.StartsWith("font")
' opening font tag
colorName = invalid
color = m.fontColor.Match(tagValue)
if color.Count() > 1
color = color[1].Replace("'", string.EMPTY).Replace("""", string.EMPTY).Trim()
if color.StartsWith("#")
' hex color code (must be 3 or 6 digits)
sanitizedColor = "#"
colorChars = color.Mid(1).Split(string.EMPTY)
isShortColor = colorChars.Count() = 3
for each c in colorChars
if c >= "0" and c <= "9" or c >= "a" and c <= "f"
sanitizedColor += c
if isShortColor
sanitizedColor += c
end if
end if
end for
if sanitizedColor.Len() = 7
color = sanitizedColor
colorName = color
end if
else if color.StartsWith("rgb(") or color.StartsWith("rgba(")
' rgb or rgba color code (alpha is ignored)
rgbValues = color.Mid(color.Instr("(") + 1).Replace(")", string.EMPTY).Split(",")
if rgbValues.Count() >= 3
r = rgbValues[0].Trim()
g = rgbValues[1].Trim()
b = rgbValues[2].Trim()
' verify they're numbers
' can't rely on val() because it returns 0 and the text will end up black
if r.Left(1) >= "0" and r.Left(1) <= "9" and g.Left(1) >= "0" and g.Left(1) <= "9" and b.Left(1) >= "0" and b.Left(1) <= "9"
r = val(r, 10)
g = val(g, 10)
b = val(b, 10)
if r >= 0 and r <= 255 and g >= 0 and g <= 255 and b >= 0 and b <= 255
color = "#%02x%02x%02x".Format(r, g, b)
colorName = color
end if
end if
end if
else
' is it the name of a color?
cssColor = chainLookup(m.global.constants.cssColorDict, color)
if isValid(cssColor)
colorName = color
color = cssColor
end if
end if
if isValid(colorName)
tagNumber++
replaceTagWith += `tag${tagNumber}`
newStyle = {
"fontUri": currentStyle.fontUri,
"fontSize": currentStyle.fontSize,
"color": `${color}${m.textOpac}`
}
drawingStyles.AddReplace(replaceTagWith, newStyle)
currentStyle = newStyle
end if
end if
' keep track of invalid tags
if not isValid(replaceTagWith)
openTags.push(replaceTagWith)
end if
else if tagValue = "i"
' opening italic tag
tagNumber++
replaceTagWith = `tag${tagNumber}`
newStyle = {
"fontSize": currentStyle.fontSize,
"color": currentStyle.color
}
if currentStyle.fontUri = m.fonts.bold
newStyle.fontUri = m.fonts.bolditalic
else
newStyle.fontUri = m.fonts.italic
end if
drawingStyles.AddReplace(replaceTagWith, newStyle)
currentStyle = newStyle
else if tagValue = "b"
' opening bold tag
tagNumber++
replaceTagWith = `tag${tagNumber}`
newStyle = {
"fontSize": currentStyle.fontSize,
"color": currentStyle.color
}
if currentStyle.fontUri = m.fonts.italic
newStyle.fontUri = m.fonts.bolditalic
else
newStyle.fontUri = m.fonts.bold
end if
drawingStyles.AddReplace(replaceTagWith, newStyle)
currentStyle = newStyle
else if tagValue.StartsWith("/")
' closing tag
if openTags.Count() > 0
openTag = openTags.pop()
if isValid(openTag)
replaceTagWith = `/${openTag}`
end if
end if
end if
if replaceTagWith.Len() > 0
' MultiStyleLabel doesn't support nested tags, so if this is nested,
' we need to close the previous one before opening a new one,
' and reopen it after
isClosing = replaceTagWith.StartsWith("/")
lastValidTag = invalid
for i = openTags.count() - 1 to 0 step -1
' do we have a valid tag still open?
if isValid(openTags[i])
lastValidTag = openTags[i]
exit for
end if
end for
if isValid(lastValidTag)
if isClosing
styledText += `<${replaceTagWith}><${lastValidTag}>`
currentStyle = drawingStyles[lastValidTag]
else
openTags.push(replaceTagWith)
styledText += `</${lastValidTag}><${replaceTagWith}>`
end if
else
if isClosing
currentStyle = drawingStyles["default"]
else
openTags.push(replaceTagWith)
end if
styledText += `<${replaceTagWith}>`
end if
end if
styledText += part.Mid(tagEnd + 1)
end for
label = CreateObject("roSGNode", "MultiStyleLabel")
label.drawingStyles = drawingStyles
label.text = styledText
label.horizAlign = "center"
lines = newLayoutGroup(label)
rect = newRect(lines, entry.LookupCI("id"), styledText, drawingStyles)
' Use the post-wrap line count (not the original VTT line count),
' since wrapping may have split a line into more visual lines
wrappedLineCount = styledText.split(Chr(10)).count()
finalStyles = prepareStyles(entry["styles"], rect, wrappedLineCount)
rect.translation = finalStyles.translation
adjustForCaptionOverlaps(rect, captions)
' Create window background
windowBg = createWindowBackground(entry.LookupCI("id"), styledText, rect.translation[1], m.windowBgColor, m.lineHeights)
if isValid(windowBg)
captions.push(windowBg)
end if
captions.push(rect)
end if
end for
m.top.currentCaption = captions
end sub
sub adjustForCaptionOverlaps(newCaption, existingCaptions, moveDirection = CaptionMoveDirection.UP)
newCaptionBounds = newCaption.sceneBoundingRect()
for i = existingCaptions.count() - 1 to 0 step -1
comparisonCaption = existingCaptions[i]
' Don't compare the caption to itself
if isStringEqual(newCaption.lookupCI("id"), comparisonCaption.lookupCI("id")) then continue for
comparisonCaptionBounds = comparisonCaption.sceneBoundingRect()
overlapping = isOverlapping(newCaption, comparisonCaption)
if overlapping
' If we're in the top half of the screen, push the other caption down
' otherwise push the other caption up
if newCaptionBounds.y < 540
moveDirection = CaptionMoveDirection.DOWN
end if
if moveDirection = CaptionMoveDirection.DOWN
calculatedVerticalPosition = newCaptionBounds.y + newCaptionBounds.height + 5
else
calculatedVerticalPosition = newCaptionBounds.y - comparisonCaptionBounds.height - 5
end if
if calculatedVerticalPosition < 0 then calculatedVerticalPosition = 0
comparisonCaption.translation = [comparisonCaption.translation[0], calculatedVerticalPosition]
adjustForCaptionOverlaps(comparisonCaption, existingCaptions, moveDirection)
end if
end for
end sub
function isOverlapping(node1, node2) as boolean
node1Bounds = node1.sceneBoundingRect()
node2Bounds = node2.sceneBoundingRect()
return node1Bounds.x < (node2Bounds.x + node2Bounds.width) and node2Bounds.x < (node1Bounds.x + node1Bounds.width) and node1Bounds.y < (node2Bounds.y + node2Bounds.height) and node2Bounds.y < (node1Bounds.y + node1Bounds.height)
end function
function prepareStyles(styles as object, rect, lineCount = 1 as integer) as object
captionStyles = styles
finalStyles = { translation: [960, LOWEST_CAPTION] }
verticalPosition = LOWEST_CAPTION
horizontalPosition = 960 - (rect.BoundingRect().width / 2)
' Use isValid instead of isValidAndNotEmpty to allow empty style objects ({})
' to continue processing. An empty AA is valid — it just has no explicit cues.
if not isValid(captionStyles)
return finalStyles
end if
captionLine = chainLookupReturn(captionStyles, "line", string.EMPTY)
captionPosition = chainLookupReturn(captionStyles, "position", string.EMPTY)
' Line
' If no line cue is present, position the caption so its bottom edge sits at LOWEST_CAPTION.
' Previously this case was never handled, causing captions to render off-screen.
captionLineHeight = (rect.BoundingRect().height / lineCount)
if isStringEqual(captionLine, string.EMPTY)
verticalPosition = LOWEST_CAPTION - rect.BoundingRect().height
else
verticalPosition = processLineCue(captionLine, captionLineHeight, rect.BoundingRect().height)
end if
' Position
if not isStringEqual(captionPosition, string.EMPTY)
horizontalPosition = processPositionCue(captionPosition, rect.BoundingRect().width)
end if
finalStyles.translation = [horizontalPosition, verticalPosition]
return finalStyles
end function
function processPositionCue(captionPosition as string, captionLineWidth as float) as integer
' position % defines the anchor point on screen, offset by half caption width to center on that point
calculatedPosition = 1920 * (val(captionPosition) / 100) - (captionLineWidth / 2)
if calculatedPosition + captionLineWidth > 1920
calculatedPosition = 1920 - captionLineWidth
end if
if calculatedPosition < 0
calculatedPosition = 0
end if
return calculatedPosition
end function
function processLineCue(captionLine as string, captionLineHeight as float, captionTotalHeight as float) as integer
' A percentage
if captionLine.Instr("%") <> -1
' Map the percentage directly to full screen height, matching WebVTT's
' expected behavior. The safe-area clamp below still prevents the
' caption from touching the very top edge.
calculatedPosition = 1080 * (val(captionLine) / 100)
else
' Note: Non-numeric values are treated as 0
lineValue = val(captionLine)
if lineValue = 0 then return SCREEN_PADDING
if lineValue > 0
' A positive line value. Count from the top
calculatedPosition = SCREEN_PADDING + lineValue * captionLineHeight
else
' A negative line value. Count from the bottom
calculatedPosition = LOWEST_CAPTION - Abs(lineValue * captionLineHeight)
end if
end if
if calculatedPosition + captionTotalHeight > LOWEST_CAPTION
return LOWEST_CAPTION - captionTotalHeight
end if
if calculatedPosition < SCREEN_PADDING
return SCREEN_PADDING
end if
return calculatedPosition
end function
' Measures line heights using the regular font variant as a reference.
' MultiStyleLabel reports consistent heights for bold/italic but regular may differ
' due to font metrics. Called once during init() since fonts don't change during playback.
function getLineHeights() as object
' Measure line heights once at init using a temporary Group node
' MultiStyleLabel must be attached to the scene graph to return valid boundingRect()
tempGroup = CreateObject("roSGNode", "Group")
testLabel = CreateObject("roSGNode", "MultiStyleLabel")
testLabel.drawingStyles = {
"default": {
"fontUri": m.fonts.regular,
"fontSize": m.fontSize,
"color": m.textColor
}
}
tempGroup.appendChild(testLabel)
m.top.appendChild(tempGroup)
' Measure single line
testLabel.text = "Ag"
singleLineHeight = Int(testLabel.boundingRect().height)
' Measure two lines to get the actual per-line height including LayoutGroup internal spacing
testLabel.text = "Ag" + chr(10) + "Ag"
twoLinesTotal = Int(testLabel.boundingRect().height)
m.top.removeChild(tempGroup)
return {
single: singleLineHeight,
multi: twoLinesTotal - singleLineHeight
}
end function