1 module fluid.typeface; 2 3 import bindbc.freetype; 4 5 import std.range; 6 import std.traits; 7 import std.string; 8 import std.algorithm; 9 10 import fluid.utils; 11 import fluid.backend; 12 13 14 @safe: 15 16 17 version (BindFT_Static) { 18 debug (Fluid_BuildMessages) { 19 pragma(msg, "Fluid: Using static freetype"); 20 } 21 } 22 else { 23 version = BindFT_Dynamic; 24 debug (Fluid_BuildMessages) { 25 pragma(msg, "Fluid: Using dynamic freetype"); 26 } 27 } 28 29 30 /// Low-level interface for drawing text. Represents a single typeface. 31 /// 32 /// Unlike the rest of Fluid, Typeface doesn't define pixels as 1/96th of an inch. DPI must also be specified manually. 33 /// 34 /// See: [fluid.text.Text] for an interface on a higher level. 35 interface Typeface { 36 37 /// List glyphs in the typeface. 38 long glyphCount() const; 39 40 /// Get initial pen position. 41 Vector2 penPosition() const; 42 43 /// Get line height. 44 int lineHeight() const; 45 46 /// Get advance vector for the given glyph. Uses dots, not pixels, as the unit. 47 Vector2 advance(dchar glyph) const; 48 49 /// Set font scale. This should be called at least once before drawing. 50 /// 51 /// Font renderer should cache this and not change the scale unless updated. 52 /// 53 /// Params: 54 /// scale = Horizontal and vertical DPI value, for example (96, 96) 55 Vector2 dpi(Vector2 scale); 56 57 /// Get curently set DPI. 58 Vector2 dpi() const; 59 60 /// Draw a line of text. 61 /// Note: This API is unstable and might change over time. 62 void drawLine(ref Image target, ref Vector2 penPosition, string text, Color tint) const; 63 64 /// Get the default Fluid typeface. 65 static defaultTypeface() => FreetypeTypeface.defaultTypeface; 66 67 /// Default word splitter used by measure/draw. 68 final defaultWordChunks(Range)(Range range) const { 69 70 import std.uni; 71 72 /// Pick the group the character belongs to. 73 static bool pickGroup(dchar a) { 74 75 return a.isAlphaNum || a.isPunctuation; 76 77 } 78 79 return range 80 .splitWhen!((a, b) => pickGroup(a) != pickGroup(b) && !b.isWhite) 81 .map!"text(a)" 82 .cache; 83 // TODO string allocation could probably be avoided 84 85 } 86 87 /// Measure space the given text would span. Uses dots as the unit. 88 /// 89 /// If `availableSpace` is specified, assumes text wrapping. Text wrapping is only performed on whitespace 90 /// characters. 91 /// 92 /// Params: 93 /// chunkWords = Algorithm to use to break words when wrapping text; separators must be preserved. 94 /// availableSpace = Amount of available space the text can take up (dots), used to wrap text. 95 /// text = Text to measure. 96 /// wrap = Toggle text wrapping. 97 final Vector2 measure(alias chunkWords = defaultWordChunks, String) 98 (Vector2 availableSpace, String text, bool wrap = true) const 99 if (isSomeString!String) 100 do { 101 102 // Wrapping off 103 if (!wrap) return measure(text); 104 105 auto ruler = TextRuler(this, availableSpace.x); 106 107 // TODO don't fail on decoding errors 108 // TODO RTL layouts 109 // TODO vertical text 110 // TODO lineSplitter removes the last line feed if present, which is unwanted behavior 111 112 // Split on lines 113 foreach (line; text.lineSplitter) { 114 115 ruler.startLine(); 116 117 // Split on words 118 foreach (word; chunkWords(line)) { 119 120 ruler.addWord(word); 121 122 } 123 124 } 125 126 return ruler.textSize; 127 128 } 129 130 /// ditto 131 final Vector2 measure(String)(String text) const 132 if (isSomeString!String) 133 do { 134 135 // No wrap, only explicit in-text line breaks 136 137 auto ruler = TextRuler(this); 138 139 // TODO don't fail on decoding errors 140 141 // Split on lines 142 foreach (line; text.lineSplitter) { 143 144 ruler.startLine(); 145 ruler.addWord(line); 146 147 } 148 149 return ruler.textSize; 150 151 } 152 153 /// Draw text within the given rectangle in the image. 154 final void draw(alias chunkWords = defaultWordChunks, String) 155 (ref Image image, Rectangle rectangle, String text, Color tint, bool wrap = true) 156 const { 157 158 auto ruler = TextRuler(this, rectangle.w); 159 160 // TODO decoding errors 161 162 // Text wrapping on 163 if (wrap) { 164 165 // Split on lines 166 foreach (line; text.lineSplitter) { 167 168 ruler.startLine(); 169 170 // Split on words 171 foreach (word; chunkWords(line)) { 172 173 auto penPosition = rectangle.start + ruler.addWord(word); 174 175 drawLine(image, penPosition, word, tint); 176 177 } 178 179 } 180 181 } 182 183 // Text wrapping off 184 else { 185 186 // Split on lines 187 foreach (line; text.lineSplitter) { 188 189 ruler.startLine(); 190 191 auto penPosition = rectangle.start + ruler.addWord(line); 192 193 drawLine(image, penPosition, line, tint); 194 195 } 196 197 } 198 199 } 200 201 } 202 203 /// Low level interface for measuring text. 204 struct TextRuler { 205 206 /// Typeface to use for the text. 207 const Typeface typeface; 208 209 /// Maximum width for a single line. If `NaN`, no word breaking is performed. 210 float lineWidth; 211 212 /// Current pen position. 213 Vector2 penPosition; 214 215 /// Total size of the text. 216 Vector2 textSize; 217 218 this(const Typeface typeface, float lineWidth = float.nan) { 219 220 this.typeface = typeface; 221 this.lineWidth = lineWidth; 222 223 penPosition = typeface.penPosition - typeface.lineHeight; 224 225 } 226 227 /// Begin a new line. 228 void startLine() { 229 230 const lineHeight = typeface.lineHeight; 231 232 // Move the pen to the next line 233 penPosition.x = 0; 234 penPosition.y += lineHeight; 235 textSize.y += lineHeight; 236 237 } 238 239 /// Add the given word to the text. The text must be single line.; 240 /// Returns: Pen position for the word. 241 Vector2 addWord(String)(String word) { 242 243 const maxWordWidth = lineWidth - penPosition.x; 244 245 float wordSpan = 0; 246 247 // Measure each glyph 248 foreach (dchar glyph; word) { 249 250 wordSpan += typeface.advance(glyph).x; 251 252 } 253 254 // Exceeded line width 255 // Automatically false if lineWidth is NaN 256 if (maxWordWidth < wordSpan && wordSpan < lineWidth) { 257 258 // Start a new line 259 startLine(); 260 261 } 262 263 const wordPosition = penPosition; 264 265 // Update pen position 266 penPosition.x += wordSpan; 267 268 // Allocate space 269 if (penPosition.x > textSize.x) textSize.x = penPosition.x; 270 271 return wordPosition; 272 273 } 274 275 } 276 277 /// Represents a freetype2-powered typeface. 278 class FreetypeTypeface : Typeface { 279 280 public { 281 282 /// Underlying face. 283 FT_Face face; 284 285 /// Adjust line height. `1` uses the original line height, `2` doubles it. 286 float lineHeightFactor = 1; 287 288 } 289 290 private { 291 292 /// If true, this typeface has been loaded using this class, making the class responsible for freeing the font. 293 bool _isOwner; 294 295 /// Font size loaded (points). 296 int _size; 297 298 /// Current DPI set for the typeface. 299 int _dpiX, _dpiY; 300 301 } 302 303 static FreetypeTypeface defaultTypeface; 304 305 static this() @trusted { 306 307 // Set the default typeface 308 FreetypeTypeface.defaultTypeface = new FreetypeTypeface(14); 309 310 } 311 312 /// Load the default typeface 313 this(int size) @trusted { 314 315 static typefaceFile = cast(ubyte[]) import("ruda-regular.ttf"); 316 const typefaceSize = cast(int) typefaceFile.length; 317 318 // Load the font 319 if (auto error = FT_New_Memory_Face(freetype, typefaceFile.ptr, typefaceSize, 0, &face)) { 320 321 assert(false, format!"Failed to load default Fluid typeface at size %s, error no. %s"(size, error)); 322 323 } 324 325 // Mark self as the owner 326 this._size = size; 327 this.isOwner = true; 328 this.lineHeightFactor = 1.16; 329 330 } 331 332 /// Use an existing freetype2 font. 333 this(FT_Face face, int size) { 334 335 this.face = face; 336 this._size = size; 337 338 } 339 340 /// Load a font from a file. 341 /// Params: 342 /// backend = I/O Fluid backend, used to adjust the scale of the font. 343 /// filename = Filename of the font file. 344 /// size = Size of the font to load (in points). 345 this(string filename, int size) @trusted { 346 347 this._isOwner = true; 348 this._size = size; 349 350 // TODO proper exceptions 351 if (auto error = FT_New_Face(freetype, filename.toStringz, 0, &this.face)) { 352 353 throw new Exception(format!"Failed to load `%s`, error no. %s"(filename, error)); 354 355 } 356 357 } 358 359 ~this() @trusted { 360 361 // Ignore if the resources used by the class have been borrowed 362 if (!_isOwner) return; 363 364 FT_Done_Face(face); 365 366 } 367 368 bool isOwner() const => _isOwner; 369 bool isOwner(bool value) @system => _isOwner = value; 370 371 long glyphCount() const { 372 373 return face.num_glyphs; 374 375 } 376 377 /// Get initial pen position. 378 Vector2 penPosition() const { 379 380 return Vector2(0, face.size.metrics.ascender) / 64; 381 382 } 383 384 /// Line height. 385 int lineHeight() const { 386 387 // +1 is an error margin 388 return cast(int) (face.size.metrics.height * lineHeightFactor / 64) + 1; 389 390 } 391 392 Vector2 dpi() const { 393 394 return Vector2(_dpiX, _dpiY); 395 396 } 397 398 Vector2 dpi(Vector2 dpi) @trusted { 399 400 const dpiX = cast(int) dpi.x; 401 const dpiY = cast(int) dpi.y; 402 403 // Ignore if there's no change 404 if (dpiX == _dpiX && dpiY == _dpiY) return dpi; 405 406 _dpiX = dpiX; 407 _dpiY = dpiY; 408 409 // Load size 410 if (auto error = FT_Set_Char_Size(face, 0, _size*64, dpiX, dpiY)) { 411 412 throw new Exception( 413 format!"Failed to load font at size %s at DPI %sx%s, error no. %s"(_size, dpiX, dpiY, error) 414 ); 415 416 } 417 418 return dpi; 419 420 } 421 422 /// Get advance vector for the given glyph 423 Vector2 advance(dchar glyph) const @trusted { 424 425 assert(_dpiX && _dpiY, "Font DPI hasn't been set"); 426 427 // Sadly, there is no way to make FreeType operate correctly in `const` environment. 428 429 // Load the glyph 430 if (auto error = FT_Load_Char(cast(FT_FaceRec*) face, glyph, FT_LOAD_DEFAULT)) { 431 432 return Vector2(0, 0); 433 434 } 435 436 // Advance the cursor position 437 // TODO RTL layouts 438 return Vector2(face.glyph.advance.tupleof) / 64; 439 440 } 441 442 /// Draw a line of text 443 void drawLine(ref Image target, ref Vector2 penPosition, string text, Color tint) const @trusted { 444 445 assert(_dpiX && _dpiY, "Font DPI hasn't been set"); 446 447 foreach (dchar glyph; text) { 448 449 // Load the glyph 450 if (auto error = FT_Load_Char(cast(FT_FaceRec*) face, glyph, FT_LOAD_RENDER)) { 451 452 continue; 453 454 } 455 456 const bitmap = face.glyph.bitmap; 457 458 assert(bitmap.pixel_mode == FT_PIXEL_MODE_GRAY); 459 460 // Draw it to the image 461 foreach (y; 0..bitmap.rows) { 462 463 foreach (x; 0..bitmap.width) { 464 465 // Each pixel is a single byte 466 const pixel = *cast(ubyte*) (bitmap.buffer + bitmap.pitch*y + x); 467 468 const targetX = cast(int) penPosition.x + face.glyph.bitmap_left + x; 469 const targetY = cast(int) penPosition.y - face.glyph.bitmap_top + y; 470 471 // Don't draw pixels out of bounds 472 if (targetX >= target.width || targetY >= target.height) continue; 473 474 // Note: ImageDrawPixel overrides the pixel — alpha blending has to be done by us 475 const oldColor = target.get(targetX, targetY); 476 const newColor = tint.setAlpha(cast(float) pixel / pixel.max); 477 const color = alphaBlend(oldColor, newColor); 478 479 target.get(targetX, targetY) = color; 480 481 } 482 483 } 484 485 // Advance pen positon 486 penPosition += Vector2(face.glyph.advance.tupleof) / 64; 487 488 } 489 490 } 491 492 } 493 494 /// Font rendering via Raylib. Discouraged, potentially slow, and not HiDPI-compatible. Use `FreetypeTypeface` instead. 495 version (Have_raylib_d) 496 class RaylibTypeface : Typeface { 497 498 import raylib; 499 500 public { 501 502 /// Character spacing, as a fraction of the font size. 503 float spacing = 0.1; 504 505 /// Line height relative to font height. 506 float relativeLineHeight = 1.4; 507 508 /// Scale to apply for the typeface. 509 float scale = 1.0; 510 511 } 512 513 private { 514 515 /// Underlying Raylib font. 516 Font _font; 517 518 /// If true, this is the default font, and has to be available ahead of time. 519 /// 520 /// If the typeface is requested before Raylib is loaded (...and it is...), the default font won't be available, 521 /// so we must use late loading. 522 bool _isDefault; 523 524 /// If true, this typeface has been loaded using this class, making the class responsible for freeing the font. 525 bool _isOwner; 526 527 } 528 529 /// Object holding the default typeface. 530 static RaylibTypeface defaultTypeface() @trusted { 531 532 static RaylibTypeface typeface; 533 534 // Load the typeface 535 if (!typeface) { 536 typeface = new RaylibTypeface(GetFontDefault); 537 typeface._isDefault = true; 538 typeface.scale = 2.0; 539 } 540 541 return typeface; 542 543 } 544 545 /// Load a Raylib font. 546 this(Font font) { 547 548 this._font = font; 549 550 } 551 552 /// Load a Raylib font from file. 553 deprecated("Raylib font rendering is inefficient and lacks scaling. Use FreetypeTypeface instead") 554 this(string filename, int size) @trusted { 555 556 this._font = LoadFontEx(filename.toStringz, size, null, 0); 557 this.isOwner = true; 558 559 } 560 561 ~this() @trusted { 562 563 if (isOwner) { 564 565 UnloadFont(_font); 566 567 } 568 569 } 570 571 Font font() @trusted { 572 573 if (_isDefault) 574 return _font = GetFontDefault; 575 else 576 return _font; 577 578 } 579 580 const(Font) font() const @trusted { 581 582 if (_isDefault) 583 return GetFontDefault; 584 else 585 return _font; 586 587 } 588 589 bool isOwner() const => _isOwner; 590 bool isOwner(bool value) @system => _isOwner = value; 591 592 /// List glyphs in the typeface. 593 long glyphCount() const { 594 595 return font.glyphCount; 596 597 } 598 599 /// Get initial pen position. 600 Vector2 penPosition() const { 601 602 return Vector2(0, 0); 603 604 } 605 606 /// Get font height in pixels. 607 int fontHeight() const { 608 609 return cast(int) (font.baseSize * scale); 610 611 } 612 613 /// Get line height in pixels. 614 int lineHeight() const { 615 616 return cast(int) (fontHeight * relativeLineHeight); 617 618 } 619 620 /// Changing DPI at runtime is not supported for Raylib typefaces. 621 Vector2 dpi(Vector2 dpi) { 622 623 // Not supported for Raylib typefaces. 624 return Vector2(96, 96); 625 626 } 627 628 Vector2 dpi() const { 629 630 return Vector2(96, 96); 631 632 } 633 634 /// Get advance vector for the given glyph. 635 Vector2 advance(dchar codepoint) const @trusted { 636 637 const glyph = GetGlyphInfo(cast() font, codepoint); 638 const spacing = fontHeight * this.spacing; 639 const baseAdvanceX = glyph.advanceX 640 ? glyph.advanceX 641 : glyph.offsetX + glyph.image.width; 642 const advanceX = baseAdvanceX * scale; 643 644 return Vector2(advanceX + spacing, 0); 645 646 } 647 648 /// Draw a line of text 649 /// Note: This API is unstable and might change over time. 650 void drawLine(ref .Image target, ref Vector2 penPosition, string text, Color tint) const @trusted { 651 652 // Note: `DrawTextEx` doesn't scale `spacing`, but `ImageDrawTextEx` DOES. The image is first drawn at base size 653 // and *then* scaled. 654 const spacing = font.baseSize * this.spacing; 655 656 // We trust Raylib will not mutate the font 657 // Raylib is single-threaded, so it shouldn't cause much harm anyway... 658 auto font = cast() this.font; 659 660 // Make a Raylib-compatible wrapper for image data 661 auto result = target.toRaylib; 662 663 ImageDrawTextEx(&result, font, text.toStringz, penPosition, fontHeight, spacing, tint); 664 665 } 666 667 } 668 669 version (BindFT_Dynamic) 670 shared static this() @system { 671 672 // Ignore if freetype was already loaded 673 if (isFreeTypeLoaded) return; 674 675 // Load freetype 676 FTSupport ret = loadFreeType(); 677 678 // Check version 679 if (ret != ftSupport) { 680 681 if (ret == FTSupport.noLibrary) { 682 683 assert(false, "freetype2 failed to load"); 684 685 } 686 else if (FTSupport.badLibrary) { 687 688 assert(false, format!"found freetype2 is of incompatible version %s (needed %s)"(ret, ftSupport)); 689 690 } 691 692 } 693 694 } 695 696 /// Get the thread-local freetype reference. 697 FT_Library freetype() @trusted { 698 699 static FT_Library library; 700 701 // Load the library 702 if (library != library.init) return library; 703 704 if (auto error = FT_Init_FreeType(&library)) { 705 706 assert(false, format!"Failed to load freetype2: %s"(error)); 707 708 } 709 710 return library; 711 712 }