From 0e4369b82770f024a94ca39ff077b257f97341d7 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 00:47:39 +0300 Subject: [PATCH 1/7] Add shared UTF-8 source span typesetting --- CHANGES.md | 8 + README.md | 21 ++ src/figdraw/bindings/native_dynlib.json | 7 + src/figdraw/common/fonttypes.nim | 47 ++++- src/figdraw/common/fontutils.nim | 45 ++++ src/figdraw/common/textbackends/harfbuzzy.nim | 194 +++++++++++++++--- src/figdraw/common/textbackends/pixie.nim | 16 ++ src/figdraw/dynlib.nim | 29 ++- tests/tfontutils.nim | 90 ++++++++ 9 files changed, 424 insertions(+), 33 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e5155b52..0fcabaa4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,13 @@ # Changes +## Unreleased + +- Add `typesetSourceSpans` and its measurement variant for styled byte ranges + in a shared `Utf8Runes` source. Layouts retain that source, while Harfbuzzy + shapes normal-case ranges without keeping a string for each styled run. +- Expose sparse rune/byte position lookup on `Utf8Runes` so callers can create + byte ranges without a dense index. + ## 0.41.0 - Move the ownership-safe fixed-ring RChan implementation and its regression diff --git a/README.md b/README.md index 67a6d45c..ea0da054 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,27 @@ let layout = typeset( ) ``` +For text that is already stored as one UTF-8 buffer, `typesetSourceSpans` +accepts styled byte ranges and keeps that source in the returned layout: + +```nim +let source = initUtf8Runes("Hello שלום") +let layout = typesetSourceSpans( + rect(0, 0, 520, 120), + source, + [TextSourceSpan(style: fs(bodyFont, fill(rgba(30, 34, 40, 255))), + byteStart: 0, byteEnd: source.byteLength)], + minContent = false, + wrap = true, +) +``` + +Ranges must cover the source in order and end on UTF-8 rune boundaries. +`typesetSourceSpansForMeasurement` follows the same contract without publishing +glyph images. Harfbuzzy shapes normal-case text with temporary strings for each +shaping group; Pixie currently materializes strings for its span API. Both +backends retain the original `Utf8Runes` in the layout. + Typeface metadata is parsed once when a face is registered and is available through the same API with every text backend: diff --git a/src/figdraw/bindings/native_dynlib.json b/src/figdraw/bindings/native_dynlib.json index a990598d..2b4b2830 100644 --- a/src/figdraw/bindings/native_dynlib.json +++ b/src/figdraw/bindings/native_dynlib.json @@ -134,6 +134,11 @@ {"source": "../common/fonttypes.nim", "name": "len"}, {"source": "../common/fonttypes.nim", "name": "isEmpty"}, {"source": "../common/fonttypes.nim", "name": "stringValue"}, + {"source": "../common/fonttypes.nim", "name": "byteLength"}, + {"source": "../common/fonttypes.nim", "name": "runeCheckpointCount"}, + {"source": "../common/fonttypes.nim", "name": "byteOffsetForRune"}, + {"source": "../common/fonttypes.nim", "name": "runeIndexAtOrBeforeByte"}, + {"source": "../common/fonttypes.nim", "name": "runeIndexAtOrAfterByte"}, {"source": "../common/fonttypes.nim", "name": "utf8RunesFromText"}, {"source": "../common/fonttypes.nim", "name": "initUtf8Runes"}, {"source": "../common/fonttypes.nim", "name": "copyUtf8Runes"}, @@ -158,6 +163,8 @@ {"source": "../common/fontutils.nim", "name": "placeStyledGlyphs"}, {"source": "../common/fontutils.nim", "name": "typesetStyled"}, {"source": "../common/fontutils.nim", "name": "typesetStyledForMeasurement"}, + {"source": "../common/fontutils.nim", "name": "typesetSourceSpans"}, + {"source": "../common/fontutils.nim", "name": "typesetSourceSpansForMeasurement"}, {"source": "../common/fontglyphs.nim", "name": "hash"}, {"source": "../common/fontglyphs.nim", "name": "glyphs"}, {"source": "../common/fontglyphs.nim", "name": "generateGlyph"}, diff --git a/src/figdraw/common/fonttypes.nim b/src/figdraw/common/fonttypes.nim index 7021b728..8971ab3b 100644 --- a/src/figdraw/common/fonttypes.nim +++ b/src/figdraw/common/fonttypes.nim @@ -74,6 +74,11 @@ type font*: FigFont color*: Fill + TextSourceSpan* = object ## A styled byte range in one immutable UTF-8 source. + style*: FontStyle + byteStart*: int + byteEnd*: int + GlyphSourceRange* = object byteStart*: int ## Inclusive source byte index. byteEnd*: int ## Exclusive source byte index. @@ -201,6 +206,16 @@ func stringValue*(runes: Utf8Runes): string = ## Returns the UTF-8 representation. if runes.isNil: "" else: runes.text +func bytes*(runes: Utf8Runes): lent string = + ## Borrows the immutable UTF-8 bytes for the lifetime of `runes`. + runes.text + +func byteLength*(runes: Utf8Runes): int = + if runes.isNil: 0 else: runes.text.len + +func runeCheckpointCount*(runes: Utf8Runes): int = + if runes.isNil: 0 else: runes.runeByteOffsets.len + proc toRunes*(runes: Utf8Runes): seq[Rune] = ## Materializes decoded runes for APIs that specifically require a sequence. runes.stringValue().toRunes() @@ -221,7 +236,7 @@ proc copyUtf8Runes*(runes: Utf8Runes): Utf8Runes = for index, byteOffset in runes.runeByteOffsets: result.runeByteOffsets[index] = byteOffset -proc byteOffsetForRune(runes: Utf8Runes, index: int): int = +proc byteOffsetForRune*(runes: Utf8Runes, index: int): int = if index < 0 or index > runes.len: raise newException(IndexDefect, "UTF-8 rune index out of bounds") if runes.len == 0: @@ -236,6 +251,36 @@ proc byteOffsetForRune(runes: Utf8Runes, index: int): int = inc runeIndex byteOffset +proc runeIndexAtOrBeforeByte*(runes: Utf8Runes, byteOffset: int): int = + ## Returns the rune containing this byte, or the rune at a byte boundary. + if byteOffset < 0 or byteOffset > runes.byteLength: + raise newException(IndexDefect, "UTF-8 byte index out of bounds") + if runes.isNil: + return 0 + var + lo = 0 + hi = runes.runeByteOffsets.len + while lo + 1 < hi: + let mid = (lo + hi) div 2 + if int(runes.runeByteOffsets[mid]) <= byteOffset: + lo = mid + else: + hi = mid + result = lo * Utf8RuneIndexStride + var currentByte = int(runes.runeByteOffsets[lo]) + while currentByte < byteOffset and result < runes.len: + let nextByte = currentByte + runes.text.utf8RuneWidth(currentByte) + if nextByte > byteOffset: + break + currentByte = nextByte + inc result + +proc runeIndexAtOrAfterByte*(runes: Utf8Runes, byteOffset: int): int = + ## Returns the next rune when the byte is inside a UTF-8 sequence. + result = runes.runeIndexAtOrBeforeByte(byteOffset) + if runes.byteOffsetForRune(result) < byteOffset: + inc result + proc byteRangeForRune*(runes: Utf8Runes, index: int): tuple[start, stop: int] = ## Returns the UTF-8 byte range for one rune. if index < 0 or index >= runes.len: diff --git a/src/figdraw/common/fontutils.nim b/src/figdraw/common/fontutils.nim index 2b4fcd88..f24e45d5 100644 --- a/src/figdraw/common/fontutils.nim +++ b/src/figdraw/common/fontutils.nim @@ -65,6 +65,51 @@ proc typesetForMeasurement*( ## Typesets without generating or publishing glyph images. textBackend.typeset(box, uiSpans, hAlign, vAlign, minContent, wrap, false) +proc validateSourceSpans(source: Utf8Runes, spans: openArray[TextSourceSpan]) = + if source.isNil or spans.len == 0: + raise newException(ValueError, "text source and spans are required") + var nextByte = 0 + for span in spans: + if span.byteStart != nextByte or span.byteEnd < span.byteStart or + span.byteEnd > source.byteLength or + source.byteOffsetForRune(source.runeIndexAtOrBeforeByte(span.byteEnd)) != + span.byteEnd: + raise newException(ValueError, "text spans must cover whole UTF-8 runes in order") + nextByte = span.byteEnd + if nextByte != source.byteLength: + raise newException(ValueError, "text spans must cover the whole source") + +proc typesetSourceSpans*( + box: Rect, + source: Utf8Runes, + spans: openArray[TextSourceSpan], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, +): GlyphArrangement = + ## Typesets styled ranges from one shared immutable UTF-8 source. + ## Spans must be ordered and cover every source byte exactly once. + source.validateSourceSpans(spans) + textBackend.typesetSourceSpans( + box, source, spans, hAlign, vAlign, minContent, wrap, true + ) + +proc typesetSourceSpansForMeasurement*( + box: Rect, + source: Utf8Runes, + spans: openArray[TextSourceSpan], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, +): GlyphArrangement = + ## Measures styled ranges without publishing glyph images. + source.validateSourceSpans(spans) + textBackend.typesetSourceSpans( + box, source, spans, hAlign, vAlign, minContent, wrap, false + ) + proc typesetStyled*( box: Rect, uiSpans: openArray[(FontStyle, string)], diff --git a/src/figdraw/common/textbackends/harfbuzzy.nim b/src/figdraw/common/textbackends/harfbuzzy.nim index 057e2c1a..b8f48bd8 100644 --- a/src/figdraw/common/textbackends/harfbuzzy.nim +++ b/src/figdraw/common/textbackends/harfbuzzy.nim @@ -19,6 +19,7 @@ const type DecodedSource = object runes: Utf8Runes + directSource: bool displayByteStarts: seq[int] displayByteEnds: seq[int] sourceByteStarts: seq[int] @@ -29,6 +30,9 @@ type ShapedSpan = object style: FontStyle text: string + source: Utf8Runes + byteStart: int + byteEnd: int byteOffset: int Paragraph = object @@ -188,6 +192,61 @@ proc splitParagraphs(spans: openArray[(FontStyle, string)]): seq[Paragraph] = byteOffset += text.len +proc splitSourceParagraphs( + source: Utf8Runes, spans: openArray[TextSourceSpan] +): seq[Paragraph] = + result.add Paragraph(byteStart: 0) + for span in spans: + if not result[^1].hasLineStyle: + result[^1].lineStyle = span.style + result[^1].hasLineStyle = true + var + partStart = span.byteStart + byteIndex = span.byteStart + while byteIndex < span.byteEnd: + let ch = source.bytes[byteIndex] + if ch == '\n' or ch == '\r': + if byteIndex > partStart: + result[^1].spans.add ShapedSpan( + style: span.style, + source: source, + byteStart: partStart, + byteEnd: byteIndex, + byteOffset: partStart, + ) + var breakLen = 1 + if ch == '\r' and byteIndex + 1 < span.byteEnd and + source.bytes[byteIndex + 1] == '\n': + breakLen = 2 + byteIndex += breakLen + partStart = byteIndex + result.add Paragraph( + byteStart: byteIndex, lineStyle: span.style, hasLineStyle: true + ) + else: + inc byteIndex + if partStart < span.byteEnd: + result[^1].spans.add ShapedSpan( + style: span.style, + source: source, + byteStart: partStart, + byteEnd: span.byteEnd, + byteOffset: partStart, + ) + +func spanByteLength(span: ShapedSpan): int = + if span.source.isNil: + span.text.len + else: + span.byteEnd - span.byteStart + +proc appendSpanText(output: var string, span: ShapedSpan) = + if span.source.isNil: + output.add span.text + else: + for byteIndex in span.byteStart ..< span.byteEnd: + output.add span.source.bytes[byteIndex] + proc shapingGroups(paragraph: Paragraph): seq[ShapedGroup] = for span in paragraph.spans: if result.len > 0 and result[^1].style.font == span.style.font: @@ -196,8 +255,11 @@ proc shapingGroups(paragraph: Paragraph): seq[ShapedGroup] = result.add ShapedGroup(style: span.style, spans: @[span]) proc text(group: ShapedGroup): string = + if group.spans.len == 1 and not group.spans[0].source.isNil: + let span = group.spans[0] + return span.source.bytes[span.byteStart ..< span.byteEnd] for span in group.spans: - result.add span.text + result.appendSpanText(span) proc byteOffset(group: ShapedGroup): int = if group.spans.len > 0: @@ -205,13 +267,20 @@ proc byteOffset(group: ShapedGroup): int = proc fillForByte(group: ShapedGroup, byteOffset: int): Fill = for span in group.spans: - if byteOffset >= span.byteOffset and byteOffset < span.byteOffset + span.text.len: + if byteOffset >= span.byteOffset and + byteOffset < span.byteOffset + span.spanByteLength: return span.style.color result = group.style.color proc runeRangeForBytes( decoded: DecodedSource, byteStart, byteEnd: int ): GlyphSourceRange = + if decoded.directSource: + result.byteStart = byteStart + result.byteEnd = byteEnd + result.runeStart = decoded.runes.runeIndexAtOrBeforeByte(byteStart) + result.runeEnd = decoded.runes.runeIndexAtOrAfterByte(byteEnd) + return result.byteStart = high(int) result.byteEnd = 0 result.runeStart = decoded.runes.len @@ -231,6 +300,12 @@ proc runeRangeForBytes( proc sourceInsertionForDisplayByte( decoded: DecodedSource, byteOffset: int ): GlyphSourceRange = + if decoded.directSource: + result.byteStart = byteOffset + result.byteEnd = byteOffset + result.runeStart = decoded.runes.runeIndexAtOrBeforeByte(byteOffset) + result.runeEnd = result.runeStart + return for i in 0 ..< decoded.displayByteStarts.len: if decoded.displayByteStarts[i] >= byteOffset: result.byteStart = decoded.sourceByteStarts[i] @@ -759,12 +834,12 @@ proc appendShapedGroup( arrangement: var GlyphArrangement, decoded: DecodedSource, group: ShapedGroup, + groupText: string, pen: var Vec2, safeBreakAfter: var seq[bool], glyphText: var string, ) = let - groupText = group.text() groupByteOffset = group.byteOffset() fontInfos = initHarfbuzzFontInfos(group.style.font) paragraph = fontInfos.shapeParagraph(group.style.font, groupText) @@ -826,36 +901,19 @@ proc appendShapedGroup( pen += advance -proc typeset*( +proc typesetPrepared( box: Rect, - uiSpans: openArray[(FontStyle, string)], - hAlign = FontHorizontal.Left, - vAlign = FontVertical.Top, - minContent: bool, - wrap: bool, - rasterize: bool, + decoded: DecodedSource, + paragraphs: seq[Paragraph], + fontSizes: openArray[float], + contentHash: Hash, + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, ): GlyphArrangement = - ## Typesets with Harfbuzzy and converts shaped glyph ids into FigDraw data. threadEffects: AppMainThread - - var shapedSpans = newSeqOfCap[(FontStyle, string)](uiSpans.len) - for (style, text) in uiSpans: - shapedSpans.add((style, text.applyFontCase(style.font.fontCase))) - - let decoded = decodeSource(uiSpans, shapedSpans) - let fontSizes = shapedSpans.mapIt(it[0].font.size.float) - - result = GlyphArrangement( - contentHash: block: - var h = Hash(0) - h = h !& getContentHash(box.wh, uiSpans, hAlign, vAlign, minContent, wrap) - h = h !& hash(figUiScale()) - !$h, - sourceRunes: decoded.runes, - ) - - let paragraphs = splitParagraphs(shapedSpans) + result = GlyphArrangement(contentHash: contentHash, sourceRunes: decoded.runes) var pen = vec2(0, 0) glyphText = newStringOfCap(decoded.runes.len) @@ -867,13 +925,16 @@ proc typeset*( for sourceGroup in paragraph.shapingGroups(): var group = sourceGroup if group.spans.len > 0: - group.style.font = group.style.font.resolveFallbacks(group.text()) + let groupText = group.text() + group.style.font = group.style.font.resolveFallbacks(groupText) resolvedFonts.add group.style.font let groupGlyphFont = glyphFontFor(group.style.font).glyph let baseline = groupGlyphFont.descentAdj if result.arrangedGlyphs.len == 0: pen.y = baseline - result.appendShapedGroup(decoded, group, pen, safeBreakAfter, glyphText) + result.appendShapedGroup( + decoded, group, groupText, pen, safeBreakAfter, glyphText + ) if result.arrangedGlyphs.len == glyphStart and paragraphs.len > 1 and paragraph.hasLineStyle: @@ -909,3 +970,74 @@ proc typeset*( when figdrawTextBackend == "hybrid": if rasterize: result.generateGlyphImages() + +proc typeset*( + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, + rasterize: bool, +): GlyphArrangement = + ## Typesets with Harfbuzzy and converts shaped glyph ids into FigDraw data. + threadEffects: + AppMainThread + + var shapedSpans = newSeqOfCap[(FontStyle, string)](uiSpans.len) + for (style, text) in uiSpans: + shapedSpans.add((style, text.applyFontCase(style.font.fontCase))) + let + decoded = decodeSource(uiSpans, shapedSpans) + paragraphs = splitParagraphs(shapedSpans) + fontSizes = shapedSpans.mapIt(it[0].font.size.float) + contentHash = block: + var h = Hash(0) + h = h !& getContentHash(box.wh, uiSpans, hAlign, vAlign, minContent, wrap) + h = h !& hash(figUiScale()) + !$h + typesetPrepared( + box, decoded, paragraphs, fontSizes, contentHash, hAlign, vAlign, minContent, wrap, + rasterize, + ) + +proc typesetSourceSpans*( + box: Rect, + source: Utf8Runes, + spans: openArray[TextSourceSpan], + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, +): GlyphArrangement = + ## Shapes normal-case text directly from shared source ranges. + threadEffects: + AppMainThread + + for span in spans: + if span.style.font.fontCase != NormalCase: + var owned = newSeqOfCap[(FontStyle, string)](spans.len) + for item in spans: + owned.add((item.style, source.bytes[item.byteStart ..< item.byteEnd])) + result = typeset(box, owned, hAlign, vAlign, minContent, wrap, rasterize) + result.sourceRunes = source + return + + let + decoded = DecodedSource(runes: source, directSource: true) + paragraphs = splitSourceParagraphs(source, spans) + fontSizes = spans.mapIt(it.style.font.size.float) + contentHash = block: + var h = Hash(0) + h = h !& hash(box.wh) + h = h !& hash(source.bytes) + h = h !& hash(spans) + h = h !& hash(hAlign) + h = h !& hash(vAlign) + h = h !& hash(minContent) + h = h !& hash(wrap) + h = h !& hash(figUiScale()) + !$h + typesetPrepared( + box, decoded, paragraphs, fontSizes, contentHash, hAlign, vAlign, minContent, wrap, + rasterize, + ) diff --git a/src/figdraw/common/textbackends/pixie.nim b/src/figdraw/common/textbackends/pixie.nim index 2c665392..9650d6e5 100644 --- a/src/figdraw/common/textbackends/pixie.nim +++ b/src/figdraw/common/textbackends/pixie.nim @@ -103,6 +103,7 @@ proc typeset*( result = convertArrangement( minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap ) + let contentAdjusted = result.calcMinMaxContent() result.minSize = contentAdjusted.minSize result.maxSize = contentAdjusted.maxSize @@ -122,3 +123,18 @@ proc typeset*( result.addFontSizePadding(sz) if rasterize: result.generateGlyphImages() + +proc typesetSourceSpans*( + box: Rect, + source: Utf8Runes, + sourceSpans: openArray[TextSourceSpan], + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, +): GlyphArrangement = + ## Pixie requires owned span strings. Share the retained source in the result. + var spans = newSeqOfCap[(FontStyle, string)](sourceSpans.len) + for span in sourceSpans: + spans.add((span.style, source.bytes[span.byteStart ..< span.byteEnd])) + result = typeset(box, spans, hAlign, vAlign, minContent, wrap, rasterize) + result.sourceRunes = source diff --git a/src/figdraw/dynlib.nim b/src/figdraw/dynlib.nim index 511d7fc0..f927f830 100644 --- a/src/figdraw/dynlib.nim +++ b/src/figdraw/dynlib.nim @@ -51,7 +51,8 @@ export figdraw_native_abi except backendSupportsDedicatedRenderThread, supportsDedicatedRenderThread, useDedicatedRenderThread, beginFrame, renderFrame, endFrame, configureUiScale, refreshUiScale, presentNow, siwinBackendName, siwinDisplayServerName, getFigFont, - getTypefaceSource, getTypefaceInfo, typeset, typesetForMeasurement, generateGlyph, + getTypefaceSource, getTypefaceInfo, typeset, typesetForMeasurement, + typesetSourceSpans, typesetSourceSpansForMeasurement, generateGlyph, generateGlyphImages, hash, getContentHash, findSystemTypeface, findSystemFontFile, systemDefaultFontNames, FillKind, FillGradientAxis @@ -248,6 +249,32 @@ proc typesetForMeasurement*( box, spans, hAlign, vAlign, minContent, wrap ) +proc typesetSourceSpans*( + box: bumpy.Rect, + source: Utf8Runes, + spans: openArray[TextSourceSpan], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent = false, + wrap = true, +): GlyphArrangement {.inline.} = + figdraw_native_abi.typesetSourceSpans( + box, source, spans, hAlign, vAlign, minContent, wrap + ) + +proc typesetSourceSpansForMeasurement*( + box: bumpy.Rect, + source: Utf8Runes, + spans: openArray[TextSourceSpan], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent = false, + wrap = true, +): GlyphArrangement {.inline.} = + figdraw_native_abi.typesetSourceSpansForMeasurement( + box, source, spans, hAlign, vAlign, minContent, wrap + ) + proc typeset*[T: FigFont | FontRef]( box: bumpy.Rect, font: T, diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 71c7c8cc..0643035e 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -164,6 +164,9 @@ suite "fontutils": check arrangement.runes == sourceRunes check materialized == sourceRunes check legacyRuneCount(runes) == runes.len + var nilRunes: Utf8Runes + check nilRunes.runeIndexAtOrBeforeByte(0) == 0 + check nilRunes.runeIndexAtOrAfterByte(0) == 0 test "UTF-8 glyph builder preserves source byte ranges": let @@ -437,6 +440,93 @@ suite "fontutils": check font.underline check font.strikethrough + test "source spans retain one UTF-8 source across styles and newlines": + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + uiFont = FigFont(typefaceId: typefaceId, size: 18.0'f32) + source = initUtf8Runes("éA\n中") + firstStyle = fs(uiFont, fill(rgba(255, 0, 0, 255))) + secondStyle = fs(uiFont, fill(rgba(0, 0, 255, 255))) + spans = [ + TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 2), + TextSourceSpan(style: secondStyle, byteStart: 2, byteEnd: 7), + ] + box = rect(0, 0, 240, 90) + borrowed = + typesetSourceSpans(box, source, spans, minContent = false, wrap = false) + owned = typeset( + box, + [(firstStyle, "é"), (secondStyle, "A\n中")], + minContent = false, + wrap = false, + ) + check cast[pointer](borrowed.sourceRunes) == cast[pointer](source) + check borrowed.sourceRunes == owned.sourceRunes + check borrowed.arrangedGlyphs.len == owned.arrangedGlyphs.len + check borrowed.lines.len == owned.lines.len + for index, glyph in borrowed.arrangedGlyphs: + check glyph.source == owned.arrangedGlyphs[index].source + check source.byteOffsetForRune(3) == 4 + check source.runeIndexAtOrBeforeByte(6) == 3 + check source.runeIndexAtOrAfterByte(6) == 4 + + expect ValueError: + discard typesetSourceSpans( + box, + source, + [TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 3)], + minContent = false, + wrap = false, + ) + + expect ValueError: + discard typesetSourceSpans( + box, + source, + [ + TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 1), + TextSourceSpan(style: secondStyle, byteStart: 1, byteEnd: 7), + ], + minContent = false, + wrap = false, + ) + + test "source spans preserve empty and case-transformed layouts": + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + plain = FigFont(typefaceId: typefaceId, size: 18.0'f32) + uppercase = FigFont(typefaceId: typefaceId, size: 18.0'f32, fontCase: UpperCase) + emptySource = initUtf8Runes("") + caseSource = initUtf8Runes("abé") + plainStyle = fs(plain, fill(rgba(255, 0, 0, 255))) + caseStyle = fs(uppercase, fill(rgba(255, 0, 0, 255))) + box = rect(0, 0, 240, 90) + empty = typesetSourceSpansForMeasurement( + box, + emptySource, + [TextSourceSpan(style: plainStyle, byteStart: 0, byteEnd: 0)], + minContent = false, + wrap = false, + ) + transformed = typesetSourceSpansForMeasurement( + box, + caseSource, + [TextSourceSpan(style: caseStyle, byteStart: 0, byteEnd: 4)], + minContent = false, + wrap = false, + ) + owned = typesetForMeasurement( + box, [(caseStyle, "abé")], minContent = false, wrap = false + ) + check cast[pointer](empty.sourceRunes) == cast[pointer](emptySource) + check empty.sourceRunes.len == 0 + check cast[pointer](transformed.sourceRunes) == cast[pointer](caseSource) + check transformed.arrangedGlyphs.len == owned.arrangedGlyphs.len + for index, glyph in transformed.arrangedGlyphs: + check glyph.source == owned.arrangedGlyphs[index].source + test "getTypesetImpl returns consistent hashes and generated glyph images": let fontData = readFile(figDataDir() / "Ubuntu.ttf") let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) From e74204129b964f7641151b62263938c5c26afd56 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 01:08:51 +0300 Subject: [PATCH 2/7] Use compact style tables and preserve Pixie source ranges --- CHANGES.md | 8 +- README.md | 20 +++- src/figdraw/common/fontglyphs.nim | 61 +++++++++- src/figdraw/common/fonttypes.nim | 13 ++- src/figdraw/common/fontutils.nim | 52 +++++---- src/figdraw/common/textbackends/harfbuzzy.nim | 56 ++++++---- src/figdraw/common/textbackends/pixie.nim | 33 ++++-- src/figdraw/dynlib.nim | 10 +- tests/tfontutils.nim | 105 +++++++++++++++--- 9 files changed, 265 insertions(+), 93 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 0fcabaa4..afbd8926 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,9 +2,11 @@ ## Unreleased -- Add `typesetSourceSpans` and its measurement variant for styled byte ranges - in a shared `Utf8Runes` source. Layouts retain that source, while Harfbuzzy - shapes normal-case ranges without keeping a string for each styled run. +- Add `typesetSourceSpans` and its measurement variant for compact + `StyledTextRun` ranges and a separate `FontStyle` table. Layouts retain the + shared `Utf8Runes` source. Pixie reuses it for display text when unchanged + and maps converted or filtered glyphs back to original source byte ranges. + Harfbuzzy shapes normal-case ranges without keeping a string per styled run. - Expose sparse rune/byte position lookup on `Utf8Runes` so callers can create byte ranges without a dense index. diff --git a/README.md b/README.md index ea0da054..491344e1 100644 --- a/README.md +++ b/README.md @@ -790,22 +790,32 @@ For text that is already stored as one UTF-8 buffer, `typesetSourceSpans` accepts styled byte ranges and keeps that source in the returned layout: ```nim -let source = initUtf8Runes("Hello שלום") +let + source = initUtf8Runes("Hello שלום") + styles = [fs(bodyFont, fill(rgba(30, 34, 40, 255)))] + runs = [StyledTextRun( + byteStart: 0, byteEnd: source.byteLength.uint32, + runeStart: 0, runeEnd: source.len.uint32, + styleId: TextStyleId(0), + )] let layout = typesetSourceSpans( rect(0, 0, 520, 120), source, - [TextSourceSpan(style: fs(bodyFont, fill(rgba(30, 34, 40, 255))), - byteStart: 0, byteEnd: source.byteLength)], + runs, + styles, minContent = false, wrap = true, ) ``` -Ranges must cover the source in order and end on UTF-8 rune boundaries. +Ranges must cover the source in order, match their rune indexes, and refer to +valid entries in the style table. `typesetSourceSpansForMeasurement` follows the same contract without publishing glyph images. Harfbuzzy shapes normal-case text with temporary strings for each shaping group; Pixie currently materializes strings for its span API. Both -backends retain the original `Utf8Runes` in the layout. +backends retain the original `Utf8Runes` as `sourceRunes`. Pixie also shares it +as `runes` when display runes match the source. Case conversion and filtered +controls require separate display runes, with glyph ranges mapped to source. Typeface metadata is parsed once when a face is registered and is available through the same API with every text backend: diff --git a/src/figdraw/common/fontglyphs.nim b/src/figdraw/common/fontglyphs.nim index 305635f0..5d06b2ac 100644 --- a/src/figdraw/common/fontglyphs.nim +++ b/src/figdraw/common/fontglyphs.nim @@ -247,6 +247,46 @@ proc generateGlyphImages*(arrangement: GlyphArrangement, lcdFiltering = false) = for glyph in arrangement.glyphs(): glyph.generateGlyph(lcdFiltering = lcdFiltering) +proc matchesDisplayRunes(source: Utf8Runes, display: openArray[Rune]): bool = + if source.isNil or source.len != display.len: + return false + var index = 0 + for rune in source: + if rune != display[index]: + return false + inc index + true + +proc mapPixieSourceRanges( + glyphs: var seq[ArrangedGlyph], source: Utf8Runes, runs: openArray[StyledTextRun] +) = + ## Pixie keeps one display rune per source rune except filtered controls. + ## Its font-case conversion can change byte widths, so use source positions. + let bytes = source.bytes + var glyphIndex = 0 + for run in runs: + var + byteIndex = int(run.byteStart) + runeIndex = int(run.runeStart) + while byteIndex < int(run.byteEnd): + let + rune = bytes.runeAt(byteIndex) + nextByte = min(byteIndex + max(1, bytes.runeLenAt(byteIndex)), int(run.byteEnd)) + if rune.uint32 >= 32 or rune == Rune(10): + if glyphIndex >= glyphs.len: + raise newException(ValueError, "Pixie display runes differ from source runs") + glyphs[glyphIndex].source = GlyphSourceRange( + byteStart: byteIndex, + byteEnd: nextByte, + runeStart: runeIndex, + runeEnd: runeIndex + 1, + ) + inc glyphIndex + byteIndex = nextByte + inc runeIndex + if glyphIndex != glyphs.len: + raise newException(ValueError, "Pixie display runes differ from source runs") + proc convertArrangement*( arrangement: Arrangement, box: Rect, @@ -256,6 +296,8 @@ proc convertArrangement*( gfonts: seq[GlyphFont], minContent = false, wrap = false, + sourceRunes: Utf8Runes = nil, + sourceRuns: openArray[StyledTextRun] = [], ): GlyphArrangement = var lines = newSeqOfCap[Slice[int]](arrangement.lines.len()) @@ -268,7 +310,16 @@ proc convertArrangement*( for rect in arrangement.selectionRects: selectionRects.add rect - let storedRunes = initArrangementRunes(arrangement.runes) + let displayRunes = + if sourceRunes.matchesDisplayRunes(arrangement.runes): + sourceRunes + else: + initArrangementRunes(arrangement.runes) + var arrangedGlyphs = buildArrangedGlyphs( + displayRunes, arrangement.positions, selectionRects, spanSlices, gfonts + ) + if not sourceRunes.isNil: + arrangedGlyphs.mapPixieSourceRanges(sourceRunes, sourceRuns) result = GlyphArrangement( contentHash: block: @@ -280,11 +331,9 @@ proc convertArrangement*( spans: spanSlices, fonts: gfonts, spanColors: uiSpans.mapIt(it[0].color), - sourceRunes: storedRunes, - arrangedGlyphs: buildArrangedGlyphs( - storedRunes, arrangement.positions, selectionRects, spanSlices, gfonts - ), - runes: storedRunes, + sourceRunes: (if sourceRunes.isNil: displayRunes else: sourceRunes), + arrangedGlyphs: arrangedGlyphs, + runes: displayRunes, positions: arrangement.positions, selectionRects: selectionRects, ) diff --git a/src/figdraw/common/fonttypes.nim b/src/figdraw/common/fonttypes.nim index 8971ab3b..6bdc367b 100644 --- a/src/figdraw/common/fonttypes.nim +++ b/src/figdraw/common/fonttypes.nim @@ -74,10 +74,15 @@ type font*: FigFont color*: Fill - TextSourceSpan* = object ## A styled byte range in one immutable UTF-8 source. - style*: FontStyle - byteStart*: int - byteEnd*: int + TextStyleId* = distinct uint32 + + StyledTextRun* = object + ## Source range and compact style-table index for one borrowed text run. + byteStart*: uint32 + byteEnd*: uint32 + runeStart*: uint32 + runeEnd*: uint32 + styleId*: TextStyleId GlyphSourceRange* = object byteStart*: int ## Inclusive source byte index. diff --git a/src/figdraw/common/fontutils.nim b/src/figdraw/common/fontutils.nim index f24e45d5..a12fff9d 100644 --- a/src/figdraw/common/fontutils.nim +++ b/src/figdraw/common/fontutils.nim @@ -65,49 +65,61 @@ proc typesetForMeasurement*( ## Typesets without generating or publishing glyph images. textBackend.typeset(box, uiSpans, hAlign, vAlign, minContent, wrap, false) -proc validateSourceSpans(source: Utf8Runes, spans: openArray[TextSourceSpan]) = - if source.isNil or spans.len == 0: - raise newException(ValueError, "text source and spans are required") +proc validateSourceSpans( + source: Utf8Runes, runs: openArray[StyledTextRun], styles: openArray[FontStyle] +) = + if source.isNil or runs.len == 0: + raise newException(ValueError, "text source and runs are required") var nextByte = 0 - for span in spans: - if span.byteStart != nextByte or span.byteEnd < span.byteStart or - span.byteEnd > source.byteLength or - source.byteOffsetForRune(source.runeIndexAtOrBeforeByte(span.byteEnd)) != - span.byteEnd: - raise newException(ValueError, "text spans must cover whole UTF-8 runes in order") - nextByte = span.byteEnd - if nextByte != source.byteLength: - raise newException(ValueError, "text spans must cover the whole source") + var nextRune = 0 + for run in runs: + let + byteStart = int(run.byteStart) + byteEnd = int(run.byteEnd) + runeStart = int(run.runeStart) + runeEnd = int(run.runeEnd) + styleIndex = int(uint32(run.styleId)) + if byteStart != nextByte or runeStart != nextRune or byteEnd < byteStart or + runeEnd < runeStart or byteEnd > source.byteLength or runeEnd > source.len or + styleIndex >= styles.len or source.byteOffsetForRune(runeStart) != byteStart or + source.byteOffsetForRune(runeEnd) != byteEnd: + raise newException(ValueError, "text runs must cover whole UTF-8 runes in order") + nextByte = byteEnd + nextRune = runeEnd + if nextByte != source.byteLength or nextRune != source.len: + raise newException(ValueError, "text runs must cover the whole source") proc typesetSourceSpans*( box: Rect, source: Utf8Runes, - spans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign = FontHorizontal.Left, vAlign = FontVertical.Top, minContent: bool, wrap: bool, ): GlyphArrangement = - ## Typesets styled ranges from one shared immutable UTF-8 source. - ## Spans must be ordered and cover every source byte exactly once. - source.validateSourceSpans(spans) + ## Typesets compact styled ranges from one shared immutable UTF-8 source. + ## Runs must be ordered and cover every source rune exactly once. + source.validateSourceSpans(runs, styles) textBackend.typesetSourceSpans( - box, source, spans, hAlign, vAlign, minContent, wrap, true + box, source, runs, styles, hAlign, vAlign, minContent, wrap, true ) proc typesetSourceSpansForMeasurement*( box: Rect, source: Utf8Runes, - spans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign = FontHorizontal.Left, vAlign = FontVertical.Top, minContent: bool, wrap: bool, ): GlyphArrangement = ## Measures styled ranges without publishing glyph images. - source.validateSourceSpans(spans) + source.validateSourceSpans(runs, styles) textBackend.typesetSourceSpans( - box, source, spans, hAlign, vAlign, minContent, wrap, false + box, source, runs, styles, hAlign, vAlign, minContent, wrap, false ) proc typesetStyled*( diff --git a/src/figdraw/common/textbackends/harfbuzzy.nim b/src/figdraw/common/textbackends/harfbuzzy.nim index b8f48bd8..4569f74c 100644 --- a/src/figdraw/common/textbackends/harfbuzzy.nim +++ b/src/figdraw/common/textbackends/harfbuzzy.nim @@ -193,44 +193,45 @@ proc splitParagraphs(spans: openArray[(FontStyle, string)]): seq[Paragraph] = byteOffset += text.len proc splitSourceParagraphs( - source: Utf8Runes, spans: openArray[TextSourceSpan] + source: Utf8Runes, runs: openArray[StyledTextRun], styles: openArray[FontStyle] ): seq[Paragraph] = result.add Paragraph(byteStart: 0) - for span in spans: + for run in runs: + let + style = styles[int(uint32(run.styleId))] + runStart = int(run.byteStart) + runEnd = int(run.byteEnd) if not result[^1].hasLineStyle: - result[^1].lineStyle = span.style + result[^1].lineStyle = style result[^1].hasLineStyle = true var - partStart = span.byteStart - byteIndex = span.byteStart - while byteIndex < span.byteEnd: + partStart = runStart + byteIndex = runStart + while byteIndex < runEnd: let ch = source.bytes[byteIndex] if ch == '\n' or ch == '\r': if byteIndex > partStart: result[^1].spans.add ShapedSpan( - style: span.style, + style: style, source: source, byteStart: partStart, byteEnd: byteIndex, byteOffset: partStart, ) var breakLen = 1 - if ch == '\r' and byteIndex + 1 < span.byteEnd and - source.bytes[byteIndex + 1] == '\n': + if ch == '\r' and byteIndex + 1 < runEnd and source.bytes[byteIndex + 1] == '\n': breakLen = 2 byteIndex += breakLen partStart = byteIndex - result.add Paragraph( - byteStart: byteIndex, lineStyle: span.style, hasLineStyle: true - ) + result.add Paragraph(byteStart: byteIndex, lineStyle: style, hasLineStyle: true) else: inc byteIndex - if partStart < span.byteEnd: + if partStart < runEnd: result[^1].spans.add ShapedSpan( - style: span.style, + style: style, source: source, byteStart: partStart, - byteEnd: span.byteEnd, + byteEnd: runEnd, byteOffset: partStart, ) @@ -1004,7 +1005,8 @@ proc typeset*( proc typesetSourceSpans*( box: Rect, source: Utf8Runes, - spans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign: FontHorizontal, vAlign: FontVertical, minContent, wrap, rasterize: bool, @@ -1013,24 +1015,30 @@ proc typesetSourceSpans*( threadEffects: AppMainThread - for span in spans: - if span.style.font.fontCase != NormalCase: - var owned = newSeqOfCap[(FontStyle, string)](spans.len) - for item in spans: - owned.add((item.style, source.bytes[item.byteStart ..< item.byteEnd])) + for candidate in runs: + if styles[int(uint32(candidate.styleId))].font.fontCase != NormalCase: + var owned = newSeqOfCap[(FontStyle, string)](runs.len) + for run in runs: + owned.add( + ( + styles[int(uint32(run.styleId))], + source.bytes[int(run.byteStart) ..< int(run.byteEnd)], + ) + ) result = typeset(box, owned, hAlign, vAlign, minContent, wrap, rasterize) result.sourceRunes = source return let decoded = DecodedSource(runes: source, directSource: true) - paragraphs = splitSourceParagraphs(source, spans) - fontSizes = spans.mapIt(it.style.font.size.float) + paragraphs = splitSourceParagraphs(source, runs, styles) + fontSizes = runs.mapIt(styles[int(uint32(it.styleId))].font.size.float) contentHash = block: var h = Hash(0) h = h !& hash(box.wh) h = h !& hash(source.bytes) - h = h !& hash(spans) + h = h !& hash(runs) + h = h !& hash(styles) h = h !& hash(hAlign) h = h !& hash(vAlign) h = h !& hash(minContent) diff --git a/src/figdraw/common/textbackends/pixie.nim b/src/figdraw/common/textbackends/pixie.nim index 9650d6e5..e49365e2 100644 --- a/src/figdraw/common/textbackends/pixie.nim +++ b/src/figdraw/common/textbackends/pixie.nim @@ -18,6 +18,8 @@ proc typeset*( minContent: bool, wrap: bool, rasterize: bool, + sourceRunes: Utf8Runes = nil, + sourceRuns: openArray[StyledTextRun] = [], ): GlyphArrangement = ## Typesets with Pixie, then converts to FigDraw's backend-neutral data. threadEffects: @@ -70,7 +72,8 @@ proc typeset*( let arrangement = pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) result = convertArrangement( - arrangement, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap + arrangement, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, + sourceRuns, ) let content = result.calcMinMaxContent() @@ -84,8 +87,10 @@ proc typeset*( let arr = pixie.typeset( spans, bounds = wh, hAlign = LeftAlign, vAlign = TopAlign, wrap = wrap ) - let minResult = - convertArrangement(arr, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap) + let minResult = convertArrangement( + arr, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, + sourceRuns, + ) let minContentSize = minResult.calcMinMaxContent() trace "minContent:", @@ -101,7 +106,8 @@ proc typeset*( let minAdjusted = pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) result = convertArrangement( - minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap + minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, + sourceRunes, sourceRuns, ) let contentAdjusted = result.calcMinMaxContent() @@ -127,14 +133,19 @@ proc typeset*( proc typesetSourceSpans*( box: Rect, source: Utf8Runes, - sourceSpans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign: FontHorizontal, vAlign: FontVertical, minContent, wrap, rasterize: bool, ): GlyphArrangement = - ## Pixie requires owned span strings. Share the retained source in the result. - var spans = newSeqOfCap[(FontStyle, string)](sourceSpans.len) - for span in sourceSpans: - spans.add((span.style, source.bytes[span.byteStart ..< span.byteEnd])) - result = typeset(box, spans, hAlign, vAlign, minContent, wrap, rasterize) - result.sourceRunes = source + ## Pixie requires owned span strings, but layout retains the source directly. + var spans = newSeqOfCap[(FontStyle, string)](runs.len) + for run in runs: + spans.add( + ( + styles[int(uint32(run.styleId))], + source.bytes[int(run.byteStart) ..< int(run.byteEnd)], + ) + ) + typeset(box, spans, hAlign, vAlign, minContent, wrap, rasterize, source, runs) diff --git a/src/figdraw/dynlib.nim b/src/figdraw/dynlib.nim index f927f830..c977ecc6 100644 --- a/src/figdraw/dynlib.nim +++ b/src/figdraw/dynlib.nim @@ -252,27 +252,29 @@ proc typesetForMeasurement*( proc typesetSourceSpans*( box: bumpy.Rect, source: Utf8Runes, - spans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign = FontHorizontal.Left, vAlign = FontVertical.Top, minContent = false, wrap = true, ): GlyphArrangement {.inline.} = figdraw_native_abi.typesetSourceSpans( - box, source, spans, hAlign, vAlign, minContent, wrap + box, source, runs, styles, hAlign, vAlign, minContent, wrap ) proc typesetSourceSpansForMeasurement*( box: bumpy.Rect, source: Utf8Runes, - spans: openArray[TextSourceSpan], + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], hAlign = FontHorizontal.Left, vAlign = FontVertical.Top, minContent = false, wrap = true, ): GlyphArrangement {.inline.} = figdraw_native_abi.typesetSourceSpansForMeasurement( - box, source, spans, hAlign, vAlign, minContent, wrap + box, source, runs, styles, hAlign, vAlign, minContent, wrap ) proc typeset*[T: FigFont | FontRef]( diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 0643035e..1209c7a8 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -440,7 +440,8 @@ suite "fontutils": check font.underline check font.strikethrough - test "source spans retain one UTF-8 source across styles and newlines": + test "source runs retain one UTF-8 source across styles and newlines": + check sizeof(StyledTextRun) == 20 let fontData = readFile(figDataDir() / "Ubuntu.ttf") typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) @@ -448,13 +449,18 @@ suite "fontutils": source = initUtf8Runes("éA\n中") firstStyle = fs(uiFont, fill(rgba(255, 0, 0, 255))) secondStyle = fs(uiFont, fill(rgba(0, 0, 255, 255))) - spans = [ - TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 2), - TextSourceSpan(style: secondStyle, byteStart: 2, byteEnd: 7), + styles = [firstStyle, secondStyle] + runs = [ + StyledTextRun( + byteStart: 0, byteEnd: 2, runeStart: 0, runeEnd: 1, styleId: TextStyleId(0) + ), + StyledTextRun( + byteStart: 2, byteEnd: 7, runeStart: 1, runeEnd: 4, styleId: TextStyleId(1) + ), ] box = rect(0, 0, 240, 90) borrowed = - typesetSourceSpans(box, source, spans, minContent = false, wrap = false) + typesetSourceSpans(box, source, runs, styles, minContent = false, wrap = false) owned = typeset( box, [(firstStyle, "é"), (secondStyle, "A\n中")], @@ -462,6 +468,8 @@ suite "fontutils": wrap = false, ) check cast[pointer](borrowed.sourceRunes) == cast[pointer](source) + when figdrawTextBackend == "pixie": + check cast[pointer](borrowed.runes) == cast[pointer](source) check borrowed.sourceRunes == owned.sourceRunes check borrowed.arrangedGlyphs.len == owned.arrangedGlyphs.len check borrowed.lines.len == owned.lines.len @@ -475,7 +483,12 @@ suite "fontutils": discard typesetSourceSpans( box, source, - [TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 3)], + [ + StyledTextRun( + byteStart: 0, byteEnd: 3, runeStart: 0, runeEnd: 2, styleId: TextStyleId(0) + ) + ], + styles, minContent = false, wrap = false, ) @@ -485,48 +498,108 @@ suite "fontutils": box, source, [ - TextSourceSpan(style: firstStyle, byteStart: 0, byteEnd: 1), - TextSourceSpan(style: secondStyle, byteStart: 1, byteEnd: 7), + StyledTextRun( + byteStart: 0, byteEnd: 7, runeStart: 0, runeEnd: 4, styleId: TextStyleId(2) + ) ], + styles, minContent = false, wrap = false, ) - test "source spans preserve empty and case-transformed layouts": + expect ValueError: + discard typesetSourceSpans( + box, + source, + [ + StyledTextRun( + byteStart: 0, byteEnd: 1, runeStart: 0, runeEnd: 1, styleId: TextStyleId(0) + ), + StyledTextRun( + byteStart: 1, byteEnd: 7, runeStart: 1, runeEnd: 4, styleId: TextStyleId(1) + ), + ], + styles, + minContent = false, + wrap = false, + ) + + test "source runs preserve empty and width-changing case conversions": let fontData = readFile(figDataDir() / "Ubuntu.ttf") typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) plain = FigFont(typefaceId: typefaceId, size: 18.0'f32) uppercase = FigFont(typefaceId: typefaceId, size: 18.0'f32, fontCase: UpperCase) emptySource = initUtf8Runes("") - caseSource = initUtf8Runes("abé") + caseSource = initUtf8Runes("ıA") plainStyle = fs(plain, fill(rgba(255, 0, 0, 255))) caseStyle = fs(uppercase, fill(rgba(255, 0, 0, 255))) box = rect(0, 0, 240, 90) empty = typesetSourceSpansForMeasurement( box, emptySource, - [TextSourceSpan(style: plainStyle, byteStart: 0, byteEnd: 0)], + [StyledTextRun(styleId: TextStyleId(0))], + [plainStyle], minContent = false, wrap = false, ) transformed = typesetSourceSpansForMeasurement( box, caseSource, - [TextSourceSpan(style: caseStyle, byteStart: 0, byteEnd: 4)], + [ + StyledTextRun( + byteStart: 0, byteEnd: 3, runeStart: 0, runeEnd: 2, styleId: TextStyleId(0) + ) + ], + [caseStyle], minContent = false, wrap = false, ) owned = typesetForMeasurement( - box, [(caseStyle, "abé")], minContent = false, wrap = false + box, [(caseStyle, "ıA")], minContent = false, wrap = false ) check cast[pointer](empty.sourceRunes) == cast[pointer](emptySource) check empty.sourceRunes.len == 0 check cast[pointer](transformed.sourceRunes) == cast[pointer](caseSource) + check transformed.sourceRunes.byteLength == 3 + when figdrawTextBackend == "pixie": + check transformed.runes.stringValue() == "IA" check transformed.arrangedGlyphs.len == owned.arrangedGlyphs.len - for index, glyph in transformed.arrangedGlyphs: - check glyph.source == owned.arrangedGlyphs[index].source - + when figdrawTextBackend != "pixie": + for index, glyph in transformed.arrangedGlyphs: + check glyph.source == owned.arrangedGlyphs[index].source + check transformed.arrangedGlyphs[0].source.byteEnd == 2 + check transformed.arrangedGlyphs[1].source.byteStart == 2 + + when figdrawTextBackend == "pixie": + test "source runs map around controls removed by Pixie": + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + uiFont = FigFont(typefaceId: typefaceId, size: 18.0'f32) + source = initUtf8Runes("A\tB") + style = fs(uiFont) + arrangement = typesetSourceSpansForMeasurement( + rect(0, 0, 240, 90), + source, + [ + StyledTextRun( + byteStart: 0, + byteEnd: 3, + runeStart: 0, + runeEnd: 3, + styleId: TextStyleId(0), + ) + ], + [style], + minContent = false, + wrap = false, + ) + check cast[pointer](arrangement.sourceRunes) == cast[pointer](source) + check arrangement.runes.stringValue() == "AB" + check arrangement.arrangedGlyphs.len == 2 + check arrangement.arrangedGlyphs[0].source.byteStart == 0 + check arrangement.arrangedGlyphs[1].source.byteStart == 2 test "getTypesetImpl returns consistent hashes and generated glyph images": let fontData = readFile(figDataDir() / "Ubuntu.ttf") let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) From c6e27796ecd59dfdae2f5db9f67211c84c75f4e6 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 01:14:04 +0300 Subject: [PATCH 3/7] Add RSS benchmark for shared text runs --- tools/bench_text_source_rss.nim | 105 ++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tools/bench_text_source_rss.nim diff --git a/tools/bench_text_source_rss.nim b/tools/bench_text_source_rss.nim new file mode 100644 index 00000000..096146bc --- /dev/null +++ b/tools/bench_text_source_rss.nim @@ -0,0 +1,105 @@ +## Compare retained input memory for owned text spans and shared source runs. +## Compile once, then run each mode in its own process: +## nim c -d:release -o:/tmp/figdraw-text-rss tools/bench_text_source_rss.nim +## /tmp/figdraw-text-rss owned +## /tmp/figdraw-text-rss shared + +import std/[os, strutils] + +import figdraw/common/fonttypes + +when defined(linux): + proc rssKb(): int64 = + for line in lines("/proc/self/status"): + if line.startsWith("VmRSS:"): + let fields = line.splitWhitespace() + if fields.len >= 2: + return parseInt(fields[1]).int64 + raise newException(IOError, "unable to read VmRSS from /proc/self/status") + +elif defined(macosx): + # Use the same task_info RSS probe as Sigils tests/trchannels.nim. + type + MachPort = uint32 + MachMsgTypeNumber = uint32 + TimeValue = object + seconds: int32 + microseconds: int32 + + MachTaskBasicInfo = object + virtualSize: uint64 + residentSize: uint64 + residentSizeMax: uint64 + userTime: TimeValue + systemTime: TimeValue + policy: int32 + suspendCount: int32 + + const machTaskBasicInfoFlavor = 20 + + var machTaskSelf {.importc: "mach_task_self_", header: "".}: + MachPort + + proc taskInfo( + task: MachPort, + flavor: cint, + taskInfoOut: ptr MachTaskBasicInfo, + taskInfoOutCount: ptr MachMsgTypeNumber, + ): cint {.importc: "task_info", header: "".} + + proc rssKb(): int64 = + var info: MachTaskBasicInfo + var count = MachMsgTypeNumber(sizeof(MachTaskBasicInfo) div sizeof(cuint)) + let status = taskInfo(machTaskSelf, machTaskBasicInfoFlavor, addr info, addr count) + doAssert status == 0, "task_info failed with kern_return_t " & $status + int64(info.residentSize div 1024) + +else: + {.error: "RSS benchmark supports Linux and macOS".} + +const + SourceBytes = 16 * 1024 * 1024 + RunBytes = 256 + RunCount = SourceBytes div RunBytes + +if paramCount() != 1 or paramStr(1) notin ["owned", "shared"]: + quit "usage: bench_text_source_rss owned|shared" + +let + mode = paramStr(1) + source = initUtf8Runes(repeat("abcdefgh", SourceBytes div 8)) + styles = [fs(FigFont(size: 18.0'f32)), fs(FigFont(size: 19.0'f32))] + baselineKb = rssKb() + +var owned: seq[(FontStyle, string)] +var shared: seq[StyledTextRun] +if mode == "owned": + owned = newSeqOfCap[(FontStyle, string)](RunCount) + for index in 0 ..< RunCount: + let start = index * RunBytes + owned.add((styles[index mod 2], source.bytes[start ..< start + RunBytes])) +else: + shared = newSeqOfCap[StyledTextRun](RunCount) + for index in 0 ..< RunCount: + let start = index * RunBytes + shared.add StyledTextRun( + byteStart: start.uint32, + byteEnd: (start + RunBytes).uint32, + runeStart: start.uint32, + runeEnd: (start + RunBytes).uint32, + styleId: TextStyleId(index mod 2), + ) + +let retainedKb = rssKb() +echo "mode=", + mode, + " source_mib=", + SourceBytes div (1024 * 1024), + " runs=", + max(owned.len, shared.len), + " baseline_kib=", + baselineKb, + " retained_kib=", + retainedKb, + " input_delta_kib=", + retainedKb - baselineKb From 9b750fd69ef8fae82e05c26934ee6cb4d81be95e Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 01:49:26 +0300 Subject: [PATCH 4/7] Preserve Pixie text API and align filtered spans --- CHANGES.md | 3 + src/figdraw/common/fontglyphs.nim | 63 ++++++++++++++-- src/figdraw/common/fonttypes.nim | 3 + src/figdraw/common/textbackends/pixie.nim | 34 ++++++--- tests/tfontutils.nim | 92 +++++++++++++++++++++++ 5 files changed, 178 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index afbd8926..b1143ce8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,9 @@ Harfbuzzy shapes normal-case ranges without keeping a string per styled run. - Expose sparse rune/byte position lookup on `Utf8Runes` so callers can create byte ranges without a dense index. +- Preserve the existing Pixie typesetting and arrangement-conversion proc + signatures, keep fonts and colors aligned when Pixie filters a whole control + span, and raise `ValueError` when borrowing bytes from a nil `Utf8Runes`. ## 0.41.0 diff --git a/src/figdraw/common/fontglyphs.nim b/src/figdraw/common/fontglyphs.nim index 5d06b2ac..23d32161 100644 --- a/src/figdraw/common/fontglyphs.nim +++ b/src/figdraw/common/fontglyphs.nim @@ -257,6 +257,15 @@ proc matchesDisplayRunes(source: Utf8Runes, display: openArray[Rune]): bool = inc index true +proc pixieHasDisplayRune(text: string): bool = + ## Match Pixie's control filtering so style metadata uses the same spans. + var byteIndex = 0 + var rune: Rune + while byteIndex < text.len: + fastRuneAt(text, byteIndex, rune, true) + if rune.uint32 >= 32 or rune == Rune(10): + return true + proc mapPixieSourceRanges( glyphs: var seq[ArrangedGlyph], source: Utf8Runes, runs: openArray[StyledTextRun] ) = @@ -287,28 +296,36 @@ proc mapPixieSourceRanges( if glyphIndex != glyphs.len: raise newException(ValueError, "Pixie display runes differ from source runs") -proc convertArrangement*( +proc convertArrangementImpl( arrangement: Arrangement, box: Rect, uiSpans: openArray[(FontStyle, string)], hAlign: FontHorizontal, vAlign: FontVertical, gfonts: seq[GlyphFont], - minContent = false, - wrap = false, - sourceRunes: Utf8Runes = nil, - sourceRuns: openArray[StyledTextRun] = [], + minContent, wrap: bool, + sourceRunes: Utf8Runes, + sourceRuns: openArray[StyledTextRun], ): GlyphArrangement = var lines = newSeqOfCap[Slice[int]](arrangement.lines.len()) spanSlices = newSeqOfCap[Slice[int]](arrangement.spans.len()) selectionRects = newSeqOfCap[Rect](arrangement.selectionRects.len()) + visibleFonts = newSeqOfCap[GlyphFont](arrangement.spans.len()) + visibleColors = newSeqOfCap[Fill](arrangement.spans.len()) for line in arrangement.lines: lines.add line[0] .. line[1] for span in arrangement.spans: spanSlices.add span[0] .. span[1] for rect in arrangement.selectionRects: selectionRects.add rect + for index, (style, text) in uiSpans: + if index < gfonts.len and text.pixieHasDisplayRune(): + visibleFonts.add gfonts[index] + visibleColors.add style.color + if visibleFonts.len != arrangement.spans.len: + visibleFonts = gfonts + visibleColors = uiSpans.mapIt(it[0].color) let displayRunes = if sourceRunes.matchesDisplayRunes(arrangement.runes): @@ -316,7 +333,7 @@ proc convertArrangement*( else: initArrangementRunes(arrangement.runes) var arrangedGlyphs = buildArrangedGlyphs( - displayRunes, arrangement.positions, selectionRects, spanSlices, gfonts + displayRunes, arrangement.positions, selectionRects, spanSlices, visibleFonts ) if not sourceRunes.isNil: arrangedGlyphs.mapPixieSourceRanges(sourceRunes, sourceRuns) @@ -329,11 +346,41 @@ proc convertArrangement*( !$h, lines: lines, spans: spanSlices, - fonts: gfonts, - spanColors: uiSpans.mapIt(it[0].color), + fonts: visibleFonts, + spanColors: visibleColors, sourceRunes: (if sourceRunes.isNil: displayRunes else: sourceRunes), arrangedGlyphs: arrangedGlyphs, runes: displayRunes, positions: arrangement.positions, selectionRects: selectionRects, ) + +proc convertArrangement*( + arrangement: Arrangement, + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign: FontHorizontal, + vAlign: FontVertical, + gfonts: seq[GlyphFont], + minContent = false, + wrap = false, +): GlyphArrangement = + convertArrangementImpl( + arrangement, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, nil, [] + ) + +proc convertArrangementWithSource*( + arrangement: Arrangement, + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign: FontHorizontal, + vAlign: FontVertical, + gfonts: seq[GlyphFont], + minContent, wrap: bool, + sourceRunes: Utf8Runes, + sourceRuns: openArray[StyledTextRun], +): GlyphArrangement = + convertArrangementImpl( + arrangement, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, + sourceRuns, + ) diff --git a/src/figdraw/common/fonttypes.nim b/src/figdraw/common/fonttypes.nim index 6bdc367b..88beafe9 100644 --- a/src/figdraw/common/fonttypes.nim +++ b/src/figdraw/common/fonttypes.nim @@ -213,6 +213,9 @@ func stringValue*(runes: Utf8Runes): string = func bytes*(runes: Utf8Runes): lent string = ## Borrows the immutable UTF-8 bytes for the lifetime of `runes`. + ## Raises ValueError for a nil source, which cannot supply a borrowed string. + if runes.isNil: + raise newException(ValueError, "cannot borrow bytes from nil Utf8Runes") runes.text func byteLength*(runes: Utf8Runes): int = diff --git a/src/figdraw/common/textbackends/pixie.nim b/src/figdraw/common/textbackends/pixie.nim index e49365e2..4fc2c444 100644 --- a/src/figdraw/common/textbackends/pixie.nim +++ b/src/figdraw/common/textbackends/pixie.nim @@ -10,16 +10,16 @@ import ../shared import ../typefaces import ./common -proc typeset*( +proc typesetWithSource( box: Rect, uiSpans: openArray[(FontStyle, string)], - hAlign = FontHorizontal.Left, - vAlign = FontVertical.Top, + hAlign: FontHorizontal, + vAlign: FontVertical, minContent: bool, wrap: bool, rasterize: bool, - sourceRunes: Utf8Runes = nil, - sourceRuns: openArray[StyledTextRun] = [], + sourceRunes: Utf8Runes, + sourceRuns: openArray[StyledTextRun], ): GlyphArrangement = ## Typesets with Pixie, then converts to FigDraw's backend-neutral data. threadEffects: @@ -71,7 +71,7 @@ proc typeset*( let arrangement = pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) - result = convertArrangement( + result = convertArrangementWithSource( arrangement, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, sourceRuns, ) @@ -87,7 +87,7 @@ proc typeset*( let arr = pixie.typeset( spans, bounds = wh, hAlign = LeftAlign, vAlign = TopAlign, wrap = wrap ) - let minResult = convertArrangement( + let minResult = convertArrangementWithSource( arr, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, sourceRuns, ) @@ -105,7 +105,7 @@ proc typeset*( let wh = vec2(wh.x, minContentSize.bounding.h) let minAdjusted = pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) - result = convertArrangement( + result = convertArrangementWithSource( minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, sourceRuns, ) @@ -130,6 +130,20 @@ proc typeset*( if rasterize: result.generateGlyphImages() +proc typeset*( + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, + rasterize: bool, +): GlyphArrangement = + ## Typesets with Pixie, then converts to FigDraw's backend-neutral data. + threadEffects: + AppMainThread + typesetWithSource(box, uiSpans, hAlign, vAlign, minContent, wrap, rasterize, nil, []) + proc typesetSourceSpans*( box: Rect, source: Utf8Runes, @@ -148,4 +162,6 @@ proc typesetSourceSpans*( source.bytes[int(run.byteStart) ..< int(run.byteEnd)], ) ) - typeset(box, spans, hAlign, vAlign, minContent, wrap, rasterize, source, runs) + typesetWithSource( + box, spans, hAlign, vAlign, minContent, wrap, rasterize, source, runs + ) diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 1209c7a8..819a26e6 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -1,6 +1,9 @@ import std/[hashes, options, os, tables, tempfiles, unicode, unittest] import figdraw +when figdrawTextBackend == "pixie": + import figdraw/common/textbackends/pixie as pixieBackend + import pkg/pixie/fonts as pixieFonts proc resetFontState() = resetFontCache(clearStaticTypefaces = true) @@ -479,6 +482,11 @@ suite "fontutils": check source.runeIndexAtOrBeforeByte(6) == 3 check source.runeIndexAtOrAfterByte(6) == 4 + when not defined(useNativeDynlib): + let missing: Utf8Runes = nil + expect ValueError: + discard missing.bytes + expect ValueError: discard typesetSourceSpans( box, @@ -572,6 +580,90 @@ suite "fontutils": check transformed.arrangedGlyphs[1].source.byteStart == 2 when figdrawTextBackend == "pixie": + test "legacy Pixie proc signatures remain assignable": + type + LegacyPixieTypeset = proc( + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, + ): GlyphArrangement + LegacyConvertArrangement = proc( + arrangement: pixieFonts.Arrangement, + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign: FontHorizontal, + vAlign: FontVertical, + gfonts: seq[GlyphFont], + minContent, wrap: bool, + ): GlyphArrangement + + let + legacyTypeset: LegacyPixieTypeset = pixieBackend.typeset + legacyConvert: LegacyConvertArrangement = convertArrangement + check not legacyTypeset.isNil + check not legacyConvert.isNil + + test "source runs retain visible styles around filtered control spans": + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + source = initUtf8Runes("A\tB") + first = fs( + FigFont(typefaceId: typefaceId, size: 18.0'f32), fill(rgba(255, 0, 0, 255)) + ) + hidden = fs( + FigFont(typefaceId: typefaceId, size: 20.0'f32), fill(rgba(0, 255, 0, 255)) + ) + last = fs( + FigFont(typefaceId: typefaceId, size: 22.0'f32), fill(rgba(0, 0, 255, 255)) + ) + box = rect(0, 0, 240, 90) + borrowed = typesetSourceSpansForMeasurement( + box, + source, + [ + StyledTextRun( + byteStart: 0, + byteEnd: 1, + runeStart: 0, + runeEnd: 1, + styleId: TextStyleId(0), + ), + StyledTextRun( + byteStart: 1, + byteEnd: 2, + runeStart: 1, + runeEnd: 2, + styleId: TextStyleId(1), + ), + StyledTextRun( + byteStart: 2, + byteEnd: 3, + runeStart: 2, + runeEnd: 3, + styleId: TextStyleId(2), + ), + ], + [first, hidden, last], + minContent = false, + wrap = false, + ) + owned = typesetForMeasurement( + box, + [(first, "A"), (hidden, "\t"), (last, "B")], + minContent = false, + wrap = false, + ) + for arrangement in [borrowed, owned]: + check arrangement.spans.len == 2 + check arrangement.fonts.len == 2 + check arrangement.spanColors == @[first.color, last.color] + check arrangement.fonts[0].size == 18.0'f32 + check arrangement.fonts[1].size == 22.0'f32 + check borrowed.arrangedGlyphs[1].source.byteStart == 2 + test "source runs map around controls removed by Pixie": let fontData = readFile(figDataDir() / "Ubuntu.ttf") From ca08508c993d5bca837b289d900ee0b05e430fea Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 01:54:46 +0300 Subject: [PATCH 5/7] Verify native source spans and bump FigDraw version --- CHANGES.md | 2 +- figdraw.nimble | 2 +- tests/tdynlib_api.nim | 28 ++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b1143ce8..547d9417 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # Changes -## Unreleased +## 0.42.0 - Add `typesetSourceSpans` and its measurement variant for compact `StyledTextRun` ranges and a separate `FontStyle` table. Layouts retain the diff --git a/figdraw.nimble b/figdraw.nimble index 2dc8ec9a..16a9af82 100644 --- a/figdraw.nimble +++ b/figdraw.nimble @@ -1,4 +1,4 @@ -version = "0.41.0" +version = "0.42.0" author = "Jaremy Creechley" description = "UI Engine for Nim" license = "MIT" diff --git a/tests/tdynlib_api.nim b/tests/tdynlib_api.nim index 72927177..b04ab3d2 100644 --- a/tests/tdynlib_api.nim +++ b/tests/tdynlib_api.nim @@ -325,6 +325,34 @@ suite "native dynlib API": check supportedFontFileExtensions().len > 0 check storage.copyUtf8Runes().stringValue() == source + test "typesets compact source spans through the native binding": + let + fontData = readFile(figDataDir() / "Ubuntu.ttf") + typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + font = FigFont(typefaceId: typefaceId, size: 18.0'f32) + source = figdraw_native_abi.initUtf8Runes("éA") + styles = [fs(font, fill(rgba(255, 0, 0, 255)))] + runs = [ + StyledTextRun( + byteStart: 0, byteEnd: 3, runeStart: 0, runeEnd: 2, styleId: TextStyleId(0) + ) + ] + arrangement = figdraw_native_abi.typesetSourceSpansForMeasurement( + bumpy.rect(0, 0, 240, 90), + source, + runs, + styles, + FontHorizontal.Left, + FontVertical.Top, + false, + false, + ) + + check source.byteOffsetForRune(1) == 2 + check source.runeIndexAtOrBeforeByte(2) == 1 + check cast[pointer](arrangement.sourceRunes) == cast[pointer](source) + check arrangement.arrangedGlyphs.len == 2 + test "uses direct UTF-8 constructors with shared Rune and sink string arguments": let source = "A λ 😀" From 2e5402d6cff1b327e6ac41616c63de9a1c099c46 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 04:25:34 +0300 Subject: [PATCH 6/7] Share immutable glyph layouts through range views --- CHANGES.md | 3 + figdraw.nimble | 1 + src/figdraw/common/fontglyphs.nim | 10 +- src/figdraw/common/fonttypes.nim | 146 +++++++++++++++++++++++------- 4 files changed, 121 insertions(+), 39 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 547d9417..775acde7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,9 @@ ## 0.42.0 +- Add immutable shared glyph arrangements and glyph-range views for line render + nodes. Frozen arrangements discard redundant position and selection arrays + when arranged glyphs contain the same geometry. - Add `typesetSourceSpans` and its measurement variant for compact `StyledTextRun` ranges and a separate `FontStyle` table. Layouts retain the shared `Utf8Runes` source. Pixie reuses it for display text when unchanged diff --git a/figdraw.nimble b/figdraw.nimble index 16a9af82..2c2017fd 100644 --- a/figdraw.nimble +++ b/figdraw.nimble @@ -7,6 +7,7 @@ srcDir = "src" # Dependencies requires "nim >= 2.2" requires "sigils >= 0.31.0" +requires "threading >= 0.2.1" requires "pixie >= 5.0.1" requires "chroma >= 0.2.7" requires "bumpy" diff --git a/src/figdraw/common/fontglyphs.nim b/src/figdraw/common/fontglyphs.nim index 23d32161..89173553 100644 --- a/src/figdraw/common/fontglyphs.nim +++ b/src/figdraw/common/fontglyphs.nim @@ -177,11 +177,7 @@ proc buildArrangedGlyphs*( iterator glyphs*(arrangement: GlyphArrangement): GlyphPosition = var idx = 0 - let arrangedGlyphCount = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs.len - else: - arrangement.runes.len + let arrangedGlyphCount = arrangement.glyphCount() block: for i, span in arrangement.spans: @@ -200,8 +196,8 @@ iterator glyphs*(arrangement: GlyphArrangement): GlyphPosition = fill(rgba(0, 0, 0, 255)) while idx < arrangedGlyphCount and idx in span: let arranged = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs[idx] + if arrangement.isGlyphView() or arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyph(idx) else: let rune = arrangement.runes[idx] ArrangedGlyph( diff --git a/src/figdraw/common/fonttypes.nim b/src/figdraw/common/fonttypes.nim index 88beafe9..f5033de5 100644 --- a/src/figdraw/common/fonttypes.nim +++ b/src/figdraw/common/fonttypes.nim @@ -1,4 +1,5 @@ -import std/[hashes, unicode] +import std/[hashes, isolation, unicode] +import threading/smartptrs import uimaths import filltypes @@ -120,6 +121,9 @@ type type GlyphArrangement* = object + shared*: ConstPtr[GlyphArrangement] ## Immutable owner for a glyph-range view. + viewGlyphStart*: uint32 ## Inclusive glyph offset in `shared`. + viewGlyphEnd*: uint32 ## Exclusive glyph offset in `shared`. contentHash*: Hash lines*: seq[Slice[int]] ## The (start, stop) of the lines of text. spans*: seq[Slice[int]] ## The (start, stop) of the spans in the text. @@ -350,6 +354,10 @@ func `==`*(a: openArray[Rune], b: Utf8Runes): bool = func utf8RunesEqual*(a, b: Utf8Runes): bool = a == b +func sameUtf8Runes*(a, b: Utf8Runes): bool {.inline.} = + ## Checks buffer identity; `==` compares UTF-8 contents instead. + system.`==`(a, b) + func utf8RunesEqualRunes*(a: Utf8Runes, b: openArray[Rune]): bool = a == b @@ -361,6 +369,71 @@ proc initArrangementRunes*(runes: openArray[Rune]): ArrangementRunes = ## Creates storage suitable for `GlyphArrangement` rune fields. initUtf8Runes(runes) +proc copiedSequence[T](values: openArray[T]): seq[T] = + result = newSeqOfCap[T](values.len) + for value in values: + result.add value + +proc shareGlyphArrangement*(layout: sink GlyphArrangement): ConstPtr[GlyphArrangement] = + ## Freeze one complete layout for read-only sharing across render threads. + ## The copy detaches any arrays or UTF-8 buffers also retained by the caller. + var owned = move layout + if not owned.shared.isNil: + raise newException(ValueError, "cannot freeze a glyph-range view") + owned.lines = copiedSequence(owned.lines) + owned.spans = copiedSequence(owned.spans) + owned.fonts = copiedSequence(owned.fonts) + owned.spanColors = copiedSequence(owned.spanColors) + owned.arrangedGlyphs = copiedSequence(owned.arrangedGlyphs) + if owned.arrangedGlyphs.len > 0: + # ArrangedGlyph already owns both geometry values. Frozen layouts use it. + owned.positions = @[] + owned.selectionRects = @[] + else: + owned.positions = copiedSequence(owned.positions) + owned.selectionRects = copiedSequence(owned.selectionRects) + let sameText = owned.sourceRunes.sameUtf8Runes(owned.runes) + owned.sourceRunes = owned.sourceRunes.copyUtf8Runes() + if sameText: + owned.runes = owned.sourceRunes + else: + owned.runes = owned.runes.copyUtf8Runes() + newConstPtr(unsafeIsolate(move owned)) + +func glyphCount*(arrangement: GlyphArrangement): int + +proc glyphArrangementView*( + owner: ConstPtr[GlyphArrangement], glyphRange: Slice[int] +): GlyphArrangement = + ## Borrows an inclusive glyph range from a complete arrangement. + ## Legacy layouts without arranged glyph records use owned slices. + if owner.isNil or not owner[].shared.isNil or glyphRange.a < 0 or + glyphRange.b < glyphRange.a or glyphRange.b >= owner[].glyphCount() or + glyphRange.b >= high(uint32).int: + raise newException(ValueError, "invalid glyph arrangement view range") + let count = glyphRange.b - glyphRange.a + 1 + result.lines = @[0 .. count - 1] + result.maxSize = owner[].maxSize + result.minSize = owner[].minSize + result.bounding = owner[].bounding + if owner[].arrangedGlyphs.len > 0: + result.shared = owner + result.viewGlyphStart = glyphRange.a.uint32 + result.viewGlyphEnd = (glyphRange.b + 1).uint32 + else: + # Legacy layouts without ArrangedGlyph records retain the owned slice path. + result.runes = owner[].runes[glyphRange] + result.positions = owner[].positions[glyphRange] + result.selectionRects = owner[].selectionRects[glyphRange] + for spanIndex, span in owner[].spans: + let first = max(span.a, glyphRange.a) + let last = min(span.b, glyphRange.b) + if first <= last: + result.spans.add first - glyphRange.a .. last - glyphRange.a + result.fonts.add owner[].fonts[spanIndex] + if spanIndex < owner[].spanColors.len: + result.spanColors.add owner[].spanColors[spanIndex] + func textBackend*(): string = ## Text backend compiled into FigDraw. figdrawTextBackend @@ -426,29 +499,48 @@ func syntheticFontGlyphId*(fontId: FontId, rune: Rune): FontGlyphId {.inline.} = discard fontId FontGlyphId(rune.uint32) +func arrangedGlyph*(arrangement: GlyphArrangement, glyphIndex: int): ArrangedGlyph = + ## Return a glyph from a complete layout or a shared range view. + if arrangement.shared.isNil: + arrangement.arrangedGlyphs[glyphIndex] + else: + arrangement.shared[].arrangedGlyphs[int(arrangement.viewGlyphStart) + glyphIndex] + +func isGlyphView*(arrangement: GlyphArrangement): bool = + not arrangement.shared.isNil + +func displayRune*(arrangement: GlyphArrangement, glyphIndex: int): Rune = + if arrangement.shared.isNil: + arrangement.runes[glyphIndex] + else: + arrangement.shared[].runes[int(arrangement.viewGlyphStart) + glyphIndex] + +func glyphSource( + arrangement: GlyphArrangement, glyphIndex: int +): GlyphSourceRange {.inline.} + func sourceRune*(arrangement: GlyphArrangement, glyphIndex: int): Rune {.inline.} = ## Returns the cheap representative source rune for a glyph. - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs[glyphIndex].rune - else: + if arrangement.shared.isNil and arrangement.arrangedGlyphs.len == 0: arrangement.runes[glyphIndex] + else: + arrangement.arrangedGlyph(glyphIndex).rune func sourceRuneRange*( arrangement: GlyphArrangement, glyphIndex: int ): Slice[int] {.inline.} = ## Returns the inclusive source-rune range for a glyph. - let source = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs[glyphIndex].source - else: - GlyphSourceRange(runeStart: glyphIndex, runeEnd: glyphIndex + 1) + let source = arrangement.glyphSource(glyphIndex) result = source.runeStart .. source.runeEnd - 1 iterator sourceRunes*(arrangement: GlyphArrangement, glyphIndex: int): Rune = ## Iterates the source runes mapped to a glyph. let sourceRange = arrangement.sourceRuneRange(glyphIndex) if sourceRange.a <= sourceRange.b: - if arrangement.sourceRunes.len > 0: + if not arrangement.shared.isNil: + for i in sourceRange: + yield arrangement.shared[].sourceRunes[i] + elif arrangement.sourceRunes.len > 0: for i in sourceRange: yield arrangement.sourceRunes[i] else: @@ -468,8 +560,8 @@ func byteSourceIntersects( func glyphSource( arrangement: GlyphArrangement, glyphIndex: int ): GlyphSourceRange {.inline.} = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs[glyphIndex].source + if not arrangement.shared.isNil or arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyph(glyphIndex).source else: GlyphSourceRange( runeStart: glyphIndex, @@ -489,11 +581,7 @@ func glyphRangeFor*( let runeStart = max(sourceRange.a, 0) runeEnd = sourceRange.b + 1 - glyphCount = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs.len - else: - arrangement.runes.len + glyphCount = arrangement.glyphCount() result = 0 .. -1 for glyphIndex in 0 ..< glyphCount: @@ -514,11 +602,7 @@ func glyphRangeForRawBytes*( let byteStart = max(byteRange.a, 0) byteEnd = byteRange.b + 1 - glyphCount = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs.len - else: - arrangement.runes.len + glyphCount = arrangement.glyphCount() result = 0 .. -1 for glyphIndex in 0 ..< glyphCount: @@ -530,14 +614,16 @@ func glyphRangeForRawBytes*( func glyphCount*(arrangement: GlyphArrangement): int = ## Returns the number of visual glyphs in the arrangement. - if arrangement.arrangedGlyphs.len > 0: + if not arrangement.shared.isNil: + int(arrangement.viewGlyphEnd - arrangement.viewGlyphStart) + elif arrangement.arrangedGlyphs.len > 0: arrangement.arrangedGlyphs.len else: arrangement.runes.len func rectForGlyph(arrangement: GlyphArrangement, glyphIndex: int): Rect {.inline.} = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs[glyphIndex].rect + if not arrangement.shared.isNil or arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyph(glyphIndex).rect else: arrangement.selectionRects[glyphIndex] @@ -887,13 +973,7 @@ func containsPoint(rect: Rect, point: Vec2): bool {.inline.} = func glyphIndexAt*(arrangement: GlyphArrangement, point: Vec2): int = ## Returns the glyph index at a local text-layout point, or `-1`. - let glyphCount = - if arrangement.arrangedGlyphs.len > 0: - arrangement.arrangedGlyphs.len - else: - arrangement.selectionRects.len - - for glyphIndex in 0 ..< glyphCount: + for glyphIndex in 0 ..< arrangement.glyphCount(): if arrangement.rectForGlyph(glyphIndex).containsPoint(point): return glyphIndex -1 @@ -907,6 +987,8 @@ func sourceRuneRangeAt*(arrangement: GlyphArrangement, point: Vec2): Slice[int] func sourceRuneCount*(arrangement: GlyphArrangement): int = ## Returns the number of source runes represented by the arrangement. + if not arrangement.shared.isNil: + return arrangement.shared[].sourceRuneCount() if arrangement.sourceRunes.len > 0: arrangement.sourceRunes.len else: From 7eefcaad938fbae4a2a5a1a0d87a46de38850bb7 Mon Sep 17 00:00:00 2001 From: Jaremy Creechley Date: Wed, 23 Sep 2026 04:32:48 +0300 Subject: [PATCH 7/7] Cover shared glyph layout views --- tests/tfontutils.nim | 86 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 819a26e6..ccdce93f 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -1,6 +1,7 @@ import std/[hashes, options, os, tables, tempfiles, unicode, unittest] import figdraw +import threading/smartptrs when figdrawTextBackend == "pixie": import figdraw/common/textbackends/pixie as pixieBackend import pkg/pixie/fonts as pixieFonts @@ -189,6 +190,91 @@ suite "fontutils": check arranged[2].source.byteStart == 5 check arranged[2].source.byteEnd == 7 + test "frozen glyph views share source and geometry across line slices": + let + source = initArrangementRunes("a🙂b") + fontId = FontId(Hash(17)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + positions = @[vec2(0, 0), vec2(10, 0), vec2(20, 0)] + rects = @[rect(0, 0, 10, 14), rect(10, 0, 10, 14), rect(20, 0, 10, 14)] + var layout = GlyphArrangement( + lines: @[0 .. 2], + spans: @[0 .. 2], + fonts: @[glyphFont], + sourceRunes: source, + runes: source, + positions: positions, + selectionRects: rects, + arrangedGlyphs: + buildArrangedGlyphs(source, positions, rects, @[0 .. 2], @[glyphFont]), + ) + + let owner = shareGlyphArrangement(layout) + check owner[].positions.len == 0 + check owner[].selectionRects.len == 0 + check owner[].sourceRunes.sameUtf8Runes(owner[].runes) + check not owner[].sourceRunes.sameUtf8Runes(source) + + let view = owner.glyphArrangementView(1 .. 2) + check view.isGlyphView() + check view.glyphCount() == 2 + check view.arrangedGlyphs.len == 0 + check view.runes.isNil + check view.sourceRuneRange(0) == 1 .. 1 + check view.glyphRangeForRawBytes(1 .. 4) == 0 .. 0 + var sourceText: string + for rune in view.sourceRunes(0): + sourceText.add rune + check sourceText == "🙂" + var glyphBytes: seq[int] + for glyph in view.glyphs(): + glyphBytes.add glyph.source.byteStart + check glyphBytes == @[1, 5] + + layout.arrangedGlyphs[1].pos.x = 999 + check view.arrangedGlyph(0).pos.x == 10 + expect ValueError: + discard owner.glyphArrangementView(0 .. 3) + + test "frozen layout keeps distinct equal buffers and legacy slices": + let + source = initArrangementRunes("abc") + display = initArrangementRunes("abc") + fontId = FontId(Hash(18)) + font = GlyphFont(fontId: fontId, lineHeight: 12) + positions = @[vec2(0, 0), vec2(8, 0), vec2(16, 0)] + rects = @[rect(0, 0, 8, 12), rect(8, 0, 8, 12), rect(16, 0, 8, 12)] + modern = GlyphArrangement( + spans: @[0 .. 2], + fonts: @[font], + sourceRunes: source, + runes: display, + arrangedGlyphs: + buildArrangedGlyphs(display, positions, rects, @[0 .. 2], @[font]), + ) + modernOwner = shareGlyphArrangement(modern) + check modernOwner[].sourceRunes == modernOwner[].runes + check not modernOwner[].sourceRunes.sameUtf8Runes(modernOwner[].runes) + + let legacyOwner = shareGlyphArrangement( + GlyphArrangement( + spans: @[0 .. 2], + fonts: @[font], + runes: display, + positions: positions, + selectionRects: rects, + ) + ) + let legacyView = legacyOwner.glyphArrangementView(1 .. 2) + check not legacyView.isGlyphView() + check legacyView.runes.stringValue() == "bc" + check legacyView.positions.len == 2 + var count = 0 + for glyph in legacyView.glyphs(): + check glyph.rect.w == 8 + inc count + check count == 2 + test "load typeface from buffer": let fontData = readFile(figDataDir() / "Ubuntu.ttf") let id1 = loadTypeface("Ubuntu.ttf", fontData, TTF)