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