diff --git a/.github/workflows/build-full.yml b/.github/workflows/build-full.yml index fea9575..ce9f9b5 100644 --- a/.github/workflows/build-full.yml +++ b/.github/workflows/build-full.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: nimversion: - - '2.2.8' + - '2.2.10' os: - ubuntu-24.04 - macos-14 @@ -26,9 +26,10 @@ jobs: - name: Setup software OpenGL/Vulkan (Mesa) + Weston (Wayland) if: runner.os == 'Linux' + timeout-minutes: 15 run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends \ + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ weston \ xwayland \ wayland-protocols \ @@ -49,6 +50,16 @@ jobs: libegl-mesa0 \ libgles2 + - name: Install Linux text shaping dependencies + if: runner.os == 'Linux' + timeout-minutes: 5 + run: | + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + pkg-config \ + libharfbuzz-dev \ + libfribidi-dev + pkg-config --cflags --libs harfbuzz + - name: Vulkan diagnostics if: runner.os == 'Linux' run: | @@ -91,7 +102,7 @@ jobs: key: ${{ runner.os }}-${{ hashFiles('figdraw.nimble') }} - name: Install Deps - run: atlas install -t:8 --features:windy,sdl2,siwin,sharedlib + run: atlas install -t:8 --features:windy,sdl2,siwin,sharedlib,harfbuzz - name: Build Bindings run: | diff --git a/README.md b/README.md index fd9aeb9..a6125fa 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Features: - Rects & shadows default to SDF (signed-distance-field) primitives for crisp, dynamic, and low memory UI primitives - Lightweight, multiplatform, and high performance by design! (few or no allocations for each frame) - Thread-safe renderer pipeline. (render tree construction and preparation can be done off the main thread) -- Modern and fast text rendering and layout using [Pixie](https://github.com/treeform/pixie/) with thread-safe Text API. +- Fast pure Nimtext rendering and layout using [Pixie](https://github.com/treeform/pixie/) by default +- Optional Harfbuzzy support for shaping and rendering more complex scripts, font fallback, ligatures, and variable fonts! - Image rendering using a texture atlas. - Supports layering and multiple "roots" per layer - great for menus, overlays, etc. - SDF/MSDF (Multi-SDF) based glyph rendering. @@ -284,6 +285,92 @@ proc makeGradientDemo(w, h: float32, uiFont: FigFont): Renders = )) ``` +## Font Shaping with Harfbuzzy + +FigDraw uses Pixie text layout by default. For complex scripts, glyph ligatures, +font fallback, OpenType features, and variable-font axes, compile with the +optional Harfbuzzy backend. + +Install the optional Harfbuzzy dependency when trying the repo: + +```sh +atlas install --feature:windy --feature:harfbuzz +``` + +Add the `harfbuzz` feature when using FigDraw from another project: + +```nim +requires "https://github.com/elcritch/figdraw[windy,harfbuzz]" +``` + +Note: Windy is the default example. Harfbuzz support works with Siwin or other WMs. + +Then compile with the Harfbuzzy text backend: + +```sh +nim r -d:figdrawTextBackend=harfbuzzy examples/windy_text_shaping_demo.nim +``` + +For an example or app that should always use Harfbuzzy, put the backend switch +in a sibling `.nims` file: + +```nim +# examples/windy_text_shaping_demo.nims +switch("define", "figdrawTextBackend=harfbuzzy") +``` + +The public text API stays FigDraw-owned: use `typeset` as usual, but pass +shaping controls through `FigFont`. + +```nim +import figdraw/commons +import figdraw/common/fonttypes +import chroma + +let + ui = loadTypeface("data/Ubuntu.ttf") + arabic = loadTypeface("examples/fonts/NotoNaskhArabic-wght.ttf") + hebrew = loadTypeface("examples/fonts/NotoSansHebrew-wdth-wght.ttf") + + bodyFont = FigFont( + typefaceId: ui, + size: 22.0'f32, + fallbackTypefaceIds: @[arabic, hebrew], + features: @[fontFeature("kern"), fontFeature("liga")], + variations: @[fontVariation("wght", 560.0'f32)], + ) + +let layout = typeset( + rect(0, 0, 520, 120), + [span(bodyFont, rgba(30, 34, 40, 255), "Hello שלום السلام")], + minContent = false, + wrap = true, +) +``` + +Useful backend modes: + +- `-d:figdrawTextBackend=pixie`: default Pixie layout and raster path. +- `-d:figdrawTextBackend=harfbuzzy`: Harfbuzzy shaping with glyph-id + rasterization. +- `-d:figdrawTextBackend=hybrid`: Harfbuzzy layout converted through the Pixie + compatibility raster path, useful for diagnostics. + +When shaping is enabled, one visual glyph is not necessarily one source rune. +Use the source-aware helpers for selection, hit testing, and carets: + +- `selectionRectsFor(sourceRange)`: merged visual selection bands for source + rune ranges. +- `glyphSelectionRectsFor(sourceRange)`: raw per-glyph rectangles for + diagnostics. +- `sourceRuneRangeAt(point)`: source range under a local text-layout point. +- `caretPositionsFor(sourceRune)`: visual caret rectangles for a source + insertion index, including split positions at bidi boundaries. + +See [docs/font_shaping.md](docs/font_shaping.md) for the data model details and +[examples/windy_text_shaping_demo.nim](examples/windy_text_shaping_demo.nim) for +a complete Arabic, Hebrew, Devanagari, fallback, and ligature demo. + ## Layers and ZLevel `Renders` is an ordered table of `ZLevel -> RenderList`. Lower zlevels are drawn first, so higher diff --git a/config.nims b/config.nims index 653dfec..ea445b8 100644 --- a/config.nims +++ b/config.nims @@ -50,6 +50,8 @@ task test, "run unit test": for file in listFiles("examples"): if file.startsWith("examples/windy_") and file.endsWith(".nim"): nimExec("c", file) + elif file.startsWith("examples/siwin_") and file.endsWith(".nim"): + nimExec("c", file) elif file.startsWith("examples/sdl2_") and file.endsWith(".nim"): if enableSdl2: nimExec("c", file, "-d:figdraw.metal=off -d:figdraw.vulkan=off") diff --git a/docs/font_ownership.md b/docs/font_ownership.md new file mode 100644 index 0000000..ab0a724 --- /dev/null +++ b/docs/font_ownership.md @@ -0,0 +1,343 @@ +# Font Ownership + +This document describes a possible ownership model for font-backed glyph images. +The goal is to let FigDraw track which renderer image-cache entries belong to a +specific font identity and make those entries eligible for automatic cleanup when +the font is no longer in use. + +This is a design note. The current implementation keeps global font/typeface +registries and renderer-local image atlas entries, but it does not yet track +image ownership by font. + +## Current State + +`FigFont` is the public value object that describes a font: + +- `typefaceId` +- `size` +- `lineHeight` +- case, underline, strikethrough, kerning settings +- fallback typefaces +- OpenType feature settings +- variable font coordinates + +`FigFont.hash` includes these fields, and `FontId` is derived from that hash. +The Pixie path also folds `figUiScale()` into the cached font id so raster +entries are separated by scale. + +Glyph cache identity is currently: + +```nim +hash((2344, glyph.fontId, glyph.glyphId, lcdFiltering, subpixelVariant)) +``` + +That is enough to avoid collisions between font configurations and glyph +variants. It is not enough for eviction because renderer backends only store +image ids as atlas entries: + +```nim +entries: Table[Hash, Rect] +``` + +The global image queue also tracks only whether an image id has been queued: + +```nim +imageCached: HashSet[ImageId] +``` + +As a result, FigDraw can answer "is this image cached?", but cannot answer +"which cached glyph images belong to this `FontId`?" + +## Goals + +- Track glyph image ownership by `FontId`. +- Allow renderer-local removal of all glyph images for a font. +- Keep `FigFont` as an ordinary ARC/ORC-managed value object. +- Work with regular `--mm:arc` and `--mm:orc`, not only `--mm:atomicArc`. +- Avoid calling GPU APIs from destructors or non-render threads. +- Preserve the current simple user-facing font API. + +## Non-Goals + +- Do not make every `FigFont` copy retain/release renderer resources. +- Do not require applications to compile with `--mm:atomicArc`. +- Do not immediately solve complete atlas memory reuse. Logical eviction and + physical atlas compaction can be separate phases. +- Do not evict user-loaded images just because font glyphs are evicted. + +## Image Ownership Metadata + +Add ownership metadata beside image ids: + +```nim +type + ImageOwnerKind* = enum + iokNone + iokFontGlyph + + ImageOwner* = object + case kind*: ImageOwnerKind + of iokNone: + discard + of iokFontGlyph: + fontId*: FontId + glyphId*: FontGlyphId + lcdFiltering*: bool + subpixelVariant*: uint8 + + ImageCacheEntry* = object + rect*: Rect + owner*: ImageOwner + width*, height*: int +``` + +Backends can keep the existing `entries` table for drawing compatibility and add +a richer side table: + +```nim +imageEntries: Table[Hash, ImageCacheEntry] +fontImages: Table[FontId, HashSet[Hash]] +``` + +When a glyph image is uploaded, the backend records: + +- image id to atlas rect +- image id to owner metadata +- font id to image id + +For non-font images, `owner.kind` is `iokNone`. + +## Upload API + +Extend `ImgObj` so queued uploads can carry ownership: + +```nim +type + ImgObj* = object + id*: ImageId + owner*: ImageOwner + case kind*: ImgKind + of FlippyImg: + flippy*: Flippy + of PixieImg: + pimg*: Image +``` + +Glyph generation can pass owner metadata through both upload paths: + +```nim +proc glyphImageOwner( + glyph: GlyphPosition, + lcdFiltering: bool, + subpixelVariant: int, +): ImageOwner = + ImageOwner( + kind: iokFontGlyph, + fontId: glyph.fontId, + glyphId: glyph.glyphId, + lcdFiltering: lcdFiltering, + subpixelVariant: uint8(subpixelVariant), + ) +``` + +Render-time misses currently call `ctx.putImage(glyphId, img)` directly. That +path should grow an overload or option object: + +```nim +method putImage*( + ctx: BackendContext, + key: Hash, + image: Image, + owner: ImageOwner, +) {.base.} +``` + +The existing overload can delegate with `ImageOwner(kind: iokNone)`. + +## Renderer Cache API + +Expose renderer-level helpers instead of requiring callers to know backend +internals: + +```nim +type + ImageCacheStats* = object + entries*: int + fontGlyphEntries*: int + logicalBytes*: int + atlasSize*: int + +proc clearFontImages*[BackendState]( + renderer: FigRenderer[BackendState], + fontId: FontId, +): int + +proc imageCacheStats*[BackendState]( + renderer: FigRenderer[BackendState], +): ImageCacheStats +``` + +Backend methods: + +```nim +method removeImage*(ctx: BackendContext, key: Hash): bool {.base.} +method clearFontImages*(ctx: BackendContext, fontId: FontId): int {.base.} +method imageCacheStats*(ctx: BackendContext): ImageCacheStats {.base.} +``` + +`clearFontImages` should flush pending draw batches first, then remove the image +ids from `entries`, `imageEntries`, and `fontImages`. + +## Font Lease + +Automatic cleanup should use a separate lease object, not hooks on `FigFont`. + +`FigFont` is copied through styles, layout spans, bindings, and tests. Giving it +custom refcount hooks would make ordinary value copies affect renderer cache +state. A separate lease makes the lifetime contract explicit while keeping the +existing font value API intact. + +Sketch: + +```nim +type + FontLease* = object + p: ptr FontLeasePayload + + FontLeasePayload = object + refs: Atomic[int] + fontId: FontId + +proc retainFont*(fontId: FontId): FontLease +proc retainFont*(font: FigFont): FontLease +proc fontId*(lease: FontLease): FontId +``` + +The payload should use `allocShared` / `deallocShared` because it may cross +threads. The payload should only store simple shared-safe data such as `FontId` +and an atomic count. It should not store `FigFont`, `seq`, `string`, `Table`, or +renderer objects. + +Ownership hooks follow the existing shared-handle pattern used by ARC/ORC code: + +```nim +proc `=destroy`*(lease: FontLease) = + if lease.p != nil: + if lease.p.refs.fetchSub(1, moAcquireRelease) == 1: + enqueueFontRelease(lease.p.fontId) + deallocShared(lease.p) + +proc `=wasMoved`*(lease: var FontLease) = + lease.p = nil + +proc `=dup`*(src: FontLease): FontLease = + if src.p != nil: + discard src.p.refs.fetchAdd(1, moRelaxed) + result.p = src.p + +proc `=copy`*(dest: var FontLease, src: FontLease) = + if src.p != nil: + discard src.p.refs.fetchAdd(1, moRelaxed) + `=destroy`(dest) + dest.p = src.p +``` + +The destructor must only enqueue a release request. It must not call renderer or +GPU APIs directly. + +## Release Queue + +Last lease release can happen on any ARC/ORC thread. Renderer cleanup must happen +on the render thread. Use a small queue: + +```nim +type + FontReleaseRequest* = object + fontId*: FontId + +var fontReleaseChan = newRChan[FontReleaseRequest](1024) +``` + +At the beginning of `renderRoot`, drain pending requests and mark zero-ref fonts +as eviction candidates: + +```nim +while fontReleaseChan.tryRecv(req): + ctx.markFontImagesUnused(req.fontId) +``` + +The renderer can either clear immediately or wait until cache pressure: + +- immediate: simplest and deterministic +- delayed: avoids churn when a font disappears for one frame and returns +- pressure-based: best long-term behavior for large documents or editors + +The recommended default is delayed eviction with a short grace period measured +in frames. + +## Atlas Reuse + +Phase one can remove `entries` and metadata. That makes glyphs logically evicted: +future draws will miss and regenerate them. It does not reclaim atlas pixels +because current backends allocate atlas space monotonically through a height-map +allocator. + +Physical memory reuse needs one of these follow-up designs: + +- free-list allocator: track free rectangles and reuse space for equal or smaller + images +- page atlas: store glyphs in pages and release whole empty pages +- periodic rebuild: keep source metadata for live entries, create a new atlas, + and re-upload live images + +For glyphs, rebuild is practical because glyph images can be regenerated from +`fontId + glyphId + lcdFiltering + subpixelVariant`. For user-loaded images, +rebuild requires retained source pixels or a reload path. User images should +stay out of font-driven eviction unless they also have rebuild metadata. + +## Suggested Implementation Phases + +1. Add `ImageOwner`, `ImageCacheEntry`, and backend side tables. +2. Route glyph uploads through owner-aware `loadImage` / `putImage` paths. +3. Add `clearFontImages` and `imageCacheStats`. +4. Add tests for owner metadata and logical eviction with a fake or minimal + backend. +5. Add `FontLease` and the font release queue. +6. Drain release requests at render-frame boundaries. +7. Add delayed or pressure-based eviction policy. +8. Add atlas reuse or rebuild after logical eviction is stable. + +## Open Questions + +- Should `retainFont(FigFont)` register the font in `fontTable`, or require the + caller to pass an already-resolved `FontId`? +- Should text layout objects hold `FontLease` values, or should higher-level UI + widgets own leases? +- Should zero-ref font glyphs be cleared immediately in tests but delayed in + runtime builds? +- Should font eviction also clear shaped-layout caches if those are added later? +- Should fallback fonts retain separate leases, or should a primary font lease + retain its full fallback chain? + +## Recommended Public Shape + +Keep normal drawing code unchanged: + +```nim +let font = FigFont(typefaceId: ubuntu, size: 18) +node.textLayout = typeset(box, [(fs(font), "Hello")], ...) +``` + +Add explicit cache ownership only where an application wants deterministic font +cache lifetime: + +```nim +block: + let fontLease = retainFont(font) + drawDocumentWith(font) +# Release is enqueued when fontLease leaves scope. +``` + +For most users, renderer cache policy should be automatic. `FontLease` is mainly +for editors, document viewers, font pickers, and long-running applications that +load many font configurations over time. diff --git a/docs/font_shaping.md b/docs/font_shaping.md index e9b1a8f..94ac63d 100644 --- a/docs/font_shaping.md +++ b/docs/font_shaping.md @@ -1,217 +1,259 @@ -# Font Shaping Plan +# Font Shaping -This plan describes how to add HarfBuzz-based shaping as a compile-time -alternative to the current Pixie text path. +FigDraw text layout is glyph-id-first. `fontId + glyphId` is the render and +cache identity; source runes remain available for compatibility, diagnostics, +selection, and hit testing. -## Goals +Harfbuzzy is an optional shaping backend. FigDraw owns the public data model, +backend selection, layout conversion, source mapping, wrapping policy, caret +helpers, and raster dispatch. Harfbuzzy stays behind FigDraw adapters. -- Keep the existing FigDraw text API usable for callers. -- Support shaped glyph output: glyph ids, advances, offsets, and source - clusters. -- Keep Pixie as the default font backend until the HarfBuzz path is complete. -- Avoid leaking HarfBuzz handles or FFI details into public FigDraw node APIs. -- Preserve current glyph atlas, LCD filtering, subpixel positioning, selection, - and renderer behavior where possible. +## Backend Modes -## Current Design +`figdrawTextBackend` is a compile-time string define: -FigDraw currently uses Pixie for three separate jobs: +```nim +const figdrawTextBackend* {.strdefine.} = "pixie" +``` -- Font loading and metrics in `src/figdraw/common/typefaces.nim`. -- Text layout in `src/figdraw/common/fontutils.nim` via `pixie.typeset`. -- Glyph image generation in `src/figdraw/common/fontglyphs.nim`, keyed by - `(fontId, rune, filtering, subpixelVariant)`. +Supported modes: -That works for simple Unicode glyph lookup, but shaped text needs glyph identity -from the font rather than Unicode runes. Arabic joining, ligatures, Hebrew marks, -and OpenType substitutions can all produce glyph ids that do not map cleanly to a -single input rune. +- `pixie`: Default Pixie layout and Pixie rasterization. Glyph ids are stable + synthetic ids derived from source runes. +- `hybrid`: Harfbuzzy layout converted to FigDraw arrangements while rendering + through Pixie-compatible rune rasterization where possible. This is useful for + diagnostics, but is not a complex-script rendering target. +- `harfbuzzy`: Harfbuzzy shaping and FigDraw's glyph-id raster provider. -## Backend Selection +`fontutils.typeset` remains the public entry point and delegates to the selected +backend. -Add a compile-time text backend switch: +## Modules -```nim -const fontBackend {.strdefine: "figdraw.fontBackend".} = "pixie" - -when fontBackend == "harfbuzz": - import ./textbackends/harfbuzz as textbackend -elif fontBackend == "pixie": - import ./textbackends/pixie as textbackend -else: - {.error: "unknown figdraw.fontBackend".} -``` +- `common/fonttypes.nim`: Backend-neutral public data types and source mapping + helpers. +- `common/typefaces.nim`: Font loading, font ids, static registry, and backend + dispatch. +- `common/textbackends/pixie.nim`: Pixie layout backend. +- `common/textbackends/harfbuzzy.nim`: Harfbuzzy shaping adapter. +- `common/textrasters/pixie_raster.nim`: Pixie compatibility raster provider. +- `common/textrasters/glyphid_raster.nim`: Glyph-id raster provider using + HarfBuzz draw callbacks and Pixie path filling. +- `common/fontglyphs.nim`: Glyph iteration, glyph cache keys, and raster + dispatch. + +## Public Data + +`GlyphArrangement.arrangedGlyphs` is the canonical placement data. The legacy +parallel arrays `runes`, `positions`, and `selectionRects` remain populated for +current callers. -The public `typeset`, `loadTypeface`, and `FigFont` APIs should remain stable. -Backend-specific handle types stay private to backend modules. +Important fields: -## Module Layout +- `FontGlyphId`: Font-scoped glyph id. In Harfbuzzy mode this is the HarfBuzz + glyph codepoint. In Pixie mode it is synthetic. +- `GlyphSourceRange`: Half-open byte and rune source ranges for a shaped glyph. +- `ArrangedGlyph.rune`: Cheap representative source rune. This is useful for + compatibility and debugging, but callers must not treat it as a one-to-one + source mapping. +- `GlyphArrangement.sourceRunes`: Decoded source text for range-aware callers. -Proposed split: +`FigFont` carries shaping controls in backend-neutral terms: -- `common/fonttypes.nim` - Backend-neutral public data types. -- `common/typefaces.nim` - Public font loading API, static registry, ids, and backend dispatch. -- `common/textbackends/pixie.nim` - Current Pixie implementation moved behind the backend interface. -- `common/textbackends/harfbuzz.nim` - HarfBuzz shaping implementation using `../harfbuzzy`. -- `common/fontglyphs.nim` - Backend-neutral glyph iteration, glyph cache keys, and rasterization dispatch. -- `common/textbidi.nim` - Later bidi/run-itemization layer. Not part of the first HarfBuzz slice. +- `fallbackTypefaceIds`: Ordered fallback typeface ids. Harfbuzzy shaping tries + the primary typeface first, then fallbacks for unsupported shaped runs. +- `features`: OpenType feature settings such as `fontFeature("liga", 0)` or + `fontFeature("kern")`. +- `variations`: OpenType variable-axis coordinates such as + `fontVariation("wght", 650.0'f32)`. -## Data Model +These fields are part of `FigFont` hashing, so shaped layout, glyph cache ids, +and glyph-id rasterization stay separated by fallback chain, feature set, and +variable-font coordinates. -Add backend-neutral shaped glyph data: +`GlyphPosition`, yielded by `glyphs(arrangement)`, mirrors the glyph-id-first +shape used by render code: ```nim type - GlyphIndex* = distinct uint32 - - ArrangedGlyph* = object + GlyphPosition* = ref object fontId*: FontId - glyphId*: GlyphIndex + glyphId*: FontGlyphId cluster*: uint32 + source*: GlyphSourceRange rune*: Rune + isWhitespace*: bool pos*: Vec2 - advance*: Vec2 - offset*: Vec2 + imageOffset*: Vec2 rect*: Rect + descent*: float32 + lineHeight*: float32 + fill*: Fill +``` + +Rendering uses `glyph.glyphId` for cache identity and keeps `glyph.rune` for +cheap whitespace checks and human-readable diagnostics. + +## Source Mapping + +Use source helpers instead of assuming one visual glyph equals one source rune. +Ligatures, combining marks, and mixed-direction visual runs can map one source +range to multiple glyphs, or multiple source runes to one glyph. + +Cheap glyph-to-source helpers: + +```nim +func sourceRune*(arrangement: GlyphArrangement, glyphIndex: int): Rune +func sourceRuneRange*(arrangement: GlyphArrangement, glyphIndex: int): Slice[int] +iterator sourceRunes*(arrangement: GlyphArrangement, glyphIndex: int): Rune ``` -Update `GlyphArrangement` to either store `glyphs*: seq[ArrangedGlyph]` or to -carry equivalent parallel arrays during migration. Keep the existing -`runes`, `positions`, and `selectionRects` populated until current renderer and -tests are moved over. - -`rune` should be treated as source/debug metadata. Rendering and caching must use -`glyphId`. - -## HarfBuzz Flow - -For each shaped run: - -1. Resolve `FigFont` to a backend font record. -2. Build HarfBuzz shape options from direction, script, language, flags, and - features. -3. Shape text with `harfbuzzy`. -4. Convert HarfBuzz font units to FigDraw pixels: - - ```nim - px = hbPosition.float32 * (font.size / face.upem.float32) - ``` - -5. Accumulate pen position from `xAdvance` and `yAdvance`. -6. Apply `xOffset` and `yOffset` to the glyph draw position. -7. Store `glyph.codepoint` as `GlyphIndex`. -8. Store `glyph.cluster` for selection, hit testing, and source mapping. - -HarfBuzz lays out one run. FigDraw remains responsible for paragraph layout, -line wrapping, horizontal alignment, vertical alignment, min/max content, and -selection rectangles. - -## Glyph Rasterization - -The renderer can stay mostly unchanged if `GlyphPosition` becomes glyph-id -based. - -Required changes: - -- Change glyph cache key from `(fontId, rune, filtering, subpixelVariant)` to - `(fontId, glyphId, filtering, subpixelVariant)`. -- Skip invisible glyphs by glyph metadata or extents, not only by - `rune.isWhiteSpace`. -- Render glyph images by `fontId + glyphId`. - -Rasterization options: - -1. Use HarfBuzz `hb-raster` to render glyph masks or BGRA color glyph images. - HarfBuzz 14.x exposes CPU raster APIs such as `hb_raster_draw_glyph`, - `hb_raster_draw_render`, and `hb_raster_paint_glyph`. -2. Use HarfBuzz `hb-gpu` for direct GPU outline/color-glyph rendering. This is - a larger renderer integration because FigDraw would upload HarfBuzz-encoded - glyph blobs and use HarfBuzz shader snippets instead of the current bitmap - atlas path. -3. Extend Pixie/OpenType to expose `getGlyphPath(glyphId)` and keep Pixie as the - rasterizer. This is still useful if FigDraw wants to keep all glyph cache - output as Pixie `Image`s. -4. Add a FreeType-backed rasterization path later. -5. Keep a temporary rune path only for Pixie compatibility. This does not solve - Arabic shaping or ligatures. - -The first useful HarfBuzz implementation should use either HarfBuzz `hb-raster` -or Pixie glyph-id paths, depending on whether it is easier to extend -`../harfbuzzy` or Pixie's OpenType surface first. HarfBuzz `hb-gpu` should be a -separate later project because it bypasses FigDraw's current image-atlas model. - -## Bidi - -HarfBuzz does not perform bidi processing. The first slice can support explicit -single-direction runs. - -Full mixed-direction support needs: - -- Paragraph bidi analysis, probably FriBidi or ICU. -- Visual run ordering. -- Logical-to-visual and visual-to-logical cluster mapping. -- Line-level reordering after wrapping. - -This should live outside the HarfBuzz backend so the shaping backend receives -same-direction runs. - -## `harfbuzzy` Gaps - -Before relying on `../harfbuzzy` for FigDraw, add or verify: - -- `addUtf8(text, itemOffset, itemLength)` so shaping runs can use full paragraph - context. -- Public buffer cluster-level setters around existing raw - `hb_buffer_set_cluster_level`. -- Proper glyph flag extraction, especially `unsafe_to_break`. -- `typefaceFromBlob` or `initTypeface(data)` for FigDraw's static typeface - registry. -- A stable way to retrieve face `upem`, extents, glyph extents, and glyph - advances without exposing raw handles. -- Bindings and wrappers for HarfBuzz rendering APIs if FigDraw uses HarfBuzz for - rasterization: - - `hb-draw` / `hb_font_draw_glyph_or_fail` for vector outlines. - - `hb-raster` for CPU glyph masks and BGRA color glyph output. - - `hb-gpu` only if FigDraw adopts HarfBuzz's GPU glyph encoding path. - -## Migration Phases - -1. Add `GlyphIndex` and shaped glyph fields while preserving existing Pixie - behavior. -2. Move the current Pixie implementation behind `textbackends/pixie.nim`. -3. Change glyph cache and renderer code to use `glyphId`. -4. Add glyph-id rasterization through HarfBuzz `hb-raster` or Pixie glyph-id - paths. -5. Add `textbackends/harfbuzz.nim` for unidirectional runs. -6. Add tests comparing Latin Pixie and HarfBuzz layout for simple text. -7. Add Arabic and Hebrew fixture tests with known fonts. -8. Add bidi itemization and mixed-direction selection tests. - -## Tests - -Focused tests should cover: - -- Existing Pixie backend behavior under default build flags. -- `-d:figdraw.fontBackend=harfbuzz` compile and smoke tests. -- Static font registry loading through the HarfBuzz backend. -- Glyph cache separation by glyph id, LCD filtering, and subpixel variant. -- Arabic shaping with a font such as Noto Naskh Arabic. -- Hebrew marks with a font such as Noto Sans Hebrew. -- Ligature clusters and selection rectangles. -- Mixed LTR/RTL text after bidi support is added. - -## Open Questions - -- Should `GlyphArrangement` expose shaped glyphs directly, or keep an iterator as - the only stable access surface? -- Should line wrapping happen before shaping for simple text or after shaping - using HarfBuzz `unsafe_to_break` flags? -- Which bidi dependency should FigDraw prefer: FriBidi, ICU, or a small Nim - implementation? -- Should HarfBuzz be a package feature, a compile-time define only, or both? +Range selection and hit testing helpers: + +```nim +func glyphRangeFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): Slice[int] + +func glyphRangeForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): Slice[int] + +func glyphSelectionRectsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] + +func glyphSelectionRectsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] + +func selectionBandsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] + +func selectionBandsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] + +func selectionRectsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] + +func selectionRectsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] + +func glyphIndexAt*(arrangement: GlyphArrangement, point: Vec2): int +func sourceRuneRangeAt*(arrangement: GlyphArrangement, point: Vec2): Slice[int] +``` + +`selectionRectsFor` returns merged visual selection bands for source-rune ranges. +It groups selected glyphs by visual line fragment and uses the full line height +for each band, which avoids shaped glyph boxes producing uneven or overlapping +selection paint. Use `glyphSelectionRectsFor` when a caller needs the raw glyph +rectangles for diagnostics or fine-grained hit-test checks. The `RawBytes` +variants are for lower-level callers that already have byte offsets. + +Caret helpers expose source insertion indices instead of glyph indices: + +```nim +type + TextCaretAffinity* = enum + CaretLeading + CaretInside + CaretTrailing + + TextCaretPosition* = object + sourceRune*: int + glyphIndex*: int + lineIndex*: int + affinity*: TextCaretAffinity + pos*: Vec2 + rect*: Rect + +func caretPositionsFor*( + arrangement: GlyphArrangement, sourceRune: int +): seq[TextCaretPosition] + +func nearestSourceRuneForCaretPoint*( + arrangement: GlyphArrangement, point: Vec2 +): int +``` + +`caretPositionsFor` can return more than one visual caret rectangle at +bidi boundaries. `nearestSourceRuneForCaretPoint` performs the inverse query for +local hit testing. + +## Harfbuzzy Layout + +The Harfbuzzy adapter converts shaped runs into FigDraw data: + +- `glyph.codepoint` becomes `FontGlyphId`. +- `glyph.cluster` is retained for source mapping and break logic. +- The adapter shapes through a Harfbuzzy `ShapeContext` built from the primary + typeface plus `FigFont.fallbackTypefaceIds`. +- OpenType features from `FigFont.features` are passed to paragraph shaping. +- Variable axes from `FigFont.variations` are applied to each Harfbuzzy font + before shaping. +- A single styled input span can become multiple FigDraw spans when fallback + picks different typefaces. Each emitted span keeps the input fill and stores + the actual shaped `fontId` used by its glyph run. +- HarfBuzz glyph flags are consumed inside the adapter so preferred wrapping can + respect unsafe-to-break metadata without exposing HarfBuzz-specific flags. +- Source byte and rune ranges are stored in `GlyphSourceRange`. +- Advances and offsets are scaled by `font.size / face.upem`. +- `imageOffset` comes from glyph extents so raster images can include negative + bearings while baseline placement stays stable. + +The backend wraps greedily over shaped glyphs. It prefers whitespace clusters +when the next shaped glyph is safe to break before, recognizes soft hyphen, +zero-width space, hyphen-like separators, and common CJK/Kana/Hangul adjacent +break opportunities, and falls back to hard shaped-glyph boundaries for +overlong text. It never splits inside a shaped glyph. + +Line slices are aligned after wrapping. Vertical alignment is applied to the +whole arrangement. When `minContent` is enabled, Harfbuzzy expands the alignment +height to the wrapped content height before vertical alignment so bottom- or +middle-aligned text does not shift above the layout bounds. + +## Rendering + +Glyph cache identity is glyph-id-based: + +```nim +proc hash*(glyph: GlyphPosition, lcdFiltering = false, subpixelVariant = 0): Hash = + hash((2344, glyph.fontId, glyph.glyphId, lcdFiltering, subpixelVariant)) +``` + +Raster dispatch follows the selected backend: + +- `harfbuzzy` renders by `fontId + glyphId` through the glyph-id raster + provider. Variable axes stored on the resolved `FigFont` are applied before + drawing glyph outlines. +- `pixie` and `hybrid` render through Pixie's rune raster path. + +Selection and hit testing use source ranges, not glyph ids. + +## Regression Coverage + +The test suite covers: + +- Shaped glyph ids and source ranges. +- Ligature source mapping and source-rune iteration. +- Wrapping at shaped glyph boundaries. +- Preserving ligature ranges on one line. +- CJK wrapping without whitespace using the dependency test font. +- Combining marks and Hebrew marks through source-range selection and hit + testing. +- Mixed LTR/RTL source-range hit testing and caret-position helpers. +- Font fallback preserving fallback `fontId` on shaped runs. +- OpenType feature control for ligature shaping. +- Variable axes carrying through shaped font ids. +- Arabic shaping when a suitable named system Arabic font is available. +- Pure Harfbuzzy glyph-id rasterization. + +## Future Extensions + +- Full Unicode Line Breaking Algorithm tailoring for locale-specific wrapping. +- Widget-level editing policy, including arrow-key behavior, selection + extension, and IME integration. diff --git a/examples/fonts/FiraCode-wght.ttf b/examples/fonts/FiraCode-wght.ttf new file mode 100644 index 0000000..83f2b38 Binary files /dev/null and b/examples/fonts/FiraCode-wght.ttf differ diff --git a/examples/fonts/NotoNaskhArabic-wght.ttf b/examples/fonts/NotoNaskhArabic-wght.ttf new file mode 100644 index 0000000..a8d2867 Binary files /dev/null and b/examples/fonts/NotoNaskhArabic-wght.ttf differ diff --git a/examples/fonts/NotoSansDevanagari-wdth-wght.ttf b/examples/fonts/NotoSansDevanagari-wdth-wght.ttf new file mode 100644 index 0000000..e703d52 Binary files /dev/null and b/examples/fonts/NotoSansDevanagari-wdth-wght.ttf differ diff --git a/examples/fonts/NotoSansHebrew-wdth-wght.ttf b/examples/fonts/NotoSansHebrew-wdth-wght.ttf new file mode 100644 index 0000000..f31f73b Binary files /dev/null and b/examples/fonts/NotoSansHebrew-wdth-wght.ttf differ diff --git a/examples/fonts/OFL-FiraCode.txt b/examples/fonts/OFL-FiraCode.txt new file mode 100644 index 0000000..67a0efa --- /dev/null +++ b/examples/fonts/OFL-FiraCode.txt @@ -0,0 +1,93 @@ +Copyright 2014-2020 The Fira Code Project Authors (https://github.com/tonsky/FiraCode) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/OFL-NotoNaskhArabic.txt b/examples/fonts/OFL-NotoNaskhArabic.txt new file mode 100644 index 0000000..788fd32 --- /dev/null +++ b/examples/fonts/OFL-NotoNaskhArabic.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/OFL-NotoSansDevanagari.txt b/examples/fonts/OFL-NotoSansDevanagari.txt new file mode 100644 index 0000000..cd2cc5c --- /dev/null +++ b/examples/fonts/OFL-NotoSansDevanagari.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/OFL-NotoSansHebrew.txt b/examples/fonts/OFL-NotoSansHebrew.txt new file mode 100644 index 0000000..aa7598b --- /dev/null +++ b/examples/fonts/OFL-NotoSansHebrew.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/hebrew) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/README.md b/examples/fonts/README.md new file mode 100644 index 0000000..b9d0460 --- /dev/null +++ b/examples/fonts/README.md @@ -0,0 +1,20 @@ +# Example Fonts + +These fonts are bundled for the text shaping demo. + +- `NotoNaskhArabic-wght.ttf` + Source: +- `NotoSansHebrew-wdth-wght.ttf` + Source: +- `NotoSansDevanagari-wdth-wght.ttf` + Source: +- `FiraCode-wght.ttf` + Source: + +All are from the Google Fonts repository and are licensed under the SIL Open +Font License 1.1. The downloaded license texts are included as: + +- `OFL-NotoNaskhArabic.txt` +- `OFL-NotoSansHebrew.txt` +- `OFL-NotoSansDevanagari.txt` +- `OFL-FiraCode.txt` diff --git a/examples/siwin_shared.nim b/examples/siwin_shared.nim index 088c81f..734e20b 100644 --- a/examples/siwin_shared.nim +++ b/examples/siwin_shared.nim @@ -19,19 +19,19 @@ const TraceShared {.booldefine: "figdraw.traceSharedLib".}: bool = false let - redFill = ColorRGBA(r: 220, g: 40, b: 40, a: 155) - redStroke = ColorRGBA(r: 0, g: 0, b: 0, a: 155) - greenSolid = ColorRGBA(r: 40, g: 180, b: 90, a: 155) - greenGradStart = ColorRGBA(r: 18, g: 112, b: 64, a: 255) - greenGradMid = ColorRGBA(r: 40, g: 180, b: 90, a: 255) - greenGradStop = ColorRGBA(r: 78, g: 224, b: 188, a: 255) - blueSolid = ColorRGBA(r: 60, g: 90, b: 220, a: 155) - blueGradStart = ColorRGBA(r: 44, g: 72, b: 186, a: 255) - blueGradMid = ColorRGBA(r: 60, g: 90, b: 220, a: 255) - blueGradStop = ColorRGBA(r: 118, g: 168, b: 255, a: 255) - whiteStroke = ColorRGBA(r: 255, g: 255, b: 255, a: 210) - blackShadow = ColorRGBA(r: 0, g: 0, b: 0, a: 155) - blueInnerShadow = ColorRGBA(r: 40, g: 40, b: 60, a: 150) + redFill = colorRgba(220, 40, 40, 155) + redStroke = colorRgba(0, 0, 0, 155) + greenSolid = colorRgba(40, 180, 90, 155) + greenGradStart = colorRgba(18, 112, 64, 255) + greenGradMid = colorRgba(40, 180, 90, 255) + greenGradStop = colorRgba(78, 224, 188, 255) + blueSolid = colorRgba(60, 90, 220, 155) + blueGradStart = colorRgba(44, 72, 186, 255) + blueGradMid = colorRgba(60, 90, 220, 255) + blueGradStop = colorRgba(118, 168, 255, 255) + whiteStroke = colorRgba(255, 255, 255, 210) + blackShadow = colorRgba(0, 0, 0, 155) + blueInnerShadow = colorRgba(40, 40, 60, 150) redBorder = 5.0'f32 blueBorder = 4.0'f32 @@ -50,7 +50,7 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = quit("makeRenderTree: newRectangleFig returned nil", 1) when TraceShared: echo "trace: background created" - background.setFillColorRgba(ColorRGBA(r: 255, g: 255, b: 255, a: 155)) + background.setFillColorRgba(colorRgba(255, 255, 255, 155)) when TraceShared: echo "trace: background fill set" discard renders.addRoot(0, background) @@ -82,8 +82,10 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = jitterY = cos((t * 0.9'f32 + i.float32 * 0.2'f32).float64).float32 * 20 offsetX = min(max(baseX + jitterX, 0'f32), maxX) offsetY = min(max(baseY + jitterY, 0'f32), maxY) - sizePulseW = 0.5'f32 + 0.5'f32 * sin((t * 0.8'f32 + i.float32 * 0.07'f32).float64).float32 - sizePulseH = 0.5'f32 + 0.5'f32 * cos((t * 0.65'f32 + i.float32 * 0.09'f32).float64).float32 + sizePulseW = + 0.5'f32 + 0.5'f32 * sin((t * 0.8'f32 + i.float32 * 0.07'f32).float64).float32 + sizePulseH = + 0.5'f32 + 0.5'f32 * cos((t * 0.65'f32 + i.float32 * 0.09'f32).float64).float32 redW = 160.0'f32 + 100.0'f32 * sizePulseW redH = 110.0'f32 + 70.0'f32 * sizePulseH greenW = 160.0'f32 + 100.0'f32 * sizePulseH @@ -98,11 +100,18 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = echo "trace: red created" redFig.setFillColorRgba(redFill) let - cornerPulse = 0.5'f32 + 0.5'f32 * sin((t * 1.25'f32 + i.float32 * 0.11'f32).float64).float32 + cornerPulse = + 0.5'f32 + 0.5'f32 * sin((t * 1.25'f32 + i.float32 * 0.11'f32).float64).float32 c0 = 4.0'f32 + 26.0'f32 * cornerPulse c1 = 6.0'f32 + 22.0'f32 * (1.0'f32 - cornerPulse) - c2 = 8.0'f32 + 18.0'f32 * (0.5'f32 + 0.5'f32 * sin((t * 0.7'f32 + i.float32 * 0.05'f32).float64).float32) - c3 = 10.0'f32 + 16.0'f32 * (0.5'f32 + 0.5'f32 * cos((t * 0.8'f32 + i.float32 * 0.06'f32).float64).float32) + c2 = + 8.0'f32 + + 18.0'f32 * + (0.5'f32 + 0.5'f32 * sin((t * 0.7'f32 + i.float32 * 0.05'f32).float64).float32) + c3 = + 10.0'f32 + + 16.0'f32 * + (0.5'f32 + 0.5'f32 * cos((t * 0.8'f32 + i.float32 * 0.06'f32).float64).float32) when not TraceShared: redFig.setCorners(cornerRadii(c0, c1, c2, c3)) redFig.setStroke(redBorder, redStroke) @@ -114,7 +123,8 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = if i == 0: echo "trace: red added" - let greenFig = newRectangleFig(greenStartX + offsetX, greenStartY + offsetY, greenW, greenH) + let greenFig = + newRectangleFig(greenStartX + offsetX, greenStartY + offsetY, greenW, greenH) GC_ref(greenFig) when TraceShared: if i == 0: @@ -123,7 +133,9 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = when not TraceShared: if useGreenGradient: let axis = (if (i mod 4) < 2: fgaX else: fgaDiagTLBR) - greenFig.setFillLinear3(greenGradStart, greenGradMid, greenGradStop, axis, 128'u8) + greenFig.setFillLinear3( + greenGradStart, greenGradMid, greenGradStop, axis, 128'u8 + ) else: greenFig.setFillColorRgba(greenSolid) else: @@ -132,16 +144,26 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = if i == 0: echo "trace: green fill" let - greenCornerPulse = 0.5'f32 + 0.5'f32 * cos((t * 0.95'f32 + i.float32 * 0.08'f32).float64).float32 + greenCornerPulse = + 0.5'f32 + 0.5'f32 * cos((t * 0.95'f32 + i.float32 * 0.08'f32).float64).float32 g0 = 6.0'f32 + 22.0'f32 * greenCornerPulse g1 = 8.0'f32 + 18.0'f32 * (1.0'f32 - greenCornerPulse) - g2 = 10.0'f32 + 16.0'f32 * (0.5'f32 + 0.5'f32 * cos((t * 0.75'f32 + i.float32 * 0.04'f32).float64).float32) - g3 = 12.0'f32 + 14.0'f32 * (0.5'f32 + 0.5'f32 * sin((t * 0.85'f32 + i.float32 * 0.05'f32).float64).float32) - shadowPulse = 0.5'f32 + 0.5'f32 * sin((t * 1.1'f32 + i.float32 * 0.05'f32).float64).float32 + g2 = + 10.0'f32 + + 16.0'f32 * + (0.5'f32 + 0.5'f32 * cos((t * 0.75'f32 + i.float32 * 0.04'f32).float64).float32) + g3 = + 12.0'f32 + + 14.0'f32 * + (0.5'f32 + 0.5'f32 * sin((t * 0.85'f32 + i.float32 * 0.05'f32).float64).float32) + shadowPulse = + 0.5'f32 + 0.5'f32 * sin((t * 1.1'f32 + i.float32 * 0.05'f32).float64).float32 shadowBlur = max(0.0'f32, 6.0'f32 + 18.0'f32 * shadowPulse) shadowSpread = max(0.0'f32, 4.0'f32 + 20.0'f32 * (1.0'f32 - shadowPulse)) - shadowX = 6.0'f32 + 10.0'f32 * sin((t * 0.9'f32 + i.float32 * 0.03'f32).float64).float32 - shadowY = 6.0'f32 + 10.0'f32 * cos((t * 0.9'f32 + i.float32 * 0.03'f32).float64).float32 + shadowX = + 6.0'f32 + 10.0'f32 * sin((t * 0.9'f32 + i.float32 * 0.03'f32).float64).float32 + shadowY = + 6.0'f32 + 10.0'f32 * cos((t * 0.9'f32 + i.float32 * 0.03'f32).float64).float32 when not TraceShared: greenFig.setCorners(cornerRadii(g0, g1, g2, g3)) when TraceShared: @@ -154,13 +176,7 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = echo "trace: green clear shadows" when not TraceShared: greenFig.setShadow( - 0, - DropShadow, - shadowBlur, - shadowSpread, - shadowX, - shadowY, - blackShadow, + 0, DropShadow, shadowBlur, shadowSpread, shadowX, shadowY, blackShadow ) when TraceShared: if i == 0: @@ -170,7 +186,8 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = if i == 0: echo "trace: green added" - let blueFig = newRectangleFig(blueStartX + offsetX, blueStartY + offsetY, blueW, blueH) + let blueFig = + newRectangleFig(blueStartX + offsetX, blueStartY + offsetY, blueW, blueH) GC_ref(blueFig) when TraceShared: if i == 0: @@ -193,7 +210,8 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = if i == 0: echo "trace: blue stroke" let - insetPulse = 0.5'f32 + 0.5'f32 * sin((t * 1.05'f32 + i.float32 * 0.06'f32).float64).float32 + insetPulse = + 0.5'f32 + 0.5'f32 * sin((t * 1.05'f32 + i.float32 * 0.06'f32).float64).float32 insetBlur = max(0.0'f32, 8.0'f32 + 10.0'f32 * insetPulse) insetSpread = max(0.0'f32, 2.0'f32 + 10.0'f32 * (1.0'f32 - insetPulse)) insetX = 6.0'f32 * sin((t * 0.85'f32 + i.float32 * 0.04'f32).float64).float32 @@ -205,13 +223,7 @@ proc buildRenderTree(renders: Renders, w, h: float32, frame: int) = echo "trace: blue clear shadows" when not TraceShared: blueFig.setShadow( - 0, - InnerShadow, - insetBlur, - insetSpread, - insetX, - insetY, - blueInnerShadow, + 0, InnerShadow, insetBlur, insetSpread, insetX, insetY, blueInnerShadow ) when TraceShared: if i == 0: @@ -262,23 +274,13 @@ when isMainModule: let title = "Siwin RenderList (Nim Shared Lib)" let app = newFigSiwinAppBinding( - 800'i32, - 600'i32, - title, - 512, - 1.0'f32, - false, - true, - 0'i32, - true, - false, - false, + 800'i32, 600'i32, title, 512, 1.0'f32, false, true, 0'i32, true, false, false ) if app.isNil: quit("Failed to create siwin app", 1) when TraceShared: - echo "trace: created siwin app backend=", app.siwinBackendName(), - " display=", app.siwinDisplayServerName() + echo "trace: created siwin app backend=", + app.siwinBackendName(), " display=", app.siwinDisplayServerName() app.siwinFirstStep() when TraceShared: echo "trace: first step" @@ -324,7 +326,7 @@ when isMainModule: hudY = hudMargin let hudRect = newRectangleFig(hudX, hudY, hudW, hudH) GC_ref(hudRect) - hudRect.setFillColorRgba(ColorRGBA(r: 0, g: 0, b: 0, a: 155)) + hudRect.setFillColorRgba(colorRgba(0, 0, 0, 155)) hudRect.setCorners(cornerRadii(8, 8, 8, 8)) discard renders.addRoot(0, hudRect) @@ -349,7 +351,7 @@ when isMainModule: if not fpsLayout.isNil: let hudText = newTextFig(hudTextX, hudTextY, hudTextW, hudTextH) GC_ref(hudText) - hudText.setFillColorRgba(ColorRGBA(r: 0, g: 0, b: 0, a: 0)) + hudText.setFillColorRgba(colorRgba(0, 0, 0, 0)) setFigTextLayoutBinding(hudText, fpsLayout) discard renders.addRoot(0, hudText) when TraceShared: @@ -373,8 +375,7 @@ when isMainModule: let avgMake = makeRenderTreeMsSum / max(1, fpsFrames).float let avgRender = renderFrameMsSum / max(1, fpsFrames).float echo "fps: ", - fps, " | elems: ", lastElementCount, - " | makeRenderTree avg(us): ", avgMake, + fps, " | elems: ", lastElementCount, " | makeRenderTree avg(us): ", avgMake, " | renderFrame avg(us): ", avgRender fpsFrames = 0 fpsStart = now diff --git a/examples/siwin_shared_c.c b/examples/siwin_shared_c.c index 1090370..464823c 100644 --- a/examples/siwin_shared_c.c +++ b/examples/siwin_shared_c.c @@ -57,39 +57,15 @@ static int env_enabled(const char *name, int default_value) { strcmp(value, "yes") == 0 || strcmp(value, "on") == 0; } -static char *take_error_string(void) { - GennyBuffer buffer = figdraw_take_error(); - intptr_t len = figdraw_genny_buffer_len(buffer); - const char *data = figdraw_genny_buffer_data(buffer); - char *result = (char *)calloc((size_t)len + 1, 1); - if (result != NULL && data != NULL && len > 0) { - memcpy(result, data, (size_t)len); - } - figdraw_genny_buffer_unref(buffer); - return result; -} - static int check_figdraw_error(const char *context) { if (!figdraw_check_error()) { return 0; } - char *message = take_error_string(); + const char *message = figdraw_take_error(); fprintf(stderr, "%s: %s\n", context, message != NULL ? message : "unknown error"); - free(message); return 1; } -static char *buffer_to_string(GennyBuffer buffer) { - intptr_t len = figdraw_genny_buffer_len(buffer); - const char *data = figdraw_genny_buffer_data(buffer); - char *result = (char *)calloc((size_t)len + 1, 1); - if (result != NULL && data != NULL && len > 0) { - memcpy(result, data, (size_t)len); - } - figdraw_genny_buffer_unref(buffer); - return result; -} - static int add_root_checked(Renders renders, FigRef fig) { figdraw_renders_add_root(renders, 0, fig); if (check_figdraw_error("figdraw_renders_add_root")) { @@ -103,7 +79,7 @@ static int build_render_tree(Renders renders, float width, float height, int fra float t = (float)frame * 0.02f; FigRef background = figdraw_new_rectangle_fig(0.0f, 0.0f, width, height); - if (background == NULL) { + if (background == 0) { fprintf(stderr, "new background fig failed\n"); return 1; } @@ -225,11 +201,11 @@ int main(void) { figdraw_set_fig_data_dir("data"); TypefaceRef typeface = figdraw_load_typeface_binding("Ubuntu.ttf"); - if (check_figdraw_error("figdraw_load_typeface_binding") || typeface == NULL) { + if (check_figdraw_error("figdraw_load_typeface_binding") || typeface == 0) { return 1; } FigFontRef fps_font = figdraw_new_fig_font_binding(typeface, 18.0f); - if (fps_font == NULL) { + if (fps_font == 0) { fprintf(stderr, "Failed to create fps font\n"); return 1; } @@ -237,17 +213,14 @@ int main(void) { FigSiwinAppRef app = figdraw_new_fig_siwin_app_binding( 800, 600, "Siwin RenderList (C Shared Lib)", 512, 1.0f, 0, 1, 0, 1, 0, 0); - if (check_figdraw_error("figdraw_new_fig_siwin_app_binding") || app == NULL) { + if (check_figdraw_error("figdraw_new_fig_siwin_app_binding") || app == 0) { return 1; } - char *backend = buffer_to_string(figdraw_fig_siwin_app_ref_siwin_backend_name(app)); - char *display = - buffer_to_string(figdraw_fig_siwin_app_ref_siwin_display_server_name(app)); + const char *backend = figdraw_fig_siwin_app_ref_siwin_backend_name(app); + const char *display = figdraw_fig_siwin_app_ref_siwin_display_server_name(app); printf("backend=%s display=%s\n", backend != NULL ? backend : "", display != NULL ? display : ""); - free(backend); - free(display); figdraw_fig_siwin_app_ref_siwin_first_step(app); if (check_figdraw_error("figdraw_fig_siwin_app_ref_siwin_first_step")) { @@ -255,7 +228,7 @@ int main(void) { } Renders renders = figdraw_new_renders(); - if (renders == NULL) { + if (renders == 0) { fprintf(stderr, "Failed to create renders\n"); return 1; } @@ -324,7 +297,7 @@ int main(void) { if (check_figdraw_error("figdraw_typeset_text_binding")) { return 1; } - if (fps_layout != NULL) { + if (fps_layout != 0) { FigRef hud_text = figdraw_new_text_fig(text_x, text_y, text_w, text_h); figdraw_fig_ref_set_fill_color_rgba(hud_text, (ColorRGBA){0, 0, 0, 0}); figdraw_set_fig_text_layout_binding(hud_text, fps_layout); diff --git a/examples/siwin_text.nim b/examples/siwin_text.nim index 8b71dd5..e3b1bd1 100644 --- a/examples/siwin_text.nim +++ b/examples/siwin_text.nim @@ -419,7 +419,7 @@ when isMainModule: let monoTypefaceId = loadTypeface("HackNerdFont-Regular.ttf") let monoFont = FigFont(typefaceId: monoTypefaceId, size: MonoFontSize) - let size = ivec2(900, 600) + let size = ivec2(900, 690) var frames = 0 var fpsFrames = 0 @@ -460,7 +460,6 @@ when isMainModule: onClose: proc(e: CloseEvent) = app_running = false, onResize: proc(e: ResizeEvent) = - appWindow.refreshUiScale(useAutoScale) redraw(), onKey: proc(e: KeyEvent) = if e.pressed and e.key == Key.g: diff --git a/examples/windy_text.nim b/examples/windy_text.nim index 890b6f9..12645fa 100644 --- a/examples/windy_text.nim +++ b/examples/windy_text.nim @@ -438,7 +438,7 @@ when isMainModule: let monoTypefaceId = loadTypeface("HackNerdFont-Regular.ttf") let monoFont = FigFont(typefaceId: monoTypefaceId, size: MonoFontSize) - let size = ivec2(900, 600) + let size = ivec2(900, 690) var frames = 0 var fpsFrames = 0 @@ -459,9 +459,6 @@ when isMainModule: proc redraw() = renderer.beginFrame() let sz = window.logicalSize() - let szOrig = window.size() - let factor = round(szOrig.x.float32 / size.x.float32, 1) - setFigUiScale factor let modeLine = textStatusLine(textSubpixelMode, lcdFilteringEnabled) var renders = makeRenderTree(sz.x, sz.y, uiFont, monoFont, modeLine) diff --git a/examples/windy_text_shaping_demo.nim b/examples/windy_text_shaping_demo.nim new file mode 100644 index 0000000..9520dc1 --- /dev/null +++ b/examples/windy_text_shaping_demo.nim @@ -0,0 +1,667 @@ +import std/[os, strutils, times, unicode] + +import chroma +import chronicles + +when defined(useWindex): + import windex +else: + import figdraw/windyshim + +import figdraw/commons +import figdraw/common/fonttypes +import figdraw/fignodes +import figdraw/figrender as glrenderer + +const + RunOnce {.booldefine: "figdraw.runOnce".}: bool = false + ExampleDir = currentSourcePath().parentDir + RepoDir = ExampleDir.parentDir + UbuntuFontFile = RepoDir / "data" / "Ubuntu.ttf" + ArabicFontFile = ExampleDir / "fonts" / "NotoNaskhArabic-wght.ttf" + HebrewFontFile = ExampleDir / "fonts" / "NotoSansHebrew-wdth-wght.ttf" + DevanagariFontFile = ExampleDir / "fonts" / "NotoSansDevanagari-wdth-wght.ttf" + CodeFontFile = ExampleDir / "fonts" / "FiraCode-wght.ttf" + +const + ArabicBody = + "السلام عليكم ورحمة الله وبركاته\n" & + "النص العربي يحتاج إلى تشكيل واتجاه صحيح ولف أسطر هادئ." + HebrewBody = + "שָׁלוֹם עוֹלָם וּבְרוּכִים הַבָּאִים\n" & + "טֶקְסְט עִבְרִי צָרִיךְ נִקּוּד, כִּוּוּן נָכוֹן וּשְׁבִירַת שׁוּרוֹת יַצִּיבָה." + DevanagariBody = + "नमस्ते दुनिया और आपका स्वागत है\n" & + "देवनागरी पाठ को मात्रा, संयुक्ताक्षर और स्थिर पंक्ति-विन्यास चाहिए." + +type DemoFonts = object + title: FigFont + body: FigFont + metric: FigFont + codePlain: FigFont + code: FigFont + arabic: FigFont + hebrew: FigFont + devanagari: FigFont + +type LigatureSample = object + label: string + unfused: string + fused: string + +proc requireFile(path: string) = + if not fileExists(path): + raise newException(IOError, "Missing demo asset: " & path) + +proc initDemoFonts(): DemoFonts = + for path in [ + UbuntuFontFile, ArabicFontFile, HebrewFontFile, DevanagariFontFile, CodeFontFile + ]: + requireFile(path) + + let + ubuntu = loadTypeface(UbuntuFontFile) + arabic = loadTypeface(ArabicFontFile) + hebrew = loadTypeface(HebrewFontFile) + devanagari = loadTypeface(DevanagariFontFile) + code = loadTypeface(CodeFontFile) + commonFeatures = @[fontFeature("kern"), fontFeature("liga")] + codePlainFeatures = + @[fontFeature("kern"), fontFeature("liga", 0), fontFeature("calt", 0)] + codeFeatures = @[fontFeature("kern"), fontFeature("liga"), fontFeature("calt")] + fallbackTypefaces = @[arabic, hebrew, devanagari] + + result = DemoFonts( + title: FigFont( + typefaceId: ubuntu, + size: 22.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + body: FigFont( + typefaceId: ubuntu, + size: 18.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + metric: FigFont( + typefaceId: ubuntu, + size: 13.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: commonFeatures, + ), + codePlain: FigFont( + typefaceId: code, + size: 24.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: codePlainFeatures, + variations: @[fontVariation("wght", 520.0'f32)], + ), + code: FigFont( + typefaceId: code, + size: 24.0'f32, + fallbackTypefaceIds: fallbackTypefaces, + features: codeFeatures, + variations: @[fontVariation("wght", 520.0'f32)], + ), + arabic: FigFont( + typefaceId: arabic, + size: 32.0'f32, + fallbackTypefaceIds: @[hebrew, devanagari, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32)], + ), + hebrew: FigFont( + typefaceId: hebrew, + size: 32.0'f32, + fallbackTypefaceIds: @[arabic, devanagari, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32), fontVariation("wdth", 96.0'f32)], + ), + devanagari: FigFont( + typefaceId: devanagari, + size: 32.0'f32, + fallbackTypefaceIds: @[arabic, hebrew, ubuntu], + features: commonFeatures, + variations: @[fontVariation("wght", 560.0'f32), fontVariation("wdth", 100.0'f32)], + ), + ) + +proc addRect( + renders: var Renders, + parent: FigIdx, + box: Rect, + fill: Fill, + corners = 0.0'f32, + zlevel = 0.ZLevel, + stroke = RenderStroke(), + shadows: array[ShadowCount, RenderShadow] = + [RenderShadow(), RenderShadow(), RenderShadow(), RenderShadow()], +): FigIdx {.discardable.} = + renders.addChild( + zlevel, + parent, + Fig( + kind: nkRectangle, + zlevel: zlevel, + screenBox: box, + fill: fill, + corners: [corners, corners, corners, corners], + stroke: stroke, + shadows: shadows, + ), + ) + +proc addTextLayout( + renders: var Renders, + parent: FigIdx, + box: Rect, + layout: GlyphArrangement, + zlevel = 0.ZLevel, +) = + discard renders.addChild( + zlevel, + parent, + Fig( + kind: nkText, zlevel: zlevel, screenBox: box, fill: clearColor, textLayout: layout + ), + ) + +proc textLayout( + box: Rect, + spans: openArray[(FontStyle, string)], + hAlign = Left, + vAlign = Top, + wrap = true, +): GlyphArrangement = + typeset( + rect(0, 0, box.w, box.h), + spans, + hAlign = hAlign, + vAlign = vAlign, + minContent = false, + wrap = wrap, + ) + +proc runeRange(text, phrase: string): Slice[int] = + let startByte = text.find(phrase) + if startByte < 0: + return 0 .. -1 + + let endByte = startByte + phrase.len + var + runeIndex = 0 + byteIndex = 0 + startRune = -1 + endRune = -1 + + while byteIndex < text.len: + if byteIndex == startByte: + startRune = runeIndex + if byteIndex < endByte: + endRune = runeIndex + else: + break + byteIndex += runeLenAt(text, byteIndex) + inc runeIndex + + if startRune < 0 or endRune < startRune: + return 0 .. -1 + startRune .. endRune + +proc addSourceHighlight( + renders: var Renders, + parent: FigIdx, + origin: Vec2, + layout: GlyphArrangement, + sourceRange: Slice[int], + fill: Fill, +) = + if sourceRange.a > sourceRange.b: + return + + for selection in layout.selectionRectsFor(sourceRange): + if selection.h <= 0: + continue + let box = rect( + origin.x + selection.x, + origin.y + selection.y, + max(selection.w, 2.0'f32), + selection.h, + ) + discard renders.addRect(parent, box, fill, corners = 4.0'f32) + +proc addCaretMarkers( + renders: var Renders, + parent: FigIdx, + origin: Vec2, + layout: GlyphArrangement, + sourceRune: int, + fill: Fill, +) = + for caret in layout.caretPositionsFor(sourceRune): + let box = + rect(origin.x + caret.pos.x - 1.0'f32, origin.y + caret.pos.y, 2, caret.rect.h) + discard renders.addRect(parent, box, fill, corners = 1.0'f32) + +proc layoutStats(name: string, layout: GlyphArrangement): string = + name & " glyphs " & $layout.arrangedGlyphs.len & " source " & $layout.sourceRunes.len & + " lines " & $layout.lines.len + +proc addText( + renders: var Renders, + parent: FigIdx, + box: Rect, + font: FigFont, + text: string, + fill: Fill, + hAlign = Left, + vAlign = Top, + wrap = false, +) = + let layout = textLayout( + box, [(fs(font, fill), text)], hAlign = hAlign, vAlign = vAlign, wrap = wrap + ) + renders.addTextLayout(parent, box, layout) + +proc addCenteredText( + renders: var Renders, + parent: FigIdx, + box: Rect, + font: FigFont, + text: string, + fill: Fill, +) = + let layout = textLayout( + box, [(fs(font, fill), text)], hAlign = Center, vAlign = Middle, wrap = false + ) + renders.addTextLayout(parent, box, layout) + +proc addSampleCard( + renders: var Renders, + root: FigIdx, + box: Rect, + title: string, + body: string, + highlightPhrase: string, + font: FigFont, + labelFont: FigFont, + metricFont: FigFont, + accent: Fill, + hAlign: FontHorizontal, + ligatures: seq[LigatureSample] = @[], +) = + let card = renders.addRect( + root, + box, + rgba(255, 255, 255, 255), + corners = 8.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 32).color), + shadows = [ + RenderShadow( + style: DropShadow, + blur: 20, + spread: 0, + x: 0, + y: 8, + fill: rgba(0, 0, 0, 24).color, + ), + RenderShadow(), + RenderShadow(), + RenderShadow(), + ], + ) + + let titleBox = rect(box.x + 22, box.y + 18, box.w - 44, 30) + renders.addText(card, titleBox, labelFont, title, rgba(40, 45, 50, 255)) + + let hasLigatures = ligatures.len > 0 + let + metricBox = rect(box.x + 22, box.y + box.h - 43, box.w - 44, 30) + ligatureH = 36.0'f32 + 38.0'f32 * ligatures.len.float32 + ligatureBox = + if hasLigatures: + rect(box.x + 22, metricBox.y - ligatureH - 14.0'f32, box.w - 44, ligatureH) + else: + rect(0, 0, 0, 0) + textBottom = + if hasLigatures: + ligatureBox.y - 12 + else: + metricBox.y - 12 + textBox = + rect(box.x + 22, box.y + 62, box.w - 44, max(24.0'f32, textBottom - box.y - 62)) + let layout = textLayout( + textBox, [(fs(font, rgba(18, 20, 24, 255)), body)], hAlign = hAlign, wrap = true + ) + + renders.addSourceHighlight( + card, + textBox.xy, + layout, + body.runeRange(highlightPhrase), + linear(rgba(80, 190, 255, 70), rgba(30, 100, 210, 48), axis = fgaY), + ) + renders.addCaretMarkers( + card, textBox.xy, layout, body.runeRange(highlightPhrase).a, rgba(33, 92, 185, 210) + ) + renders.addTextLayout(card, textBox, layout) + + if hasLigatures: + renders.addRect( + card, + ligatureBox, + linear(rgba(246, 248, 249, 255), rgba(231, 236, 239, 255), axis = fgaY), + corners = 5.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 22).color), + ) + let + labelW = min(86.0'f32, ligatureBox.w * 0.28'f32) + sampleW = max(44.0'f32, (ligatureBox.w - labelW - 32.0'f32) / 2.0'f32) + labelHeaderBox = rect(ligatureBox.x + 10, ligatureBox.y + 8, labelW, 16) + unfusedLabelBox = + rect(ligatureBox.x + labelW + 12, ligatureBox.y + 8, sampleW, 16) + fusedLabelBox = + rect(unfusedLabelBox.x + sampleW + 12, ligatureBox.y + 8, sampleW, 16) + sampleFont = FigFont( + typefaceId: font.typefaceId, + size: max(22.0'f32, min(font.size * 0.82'f32, 30.0'f32)), + fallbackTypefaceIds: font.fallbackTypefaceIds, + features: font.features, + variations: font.variations, + ) + renders.addText(card, labelHeaderBox, metricFont, "form", rgba(98, 106, 114, 225)) + renders.addText( + card, unfusedLabelBox, metricFont, "unfused", rgba(98, 106, 114, 225) + ) + renders.addText(card, fusedLabelBox, metricFont, "fused", rgba(98, 106, 114, 225)) + for i, ligature in ligatures: + let + rowY = ligatureBox.y + 27.0'f32 + 38.0'f32 * i.float32 + labelBox = rect(labelHeaderBox.x, rowY, labelW, 38) + unfusedBox = rect(unfusedLabelBox.x, rowY, sampleW, 38) + fusedBox = rect(fusedLabelBox.x, rowY, sampleW, 38) + renders.addText( + card, + labelBox, + metricFont, + ligature.label, + rgba(78, 86, 94, 235), + vAlign = Middle, + ) + renders.addCenteredText( + card, unfusedBox, sampleFont, ligature.unfused, rgba(24, 28, 32, 255) + ) + renders.addCenteredText( + card, fusedBox, sampleFont, ligature.fused, rgba(24, 28, 32, 255) + ) + + renders.addRect(card, metricBox, accent, corners = 5.0'f32) + renders.addText( + card, + metricBox, + metricFont, + layoutStats(title, layout), + rgba(255, 255, 255, 235), + hAlign = Center, + vAlign = Middle, + ) + +proc makeRenderTree*(w, h: float32, fonts: DemoFonts): Renders = + result = Renders() + let root = result.addRoot( + 0.ZLevel, + Fig( + kind: nkRectangle, + zlevel: 0.ZLevel, + screenBox: rect(0, 0, w, h), + fill: linear(rgba(236, 240, 241, 255), rgba(215, 222, 226, 255), axis = fgaY), + ), + ) + + let + pad = 28.0'f32 + titleHeight = 66.0'f32 + gap = 18.0'f32 + usableW = max(360.0'f32, w - pad * 2) + columnCount = + if usableW >= 1460.0'f32: + 4 + elif usableW >= 1120.0'f32: + 3 + elif usableW >= 760.0'f32: + 2 + else: + 1 + scriptCount = 3 + scriptRows = (scriptCount + columnCount - 1) div columnCount + cardW = (usableW - gap * (columnCount.float32 - 1.0'f32)) / columnCount.float32 + mixedMinH = 200.0'f32 + availableH = max(0.0'f32, h - pad * 2 - titleHeight - mixedMinH - gap) + topCardH = + max(190.0'f32, (availableH - gap * scriptRows.float32) / scriptRows.float32) + lowerY = pad + titleHeight + (topCardH + gap) * scriptRows.float32 + lowerH = max(0.0'f32, h - lowerY - pad) + + proc cardRect(index: int): Rect = + let + col = index mod columnCount + row = index div columnCount + rect( + pad + (cardW + gap) * col.float32, + pad + titleHeight + (topCardH + gap) * row.float32, + cardW, + topCardH, + ) + + let titleBox = rect(pad, pad, usableW, 34) + result.addText( + root, + titleBox, + fonts.title, + "FigDraw Text Shaping", + linear(rgba(30, 42, 58, 255), rgba(45, 92, 145, 255), axis = fgaX), + ) + + let backendBox = rect(pad, pad + 34, usableW, 24) + result.addText( + root, + backendBox, + fonts.metric, + "backend: " & figdrawTextBackend, + rgba(74, 84, 94, 255), + ) + + let arabicCard = cardRect(0) + result.addSampleCard( + root, + arabicCard, + "Arabic", + ArabicBody, + "العربي", + fonts.arabic, + fonts.body, + fonts.metric, + linear(rgba(21, 135, 115, 235), rgba(25, 92, 145, 235), axis = fgaX), + Right, + @[ + LigatureSample(label: "la", unfused: "ل + ا", fused: "لا"), + LigatureSample(label: "lm", unfused: "ل + م", fused: "لم"), + ], + ) + + let hebrewCard = cardRect(1) + result.addSampleCard( + root, + hebrewCard, + "Hebrew", + HebrewBody, + "עִבְרִי", + fonts.hebrew, + fonts.body, + fonts.metric, + linear(rgba(114, 68, 160, 235), rgba(58, 112, 188, 235), axis = fgaX), + Right, + ) + + let devanagariCard = cardRect(2) + result.addSampleCard( + root, + devanagariCard, + "Devanagari", + DevanagariBody, + "देवनागरी", + fonts.devanagari, + fonts.body, + fonts.metric, + linear(rgba(185, 96, 34, 235), rgba(118, 113, 34, 235), axis = fgaX), + Left, + @[ + LigatureSample(label: "ksha", unfused: "क् + ष", fused: "क्ष"), + LigatureSample(label: "rta", unfused: "र् + ट", fused: "र्ट"), + ], + ) + + let mixedCard = rect(pad, lowerY, usableW, lowerH) + let mixed = result.addRect( + root, + mixedCard, + rgba(252, 253, 253, 255), + corners = 8.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 32).color), + ) + result.addText( + mixed, + rect(mixedCard.x + 22, mixedCard.y + 18, mixedCard.w - 44, 30), + fonts.body, + "Mixed Fallback Runs", + rgba(40, 45, 50, 255), + ) + + let + mixedContentBox = + rect(mixedCard.x + 22, mixedCard.y + 58, mixedCard.w - 44, mixedCard.h - 80) + fallbackBox = rect(mixedContentBox.x, mixedContentBox.y, mixedContentBox.w, 40) + codeLabelBox = + rect(mixedContentBox.x, fallbackBox.y + fallbackBox.h + 10, mixedContentBox.w, 18) + codeBoxY = codeLabelBox.y + 22 + codeBox = rect( + mixedContentBox.x, + codeBoxY, + mixedContentBox.w, + max(64.0'f32, mixedContentBox.y + mixedContentBox.h - codeBoxY), + ) + let mixedText = + "FigDraw fallback: العربية + עברית + देवनागरी + English\n" & + "glyph ids, source ranges, wrapping, and caret positions" + let mixedLayout = textLayout( + fallbackBox, + [(fs(fonts.body, rgba(20, 22, 24, 255)), mixedText)], + hAlign = Left, + wrap = true, + ) + result.addTextLayout(mixed, fallbackBox, mixedLayout) + result.addText( + mixed, codeLabelBox, fonts.metric, "Coding ligatures", rgba(74, 84, 94, 235) + ) + result.addRect( + mixed, + codeBox, + linear(rgba(245, 247, 248, 255), rgba(231, 236, 239, 255), axis = fgaY), + corners = 5.0'f32, + stroke = RenderStroke(weight: 1.0'f32, fill: rgba(0, 0, 0, 22).color), + ) + let + codeText = "!= === !== <= >= -> => |> &&" + codeGap = 16.0'f32 + codeColW = max(80.0'f32, (codeBox.w - 24.0'f32 - codeGap) / 2.0'f32) + plainLabelBox = rect(codeBox.x + 12, codeBox.y + 8, codeColW, 16) + fusedLabelBox = + rect(plainLabelBox.x + codeColW + codeGap, codeBox.y + 8, codeColW, 16) + plainTextBox = rect(codeBox.x + 12, codeBox.y + 25, codeColW, codeBox.h - 31) + fusedTextBox = rect(fusedLabelBox.x, plainTextBox.y, codeColW, plainTextBox.h) + result.addText(mixed, plainLabelBox, fonts.metric, "unfused", rgba(98, 106, 114, 225)) + result.addText(mixed, fusedLabelBox, fonts.metric, "fused", rgba(98, 106, 114, 225)) + let plainCodeLayout = textLayout( + plainTextBox, + [(fs(fonts.codePlain, rgba(22, 28, 34, 255)), codeText)], + hAlign = Left, + wrap = false, + ) + result.addTextLayout(mixed, plainTextBox, plainCodeLayout) + let fusedCodeLayout = textLayout( + fusedTextBox, + [(fs(fonts.code, rgba(22, 28, 34, 255)), codeText)], + hAlign = Left, + wrap = false, + ) + result.addTextLayout(mixed, fusedTextBox, fusedCodeLayout) + +when isMainModule: + var appRunning = true + let + title = windyWindowTitle("FigDraw Text Shaping") + size = ivec2(1280, 800) + window = newWindyWindow(size = size, fullscreen = false, title = title) + + if getEnv("HDI") != "": + setFigUiScale getEnv("HDI").parseFloat() + else: + setFigUiScale window.contentScale() + if size != size.scaled(): + window.size = size.scaled() + + let fonts = initDemoFonts() + let renderer = + glrenderer.newFigRenderer(atlasSize = 512, backendState = WindyRenderBackend()) + renderer.setupBackend(window) + + info "Text shaping demo startup", + backend = figdrawTextBackend, + windowW = window.size().x, + windowH = window.size().y, + scale = window.contentScale() + + var + renders = makeRenderTree(0.0'f32, 0.0'f32, fonts) + lastSize = vec2(0.0'f32, 0.0'f32) + frames = 0 + fpsFrames = 0 + fpsStart = epochTime() + + proc redraw() = + renderer.beginFrame() + let sz = window.logicalSize() + if sz != lastSize: + lastSize = sz + renders = makeRenderTree(sz.x, sz.y, fonts) + renderer.renderFrame(renders, sz) + renderer.endFrame() + + window.onCloseRequest = proc() = + appRunning = false + window.onResize = proc() = + redraw() + + try: + while appRunning: + pollEvents() + redraw() + + inc frames + inc fpsFrames + let now = epochTime() + if now - fpsStart >= 1.0: + debug "Text shaping demo heartbeat", + fps = fpsFrames.float / (now - fpsStart), frames = frames + fpsFrames = 0 + fpsStart = now + + if RunOnce and frames >= 1: + appRunning = false + else: + when not defined(emscripten): + sleep(16) + finally: + when not defined(emscripten): + window.close() diff --git a/examples/windy_text_shaping_demo.nims b/examples/windy_text_shaping_demo.nims new file mode 100644 index 0000000..9bc6ee5 --- /dev/null +++ b/examples/windy_text_shaping_demo.nims @@ -0,0 +1 @@ +switch("define", "figdrawTextBackend=harfbuzzy") diff --git a/figdraw.nimble b/figdraw.nimble index 9fe085a..fe9b3e2 100644 --- a/figdraw.nimble +++ b/figdraw.nimble @@ -1,4 +1,4 @@ -version = "0.24.3" +version = "0.25.0" author = "Jaremy Creechley" description = "UI Engine for Nim" license = "MIT" @@ -25,24 +25,21 @@ when defined(macosx): when defined(linux) or defined(bsd) or defined(windows): requires "https://github.com/planetis-m/vulkan#b223dc9" +feature "harfbuzz": + requires "gh:elcritch/harfbuzzy >= 0.2.2" feature "lottie": requires "jsony" - feature "sdl2": requires "sdl2" - feature "windy": requires "windy" feature "surfer": requires "https://github.com/nim-windowing/surfer" feature "siwin": requires "siwin >= 1.0.1" - feature "vulkan": requires "https://github.com/planetis-m/vulkan#b223dc9" feature "metal": requires "https://github.com/elcritch/metalx#head" - feature "sharedlib": - requires "genny#head" - + requires "gh:treeform/genny#81d9659" diff --git a/src/figdraw/bindings/bindings.nim b/src/figdraw/bindings/bindings.nim index 167c6d4..1488a25 100644 --- a/src/figdraw/bindings/bindings.nim +++ b/src/figdraw/bindings/bindings.nim @@ -72,17 +72,22 @@ type inner: fnt.GlyphArrangement when ExportSiwinShim and not defined(emscripten): - type - FigSiwinAppRef* = ref object - window: siwinshim.Window - renderer: fgr.FigRenderer[siwinshim.SiwinRenderBackend] - autoScale: bool + type FigSiwinAppRef* = ref object + window: siwinshim.Window + renderer: fgr.FigRenderer[siwinshim.SiwinRenderBackend] + autoScale: bool proc newFig(): FigRef = FigRef(inner: fdn.Fig(kind: fdn.nkFrame)) proc initRgba(r, g, b, a: uint8): ColorRGBA = - rgba(r, g, b, a) + ColorRGBA(r: r, g: g, b: b, a: a) + +proc colorRgba(r, g, b, a: uint8): ColorRGBA = + initRgba(r, g, b, a) + +proc toChroma(color: ColorRGBA): chroma.ColorRGBA = + rgba(color.r, color.g, color.b, color.a) proc cornerRadii(topLeft, topRight, bottomLeft, bottomRight: float32): CornerRadii = CornerRadii( @@ -202,7 +207,9 @@ proc typesetTextBinding( proc figWithKind(src: fdn.Fig, kind: fdn.FigKind): fdn.Fig {.raises: [].} -proc setFigTextLayoutBinding(fig: FigRef, layout: GlyphLayoutRef) {.raises: [FigDrawError].} = +proc setFigTextLayoutBinding( + fig: FigRef, layout: GlyphLayoutRef +) {.raises: [FigDrawError].} = if fig.isNil or layout.isNil: return withFigDrawError: @@ -279,15 +286,13 @@ proc setFillColor(fig: FigRef, r, g, b, a: uint8) = proc setFillColorRgba(fig: FigRef, color: ColorRGBA) = returnIfNil fig - fig.inner.fill = fill(color) + fig.inner.fill = fill(color.toChroma()) -proc setFillLinear2(fig: FigRef, startColor, endColor: ColorRGBA, axis: FillGradientAxis) = +proc setFillLinear2( + fig: FigRef, startColor, endColor: ColorRGBA, axis: FillGradientAxis +) = returnIfNil fig - fig.inner.fill = linear( - startColor, - endColor, - axis = axis, - ) + fig.inner.fill = linear(startColor.toChroma(), endColor.toChroma(), axis = axis) proc setFillLinear3( fig: FigRef, @@ -297,9 +302,9 @@ proc setFillLinear3( ) = returnIfNil fig fig.inner.fill = linear( - startColor, - midColor, - endColor, + startColor.toChroma(), + midColor.toChroma(), + endColor.toChroma(), axis = axis, midPos = midPos, ) @@ -310,17 +315,13 @@ proc setRotation(fig: FigRef, rotation: float32) = proc setCorners(fig: FigRef, radii: CornerRadii) = returnIfNil fig - fig.inner.corners = [ - radii.topLeft, - radii.topRight, - radii.bottomLeft, - radii.bottomRight, - ] + fig.inner.corners = + [radii.topLeft, radii.topRight, radii.bottomLeft, radii.bottomRight] proc setStroke(fig: FigRef, weight: float32, color: ColorRGBA) = returnIfNil fig fig.ensureRectangle() - fig.inner.stroke = RenderStroke(weight: weight, fill: fill(color)) + fig.inner.stroke = RenderStroke(weight: weight, fill: fill(color.toChroma())) proc clearShadows(fig: FigRef) = returnIfNil fig @@ -340,12 +341,7 @@ proc setShadow( return fig.inner.shadows[shadowIndex.int] = RenderShadow( - style: style, - blur: blur, - spread: spread, - x: x, - y: y, - fill: fill(color), + style: style, blur: blur, spread: spread, x: x, y: y, fill: fill(color.toChroma()) ) proc newRenders(): Renders = @@ -355,12 +351,16 @@ proc ensureOpenGLInitialized() = when not defined(emscripten): startOpenGL(openglVersion) -proc newFigRendererBinding*(atlasSize: int, pixelScale: float32): FigRendererRef {.raises: [FigDrawError].} = +proc newFigRendererBinding*( + atlasSize: int, pixelScale: float32 +): FigRendererRef {.raises: [FigDrawError].} = withFigDrawError: ensureOpenGLInitialized() result = FigRendererRef(inner: fgr.newFigRenderer(atlasSize, pixelScale)) -proc renderFrameBinding*(renderer: FigRendererRef, renders: Renders, width, height: float32) {.raises: [FigDrawError].} = +proc renderFrameBinding*( + renderer: FigRendererRef, renders: Renders, width, height: float32 +) {.raises: [FigDrawError].} = if renderer.isNil or renders.isNil: return withFigDrawError: @@ -513,7 +513,9 @@ proc containsLayer(renders: Renders, zLevel: int8): bool = return false renders.contains(fdn.ZLevel(zLevel)) -proc addRoot(renders: Renders, zLevel: int8, root: FigRef): int16 {.raises: [FigDrawError].} = +proc addRoot( + renders: Renders, zLevel: int8, root: FigRef +): int16 {.raises: [FigDrawError].} = if renders.isNil or root.isNil: return -1'i16 withFigDrawError: @@ -529,12 +531,15 @@ proc insertRoot( var nodes = renders result = nodes.insertRoot(fdn.ZLevel(zLevel), root.inner, rootPos.Natural).int16 -proc addChild(renders: Renders, zLevel: int8, parentIdx: int16, child: FigRef): int16 {.raises: [FigDrawError].} = +proc addChild( + renders: Renders, zLevel: int8, parentIdx: int16, child: FigRef +): int16 {.raises: [FigDrawError].} = if renders.isNil or child.isNil: return -1'i16 withFigDrawError: var nodes = renders - result = nodes.addChild(fdn.ZLevel(zLevel), fdn.FigIdx(parentIdx), child.inner).int16 + result = + nodes.addChild(fdn.ZLevel(zLevel), fdn.FigIdx(parentIdx), child.inner).int16 proc insertChild( renders: Renders, zLevel: int8, parentIdx: int16, childPos: int, child: FigRef @@ -567,7 +572,9 @@ proc layerRootCount(renders: Renders, zLevel: int8): int {.raises: [FigDrawError except Exception as e: raiseFigDrawError(e) -proc getLayerNode(renders: Renders, zLevel: int8, nodeIdx: int16): FigRef {.raises: [FigDrawError].} = +proc getLayerNode( + renders: Renders, zLevel: int8, nodeIdx: int16 +): FigRef {.raises: [FigDrawError].} = if renders.isNil: return nil try: @@ -585,9 +592,9 @@ exportEnums: DirectionCorners ShadowStyle -exportObject chroma.ColorRGBA: +exportObject ColorRGBA: constructor: - initRgba(uint8, uint8, uint8, uint8) + colorRgba(uint8, uint8, uint8, uint8) exportObject CornerRadii: constructor: diff --git a/src/figdraw/common/fontglyphs.nim b/src/figdraw/common/fontglyphs.nim index bb8f829..6bd2d61 100644 --- a/src/figdraw/common/fontglyphs.nim +++ b/src/figdraw/common/fontglyphs.nim @@ -1,30 +1,34 @@ -import std/[os, unicode, sequtils, tables, strutils, sets, hashes] -import std/isolation +import std/[hashes, sequtils, unicode] import pkg/vmath import pkg/pixie import pkg/pixie/fonts -import pkg/chronicles -import ./rchannels import ./imgutils import ./shared import ./fonttypes -import ./typefaces +when figdrawTextBackend == "harfbuzzy": + import ./textrasters/glyphid_raster +import ./textrasters/pixie_raster + +export applyLcdFilter type GlyphPosition* = ref object ## Represents a glyph position after typesetting. fontId*: FontId + glyphId*: FontGlyphId + cluster*: uint32 + source*: GlyphSourceRange rune*: Rune + isWhitespace*: bool pos*: Vec2 # Where to draw the image character. + imageOffset*: Vec2 rect*: Rect descent*: float32 lineHeight*: float32 fill*: Fill -const - lcdFilterWeights = [8'i32, 77'i32, 86'i32, 77'i32, 8'i32] # FT_LCD_FILTER_DEFAULT - glyphVariantSubpixelSteps* = 10 +const glyphVariantSubpixelSteps* = 10 proc clampGlyphVariantSubpixelStep*(subpixelVariant: int): int {.inline.} = if subpixelVariant <= 0: @@ -35,42 +39,12 @@ proc toGlyphVariantSubpixelStep*(fractionalX: float32): int {.inline.} = let clamped = max(0.0'f32, min(fractionalX, 0.999'f32)) clampGlyphVariantSubpixelStep((clamped * glyphVariantSubpixelSteps.float32).int) -proc applyLcdFilter*(image: var Image) = - ## Applies FreeType's default 5-tap LCD filter horizontally. - if image.width <= 0 or image.height <= 0: - return - - let src = image.data - var filtered = newSeq[type(src[0])](src.len) - let maxX = image.width - 1 - - for y in 0 ..< image.height: - let rowStart = y * image.width - for x in 0 ..< image.width: - var sumR, sumG, sumB, sumA: int32 - for i, weight in lcdFilterWeights: - let sx = min(max(x + i - 2, 0), maxX) - let px = src[rowStart + sx] - sumR += px.r.int32 * weight - sumG += px.g.int32 * weight - sumB += px.b.int32 * weight - sumA += px.a.int32 * weight - - let idx = rowStart + x - filtered[idx] = src[idx] - filtered[idx].r = uint8((sumR + 128'i32) shr 8) - filtered[idx].g = uint8((sumG + 128'i32) shr 8) - filtered[idx].b = uint8((sumB + 128'i32) shr 8) - filtered[idx].a = uint8((sumA + 128'i32) shr 8) - - image.data = move(filtered) - proc hash*( glyph: GlyphPosition, lcdFiltering = false, subpixelVariant = 0 ): Hash {.inline.} = #result = hash((2344, glyph.fontId, glyph.rune, app.uiScale)) let variant = clampGlyphVariantSubpixelStep(subpixelVariant) - result = hash((2344, glyph.fontId, glyph.rune, lcdFiltering, variant)) + result = hash((2344, glyph.fontId, glyph.glyphId, lcdFiltering, variant)) proc generateGlyph*( glyph: GlyphPosition, @@ -79,7 +53,7 @@ proc generateGlyph*( force = false, upload = true, ): Image {.discardable.} = - if unicode.isWhiteSpace(glyph.rune): + if glyph.isWhitespace: return nil let @@ -89,82 +63,150 @@ proc generateGlyph*( if (not force) and hasImage(hashFill.ImageId): return nil - let - fontId = glyph.fontId - font = getPixieFont(fontId) - - var - text = $glyph.rune - arrangement = pixie.typeset( - @[newSpan(text, font)], - bounds = glyph.rect.wh.scaled(), - hAlign = CenterAlign, - vAlign = TopAlign, - wrap = false, + when figdrawTextBackend == "harfbuzzy": + renderGlyphIdGlyph( + hashFill.ImageId, + glyph.fontId, + glyph.glyphId, + glyph.rect, + glyph.descent, + glyph.imageOffset, + lcdFiltering = lcdFiltering, + subpixelVariant = variant, + subpixelSteps = glyphVariantSubpixelSteps, + upload = upload, + ) + else: + renderPixieGlyph( + hashFill.ImageId, + glyph.fontId, + glyph.rune, + glyph.rect, + lcdFiltering = lcdFiltering, + subpixelVariant = variant, + subpixelSteps = glyphVariantSubpixelSteps, + upload = upload, ) - if variant > 0: - let subpixelOffset = variant.float32 / glyphVariantSubpixelSteps.float32 - for i in 0 ..< arrangement.positions.len: - arrangement.positions[i].x += subpixelOffset - - let snappedBounds = arrangement.computeBounds().snapToPixels() - - let - lh = font.defaultLineHeight() - bounds = rect(0, 0, scaled(snappedBounds.w + snappedBounds.x), scaled(lh)) - - if bounds.w == 0 or bounds.h == 0: - debug "GEN IMG: ", rune = $glyph.rune, wh = repr wh, snapped = repr snappedBounds - return nil - try: - font.paint = parseHex"FFFFFF" - var image = newImage(bounds.w.int, bounds.h.int) - image.fillText(arrangement) - if lcdFiltering: - image.applyLcdFilter() - - # put into cache - if upload: - loadImage(hashFill.ImageId, image) - return image - except PixieError: - return nil +proc sourceRangesFor(runes: openArray[Rune]): seq[GlyphSourceRange] = + result = newSeq[GlyphSourceRange](runes.len) + var byteOffset = 0 + for i, rune in runes: + let byteLen = ($rune).len + result[i] = GlyphSourceRange( + byteStart: byteOffset, byteEnd: byteOffset + byteLen, runeStart: i, runeEnd: i + 1 + ) + byteOffset += byteLen + +proc buildArrangedGlyphs*( + runes: openArray[Rune], + positions: openArray[Vec2], + selectionRects: openArray[Rect], + spans: openArray[Slice[int]], + fonts: openArray[GlyphFont], +): seq[ArrangedGlyph] = + ## Builds Pixie-compatible arranged glyph records from parallel glyph arrays. + let sourceRanges = sourceRangesFor(runes) + result = newSeq[ArrangedGlyph](runes.len) + + for spanIndex, span in spans: + if spanIndex >= fonts.len: + continue + let + font = fonts[spanIndex] + start = max(span.a, 0) + stop = min(span.b, runes.len - 1) + if start > stop: + continue + + for idx in start .. stop: + let + rune = runes[idx] + pos = + if idx < positions.len: + positions[idx] + else: + vec2(0, 0) + selection = + if idx < selectionRects.len: + selectionRects[idx] + else: + rect(pos.x, pos.y, 0, 0) + result[idx] = ArrangedGlyph( + fontId: font.fontId, + glyphId: syntheticFontGlyphId(font.fontId, rune), + cluster: uint32(idx), + source: sourceRanges[idx], + rune: rune, + isWhitespace: unicode.isWhiteSpace(rune), + pos: pos, + advance: vec2(selection.w, 0), + offset: vec2(0, 0), + imageOffset: vec2(0, 0), + rect: selection, + ) iterator glyphs*(arrangement: GlyphArrangement): GlyphPosition = var idx = 0 + let arrangedGlyphCount = + if arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyphs.len + else: + arrangement.runes.len block: for i, span in arrangement.spans: + if span.a > span.b: + continue + if idx < span.a: + idx = span.a + if idx >= arrangedGlyphCount: + break + let gfont = arrangement.fonts[i] let spanColor = if i < arrangement.spanColors.len: arrangement.spanColors[i] else: fill(rgba(0, 0, 0, 255)) - while idx < arrangement.runes.len(): - let - pos = arrangement.positions[idx] - rune = arrangement.runes[idx] - selection = arrangement.selectionRects[idx] + while idx < arrangedGlyphCount and idx in span: + let arranged = + if arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyphs[idx] + else: + let rune = arrangement.runes[idx] + ArrangedGlyph( + fontId: gfont.fontId, + glyphId: syntheticFontGlyphId(gfont.fontId, rune), + cluster: uint32(idx), + source: GlyphSourceRange(runeStart: idx, runeEnd: idx + 1), + rune: rune, + isWhitespace: unicode.isWhiteSpace(rune), + pos: arrangement.positions[idx], + imageOffset: vec2(0, 0), + rect: arrangement.selectionRects[idx], + ) # Pixie arrangement positions are baseline positions; descentAdj stores # the baseline offset needed to convert to glyph image top-left. let descent = gfont.descentAdj yield GlyphPosition( - fontId: gfont.fontId, - rune: rune, - pos: pos, - rect: selection, + fontId: arranged.fontId, + glyphId: arranged.glyphId, + cluster: arranged.cluster, + source: arranged.source, + rune: arranged.rune, + isWhitespace: arranged.isWhitespace, + pos: arranged.pos, + imageOffset: arranged.imageOffset, + rect: arranged.rect, descent: descent, lineHeight: gfont.lineHeight, fill: spanColor, ) idx.inc() - if idx notin span: - break proc generateGlyphImages*(arrangement: GlyphArrangement, lcdFiltering = false) = ## returns Glyph's hash, will generate glyph if needed @@ -205,6 +247,10 @@ proc convertArrangement*( spans: spanSlices, fonts: gfonts, spanColors: uiSpans.mapIt(it[0].color), + sourceRunes: arrangement.runes, + arrangedGlyphs: buildArrangedGlyphs( + arrangement.runes, arrangement.positions, selectionRects, spanSlices, gfonts + ), runes: arrangement.runes, positions: arrangement.positions, selectionRects: selectionRects, diff --git a/src/figdraw/common/fonttypes.nim b/src/figdraw/common/fonttypes.nim index f5dd795..c0ccf30 100644 --- a/src/figdraw/common/fonttypes.nim +++ b/src/figdraw/common/fonttypes.nim @@ -12,6 +12,7 @@ type TypefaceId* = distinct Hash FontId* = distinct Hash GlyphId* = distinct Hash + FontGlyphId* = distinct uint32 FontName* = distinct string FontCase* = enum @@ -41,6 +42,16 @@ type descentAdj*: float32 ## The line height in pixels or autoLineHeight for the font's default line height. + FontFeature* = object + tag*: string ## OpenType feature tag, for example "liga" or "kern". + value*: uint32 ## Feature value. Most boolean features use 0 or 1. + start*: uint32 ## Inclusive glyph-range start for the feature. + ending*: uint32 ## Exclusive glyph-range end for the feature. + + FontVariation* = object + tag*: string ## OpenType variation axis tag, for example "wght" or "wdth". + value*: float32 ## Axis coordinate in the font's design-space units. + FigFont* = object typefaceId*: TypefaceId size*: float32 = 12.0'f32 ## Font size in pixels. @@ -50,17 +61,41 @@ type underline*: bool ## Apply an underline. strikethrough*: bool ## Apply a strikethrough. noKerningAdjustments*: bool ## Optionally disable kerning pair adjustments + fallbackTypefaceIds*: seq[TypefaceId] ## Ordered font fallback chain. + features*: seq[FontFeature] ## OpenType features applied while shaping. + variations*: seq[FontVariation] ## OpenType variable-axis coordinates. FontStyle* = object font*: FigFont color*: Fill + GlyphSourceRange* = object + byteStart*: int ## Inclusive source byte index. + byteEnd*: int ## Exclusive source byte index. + runeStart*: int ## Inclusive index into GlyphArrangement.sourceRunes. + runeEnd*: int ## Exclusive index into GlyphArrangement.sourceRunes. + + ArrangedGlyph* = object + fontId*: FontId + glyphId*: FontGlyphId + cluster*: uint32 + source*: GlyphSourceRange + rune*: Rune ## Cheap first/source rune for compatibility and diagnostics. + isWhitespace*: bool + pos*: Vec2 + advance*: Vec2 + offset*: Vec2 + imageOffset*: Vec2 ## Offset from baseline top-left to the raster image origin. + rect*: Rect + GlyphArrangement* = object 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. fonts*: seq[GlyphFont] ## The font for each span. spanColors*: seq[Fill] ## The fill for each span. + sourceRunes*: seq[Rune] ## The decoded source runes for glyph source ranges. + arrangedGlyphs*: seq[ArrangedGlyph] ## Glyph-id-first placement data. runes*: seq[Rune] ## The runes of the text. positions*: seq[Vec2] ## The positions of the glyphs for each rune. selectionRects*: seq[Rect] ## The selection rects for each glyph. @@ -68,6 +103,28 @@ type minSize*: Vec2 bounding*: Rect + TextCaretAffinity* = enum + CaretLeading + CaretInside + CaretTrailing + + TextCaretPosition* = object + sourceRune*: int ## Source insertion index in `GlyphArrangement.sourceRunes`. + glyphIndex*: int ## Visual glyph index that produced this caret position. + lineIndex*: int + affinity*: TextCaretAffinity + pos*: Vec2 ## Local caret top position. + rect*: Rect ## Local caret rectangle. + + SelectionSourceKind = enum + sskRunes + sskBytes + +const figdrawTextBackend* {.strdefine.} = "pixie" + +static: + doAssert figdrawTextBackend in ["pixie", "harfbuzzy", "hybrid"] + proc hash*(id: TypefaceId): Hash {.borrow.} proc `==`*(a, b: TypefaceId): bool {.borrow.} @@ -77,10 +134,575 @@ proc `==`*(a, b: FontId): bool {.borrow.} proc hash*(id: GlyphId): Hash {.borrow.} proc `==`*(a, b: GlyphId): bool {.borrow.} +proc hash*(id: FontGlyphId): Hash {.borrow.} +proc `==`*(a, b: FontGlyphId): bool {.borrow.} +proc `$`*(id: FontGlyphId): string {.borrow.} + proc hash*(name: FontName): Hash {.borrow.} proc `==`*(a, b: FontName): bool {.borrow.} proc `$`*(name: FontName): string {.borrow.} +func fontFeature*( + tag: string, value = 1'u32, start = 0'u32, ending = uint32.high +): FontFeature = + ## Creates an OpenType feature setting for Harfbuzz-backed shaping. + FontFeature(tag: tag, value: value, start: start, ending: ending) + +func fontVariation*(tag: string, value: float32): FontVariation = + ## Creates an OpenType variable-axis coordinate for Harfbuzz-backed fonts. + FontVariation(tag: tag, value: value) + +proc hash*(feature: FontFeature): Hash = + hash((feature.tag, feature.value, feature.start, feature.ending)) + +proc hash*(variation: FontVariation): Hash = + hash((variation.tag, variation.value)) + +func syntheticFontGlyphId*(fontId: FontId, rune: Rune): FontGlyphId {.inline.} = + ## Returns the Pixie-compatible synthetic glyph id for a source rune. + ## The id is interpreted together with fontId by render/cache code. + discard fontId + FontGlyphId(rune.uint32) + +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: + arrangement.runes[glyphIndex] + +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) + 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: + for i in sourceRange: + yield arrangement.sourceRunes[i] + else: + for i in sourceRange: + yield arrangement.runes[i] + +func sourceIntersects( + source: GlyphSourceRange, runeStart, runeEnd: int +): bool {.inline.} = + source.runeStart < runeEnd and runeStart < source.runeEnd + +func byteSourceIntersects( + source: GlyphSourceRange, byteStart, byteEnd: int +): bool {.inline.} = + source.byteStart < byteEnd and byteStart < source.byteEnd + +func glyphSource( + arrangement: GlyphArrangement, glyphIndex: int +): GlyphSourceRange {.inline.} = + if arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyphs[glyphIndex].source + else: + GlyphSourceRange( + runeStart: glyphIndex, + runeEnd: glyphIndex + 1, + byteStart: glyphIndex, + byteEnd: glyphIndex + 1, + ) + +func glyphRangeFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): Slice[int] = + ## Returns the inclusive glyph range touching an inclusive source-rune range. + ## Returns `0 .. -1` when no glyph intersects the source range. + if sourceRange.a > sourceRange.b: + return 0 .. -1 + + let + runeStart = max(sourceRange.a, 0) + runeEnd = sourceRange.b + 1 + glyphCount = + if arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyphs.len + else: + arrangement.runes.len + + result = 0 .. -1 + for glyphIndex in 0 ..< glyphCount: + if arrangement.glyphSource(glyphIndex).sourceIntersects(runeStart, runeEnd): + if result.a > result.b: + result = glyphIndex .. glyphIndex + else: + result.b = glyphIndex + +func glyphRangeForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): Slice[int] = + ## Returns the inclusive glyph range touching an inclusive raw source-byte range. + ## Returns `0 .. -1` when no glyph intersects the source range. + if byteRange.a > byteRange.b: + return 0 .. -1 + + let + byteStart = max(byteRange.a, 0) + byteEnd = byteRange.b + 1 + glyphCount = + if arrangement.arrangedGlyphs.len > 0: + arrangement.arrangedGlyphs.len + else: + arrangement.runes.len + + result = 0 .. -1 + for glyphIndex in 0 ..< glyphCount: + if arrangement.glyphSource(glyphIndex).byteSourceIntersects(byteStart, byteEnd): + if result.a > result.b: + result = glyphIndex .. glyphIndex + else: + result.b = glyphIndex + +func glyphCount(arrangement: GlyphArrangement): int {.inline.} = + if 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 + else: + arrangement.selectionRects[glyphIndex] + +func sourceIntersectsSelection( + source: GlyphSourceRange, + selectionStart, selectionEnd: int, + sourceKind: SelectionSourceKind, +): bool {.inline.} = + case sourceKind + of sskRunes: + source.sourceIntersects(selectionStart, selectionEnd) + of sskBytes: + source.byteSourceIntersects(selectionStart, selectionEnd) + +func normalizedGlyphLine(arrangement: GlyphArrangement, line: Slice[int]): Slice[int] = + let glyphCount = arrangement.glyphCount() + if glyphCount == 0: + return 0 .. -1 + + result = max(line.a, 0) .. min(line.b, glyphCount - 1) + if result.a > result.b: + result = 0 .. -1 + +func selectionLineBox(arrangement: GlyphArrangement, line: Slice[int]): Rect = + var + foundGlyph = false + minY = float32.high + maxY = -float32.high + + for glyphIndex in line: + let glyphRect = arrangement.rectForGlyph(glyphIndex) + minY = min(minY, glyphRect.y) + maxY = max(maxY, glyphRect.y + glyphRect.h) + foundGlyph = true + + if foundGlyph: + result = rect(0, minY, 0, max(maxY - minY, 0.0'f32)) + else: + result = rect(0, 0, 0, 0) + +func lineForGlyph(arrangement: GlyphArrangement, glyphIndex: int): Slice[int] = + if arrangement.lines.len > 0: + for line in arrangement.lines: + if glyphIndex >= line.a and glyphIndex <= line.b: + return line + 0 .. arrangement.glyphCount() - 1 + +func lineIndexForGlyph(arrangement: GlyphArrangement, glyphIndex: int): int = + for lineIndex, line in arrangement.lines: + if glyphIndex >= line.a and glyphIndex <= line.b: + return lineIndex + 0 + +func glyphAppearsRtl(arrangement: GlyphArrangement, glyphIndex: int): bool = + let + line = arrangement.lineForGlyph(glyphIndex) + source = arrangement.glyphSource(glyphIndex) + if glyphIndex > line.a: + let prevSource = arrangement.glyphSource(glyphIndex - 1) + if prevSource.runeStart > source.runeStart: + return true + if glyphIndex < line.b: + let nextSource = arrangement.glyphSource(glyphIndex + 1) + if nextSource.runeStart < source.runeStart: + return true + false + +func sameSourceRange(a, b: GlyphSourceRange): bool {.inline.} = + a.byteStart == b.byteStart and a.byteEnd == b.byteEnd and a.runeStart == b.runeStart and + a.runeEnd == b.runeEnd + +func clusterGlyphRangeForGlyph( + arrangement: GlyphArrangement, glyphIndex: int +): Slice[int] = + let + line = arrangement.lineForGlyph(glyphIndex) + source = arrangement.glyphSource(glyphIndex) + + result = glyphIndex .. glyphIndex + while result.a > line.a and + arrangement.glyphSource(result.a - 1).sameSourceRange(source): + dec result.a + while result.b < line.b and + arrangement.glyphSource(result.b + 1).sameSourceRange(source): + inc result.b + +func clusterRectForGlyph(arrangement: GlyphArrangement, glyphIndex: int): Rect = + let cluster = arrangement.clusterGlyphRangeForGlyph(glyphIndex) + var + minX = float32.high + minY = float32.high + maxX = -float32.high + maxY = -float32.high + foundGlyph = false + + for clusterGlyphIndex in cluster: + let glyphRect = arrangement.rectForGlyph(clusterGlyphIndex) + minX = min(minX, min(glyphRect.x, glyphRect.x + glyphRect.w)) + minY = min(minY, glyphRect.y) + maxX = max(maxX, max(glyphRect.x, glyphRect.x + glyphRect.w)) + maxY = max(maxY, glyphRect.y + glyphRect.h) + foundGlyph = true + + if foundGlyph: + rect(minX, minY, maxX - minX, maxY - minY) + else: + arrangement.rectForGlyph(glyphIndex) + +func glyphSelectionRectsForRange( + arrangement: GlyphArrangement, + sourceRange: Slice[int], + sourceKind: SelectionSourceKind, +): seq[Rect] = + if sourceRange.a > sourceRange.b: + return + + let + selectionStart = max(sourceRange.a, 0) + selectionEnd = sourceRange.b + 1 + if selectionEnd <= selectionStart: + return + + for glyphIndex in 0 ..< arrangement.glyphCount(): + let source = arrangement.glyphSource(glyphIndex) + if source.sourceIntersectsSelection(selectionStart, selectionEnd, sourceKind): + result.add arrangement.rectForGlyph(glyphIndex) + +func glyphSelectionRectsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] = + ## Returns raw glyph rectangles for glyphs touching a source-rune range. + glyphSelectionRectsForRange(arrangement, sourceRange, sskRunes) + +func glyphSelectionRectsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] = + ## Returns raw glyph rectangles for glyphs touching a raw source-byte range. + glyphSelectionRectsForRange(arrangement, byteRange, sskBytes) + +func sourceBounds( + source: GlyphSourceRange, sourceKind: SelectionSourceKind +): tuple[start, ending: int] {.inline.} = + case sourceKind + of sskRunes: + (source.runeStart, source.runeEnd) + of sskBytes: + (source.byteStart, source.byteEnd) + +func selectedGlyphRectForRange( + arrangement: GlyphArrangement, + glyphIndex: int, + selectionStart, selectionEnd: int, + sourceKind: SelectionSourceKind, +): Rect = + let + source = arrangement.glyphSource(glyphIndex) + bounds = source.sourceBounds(sourceKind) + clippedStart = max(selectionStart, bounds.start) + clippedEnd = min(selectionEnd, bounds.ending) + if clippedEnd <= clippedStart or bounds.ending <= bounds.start: + return rect(0, 0, 0, 0) + + let + glyphRect = arrangement.clusterRectForGlyph(glyphIndex) + minX = min(glyphRect.x, glyphRect.x + glyphRect.w) + maxX = max(glyphRect.x, glyphRect.x + glyphRect.w) + width = maxX - minX + sourceLen = max(bounds.ending - bounds.start, 1).float32 + startT = + max(0.0'f32, min((clippedStart - bounds.start).float32 / sourceLen, 1.0'f32)) + endT = max(0.0'f32, min((clippedEnd - bounds.start).float32 / sourceLen, 1.0'f32)) + rtl = arrangement.glyphAppearsRtl(glyphIndex) + startX = + if rtl: + maxX - width * startT + else: + minX + width * startT + endX = + if rtl: + maxX - width * endT + else: + minX + width * endT + + rect(min(startX, endX), glyphRect.y, abs(endX - startX), glyphRect.h) + +func flushSelectionBand(bands: var seq[Rect], band: var Rect, bandActive: var bool) = + if bandActive: + bands.add band + bandActive = false + +func addGlyphToSelectionBand( + band: var Rect, bandActive: var bool, lineBox, glyphRect: Rect +) = + let + glyphMinX = min(glyphRect.x, glyphRect.x + glyphRect.w) + glyphMaxX = max(glyphRect.x, glyphRect.x + glyphRect.w) + + if bandActive: + let + bandMinX = min(band.x, glyphMinX) + bandMaxX = max(band.x + band.w, glyphMaxX) + band = rect(bandMinX, lineBox.y, bandMaxX - bandMinX, lineBox.h) + else: + band = rect(glyphMinX, lineBox.y, glyphMaxX - glyphMinX, lineBox.h) + bandActive = true + +func addSelectionBandsForLine( + arrangement: GlyphArrangement, + line: Slice[int], + selectionStart, selectionEnd: int, + sourceKind: SelectionSourceKind, + bands: var seq[Rect], +) = + let glyphLine = arrangement.normalizedGlyphLine(line) + if glyphLine.a <= glyphLine.b: + let lineBox = arrangement.selectionLineBox(glyphLine) + var + band = rect(0, lineBox.y, 0, lineBox.h) + bandActive = false + + for glyphIndex in glyphLine: + let source = arrangement.glyphSource(glyphIndex) + if source.sourceIntersectsSelection(selectionStart, selectionEnd, sourceKind): + let selectionRect = arrangement.selectedGlyphRectForRange( + glyphIndex, selectionStart, selectionEnd, sourceKind + ) + band.addGlyphToSelectionBand(bandActive, lineBox, selectionRect) + else: + bands.flushSelectionBand(band, bandActive) + + bands.flushSelectionBand(band, bandActive) + +func selectionBandsForRange( + arrangement: GlyphArrangement, + sourceRange: Slice[int], + sourceKind: SelectionSourceKind, +): seq[Rect] = + if sourceRange.a > sourceRange.b: + return + + let + selectionStart = max(sourceRange.a, 0) + selectionEnd = sourceRange.b + 1 + if selectionEnd <= selectionStart: + return + + if arrangement.lines.len > 0: + for line in arrangement.lines: + arrangement.addSelectionBandsForLine( + line, selectionStart, selectionEnd, sourceKind, result + ) + else: + arrangement.addSelectionBandsForLine( + 0 .. arrangement.glyphCount() - 1, + selectionStart, + selectionEnd, + sourceKind, + result, + ) + +func selectionBandsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] = + ## Returns merged visual selection bands for a source-rune range. + selectionBandsForRange(arrangement, sourceRange, sskRunes) + +func selectionBandsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] = + ## Returns merged visual selection bands for a raw source-byte range. + selectionBandsForRange(arrangement, byteRange, sskBytes) + +func selectionRectsFor*( + arrangement: GlyphArrangement, sourceRange: Slice[int] +): seq[Rect] = + ## Returns merged visual selection bands for a source-rune range. + ## Use `glyphSelectionRectsFor` for raw per-glyph rectangles. + arrangement.selectionBandsFor(sourceRange) + +func selectionRectsForRawBytes*( + arrangement: GlyphArrangement, byteRange: Slice[int] +): seq[Rect] = + ## Returns merged visual selection bands for a raw source-byte range. + ## Use `glyphSelectionRectsForRawBytes` for raw per-glyph rectangles. + arrangement.selectionBandsForRawBytes(byteRange) + +func containsPoint(rect: Rect, point: Vec2): bool {.inline.} = + point.x >= rect.x and point.y >= rect.y and point.x < rect.x + rect.w and + point.y < rect.y + rect.h + +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: + if arrangement.rectForGlyph(glyphIndex).containsPoint(point): + return glyphIndex + -1 + +func sourceRuneRangeAt*(arrangement: GlyphArrangement, point: Vec2): Slice[int] = + ## Returns the source-rune range at a local text-layout point, or `0 .. -1`. + let glyphIndex = arrangement.glyphIndexAt(point) + if glyphIndex < 0: + return 0 .. -1 + arrangement.sourceRuneRange(glyphIndex) + +func sourceRuneCount(arrangement: GlyphArrangement): int {.inline.} = + if arrangement.sourceRunes.len > 0: + arrangement.sourceRunes.len + else: + arrangement.runes.len + +func caretX(glyphRect: Rect, rtl, sourceStart: bool): float32 {.inline.} = + if sourceStart: + if rtl: + glyphRect.x + glyphRect.w + else: + glyphRect.x + else: + if rtl: + glyphRect.x + else: + glyphRect.x + glyphRect.w + +func sameCaret(a, b: TextCaretPosition): bool {.inline.} = + a.sourceRune == b.sourceRune and a.lineIndex == b.lineIndex and + abs(a.pos.x - b.pos.x) < 0.01'f32 and abs(a.pos.y - b.pos.y) < 0.01'f32 + +func addCaret(carets: var seq[TextCaretPosition], caret: TextCaretPosition) = + for existing in carets: + if existing.sameCaret(caret): + return + carets.add caret + +func caretPositionsFor*( + arrangement: GlyphArrangement, sourceRune: int +): seq[TextCaretPosition] = + ## Returns visual caret positions for a source insertion index. + ## Bidi boundaries can produce more than one visual position. + let sourceCount = arrangement.sourceRuneCount() + if sourceRune < 0 or sourceRune > sourceCount: + return + + let glyphCount = arrangement.glyphCount() + if glyphCount == 0: + if sourceRune == 0: + result.add TextCaretPosition( + sourceRune: 0, + glyphIndex: -1, + lineIndex: 0, + affinity: CaretInside, + pos: vec2(0, 0), + rect: rect(0, 0, 0, 0), + ) + return + + for glyphIndex in 0 ..< glyphCount: + let + source = arrangement.glyphSource(glyphIndex) + glyphRect = arrangement.clusterRectForGlyph(glyphIndex) + rtl = arrangement.glyphAppearsRtl(glyphIndex) + lineIndex = arrangement.lineIndexForGlyph(glyphIndex) + + if source.runeStart == sourceRune: + let x = glyphRect.caretX(rtl, sourceStart = true) + result.addCaret TextCaretPosition( + sourceRune: sourceRune, + glyphIndex: glyphIndex, + lineIndex: lineIndex, + affinity: CaretLeading, + pos: vec2(x, glyphRect.y), + rect: rect(x, glyphRect.y, 0, glyphRect.h), + ) + + if source.runeEnd == sourceRune: + let x = glyphRect.caretX(rtl, sourceStart = false) + result.addCaret TextCaretPosition( + sourceRune: sourceRune, + glyphIndex: glyphIndex, + lineIndex: lineIndex, + affinity: CaretTrailing, + pos: vec2(x, glyphRect.y), + rect: rect(x, glyphRect.y, 0, glyphRect.h), + ) + + if sourceRune > source.runeStart and sourceRune < source.runeEnd: + let + rangeLen = max(source.runeEnd - source.runeStart, 1) + t = (sourceRune - source.runeStart).float32 / rangeLen.float32 + x = + if rtl: + glyphRect.x + glyphRect.w * (1.0'f32 - t) + else: + glyphRect.x + glyphRect.w * t + result.addCaret TextCaretPosition( + sourceRune: sourceRune, + glyphIndex: glyphIndex, + lineIndex: lineIndex, + affinity: CaretInside, + pos: vec2(x, glyphRect.y), + rect: rect(x, glyphRect.y, 0, glyphRect.h), + ) + +func nearestSourceRuneForCaretPoint*(arrangement: GlyphArrangement, point: Vec2): int = + ## Returns the source insertion index nearest to a local text-layout point. + let sourceCount = arrangement.sourceRuneCount() + result = 0 + var bestDistance = float32.high + for sourceRune in 0 .. sourceCount: + for caret in arrangement.caretPositionsFor(sourceRune): + let + dx = point.x - caret.pos.x + dy = + if point.y < caret.rect.y: + caret.rect.y - point.y + elif point.y > caret.rect.y + caret.rect.h: + point.y - (caret.rect.y + caret.rect.h) + else: + 0.0'f32 + distance = dx * dx + dy * dy + if distance < bestDistance: + bestDistance = distance + result = sourceRune + proc hash*(fnt: FigFont): Hash = var h = Hash(0) for n, f in fnt.fieldPairs(): diff --git a/src/figdraw/common/fontutils.nim b/src/figdraw/common/fontutils.nim index a517d42..b1947d2 100644 --- a/src/figdraw/common/fontutils.nim +++ b/src/figdraw/common/fontutils.nim @@ -1,13 +1,8 @@ -import std/[os, unicode, sequtils, tables, strutils, sets, hashes, math] -import std/isolation +import std/[hashes, unicode] import pkg/vmath -import pkg/pixie import pkg/pixie/fonts -import pkg/chronicles -import ./rchannels -import ./imgutils import ./shared import ./fonttypes @@ -16,61 +11,10 @@ import ./fontglyphs export loadTypeface, convertFont, registerStaticTypeface -proc calcMinMaxContent( - textLayout: GlyphArrangement -): tuple[maxSize, minSize: Vec2, bounding: Rect] = - ## estimate the maximum and minimum size of a given typesetting - - var longestWord: Slice[int] - var longestWordLen: float - - var words = 0 - var wordsHeight = 0.0 - var curr: Slice[int] - var currLen: float - var maxWidth: float - var rect: Rect = rect(float32.high, float32.high, 0, 0) - - # find longest word and count the number of words - # herein min content width is longest word - # herein max content height is a word on each line - var idx = 0 - for glyph in textLayout.glyphs(): - maxWidth += glyph.rect.w - rect.x = min(rect.x, glyph.rect.x) - rect.y = min(rect.y, glyph.rect.y) - rect.w = max(rect.w, glyph.rect.x + glyph.rect.w) - rect.h = max(rect.h, glyph.rect.y + glyph.rect.h) - - if glyph.rune.isWhiteSpace: - curr = idx + 1 .. idx - currLen = 0.0 - else: - if curr.len() == 1: - words.inc - wordsHeight += glyph.lineHeight - curr.b = idx - currLen += glyph.rect.w - - if currLen > longestWordLen: - longestWord = curr - longestWordLen = currLen - - idx.inc() - - # find tallest font - var maxLine = 0.0 - for font in textLayout.fonts: - maxLine = max(maxLine, font.lineHeight) - - # set results - result.minSize.x = longestWordLen - result.minSize.y = maxLine - - result.maxSize.x = maxWidth - result.maxSize.y = wordsHeight - - result.bounding = rect +when figdrawTextBackend == "harfbuzzy" or figdrawTextBackend == "hybrid": + import ./textbackends/harfbuzzy as textBackend +else: + import ./textbackends/pixie as textBackend proc typeset*( box: Rect, @@ -80,103 +24,7 @@ proc typeset*( minContent: bool, wrap: bool, ): GlyphArrangement = - ## does the typesetting using pixie, then converts the typeseet results - ## into Figuro's own internal types - ## Primarily done for thread safety - threadEffects: - AppMainThread - - var - wh = box.wh - sz = uiSpans.mapIt(it[0].font.size.float) - minSz = sz.foldl(max(a, b), 0.0) - - var spans: seq[Span] - var pfs: seq[Font] - var gfonts: seq[GlyphFont] - for (style, txt) in uiSpans: - let (fontId, pf) = style.convertFont() - pfs.add(pf) - spans.add(newSpan(txt, pf)) - assert not pf.typeface.isNil - let lineHeight = if pf.lineHeight >= 0: pf.lineHeight else: pf.defaultLineHeight() - let lineGap = (lineHeight / pf.scale) - pf.typeface.ascent + pf.typeface.descent - let baselineOffset = round((pf.typeface.ascent + lineGap / 2) * pf.scale) - gfonts.add GlyphFont( - fontId: fontId, lineHeight: lineHeight, descentAdj: baselineOffset - ) - - var ha: HorizontalAlignment - case hAlign - of Left: - ha = LeftAlign - of Center: - ha = CenterAlign - of Right: - ha = RightAlign - - var va: VerticalAlignment - case vAlign - of Top: - va = TopAlign - of Middle: - va = MiddleAlign - of Bottom: - va = BottomAlign - - let arrangement = - pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) - result = convertArrangement(arrangement, box, uiSpans, hAlign, vAlign, gfonts) - - let content = result.calcMinMaxContent() - result.minSize = content.minSize - result.maxSize = content.maxSize - result.bounding = content.bounding - - if minContent: - ## calcaulate min width of content - var wh = wh - wh.y = result.maxSize.y - let arr = pixie.typeset( - spans, bounds = wh, hAlign = LeftAlign, vAlign = TopAlign, wrap = wrap - ) - let minResult = convertArrangement(arr, box, uiSpans, hAlign, vAlign, gfonts) - - let minContent = minResult.calcMinMaxContent() - trace "minContent:", - boxWh = box.wh, - wh = wh, - minSize = minContent.minSize, - maxSize = minContent.maxSize, - bounding = minContent.bounding, - boundH = result.bounding.h - - if minContent.bounding.h > result.bounding.h: - let wh = vec2(wh.x, minContent.bounding.h) - let minAdjusted = - pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) - result = convertArrangement(minAdjusted, box, uiSpans, hAlign, vAlign, gfonts) - let contentAdjusted = result.calcMinMaxContent() - result.minSize = contentAdjusted.minSize - result.maxSize = contentAdjusted.maxSize - result.bounding = contentAdjusted.bounding - trace "minContent:adjusted", - boxWh = box.wh, - wh = wh, - wrap = wrap, - minSize = result.minSize, - maxSize = result.maxSize, - bounding = result.bounding - - result.minSize.y = result.bounding.h - else: - result.minSize.y = max(result.minSize.y, result.bounding.h) - - let maxLineHeight = max(sz) - result.minSize += vec2(maxLineHeight / 2, 0) - result.maxSize += vec2(maxLineHeight / 2, 0) - result.bounding = result.bounding + rect(0, 0, 0, maxLineHeight / 2) - result.generateGlyphImages() + textBackend.typeset(box, uiSpans, hAlign, vAlign, minContent, wrap) proc typeset*( box: Rect, @@ -215,7 +63,6 @@ proc placeGlyphs*( contentHash = Hash(0) for (rune, pos) in glyphs: - let scaledPos = pos let baselineOffset = cachedFont.glyph.descentAdj var baselinePos = pos if origin == GlyphTopLeft: @@ -236,6 +83,9 @@ proc placeGlyphs*( result.spans = @[0 .. glyphs.len - 1] result.fonts = @[cachedFont.glyph] result.spanColors = @[style.color] + result.sourceRunes = runes + result.arrangedGlyphs = + buildArrangedGlyphs(runes, positions, selectionRects, result.spans, result.fonts) result.runes = runes result.positions = positions result.selectionRects = selectionRects diff --git a/src/figdraw/common/textbackends/common.nim b/src/figdraw/common/textbackends/common.nim new file mode 100644 index 0000000..1b44dc9 --- /dev/null +++ b/src/figdraw/common/textbackends/common.nim @@ -0,0 +1,88 @@ +import std/unicode + +import pkg/vmath + +import ../fonttypes + +proc calcMinMaxContent*( + textLayout: GlyphArrangement +): tuple[maxSize, minSize: Vec2, bounding: Rect] = + ## Estimate maximum and minimum content size for a glyph arrangement. + var longestWord: Slice[int] + var longestWordLen: float + + var words = 0 + var wordsHeight = 0.0 + var curr: Slice[int] + var currLen: float + var maxWidth: float + var rect: Rect = rect(float32.high, float32.high, 0, 0) + + let glyphCount = + if textLayout.arrangedGlyphs.len > 0: + textLayout.arrangedGlyphs.len + else: + textLayout.runes.len + + for idx in 0 ..< glyphCount: + let glyphRect = + if textLayout.arrangedGlyphs.len > 0: + textLayout.arrangedGlyphs[idx].rect + elif idx < textLayout.selectionRects.len: + textLayout.selectionRects[idx] + else: + rect(0, 0, 0, 0) + let glyphRune = + if textLayout.arrangedGlyphs.len > 0: + textLayout.arrangedGlyphs[idx].rune + else: + textLayout.runes[idx] + + maxWidth += glyphRect.w + rect.x = min(rect.x, glyphRect.x) + rect.y = min(rect.y, glyphRect.y) + rect.w = max(rect.w, glyphRect.x + glyphRect.w) + rect.h = max(rect.h, glyphRect.y + glyphRect.h) + + if glyphRune.isWhiteSpace: + curr = idx + 1 .. idx + currLen = 0.0 + else: + if curr.len() == 1: + words.inc + for fontIndex, span in textLayout.spans: + if idx in span and fontIndex < textLayout.fonts.len: + wordsHeight += textLayout.fonts[fontIndex].lineHeight + break + curr.b = idx + currLen += glyphRect.w + + if currLen > longestWordLen: + longestWord = curr + longestWordLen = currLen + + var maxLine = 0.0 + for font in textLayout.fonts: + maxLine = max(maxLine, font.lineHeight) + + result.minSize.x = longestWordLen + result.minSize.y = maxLine + + result.maxSize.x = maxWidth + result.maxSize.y = wordsHeight + + if glyphCount == 0: + rect = rect(0, 0, 0, 0) + result.bounding = rect + +proc maxFontSize*(fontSizes: openArray[float]): float32 = + for size in fontSizes: + result = max(result, size.float32) + +proc addFontSizePadding*( + arrangement: var GlyphArrangement, fontSizes: openArray[float] +) = + let maxLineHeight = maxFontSize(fontSizes) + arrangement.minSize += vec2(maxLineHeight / 2, 0) + arrangement.maxSize += vec2(maxLineHeight / 2, 0) + arrangement.bounding = arrangement.bounding + rect(0, 0, 0, maxLineHeight / 2) diff --git a/src/figdraw/common/textbackends/harfbuzzy.nim b/src/figdraw/common/textbackends/harfbuzzy.nim new file mode 100644 index 0000000..cb74c98 --- /dev/null +++ b/src/figdraw/common/textbackends/harfbuzzy.nim @@ -0,0 +1,475 @@ +import std/[algorithm, hashes, math, sequtils, strutils, unicode] + +import pkg/harfbuzzy as hb +import pkg/vmath + +import ../fonttypes +import ../shared +import ../typefaces +import ./common + +when figdrawTextBackend == "hybrid": + import ../fontglyphs + +const hbGlyphFlagUnsafeToBreak = 0x00000001'u32 + +type + DecodedSource = object + runes: seq[Rune] + byteStarts: seq[int] + byteEnds: seq[int] + + HarfbuzzFontInfo = object + typeface: hb.Typeface + fontId: FontId + glyphFont: GlyphFont + +proc decodeSource(text: string): DecodedSource = + var byteOffset = 0 + for rune in text.runes: + result.runes.add rune + result.byteStarts.add byteOffset + byteOffset += ($rune).len + result.byteEnds.add byteOffset + +proc applyFontCase(text: string, fontCase: FontCase): string = + case fontCase + of NormalCase: + text + of UpperCase: + text.toUpper() + of LowerCase: + text.toLower() + of TitleCase: + text.title() + +proc runeRangeForBytes( + decoded: DecodedSource, byteStart, byteEnd: int +): GlyphSourceRange = + result.byteStart = byteStart + result.byteEnd = byteEnd + result.runeStart = decoded.runes.len + result.runeEnd = decoded.runes.len + + for i in 0 ..< decoded.runes.len: + if decoded.byteEnds[i] > byteStart: + result.runeStart = i + break + + for i in result.runeStart ..< decoded.runes.len: + if decoded.byteStarts[i] >= byteEnd: + result.runeEnd = i + break + if result.runeEnd == decoded.runes.len and byteEnd >= byteStart: + result.runeEnd = decoded.runes.len + if result.runeStart > result.runeEnd: + result.runeEnd = result.runeStart + +proc firstRune(decoded: DecodedSource, source: GlyphSourceRange): Rune = + if source.runeStart >= 0 and source.runeStart < source.runeEnd and + source.runeStart < decoded.runes.len: + return decoded.runes[source.runeStart] + Rune(0) + +proc sourceIsWhitespace(decoded: DecodedSource, source: GlyphSourceRange): bool = + if source.runeStart >= source.runeEnd: + return false + for i in source.runeStart ..< source.runeEnd: + if i >= decoded.runes.len or not decoded.runes[i].isWhiteSpace: + return false + true + +func isCjkLineBreakRune(rune: Rune): bool = + let cp = rune.uint32 + cp in 0x1100'u32 .. 0x11ff'u32 or cp in 0x2e80'u32 .. 0x30ff'u32 or + cp in 0x3400'u32 .. 0x4dbf'u32 or cp in 0x4e00'u32 .. 0x9fff'u32 or + cp in 0xac00'u32 .. 0xd7af'u32 or cp in 0xf900'u32 .. 0xfaff'u32 or + cp in 0xff65'u32 .. 0xff9f'u32 + +func canBreakAfterRune(rune: Rune): bool = + if rune.isWhiteSpace: + return true + + case rune.uint32 + of 0x002d'u32, 0x002f'u32, 0x00ad'u32, 0x058a'u32, 0x05be'u32, 0x1400'u32, 0x1806'u32, + 0x200b'u32, 0x2053'u32, 0x207b'u32, 0x208b'u32, 0x2212'u32, 0x2e17'u32, + 0x2e1a'u32, 0x301c'u32, 0x3030'u32, 0x30a0'u32, 0xfe58'u32, 0xfe63'u32, 0xff0d'u32: + true + of 0x2010'u32 .. 0x2015'u32, 0xfe31'u32 .. 0xfe32'u32: + true + else: + false + +proc imageOffsetForGlyph( + typeface: hb.Typeface, glyphId: hb.Codepoint, font: GlyphFont, scale: float32 +): Vec2 = + try: + let extents = typeface.font.glyphExtents(glyphId) + let + x0 = extents.xBearing.float32 * scale + x1 = x0 + extents.width.float32 * scale + y0 = font.descentAdj - extents.yBearing.float32 * scale + y1 = y0 - extents.height.float32 * scale + result = vec2(floor(min(0.0'f32, min(x0, x1))), floor(min(0.0'f32, min(y0, y1)))) + except ValueError: + result = vec2(0, 0) + +proc toHarfbuzzFeatures(features: openArray[FontFeature]): seq[hb.Feature] = + for feature in features: + result.add hb.initFeature( + hb.toTag(feature.tag), feature.value, feature.start, feature.ending + ) + +proc toHarfbuzzVariations(variations: openArray[FontVariation]): seq[hb.Variation] = + for variation in variations: + result.add hb.initVariation(hb.toTag(variation.tag), variation.value) + +proc initHarfbuzzTypeface(font: FigFont): hb.Typeface = + let source = getTypefaceSource(font.typefaceId) + let blob = hb.initBlob(source.data) + let face = hb.initFace(blob) + result = hb.initTypeface(face) + result.font.setVariations(font.variations.toHarfbuzzVariations()) + +proc fallbackFont(font: FigFont, typefaceId: TypefaceId): FigFont = + result = font + result.typefaceId = typefaceId + +proc initHarfbuzzFontInfos(font: FigFont): seq[HarfbuzzFontInfo] = + var typefaceIds = @[font.typefaceId] + for fallbackId in font.fallbackTypefaceIds: + if fallbackId notin typefaceIds: + typefaceIds.add fallbackId + + for typefaceId in typefaceIds: + let + figFont = font.fallbackFont(typefaceId) + fontInfo = glyphFontFor(figFont) + result.add HarfbuzzFontInfo( + typeface: initHarfbuzzTypeface(figFont), + fontId: fontInfo.id, + glyphFont: fontInfo.glyph, + ) + +proc shapeParagraph( + fontInfos: openArray[HarfbuzzFontInfo], font: FigFont, text: string +): hb.ShapedParagraph = + var typefaces = newSeqOfCap[hb.Typeface](fontInfos.len) + for info in fontInfos: + typefaces.add info.typeface + + let context = hb.initShapeContext( + typefaces, hb.ParagraphOptions(features: font.features.toHarfbuzzFeatures()) + ) + context.shapeParagraph(text) + +proc pxScale(typeface: hb.Typeface, font: FigFont): float32 = + let upem = typeface.face.upem + if upem <= 0: + return 1.0'f32 + font.size / upem.float32 + +proc nextClusterBoundary(run: hb.ShapedRun, cluster: int): int = + var boundaries = @[run.textRun.byteEnd] + for glyph in run.glyphRun.glyphs: + let glyphCluster = int(glyph.cluster) + if glyphCluster > cluster: + boundaries.add glyphCluster + boundaries.sort() + result = boundaries[0] + +proc shiftGlyph(arrangement: var GlyphArrangement, glyphIndex: int, delta: Vec2) = + arrangement.arrangedGlyphs[glyphIndex].pos += delta + arrangement.arrangedGlyphs[glyphIndex].rect = + arrangement.arrangedGlyphs[glyphIndex].rect + rect(delta.x, delta.y, 0, 0) + arrangement.positions[glyphIndex] += delta + arrangement.selectionRects[glyphIndex] = + arrangement.selectionRects[glyphIndex] + rect(delta.x, delta.y, 0, 0) + +proc lineBounds(arrangement: GlyphArrangement, line: Slice[int]): Rect = + result = rect(float32.high, float32.high, 0, 0) + if line.a > line.b: + return rect(0, 0, 0, 0) + for glyphIndex in line: + let glyphRect = arrangement.arrangedGlyphs[glyphIndex].rect + result.x = min(result.x, glyphRect.x) + result.y = min(result.y, glyphRect.y) + result.w = max(result.w, glyphRect.x + glyphRect.w) + result.h = max(result.h, glyphRect.y + glyphRect.h) + result.w -= result.x + result.h -= result.y + +proc applyAlignment( + arrangement: var GlyphArrangement, + box: Rect, + hAlign: FontHorizontal, + vAlign: FontVertical, +) = + if arrangement.arrangedGlyphs.len == 0: + return + + let lines = + if arrangement.lines.len > 0: + arrangement.lines + else: + @[0 .. arrangement.arrangedGlyphs.len - 1] + + for line in lines: + let bounds = arrangement.lineBounds(line) + var dx = -bounds.x + case hAlign + of Left: + discard + of Center: + dx += (box.w - bounds.w) / 2 + of Right: + dx += box.w - bounds.w + if dx != 0: + for glyphIndex in line: + arrangement.shiftGlyph(glyphIndex, vec2(dx, 0)) + + let bounds = arrangement.calcMinMaxContent().bounding + var dy = -bounds.y + case vAlign + of Top: + discard + of Middle: + dy += (box.h - bounds.h) / 2 + of Bottom: + dy += box.h - bounds.h + + if dy != 0: + for glyphIndex in 0 ..< arrangement.arrangedGlyphs.len: + arrangement.shiftGlyph(glyphIndex, vec2(0, dy)) + +proc lineHeight(arrangement: GlyphArrangement, line: Slice[int]): float32 = + for glyphIndex in line: + result = max(result, arrangement.arrangedGlyphs[glyphIndex].rect.h) + if result <= 0: + for font in arrangement.fonts: + result = max(result, font.lineHeight) + +proc glyphWrapWidth(glyph: ArrangedGlyph): float32 {.inline.} = + max(glyph.rect.w, abs(glyph.advance.x)) + +proc preferredLineBreakAfter(arrangement: GlyphArrangement, glyphIndex: int): bool = + let glyph = arrangement.arrangedGlyphs[glyphIndex] + if glyph.isWhitespace: + return true + if glyph.source.runeEnd <= glyph.source.runeStart or + glyph.source.runeEnd > arrangement.sourceRunes.len: + return false + + let lastRune = arrangement.sourceRunes[glyph.source.runeEnd - 1] + if lastRune.canBreakAfterRune: + return true + + if glyphIndex + 1 < arrangement.arrangedGlyphs.len: + let nextGlyph = arrangement.arrangedGlyphs[glyphIndex + 1] + if glyph.source.runeEnd == nextGlyph.source.runeStart and + nextGlyph.source.runeStart < arrangement.sourceRunes.len: + let nextRune = arrangement.sourceRunes[nextGlyph.source.runeStart] + return lastRune.isCjkLineBreakRune and nextRune.isCjkLineBreakRune + + false + +proc buildWrappedLines( + arrangement: GlyphArrangement, boxWidth: float32, safeBreakAfter: openArray[bool] +): seq[Slice[int]] = + let glyphCount = arrangement.arrangedGlyphs.len + if glyphCount == 0: + return + if boxWidth <= 0: + return @[0 .. glyphCount - 1] + + var + lineStart = 0 + lineWidth = 0.0'f32 + lastBreak = -1 + glyphIndex = 0 + + while glyphIndex < glyphCount: + let glyph = arrangement.arrangedGlyphs[glyphIndex] + let width = glyph.glyphWrapWidth() + + if glyphIndex > lineStart and lineWidth + width > boxWidth: + if lastBreak >= lineStart and lastBreak < glyphIndex: + result.add lineStart .. lastBreak + lineStart = lastBreak + 1 + else: + result.add lineStart .. glyphIndex - 1 + lineStart = glyphIndex + lineWidth = 0 + lastBreak = -1 + glyphIndex = lineStart + continue + + lineWidth += width + let + breakAfter = glyphIndex >= safeBreakAfter.len or safeBreakAfter[glyphIndex] + preferredBreakAfter = arrangement.preferredLineBreakAfter(glyphIndex) + if preferredBreakAfter and breakAfter: + lastBreak = glyphIndex + inc glyphIndex + + if lineStart < glyphCount: + result.add lineStart .. glyphCount - 1 + +proc reflowLines(arrangement: var GlyphArrangement) = + var lineTop = 0.0'f32 + for line in arrangement.lines: + var lineX = 0.0'f32 + let lineHeight = arrangement.lineHeight(line) + for glyphIndex in line: + let + oldGlyph = arrangement.arrangedGlyphs[glyphIndex] + posOffset = oldGlyph.pos - oldGlyph.rect.xy + newRect = rect(lineX, lineTop, oldGlyph.rect.w, oldGlyph.rect.h) + newPos = newRect.xy + posOffset + + arrangement.arrangedGlyphs[glyphIndex].rect = newRect + arrangement.arrangedGlyphs[glyphIndex].pos = newPos + arrangement.positions[glyphIndex] = newPos + arrangement.selectionRects[glyphIndex] = newRect + lineX += oldGlyph.glyphWrapWidth() + lineTop += lineHeight + +proc appendShapedSpan( + arrangement: var GlyphArrangement, + decoded: DecodedSource, + style: FontStyle, + text: string, + byteOffset: int, + pen: var Vec2, + safeBreakAfter: var seq[bool], +) = + let + fontInfos = initHarfbuzzFontInfos(style.font) + paragraph = fontInfos.shapeParagraph(style.font, text) + + for run in paragraph.visualRuns: + if run.glyphRun.glyphs.len == 0: + continue + + let + fontIndex = + if run.typefaceIndex >= 0 and run.typefaceIndex < fontInfos.len: + run.typefaceIndex + else: + 0 + fontInfo = fontInfos[fontIndex] + scale = fontInfo.typeface.pxScale(style.font) + spanStart = arrangement.arrangedGlyphs.len + + arrangement.fonts.add fontInfo.glyphFont + arrangement.spanColors.add style.color + + for runGlyphIndex, glyph in run.glyphRun.glyphs: + let + cluster = int(glyph.cluster) + nextCluster = run.nextClusterBoundary(cluster) + source = + decoded.runeRangeForBytes(byteOffset + cluster, byteOffset + nextCluster) + rune = decoded.firstRune(source) + advance = vec2(glyph.xAdvance.float32 * scale, -glyph.yAdvance.float32 * scale) + offset = vec2(glyph.xOffset.float32 * scale, -glyph.yOffset.float32 * scale) + pos = pen + offset + drawPos = vec2(pos.x, pos.y - fontInfo.glyphFont.descentAdj) + selectionWidth = max(abs(advance.x), 0.0'f32) + selection = + rect(drawPos.x, drawPos.y, selectionWidth, fontInfo.glyphFont.lineHeight) + imageOffset = fontInfo.typeface.imageOffsetForGlyph( + glyph.codepoint, fontInfo.glyphFont, scale + ) + + arrangement.arrangedGlyphs.add ArrangedGlyph( + fontId: fontInfo.fontId, + glyphId: FontGlyphId(glyph.codepoint.uint32), + cluster: glyph.cluster, + source: source, + rune: rune, + isWhitespace: decoded.sourceIsWhitespace(source), + pos: pos, + advance: advance, + offset: offset, + imageOffset: imageOffset, + rect: selection, + ) + arrangement.runes.add rune + arrangement.positions.add pos + arrangement.selectionRects.add selection + let nextGlyphUnsafeToBreak = + runGlyphIndex + 1 < run.glyphRun.glyphs.len and + (run.glyphRun.glyphs[runGlyphIndex + 1].flags and hbGlyphFlagUnsafeToBreak) != 0 + safeBreakAfter.add not nextGlyphUnsafeToBreak + + pen += advance + + let spanStop = arrangement.arrangedGlyphs.len - 1 + arrangement.spans.add spanStart .. spanStop + +proc typeset*( + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: 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 sourceText = shapedSpans.mapIt(it[1]).join("") + let decoded = decodeSource(sourceText) + 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) + h = h !& hash(figUiScale()) + !$h, + sourceRunes: decoded.runes, + ) + + var pen = vec2(0, 0) + var byteOffset = 0 + var safeBreakAfter: seq[bool] + for (style, text) in shapedSpans: + let baseline = glyphFontFor(style.font).glyph.descentAdj + if result.arrangedGlyphs.len == 0: + pen.y = baseline + result.appendShapedSpan(decoded, style, text, byteOffset, pen, safeBreakAfter) + byteOffset += text.len + + if result.arrangedGlyphs.len > 0: + result.lines = + if wrap: + result.buildWrappedLines(box.w, safeBreakAfter) + else: + @[0 .. result.arrangedGlyphs.len - 1] + if wrap: + result.reflowLines() + + var alignmentBox = box + if minContent: + let content = result.calcMinMaxContent() + alignmentBox.h = max(alignmentBox.h, content.bounding.h) + + result.applyAlignment(alignmentBox, hAlign, vAlign) + + let content = result.calcMinMaxContent() + result.minSize = content.minSize + result.maxSize = content.maxSize + result.bounding = content.bounding + if minContent: + result.minSize.y = max(result.minSize.y, result.bounding.h) + result.addFontSizePadding(fontSizes) + + when figdrawTextBackend == "hybrid": + result.generateGlyphImages() diff --git a/src/figdraw/common/textbackends/pixie.nim b/src/figdraw/common/textbackends/pixie.nim new file mode 100644 index 0000000..e8401f4 --- /dev/null +++ b/src/figdraw/common/textbackends/pixie.nim @@ -0,0 +1,112 @@ +import std/[math, sequtils] + +import pkg/chronicles +import pkg/pixie +import pkg/pixie/fonts + +import ../fontglyphs +import ../fonttypes +import ../shared +import ../typefaces +import ./common + +proc typeset*( + box: Rect, + uiSpans: openArray[(FontStyle, string)], + hAlign = FontHorizontal.Left, + vAlign = FontVertical.Top, + minContent: bool, + wrap: bool, +): GlyphArrangement = + ## Typesets with Pixie, then converts to FigDraw's backend-neutral data. + threadEffects: + AppMainThread + + var + wh = box.wh + sz = uiSpans.mapIt(it[0].font.size.float) + + var spans: seq[Span] + var gfonts: seq[GlyphFont] + for (style, txt) in uiSpans: + let (fontId, pf) = style.convertFont() + spans.add(newSpan(txt, pf)) + assert not pf.typeface.isNil + let lineHeight = + if pf.lineHeight >= 0: + pf.lineHeight + else: + pf.defaultLineHeight() + let lineGap = (lineHeight / pf.scale) - pf.typeface.ascent + pf.typeface.descent + let baselineOffset = round((pf.typeface.ascent + lineGap / 2) * pf.scale) + gfonts.add GlyphFont( + fontId: fontId, lineHeight: lineHeight, descentAdj: baselineOffset + ) + + var ha: HorizontalAlignment + case hAlign + of Left: + ha = LeftAlign + of Center: + ha = CenterAlign + of Right: + ha = RightAlign + + var va: VerticalAlignment + case vAlign + of Top: + va = TopAlign + of Middle: + va = MiddleAlign + of Bottom: + va = BottomAlign + + let arrangement = + pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) + result = convertArrangement(arrangement, box, uiSpans, hAlign, vAlign, gfonts) + + let content = result.calcMinMaxContent() + result.minSize = content.minSize + result.maxSize = content.maxSize + result.bounding = content.bounding + + if minContent: + var wh = wh + wh.y = result.maxSize.y + let arr = pixie.typeset( + spans, bounds = wh, hAlign = LeftAlign, vAlign = TopAlign, wrap = wrap + ) + let minResult = convertArrangement(arr, box, uiSpans, hAlign, vAlign, gfonts) + + let minContent = minResult.calcMinMaxContent() + trace "minContent:", + boxWh = box.wh, + wh = wh, + minSize = minContent.minSize, + maxSize = minContent.maxSize, + bounding = minContent.bounding, + boundH = result.bounding.h + + if minContent.bounding.h > result.bounding.h: + let wh = vec2(wh.x, minContent.bounding.h) + let minAdjusted = + pixie.typeset(spans, bounds = wh, hAlign = ha, vAlign = va, wrap = wrap) + result = convertArrangement(minAdjusted, box, uiSpans, hAlign, vAlign, gfonts) + let contentAdjusted = result.calcMinMaxContent() + result.minSize = contentAdjusted.minSize + result.maxSize = contentAdjusted.maxSize + result.bounding = contentAdjusted.bounding + trace "minContent:adjusted", + boxWh = box.wh, + wh = wh, + wrap = wrap, + minSize = result.minSize, + maxSize = result.maxSize, + bounding = result.bounding + + result.minSize.y = result.bounding.h + else: + result.minSize.y = max(result.minSize.y, result.bounding.h) + + result.addFontSizePadding(sz) + result.generateGlyphImages() diff --git a/src/figdraw/common/textrasters/glyphid_raster.nim b/src/figdraw/common/textrasters/glyphid_raster.nim new file mode 100644 index 0000000..02c81ca --- /dev/null +++ b/src/figdraw/common/textrasters/glyphid_raster.nim @@ -0,0 +1,338 @@ +import std/math + +import pkg/chroma +import pkg/harfbuzzy/raw as hbraw +import pkg/pixie +import pkg/vmath + +import ../fonttypes +import ../imgutils +import ../shared +import ../typefaces +import ./pixie_raster + +const hbHeader = "hb.h" + +type + HbDrawFuncsObj {.importc: "hb_draw_funcs_t", header: hbHeader, incompleteStruct.} = object + + HbDrawStateObj {.importc: "hb_draw_state_t", header: hbHeader, incompleteStruct.} = object + + HbDrawFuncs = ptr HbDrawFuncsObj + HbDrawState = ptr HbDrawStateObj + + HbDrawMoveToFunc = proc( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + toX, toY: cfloat, + userData: pointer, + ) {.cdecl.} + + HbDrawLineToFunc = proc( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + toX, toY: cfloat, + userData: pointer, + ) {.cdecl.} + + HbDrawQuadraticToFunc = proc( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + controlX, controlY, toX, toY: cfloat, + userData: pointer, + ) {.cdecl.} + + HbDrawCubicToFunc = proc( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + control1X, control1Y, control2X, control2Y, toX, toY: cfloat, + userData: pointer, + ) {.cdecl.} + + HbDrawClosePathFunc = proc( + funcs: HbDrawFuncs, drawData: pointer, state: HbDrawState, userData: pointer + ) {.cdecl.} + + DrawPathState = object + path: Path + + HbFontHandles = object + blob: hbraw.HbBlob + face: hbraw.HbFace + font: hbraw.HbFont + +proc hb_draw_funcs_create(): HbDrawFuncs {. + cdecl, importc: "hb_draw_funcs_create", dynlib: hbraw.hbLib +.} + +proc hb_draw_funcs_destroy( + funcs: HbDrawFuncs +) {.cdecl, importc: "hb_draw_funcs_destroy", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_make_immutable( + funcs: HbDrawFuncs +) {.cdecl, importc: "hb_draw_funcs_make_immutable", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_set_move_to_func( + funcs: HbDrawFuncs, + callback: HbDrawMoveToFunc, + userData: pointer, + destroy: hbraw.HbDestroyFunc, +) {.cdecl, importc: "hb_draw_funcs_set_move_to_func", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_set_line_to_func( + funcs: HbDrawFuncs, + callback: HbDrawLineToFunc, + userData: pointer, + destroy: hbraw.HbDestroyFunc, +) {.cdecl, importc: "hb_draw_funcs_set_line_to_func", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_set_quadratic_to_func( + funcs: HbDrawFuncs, + callback: HbDrawQuadraticToFunc, + userData: pointer, + destroy: hbraw.HbDestroyFunc, +) {.cdecl, importc: "hb_draw_funcs_set_quadratic_to_func", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_set_cubic_to_func( + funcs: HbDrawFuncs, + callback: HbDrawCubicToFunc, + userData: pointer, + destroy: hbraw.HbDestroyFunc, +) {.cdecl, importc: "hb_draw_funcs_set_cubic_to_func", dynlib: hbraw.hbLib.} + +proc hb_draw_funcs_set_close_path_func( + funcs: HbDrawFuncs, + callback: HbDrawClosePathFunc, + userData: pointer, + destroy: hbraw.HbDestroyFunc, +) {.cdecl, importc: "hb_draw_funcs_set_close_path_func", dynlib: hbraw.hbLib.} + +proc hb_font_draw_glyph_or_fail( + font: hbraw.HbFont, glyph: hbraw.HbCodepoint, funcs: HbDrawFuncs, drawData: pointer +): hbraw.HbBool {.cdecl, importc: "hb_font_draw_glyph_or_fail", dynlib: hbraw.hbLib.} + +proc hbTag(tag: string): hbraw.HbTag = + if tag.len == 0 or tag.len > 4: + raise newException(ValueError, "HarfBuzz tags must contain 1 to 4 bytes") + hbraw.hb_tag_from_string(tag.cstring, cint(tag.len)) + +proc setVariations(font: hbraw.HbFont, variations: openArray[FontVariation]) = + if variations.len == 0: + return + + var rawVariations = newSeq[hbraw.HbVariation](variations.len) + for i, variation in variations: + rawVariations[i] = + hbraw.HbVariation(tag: hbTag(variation.tag), value: cfloat(variation.value)) + hbraw.hb_font_set_variations(font, addr rawVariations[0], cuint(rawVariations.len)) + +proc drawPathState(drawData: pointer): ptr DrawPathState {.inline.} = + cast[ptr DrawPathState](drawData) + +proc drawMoveTo( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + toX, toY: cfloat, + userData: pointer, +) {.cdecl.} = + discard funcs + discard state + discard userData + drawData.drawPathState().path.moveTo(toX.float32, toY.float32) + +proc drawLineTo( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + toX, toY: cfloat, + userData: pointer, +) {.cdecl.} = + discard funcs + discard state + discard userData + drawData.drawPathState().path.lineTo(toX.float32, toY.float32) + +proc drawQuadraticTo( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + controlX, controlY, toX, toY: cfloat, + userData: pointer, +) {.cdecl.} = + discard funcs + discard state + discard userData + drawData.drawPathState().path.quadraticCurveTo( + controlX.float32, controlY.float32, toX.float32, toY.float32 + ) + +proc drawCubicTo( + funcs: HbDrawFuncs, + drawData: pointer, + state: HbDrawState, + control1X, control1Y, control2X, control2Y, toX, toY: cfloat, + userData: pointer, +) {.cdecl.} = + discard funcs + discard state + discard userData + drawData.drawPathState().path.bezierCurveTo( + control1X.float32, control1Y.float32, control2X.float32, control2Y.float32, + toX.float32, toY.float32, + ) + +proc drawClosePath( + funcs: HbDrawFuncs, drawData: pointer, state: HbDrawState, userData: pointer +) {.cdecl.} = + discard funcs + discard state + discard userData + drawData.drawPathState().path.closePath() + +proc createDrawFuncs(): HbDrawFuncs = + result = hb_draw_funcs_create() + if result == nil: + raise newException(ValueError, "could not create HarfBuzz draw functions") + + hb_draw_funcs_set_move_to_func(result, drawMoveTo, nil, nil) + hb_draw_funcs_set_line_to_func(result, drawLineTo, nil, nil) + hb_draw_funcs_set_quadratic_to_func(result, drawQuadraticTo, nil, nil) + hb_draw_funcs_set_cubic_to_func(result, drawCubicTo, nil, nil) + hb_draw_funcs_set_close_path_func(result, drawClosePath, nil, nil) + hb_draw_funcs_make_immutable(result) + +proc destroy(handles: var HbFontHandles) = + if handles.font != nil: + hbraw.hb_font_destroy(handles.font) + handles.font = nil + if handles.face != nil: + hbraw.hb_face_destroy(handles.face) + handles.face = nil + if handles.blob != nil: + hbraw.hb_blob_destroy(handles.blob) + handles.blob = nil + +proc initHbFont(fontId: FontId): HbFontHandles = + let + font = getFigFont(fontId) + source = getTypefaceSource(font.typefaceId) + + if source.data.len == 0: + raise newException(ValueError, "typeface source data is empty") + + result.blob = hbraw.hb_blob_create( + source.data.cstring, + cuint(source.data.len), + hbraw.HB_MEMORY_MODE_DUPLICATE, + nil, + nil, + ) + if result.blob == nil: + raise newException(ValueError, "could not create HarfBuzz blob") + + result.face = hbraw.hb_face_create(result.blob, 0) + if result.face == nil: + result.destroy() + raise newException(ValueError, "could not create HarfBuzz face") + + result.font = hbraw.hb_font_create(result.face) + if result.font == nil: + result.destroy() + raise newException(ValueError, "could not create HarfBuzz font") + + hbraw.hb_ot_font_set_funcs(result.font) + let upem = hbraw.hb_face_get_upem(result.face) + if upem > 0: + hbraw.hb_font_set_scale(result.font, cint(upem), cint(upem)) + result.font.setVariations(font.variations) + +proc drawGlyphPath(font: hbraw.HbFont, glyphId: FontGlyphId): Path = + let funcs = createDrawFuncs() + defer: + hb_draw_funcs_destroy(funcs) + + var state = DrawPathState(path: newPath()) + let ok = hb_font_draw_glyph_or_fail( + font, hbraw.HbCodepoint(uint32(glyphId)), funcs, addr state + ) + if ok == 0: + return nil + state.path + +proc imageBoundsFor(path: Path, fallbackSize: Vec2): Rect = + var bounds = path.computeBounds().snapToPixels() + if bounds.w <= 0 or bounds.h <= 0: + bounds = rect(0, 0, fallbackSize.x, fallbackSize.y).snapToPixels() + bounds.w = max(bounds.w, fallbackSize.x) + bounds.h = max(bounds.h, fallbackSize.y) + bounds + +proc renderGlyphIdGlyph*( + imageId: ImageId, + fontId: FontId, + glyphId: FontGlyphId, + glyphRect: Rect, + descent: float32, + imageOffset: Vec2, + lcdFiltering = false, + subpixelVariant = 0, + subpixelSteps = 10, + upload = true, +): Image {.discardable.} = + ## Renders one glyph by shaped font glyph id through HarfBuzz draw callbacks. + var handles = initHbFont(fontId) + defer: + handles.destroy() + + let + figFont = getFigFont(fontId) + upem = hbraw.hb_face_get_upem(handles.face) + if upem == 0: + return nil + + var path = handles.font.drawGlyphPath(glyphId) + if path == nil or ($path).len == 0: + return nil + + let + fontScale = figFont.size.getScaledFont() / upem.float32 + subpixelOffset = + if subpixelVariant > 0: + subpixelVariant.float32 / subpixelSteps.float32 + else: + 0.0'f32 + imageOffsetPx = imageOffset.scaled() + baselineY = descent.scaled() + transform = + translate(vec2(subpixelOffset - imageOffsetPx.x, baselineY - imageOffsetPx.y)) * + scale(vec2(fontScale, -fontScale)) + + path.transform(transform) + + let + fallbackSize = + vec2(max(glyphRect.w.scaled(), 1.0'f32), max(glyphRect.h.scaled(), 1.0'f32)) + bounds = imageBoundsFor(path, fallbackSize) + imageWidth = max(ceil(bounds.x + bounds.w).int, 1) + imageHeight = max(ceil(bounds.y + bounds.h).int, 1) + + if imageWidth <= 0 or imageHeight <= 0: + return nil + + try: + var image = newImage(imageWidth, imageHeight) + image.fillPath(path, parseSomePaint(rgba(255, 255, 255, 255))) + if lcdFiltering: + image.applyLcdFilter() + + if upload: + loadImage(imageId, image) + return image + except PixieError: + return nil diff --git a/src/figdraw/common/textrasters/pixie_raster.nim b/src/figdraw/common/textrasters/pixie_raster.nim new file mode 100644 index 0000000..ca9ede5 --- /dev/null +++ b/src/figdraw/common/textrasters/pixie_raster.nim @@ -0,0 +1,95 @@ +import std/unicode + +import pkg/chronicles +import pkg/pixie +import pkg/pixie/fonts + +import ../fonttypes +import ../imgutils +import ../shared +import ../typefaces + +const lcdFilterWeights = [8'i32, 77'i32, 86'i32, 77'i32, 8'i32] + ## FreeType's default 5-tap LCD filter weights. + +proc applyLcdFilter*(image: var Image) = + ## Applies FreeType's default 5-tap LCD filter horizontally. + if image.width <= 0 or image.height <= 0: + return + + let src = image.data + var filtered = newSeq[type(src[0])](src.len) + let maxX = image.width - 1 + + for y in 0 ..< image.height: + let rowStart = y * image.width + for x in 0 ..< image.width: + var sumR, sumG, sumB, sumA: int32 + for i, weight in lcdFilterWeights: + let sx = min(max(x + i - 2, 0), maxX) + let px = src[rowStart + sx] + sumR += px.r.int32 * weight + sumG += px.g.int32 * weight + sumB += px.b.int32 * weight + sumA += px.a.int32 * weight + + let idx = rowStart + x + filtered[idx] = src[idx] + filtered[idx].r = uint8((sumR + 128'i32) shr 8) + filtered[idx].g = uint8((sumG + 128'i32) shr 8) + filtered[idx].b = uint8((sumB + 128'i32) shr 8) + filtered[idx].a = uint8((sumA + 128'i32) shr 8) + + image.data = move(filtered) + +proc renderPixieGlyph*( + imageId: ImageId, + fontId: FontId, + rune: Rune, + glyphRect: Rect, + lcdFiltering = false, + subpixelVariant = 0, + subpixelSteps = 10, + upload = true, +): Image {.discardable.} = + ## Renders one glyph through Pixie's rune-based raster path. + let font = getPixieFont(fontId) + + var + text = $rune + arrangement = pixie.typeset( + @[newSpan(text, font)], + bounds = glyphRect.wh.scaled(), + hAlign = CenterAlign, + vAlign = TopAlign, + wrap = false, + ) + + if subpixelVariant > 0: + let subpixelOffset = subpixelVariant.float32 / subpixelSteps.float32 + for i in 0 ..< arrangement.positions.len: + arrangement.positions[i].x += subpixelOffset + + let snappedBounds = arrangement.computeBounds().snapToPixels() + + let + lineHeight = font.defaultLineHeight() + bounds = rect(0, 0, scaled(snappedBounds.w + snappedBounds.x), scaled(lineHeight)) + + if bounds.w == 0 or bounds.h == 0: + debug "GEN IMG: ", + rune = $rune, rectWh = repr glyphRect.wh, snapped = repr snappedBounds + return nil + + try: + font.paint = parseHex"FFFFFF" + var image = newImage(bounds.w.int, bounds.h.int) + image.fillText(arrangement) + if lcdFiltering: + image.applyLcdFilter() + + if upload: + loadImage(imageId, image) + return image + except PixieError: + return nil diff --git a/src/figdraw/common/typefaces.nim b/src/figdraw/common/typefaces.nim index 9361a4a..610ad41 100644 --- a/src/figdraw/common/typefaces.nim +++ b/src/figdraw/common/typefaces.nim @@ -20,9 +20,15 @@ type TypeFaceKinds* = enum OTF SVG +type TypefaceSource* = object + name*: string + data*: string + kind*: TypeFaceKinds + var typefaceTable*: Table[TypefaceId, Typeface] ## holds the table of parsed fonts fontTable*: Table[FontId, FigFont] + typefaceSourceTable*: Table[TypefaceId, TypefaceSource] staticTypefaceTable*: Table[string, tuple[name: string, data: string, kind: TypeFaceKinds]] fontLock*: Lock @@ -89,6 +95,12 @@ proc readTypefaceImpl( result.filePath = name +proc typefaceKindFromPath(path: string): TypeFaceKinds = + case splitFile(path).ext.toLowerAscii() + of ".otf": OTF + of ".svg": SVG + else: TTF + proc loadTypeface*(name: string, fallbackNames: openArray[string] = []): TypefaceId = ## loads a font from a file and adds it to the font index @@ -116,11 +128,17 @@ proc loadTypeface*(name: string, fallbackNames: openArray[string] = []): Typefac var loaded = false var typeface: Typeface + var source: TypefaceSource for candidate in candidateNames: let typefacePath = resolveTypefacePath(candidate) if typefacePath.len > 0: try: typeface = readTypeface(typefacePath) + source = TypefaceSource( + name: typefacePath, + data: readFile(typefacePath), + kind: typefaceKindFromPath(typefacePath), + ) loaded = true break except PixieError: @@ -135,6 +153,9 @@ proc loadTypeface*(name: string, fallbackNames: openArray[string] = []): Typefac requested = name, candidate = candidate, staticName = staticEntry.name typeface = readTypefaceImpl(staticEntry.name, staticEntry.data, staticEntry.kind) + source = TypefaceSource( + name: staticEntry.name, data: staticEntry.data, kind: staticEntry.kind + ) loaded = true break except PixieError: @@ -155,6 +176,7 @@ proc loadTypeface*(name: string, fallbackNames: openArray[string] = []): Typefac if id in typefaceTable: doAssert typefaceTable[id] == typeface typefaceTable[id] = typeface + typefaceSourceTable[id] = source result = id proc loadTypeface*(name, data: string, kind: TypeFaceKinds): TypefaceId = @@ -165,8 +187,22 @@ proc loadTypeface*(name, data: string, kind: TypeFaceKinds): TypefaceId = id = typeface.getId() typefaceTable[id] = typeface + typefaceSourceTable[id] = TypefaceSource(name: name, data: data, kind: kind) result = id +proc getTypefaceSource*(id: TypefaceId): TypefaceSource = + if id notin typefaceSourceTable: + raise newException( + ValueError, "typeface source data is not available for id " & $Hash(id) + ) + typefaceSourceTable[id] + +proc getFigFont*(fontId: FontId): FigFont = + withLock(fontLock): + if fontId notin fontTable: + raise newException(ValueError, "font is not available for id " & $Hash(fontId)) + result = fontTable[fontId] + proc pixieFont(font: FigFont): (FontId, Font) = let id = FontId(hash((font.getId(), figUiScale()))) diff --git a/src/figdraw/figrender.nim b/src/figdraw/figrender.nim index 56ffd20..7e5e429 100644 --- a/src/figdraw/figrender.nim +++ b/src/figdraw/figrender.nim @@ -331,31 +331,31 @@ proc renderText(ctx: BackendContext, node: Fig) {.forbids: [AppMainThreadEff].} ctx.translate(vec2(0.0'f32, invertPivotY)) ctx.scale(vec2(1.0'f32, -1.0'f32)) - if NfSelectText in node.flags and fillAlphaMax(node.fill) > 0'u8: - let rects = node.textLayout.selectionRects - if rects.len > 0 and node.selectionRange.a <= node.selectionRange.b: - let startIdx = max(node.selectionRange.a, 0) - let endIdx = min(node.selectionRange.b, rects.len - 1) - let zeroRadii = [0.0'f32, 0.0'f32, 0.0'f32, 0.0'f32] - for idx in startIdx .. endIdx: - let rect = rects[idx].scaled() - if rect.w > 0 and rect.h > 0: - ctx.drawRoundedRectSdf( - rect = rect, - fill = node.fill.toBackendFill(), - radii = zeroRadii, - mode = figbackend.SdfMode.sdfModeClipAA, - factor = 4.0'f32, - spread = 0.0'f32, - shapeSize = vec2(0.0'f32, 0.0'f32), - ) + if NfSelectText in node.flags and fillAlphaMax(node.fill) > 0'u8 and + node.selectionRange.a <= node.selectionRange.b: + let + sourceRange = node.selectionRange.a.int .. node.selectionRange.b.int + zeroRadii = [0.0'f32, 0.0'f32, 0.0'f32, 0.0'f32] + for selection in node.textLayout.selectionRectsFor(sourceRange): + if selection.h > 0: + let selectionRect = + rect(selection.x, selection.y, max(selection.w, 1.0'f32), selection.h) + ctx.drawRoundedRectSdf( + rect = selectionRect.scaled(), + fill = node.fill.toBackendFill(), + radii = zeroRadii, + mode = figbackend.SdfMode.sdfModeClipAA, + factor = 4.0'f32, + spread = 0.0'f32, + shapeSize = vec2(0.0'f32, 0.0'f32), + ) for glyph in node.textLayout.glyphs(): - if unicode.isWhiteSpace(glyph.rune): + if glyph.isWhitespace: continue var - glyphPos = glyphLocalPos(glyph.pos, glyph.descent) + glyphPos = glyphLocalPos(glyph.pos, glyph.descent) + glyph.imageOffset.scaled() subpixelShift = 0.0'f32 subpixelVariant = 0 if subpixelPositioning: diff --git a/tests/tfontutils.nim b/tests/tfontutils.nim index 08eb335..bc1d22d 100644 --- a/tests/tfontutils.nim +++ b/tests/tfontutils.nim @@ -1,4 +1,4 @@ -import std/[os, unittest, tables, locks, unicode] +import std/[hashes, os, unittest, tables, locks, unicode] import pkg/pixie import pkg/pixie/fonts @@ -12,11 +12,77 @@ import figdraw/extras/systemfonts proc resetFontState() = typefaceTable = initTable[TypefaceId, Typeface]() fontTable = initTable[FontId, FigFont]() + typefaceSourceTable = initTable[TypefaceId, TypefaceSource]() staticTypefaceTable = initTable[string, tuple[name: string, data: string, kind: TypeFaceKinds]]() #withLock imageCachedLock: # imageCached.clear() +proc firstLoadableSystemFontPath(candidates: openArray[string]): string = + let preferred = findSystemFontFile(candidates) + if preferred.len > 0: + try: + discard readTypeface(preferred) + return preferred + except PixieError: + discard + + for path in systemFontFiles(): + try: + discard readTypeface(path) + return path + except PixieError: + discard + + "" + +when figdrawTextBackend == "harfbuzzy" or figdrawTextBackend == "hybrid": + proc firstLoadableNamedSystemFontPath(candidates: openArray[string]): string = + let preferred = findSystemFontFile(candidates) + if preferred.len > 0: + try: + discard readTypeface(preferred) + return preferred + except PixieError: + discard + "" + +proc testGlyph( + fontId: FontId, sourceRune: int, glyphId: uint32, box: Rect +): ArrangedGlyph = + let rune = Rune(0x61'i32 + sourceRune.int32) + ArrangedGlyph( + fontId: fontId, + glyphId: FontGlyphId(glyphId), + source: GlyphSourceRange( + byteStart: sourceRune, + byteEnd: sourceRune + 1, + runeStart: sourceRune, + runeEnd: sourceRune + 1, + ), + rune: rune, + pos: box.xy, + rect: box, + ) + +proc testGlyphRange( + fontId: FontId, sourceRange: Slice[int], glyphId: uint32, box: Rect +): ArrangedGlyph = + let rune = Rune(0x61'i32 + sourceRange.a.int32) + ArrangedGlyph( + fontId: fontId, + glyphId: FontGlyphId(glyphId), + source: GlyphSourceRange( + byteStart: sourceRange.a, + byteEnd: sourceRange.b + 1, + runeStart: sourceRange.a, + runeEnd: sourceRange.b + 1, + ), + rune: rune, + pos: box.xy, + rect: box, + ) + suite "fontutils": setup: resetFontState() @@ -30,6 +96,8 @@ suite "fontutils": check id1.int != 0 check id1 == id2 check id1 in typefaceTable + check id1 in typefaceSourceTable + check typefaceSourceTable[id1].data == fontData test "convertFont caches pixie font": let fontData = readFile(figDataDir() / "Ubuntu.ttf") @@ -74,19 +142,684 @@ suite "fontutils": check arrangement.spanColors.len == spans.len check arrangement.runes.len == arrangement.positions.len check arrangement.runes.len == arrangement.selectionRects.len + check arrangement.sourceRunes == arrangement.runes + check arrangement.arrangedGlyphs.len == arrangement.runes.len check arrangement.maxSize.x >= arrangement.minSize.x check arrangement.maxSize.y >= arrangement.minSize.y check arrangement.bounding.w > 0'f32 check arrangement.bounding.h > 0'f32 + for i in 0 ..< arrangement.arrangedGlyphs.len: + let arranged = arrangement.arrangedGlyphs[i] + check arranged.rune == arrangement.runes[i] + check arranged.pos == arrangement.positions[i] + check arranged.rect == arrangement.selectionRects[i] + when figdrawTextBackend == "pixie": + check arranged.glyphId == syntheticFontGlyphId(arranged.fontId, arranged.rune) + else: + check arranged.glyphId != FontGlyphId(0) + check arrangement.sourceRune(i) == arrangement.runes[i] + check arrangement.sourceRuneRange(i) == i .. i + + var sourceCount = 0 + for sourceRune in sourceRunes(arrangement, i): + check sourceRune == arrangement.runes[i] + inc sourceCount + check sourceCount == 1 + var foundNonWhitespace = false for glyph in arrangement.glyphs(): if not glyph.rune.isWhiteSpace: foundNonWhitespace = true - check hasImage(glyph.hash().ImageId) + when figdrawTextBackend == "pixie": + check glyph.glyphId == syntheticFontGlyphId(glyph.fontId, glyph.rune) + else: + check glyph.glyphId != FontGlyphId(0) + check glyph.source.runeStart >= 0 + check glyph.source.runeEnd == glyph.source.runeStart + 1 + when figdrawTextBackend != "harfbuzzy": + check hasImage(glyph.hash().ImageId) break check foundNonWhitespace + test "source selection bands use full line height": + let + fontId = FontId(Hash(42)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 3], + spans: @[0 .. 3], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: + @[ + testGlyph(fontId, 0, 10, rect(0, 2, 12, 10)), + testGlyph(fontId, 1, 11, rect(12, 4, 8, 6)), + testGlyph(fontId, 2, 12, rect(20, 0, 10, 14)), + testGlyph(fontId, 3, 13, rect(30, 2, 10, 10)), + ], + runes: sourceRunes, + selectionRects: + @[ + rect(0, 2, 12, 10), + rect(12, 4, 8, 6), + rect(20, 0, 10, 14), + rect(30, 2, 10, 10), + ], + ) + + let rawRects = arrangement.glyphSelectionRectsFor(1 .. 2) + check rawRects == @[rect(12, 4, 8, 6), rect(20, 0, 10, 14)] + + let bands = arrangement.selectionRectsFor(1 .. 2) + check bands == @[rect(12, 0, 18, 14)] + check arrangement.selectionBandsFor(1 .. 2) == bands + check arrangement.selectionRectsForRawBytes(1 .. 2) == bands + + test "source selection bands split separated visual fragments": + let + fontId = FontId(Hash(43)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = + @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0), "e".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 4], + spans: @[0 .. 4], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: + @[ + testGlyph(fontId, 0, 10, rect(0, 0, 10, 14)), + testGlyph(fontId, 1, 11, rect(10, 0, 10, 14)), + testGlyph(fontId, 3, 13, rect(20, 0, 10, 14)), + testGlyph(fontId, 2, 12, rect(30, 0, 10, 14)), + testGlyph(fontId, 4, 14, rect(40, 0, 10, 14)), + ], + runes: sourceRunes, + selectionRects: + @[ + rect(0, 0, 10, 14), + rect(10, 0, 10, 14), + rect(20, 0, 10, 14), + rect(30, 0, 10, 14), + rect(40, 0, 10, 14), + ], + ) + + let rawRects = arrangement.glyphSelectionRectsFor(1 .. 2) + check rawRects == @[rect(10, 0, 10, 14), rect(30, 0, 10, 14)] + check arrangement.selectionRectsFor(1 .. 2) == + @[rect(10, 0, 10, 14), rect(30, 0, 10, 14)] + + test "source selection bands clip partial ligature ranges": + let + fontId = FontId(Hash(44)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 0], + spans: @[0 .. 0], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: @[testGlyphRange(fontId, 0 .. 3, 20, rect(10, 2, 40, 10))], + runes: sourceRunes, + selectionRects: @[rect(10, 2, 40, 10)], + ) + + check arrangement.glyphSelectionRectsFor(1 .. 1) == @[rect(10, 2, 40, 10)] + check arrangement.selectionRectsFor(1 .. 1) == @[rect(20, 2, 10, 10)] + check arrangement.selectionRectsFor(1 .. 2) == @[rect(20, 2, 20, 10)] + + test "source selection bands clip rtl partial ligature ranges from right edge": + let + fontId = FontId(Hash(45)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = + @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0), "e".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 2], + spans: @[0 .. 2], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: + @[ + testGlyph(fontId, 4, 24, rect(0, 0, 10, 14)), + testGlyphRange(fontId, 1 .. 3, 21, rect(10, 0, 30, 14)), + testGlyph(fontId, 0, 20, rect(40, 0, 10, 14)), + ], + runes: sourceRunes, + selectionRects: @[rect(0, 0, 10, 14), rect(10, 0, 30, 14), rect(40, 0, 10, 14)], + ) + + check arrangement.selectionRectsFor(1 .. 1) == @[rect(30, 0, 10, 14)] + check arrangement.selectionRectsFor(2 .. 3) == @[rect(10, 0, 20, 14)] + + test "caret positions collapse ltr shaped cluster fragments": + let + fontId = FontId(Hash(46)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 3], + spans: @[0 .. 3], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: + @[ + testGlyph(fontId, 0, 10, rect(0, 0, 10, 14)), + testGlyphRange(fontId, 1 .. 2, 21, rect(22, 0, 0, 14)), + testGlyphRange(fontId, 1 .. 2, 22, rect(10, 0, 20, 14)), + testGlyph(fontId, 3, 13, rect(30, 0, 10, 14)), + ], + runes: sourceRunes, + selectionRects: + @[ + rect(0, 0, 10, 14), + rect(22, 0, 0, 14), + rect(10, 0, 20, 14), + rect(30, 0, 10, 14), + ], + ) + + let + startCarets = arrangement.caretPositionsFor(1) + insideCarets = arrangement.caretPositionsFor(2) + endCarets = arrangement.caretPositionsFor(3) + + check startCarets.len == 1 + check abs(startCarets[0].pos.x - 10.0'f32) < 0.01'f32 + check insideCarets.len == 1 + check abs(insideCarets[0].pos.x - 20.0'f32) < 0.01'f32 + check endCarets.len == 1 + check abs(endCarets[0].pos.x - 30.0'f32) < 0.01'f32 + check arrangement.selectionRectsFor(1 .. 1) == @[rect(10, 0, 10, 14)] + + test "caret positions collapse rtl shaped cluster fragments": + let + fontId = FontId(Hash(47)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 14, descentAdj: 10) + sourceRunes = + @["a".runeAt(0), "b".runeAt(0), "c".runeAt(0), "d".runeAt(0), "e".runeAt(0)] + arrangement = GlyphArrangement( + lines: @[0 .. 3], + spans: @[0 .. 3], + fonts: @[glyphFont], + sourceRunes: sourceRunes, + arrangedGlyphs: + @[ + testGlyph(fontId, 4, 14, rect(0, 0, 10, 14)), + testGlyphRange(fontId, 1 .. 2, 21, rect(22, 0, 0, 14)), + testGlyphRange(fontId, 1 .. 2, 22, rect(10, 0, 20, 14)), + testGlyph(fontId, 0, 10, rect(30, 0, 10, 14)), + ], + runes: sourceRunes, + selectionRects: + @[ + rect(0, 0, 10, 14), + rect(22, 0, 0, 14), + rect(10, 0, 20, 14), + rect(30, 0, 10, 14), + ], + ) + + let + startCarets = arrangement.caretPositionsFor(1) + insideCarets = arrangement.caretPositionsFor(2) + endCarets = arrangement.caretPositionsFor(3) + + check startCarets.len == 1 + check abs(startCarets[0].pos.x - 30.0'f32) < 0.01'f32 + check insideCarets.len == 1 + check abs(insideCarets[0].pos.x - 20.0'f32) < 0.01'f32 + check endCarets.len == 1 + check abs(endCarets[0].pos.x - 10.0'f32) < 0.01'f32 + check arrangement.selectionRectsFor(2 .. 2) == @[rect(10, 0, 10, 14)] + + when figdrawTextBackend == "harfbuzzy" or figdrawTextBackend == "hybrid": + test "harfbuzzy backend emits shaped glyph ids and source ranges": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 18.0'f32) + let box = rect(0, 0, 240, 60) + let spans = [(fs(uiFont), "Hello")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 5 + check arrangement.arrangedGlyphs.len == 5 + + var foundShapedId = false + for i, glyph in arrangement.arrangedGlyphs: + check glyph.source.runeStart == i + check glyph.source.runeEnd == i + 1 + if glyph.glyphId != syntheticFontGlyphId(glyph.fontId, glyph.rune): + foundShapedId = true + check foundShapedId + + test "source range helpers map ligatures back to source runes": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let box = rect(0, 0, 300, 80) + let spans = [(fs(uiFont), "office")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 6 + check arrangement.arrangedGlyphs.len < arrangement.sourceRunes.len + + let ligatureGlyphRange = arrangement.glyphRangeFor(1 .. 3) + check ligatureGlyphRange.a == ligatureGlyphRange.b + + let ligatureGlyph = ligatureGlyphRange.a + check arrangement.sourceRuneRange(ligatureGlyph) == 1 .. 3 + + var source = "" + for rune in sourceRunes(arrangement, ligatureGlyph): + source.add $rune + check source == "ffi" + + let rects = arrangement.glyphSelectionRectsFor(2 .. 2) + check rects.len == 1 + check rects[0] == arrangement.arrangedGlyphs[ligatureGlyph].rect + check arrangement.selectionRectsFor(2 .. 2).len == 1 + + let hitPoint = vec2(rects[0].x + rects[0].w / 2, rects[0].y + rects[0].h / 2) + check arrangement.glyphIndexAt(hitPoint) == ligatureGlyph + check arrangement.sourceRuneRangeAt(hitPoint) == 1 .. 3 + + test "OpenType features can disable discretionary ligature shaping": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont( + typefaceId: typefaceId, + size: 32.0'f32, + features: @[fontFeature("liga", 0), fontFeature("clig", 0)], + ) + let box = rect(0, 0, 300, 80) + let spans = [(fs(uiFont), "office")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 6 + check arrangement.arrangedGlyphs.len == arrangement.sourceRunes.len + + let glyphRange = arrangement.glyphRangeFor(1 .. 3) + check glyphRange.b - glyphRange.a + 1 == 3 + + test "harfbuzzy fallback stores fallback font ids on shaped runs": + let + primaryId = loadTypeface( + getCurrentDir() / "examples/fonts" / "NotoSansHebrew-wdth-wght.ttf" + ) + arabicId = + loadTypeface(getCurrentDir() / "examples/fonts" / "NotoNaskhArabic-wght.ttf") + uiFont = FigFont( + typefaceId: primaryId, size: 32.0'f32, fallbackTypefaceIds: @[arabicId] + ) + box = rect(0, 0, 360, 90) + spans = [(fs(uiFont), "abc سلام")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.spans.len == arrangement.fonts.len + check arrangement.spans.len == arrangement.spanColors.len + + var sawArabicFallback = false + for glyph in arrangement.arrangedGlyphs: + var sourceHasArabic = false + for sourceIndex in glyph.source.runeStart ..< glyph.source.runeEnd: + let codepoint = arrangement.sourceRunes[sourceIndex].uint32 + if codepoint in 0x0600'u32 .. 0x06ff'u32: + sourceHasArabic = true + break + + if sourceHasArabic: + check getFigFont(glyph.fontId).typefaceId == arabicId + sawArabicFallback = true + + check sawArabicFallback + + test "variable axes are carried by shaped font ids": + let typefaceId = loadTypeface( + getCurrentDir() / "examples/fonts" / "NotoSansHebrew-wdth-wght.ttf" + ) + let lightFont = FigFont( + typefaceId: typefaceId, + size: 32.0'f32, + variations: @[fontVariation("wght", 300.0'f32), fontVariation("wdth", 90.0'f32)], + ) + let boldFont = FigFont( + typefaceId: typefaceId, + size: 32.0'f32, + variations: + @[fontVariation("wght", 800.0'f32), fontVariation("wdth", 100.0'f32)], + ) + let box = rect(0, 0, 300, 80) + + let + lightArrangement = typeset( + box, + [(fs(lightFont), "שלום")], + hAlign = Left, + vAlign = Top, + minContent = false, + wrap = false, + ) + boldArrangement = typeset( + box, + [(fs(boldFont), "שלום")], + hAlign = Left, + vAlign = Top, + minContent = false, + wrap = false, + ) + + check lightArrangement.arrangedGlyphs.len == boldArrangement.arrangedGlyphs.len + check lightArrangement.arrangedGlyphs.len > 0 + check lightArrangement.arrangedGlyphs[0].fontId != + boldArrangement.arrangedGlyphs[0].fontId + check getFigFont(lightArrangement.arrangedGlyphs[0].fontId).variations == + lightFont.variations + check getFigFont(boldArrangement.arrangedGlyphs[0].fontId).variations == + boldFont.variations + + test "harfbuzzy wrap creates line slices at shaped glyph boundaries": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 24.0'f32) + let box = rect(0, 0, 95, 200) + let spans = [(fs(uiFont), "alpha beta gamma")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = true + ) + + check arrangement.lines.len > 1 + + var covered = 0 + var previousStop = -1 + var previousY = -1.0'f32 + for line in arrangement.lines: + check line.a == previousStop + 1 + check line.a <= line.b + check line.b < arrangement.arrangedGlyphs.len + + var + minX = float32.high + maxX = -float32.high + maxGlyphWidth = 0.0'f32 + minY = float32.high + for glyphIndex in line: + let glyph = arrangement.arrangedGlyphs[glyphIndex] + minX = min(minX, glyph.rect.x) + maxX = max(maxX, glyph.rect.x + glyph.rect.w) + maxGlyphWidth = max(maxGlyphWidth, glyph.rect.w) + minY = min(minY, glyph.rect.y) + check glyph.source.runeStart >= 0 + check glyph.source.runeEnd <= arrangement.sourceRunes.len + inc covered + + let lineWidth = maxX - minX + check lineWidth <= box.w + 0.1'f32 or maxGlyphWidth > box.w + check minY > previousY + previousY = minY + previousStop = line.b + + check covered == arrangement.arrangedGlyphs.len + + test "harfbuzzy wrap keeps ligature source ranges on one line": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let box = rect(0, 0, 78, 160) + let spans = [(fs(uiFont), "office office")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = true + ) + + check arrangement.lines.len > 1 + let ligatureGlyph = arrangement.glyphRangeFor(1 .. 3).a + check ligatureGlyph >= 0 + check arrangement.sourceRuneRange(ligatureGlyph) == 1 .. 3 + + var lineIndex = -1 + for i, line in arrangement.lines: + if ligatureGlyph >= line.a and ligatureGlyph <= line.b: + lineIndex = i + break + check lineIndex >= 0 + + for glyphIndex in arrangement.glyphRangeFor(1 .. 3): + var glyphLineIndex = -1 + for i, line in arrangement.lines: + if glyphIndex >= line.a and glyphIndex <= line.b: + glyphLineIndex = i + break + check glyphLineIndex == lineIndex + + test "harfbuzzy minContent keeps bottom-aligned wrapped text in bounds": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 24.0'f32) + let box = rect(0, 0, 82, 20) + let spans = [(fs(uiFont), "alpha beta gamma delta")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Bottom, minContent = true, wrap = true + ) + + check arrangement.lines.len > 1 + check arrangement.bounding.y >= -0.01'f32 + check arrangement.minSize.y > box.h + + test "harfbuzzy mixed direction text preserves source hit ranges": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 28.0'f32) + let box = rect(0, 0, 420, 80) + let spans = [(fs(uiFont), "abc שלום xyz")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 12 + let hebrewGlyphRange = arrangement.glyphRangeFor(4 .. 7) + check hebrewGlyphRange.a >= 0 + check hebrewGlyphRange.b >= hebrewGlyphRange.a + + let rects = arrangement.glyphSelectionRectsFor(4 .. 7) + check rects.len == hebrewGlyphRange.b - hebrewGlyphRange.a + 1 + check arrangement.selectionRectsFor(4 .. 7).len > 0 + for rect in rects: + let point = vec2(rect.x + rect.w / 2, rect.y + rect.h / 2) + let sourceRange = arrangement.sourceRuneRangeAt(point) + check sourceRange.a >= 4 + check sourceRange.b <= 7 + + test "harfbuzzy wraps CJK text without whitespace": + let fontPath = "deps/pixie/tests/fonts/NotoSansJP-Regular.ttf" + if not fileExists(fontPath): + check true + else: + let typefaceId = loadTypeface(fontPath) + let uiFont = FigFont(typefaceId: typefaceId, size: 24.0'f32) + let box = rect(0, 0, 72, 200) + let spans = [(fs(uiFont), "日本語日本語日本語")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = true + ) + + check arrangement.lines.len > 1 + var previousStop = -1 + for line in arrangement.lines: + check line.a == previousStop + 1 + check line.a <= line.b + check line.b < arrangement.arrangedGlyphs.len + + var + minX = float32.high + maxX = -float32.high + for glyphIndex in line: + let glyph = arrangement.arrangedGlyphs[glyphIndex] + minX = min(minX, glyph.rect.x) + maxX = max(maxX, glyph.rect.x + glyph.rect.w) + check not glyph.rune.isWhiteSpace + + check maxX - minX <= box.w + 0.1'f32 + previousStop = line.b + + test "harfbuzzy source helpers cover combining marks": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let acute = $Rune(0x0301) + let box = rect(0, 0, 260, 80) + let spans = [(fs(uiFont), "Cafe" & acute & " test")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 10 + let markGlyphRange = arrangement.glyphRangeFor(4 .. 4) + check markGlyphRange.a >= 0 + check markGlyphRange.b >= markGlyphRange.a + + let markRects = arrangement.glyphSelectionRectsFor(4 .. 4) + check markRects.len > 0 + check arrangement.selectionRectsFor(4 .. 4).len > 0 + for rect in markRects: + let point = vec2(rect.x + rect.w / 2, rect.y + rect.h / 2) + let sourceRange = arrangement.sourceRuneRangeAt(point) + check sourceRange.a <= 4 + check sourceRange.b >= 4 + + let carets = arrangement.caretPositionsFor(4) + check carets.len > 0 + check arrangement.nearestSourceRuneForCaretPoint(carets[0].pos) == 4 + + test "harfbuzzy source helpers cover Hebrew marks": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let text = "ש" & $Rune(0x05b8) & $Rune(0x05c1) & "לו" & $Rune(0x05b9) & "ם" + let box = rect(0, 0, 260, 80) + let spans = [(fs(uiFont), text)] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 7 + for markIndex in [1, 2, 5]: + let markGlyphRange = arrangement.glyphRangeFor(markIndex .. markIndex) + check markGlyphRange.a >= 0 + check markGlyphRange.b >= markGlyphRange.a + + let markRects = arrangement.glyphSelectionRectsFor(markIndex .. markIndex) + check markRects.len > 0 + check arrangement.selectionRectsFor(markIndex .. markIndex).len > 0 + for rect in markRects: + if rect.w > 0 and rect.h > 0: + let point = vec2(rect.x + rect.w / 2, rect.y + rect.h / 2) + let sourceRange = arrangement.sourceRuneRangeAt(point) + check sourceRange.a <= markIndex + check sourceRange.b >= markIndex + + let carets = arrangement.caretPositionsFor(markIndex) + check carets.len > 0 + + test "harfbuzzy caret helpers expose split mixed-direction positions": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 28.0'f32) + let box = rect(0, 0, 420, 80) + let spans = [(fs(uiFont), "abc שלום xyz")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + let hebrewStartCarets = arrangement.caretPositionsFor(4) + check hebrewStartCarets.len > 0 + for caret in hebrewStartCarets: + check caret.sourceRune == 4 + check caret.glyphIndex >= 0 + check caret.lineIndex == 0 + check arrangement.nearestSourceRuneForCaretPoint(caret.pos) == 4 + + let hebrewEndCarets = arrangement.caretPositionsFor(8) + check hebrewEndCarets.len > 0 + for caret in hebrewEndCarets: + check caret.sourceRune == 8 + + test "harfbuzzy shapes Arabic when a system Arabic font is available": + let fontPath = firstLoadableNamedSystemFontPath( + [ + "Noto Naskh Arabic", "Noto Sans Arabic", "Geeza Pro", "Arial Unicode", + "Arial", "DejaVu Sans", + ] + ) + if fontPath.len == 0: + check true + else: + let typefaceId = loadTypeface(fontPath) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let box = rect(0, 0, 320, 90) + let spans = [(fs(uiFont), "سلام")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + + check arrangement.sourceRunes.len == 4 + check arrangement.arrangedGlyphs.len > 0 + check arrangement.arrangedGlyphs.len <= arrangement.sourceRunes.len + for glyph in arrangement.arrangedGlyphs: + check glyph.glyphId != FontGlyphId(0) + check glyph.source.runeStart >= 0 + check glyph.source.runeEnd <= arrangement.sourceRunes.len + + when figdrawTextBackend == "harfbuzzy": + test "harfbuzzy glyph id raster provider renders shaped glyph images": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 32.0'f32) + let box = rect(0, 0, 300, 80) + let spans = [(fs(uiFont), "office")] + + let arrangement = typeset( + box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false + ) + let glyphIndex = arrangement.glyphRangeFor(1 .. 3).a + + var glyphs = newSeq[GlyphPosition]() + for glyph in arrangement.glyphs(): + glyphs.add glyph + + check glyphIndex >= 0 + check glyphIndex < glyphs.len + let image = glyphs[glyphIndex].generateGlyph(force = true, upload = false) + + check image != nil + check image.width > 0 + check image.height > 0 + check image.opaqueBounds().w > 0 + check image.opaqueBounds().h > 0 + test "glyph hash separates lcd filtering variants": let fontData = readFile(figDataDir() / "Ubuntu.ttf") let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) @@ -123,6 +856,70 @@ suite "fontutils": break check checked + test "glyph hash uses glyph id for cache identity": + let fontData = readFile(figDataDir() / "Ubuntu.ttf") + let typefaceId = loadTypeface("Ubuntu.ttf", fontData, TTF) + let uiFont = FigFont(typefaceId: typefaceId, size: 18.0'f32) + let box = rect(0, 0, 240, 60) + let spans = [(fs(uiFont), "A")] + let arrangement = + typeset(box, spans, hAlign = Left, vAlign = Top, minContent = false, wrap = false) + + let b = "B".runeAt(0) + var checked = false + for glyph in arrangement.glyphs(): + if glyph.rune.isWhiteSpace: + continue + let sameGlyphDifferentRune = + GlyphPosition(fontId: glyph.fontId, glyphId: glyph.glyphId, rune: b) + let differentGlyphSameRune = GlyphPosition( + fontId: glyph.fontId, + glyphId: syntheticFontGlyphId(glyph.fontId, b), + rune: glyph.rune, + ) + + check glyph.hash() == sameGlyphDifferentRune.hash() + check glyph.hash() != differentGlyphSameRune.hash() + checked = true + break + check checked + + test "glyph iterator skips empty spans before assigning style": + let + rune = "A".runeAt(0) + fontId = FontId(Hash(1)) + glyphFont = GlyphFont(fontId: fontId, lineHeight: 12, descentAdj: 3) + firstFill = fill(rgba(220, 40, 40, 255)) + secondFill = fill(rgba(40, 90, 220, 255)) + arrangement = GlyphArrangement( + lines: @[0 .. 0], + spans: @[0 .. -1, 0 .. 0], + fonts: @[glyphFont, glyphFont], + spanColors: @[firstFill, secondFill], + sourceRunes: @[rune], + arrangedGlyphs: + @[ + ArrangedGlyph( + fontId: fontId, + glyphId: FontGlyphId(65), + cluster: 0, + source: + GlyphSourceRange(byteStart: 0, byteEnd: 1, runeStart: 0, runeEnd: 1), + rune: rune, + pos: vec2(10, 12), + rect: rect(10, 0, 8, 12), + ) + ], + ) + + var glyphs = newSeq[GlyphPosition]() + for glyph in arrangement.glyphs(): + glyphs.add glyph + + check glyphs.len == 1 + check glyphs[0].fill == secondFill + check glyphs[0].lineHeight == glyphFont.lineHeight + test "glyph-variant subpixel step maps fractional x to 10 steps": check toGlyphVariantSubpixelStep(0.0'f32) == 0 check toGlyphVariantSubpixelStep(0.09'f32) == 0 @@ -178,6 +975,8 @@ suite "fontutils": let arrangement = placeGlyphs(uiFont, positions, origin = GlyphTopLeft) check arrangement.runes.len == positions.len + check arrangement.sourceRunes == arrangement.runes + check arrangement.arrangedGlyphs.len == positions.len check arrangement.spans.len == 1 check arrangement.fonts.len == 1 @@ -187,6 +986,9 @@ suite "fontutils": let charPos = vec2(glyph.pos.x, glyph.pos.y - glyph.descent) check abs(charPos.x - expected.x) < 0.01'f32 check abs(charPos.y - expected.y) < 0.01'f32 + check glyph.glyphId == syntheticFontGlyphId(glyph.fontId, glyph.rune) + check arrangement.sourceRune(idx) == positions[idx][0] + check arrangement.sourceRuneRange(idx) == idx .. idx if not glyph.rune.isWhiteSpace: check hasImage(glyph.hash().ImageId) inc idx @@ -232,24 +1034,6 @@ suite "fontutils": elif defined(linux) or defined(freebsd): candidates = @["DejaVu Sans", "Noto Sans", "Liberation Sans", "Ubuntu"] - proc firstLoadableSystemFontPath(candidates: openArray[string]): string = - let preferred = findSystemFontFile(candidates) - if preferred.len > 0: - try: - discard readTypeface(preferred) - return preferred - except PixieError: - discard - - for path in systemFontFiles(): - try: - discard readTypeface(path) - return path - except PixieError: - discard - - "" - let systemPath = firstLoadableSystemFontPath(candidates) if systemPath.len == 0: check true