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