diff --git a/CHANGES.md b/CHANGES.md index 00ccb3a..7e4be9b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,21 @@ # Changes +## 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 + 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. +- 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 - Allocate Metal backdrop and rect-mask storage on first use, release optional diff --git a/README.md b/README.md index 67a6d45..491344e 100644 --- a/README.md +++ b/README.md @@ -786,6 +786,37 @@ 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 שלום") + 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, + runs, + styles, + minContent = false, + wrap = true, +) +``` + +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` 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/figdraw.nimble b/figdraw.nimble index 2dc8ec9..2c2017f 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" @@ -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/bindings/native_dynlib.json b/src/figdraw/bindings/native_dynlib.json index a990598..2b4b283 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/fontglyphs.nim b/src/figdraw/common/fontglyphs.nim index 305635f..8917355 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( @@ -247,28 +243,96 @@ proc generateGlyphImages*(arrangement: GlyphArrangement, lcdFiltering = false) = for glyph in arrangement.glyphs(): glyph.generateGlyph(lcdFiltering = lcdFiltering) -proc convertArrangement*( +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 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] +) = + ## 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 convertArrangementImpl( arrangement: Arrangement, box: Rect, uiSpans: openArray[(FontStyle, string)], hAlign: FontHorizontal, vAlign: FontVertical, gfonts: seq[GlyphFont], - minContent = false, - wrap = false, + 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 storedRunes = initArrangementRunes(arrangement.runes) + let displayRunes = + if sourceRunes.matchesDisplayRunes(arrangement.runes): + sourceRunes + else: + initArrangementRunes(arrangement.runes) + var arrangedGlyphs = buildArrangedGlyphs( + displayRunes, arrangement.positions, selectionRects, spanSlices, visibleFonts + ) + if not sourceRunes.isNil: + arrangedGlyphs.mapPixieSourceRanges(sourceRunes, sourceRuns) result = GlyphArrangement( contentHash: block: @@ -278,13 +342,41 @@ proc convertArrangement*( !$h, lines: lines, spans: spanSlices, - fonts: gfonts, - spanColors: uiSpans.mapIt(it[0].color), - sourceRunes: storedRunes, - arrangedGlyphs: buildArrangedGlyphs( - storedRunes, arrangement.positions, selectionRects, spanSlices, gfonts - ), - runes: storedRunes, + 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 7021b72..f5033de 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 @@ -74,6 +75,16 @@ type font*: FigFont color*: Fill + 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. byteEnd*: int ## Exclusive source byte index. @@ -110,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. @@ -201,6 +215,19 @@ 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`. + ## 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 = + 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 +248,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 +263,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: @@ -297,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 @@ -308,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 @@ -373,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: @@ -415,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, @@ -436,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: @@ -461,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: @@ -477,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] @@ -834,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 @@ -854,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: diff --git a/src/figdraw/common/fontutils.nim b/src/figdraw/common/fontutils.nim index 2b4fcd8..a12fff9 100644 --- a/src/figdraw/common/fontutils.nim +++ b/src/figdraw/common/fontutils.nim @@ -65,6 +65,63 @@ proc typesetForMeasurement*( ## Typesets without generating or publishing glyph images. textBackend.typeset(box, uiSpans, hAlign, vAlign, minContent, wrap, false) +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 + 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, + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, +): GlyphArrangement = + ## 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, runs, styles, hAlign, vAlign, minContent, wrap, true + ) + +proc typesetSourceSpansForMeasurement*( + box: Rect, + source: Utf8Runes, + 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(runs, styles) + textBackend.typesetSourceSpans( + box, source, runs, styles, 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 057e2c1..4569f74 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,62 @@ proc splitParagraphs(spans: openArray[(FontStyle, string)]): seq[Paragraph] = byteOffset += text.len +proc splitSourceParagraphs( + source: Utf8Runes, runs: openArray[StyledTextRun], styles: openArray[FontStyle] +): seq[Paragraph] = + result.add Paragraph(byteStart: 0) + 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 = style + result[^1].hasLineStyle = true + var + 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: style, + source: source, + byteStart: partStart, + byteEnd: byteIndex, + byteOffset: partStart, + ) + var breakLen = 1 + 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: style, hasLineStyle: true) + else: + inc byteIndex + if partStart < runEnd: + result[^1].spans.add ShapedSpan( + style: style, + source: source, + byteStart: partStart, + byteEnd: runEnd, + 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 +256,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 +268,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 +301,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 +835,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 +902,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 +926,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 +971,81 @@ 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, + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, +): GlyphArrangement = + ## Shapes normal-case text directly from shared source ranges. + threadEffects: + AppMainThread + + 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, 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(runs) + h = h !& hash(styles) + 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 2c66539..4fc2c44 100644 --- a/src/figdraw/common/textbackends/pixie.nim +++ b/src/figdraw/common/textbackends/pixie.nim @@ -10,14 +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, + sourceRuns: openArray[StyledTextRun], ): GlyphArrangement = ## Typesets with Pixie, then converts to FigDraw's backend-neutral data. threadEffects: @@ -69,8 +71,9 @@ 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 + result = convertArrangementWithSource( + 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 = convertArrangementWithSource( + arr, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, sourceRunes, + sourceRuns, + ) let minContentSize = minResult.calcMinMaxContent() trace "minContent:", @@ -100,9 +105,11 @@ 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( - minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap + result = convertArrangementWithSource( + minAdjusted, box, uiSpans, hAlign, vAlign, gfonts, minContent, wrap, + sourceRunes, sourceRuns, ) + let contentAdjusted = result.calcMinMaxContent() result.minSize = contentAdjusted.minSize result.maxSize = contentAdjusted.maxSize @@ -122,3 +129,39 @@ proc typeset*( result.addFontSizePadding(sz) 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, + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], + hAlign: FontHorizontal, + vAlign: FontVertical, + minContent, wrap, rasterize: bool, +): GlyphArrangement = + ## 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)], + ) + ) + typesetWithSource( + box, spans, hAlign, vAlign, minContent, wrap, rasterize, source, runs + ) diff --git a/src/figdraw/dynlib.nim b/src/figdraw/dynlib.nim index 511d7fc..c977ecc 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,34 @@ proc typesetForMeasurement*( box, spans, hAlign, vAlign, minContent, wrap ) +proc typesetSourceSpans*( + box: bumpy.Rect, + source: Utf8Runes, + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent = false, + wrap = true, +): GlyphArrangement {.inline.} = + figdraw_native_abi.typesetSourceSpans( + box, source, runs, styles, hAlign, vAlign, minContent, wrap + ) + +proc typesetSourceSpansForMeasurement*( + box: bumpy.Rect, + source: Utf8Runes, + runs: openArray[StyledTextRun], + styles: openArray[FontStyle], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent = false, + wrap = true, +): GlyphArrangement {.inline.} = + figdraw_native_abi.typesetSourceSpansForMeasurement( + box, source, runs, styles, hAlign, vAlign, minContent, wrap + ) + proc typeset*[T: FigFont | FontRef]( box: bumpy.Rect, font: T, diff --git a/tests/tdynlib_api.nim b/tests/tdynlib_api.nim index 7292717..b04ab3d 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 λ 😀" diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 71c7c8c..ccdce93 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -1,6 +1,10 @@ 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 proc resetFontState() = resetFontCache(clearStaticTypefaces = true) @@ -164,6 +168,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 @@ -183,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) @@ -437,6 +529,255 @@ suite "fontutils": check font.underline check font.strikethrough + 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) + 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))) + 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, runs, styles, minContent = false, wrap = false) + owned = typeset( + box, + [(firstStyle, "é"), (secondStyle, "A\n中")], + minContent = false, + 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 + 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 + + when not defined(useNativeDynlib): + let missing: Utf8Runes = nil + expect ValueError: + discard missing.bytes + + expect ValueError: + discard typesetSourceSpans( + box, + source, + [ + StyledTextRun( + byteStart: 0, byteEnd: 3, runeStart: 0, runeEnd: 2, styleId: TextStyleId(0) + ) + ], + styles, + minContent = false, + wrap = false, + ) + + expect ValueError: + discard typesetSourceSpans( + box, + source, + [ + StyledTextRun( + byteStart: 0, byteEnd: 7, runeStart: 0, runeEnd: 4, styleId: TextStyleId(2) + ) + ], + styles, + minContent = false, + wrap = false, + ) + + 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("ı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, + [StyledTextRun(styleId: TextStyleId(0))], + [plainStyle], + minContent = false, + wrap = false, + ) + transformed = typesetSourceSpansForMeasurement( + box, + caseSource, + [ + StyledTextRun( + byteStart: 0, byteEnd: 3, runeStart: 0, runeEnd: 2, styleId: TextStyleId(0) + ) + ], + [caseStyle], + minContent = false, + wrap = false, + ) + owned = typesetForMeasurement( + 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 + 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 "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") + 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) diff --git a/tools/bench_text_source_rss.nim b/tools/bench_text_source_rss.nim new file mode 100644 index 0000000..096146b --- /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