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.rope; 11 import fluid.utils; 12 import fluid.backend; 13 14 15 @safe: 16 17 18 version (BindFT_Static) { 19 debug (Fluid_BuildMessages) { 20 pragma(msg, "Fluid: Using static freetype"); 21 } 22 } 23 else { 24 version = BindFT_Dynamic; 25 debug (Fluid_BuildMessages) { 26 pragma(msg, "Fluid: Using dynamic freetype"); 27 } 28 } 29 30 /// Low-level interface for drawing text. Represents a single typeface. 31 /// 32 /// Unlike the rest of Fluid, Typeface uses screen-space dots directly, instead of fixed-size pixels. Consequently, DPI 33 /// must be specified manually. 34 /// 35 /// See: [fluid.text.Text] for an interface on a higher level. 36 interface Typeface { 37 38 /// List glyphs in the typeface. 39 long glyphCount() const; 40 41 /// Get initial pen position. 42 Vector2 penPosition() const; 43 44 /// Get line height. 45 int lineHeight() const; 46 47 /// Width of an indent/tab character, in dots. 48 /// `Text` sets `indentWidth` automatically. 49 ref inout(int) indentWidth() inout; 50 51 /// Get advance vector for the given glyph. Uses dots, not pixels, as the unit. 52 Vector2 advance(dchar glyph); 53 54 /// Set font scale. This should be called at least once before drawing. 55 /// `Text` sets DPI automatically. 56 /// 57 /// Font renderer should cache this and not change the scale unless updated. 58 /// 59 /// Params: 60 /// scale = Horizontal and vertical DPI value, for example (96, 96) 61 Vector2 dpi(Vector2 scale); 62 63 /// Get curently set DPI. 64 Vector2 dpi() const; 65 66 /// Draw a line of text. 67 /// Note: This API is unstable and might change over time. 68 /// Params: 69 /// target = Image to draw to. 70 /// penPosition = Pen position for the beginning of the line. Updated to the pen position at the end of th line. 71 /// text = Text to draw. 72 /// paletteIndex = If the image has a palette, this is the index to get colors from. 73 void drawLine(ref Image target, ref Vector2 penPosition, Rope text, ubyte paletteIndex = 0) const; 74 75 /// Instances of Typeface have to be comparable in a memory-safe manner. 76 bool opEquals(const Object object) @safe const; 77 78 /// Get the default Fluid typeface. 79 static defaultTypeface() => FreetypeTypeface.defaultTypeface; 80 81 /// Default word splitter used by measure/draw. 82 alias defaultWordChunks = .breakWords; 83 84 /// Updated version of `std.string.lineSplitter` that includes trailing empty lines. 85 /// 86 /// `lineSplitterIndex` will produce a tuple with the index into the original text as the first element. 87 static lineSplitter(KeepTerminator keepTerm = No.keepTerminator, Range)(Range text) 88 if (isSomeChar!(ElementType!Range)) 89 do { 90 91 import std.utf : UTFException, byDchar; 92 import std.uni : lineSep, paraSep; 93 94 const hasEmptyLine = byDchar(text).endsWith('\r', '\n', '\v', '\f', "\r\n", lineSep, paraSep, '\u0085') != 0; 95 auto split = .lineSplitter!keepTerm(text); 96 97 // Include the empty line if present 98 return hasEmptyLine.choose( 99 split.chain(only(typeof(text).init)), 100 split, 101 ); 102 103 } 104 105 /// ditto 106 static lineSplitterIndex(Range)(Range text) { 107 108 import std.typecons : tuple; 109 110 auto initialValue = tuple(size_t.init, Range.init, size_t.init); 111 112 return Typeface.lineSplitter!(Yes.keepTerminator)(text) 113 114 // Insert the index, remove the terminator 115 // Position [2] is line end index 116 .cumulativeFold!((a, line) => tuple(a[2], line.chomp, a[2] + line.length))(initialValue) 117 118 // Remove item [2] 119 .map!(a => tuple(a[0], a[1])); 120 121 } 122 123 unittest { 124 125 import std.typecons : tuple; 126 127 auto myLine = "One\nTwo\r\nThree\vStuff\nï\nö"; 128 auto result = [ 129 tuple(0, "One"), 130 tuple(4, "Two"), 131 tuple(9, "Three"), 132 tuple(15, "Stuff"), 133 tuple(21, "ï"), 134 tuple(24, "ö"), 135 ]; 136 137 assert(lineSplitterIndex(myLine).equal(result)); 138 assert(lineSplitterIndex(Rope(myLine)).equal(result)); 139 140 } 141 142 unittest { 143 144 assert(lineSplitter(Rope("ą")).equal(lineSplitter("ą"))); 145 146 } 147 148 /// Measure space the given text would span. Uses dots as the unit. 149 /// 150 /// If `availableSpace` is specified, assumes text wrapping. Text wrapping is only performed on whitespace 151 /// characters. 152 /// 153 /// Params: 154 /// chunkWords = Algorithm to use to break words when wrapping text; separators must be preserved as separate 155 /// words. 156 /// availableSpace = Amount of available space the text can take up (dots), used to wrap text. 157 /// text = Text to measure. 158 /// wrap = Toggle text wrapping. Defaults to on, unless using the single argument overload. 159 /// 160 /// Returns: 161 /// Vector2 representing the text size, if `TextRuler` is not specified as an argument. 162 final Vector2 measure(alias chunkWords = defaultWordChunks, String) 163 (Vector2 availableSpace, String text, bool wrap = true) 164 do { 165 166 auto ruler = TextRuler(this, wrap ? availableSpace.x : float.nan); 167 168 measure!chunkWords(ruler, text, wrap); 169 170 return ruler.textSize; 171 172 } 173 174 /// ditto 175 final Vector2 measure(String)(String text) { 176 177 // No wrap, only explicit in-text line breaks 178 auto ruler = TextRuler(this); 179 180 measure(ruler, text, false); 181 182 return ruler.textSize; 183 184 } 185 186 /// ditto 187 static void measure(alias chunkWords = defaultWordChunks, String) 188 (ref TextRuler ruler, String text, bool wrap = true) 189 do { 190 191 // TODO don't fail on decoding errors 192 // TODO RTL layouts 193 // TODO vertical text 194 195 // Split on lines 196 foreach (line; lineSplitter(text)) { 197 198 ruler.startLine(); 199 200 // Split on words; do nothing in particular, just run the measurements 201 foreach (word, penPosition; eachWord!chunkWords(ruler, line, wrap)) { } 202 203 } 204 205 } 206 207 /// Helper function 208 static auto eachWord(alias chunkWords = defaultWordChunks, String) 209 (ref TextRuler ruler, String text, bool wrap = true) 210 do { 211 212 struct Helper { 213 214 alias ElementType = CommonType!(String, typeof(chunkWords(text).front)); 215 216 // I'd use `choose` but it's currently broken 217 int opApply(int delegate(ElementType, Vector2) @safe yield) { 218 219 // Text wrapping on 220 if (wrap) { 221 222 auto range = chunkWords(text); 223 224 // Empty line, yield an empty word 225 if (range.empty) { 226 227 const penPosition = ruler.addWord(String.init); 228 if (const ret = yield(String.init, penPosition)) return ret; 229 230 } 231 232 // Split each word 233 else foreach (word; chunkWords(text)) { 234 235 const penPosition = ruler.addWord(word); 236 if (const ret = yield(word, penPosition)) return ret; 237 238 } 239 240 return 0; 241 242 } 243 244 // Text wrapping off 245 else { 246 247 const penPosition = ruler.addWord(text); 248 return yield(text, penPosition); 249 250 } 251 252 } 253 254 } 255 256 return Helper(); 257 258 } 259 260 /// Draw text within the given rectangle in the image. 261 final void draw(alias chunkWords = defaultWordChunks, String) 262 (ref Image image, Rectangle rectangle, String text, ubyte paletteIndex, bool wrap = true) 263 const { 264 265 auto ruler = TextRuler(this, rectangle.w); 266 267 // TODO decoding errors 268 269 // Split on lines 270 foreach (line; this.lineSplitter(text)) { 271 272 ruler.startLine(); 273 274 // Split on words 275 foreach (word, penPosition; eachWord!chunkWords(ruler, line, wrap)) { 276 277 auto wordPenPosition = rectangle.start + penPosition; 278 279 drawLine(image, wordPenPosition, word, paletteIndex); 280 281 } 282 283 } 284 285 } 286 287 /// Helper function for typeface implementations, providing a "draw" function for tabs, adjusting the pen position 288 /// automatically. 289 protected final void drawTab(ref Vector2 penPosition) const { 290 291 penPosition.x += _tabWidth(penPosition.x); 292 293 } 294 295 private final float _tabWidth(float xoffset) const { 296 297 return indentWidth - (xoffset % indentWidth); 298 299 } 300 301 } 302 303 /// Break words on whitespace and punctuation. Splitter characters stick to the word that precedes them, e.g. 304 /// `foo!! bar.` is split as `["foo!! ", "bar."]`. 305 auto breakWords(Range)(Range range) { 306 307 import std.uni : isAlphaNum, isWhite; 308 import std.utf : decodeFront; 309 310 /// Pick the group the character belongs to. 311 static int pickGroup(dchar a) { 312 313 return a.isAlphaNum ? 0 314 : a.isWhite ? 1 315 : 2; 316 317 } 318 319 /// Splitter function that splits in any case two 320 static bool isSplit(dchar a, dchar b) { 321 322 return !a.isAlphaNum && !b.isWhite && pickGroup(a) != pickGroup(b); 323 324 } 325 326 struct BreakWords { 327 328 Range range; 329 Range front = Range.init; 330 331 bool empty() const => front.empty; 332 void popFront() { 333 334 dchar lastChar = 0; 335 auto originalRange = range.save; 336 337 while (!range.empty) { 338 339 if (lastChar && isSplit(lastChar, range.front)) break; 340 lastChar = range.decodeFront; 341 342 } 343 344 front = originalRange[0 .. $ - range.length]; 345 346 } 347 348 } 349 350 auto chunks = BreakWords(range); 351 chunks.popFront; 352 return chunks; 353 354 } 355 356 unittest { 357 358 const test = "hellö world! 123 hellö123*hello -- hello -- - &&abcde!a!!?!@!@#3"; 359 const result = [ 360 "hellö ", 361 "world! ", 362 "123 ", 363 "hellö123*", 364 "hello ", 365 "-- ", 366 "hello ", 367 "-- ", 368 "- ", 369 "&&", 370 "abcde!", 371 "a!!?!@!@#", 372 "3" 373 ]; 374 375 assert(breakWords(test).equal(result)); 376 assert(breakWords(Rope(test)).equal(result)); 377 378 const test2 = "Аа Бб Вв Гг Дд Ее Ëë Жж Зз Ии " 379 ~ "Йй Кк Лл Мм Нн Оо Пп Рр Сс Тт " 380 ~ "Уу Фф Хх Цц Чч Шш Щщ Ъъ Ыы Ьь " 381 ~ "Ээ Юю Яя "; 382 383 assert(breakWords(test2).equal(breakWords(Rope(test2)))); 384 385 } 386 387 /// Low level interface for measuring text. 388 struct TextRuler { 389 390 /// Typeface to use for the text. 391 Typeface typeface; 392 393 /// Maximum width for a single line. If `NaN`, no word breaking is performed. 394 float lineWidth; 395 396 /// Current pen position. 397 Vector2 penPosition; 398 399 /// Total size of the text. 400 Vector2 textSize; 401 402 /// Index of the word within the line. 403 size_t wordLineIndex; 404 405 this(Typeface typeface, float lineWidth = float.nan) { 406 407 this.typeface = typeface; 408 this.lineWidth = lineWidth; 409 this.penPosition = typeface.penPosition; 410 411 } 412 413 /// Get the caret as a 0 width rectangle. 414 Rectangle caret() const { 415 416 return caret(penPosition); 417 418 } 419 420 /// Get the caret as a 0 width rectangle for the given pen position. 421 Rectangle caret(Vector2 penPosition) const { 422 423 const start = penPosition - Vector2(0, typeface.penPosition.y); 424 425 return Rectangle( 426 start.tupleof, 427 0, typeface.lineHeight, 428 ); 429 430 } 431 432 /// Begin a new line. 433 void startLine() { 434 435 const lineHeight = typeface.lineHeight; 436 437 if (textSize != Vector2.init) { 438 439 // Move the pen to the next line 440 penPosition.x = typeface.penPosition.x; 441 penPosition.y += lineHeight; 442 443 } 444 445 // Allocate space for the line 446 textSize.y += lineHeight; 447 wordLineIndex = 0; 448 449 } 450 451 /// Add the given word to the text. The text must be a single line. 452 /// Returns: Pen position for the word. It might differ from the original penPosition, because the word may be 453 /// moved onto the next line. 454 Vector2 addWord(String)(String word) { 455 456 import std.utf : byDchar; 457 458 const maxWordWidth = lineWidth - penPosition.x; 459 460 float wordSpan = 0; 461 462 // Measure each glyph 463 foreach (glyph; byDchar(word)) { 464 465 // Tab aligns to set indent width 466 if (glyph == '\t') 467 wordSpan += typeface._tabWidth(penPosition.x + wordSpan); 468 469 // Other characters use their regular advance value 470 else 471 wordSpan += typeface.advance(glyph).x; 472 473 } 474 475 // Exceeded line width 476 // Automatically false if lineWidth is NaN 477 if (maxWordWidth < wordSpan && wordLineIndex != 0) { 478 479 // Start a new line 480 startLine(); 481 482 } 483 484 const wordPosition = penPosition; 485 486 // Increment word index 487 wordLineIndex++; 488 489 // Update pen position 490 penPosition.x += wordSpan; 491 492 // Allocate space 493 if (penPosition.x > textSize.x) { 494 495 textSize.x = penPosition.x; 496 497 // Limit space to not exceed maximum width (false if NaN) 498 if (textSize.x > lineWidth) { 499 500 textSize.x = lineWidth; 501 502 } 503 504 } 505 506 return wordPosition; 507 508 } 509 510 } 511 512 /// Represents a freetype2-powered typeface. 513 class FreetypeTypeface : Typeface { 514 515 public { 516 517 /// Underlying face. 518 FT_Face face; 519 520 /// Adjust line height. `1` uses the original line height, `2` doubles it. 521 float lineHeightFactor = 1; 522 523 } 524 525 protected { 526 527 /// Cache for character sizes. 528 Vector2[dchar] advanceCache; 529 530 } 531 532 private { 533 534 /// If true, this typeface has been loaded using this class, making the class responsible for freeing the font. 535 bool _isOwner; 536 537 /// Font size loaded (points). 538 int _size; 539 540 /// Current DPI set for the typeface. 541 int _dpiX, _dpiY; 542 543 int _indentWidth; 544 545 } 546 547 static FreetypeTypeface defaultTypeface; 548 549 static this() @trusted { 550 551 // Set the default typeface 552 FreetypeTypeface.defaultTypeface = new FreetypeTypeface(14); 553 554 } 555 556 /// Load the default typeface 557 this(int size) @trusted { 558 559 static typefaceFile = cast(ubyte[]) import("ruda-regular.ttf"); 560 const typefaceSize = cast(int) typefaceFile.length; 561 562 // Load the font 563 if (auto error = FT_New_Memory_Face(freetype, typefaceFile.ptr, typefaceSize, 0, &face)) { 564 565 assert(false, format!"Failed to load default Fluid typeface at size %s, error no. %s"(size, error)); 566 567 } 568 569 // Mark self as the owner 570 this._size = size; 571 this.isOwner = true; 572 this.lineHeightFactor = 1.16; 573 574 } 575 576 /// Use an existing freetype2 font. 577 this(FT_Face face, int size) { 578 579 this.face = face; 580 this._size = size; 581 582 } 583 584 /// Load a font from a file. 585 /// Params: 586 /// backend = I/O Fluid backend, used to adjust the scale of the font. 587 /// filename = Filename of the font file. 588 /// size = Size of the font to load (in points). 589 this(string filename, int size) @trusted { 590 591 this._isOwner = true; 592 this._size = size; 593 594 // TODO proper exceptions 595 if (auto error = FT_New_Face(freetype, filename.toStringz, 0, &this.face)) { 596 597 throw new Exception(format!"Failed to load `%s`, error no. %s"(filename, error)); 598 599 } 600 601 } 602 603 ~this() @trusted { 604 605 // Ignore if the resources used by the class have been borrowed 606 if (!_isOwner) return; 607 608 FT_Done_Face(face); 609 610 } 611 612 /// Instances of Typeface have to be comparable in a memory-safe manner. 613 override bool opEquals(const Object object) @safe const { 614 615 return this is object; 616 617 } 618 619 ref inout(int) indentWidth() inout => _indentWidth; 620 bool isOwner() const => _isOwner; 621 bool isOwner(bool value) @system => _isOwner = value; 622 623 long glyphCount() const { 624 625 return face.num_glyphs; 626 627 } 628 629 /// Get initial pen position. 630 Vector2 penPosition() const { 631 632 return Vector2(0, face.size.metrics.ascender) / 64; 633 634 } 635 636 /// Line height. 637 int lineHeight() const { 638 639 // +1 is an error margin 640 return cast(int) (face.size.metrics.height * lineHeightFactor / 64) + 1; 641 642 } 643 644 Vector2 dpi() const { 645 646 return Vector2(_dpiX, _dpiY); 647 648 } 649 650 Vector2 dpi(Vector2 dpi) @trusted { 651 652 const dpiX = cast(int) dpi.x; 653 const dpiY = cast(int) dpi.y; 654 655 // Ignore if there's no change 656 if (dpiX == _dpiX && dpiY == _dpiY) return dpi; 657 658 _dpiX = dpiX; 659 _dpiY = dpiY; 660 661 // Load size 662 if (auto error = FT_Set_Char_Size(face, 0, _size*64, dpiX, dpiY)) { 663 664 throw new Exception( 665 format!"Failed to load font at size %s at DPI %sx%s, error no. %s"(_size, dpiX, dpiY, error) 666 ); 667 668 } 669 670 // Clear the cache 671 advanceCache.clear(); 672 673 return dpi; 674 675 } 676 677 /// Get advance vector for the given glyph 678 Vector2 advance(dchar glyph) @trusted { 679 680 assert(_dpiX && _dpiY, "Font DPI hasn't been set"); 681 682 // Return the stored value if the result is cached 683 if (auto result = glyph in advanceCache) { 684 685 return *result; 686 687 } 688 689 // Load the glyph 690 if (auto error = FT_Load_Char(cast(FT_FaceRec*) face, glyph, FT_LOAD_DEFAULT)) { 691 692 return advanceCache[glyph] = Vector2(0, 0); 693 694 } 695 696 // Advance the cursor position 697 // TODO RTL layouts 698 return advanceCache[glyph] = Vector2(face.glyph.advance.tupleof) / 64; 699 700 } 701 702 /// Draw a line of text 703 void drawLine(ref Image target, ref Vector2 penPosition, const Rope text, ubyte paletteIndex) const @trusted { 704 705 assert(_dpiX && _dpiY, "Font DPI hasn't been set"); 706 707 foreach (glyph; text.byDchar) { 708 709 // Tab character 710 if (glyph == '\t') { 711 712 drawTab(penPosition); 713 continue; 714 715 } 716 717 // Load the glyph 718 if (auto error = FT_Load_Char(cast(FT_FaceRec*) face, glyph, FT_LOAD_RENDER)) { 719 720 continue; 721 722 } 723 724 const bitmap = face.glyph.bitmap; 725 726 assert(bitmap.pixel_mode == FT_PIXEL_MODE_GRAY); 727 728 // Draw it to the image 729 foreach (y; 0..bitmap.rows) { 730 731 foreach (x; 0..bitmap.width) { 732 733 // Each pixel is a single byte 734 const pixel = *cast(ubyte*) (bitmap.buffer + bitmap.pitch*y + x); 735 736 const targetX = cast(int) penPosition.x + face.glyph.bitmap_left + x; 737 const targetY = cast(int) penPosition.y - face.glyph.bitmap_top + y; 738 739 // Don't draw pixels out of bounds 740 if (targetX >= target.width || targetY >= target.height) continue; 741 742 // Choose the stronger color 743 const ubyte oldAlpha = target.get(targetX, targetY).a; 744 const ubyte newAlpha = ubyte.max * pixel / pixel.max; 745 746 if (newAlpha >= oldAlpha) 747 target.set(targetX, targetY, PalettedColor(newAlpha, paletteIndex)); 748 749 } 750 751 } 752 753 // Advance pen positon 754 penPosition += Vector2(face.glyph.advance.tupleof) / 64; 755 756 } 757 758 } 759 760 } 761 762 unittest { 763 764 auto image = generateColorImage(10, 10, color("#fff")); 765 auto tf = FreetypeTypeface.defaultTypeface; 766 tf.dpi = Vector2(96, 96); 767 tf.indentWidth = cast(int) (tf.advance(' ').x * 4); 768 769 Vector2 measure(string text) { 770 771 Vector2 penPosition; 772 tf.drawLine(image, penPosition, Rope(text), 0); 773 return penPosition; 774 775 } 776 777 // Draw 4 spaces to use as reference in the test 778 const indentReference = measure(" "); 779 780 assert(indentReference.x > 0); 781 assert(indentReference.x == tf.advance(' ').x * 4); 782 assert(indentReference.x == tf.indentWidth); 783 784 assert(measure("\t") == indentReference); 785 assert(measure("a\t") == indentReference); 786 787 const doubleAIndent = measure("aa").x > indentReference.x 788 ? 2 789 : 1; 790 const tripleAIndent = measure("aaa").x > doubleAIndent * indentReference.x 791 ? doubleAIndent + 1 792 : doubleAIndent; 793 794 assert(measure("aa\t") == indentReference * doubleAIndent); 795 assert(measure("aaa\t") == indentReference * tripleAIndent); 796 assert(measure("\t\t") == indentReference * 2); 797 assert(measure("a\ta\t") == indentReference * 2); 798 assert(measure("aa\taa\t") == 2 * indentReference * doubleAIndent); 799 800 } 801 802 version (BindFT_Dynamic) 803 shared static this() @system { 804 805 // Ignore if freetype was already loaded 806 if (isFreeTypeLoaded) return; 807 808 // Load freetype 809 FTSupport ret = loadFreeType(); 810 811 // Check version 812 if (ret != ftSupport) { 813 814 if (ret == FTSupport.noLibrary) { 815 816 assert(false, "freetype2 failed to load"); 817 818 } 819 else if (FTSupport.badLibrary) { 820 821 assert(false, format!"found freetype2 is of incompatible version %s (needed %s)"(ret, ftSupport)); 822 823 } 824 825 } 826 827 } 828 829 /// Get the thread-local freetype reference. 830 FT_Library freetype() @trusted { 831 832 static FT_Library library; 833 834 // Load the library 835 if (library != library.init) return library; 836 837 if (auto error = FT_Init_FreeType(&library)) { 838 839 assert(false, format!"Failed to load freetype2: %s"(error)); 840 841 } 842 843 return library; 844 845 }