examples: annotate examples
[nit.git] / lib / gamnit / bmfont.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Parse Angel Code BMFont format and draw text
16 #
17 # The BMFont format supports packed textures, varying advance per character and
18 # even kernings. It can be generated with a number of tools, inluding:
19 # * BMFont, free software Windows app, http://www.angelcode.com/products/bmfont/
20 # * Littera, a web app, http://kvazars.com/littera/
21 #
22 # Format reference: http://www.angelcode.com/products/bmfont/doc/file_format.html
23 module bmfont
24
25 private import dom
26
27 intrude import font
28
29 # BMFont description, parsed with `Text::parse_bmfont` or loaded as a `BMFontAsset`
30 #
31 # This class flattens all the `info` and `common` data.
32 class BMFont
33
34 # ---
35 # info part
36 #
37 # How the font was generated.
38
39 # Name of the source true type font
40 var face: Text
41
42 # Size of the source true type font
43 var size: Float
44
45 # Is the font bold?
46 var bold: Bool
47
48 # Is the font italic?
49 var italic: Bool
50
51 # Does the font uses the Unicode charset?
52 var unicode: Bool
53
54 # Padding for each character
55 #
56 # In the format `up,right,down,left`
57 var padding: String
58
59 # Spacing for each character
60 #
61 # In the format `horizontal,vertical`.
62 var spacing: String
63
64 # ---
65 # common part
66 #
67 # Information common to all characters
68
69 # Distance in pixels between each line of text
70 var line_height: Float
71
72 # Pixels from the top of the line to the base of the characters
73 var base: Float
74
75 # Width of the texture
76 var scale_w: Float
77
78 # Height of the texture
79 var scale_h: Float
80
81 # Textures
82 var pages = new Map[String, TextureAsset]
83
84 # Characters in the font
85 var chars = new Map[Char, BMFontChar]
86
87 # Distance between certain characters
88 var kernings = new HashMap2[Char, Char, Float]
89
90 # Additional distance between `prev_char` and `char`
91 fun kerning(prev_char: nullable Char, char: Char): Float
92 do
93 if prev_char == null then return 0.0
94 return kernings[prev_char, char] or else 0.0
95 end
96
97 redef fun to_s do return "<{class_name} {face} at {size} pt, "+
98 "{pages.length} pages, {chars.length} chars>"
99
100 # TODO
101 #
102 # # From info
103 # charset
104 # stretchH
105 # smooth
106 # aa
107 # outline
108 #
109 # # From common
110 # packed
111 # alphaChnl
112 # redChnl
113 # greenChnl
114 # blueChnl
115 end
116
117 # Description of a character in a `BMFont`
118 class BMFontChar
119
120 # Subtexture left coordinate
121 var x: Float
122
123 # Subtexture top coordinate
124 var y: Float
125
126 # Subtexture width
127 var width: Float
128
129 # Subtexture height
130 var height: Float
131
132 # Drawing offset on X
133 var xoffset: Float
134
135 # Drawing offset on Y
136 var yoffset: Float
137
138 # Cursor advance after drawing this character
139 var xadvance: Float
140
141 # Full texture contaning this character and others
142 var page: RootTexture
143
144 # TODO Channel where the image is found
145 #var chnl: Int
146
147 # Subtexture with this character image only
148 var subtexture: Texture = page.subtexture(x, y, width, height) is lazy, writable
149
150 # Scale to apply to this char only
151 var scale = 1.0 is writable
152 end
153
154 redef class Text
155
156 # Parse `self` as an XML BMFont description file
157 #
158 # Reports only basic XML format errors, other errors may be ignored or
159 # cause a crash.
160 #
161 # ~~~
162 # var desc = """
163 # <font>
164 # <info face="arial" size="72" bold="0" italic="0" charset=""
165 # unicode="1" stretchH="100" smooth="1" aa="1" padding="2,2,2,2"
166 # spacing="0,0" outline="0"/>
167 # <common lineHeight="80" base="65" scaleW="4030" scaleH="231"
168 # pages="1" packed="0"/>
169 # <pages>
170 # <page id="0" file="arial.png"/>
171 # </pages>
172 # <chars count="3">
173 # <char id="65" x="2519" y="10" width="55" height="59" xoffset="0"
174 # yoffset="13" xadvance="48" page="0" chnl="15"/>
175 # <char id="66" x="2600" y="10" width="46" height="58" xoffset="5"
176 # yoffset="13" xadvance="48" page="0" chnl="15"/>
177 # <char id="67" x="2673" y="9" width="52" height="60" xoffset="4"
178 # yoffset="12" xadvance="52" page="0" chnl="15"/>
179 # </chars>
180 # <kernings count="1">
181 # <kerning first="65" second="67" amount="-1"/>
182 # </kernings>
183 # </font>
184 # """
185 #
186 # var fnt = desc.parse_bmfont("dir_in_assets").value
187 # assert fnt.to_s == "<BMFont arial at 72.0 pt, 1 pages, 3 chars>"
188 # assert fnt.line_height == 80.0
189 # assert fnt.kernings['A', 'C'] == -1.0
190 # assert fnt.chars['A'].page.as(TextureAsset).path == "dir_in_assets/arial.png"
191 # ~~~
192 fun parse_bmfont(dir: String): MaybeError[BMFont, Error]
193 do
194 # Parse XML
195 var xml = to_xml
196 if xml isa XMLError then
197 var msg = "XML Parse Error: {xml.message}:{xml.location or else 0}"
198 return new MaybeError[BMFont, Error](maybe_error=new Error(msg))
199 end
200
201 # Basic sanity check
202 var roots = xml["font"]
203 if roots.is_empty then
204 var msg = "Error: the XML document doesn't declare the expected `font` root"
205 return new MaybeError[BMFont, Error](maybe_error=new Error(msg))
206 end
207
208 # Expect the rest of the document to be well formatted
209 var root = roots.first
210
211 var info = root["info"].first
212 assert info isa XMLAttrTag
213 var info_map = info.attributes_to_map
214
215 var common = root["common"].first
216 assert common isa XMLAttrTag
217 var common_map = common.attributes_to_map
218
219 var fnt = new BMFont(
220 info_map["face"],
221 info_map["size"].to_f,
222 info_map["bold"] == "1",
223 info_map["italic"] == "1",
224 info_map["unicode"] == "1",
225 info_map["padding"],
226 info_map["spacing"],
227 common_map["lineHeight"].to_f,
228 common_map["base"].to_f,
229 common_map["scaleW"].to_f,
230 common_map["scaleH"].to_f
231 )
232
233 # Pages / pixel data files
234 var xml_pages = root["pages"].first
235 for page in xml_pages["page"] do
236 if not page isa XMLAttrTag then continue
237
238 var attributes = page.attributes_to_map
239 var file = dir / attributes["file"]
240 fnt.pages[attributes["id"]] = new TextureAsset(file)
241 end
242
243 # Char description
244 for item in root["chars"].first["char"] do
245 if not item isa XMLAttrTag then continue
246
247 var attributes = item.attributes_to_map
248 var id = attributes["id"].to_i.code_point
249
250 var c = new BMFontChar(
251 attributes["x"].to_f, attributes["y"].to_f,
252 attributes["width"].to_f, attributes["height"].to_f,
253 attributes["xoffset"].to_f, attributes["yoffset"].to_f,
254 attributes["xadvance"].to_f,
255 fnt.pages[attributes["page"]])
256
257 fnt.chars[id] = c
258 end
259
260 # Kerning between two characters
261 var kernings = root["kernings"]
262 if kernings.not_empty then
263 for item in kernings.first["kerning"] do
264 if not item isa XMLAttrTag then continue
265
266 var attributes = item.attributes_to_map
267 var first = attributes["first"].to_i.code_point
268 var second = attributes["second"].to_i.code_point
269 var amount = attributes["amount"].to_f
270 fnt.kernings[first, second] = amount
271 end
272 end
273
274 return new MaybeError[BMFont, Error](fnt)
275 end
276 end
277
278 # BMFont from the assets folder
279 #
280 # ~~~
281 # redef class App
282 # var font = new BMFontAsset("arial.fnt")
283 # var pos: Point3d[Float] = ui_camera.top_left.offset(128.0, -128.0, 0.0)
284 # var ui_text = new TextSprites(font, pos)
285 #
286 # redef fun create_scene
287 # do
288 # super
289 #
290 # font.load
291 # assert font.error == null
292 #
293 # ui_text.text = "Hello world!"
294 # end
295 # end
296 # ~~~
297 class BMFontAsset
298 super Asset
299 super Font
300
301 # Font description
302 #
303 # Require: `error == null`
304 fun desc: BMFont
305 do
306 # Cached results
307 var cache = desc_cache
308 if cache != null then return cache
309 var error = error
310 assert error == null else print_error error
311
312 # Load on first access
313 load
314 error = self.error
315 assert error == null else print_error error
316
317 return desc_cache.as(not null)
318 end
319
320 private var desc_cache: nullable BMFont = null
321
322 # Error at loading
323 var error: nullable Error = null
324
325 # XML description in the assets folder
326 private var text_asset = new TextAsset(path) is lateinit
327
328 # Load font description and textures from the assets folder
329 #
330 # Sets `error` if an error occurred, otherwise
331 # the font description can be accessed via `desc`.
332 fun load
333 do
334 var text_asset = text_asset
335 text_asset.load
336 var error = text_asset.error
337 if error != null then
338 self.error = error
339 return
340 end
341
342 var desc = text_asset.to_s
343 var fnt_or_error = desc.parse_bmfont(path.dirname)
344 if fnt_or_error.is_error then
345 self.error = fnt_or_error.error
346 return
347 end
348
349 var fnt = fnt_or_error.value
350 self.desc_cache = fnt
351
352 # Load textures too
353 for page_name, texture in fnt.pages do
354 texture.load
355
356 # Move up any texture loading error.
357 # This algo keeps only the latest error,
358 # but this isn't a problem on single page fonts.
359 error = texture.error
360 if error != null then self.error = error
361 end
362 end
363
364 redef fun write_into(text_sprites, text)
365 do
366 var dx = 0.0
367 var dy = 0.0
368 var text_width = 0.0
369 var line_sprites = new Array[Sprite]
370 var height = 0.0
371
372 # Has the current line height been added to `height`?
373 var line_height_counted = false
374
375 # TextSprite customization
376 var max_width = text_sprites.max_width
377 var max_height = text_sprites.max_height
378 var scale = text_sprites.scale
379
380 # Font customization
381 var line_height = desc.line_height * scale
382 var partial_line_skip = line_height * partial_line_mod.to_f
383
384 # Links data
385 text_sprites.links.clear
386 var in_link = false
387 var link_sprites = new Array[Sprite]
388 var link_name = ""
389
390 # Loop over all characters
391 var prev_char = null
392 var i = -1
393 while i < text.length - 1 do
394 i += 1
395 var c = text[i]
396
397 # Special characters
398 var word_break = false
399 if c == '\n' then
400 justify(line_sprites, text_sprites.align, dx)
401 dy -= line_height
402 if max_height != null and max_height < -dy + line_height then break
403 dx = 0.0
404 if not line_height_counted then
405 # Force to account for empty lines
406 height += line_height
407 end
408 line_height_counted = false
409 prev_char = null
410 continue
411 else if c == pld then
412 dy -= partial_line_skip
413 height += partial_line_skip
414 word_break = true
415 continue
416 else if c == plu then
417 dy += partial_line_skip
418 height -= partial_line_skip # We could keep two heights and return the max
419 word_break = true
420 continue
421 else if c.is_whitespace then
422 var space_advance = if desc.chars.keys.has(' ') then
423 desc.chars[' '].xadvance
424 else if desc.chars.keys.has('f') then
425 desc.chars['f'].xadvance
426 else 16.0
427 dx += space_advance * scale
428 word_break = true
429 else if c == '[' then
430 # Open link?
431 if i + 1 < text.length and text[i+1] == '[' then
432 # Escape if duplicated
433 i += 1
434 else
435 in_link = true
436 continue
437 end
438 else if c == ']' then
439 # Close link?
440 if i + 1 < text.length and text[i+1] == ']' then
441 # Escape if duplicated
442 i += 1
443 else
444 # If there's a () use it as link_name
445 var j = i + 1
446 if j < text.length and text[j] == '(' then
447 var new_name
448 new_name = ""
449 loop
450 j += 1
451 if j > text.length then
452 # No closing ), abort
453 new_name = null
454 break
455 end
456
457 var l = text[j]
458 if l == ')' then break
459 new_name += l.to_s
460 end
461 if new_name != null then
462 link_name = new_name
463 i = j
464 end
465 end
466
467 # Register the link for the clients
468 text_sprites.links[link_name] = link_sprites
469
470 # Prepare next link
471 in_link = false
472 link_sprites = new Array[Sprite]
473 link_name = ""
474 continue
475 end
476 end
477
478 if in_link then link_name += c.to_s
479
480 # End of a word?
481 if word_break then
482 # If we care about line width, check for overflow
483 if max_width != null then
484 # Calculate the length of the next word
485 var prev_w = null
486 var word_len = 0.0
487 for wi in [i+1..text.length[ do
488 var w = text[wi]
489
490 if w == '\n' or w == pld or w == plu or w.is_whitespace or (in_link and w == ']') then break
491
492 if not desc.chars.keys.has(w) then
493 var rc = replacement_char
494 if rc == null then continue
495 w = rc
496 end
497
498 word_len += advance(prev_w, w) * scale
499 prev_w = w
500 end
501
502 # Would the line be too long?
503 if dx + word_len > max_width then
504 justify(line_sprites, text_sprites.align, dx)
505 dy -= line_height
506 if max_height != null and max_height < -dy + line_height then break
507 dx = 0.0
508 line_height_counted = false
509
510 if not text_sprites.wrap then
511 # Cut short, skip everything until the next new line
512 while c != '\n' and i < text.length - 1 do
513 i += 1
514 c = text[i]
515 end
516 end
517 end
518 end
519
520 prev_char = null
521 continue
522 end
523
524 # Replace or skip unknown characters
525 if not desc.chars.keys.has(c) then
526 var rc = replacement_char
527 if rc == null then continue
528 c = rc
529 end
530
531 var char_info = desc.chars[c]
532 var advance = char_info.xadvance
533 var kerning = desc.kerning(prev_char, c)
534
535 var x = dx + (char_info.width/2.0 + char_info.xoffset + kerning) * scale
536 var y = dy - (char_info.height/2.0 + char_info.yoffset) * scale
537 var pos = text_sprites.anchor.offset(x, y, 0.0)
538 var s = new Sprite(char_info.subtexture, pos)
539 s.scale = scale * char_info.scale
540 text_sprites.sprites.add s
541 line_sprites.add s
542 if in_link then link_sprites.add s
543
544 dx += (advance + kerning) * scale
545 prev_char = c
546
547 text_width = text_width.max(dx)
548
549 if not line_height_counted then
550 # Increase `height` only once per line iff there's a caracter
551 line_height_counted = true
552 height += line_height
553 end
554 end
555
556 justify(line_sprites, text_sprites.align, dx)
557
558 # valign
559 if text_sprites.valign != 0.0 then
560 var d = (-dy+line_height) * text_sprites.valign
561 for s in text_sprites.sprites do s.center.y += d
562 end
563
564 text_sprites.width = text_width.max(dx)
565 text_sprites.height = height
566 end
567
568 # Character replacing other characters missing from the font
569 private var replacement_char: nullable Char is lazy do
570 for c in "�?".chars do
571 if desc.chars.keys.has(c) then return c
572 end
573 return null
574 end
575
576 private fun advance(prev_char: nullable Char, char: Char): Float
577 do
578 var char_info = desc.chars[char]
579 var kerning = desc.kerning(prev_char, char)
580 return char_info.xadvance + kerning
581 end
582
583 private fun justify(line_sprites: Array[Sprite], align: Float, line_width: Float)
584 do
585 var dx = -line_width*align
586 for s in line_sprites do s.center.x += dx
587 line_sprites.clear
588 end
589 end