-------------------------------------------------------------------------------- -- typewriter.lua - per-character typewriter for Defold GUI text nodes -------------------------------------------------------------------------------- -- -- Usage (in a .gui_script): -- -- local typewriter = require "typewriter" -- -- function init(self) -- self.box = typewriter.new("text", { speed = 40, fadein_y = 6 }, function(box, text) -- print("finished:", text) -- listener: every finished text -- end) -- self.box:type("One word is red.", nil, function(box, text) -- print("this text is done") -- callback: only this text -- end) -- end -- -- self.box:finish() -- skip the waiting, fade everything in now -- -- -- Tags that change how the characters appear (see below): -- self.box:type("A whole chunk, one letter, " -- .. "a long text that wraps, line by line, " -- .. "a long text that wraps, all lines together.") -- -- -- A dialogue box: pauses the typing, continue() is the "accept" button. -- self.box:type("Let me think. I got it! Shall we go?") -- -- on the accept button: -- if self.box:continue() then -- true: the text is fading out -- self.box:type("Next text.") -- (or hide the textbox) -- end -- -- The node passed to new() is a TEXT node. It is used as -- * the container of the nodes created for each text, -- * the template (font, color, outline, tracking, leading ...) the character -- nodes are cloned from, and -- * the definition of the text area (its size and pivot), unless options.width -- is given. -- Its own text is cleared. Keep its alpha at 1, because child nodes inherit it. -- -- Node structure: every call of type() creates one empty "run" node as a child of -- the container, and all character nodes of that text are children of the run -- node (or of a word node below it, see Textbox:animate()). Fading out an old text -- therefore needs only two gui.animate calls (alpha and position of the run node), -- no matter how many characters it has, and the run node is deleted together with -- all its characters afterwards. -- The delays between characters are driven by one timer per text, so a character -- only starts its animations when its time has come. This keeps the number of -- simultaneously running animations low (Defold allows 1024 at once). -- -- Tags: anything that looks like , , or -- is a tag. Tags are never drawn and take no time. -- * Only these tags are passed on to the text renderer. They are wrapped around -- the characters they enclose, e.g. "x" -- is the text of the node for "x". Applying them is left to your text renderer. -- color, size, gradient, ul, strike, outline, shadow, shake, wave, sprite, link -- * , , , , , and are handled by -- this module only (see below). They are never added to the text of a node. -- * Every other tag is removed and has no effect. -- Tag names are not case sensitive. -- -- Self-closing tags: a tag from the list above that ends with "/>" is not an -- opening tag that waits for a closing tag, it is ONE character in itself: -- "A logo." -- * Its node is drawn with exactly the text of the tag, and the tag text is also -- what is measured to get its width, like the text of any other character. -- Tags that are open around it (e.g. ) are wrapped around it like -- around every character; without any, the node text is just the tag. -- * shows an image: the -- text renderer only reserves the space (its syntax, the size units em, px and -- % etc. are handled by Defold, see the manual "Rich text markup"). The -- module then asks gui.get_layout_objects() for the position and size of the -- sprite and puts a box node there, a child of the node of the character, so -- it fades, moves and scales with it. `src` is the name of a texture / atlas -- of the GUI scene (gui.set_texture) and `animation` the flipbook animation -- to play (gui.play_flipbook). Without `animation` only the texture is set, -- without `src` there is no box node at all, the space stays empty. -- If `src` ends with .png, .jpg or .jpeg (any capitalization) it is an image -- file instead of an atlas, and `animation` is ignored. A path that starts -- with "/" is a custom resource of the bundle (sys.load_resource), any other -- path is a file of the file system (io.open). The image is loaded once per -- textbox and src, as a GUI texture; Textbox:release_images() deletes them. -- An image that cannot be loaded prints a warning and leaves the space empty. -- A sprite works inside , , , and in -- right-to-left text, too. -- * It counts as one character for the speed. -- * It is a word of its own: a line may break before and after it, even when -- there is no space next to it (the usual no-start/no-end rules still apply to -- the neighbours, e.g. a line never starts with "." or ends with "("). -- Inside a there is no break, as for every character. Inside a -- or it becomes part of the chunk node. In right-to-left mode it is a word node of -- its own (unless it is inside a ). -- * Self-closing tags that are not in the list above (and , , -- , , ) are removed like any other unknown tag. -- -- Underlined / struck-through spaces: a space inside
    or is underlined -- or struck through too, as long as it is not at the end of its line (a space at -- an automatic or manual line break, or at the end of the text, is not). Since a -- text renderer does not draw the line for a node that only contains a space, the -- node of such a space is "
    " (same for ). -- In justified lines the node is stretched to the width of the gap, so the -- line has no hole. Not done in right-to-left mode, where spaces have no -- node (inside a the renderer handles the spaces itself). -- -- ... -- Counts its content as ONE word: a line is never broken inside it, not even at -- a space (or hyphen, dash, CJK break) inside the tag. The characters are still -- typed one by one. Spaces inside a word are not stretched by "justified". -- If the word is wider than the text area it overflows, like any long word. -- Line breaks are still possible before and after the tag where they would be -- possible without it, e.g. "a b c d" can break after "a " and -- before " d". -- -- ... -- Counts its content as ONE character when it appears: everything inside is -- drawn as a single text node that fades in at once. The character after the -- chunk still waits as long as if the tag were not there, e.g. a chunk with five -- letters makes the next character appear after five times the time between -- two normal characters. A line is never broken inside a chunk (it is one node), -- so a chunk is also unbreakable like a . The character before and after -- the chunk decide about line breaks like they would do without the tag. -- Tags inside a chunk stay inside its node. A line break inside a chunk (a -- newline character) ends the chunk, the rest becomes a chunk of its own. -- Nested chunks: the outermost one counts. -- -- ... -- Like (one node that appears at once, never broken, tags around it are -- applied once to the whole node), but its content counts as ONE character for -- the timing: the character after it starts after the time between two normal -- characters, however many characters are inside. So abc makes -- everything after it appear sooner than abc would (or no tag). -- A line break inside (a newline character) ends it, the rest is a letter of its -- own. and inside each other: the outermost one counts. -- -- ... -- A that may be broken into lines. The text is laid out and wrapped as if -- the tag were not there, then every line of the block becomes one node: the -- tags around it are applied once to the whole line, and the line fades in at -- once at the moment its first character is due. The lines therefore appear one -- after the other. What follows the block waits for every character in it, as -- with a , so a block, a chunk, a , or no tag at all end at about -- the same time. Without a line break inside, a block behaves exactly like a -- . -- "Short intro. A long sentence that needs three lines. Bye." -- Details: -- * Where lines may break does not change. Use inside to forbid breaks. -- * Spaces at the start and end of a line (e.g. at an automatic line break) are -- nodes of their own, outside of the line's node. "justified" does not -- stretch the gaps -- inside a line of a block (one node), only the ones around it. -- * A plain inside a line cannot interrupt it: it is held in front of the -- item that follows the line. A wait that is directly in front of the first -- character of a line pauses before that line, as usual. Timed waits work -- as everywhere. -- * A or inside a block is a node of its own and cuts the -- lines around it in two. For nested blocks (, and the -- automatic ones, see "disable_auto_blocking") the outermost one counts, a -- block inside a chunk does nothing. -- * Right-to-left mode: words are the nodes there and they cannot be put -- together, so the words of one line of a block fade in at the same moment -- instead (the time of the first of them). -- * Some tags make a block of their content automatically, see the option -- "disable_auto_blocking". -- -- ... -- Like (every line is one node, text is wrapped as usual), but all lines -- appear at the SAME time: at the moment its first character is due. The -- character after it still starts after the time that all characters of the -- tag would need, like after a or . Newline characters inside a -- sync do not end it, the lines after them appear together with the rest. Without -- a line break inside, sync, block and chunk behave the same. -- A plain anywhere inside a sync is held in front of the item after it -- (the group cannot be interrupted), a timed one is added in front of that item. -- A or inside also appears at that moment. In right-to-left -- mode all words of the tag fade in at the same moment. -- -- Summary of what the tags do (all are typed at the speed of their characters): -- -- tag line break inside nodes next character starts after -- (none) possible one per char all characters -- no one per char all characters -- no one all characters -- no one ONE character -- yes one per line all characters (lines one by one) -- yes one per line all characters (lines together) -- -- and -- Pauses the typing at the place of the tag. It takes no closing tag, and it has no -- effect when no character follows it (e.g. at the end of the text). -- "Let me think. I got it!" -- With a value, the character after the tag (here the space) appears that many -- seconds later than it would without the tag, and everything after it moves -- along. A value of 0 (or less) does nothing; a value that is not a number -- makes the tag useless, it is ignored. -- "Do you accept? Great!" (also written ) -- Without a value, the typing stops there until Textbox:continue() is called. -- Everything before the tag is shown by then, the rest is not drawn yet. -- A timed wait right before or after a plain one (inside the same gap between -- two characters) has no effect, the plain one counts. -- Waits do not need a speed: with speed <= 0 the text appears at once up to the -- next plain . Textbox:finish() ignores all waits and shows the whole text, -- Textbox:continue() only goes as far as the next plain . -- Inside a or (or an Arabic word) a wait cannot interrupt the -- node, which appears as a whole: the wait is held in front of the character that -- follows the chunk / word. The same holds for a line of a and for all of -- a , see there. The same holds for the lines of a tag that makes an automatic -- block (see disable_auto_blocking). -- -- Size tags: , ... are applied by the text renderer like any -- other tag, but they are taken into account for the layout: -- * Horizontal: the advance of every character (and every word in right-to-left -- mode) is measured with its tags, so the width follows the size. -- * Vertical: all characters of a line sit on a common baseline, like in a text -- editor. The height of a line is that of its largest character (its ascent -- above the baseline plus the descent below it, both scaled by the size), and -- the next line starts below it, so lines never overlap. The leading option of -- the node is applied to this line height, so with text without size tags the -- result is the same as before. -- * In right-to-left mode a word is one node, so it is measured as a whole and -- counts with the largest size of its characters. Different sizes inside one -- word are left to the renderer. -- resource.get_text_metrics() scales width and height with a size tag, but not -- max_ascent and max_descent. The size factor is therefore taken from the height -- (height with tags / height without), which means that whatever the renderer -- makes of the tag (percent, plain number, nested tags) is respected without -- parsing the value here. The characters themselves are still sized only by the -- tag, they are just positioned differently. -- -- Scale: the option "scale" (see below) scales every character node with -- gui.set_scale(), independent of the size tags. A inside a text with -- scale = 1.5 draws the characters 3 times as large: the node is scaled by 1.5 and -- the renderer applies the size tag to its text. The layout uses the full size: -- * Horizontal: the advance of every character (and word) is what -- resource.get_text_metrics() returns for its tags, times scale x. -- * Vertical: the size factor of the tags is multiplied by scale y, which is then -- used for the baselines, ascents, descents and line heights exactly like a -- size tag. -- get_text_metrics() knows nothing about the scale of the node, only about the -- tags, so the scale is always multiplied on top of its results. -- -- Zero width space (U+200B): -- -- A place where a line may break, without a space. It has no width, is not drawn -- and takes no time. For texts without spaces between the words, such as Thai. -- In Lua source, write it as the UTF-8 bytes "\226\128\139" (the escape "\u{200B}" -- does not exist in every Lua version Defold runs on). Texts from files or -- localization tables can contain the character itself. -- It only adds break places: where text breaks anyway (Thai between syllables, CJK -- between characters) it does not forbid the others. Inside a or -- there is no break. -- -- Fonts: -- -- typewriter.new("text", { -- fonts = { -- latin = "noto_sans", -- arabic = "noto_arabic", -- chinese = "noto_chinese_simplified", -- serif = "noto_serif", -- any other name: only for tags -- }, -- }) -- typewriter.new("text", { font = "noto_serif" }) -- same as fonts = { latin = "noto_serif" } -- -- self.box:type("This word is using serifs.") -- self.box:type("私は iPhone 15 が好きです。") -- Japanese font, if there is one -- -- The fonts have to be part of the GUI scene already; the values are font names as -- gui.set_font() takes them. This module only switches the font of the nodes it -- creates, a font is never written into the text of a node. As a node can only have -- one font, a text is cut into nodes wherever its font changes. That happens -- automatically, also where the text could not be cut otherwise (see below). -- Fonts given to type() are added to the ones of the textbox, and replace the -- entries with the same name. -- -- Which font a character gets: -- 1. ...
    , the manual way. `name` is a key of the option "fonts" -- (any key, not only the language names below). The tag is never drawn and takes -- no time. Tags inside each other: the innermost one counts. A tag without a -- name, or with a name that is not in "fonts" (a warning is printed once), is -- skipped. The characters inside the tag are not looked at in 2. -- 2. Otherwise the font of the language of the text. The language names are: -- latin Latin, Cyrillic, Greek, Vietnamese (and every script -- that has no name here) -- arabic Arabic, Persian, Urdu -- japanese Hiragana, Katakana -- chinese Han characters, simplified -- chinese_traditional Han characters, traditional -- korean Hangul -- thai -- devanagari Hindi, Marathi, Nepali -- A language without a font uses the font of "latin", and "latin" without a -- font uses the font of the text node that was given to new(). The four CJK -- languages are the exception: they stand in for each other (chinese and -- chinese_traditional first, then japanese and korean), since a CJK font is the -- only one that has their glyphs. -- -- How the language is found. Every line of the text (up to a line break) is -- looked at on its own: -- * Han characters are Japanese if the line has kana, Korean if it has Hangul, -- else Chinese. Chinese is simplified, unless a character that exists only in -- traditional (e.g. 國) appears anywhere in the text: then ALL Chinese text of it -- is traditional. If characters of both kinds appear, every one of them keeps -- its own kind and the ones that are the same in both follow the one before. -- (The characters that tell the two apart are the lists SIMPLIFIED_ONLY and -- TRADITIONAL_ONLY below. They hold the common ones, not every one that -- exists. Use where it matters.) -- * Latin letters (A-Z, with the accents of Latin-1 and Latin Extended-A) and the -- digits 0-9 have no language of their own. They use the font of the language -- that dominates the line, so an English word in a Japanese text and the digits -- in an Arabic text use the Japanese / Arabic font, and fonts are mixed only -- where they have to be. Dominating means: most characters of one non-Latin -- script (Han counts as its language) against the number of Latin words. A tie -- goes to latin. So in an English sentence with one Chinese word the sentence -- stays latin, and only the Chinese word gets the Chinese font. -- * Other scripts (Arabic in a Japanese text, Chinese in an Arabic text ...) always -- get their own font. -- * Spaces, punctuation and symbols use the font of the character before them -- (at the start of a line: the font of the dominating language). CJK punctuation -- and the fullwidth forms use the font of the nearest CJK character instead. -- -- Where a node has to be cut because of a font: -- * / : the pieces are separate nodes that appear at the same moment. -- * / : a line of the block is cut into one node per font. The nodes -- of one line still appear at the same moment. -- * Right-to-left mode: a word is cut where its font changes (the letters of the -- two pieces cannot join). The pieces appear one after the other. -- -- Options (defaults in DEFAULTS below): -- speed characters per second (<= 0 means everything at once) -- In right-to-left mode whole words fade in instead of single -- characters. The next word starts after the time that the -- characters of the former word (and the spaces behind it) -- would have needed, so the same text takes about the same -- time in both modes. The same rule is used for -- (and for / in this mode, see there). A -- counts as one character. -- fadein_duration seconds a character takes from alpha 0 to 1 -- fadein_x/_y pixels a character travels while fading in -- (it starts at target - fadein_x/y and ends at the target) -- fadeout_duration seconds the old characters take from alpha 1 to 0 -- fadeout_x/_y pixels an old character travels while fading out -- fade_cross 0..1, how much fading out the old text and typing the new -- one overlap. 1 = both start at the same time, 0 = the new -- text starts when the old one is completely faded out, -- values in between start the new text when that fraction -- of the fade out is still to go (0.5 = when it is half -- done). Only the fade out duration of the old text counts, -- not the time it needed to be typed. Use 0 if you run -- into "Out of animation resources" with long texts. -- width width of the text area; nil = width of the GUI node -- tracking number, optional -- Extra space between the characters, like gui.set_tracking(). -- nil = the tracking of the GUI node. It is used for measuring -- (resource.get_text_metrics) and set on every character node. -- leading number, optional -- Line height factor, like gui.set_leading(). nil = the leading -- of the GUI node. Used for the distance between the lines and -- set on every character node. -- scale number | vector (default 1) -- Scales every character node (every word node in right-to-left -- mode) with gui.set_scale(). A number scales x and y by the -- same factor, a vector (vmath.vector3 / vector4, or a table -- with x and y fields) gives the factors for x and y; z is -- ignored. Must be greater than 0. -- The width of the text area stays the same, so scaled up text -- breaks into more lines, and the horizontal alignment still -- refers to the full text area. Nodes are scaled around their -- top left corner (top centre in right-to-left mode), and the -- layout takes care of the new advances and line heights: the -- characters of a line sit on a common baseline, like with size -- tags. The scale is independent of the size tags and adds to -- them (scale 1.5 with = 3 times as large). -- fadein_x/_y and fadeout_x/_y are pixels and not scaled. -- scale_x, scale_y number, optional -- Replace the x or y factor of "scale", e.g. scale = 2, -- scale_y = 1 stretches the text horizontally only. -- alignment "left" | "right" | "center" | "justified" -- Horizontal alignment of the lines inside the text area. -- Default: "left", or "right" for right-to-left text. -- The names are physical: "left" always means the left edge -- of the text area, also in right-to-left text. -- Trailing spaces at a line break are ignored for right and -- center. "justified" stretches the spaces of every line that -- was broken automatically so that it touches both sides of -- the text area. Lines that end with a manual line break and -- the last line are not stretched, they stay left aligned. -- Lines without any space (e.g. CJK text) are stretched -- between the characters instead. A line that is wider than -- the text area (a single long word) is never moved or -- stretched. Needs a width > 0, otherwise the text is left -- aligned. -- balanced false | true (default false) -- false: lines are filled greedily, as wide as possible. -- true: the line breaks are moved so that the longest line -- gets as short as possible, while the number of lines stays -- the same as without balancing. Alignment still refers to -- the full text area, so e.g. "center" centers the balanced -- lines in the area. Needs a width > 0, otherwise it has no -- effect. -- disable_auto_blocking -- false | true (default false) -- Some tags apply to the text between their opening and closing -- tag as a whole (a shake that moves it rigidly, a wave or a -- gradient across it). Such a tag only works as intended if the -- renderer gets the text as ONE text, not one character at a -- time. Quotes and case of the attributes do not matter. -- false: the content of these tags is made a block, as if it -- were inside a tag (see there): the text is wrapped as -- usual, and every line of it becomes one node, with the tags -- around the whole line. The tags that count: -- * with fit=span or without a fit attribute, and -- with the attribute left or tl (the colours only make -- sense for the whole text): -- Horizontal -- Four -- corners -- * with fit=glyph or without a fit attribute: -- Glyph shake -- * with fit=glyph or without a fit attribute: -- Travelling wave -- A tag with any other fit value (e.g. fit=line, or fit=glyph on -- a gradient) is not changed. Only these tags are grouped, so a -- long text inside one of them can still break lines, e.g. a -- horizontal gradient on a paragraph is applied to every line. -- Details: -- * A block of this kind behaves like : spaces at the -- start or end of a line are nodes of their own, -- "justified" does not stretch the gaps inside a line, the -- node of a line appears when its first character is due, -- and what follows waits for every character of it. -- * The tags inside stay inside the node, the tags around it -- are written only once. -- * A or inside is a node of its own and cuts -- the line in two. For nested blocks, whether automatic or -- written (, ), the outermost one counts. -- * A line of one character is just that character. -- * The number of nodes does not depend on the line breaks (the -- other characters of a line get empty nodes), so -- Textbox:animate() can re-wrap the text; the nodes change -- their text then. Characters that have already appeared -- show the new text at once, without fading. -- * Right-to-left mode: the words are the nodes there, so the -- words of one line start together instead. -- * See AUTO_BLOCK to extend the list of tags. -- true: nothing is grouped automatically, every character is its -- own node and the tag is wrapped around each of them. It is then -- up to you to write the tags so that they do what you want, e.g. -- put a or inside (or around) them, or use -- another fit value. -- font string, optional -- The name of a font of the GUI scene. Short for fonts.latin. -- fonts table, optional -- { language or name = font name }, see "Fonts" above. -- If font and fonts.latin are both given, fonts.latin counts. -- rtl nil | true | false (default nil) -- nil: right-to-left mode is used automatically if the text -- contains at least one Arabic letter. -- true: right-to-left mode is forced (e.g. for Hebrew, or for -- a text that only consists of digits and punctuation). -- false: never use it, even if there are Arabic letters. -- See "Right-to-left mode" below. -- -- Rearranging a text that is already there: -- -- self.box:animate({ scale = 2.0, alignment = "right" }, 1.0, gui.EASING_LINEAR) -- -- Textbox:animate(options[, duration, easing]) -- Calculates the layout (line breaks, positions, scales) the current text would -- have with the given options, and moves/scales all its nodes there with -- gui.animate(). Doubling the scale at the same width therefore re-wraps the text -- into more lines, and the characters glide to their new places. -- options any of scale (x and y), scale_x, scale_y, width, alignment, tracking, -- leading, with the same meaning as in new()/type(). (Tracking is not -- interpolated: the nodes change it at once, only the positions glide.) They replace the options of the -- current text, so the next animate() starts from the new state; texts -- typed later use the options of the textbox as before. -- duration seconds, default 1.0. 0 (or less) changes everything at once. -- easing gui.EASING_*, default gui.EASING_LINEAR -- It can be called while the text is still being typed (characters that have not -- appeared yet are simply placed at their new position) and while an earlier -- animate() is still running (the nodes continue from where they are). Nodes that -- do not change are not animated. Every animated node uses up to two of the 1024 -- animations Defold allows at once. To keep that number low, the letters of a word -- that is completely faded in are moved by ONE node (a word node, created by the -- first animate() call that finds the word complete), and spaces without tags are -- not animated at all. Only letters that are still fading in, single-letter words -- (e.g. CJK text, where lines can break between any two characters) and spaces with -- tags (e.g. underlined) cost animations of their own. Very long texts can still -- run into "Out of animation resources" when they are rearranged while still -- fading in. -- The word nodes are children of the run node (so they count as GUI nodes: one -- more per word that was rearranged), and they are drawn after the nodes of the -- run that are not in a word. This only matters if the outline or shadow of one -- word overlaps the letters of another. -- -- Notes: -- * Positive x moves to the right, positive y moves up (Defold GUI axes). -- * Lines only break after spaces, after hyphens inside words, after dashes -- (em/en), and between CJK characters (with a few no-start/no-end rules). -- A single word that is wider than the text area is never split, it overflows. -- -- Right-to-left mode: -- * Arabic letters change their shape depending on their neighbours, so the -- letters of a word must stay together. In this mode a word (everything -- between two spaces) is ONE text node. It is measured, positioned and faded -- in as a whole, and your font/text renderer does the joining of the letters -- (and the order of letters inside the word). Spaces get no node. -- Tags inside a word stay inside its node; a tag that changes between two -- letters of one word interrupts the joining of these letters, as the -- renderer sees two separate pieces of text. -- * , and work here too. A chunk is one node, whatever is -- inside (spaces included), and it ends the Arabic word before it and starts a -- new one after it, so a chunk inside an Arabic word interrupts the joining. -- The words inside a stay together on one line. -- * and (and the automatic blocks) cannot make one node of a line here (the words are the -- nodes), so the words of one line of a , or of a whole , simply -- start together. The lines still wrap as usual. -- * The first word of every line is at the right edge of the text area, the -- following words go to the left. Lines are filled from the right edge, the -- text area itself is not mirrored: the right edge is at pivot + width, and if -- there is no width (> 0) the text starts at the right edge of the node -- (x = 0 after the pivot offset) and grows to the left, without alignment. -- * Words in another direction (English words, numbers) are placed from left to -- right inside the right-to-left line, e.g. "buy iPhone 15 now" in Arabic -- reads "iPhone 15" in its normal order. The ordering follows the Unicode -- bidirectional algorithm on word level (strong words, numbers, and neutral -- words/spaces between them), it does not look inside a word. -- * Lines only break at spaces (and around self-closing tags). A single word -- wider than the text area overflows. -- * Speed: see the option "speed". Combining marks (e.g. harakat) do not count -- as characters. -------------------------------------------------------------------------------- local M = {} local v3 = vmath.vector3 local LINEAR = gui.EASING_LINEAR local ZWSP = "\226\128\139" -- zero width space (U+200B) local DEFAULTS = { speed = 30, fadein_duration = 0.2, fadein_x = 0, fadein_y = 0, fadeout_duration = 0.2, fadeout_x = 0, fadeout_y = 0, fade_cross = 1, width = nil, tracking = nil, -- nil = tracking of the GUI node leading = nil, -- nil = leading of the GUI node scale = 1, -- number, or vector with x and y scale_x = nil, -- overrides the x factor of scale scale_y = nil, -- overrides the y factor of scale alignment = nil, -- nil = "left", or "right" for right-to-left text balanced = false, disable_auto_blocking = false, -- false: shake / wave / gradient that need one node get a block rtl = nil, -- nil = automatic (text has Arabic letters), true = always, false = never } local byte, sub, find, match, lower = string.byte, string.sub, string.find, string.match, string.lower local concat = table.concat -------------------------------------------------------------------------------- -- Character classification -------------------------------------------------------------------------------- local function make_set(list) local set = {} for _, v in ipairs(list) do set[v] = true end return set end -- The tags that are added to the text of the drawn characters. All other tags are -- dropped, except the ones in GROUPING, which only this module needs. local DRAWN = make_set({ "color", "size", "gradient", "ul", "strike", "outline", "shadow", "shake", "wave", "sprite", "link", }) -- The tags only this module handles (never added to the text of a node, see M.parse). local GROUPING = make_set({ "word", "chunk", "letter", "block", "sync", "font" }) -- Characters that should not start a line / end a line (CJK line breaking). local NO_START = make_set({ 0x2E, 0x2C, 0x21, 0x3F, 0x3B, 0x3A, 0x29, 0x5D, 0x7D, -- . , ! ? ; : ) ] } 0x3001, 0x3002, 0xFF0C, 0xFF0E, 0x30FB, 0xFF1A, 0xFF1B, 0xFF01, 0xFF1F, -- 、。,.・:;!? 0xFF09, 0xFF3D, 0xFF5D, 0x3015, 0x3009, 0x300B, 0x300D, 0x300F, 0x3011, -- )]}〕〉》」』】 0x30FC, -- ー }) local NO_END = make_set({ 0x28, 0x5B, 0x7B, -- ( [ { 0xFF08, 0xFF3B, 0xFF5B, 0x3014, 0x3008, 0x300A, 0x300C, 0x300E, 0x3010, -- ([{〔〈《「『【 }) local SPACES = make_set({ 0x20, 0x09, 0x3000 }) -- space, tab, ideographic space local function is_cjk(cp) return (cp >= 0x2E80 and cp <= 0x2FDF) or (cp >= 0x3000 and cp <= 0x30FF) or (cp >= 0x31F0 and cp <= 0x31FF) or (cp >= 0x3400 and cp <= 0x4DBF) or (cp >= 0x4E00 and cp <= 0x9FFF) or (cp >= 0xF900 and cp <= 0xFAFF) or (cp >= 0xFF00 and cp <= 0xFFEF) or (cp >= 0x20000 and cp <= 0x2FA1F) end -- Alphanumeric local function is_alnum(cp) return (cp >= 48 and cp <= 57) or (cp >= 65 and cp <= 90) or (cp >= 97 and cp <= 122) or (cp >= 0xC0 and cp < 0x2000 and cp ~= 0xD7 and cp ~= 0xF7) end -- Marks that belong to the character before them (accents, dakuten, variation -- selectors, emoji skin tones, zero width joiner, the vowel and tone marks of Thai -- and the vowel signs of Devanagari). Alone in a node they would be drawn on a -- dotted circle. local function is_combining(cp) return (cp >= 0x0300 and cp <= 0x036F) or (cp >= 0x0900 and cp <= 0x0903) -- Devanagari or (cp >= 0x093A and cp <= 0x093C) or (cp >= 0x093E and cp <= 0x094F) or (cp >= 0x0951 and cp <= 0x0957) or (cp >= 0x0962 and cp <= 0x0963) or cp == 0x0E31 or (cp >= 0x0E34 and cp <= 0x0E3A) -- Thai or (cp >= 0x0E47 and cp <= 0x0E4E) or (cp >= 0x1AB0 and cp <= 0x1AFF) or (cp >= 0x1DC0 and cp <= 0x1DFF) or (cp >= 0x20D0 and cp <= 0x20FF) or (cp >= 0x3099 and cp <= 0x309A) or (cp >= 0xFE00 and cp <= 0xFE0F) or (cp >= 0xFE20 and cp <= 0xFE2F) or (cp >= 0x1F3FB and cp <= 0x1F3FF) or (cp >= 0xE0100 and cp <= 0xE01EF) or cp == 0x200D end -- Devanagari consonants. A virama (0x094D) joins the one in front of it with the -- next one into a conjunct, which has to stay in one node. local function is_devanagari_consonant(cp) return (cp >= 0x0915 and cp <= 0x0939) or (cp >= 0x0958 and cp <= 0x095F) end -- Thai characters that no line break may be placed in front of (following vowels, -- the repetition mark, the abbreviation sign) or behind (leading vowels). local THAI_NO_START = make_set({ 0x0E2F, 0x0E30, 0x0E32, 0x0E33, 0x0E45, 0x0E46, 0x0E4F, 0x0E5A, 0x0E5B }) local function is_thai(cp) return cp >= 0x0E01 and cp <= 0x0E5B end -- Arabic letters (not digits, marks or punctuation). Enough to detect Arabic text. local function is_arabic_letter(cp) return (cp >= 0x0620 and cp <= 0x064A) or (cp >= 0x066E and cp <= 0x06D3) or cp == 0x06D5 or (cp >= 0x06E5 and cp <= 0x06E6) or (cp >= 0x06EE and cp <= 0x06EF) or (cp >= 0x06FA and cp <= 0x06FF) or (cp >= 0x0750 and cp <= 0x077F) or (cp >= 0x0870 and cp <= 0x088E) or (cp >= 0x08A0 and cp <= 0x08C9) or (cp >= 0xFB50 and cp <= 0xFBC2) or (cp >= 0xFBD3 and cp <= 0xFD3D) or (cp >= 0xFD50 and cp <= 0xFD8F) or (cp >= 0xFD92 and cp <= 0xFDC7) or (cp >= 0xFDF0 and cp <= 0xFDFB) or (cp >= 0xFE70 and cp <= 0xFEFC) end local function is_hebrew(cp) return (cp >= 0x05D0 and cp <= 0x05F4) or (cp >= 0xFB1D and cp <= 0xFB4F) end -- Letters that run right to left (Arabic and Hebrew). local function is_rtl_letter(cp) return is_arabic_letter(cp) or is_hebrew(cp) end -- Bidirectional class of a character, reduced to what the word level needs: -- "R" strong right to left "L" strong left to right -- "EN" European digit "AN" Arabic-Indic digit -- "N" everything else (neutral, marks, punctuation) local function char_class(cp) if cp >= 0x30 and cp <= 0x39 then return "EN" end if cp >= 0x06F0 and cp <= 0x06F9 then return "EN" end if cp >= 0x0660 and cp <= 0x0669 then return "AN" end if is_rtl_letter(cp) then return "R" end if (cp >= 0x0590 and cp <= 0x06FF) or is_combining(cp) then return "N" end if is_alnum(cp) or is_cjk(cp) then return "L" end return "N" end -------------------------------------------------------------------------------- -- UTF-8 -------------------------------------------------------------------------------- -- Byte length of the UTF-8 character starting at position i. Invalid bytes count -- as a character of length 1, so broken input never loops or errors. local function utf8_length(s, i) local b = byte(s, i) local len if b >= 0xC2 and b <= 0xDF then len = 2 elseif b >= 0xE0 and b <= 0xEF then len = 3 elseif b >= 0xF0 and b <= 0xF4 then len = 4 else return 1 -- ASCII or an invalid first byte end if i + len - 1 > #s then return 1 end for j = i + 1, i + len - 1 do local continuation = byte(s, j) if continuation < 0x80 or continuation > 0xBF then return 1 end end return len end local function codepoint(ch) local b1, b2, b3, b4 = byte(ch, 1, 4) local len = #ch if len == 1 then return b1 elseif len == 2 then return (b1 - 0xC0) * 64 + (b2 - 0x80) elseif len == 3 then return (b1 - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80) else return (b1 - 0xF0) * 262144 + (b2 - 0x80) * 4096 + (b3 - 0x80) * 64 + (b4 - 0x80) end end -------------------------------------------------------------------------------- -- Parsing: text -> tokens -------------------------------------------------------------------------------- -- Wraps every character token in the tags that are open at that point. -- Only the tags in DRAWN are wrapped around it, other tags are ignored. -- Tags with the same name override each other: the most recently opened one wins -- until it is closed, then the previous one is active again. Different names are -- all applied, outermost = opened first. Unclosed tags stay open until the end. -- , , , , and are not wrapped around anything, a -- character only gets to know the outermost one of each kind that is open at its -- position (the opening tag token, which is also the identity of the group): -- .word -- .chunk or (one node for all characters, never broken) -- .block or (may be broken into lines, one node per line) -- .font_tag the innermost (its .value is the name, its .parent the next -- around it), see assign_fonts() -- With auto_block, a drawn tag that needs its whole content in one node (see -- needs_block()) counts as a : it is wrapped around the characters as usual, -- and in addition the characters get it as .block (if no outer block is open). -- Every character also gets .stack, the active tags (outermost first), which is what -- the tags of a block node are written from. -- Which drawn tags need all characters of a line of their content in ONE text node -- to work as intended (a rigid shake, a wave or gradient across the whole text), and -- under which conditions. `fits`: the values of the attribute fit for which the tag -- needs it; a tag without any fit attribute counts as well. `attributes` (optional): -- one of them must be present, they only make sense for the whole text, e.g. the -- colours of a gradient. A tag with another fit value is never grouped, so the user -- decides. local AUTO_BLOCK = { gradient = { fits = { span = true }, attributes = { "left", "tl" } }, shake = { fits = { glyph = true } }, wave = { fits = { glyph = true } }, } -- Does this opening tag need a block of its own (see AUTO_BLOCK)? Quotes and case of -- the attributes do not matter. local function needs_block(name, raw) local rule = AUTO_BLOCK[name] if not rule then return false end raw = lower(raw) if find(raw, "[%s]fit%s*=") then local fit = match(raw, "[%s]fit%s*=%s*[\"']?([^\"'%s/>]*)") if not rule.fits[fit] then return false end end if rule.attributes then for _, attribute in ipairs(rule.attributes) do if find(raw, "[%s]" .. attribute .. "%s*=") then return true end end return false end return true end -- Removes the most recently opened tag with this name from the stack. local function close_tag(stack, name) for k = #stack, 1, -1 do if stack[k].name == name then table.remove(stack, k) return end end end -- Gives a char or newline token the tags that are open at its position (see above). -- A newline gets .open and .close, too: its size counts for the height of the line -- it ends, which also gives empty lines their height. local function apply_open_tags(tok, stack) local seen, active = {}, {} -- active: newest first local word, chunk, block, font_tag for k = #stack, 1, -1 do -- newest first, the newest of each name wins local tag = stack[k] if tag.name == "word" then word = tag -- the loop ends with the outermost one elseif tag.name == "chunk" or tag.name == "letter" then chunk = tag elseif tag.name == "block" or tag.name == "sync" then block = tag elseif tag.name == "font" then font_tag = font_tag or tag -- the newest one elseif not seen[tag.name] then seen[tag.name] = true active[#active + 1] = tag if tag.is_block then block = tag end -- the loop ends with the outermost end end local outermost_first, open, close = {}, {}, {} for k = #active, 1, -1 do outermost_first[#outermost_first + 1] = active[k] open[#open + 1] = active[k].raw end for _, tag in ipairs(active) do -- closed in reverse order: newest first close[#close + 1] = "" end tok.open, tok.close = concat(open), concat(close) if tok.type == "char" then tok.markup = tok.open .. tok.text .. tok.close tok.word, tok.chunk, tok.block, tok.font_tag = word, chunk, block, font_tag tok.stack = outermost_first -- inside
      or (see underlined spaces) tok.ul = seen.ul == true or seen.strike == true end end -- The name in a tag (quotes are allowed), nil if there is none. local function font_value(raw) local value = match(sub(raw, 6), "^%s*=%s*[\"']?([^\"'%s/>]*)") -- behind " this one is inside of if stack[k].name == "font" then tok.parent = stack[k] break end end end stack[#stack + 1] = tok end else -- a char or a newline apply_open_tags(tok, stack) end end end -- The value of a tag: nil = no value (wait for continue()), a number = -- seconds (0 or more), false = a value that is not a number (the tag is ignored). local function wait_value(raw) local value = match(sub(raw, 6), "^%s*=%s*[\"']?([^\"'%s/>]*)") -- behind " tags before it) and -- .stop (a plain is in the gap in front of it). The tags themselves stay in -- the token list, they are only looked at here. Timed waits in the same gap as a -- plain one are not counted: the plain one decides when the text goes on. -- .inner_stop starts as false; the groupings (chunk, block ...) set it when a plain -- inside a node has to be held in front of the item after that node. local function apply_waits(tokens) local delay = 0 -- seconds of all timed waits so far local gap_delay = 0 -- the part of it that was added in the current gap local stop = false -- a plain wait is in the current gap for _, tok in ipairs(tokens) do if tok.type == "tag" then if tok.name == "wait" and not tok.closing then local value = wait_value(tok.raw) if value == nil then delay = delay - gap_delay -- the timed waits of this gap do not count gap_delay, stop = 0, true elseif value and not stop then delay = delay + value gap_delay = gap_delay + value end end elseif tok.type == "char" then tok.delay, tok.stop, tok.inner_stop = delay, stop, false gap_delay, stop = 0, false end end end -- Reads the tag that starts at position i of the text. Returns its token and the -- position behind it, or nil if there is no tag at i. local function read_tag(text, i) local _, name_end, slash, name = find(text, "^<(/?)([%a_][%w_%-%.:]*)", i) if not name_end then return nil end local tag_end if slash == "/" then local _, e = find(text, "^%s*>", name_end + 1) tag_end = e else local following = sub(text, name_end + 1, name_end + 1) if following == ">" then tag_end = name_end + 1 elseif following == "/" and sub(text, name_end + 2, name_end + 2) == ">" then tag_end = name_end + 2 -- "" elseif following == "=" or find(following, "^%s") then local _, e = find(text, "^[^<>]*>", name_end + 1) tag_end = e end end if not tag_end then return nil end local raw = sub(text, i, tag_end) local lname = lower(name) local closing = slash == "/" local self_closing = not closing and sub(raw, -2) == "/>" if self_closing and DRAWN[lname] then -- "": the whole tag is ONE character, drawn and measured with -- exactly this text. It is a word of its own. return { type = "char", text = raw, cp = 0, is_space = false, inline = true }, tag_end + 1 end return { type = "tag", name = lname, closing = closing, raw = raw, self_closing = self_closing, }, tag_end + 1 end --- Splits a string into tokens. -- Token types: -- { type = "char", text = "a", cp = 97, is_space = false, -- delay = seconds of timed tags before it (added up), -- stop = true if a plain is directly in front of it, -- inner_stop = false (see apply_waits()), -- markup = "a", -- open = "", close = "", -- word = tag token or nil, chunk = tag token or nil } -- { type = "char", text = "", cp = 0, inline = true, ... } -- a self-closing tag from DRAWN: ONE character whose text is -- the tag itself (open/close/markup/word/chunk as above) -- { type = "tag", name = "color", closing = false, raw = "", -- self_closing = false } -- (self-closing tags that are not drawn are tags with -- self_closing = true, they have no effect) -- { type = "newline", open = "", close = "" } (tags active there) -- The open/close/markup of a char only contain the tags in DRAWN. -- A char with .break_after = true was followed by a zero width space (U+200B): a -- line may break behind it, as if there were a space (which is not drawn). -- A "char" is one UTF-8 character plus the combining marks attached to it (for -- Devanagari also the consonants joined to it by a virama). -- auto_block (optional, default false): the characters inside tags that need a -- block get it as .block (see apply_tags()); the option disable_auto_blocking = -- false of the textbox. function M.parse(text, auto_block) text = text or "" local tokens = {} local i, n = 1, #text local last_char -- the previous token, if it is a char that can still be extended local after_joiner = false -- the previous character was a zero width joiner local after_virama = false -- ... or a Devanagari virama local prev_char -- the last char token of the line (tags do not count), for U+200B while i <= n do local b = byte(text, i) local tag, after_tag = read_tag(text, i) if tag then tokens[#tokens + 1] = tag i = after_tag last_char, after_joiner, after_virama = nil, false, false elseif b == 10 or b == 13 then if b == 13 and byte(text, i + 1) == 10 then i = i + 1 end tokens[#tokens + 1] = { type = "newline" } i = i + 1 last_char, after_joiner, after_virama, prev_char = nil, false, false, nil else local len = utf8_length(text, i) local ch = sub(text, i, i + len - 1) local cp = codepoint(ch) if cp == 0x200B then -- zero width space: no node, no width, only a place where the line -- may break (behind the character before it) if prev_char then prev_char.break_after = true end last_char, after_joiner, after_virama = nil, false, false else if last_char and (after_joiner or is_combining(cp) or (after_virama and is_devanagari_consonant(cp))) then last_char.text = last_char.text .. ch else last_char = { type = "char", text = ch, cp = cp, is_space = SPACES[cp] == true } tokens[#tokens + 1] = last_char end prev_char = last_char after_joiner = cp == 0x200D after_virama = cp == 0x094D end i = i + len end end apply_tags(tokens, auto_block) apply_waits(tokens) return tokens end -------------------------------------------------------------------------------- -- Chunks and words -------------------------------------------------------------------------------- -- Joins the char tokens of one node (an Arabic word or a chunk) into its text. -- Returns the markup (the characters with the tags around them, tags that stay -- the same from one character to the next are only written once, which keeps the -- letters joined) and the largest size factor of its characters. local function join_chars(chars) local parts = {} local size = 0 local prev for _, c in ipairs(chars) do size = math.max(size, c.size) if not prev then parts[#parts + 1] = c.open .. c.text elseif c.open == prev.open then -- same tags: no tag text, keeps joining parts[#parts + 1] = c.text else parts[#parts + 1] = prev.close .. c.open .. c.text end prev = c end parts[#parts + 1] = prev.close return concat(parts), size end -- Is a plain in front of any character but the first? Such a wait cannot -- interrupt the node the characters are drawn in, it is held behind the node. local function has_inner_stop(chars) for k = 2, #chars do if chars[k].stop then return true end end return false end -- Are a and b (items next to each other) inside the same ? A chunk item -- counts with its last character at its end and with its first at its start. local function same_word(a, b) local w = (a.tail or a).word return w ~= nil and w == (b.head or b).word end -- The item for the characters of one or , see build_chunks(). local function make_chunk(chars) local markup, size = join_chars(chars) local width = 0 for _, c in ipairs(chars) do width = width + c.adv end local first, last = chars[1], chars[#chars] return { type = "chunk", markup = markup, adv = width, size = size, index = first.index, is_space = false, cp = 0, head = first, tail = last, tail_prev = chars[#chars - 1], word = first.word, block = first.block, font = first.font, chunk = first.chunk, -- a wait inside the chunk cannot interrupt it: a plain one is held in front -- of the next item, a timed one is part of its .delay already delay = first.delay, stop = first.stop, inner_stop = has_inner_stop(chars), } end -- Left-to-right counterpart of build_words(): turns the tokens into the items -- that layout() works on. Tags have no item. -- char: the char token itself, with .index = number of characters before it -- (this is what the speed counts), .adv = its advance and .size = its -- size factor -- chunk: one item for all characters of a or : -- { type = "chunk", markup = text with tags, -- adv = width, size = largest size factor of its characters, -- index = like a char, is_space = false, cp = 0, -- head / tail = first / last char token (they decide about the -- line breaks before / after it), tail_prev = the char before -- tail inside the chunk, word = the it starts in, -- block = the / it starts in } -- newline: the newline token itself -- A chunk of one character stays a plain char. The characters of a are -- counted like any others, so what follows it starts after the time they need. -- A is the same node, but all its characters count as ONE character, so -- what follows it starts after the time of a single character. -- A node has one font, so a chunk is cut where the font changes. The pieces are -- items of their own, with the same .chunk, and share_times() lets them appear -- together (a piece of one character is a plain char, as above). local function build_chunks(tokens) local items = {} local count = 0 local group -- chars of the chunk that is open local chunk_tag, chunk_index -- the / the previous character was in, and its index local function close_chunk() if not group then return end local chars = group group = nil items[#items + 1] = (#chars == 1) and chars[1] or make_chunk(chars) end for _, tok in ipairs(tokens) do if tok.type == "char" then -- a node has one font: a change of font ends the chunk, too if group and (group[1].chunk ~= tok.chunk or group[1].font ~= tok.font) then close_chunk() end if tok.chunk and tok.chunk == chunk_tag and tok.chunk.name == "letter" then tok.index = chunk_index -- the rest of a takes no time else tok.index = count count = count + 1 end chunk_tag, chunk_index = tok.chunk, tok.index tok.adv = tok.font.advance(tok.text, tok.open, tok.close) tok.size = tok.font.vertical.size_factor(tok.open, tok.close) if tok.chunk then group = group or {} group[#group + 1] = tok else items[#items + 1] = tok end elseif tok.type == "newline" then close_chunk() -- a line break ends the chunk chunk_tag = nil items[#items + 1] = tok end end close_chunk() return items end -------------------------------------------------------------------------------- -- Layout -------------------------------------------------------------------------------- local advance_caches = {} -- Returns a function giving the horizontal advance of a character in the font, -- including tracking. Measured as width(ch.."X") - width("X"): the engine adds the -- tracking only between two characters, so a lone character would come without it. -- The function takes the tags around the character (open = the opening tags, -- close = the matching closing tags) and measures with them, so that e.g. a size -- tag makes the advance bigger. Without tags it is the plain advance. -- scale_x is the horizontal scale of the nodes. The engine does not know about it, -- so the measured advance is multiplied with it. The cache holds the plain -- measurements, so it can be shared between different scales. local function get_advance_function(font, tracking, scale_x) local key = tostring(font) .. "|" .. tostring(tracking) local cache = advance_caches[key] if not cache then cache = { opts = { tracking = tracking }, tags = {} } advance_caches[key] = cache end return function(ch, open, close) open, close = open or "", close or "" local t = cache.tags[open] -- one table per combination of tags if not t then local ref = resource.get_text_metrics(font, open .. "X" .. close, cache.opts).width t = { ref = ref, glyphs = {} } cache.tags[open] = t end local advance = t.glyphs[ch] if not advance then advance = resource.get_text_metrics(font, open .. ch .. "X" .. close, cache.opts).width - t.ref t.glyphs[ch] = advance end return advance * scale_x end end local vertical_caches = {} -- Returns what the vertical layout needs to know about a font: -- ascent, descent distance of the baseline from the top / bottom of a line of -- normal size (size 1) -- factor the leading: how much it stretches a line. The engine's line -- pitch is leading * (ascent + descent), so for two lines of -- normal size the baselines are exactly one pitch apart. -- size_factor(open, close) size of text inside these tags (1 = normal), times -- scale_y, the vertical scale of the nodes. That is the factor -- the nodes are really drawn with, and everything that uses it -- (baselines, line heights, the item sizes of chunks and words) -- therefore treats the scale like a size tag. -- resource.get_text_metrics() scales width and height with a size tag, but not -- max_ascent and max_descent, so the size is taken from the height. -- ascent, descent and factor are those of the font, they are multiplied with the -- size by the layout. local function get_vertical_function(font, tracking, leading, scale_y) local key = tostring(font) .. "|" .. tostring(tracking) local cached = vertical_caches[key] if not cached then local opts = { tracking = tracking } local plain = resource.get_text_metrics(font, "X", opts) local tag_sizes = {} cached = { ascent = plain.max_ascent, descent = plain.max_descent, tag_size = function(open, close) if not open or open == "" then return 1 end local size = tag_sizes[open] if not size then local height = resource.get_text_metrics(font, open .. "X" .. close, opts).height size = (plain.height > 0 and height > 0) and height / plain.height or 1 tag_sizes[open] = size end return size end, } vertical_caches[key] = cached end return { ascent = cached.ascent, descent = cached.descent, factor = leading, size_factor = function(open, close) return cached.tag_size(open, close) * scale_y end, } end -- Vertical layout. Every line gets .size (the largest size of its items, newline -- included) and .baseline (distance of its baseline below the top of the text -- area), and every item of it gets .y, the distance of its own top below the top of -- the text area. An item (character or word) is a text node whose baseline lies -- size * ascent below its top (the ascent is the one of ITS font: fonts differ), so -- putting its top at baseline - size * ascent makes all items of the line sit on the -- same baseline, whatever their size and font. -- The next line is placed below the largest descent of the previous one and above -- the largest ascent of its own, stretched by the leading factor. For lines of one -- font and normal size this is the line pitch. -- The sizes of the items include the vertical scale of the nodes, see -- get_vertical_function(). local function place_lines(lines, fontset) local factor = fontset.leading local baseline, prev_descent for _, line in ipairs(lines) do local size, ascent, descent = 0, 0, 0 for _, item in ipairs(line.items) do local vertical = item.font.vertical size = math.max(size, item.size) ascent = math.max(ascent, item.size * vertical.ascent) descent = math.max(descent, item.size * vertical.descent) end local newline = line.newline if newline then -- counts for the line it ends, which gives empty lines their height local vertical = (newline.font or fontset.default).vertical local newline_size = vertical.size_factor(newline.open, newline.close) size = math.max(size, newline_size) ascent = math.max(ascent, newline_size * vertical.ascent) descent = math.max(descent, newline_size * vertical.descent) end if size == 0 then -- an empty last line has the normal size local vertical = fontset.default.vertical size = vertical.size_factor() ascent, descent = size * vertical.ascent, size * vertical.descent end if baseline then baseline = baseline + factor * (prev_descent + ascent) else baseline = ascent end line.size, line.baseline = size, baseline for _, item in ipairs(line.items) do item.y = baseline - item.size * item.font.vertical.ascent end prev_descent = descent end end -- May a line break be placed between item a and item b? (prev is the item before a) -- Items are chars or chunks. Never inside a . local function can_break(prev, a, b) if same_word(a, b) then return false end if prev and prev.tail then prev = prev.tail end if a.tail then prev, a = a.tail_prev or prev, a.tail end -- a chunk: its last char counts if b.head then b = b.head end -- a chunk: its first char counts if a.is_space then return not b.is_space end -- after a run of spaces if b.is_space then return false end -- spaces hang at the end of the word if a.break_after then return true end -- a zero width space if a.inline or b.inline then -- a self-closing tag is a word of its own return not NO_START[b.cp] and not NO_END[a.cp] end local cp_a, cp_b = a.cp, b.cp if cp_a == 0x2D then -- hyphen inside a word: "well-known" return prev ~= nil and is_alnum(prev.cp) and is_alnum(cp_b) end if cp_a == 0x2014 or cp_a == 0x2013 then -- em/en dash return not NO_START[cp_b] end if is_cjk(cp_a) or is_cjk(cp_b) then return not NO_START[cp_b] and not NO_END[cp_a] end if is_thai(cp_a) and is_thai(cp_b) then -- Thai has no spaces between words and this module has no dictionary, so -- lines may break between syllables, but not inside one: not behind a -- leading vowel and not in front of a following vowel. return not (cp_a >= 0x0E40 and cp_a <= 0x0E44) and not THAI_NO_START[cp_b] end return false end -- Fills lines with units: runs of items that are not split by a line break (a word -- with the spaces behind it). Used by layout() and layout_rtl(). -- Every line is { items = { ... }, hard = true if the line ends with a manual line -- break or is the last line, newline = the newline token that ends the line (nil if -- there is none) }. local Flow = {} Flow.__index = Flow function Flow.new(max_width) local flow = setmetatable({ max_width = max_width, lines = {}, x = 0 }, Flow) flow:start_line() return flow end function Flow:start_line() self.lines[#self.lines + 1] = { items = {}, hard = false } self.x = 0 end -- Adds a unit to the current line, or to a new one if it does not fit. Gives its -- items .x (offset from the left edge) and .line (number of the line, from 1). function Flow:add_unit(unit) local width, glyph_width = 0, 0 for _, item in ipairs(unit) do width = width + item.adv if not item.is_space then glyph_width = width end -- trailing spaces may hang over end if self.x > 0 and self.x + glyph_width > self.max_width then self:start_line() end local line = self.lines[#self.lines] for _, item in ipairs(unit) do item.x, item.line = self.x, #self.lines self.x = self.x + item.adv line.items[#line.items + 1] = item end end -- Ends the current line with a manual line break. function Flow:end_line(newline) local line = self.lines[#self.lines] line.hard, line.newline = true, newline self:start_line() end -- Returns the lines. The last one always counts as ended by a line break. function Flow:finish() self.lines[#self.lines].hard = true return self.lines end -- Assigns .x, .line and .unit to every char token and chunk item (see -- build_chunks()). .unit numbers the "words" of the text (the items between two -- break opportunities, trailing spaces included) from 1 in reading order. Where the -- breaks are possible does not depend on the width, the scale or the alignment, so -- the same text always gets the same units. -- Returns the lines, see Flow. local function layout(items, max_width) local flow = Flow.new(max_width) local unit = {} -- items between two break opportunities local unit_count = 0 local function flush_unit() if #unit == 0 then return end unit_count = unit_count + 1 flow:add_unit(unit) for _, item in ipairs(unit) do item.unit = unit_count end unit[#unit].brk = true -- a line may break after this item unit = {} end for index, item in ipairs(items) do if item.type == "newline" then flush_unit() flow:end_line(item) else unit[#unit + 1] = item local following = items[index + 1] if not following or following.type == "newline" or can_break(items[index - 1], item, following) then flush_unit() end end end flush_unit() return flow:finish() end -- Like layout(), but the line breaks are chosen so that the longest line is as -- short as possible without increasing the number of lines. -- The greedy layout never gets fewer lines when the width shrinks, so the -- smallest width that still gives the original line count is found by binary -- search over whole pixels. The final layout call leaves the positions of the -- chosen width in the items. -- `layout_fn` is the layout function to use (layout, or layout_rtl for words). local function layout_balanced(items, max_width, layout_fn) local lines = layout_fn(items, max_width) local target = #lines if target <= 1 or max_width == math.huge then return lines end local low, high, best = 0, math.floor(max_width), max_width while low <= high do local middle = math.floor((low + high) / 2) if #layout_fn(items, middle) <= target then best, high = middle, middle - 1 else low = middle + 1 end end return layout_fn(items, best) end -- Writes the characters of one block fragment as ONE text. Unlike join_chars() the -- tags that two neighbours have in common stay open, so an effect of the block -- (and anything around it) is written once around the whole fragment, even when -- other tags start and end inside it. Returns the markup. local function join_nested(chars) local parts, open = {}, {} for _, c in ipairs(chars) do local wanted = c.stack local keep = 0 while keep < #open and keep < #wanted and open[keep + 1] == wanted[keep + 1] do keep = keep + 1 end for k = #open, keep + 1, -1 do parts[#parts + 1] = "" open[k] = nil end for k = keep + 1, #wanted do parts[#parts + 1] = wanted[k].raw open[k] = wanted[k] end parts[#parts + 1] = c.text end for k = #open, 1, -1 do parts[#parts + 1] = "" end return concat(parts) end -- Puts the items first..last of a line (all in one block, next to each other) into -- one fragment: the first non-space item becomes the node of the whole fragment -- (.frag_markup, and .size = the largest size inside it, which its baseline needs), -- the others become empty nodes (.member). Spaces at both ends stay separate nodes. local function make_fragment(items, first, last) while first <= last and items[first].is_space do first = first + 1 end while last > first and items[last].is_space do last = last - 1 end if first > last then return end -- only spaces if last == first then return first, last end -- one character is just that character local fragment, size = {}, 0 for k = first, last do local c = items[k] fragment[#fragment + 1] = c size = math.max(size, c.size) if k > first then c.member = true if c.stop then items[last].inner_stop = true -- a wait cannot interrupt the fragment, it is held behind it end end if k < last then c.in_frag = true end end local head = items[first] head.frag_markup = join_nested(fragment) head.size = size return first, last end -- Block grouping, left-to-right mode. (A fragment has one font: it ends where the -- font changes, and a or is never part of one.) -- The text has been laid out character by -- character, so lines break as usual. Now the characters of one block (, -- , or a tag that needs one, see AUTO_BLOCK) that are next to each other in one -- line are put together, see make_fragment(). A block that continues in the next -- line has a fragment there, too. The node count stays the same, which -- Textbox:animate() relies on. -- * Every character of a fragment but the last gets .in_frag: gaps inside a -- fragment are not stretched by align_lines(), the node cannot be stretched. -- * A item inside a block is a node of its own and ends the fragment. -- * A plain inside a fragment cannot interrupt it, like in a , so it -- is held in front of the item that follows the fragment (.inner_stop of its -- last character). local function group_blocks(lines) for _, line in ipairs(lines) do local items = line.items local pieces = {} -- the fragments (or single characters) of the blocks in this line local i = 1 while i <= #items do -- (a or is a node of its own, and so is a change of font) local item = items[i] local block = item.type == "char" and not item.chunk and item.block if block then local last = i while last < #items do local following = items[last + 1] if following.type ~= "char" or following.block ~= block or following.chunk or following.font ~= item.font then break end last = last + 1 end local first_node, last_node = make_fragment(items, i, last) if first_node then pieces[#pieces + 1] = { block = block, first = first_node, last = last_node, range_first = i, range_last = last, } end i = last + 1 else i = i + 1 end end -- A line of a block that a change of font cut into several nodes (they follow -- each other directly) still appears at once: the nodes from the first to the -- last one start together, see share_times(). local k = 1 while k <= #pieces do local stop = k while stop < #pieces and pieces[stop + 1].block == pieces[k].block and pieces[stop + 1].range_first == pieces[stop].range_last + 1 do stop = stop + 1 end if stop > k then local group = {} for n = pieces[k].first, pieces[stop].last do items[n].cut_group = group end end k = stop + 1 end end end -- Collects the items that appear together into groups. key_of(item) returns the -- group of an item (nil: none) and optionally a second key that splits the group -- further (right-to-left mode: the line). local function collect_groups(items, key_of) local buckets, groups = {}, {} for _, item in ipairs(items) do local key, sub_key = key_of(item) if key then sub_key = sub_key or 0 local bucket = buckets[key] if not bucket then bucket = {} buckets[key] = bucket end local group = bucket[sub_key] if not group then group = {} bucket[sub_key] = group groups[#groups + 1] = group end group[#group + 1] = item end end return groups end -- Every node of a group takes the index and the delay of the first node of it, so it -- starts at the same moment, while what follows the group keeps its own index, which -- counts all characters of the group. A plain inside the group cannot -- interrupt it: it is held in front of the item after the group (.inner_stop of the -- last item). -- (Items without .index have no node, e.g. the spaces in right-to-left mode.) local function start_together(group) local first -- the first item that has a node local held = false -- a plain inside the group for _, item in ipairs(group) do if item.index == nil then -- no node: a wait on it would go to the next node (before the first -- node that is where the group starts, so it stays) if first then held = held or item.stop item.stop = false end elseif not first then first = item held = item.inner_stop item.inner_stop = false else item.index, item.delay = first.index, first.delay held = held or item.stop or item.inner_stop item.stop, item.inner_stop = false, false end end if held then group[#group].inner_stop = true end end -- Makes the nodes of a group appear at the same time. `items` is the list the layout -- worked on, key_of(item) returns the group an item belongs to, see collect_groups(). local function share_times(items, key_of) for _, group in ipairs(collect_groups(items, key_of)) do start_together(group) end end -- The first and the last item of a line that are not a space (nil if there is none). local function get_visible_range(items) local first, last for i, item in ipairs(items) do if not item.is_space then first = first or i last = i end end return first, last end -- Can the gap behind an item be stretched by "justified"? That is a space, or a -- break opportunity next to a CJK character. Never inside a or inside the -- node of a block fragment. local function is_stretchable_gap(item, following) if item.in_frag then return false end local space_gap = item.is_space and not same_word(item, following) local cjk_gap = item.brk and not following.is_space and (is_cjk((item.tail or item).cp) or is_cjk((following.head or following).cp)) return space_gap or cjk_gap end -- Spreads `extra` pixels over the gaps of a line, so it touches both sides. The -- width added behind an item is kept in item.stretch. local function justify_line(items, first, last, extra) local gaps, count = {}, 0 for i = first, last - 1 do if is_stretchable_gap(items[i], items[i + 1]) then gaps[i] = true count = count + 1 end end if count == 0 then return end local per_gap, shift = extra / count, 0 for i, item in ipairs(items) do item.x = item.x + shift if gaps[i] then item.stretch = per_gap -- extra width behind this space shift = shift + per_gap end end end -- Moves the items of every line according to the alignment. local function align_lines(lines, max_width, alignment) if alignment ~= "right" and alignment ~= "center" and alignment ~= "justified" then return end if max_width == math.huge then return end for _, line in ipairs(lines) do local items = line.items local first, last = get_visible_range(items) if first then -- visible width: trailing spaces hang over the edge and do not count local extra = max_width - (items[last].x + items[last].adv) if extra > 0 then if alignment == "justified" then if not line.hard then -- automatic line breaks only justify_line(items, first, last, extra) end else local offset = (alignment == "right") and extra or extra / 2 for _, item in ipairs(items) do item.x = item.x + offset end end end end end end -- Marks the spaces at the end of every line (item.trailing = true): the spaces that -- are followed by no other character in the same line, i.e. the ones that hang at -- an automatic line break, in front of a manual one, or at the end of the text. -- Every character gets the flag (true or false), so older values never stay. local function mark_line_ends(lines) for _, line in ipairs(lines) do local trailing = true for k = #line.items, 1, -1 do local item = line.items[k] if not item.is_space then trailing = false end item.trailing = trailing end end end -- Is this a space inside
        or that is not at the end of its line? -- Such a space gets a zero width space behind it, because the text renderer only -- draws the line for a space when there is a letter inside the tag. Spaces at the -- end of a line hang over the edge and get no line. local function is_underlined_space(item) return item.is_space and item.ul and not item.trailing end -------------------------------------------------------------------------------- -- Right-to-left mode: words instead of characters -------------------------------------------------------------------------------- local function contains_arabic(tokens) for _, t in ipairs(tokens) do if t.type == "char" and is_arabic_letter(t.cp) then return true end end return false end -- Returns a function giving the width of a whole word. A word is measured as one -- piece of text, because its letters change their shapes (and widths) depending -- on their neighbours. It is measured with its tags (the markup), so that size -- tags count. The cache only lives as long as the function. -- scale_x is the horizontal scale of the nodes, which the measurement does not know -- about: the measured width is multiplied with it. local function get_word_width_function(font, tracking, scale_x) local opts = { tracking = tracking } local cache = {} return function(word) local width = cache[word] if not width then width = resource.get_text_metrics(font, word, opts).width cache[word] = width end return width * scale_x end end -- Resolves the embedding level (1 = right to left, 2 = left to right inside it) -- of every item of one paragraph (items a..b), following the rules of the Unicode -- bidirectional algorithm on word level: -- W7 a number after a strong left-to-right word is left-to-right -- N1 neutrals between two items of the same direction take that direction -- (numbers count as right to left here) -- N2 all other neutrals take the paragraph direction (right to left) local function resolve_paragraph(items, a, b) local types = {} local last_strong = "R" -- start of the paragraph for i = a, b do local t = items[i].cls if t == "R" or t == "L" then last_strong = t elseif t == "EN" and last_strong == "L" then t = "L" end types[i] = t end local i = a while i <= b do if types[i] == "N" then local j = i while j < b and types[j + 1] == "N" do j = j + 1 end local before = (i > a and types[i - 1] == "L") and "L" or "R" local after = (j < b and types[j + 1] == "L") and "L" or "R" local direction = (before == after) and before or "R" for k = i, j do types[k] = direction end i = j + 1 else i = i + 1 end end for k = a, b do items[k].base_level = (types[k] == "R") and 1 or 2 end end -- Every line break (newline) starts a new paragraph. local function resolve_levels(items) local start, n = 1, #items for i = 1, n + 1 do if i > n or items[i].type == "newline" then if i - 1 >= start then resolve_paragraph(items, start, i - 1) end start = i + 1 end end end -- The direction class of a word: its first strong character decides, a word -- without one gets the class of its first digit, otherwise it is neutral. local function get_word_class(chars) local digit_class for _, c in ipairs(chars) do local class = char_class(c.cp) if class == "R" or class == "L" then return class end if not digit_class and (class == "EN" or class == "AN") then digit_class = class end end return digit_class or "N" end -- The item for the characters of one Arabic word (or chunk), see build_words(). local function make_word(chars, index, chunk) local markup, size = join_chars(chars) local first = chars[1] return { type = "word", is_space = false, cp = 0, markup = markup, adv = first.font.measure(markup), size = size, cls = get_word_class(chars), break_after = chars[#chars].break_after, index = index, chunk = chunk, word = first.word, block = first.block, font = first.font, -- plain waits inside the word are held after it delay = first.delay, stop = first.stop, inner_stop = has_inner_stop(chars), } end -- Turns the tokens into the items the right-to-left layout works with: -- word: { type = "word", is_space = false, cp = 0, adv = width, -- markup = text with tags, index = number of characters before the -- word (this is what the speed counts), -- size = largest size factor of its characters, -- word = the its first character is in, -- block = the / its first character is in } -- space: the char token itself, with .adv and .size -- newline: the newline token itself -- Tags have no item, they are part of the markup of the words. -- All characters of a or (spaces included) make ONE word item, and -- a chunk always starts a new item and ends the current one. Together with -- build_chunks() this is what makes a chunk appear like a single character (and the -- characters of a count as one). -- A node has one font, so a word (or chunk) is cut where the font changes. The -- pieces are items of their own: after each other, or, for a chunk or letter, -- together (see share_times()). local function build_words(tokens) local items = {} local count = 0 local chars, word_index, word_chunk -- the word that is open local letter, letter_index -- the the previous character was in, and its index local function close_word() if not chars then return end items[#items + 1] = make_word(chars, word_index, word_chunk) chars = nil end for _, tok in ipairs(tokens) do if tok.type == "char" then tok.size = tok.font.vertical.size_factor(tok.open, tok.close) if chars and (word_chunk ~= tok.chunk or chars[1].font ~= tok.font) then close_word() end -- the rest of a takes no time of its own local rest_of_letter = tok.chunk ~= nil and tok.chunk == letter and tok.chunk.name == "letter" local index = rest_of_letter and letter_index or count if not rest_of_letter then count = count + 1 end letter, letter_index = tok.chunk, index if tok.inline and not tok.chunk then -- a self-closing tag is a word of its own, whatever is next to it close_word() local word = make_word({ tok }, index, nil) word.inline = true items[#items + 1] = word elseif tok.is_space and not tok.chunk then close_word() tok.adv = tok.font.advance(tok.text, tok.open, tok.close) tok.cls = "N" items[#items + 1] = tok else if not chars then chars, word_index, word_chunk = {}, index, tok.chunk end chars[#chars + 1] = tok if tok.break_after and not tok.chunk then close_word() end -- (U+200B) end elseif tok.type == "newline" then close_word() letter = nil items[#items + 1] = tok end end close_word() resolve_levels(items) return items end -- Reverses the items first..last of the list. local function reverse_range(list, first, last) while first < last do list[first], list[last] = list[last], list[first] first, last = first + 1, last - 1 end end -- Puts the items of one line (still in reading order) into their visual order, -- following rule L2 of the Unicode bidirectional algorithm, and sets .x for every -- item. The line is then listed from the RIGHT edge to the left, and .x is the -- distance of the item's right edge from the right edge of the line start. local function arrange_line(line) local items = line.items local count = #items for _, item in ipairs(items) do item.level = item.base_level end for k = count, 1, -1 do -- trailing spaces get the paragraph direction if not items[k].is_space then break end items[k].level = 1 end local highest = 1 for _, item in ipairs(items) do highest = math.max(highest, item.level) end for level = highest, 1, -1 do -- reverse every run at this level or higher local i = 1 while i <= count do if items[i].level >= level then local j = i while j < count and items[j + 1].level >= level do j = j + 1 end reverse_range(items, i, j) i = j + 1 else i = i + 1 end end end -- items is now the visual order from left to right; list it from the right local ordered, x = {}, 0 for i = count, 1, -1 do local item = items[i] item.x = x x = x + item.adv ordered[#ordered + 1] = item end line.items = ordered end -- Right-to-left counterpart of layout(): same result structure, but it works on -- the items made by build_words() and breaks lines only before words that follow -- a space (and are not in the same as that space). The items are not -- changed, apart from their positions. -- Every item gets .line and .x, where .x is measured from the RIGHT edge of the -- text area (the start edge of the line). Alignment is done by align_lines() with -- mirrored names, see MIRRORED_ALIGNMENT. local function layout_rtl(items, max_width) local flow = Flow.new(max_width) local unit = {} -- a word with the spaces behind it local function flush_unit() if #unit == 0 then return end flow:add_unit(unit) unit = {} end for index, item in ipairs(items) do if item.type == "newline" then flush_unit() flow:end_line(item) else local prev = items[index - 1] -- a line may break before a word that follows a space, and around a -- self-closing tag (a word of its own) even without a space if not item.is_space and prev and not same_word(prev, item) and (prev.is_space or prev.inline or item.inline or prev.break_after) then flush_unit() end unit[#unit + 1] = item end end flush_unit() local lines = flow:finish() for _, line in ipairs(lines) do arrange_line(line) end return lines end -- Positions in right-to-left mode are measured from the right edge, so the -- alignment names are swapped for align_lines(): the line start (right edge) is -- "left" there. Unknown names mean "left" (as in left-to-right mode). local MIRRORED_ALIGNMENT = { left = "right", right = "left", center = "center", justified = "justified", } -------------------------------------------------------------------------------- -- Fonts -------------------------------------------------------------------------------- -- The languages that fonts can be given for (the keys of the option "fonts"), in the -- order that decides a tie when the language of a line is chosen. local GROUPS = { "latin", "arabic", "japanese", "chinese", "chinese_traditional", "korean", "thai", "devanagari", } -- Which fonts stand in for a language that has none of its own (after that: latin). -- Only for the CJK languages: a CJK font is the only one with their glyphs. local FALLBACKS = { chinese = { "chinese_traditional", "japanese", "korean" }, chinese_traditional = { "chinese", "japanese", "korean" }, japanese = { "chinese", "chinese_traditional", "korean" }, korean = { "japanese", "chinese", "chinese_traditional" }, } local CJK_GROUPS = make_set({ "japanese", "chinese", "chinese_traditional", "korean" }) -- Scripts that belong to exactly one language. local STRONG_GROUPS = make_set({ "arabic", "japanese", "korean", "thai", "devanagari" }) -- The Han characters that tell simplified and traditional Chinese apart: the common -- ones that exist in only one of the two. Han characters that are in neither list -- (most of them) are the same in both. Add more here if you need them. local SIMPLIFIED_ONLY = table.concat({ "国学这们说时来会个对见开门问间马鸟鱼车书长东难电买卖读写红绿听现实气爱儿韩过还进", "达边远连运动办务业产经济体点应当无与专义乐习乡亲仅从优伤价传倾关兴养决况净减凤击", "创别刘则剧劳势勋区医华协单卫厅厂历县参双变号吗员响园围图圆场块声处头夹奋妇孙宝审", "导将尔尘层属岁岛师帅带帮广庆库张归录彻忆总恶惊战报择摄据数断旧显暂机杀杂权条极样", "标梦检楼欢毕汉汤沟没灯灭热爷犹狮猫独环画疗盖监码离种积称竞笔简类粮纪约级纸线细织", "终组结给络统继续维罗联职肤胜脑脸节艺苏药获虑虽补装观规视觉计订认讨让训议讯记讲许", "论设访证评识诉词译试诗话该详语误请诸课谁调谈谢谱贝负财责贤败货质购贯贵费资赏赛赢", "赵趋跃转轮软轻载较辈辉输辑辞迟适选递逻遗邮郑酱释针钱铁银铜链销锁错键镇镜阅队阳阴", "阵阶际陆陈险随隐雾静页顶项须顾顿预领频题颜额风飞饭饮饱馆驱驶验骑鲜鸡麦黄齐齿龙龟", "剑剂劝却厉压厌吓吨咏圣坚垒够夺奖妈娱宪宽寻尽币弃弹强径恋恼悬惯愿懒扩扫执扬护拟拥", "拨挤损换捡掷摆敌敛旷晓术构柜栏桥档橱欧残毁洁浅渐滚满滩灾炉烦烧牵状猎玛琐痒盘矿础", "礼祸稳穷窃竖篮粤紧纯纵练绍绑绳缘罚网羡翘耻聪肠肿胀舰艰芦苹茧荣莲营萝蓝蜡衬袜讶谊", "贪贫贴贷贸赔赚踪躯轨辆辩迁迈违钓钢钥钦铃铺锅锋锐镰闪闭闯闲闷闹闻阔陕雳韦顺顽颁颗", "颠飘饰饼骂骄骗鸭鸣鹅鹰为众钟湿签", }) local TRADITIONAL_ONLY = table.concat({ "國學這們說時來會個對見開門問間馬鳥魚車書長東難電買賣讀寫紅綠聽現實氣愛兒韓過還進", "達邊遠連運動辦務業產經濟體點應當無與專義樂習鄉親僅從優傷價傳傾關興養決況淨減鳳擊", "創別劉則劇勞勢勳區醫華協單衛廳廠歷縣參雙變號嗎員響園圍圖圓場塊聲處頭夾奮婦孫寶審", "導將爾塵層屬歲島師帥帶幫廣慶庫張歸錄徹憶總惡驚戰報擇攝據數斷舊顯暫機殺雜權條極樣", "標夢檢樓歡畢漢湯溝沒燈滅熱爺猶獅貓獨環畫療蓋監碼離種積稱競筆簡類糧紀約級紙線細織", "終組結給絡統繼續維羅聯職膚勝腦臉節藝蘇藥獲慮雖補裝觀規視覺計訂認討讓訓議訊記講許", "論設訪證評識訴詞譯試詩話該詳語誤請諸課誰調談謝譜貝負財責賢敗貨質購貫貴費資賞賽贏", "趙趨躍轉輪軟輕載較輩輝輸輯辭遲適選遞邏遺郵鄭醬釋針錢鐵銀銅鏈銷鎖錯鍵鎮鏡閱隊陽陰", "陣階際陸陳險隨隱霧靜頁頂項須顧頓預領頻題顏額風飛飯飲飽館驅駛驗騎鮮雞麥黃齊齒龍龜", "劍劑勸卻厲壓厭嚇噸詠聖堅壘夠奪獎媽娛憲寬尋盡幣棄彈強徑戀惱懸慣願懶擴掃執揚護擬擁", "撥擠損換撿擲擺敵斂曠曉術構櫃欄橋檔櫥歐殘毀潔淺漸滾滿灘災爐煩燒牽狀獵瑪瑣癢盤礦礎", "禮禍穩窮竊豎籃粵緊純縱練紹綁繩緣罰網羨翹恥聰腸腫脹艦艱蘆蘋繭榮蓮營蘿藍蠟襯襪訝誼", "貪貧貼貸貿賠賺蹤軀軌輛辯遷邁違釣鋼鑰欽鈴鋪鍋鋒銳鐮閃閉闖閒悶鬧聞闊陝靂韋順頑頒顆", "顛飄飾餅罵驕騙鴨鳴鵝鷹為爲眾衆鐘鍾濕溼簽籤", }) local function make_codepoint_set(chars) local set = {} local i = 1 while i <= #chars do local len = utf8_length(chars, i) set[codepoint(sub(chars, i, i + len - 1))] = true i = i + len end return set end local SIMPLIFIED = make_codepoint_set(SIMPLIFIED_ONLY) local TRADITIONAL = make_codepoint_set(TRADITIONAL_ONLY) -- "s" for a character that only simplified Chinese uses, "t" for one that only -- traditional Chinese uses (Bopomofo, the phonetic script of Taiwan, counts), nil -- for a Han character that is the same in both. local function han_form(cp) if SIMPLIFIED[cp] then return "s" end if TRADITIONAL[cp] or (cp >= 0x3100 and cp <= 0x312F) or (cp >= 0x31A0 and cp <= 0x31BF) then return "t" end end -- What a character is, as far as the choice of a font goes: -- "flex" a Latin letter of Basic Latin, Latin-1 or Latin Extended-A, and -- "digit" a digit 0-9: they have no language of their own, see -- resolve_line_fonts() -- "latin" any other letter of the latin group (Cyrillic, Greek, Vietnamese, ...) -- and of the scripts that have no group of their own -- "arabic", "japanese" (kana), "korean" (Hangul), "thai", "devanagari" -- "han" a Han character (or Bopomofo): its language depends on the line -- "cjk_punct" CJK punctuation and the fullwidth forms -- "neutral" everything else: spaces, punctuation, symbols, emoji local function script_of(cp) if cp < 0x80 then if cp >= 0x30 and cp <= 0x39 then return "digit" end if (cp >= 0x41 and cp <= 0x5A) or (cp >= 0x61 and cp <= 0x7A) then return "flex" end return "neutral" end if cp < 0x2B0 then -- Latin-1, Latin Extended-A and -B, IPA if cp < 0xC0 or cp == 0xD7 or cp == 0xF7 then return "neutral" end return (cp < 0x180) and "flex" or "latin" end if cp < 0x370 then return "neutral" end -- modifier letters, combining marks if cp < 0x900 then -- Greek, Cyrillic, Armenian, Hebrew, Arabic ... if (cp >= 0x600 and cp <= 0x6FF) or (cp >= 0x750 and cp <= 0x77F) or cp >= 0x870 then return "arabic" end return "latin" end if cp < 0x980 then return "devanagari" end if cp < 0xE00 then return "latin" end if cp < 0xE80 then return "thai" end if cp < 0x1100 then return "latin" end if cp < 0x1200 then return "korean" end -- Hangul Jamo if cp < 0x2000 then if (cp >= 0x1AB0 and cp <= 0x1AFF) or (cp >= 0x1DC0 and cp <= 0x1DFF) then return "neutral" end if cp >= 0x1CD0 and cp <= 0x1CFF then return "devanagari" end return "latin" -- Latin Extended Additional (Vietnamese), Greek Extended ... end if cp < 0x2C00 then return "neutral" end -- punctuation, symbols, arrows, shapes if cp < 0x2E00 then return "latin" end -- Latin Extended-C, Cyrillic Extended-A ... if cp < 0x2E80 then return "neutral" end if cp < 0x3000 then return "han" end -- radicals if cp < 0x3040 then if cp == 0x3005 or cp == 0x3006 or cp == 0x3007 or (cp >= 0x3021 and cp <= 0x3029) or (cp >= 0x3038 and cp <= 0x303B) then return "han" end return "cjk_punct" end if cp < 0x3100 then if cp == 0x3099 or cp == 0x309A then return "neutral" end return "japanese" end if cp < 0x3130 then return "han" end -- Bopomofo if cp < 0x3190 then return "korean" end if cp < 0x31A0 then return "cjk_punct" end if cp < 0x31F0 then return "han" end -- Bopomofo Extended, strokes if cp < 0x3200 then return "japanese" end -- Katakana Phonetic Extensions if cp < 0x3400 then return "cjk_punct" end -- enclosed and compatibility forms if cp < 0x4DC0 then return "han" end if cp < 0x4E00 then return "neutral" end if cp < 0xA000 then return "han" end if cp < 0xA8E0 then return "latin" end if cp < 0xA900 then return "devanagari" end if cp < 0xA960 then return "latin" end if cp < 0xA980 then return "korean" end if cp < 0xAC00 then return "latin" end if cp < 0xD800 then return "korean" end -- Hangul syllables if cp < 0xF900 then return "neutral" end -- surrogates, private use if cp < 0xFB00 then return "han" end -- compatibility ideographs if cp < 0xFB50 then return "latin" end if cp < 0xFE00 then return "arabic" end if cp < 0xFE10 then return "neutral" end if cp < 0xFE20 then return "cjk_punct" end if cp < 0xFE30 then return "neutral" end if cp < 0xFE70 then return "cjk_punct" end if cp < 0xFF00 then return (cp == 0xFEFF) and "neutral" or "arabic" end if cp < 0xFF66 then return "cjk_punct" end -- fullwidth forms if cp < 0xFFA0 then return "japanese" end -- halfwidth Katakana if cp < 0xFFDD then return "korean" end -- halfwidth Hangul if cp < 0xFFF0 then return "cjk_punct" end if cp < 0x1B000 then return "neutral" end if cp < 0x1B170 then return "japanese" end -- Kana Supplement if cp < 0x1EE00 then return "neutral" end if cp < 0x1EF00 then return "arabic" end if cp >= 0x20000 and cp < 0x323B0 then return "han" end return "neutral" end -- The font object of one GUI font, as the layout needs it: .id for gui.set_font(), -- and the functions that measure with it (see get_advance_function(), -- get_word_width_function() and get_vertical_function()). local function new_font(id, resource, tracking, leading, sx, sy) return { id = id, advance = get_advance_function(resource, tracking, sx), measure = get_word_width_function(resource, tracking, sx), vertical = get_vertical_function(resource, tracking, leading, sy), } end --- Collects the fonts of a text: the font object of every language (see GROUPS) -- and of every name in the option "fonts". Fonts are compared by object, and one -- object stands for a font resource, so two names of the same font are the same. -- latin without a font gets the font of the container node. local function build_fontset(container, opts, tracking, leading, sx, sy) local objects = {} -- font resource -> font object local function get(id) local ok, resource = pcall(gui.get_font_resource, id) assert(ok and resource, "typewriter: there is no font '" .. tostring(id) .. "' in the GUI scene") local key = tostring(resource) local object = objects[key] if not object then object = new_font(id, resource, tracking, leading, sx, sy) objects[key] = object end return object end local by_name, by_lower_name = {}, {} for name, id in pairs(opts.fonts or {}) do assert(type(name) == "string", "typewriter: the keys of the option 'fonts' must be strings") local font = get(id) by_name[name] = font by_lower_name[lower(name)] = by_lower_name[lower(name)] or font end local latin = by_name.latin or get(gui.get_font(container)) local group_fonts = { latin = latin } for _, group in ipairs(GROUPS) do if group ~= "latin" then local font = by_name[group] if not font then for _, other in ipairs(FALLBACKS[group] or {}) do font = by_name[other] if font then break end end end group_fonts[group] = font or latin end end return { default = latin, group_fonts = group_fonts, leading = leading, by_name = by_name, by_lower_name = by_lower_name, } end local warned_fonts = {} -- The font of a tag: a font object, or false if it has no (known) name. local function resolve_font_tag(tag, fontset) if tag.font == nil then local name = tag.value local font = name and (fontset.by_name[name] or fontset.by_lower_name[lower(name)]) if name and not font and not warned_fonts[name] then warned_fonts[name] = true print("typewriter: is ignored, 'fonts' has no entry '" .. name .. "'") end tag.font = font or false end return tag.font end -- The font that the tags around a character ask for (the innermost one that -- has a known name), nil if there is none. local function get_manual_font(tok, fontset) local tag = tok.font_tag while tag do local font = resolve_font_tag(tag, fontset) if font then return font end tag = tag.parent end end local FORM_GROUP = { s = "chinese", t = "chinese_traditional" } -- Gives the Han characters of one line (tokens) their language group: .group. -- `language` is the one the line has for Han characters, `variant` how the Chinese -- of the whole text is written: "simplified", "traditional" or "mixed". local function resolve_han(han, language, variant) if language ~= "chinese" then for _, c in ipairs(han) do c.group = language end elseif variant ~= "mixed" then local group = (variant == "traditional") and "chinese_traditional" or "chinese" for _, c in ipairs(han) do c.group = group end else -- Both kinds occur: a character that is one of them keeps its kind, one that -- is the same in both follows the one before it (or, at the start, after it). local previous for _, c in ipairs(han) do local form = han_form(c.cp) if form then previous = form end c.group = previous and FORM_GROUP[previous] or nil end local following for k = #han, 1, -1 do local c = han[k] if han_form(c.cp) then following = c.group elseif not c.group then c.group = following or "chinese" end end end end -- The language group of the CJK character nearest to chars[k]: before it, else after it. local function find_cjk_group(chars, k) for j = k - 1, 1, -1 do local group = chars[j].group if group and CJK_GROUPS[group] then return group end end for j = k + 1, #chars do local c = chars[j] if c.script ~= "cjk_punct" and c.group and CJK_GROUPS[c.group] then return c.group end end end -- Gives every character of one line (paragraph.chars) that has no font yet its font. -- paragraph.han: its Han characters, paragraph.language: their language. -- The steps are explained in the "Fonts" section at the top of the file: -- 1. Han characters, other scripts: their own language. -- 2. Runs of Latin letters and digits: no language of their own, "flex". (A run -- with a letter of the other Latin scripts in it, e.g. a Vietnamese word, is -- latin as a whole, so that it is not cut.) They vote for the language of the line. -- 3. The language of the line ("host") is the one with the most votes: a character -- of a non-Latin script counts 1, a run of Latin letters counts 1. A tie goes -- to the first one of GROUPS, which is latin. -- 4. CJK punctuation follows the nearest CJK character, flex characters the host, -- and everything else (neutral) the character before it. local function resolve_line_fonts(paragraph, fontset, variant) local chars = paragraph.chars if #paragraph.han > 0 then resolve_han(paragraph.han, paragraph.language, variant) end local votes = {} local function vote(group) votes[group] = (votes[group] or 0) + 1 end local run_first, run_strong, run_letters -- the run of Latin letters and digits that is open local function end_run(last) if not run_first then return end if run_letters then vote("latin") end local group = run_strong and "latin" or "flex" for k = run_first, last do chars[k].group = group end run_first = nil end for k, c in ipairs(chars) do local script = c.script -- nil: it has a manual font if script == "flex" or script == "digit" or script == "latin" then if not run_first then run_first, run_strong, run_letters = k, false, false end if script == "latin" then run_strong, run_letters = true, true elseif script == "flex" then run_letters = true end else end_run(k - 1) if script == "han" then vote(c.group) elseif STRONG_GROUPS[script] then c.group = script vote(script) end end end end_run(#chars) local host, best = "latin", votes.latin or 0 for _, group in ipairs(GROUPS) do local count = votes[group] or 0 if count > best then host, best = group, count end end for k, c in ipairs(chars) do if c.script == "cjk_punct" then c.group = find_cjk_group(chars, k) end end local previous -- the group of the last character that had one for _, c in ipairs(chars) do if not c.font then local group = c.group if group == "flex" then group = host end if group then previous = group else group = previous or host end c.font = fontset.group_fonts[group] end end -- a line break counts with the font of the line (it gives an empty line its height) local newline = paragraph.newline if newline then local last = chars[#chars] newline.font = last and last.font or fontset.group_fonts[host] end end --- Gives every char token (and every newline) its font: .font, a font object of -- the fontset. The font tags decide first, then the language of the text, see "Fonts" -- at the top of the file. The tokens come from M.parse(). local function assign_fonts(tokens, fontset) local paragraphs = {} local traditional, simplified = false, false local first = 1 for index = 1, #tokens + 1 do local tok = tokens[index] if tok == nil or tok.type == "newline" then local paragraph = { newline = tok, chars = {}, han = {} } local kana, hangul = false, false for k = first, index - 1 do local c = tokens[k] if c.type == "char" then paragraph.chars[#paragraph.chars + 1] = c c.font = get_manual_font(c, fontset) -- nil: automatic if not c.font then local script = script_of(c.cp) c.script = script if script == "japanese" then kana = true elseif script == "korean" then hangul = true elseif script == "han" then paragraph.han[#paragraph.han + 1] = c end end end end paragraph.language = kana and "japanese" or (hangul and "korean" or "chinese") if paragraph.language == "chinese" then for _, c in ipairs(paragraph.han) do local form = han_form(c.cp) if form == "t" then traditional = true elseif form == "s" then simplified = true end end end paragraphs[#paragraphs + 1] = paragraph first = index + 1 end end local variant = "simplified" -- also if nothing tells if traditional and simplified then variant = "mixed" elseif traditional then variant = "traditional" end for _, paragraph in ipairs(paragraphs) do resolve_line_fonts(paragraph, fontset, variant) end end -------------------------------------------------------------------------------- -- GUI helpers -------------------------------------------------------------------------------- local function resolve_node(node) if type(node) == "string" then return gui.get_node(node) end return node end -- Pivot -> { fraction from the left, fraction from the bottom } of the box. local pivot_factors = { [gui.PIVOT_CENTER] = { 0.5, 0.5 }, [gui.PIVOT_N] = { 0.5, 1 }, [gui.PIVOT_NE] = { 1, 1 }, [gui.PIVOT_E] = { 1, 0.5 }, [gui.PIVOT_SE] = { 1, 0 }, [gui.PIVOT_S] = { 0.5, 0 }, [gui.PIVOT_SW] = { 0, 0 }, [gui.PIVOT_W] = { 0, 0.5 }, [gui.PIVOT_NW] = { 0, 1 }, } local function get_pivot_factors(pivot) return pivot_factors[pivot] or pivot_factors[gui.PIVOT_CENTER] end local function merge(base, extra) local out = {} for k, v in pairs(base) do out[k] = v end if extra then for k, v in pairs(extra) do out[k] = v end end return out end -- Like merge(), but the option "fonts" is merged entry by entry (so type() can add -- or replace single fonts), and "font" is turned into fonts.latin. Within one level -- fonts.latin wins over font. The result has no "font". local function merge_options(base, extra) local out = merge(base, extra) local fonts = merge(base.fonts or {}) if extra then if extra.fonts ~= nil then assert(type(extra.fonts) == "table", "typewriter: the option 'fonts' must be a table") for name, font in pairs(extra.fonts) do fonts[name] = font end end if extra.font ~= nil and not (extra.fonts and extra.fonts.latin ~= nil) then fonts.latin = extra.font end end out.fonts, out.font = fonts, nil return out end -- Reads the "scale", "scale_x" and "scale_y" options. "scale" is a number (x and -- y) or a vector with x and y fields (vmath.vector3, vmath.vector4 or a table); -- scale_x / scale_y, if given, replace the factor of their axis. -- Returns scale x and scale y. local function resolve_scale(scale, scale_x, scale_y) local sx, sy if scale == nil then sx, sy = 1, 1 elseif type(scale) == "number" then sx, sy = scale, scale else sx, sy = scale.x, scale.y end if scale_x ~= nil then sx = scale_x end if scale_y ~= nil then sy = scale_y end assert(type(sx) == "number" and type(sy) == "number", "typewriter: scale must be a number or a vector with x and y") assert(sx > 0 and sy > 0, "typewriter: scale must be greater than 0") return sx, sy end local function same_vector(a, b) return math.abs(a.x - b.x) < 0.01 and math.abs(a.y - b.y) < 0.01 and math.abs(a.z - b.z) < 0.01 end local function delete_callback(_, node) gui.delete_node(node) end -- Creates an empty text node below `parent`, at the origin of the container and -- with alpha `alpha`. It is used to move whole groups of characters at once. local function new_group_node(container, parent, alpha) local node = gui.clone(container) gui.set_parent(node, parent, false) gui.set_text(node, "") gui.set_position(node, v3(0)) gui.set_scale(node, v3(1, 1, 1)) gui.set_euler(node, v3(0)) gui.set_alpha(node, alpha) return node end -------------------------------------------------------------------------------- -- Sprites and images -------------------------------------------------------------------------------- -- Sprites: the text renderer only reserves the space for a tag, it -- draws nothing. gui.get_layout_objects() tells where (x, y = lower left corner, -- relative to the origin of the text node) and how large (width, height) the -- sprite is, so a box node is created there, with `src` as its texture and -- `animation` as its flipbook animation. The box node is a CHILD of the node of -- the letter, so it moves, scales, fades and is deleted together with it -- (it inherits the alpha of its parent). Without `src` no box node is created. -- External images: a src that ends with .png, .jpg or .jpeg (any capitalization) is -- an image file, not an atlas. A path starting with "/" is a custom resource of the -- bundle (sys.load_resource), any other path is a file of the file system (io.open). -- The image becomes a GUI texture, created once per textbox and src (`images` is -- the cache of the textbox: src -> texture id, or false if loading failed). local IMAGE_EXTENSIONS = make_set({ "png", "jpg", "jpeg" }) local image_counter = 0 local function is_image_path(src) local extension = match(src, "%.(%w+)$") return extension ~= nil and IMAGE_EXTENSIONS[lower(extension)] end local function read_image_file(src) if byte(src, 1) == 47 then -- "/" return sys.load_resource(src) end local file, err = io.open(src, "rb") if not file then return nil, err end local data = file:read("*a") file:close() return data end -- Returns the id of the GUI texture of the image, or nil if it cannot be loaded -- (a warning is printed once; the sprite then stays empty). local function get_image_texture(images, src) local id = images[src] if id ~= nil then return id or nil end images[src] = false local data, err = read_image_file(src) local img if data then img, err = image.load(data, { premultiply_alpha = true }) end if not img then print("typewriter: cannot load image '" .. src .. "': " .. tostring(err)) return nil end image_counter = image_counter + 1 id = "typewriter_image_" .. image_counter local ok = gui.new_texture(id, img.width, img.height, img.type, img.buffer, false) if not ok then print("typewriter: cannot create a texture for '" .. src .. "'") return nil end images[src] = id return id end local function clear_sprites(letter) if letter.sprites then for _, box in ipairs(letter.sprites) do gui.delete_node(box) end letter.sprites = nil end end -- Creates the box node of one sprite as a child of `parent`, the node of a letter. -- `flipbook` is the name of the animation to play, or nil. local function new_sprite_box(parent, object, texture, flipbook, layer) local w, h = object.width, object.height -- x / y is the lower left corner of the sprite relative to the pivot of -- the text node, so the box gets the pivot SW and exactly that position local box = gui.new_box_node(v3(object.x, object.y, 0), v3(w, h, 0)) gui.set_parent(box, parent, false) gui.set_pivot(box, gui.PIVOT_SW) gui.set_size_mode(box, gui.SIZE_MODE_MANUAL) gui.set_size(box, v3(w, h, 0)) gui.set_position(box, v3(object.x, object.y, 0)) gui.set_inherit_alpha(box, true) pcall(gui.set_layer, box, layer) -- same layer as the text, if it has one gui.set_texture(box, texture) if flipbook then gui.play_flipbook(box, flipbook) end return box end -- (Re)creates the box nodes of all sprite tags in the text of the node of `letter`. -- Has to be called whenever the text of that node was set. local function make_sprites(letter, container, images) clear_sprites(letter) if not find(lower(letter.markup), " inside a chunk, held for the next item for _, item in ipairs(items) do if item.type ~= "newline" then local markup, scale_x = item.markup, sx if item.frag_markup then markup = item.frag_markup -- first node of a block fragment elseif item.member then markup = "" -- the fragment is drawn by its first node elseif is_underlined_space(item) then markup = item.open .. item.text .. ZWSP .. item.close if item.stretch and item.adv > 0 then -- justified gap: item.adv and item.stretch both include sx scale_x = sx * (item.adv + item.stretch) / item.adv end end placements[#placements + 1] = { markup = markup, font = item.font, pos = v3(area.left + item.x, area.top - item.y, 0), pivot = gui.PIVOT_NW, scale = v3(scale_x, sy, 1), index = item.index, delay = item.delay, stop = item.stop or carry, unit = item.unit, -- a space is never part of a word node (it may be stretched -- by "justified"); one without any tag draws nothing at all space = item.is_space, hidden = item.member or (item.is_space and item.open == ""), } carry = item.inner_stop end end return placements end -- Placements of the nodes in right-to-left mode: one node per word (or chunk). -- Since Defold 1.13.2 the engine mirrors the meaning of the horizontal pivot for -- right-to-left text (an East pivot no longer puts the text's right edge at the node -- position, it left-aligns the line inside the node's width). A North pivot -- (horizontally centred) is the same in both directions, so each word is placed by -- its centre. This also works in 1.13.1 and earlier, where the centre pivot centres -- the text too. -- The scale works around the same pivot, so the centre of the word stays where it is -- and item.adv (which includes the scale) is its width. local function get_rtl_placements(items, area, sx, sy) local placements = {} local right = area.left + area.width local carry = false -- a plain that is held for the next word for _, item in ipairs(items) do if item.type == "word" then placements[#placements + 1] = { markup = item.markup, font = item.font, pos = v3(right - item.x - item.adv / 2, area.top - item.y, 0), pivot = gui.PIVOT_N, scale = v3(sx, sy, 1), index = item.index, delay = item.delay, stop = item.stop or carry, } carry = item.inner_stop elseif item.type == "char" then -- a space has no node, so its wait goes to the next word carry = carry or item.stop or item.inner_stop end end return placements end -- The nodes that one line of a was cut into by a change of font (see -- group_blocks()) appear together, too. local function get_cut_group(item) return item.cut_group end -- The nodes that one or was cut into (at a change of font) appear -- together. (A chunk that was not cut is a group of one, which changes nothing.) local function get_chunk(item) return item.chunk end -- The items of a start together, whatever the lines are. local function get_sync(item) local block = item.block if block and block.name == "sync" then return block end end -- In right-to-left mode the words are the nodes and cannot be put together into -- lines, so the words of one line of a block (and of a whole ) simply start -- together. local function get_sync_or_line(item) local block = item.block if not block then return nil end if block.name == "sync" then return block end if not item.chunk then return block, item.line end end --- Lays out a text and returns where every node of it goes. -- This is the part of type() that animate() needs again with changed options, so -- it only reads the container (font, tracking, leading, size, pivot) and creates -- nothing. `opts` is a complete set of options (defaults merged). -- The result has one entry per node, in the order the nodes are created (reading -- order), and does not depend on width, scale or alignment, so two results for the -- same text and the same rtl setting can be compared entry by entry: -- { markup = text of the node, pos = vector3 (relative to the container's -- origin), pivot = gui.PIVOT_*, scale = vector3, index = number of characters -- before it (what the speed counts), delay = seconds of timed waits before it, -- stop = a plain holds it, and for left-to-right text also -- unit = number of its word (see layout()), space = true for a space, -- hidden = true for a space without tags, which draws nothing } local function compute_placements(container, text, opts) local tracking = opts.tracking if tracking == nil then tracking = gui.get_tracking(container) end local leading = opts.leading if leading == nil then leading = gui.get_leading(container) end assert(type(tracking) == "number" and type(leading) == "number", "typewriter: tracking and leading must be numbers") local size = gui.get_size(container) local width = opts.width or size.x local max_width = (width > 0) and width or math.huge -- always fresh: layout writes its results into them. Unless -- disable_auto_blocking is true, the content of the tags that need it (see -- AUTO_BLOCK) is a block, like with the tag. local tokens = M.parse(text, not opts.disable_auto_blocking) local rtl = opts.rtl if rtl == nil then rtl = contains_arabic(tokens) end -- The nodes are scaled by sx, sy. The text area keeps its width, so the layout -- works with advances and sizes that include the scale. local sx, sy = resolve_scale(opts.scale, opts.scale_x, opts.scale_y) -- every character gets its font (tok.font) before anything is measured local fontset = build_fontset(container, opts, tracking, leading, sx, sy) assign_fonts(tokens, fontset) local items, layout_fn -- what the layout works on, and how if rtl then items = build_words(tokens) layout_fn = layout_rtl else items = build_chunks(tokens) layout_fn = layout end local lines if opts.balanced then lines = layout_balanced(items, max_width, layout_fn) else lines = layout_fn(items, max_width) end if rtl then align_lines(lines, max_width, MIRRORED_ALIGNMENT[opts.alignment or "right"] or "right") else group_blocks(lines) -- before align_lines() align_lines(lines, max_width, opts.alignment or "left") end place_lines(lines, fontset) -- baselines, so that different sizes and fonts line up share_times(items, get_chunk) share_times(items, get_cut_group) share_times(items, rtl and get_sync_or_line or get_sync) -- top left corner of the text area, relative to the pivot of the node local factors = get_pivot_factors(gui.get_pivot(container)) local area = { left = -factors[1] * width, top = (1 - factors[2]) * size.y, width = width } if rtl then return get_rtl_placements(items, area, sx, sy) end mark_line_ends(lines) return get_ltr_placements(items, area, sx, sy) end -- Finds the words of a text whose letters can be moved by one node: the letters of -- one unit (see layout()) without its spaces, at least two of them. Within such a -- word the letters always keep the same distances to each other, whatever the -- width, scale or alignment is: they are never separated by a line break, and the -- advances and sizes they are placed with are linear in the scale. Only a space may -- be stretched by "justified", which is why spaces are left out. -- Returns a list in reading order of { members = { indices into the letters } }. -- Right-to-left text has no units: its nodes are words already. local function find_words(placements) local units = {} local current, current_unit for n, p in ipairs(placements) do if p.unit and not p.space then if p.unit ~= current_unit then current_unit = p.unit current = { members = {} } units[#units + 1] = current end current.members[#current.members + 1] = n end end local words = {} for _, unit in ipairs(units) do if #unit.members >= 2 then words[#words + 1] = unit end end return words end -------------------------------------------------------------------------------- -- Runs: the typing of one text -------------------------------------------------------------------------------- local function stop_timer(run) if run.timer then timer.cancel(run.timer) run.timer = nil end end local function complete_run(run) if run.finished then return end run.finished = true local box = run.box for _, callback in ipairs(run.callbacks) do callback(box, run.text) end if box.listener then box.listener(box, run.text) end end -- Counts letters that are completely faded in (or need no fade in). local function count_done(run, amount) run.done = run.done + amount if run.done >= #run.letters then complete_run(run) end end local function fades_in_moving(options) return options.fadein_x ~= 0 or options.fadein_y ~= 0 end local function fades_out_moving(options) return options.fadeout_x ~= 0 or options.fadeout_y ~= 0 end -- Stops the animations of Textbox:animate() on a node. local function cancel_arrangement(node) gui.cancel_animations(node, "position") gui.cancel_animations(node, "scale") end -- Stops the fade in of a node. local function cancel_fade_in(node, moving) gui.cancel_animations(node, "color.w") if moving then gui.cancel_animations(node, "position") end end -- Characters that are still fading in or moving stop where they are. This frees -- their animation slots, the fade out of the run node takes over. local function cancel_character_animations(run) local moving = fades_in_moving(run.options) if run.arranged then -- running animations of Textbox:animate() for n = 1, run.next - 1 do cancel_arrangement(run.letters[n].node) end for _, word in ipairs(run.words) do if word.node then cancel_arrangement(word.node) end end end for _, group in ipairs(run.groups) do -- see finish_letters() if not group.done then cancel_fade_in(group.node, moving) end end for n = 1, run.next - 1 do local letter = run.letters[n] if not letter.done then cancel_fade_in(letter.node, moving) end end end -- Fades a whole run out by animating its run node, then deletes it together with -- all its characters. `fadeout` holds the fadeout options to use. Returns the fade -- out duration actually used (0 if the run was deleted at once because nothing was -- visible or the duration is 0). local function fade_out_run(run, fadeout) if not run then return 0 end stop_timer(run) local node = run.parent local duration = fadeout.fadeout_duration if duration <= 0 or run.next <= 1 then -- instant, or no character has started yet gui.delete_node(node) -- also deletes the character nodes return 0 end cancel_character_animations(run) if fades_out_moving(fadeout) then gui.animate(node, "position", v3(fadeout.fadeout_x, fadeout.fadeout_y, 0), LINEAR, duration) end gui.animate(node, "color.w", 0, LINEAR, duration, 0, delete_callback) return duration end -- When is this letter due, counted from the start of the run (without the plain -- waits released so far)? The typing speed and the timed waits decide. local function get_start_time(placement, speed) local typing_time = (speed and speed > 0) and placement.index / speed or 0 return typing_time + placement.delay end -- Creates the node of one letter (or, in right-to-left mode, one word) as a child -- of the run node `parent`. It is invisible until start_letter() fades it in. -- Its placement says where it goes, see compute_placements(). The node is scaled -- with the vector placement.scale: the "scale" option, and for the space of a -- justified line also the stretching of the node horizontally from its left edge, -- which makes the underline / strikethrough as wide as the gap. local function new_letter(box, parent, placement, opts) local node = gui.clone(box.node) gui.set_parent(node, parent, false) gui.set_font(node, placement.font.id) -- before the text: the layout objects of a sprite depend on it if opts.tracking ~= nil then gui.set_tracking(node, opts.tracking) end if opts.leading ~= nil then gui.set_leading(node, opts.leading) end gui.set_line_break(node, false) gui.set_pivot(node, placement.pivot) -- The text is set after the pivot and the line break: the layout objects of a -- sprite (position relative to the pivot) are made from the layout at the time -- the text is set. gui.set_text(node, placement.markup) gui.set_scale(node, placement.scale) gui.set_euler(node, v3(0)) gui.set_position(node, placement.pos) gui.set_alpha(node, 0) local letter = { node = node, pos = placement.pos, scale = placement.scale, markup = placement.markup, hidden = placement.hidden, stop = placement.stop, done = false, time = get_start_time(placement, opts.speed), } make_sprites(letter, box.node, box.images) -- after everything that changes the layout return letter end -- Does this letter draw nothing? (the rest of a block fragment, or a space without -- tags): no animation needed, it is just there. local function draws_nothing(letter) return letter.markup == "" or letter.hidden end -- Starts the fade in of one character. Only called when its time has come, so no -- delay is needed and nothing is animated before that. local function start_letter(run, letter) local o = run.options local node, pos = letter.node, letter.pos if draws_nothing(letter) then -- It gets alpha 1 in case animate() makes it draw something. gui.set_alpha(node, 1) letter.done = true count_done(run, 1) return end if fades_in_moving(o) then gui.set_position(node, v3(pos.x - o.fadein_x, pos.y - o.fadein_y, pos.z)) gui.animate(node, "position", pos, LINEAR, o.fadein_duration) end gui.animate(node, "color.w", 1, LINEAR, o.fadein_duration, 0, function() letter.done = true count_done(run, 1) end) end -- Is this letter held back by a plain in front of it? (Not when the wait was -- already released. Not for the empty node of a block fragment, which draws nothing: -- the fragment is shown by its first node, so there is nothing left to wait for.) local function is_stop(letter) return letter.stop and not letter.released and letter.markup ~= "" end -- Starts all characters whose time (counted from the start of the run) has come. -- A letter is due at letter.time + run.shift: letter.time is its time from the speed -- and the timed waits, run.shift is what the plain waits that were released have -- added. A letter behind a plain is not started, the run waits for it -- (run.waiting = its index) until release_wait() is called. -- run.elapsed is negative while the run waits for the old text to fade out. local function start_due(run) if run.elapsed < 0 or run.waiting then return end local letters, count = run.letters, #run.letters local shift, elapsed = run.shift, run.elapsed local n = run.next while n <= count do local letter = letters[n] if letter.time + shift > elapsed then break end if is_stop(letter) then run.waiting = n break end start_letter(run, letter) n = n + 1 end run.next = n if n > count or run.waiting then stop_timer(run) end end -- One timer per run drives start_due(). Nothing to do when everything has started -- or the run waits at a plain (the clock stands still then, too). local function start_timer(run) if run.timer or run.waiting or run.next > #run.letters then return end run.timer = timer.delay(0, true, function(_, _, dt) run.elapsed = run.elapsed + dt start_due(run) end) end -- Ends the wait at a plain : the letter behind it is due right now, and the -- clock goes on from there. local function release_wait(run) local n = run.waiting run.waiting = nil local letter = run.letters[n] letter.released = true run.shift = math.max(run.elapsed, 0) - letter.time start_due(run) start_timer(run) end -- Fades in the letters run.next .. last right now, together as one group. Letters -- already fading keep going on their own. run.next is moved behind `last`. -- The letters are moved into one group node and only that node is animated (2 -- animations instead of 2 per letter). The letters themselves become fully opaque, -- the group node is the one that is transparent. There can be several groups in -- one run (Textbox:continue() finishes up to a plain only). local function finish_letters(run, last) local first = run.next run.next = last + 1 if first > last then return end local o = run.options local node = new_group_node(run.box.node, run.parent, 0) local group = { node = node, done = false } run.groups[#run.groups + 1] = group if fades_in_moving(o) then gui.set_position(node, v3(-o.fadein_x, -o.fadein_y, 0)) gui.animate(node, "position", v3(0), LINEAR, o.fadein_duration) end for n = first, last do local letter = run.letters[n] letter.done = true -- accounted for by the group below letter.group = group -- but not really shown before group.done gui.set_parent(letter.node, node, false) -- keeps the local position gui.set_alpha(letter.node, 1) end local count = last - first + 1 gui.animate(node, "color.w", 1, LINEAR, o.fadein_duration, 0, function() group.done = true count_done(run, count) end) end -------------------------------------------------------------------------------- -- Rearranging a text that is already there -------------------------------------------------------------------------------- local ANIMATE_KEYS = make_set({"scale", "scale_x", "scale_y", "width", "alignment", "tracking", "leading"}) local INSTANT = { instant = true } -- Sets or animates the position or the scale of a node. `motion` is INSTANT, or -- { easing = ..., duration = ... }. local function glide(run, node, property, target, motion) if motion.instant then gui.cancel_animations(node, property) if property == "position" then gui.set_position(node, target) else gui.set_scale(node, target) end else gui.animate(node, property, target, motion.easing, motion.duration) run.arranged = true end end -- Moves and scales the node of a letter to its placement, if that changes anything. local function move_letter(run, letter, placement, motion) if not same_vector(placement.pos, letter.pos) then glide(run, letter.node, "position", placement.pos, motion) end if not same_vector(placement.scale, letter.scale) then glide(run, letter.node, "scale", placement.scale, motion) end end -- Is every letter of the word completely faded in (and so not moved or faded by -- anything else any more)? Letters that finish_letters() put into a group count -- only after that group has arrived. local function word_is_shown(run, word) for _, n in ipairs(word.members) do local letter = run.letters[n] if not letter.done or (letter.group and not letter.group.done) then return false end end return true end -- Puts the letters of a word below one new word node, from now on only that node -- is animated by animate(), whatever the number of letters. The word node starts -- at the origin with scale 1, so the letters keep their local position and scale -- (nothing jumps, and an animation a letter may still have is not disturbed). -- Their position and scale at that moment are the reference (letter.base_pos / -- base_scale) for move_word(). The word node is a child of the run node, so fading -- out and deleting the run still takes everything with it. local function group_word(run, word) local node = new_group_node(run.box.node, run.parent, 1) word.node, word.pos, word.scale = node, v3(0), v3(1, 1, 1) for _, n in ipairs(word.members) do local letter = run.letters[n] gui.set_parent(letter.node, node, false) -- keeps the local position letter.base_pos, letter.base_scale = letter.pos, letter.scale end end -- Moves and scales a word node so that its letters end up where `placement` (the -- new placement of its first letter) says. All letters of the word are placed -- relative to each other the same way as before, only multiplied with the new -- scale, so the transformation of the first one is the one of all: the node scales -- with the ratio of the new and the old scale, and is positioned so that the first -- letter lands on its new position. Position and scale are interpolated linearly, -- like the letters would be, so with every easing the path is the one they would -- take. local function move_word(run, word, placement, motion) local first = run.letters[word.members[1]] local base_pos, base_scale = first.base_pos, first.base_scale local sx, sy = placement.scale.x / base_scale.x, placement.scale.y / base_scale.y local scale = v3(sx, sy, 1) local pos = v3( placement.pos.x - sx * base_pos.x, placement.pos.y - sy * base_pos.y, placement.pos.z - base_pos.z) if not same_vector(pos, word.pos) then glide(run, word.node, "position", pos, motion) word.pos = pos end if not same_vector(scale, word.scale) then glide(run, word.node, "scale", scale, motion) word.scale = scale end end -- The options of a run after animate() changed some of them. A given "scale" -- replaces the factors of both axes; the stored options always hold the resulting -- vector, so scale_x / scale_y from before cannot override a new "scale". local function change_options(old, changes) local scale = changes.scale if scale == nil then local current_x, current_y = resolve_scale(old.scale, old.scale_x, old.scale_y) scale = v3(current_x, current_y, 1) end local sx, sy = resolve_scale(scale, changes.scale_x, changes.scale_y) local new = merge(old) new.scale, new.scale_x, new.scale_y = v3(sx, sy, 1), nil, nil for _, key in ipairs({ "width", "alignment", "tracking", "leading" }) do if changes[key] ~= nil then new[key] = changes[key] end end return new end -------------------------------------------------------------------------------- -- Textbox -------------------------------------------------------------------------------- local Textbox = {} Textbox.__index = Textbox --- Registers a text node as a new textbox. -- @param node GUI text node (or its id string) -- @param options table, overrides the defaults for every text of this textbox -- @param listener function(textbox, text), called every time a text is done function M.new(node, options, listener) local box = setmetatable({}, Textbox) box.node = assert(resolve_node(node), "typewriter: new() needs a GUI text node") box.options = merge_options(DEFAULTS, options) box.listener = listener box.images = {} -- GUI textures made from image files, see get_image_texture() gui.set_text(box.node, "") return box end --- Starts typing a text. Text that is currently shown is faded out. -- The fadeout options used are the ones valid for this call. -- A text that is interrupted by a new one never finishes, so neither its callback -- nor the listener are called for it. -- @param text string, may contain tags -- @param options optional, overrides the textbox options for this text -- @param callback optional function(textbox, text), called once this text is done function Textbox:type(text, options, callback) text = text or "" local opts = merge_options(self.options, options) local container = self.node local fade_time = fade_out_run(self.run, opts) self.run = nil gui.set_text(container, "") local placements = compute_placements(container, text, opts) -- The run node: parent of all characters of this text. local parent = new_group_node(container, container, 1) -- The text starts after (1 - fade_cross) of the fade out of the old text. local cross = math.max(0, math.min(1, opts.fade_cross or 1)) local run = { box = self, text = text, options = opts, letters = {}, done = 0, callbacks = {}, finished = false, parent = parent, next = 1, -- index of the first letter that has not been started elapsed = -(1 - cross) * fade_time, shift = 0, -- seconds added to the time of every letter by released waits waiting = nil, -- index of the letter held back by a plain groups = {}, -- the groups made by finish_letters() } if callback then run.callbacks[1] = callback end self.run = run for _, placement in ipairs(placements) do run.letters[#run.letters + 1] = new_letter(self, parent, placement, opts) end -- Words that animate() may move with one node once all their letters are shown. run.words = find_words(placements) for _, word in ipairs(run.words) do for _, n in ipairs(word.members) do run.letters[n].word = word end end if #run.letters == 0 then -- nothing to type (empty or tags only) run.timer = timer.delay(0, false, function() run.timer = nil complete_run(run) end) return end -- Start what is due right now, a single timer takes care of the rest. start_due(run) start_timer(run) end --- Rearranges the current text smoothly, as if it had been typed with changed -- options: the new positions and scales of all nodes are calculated (new line -- breaks, new alignment ...) and every node that has to move or change its size is -- animated there with gui.animate(). -- Works at any time: characters that have not appeared yet are simply put at their -- new place, characters that are still fading in move on while they do, and -- calling it again while a rearrangement is running continues from where the -- nodes are. The changed options stay valid for this text (a later animate() builds -- on them), but not for texts typed afterwards, those use the options of the -- textbox and of the type() call as before. -- Nodes that keep their position / scale are not animated, which saves animation -- slots (every animated node needs up to two of the 1024 that Defold allows at -- once). It saves more in two ways: -- * Once all letters of a word are completely faded in, they are put below one -- word node, and only that node is moved and scaled (2 animations for the -- whole word). A word is what a line can not be broken in (see "Notes"); its -- spaces are not part of it. Letters that are still fading in, words of only -- one letter (e.g. CJK text) and spaces with tags are animated one by one. -- In right-to-left mode a word is one node anyway. -- * A space without any tag draws nothing, so it is just put at its new place. -- @param options table with any of -- scale number | vector, scales x and y (replaces an -- earlier scale_x / scale_y) -- scale_x number, x only -- scale_y number, y only -- width width of the text area (0 = no line breaks) -- alignment "left" | "right" | "center" | "justified" -- tracking number, extra space between characters -- leading number, line height factor -- scale_x / scale_y given together with scale win over it. -- @param duration seconds, default 1.0 (0 or less: change immediately) -- @param easing gui.EASING_*, default gui.EASING_LINEAR function Textbox:animate(options, duration, easing) options = options or {} for key in pairs(options) do assert(ANIMATE_KEYS[key], "typewriter: animate() does not know the option '" .. tostring(key) .. "'") end duration = duration or 1.0 easing = easing or LINEAR local run = self.run if not run then return end local old_options = run.options local new_options = change_options(old_options, options) local placements = compute_placements(self.node, run.text, new_options) local letters = run.letters assert(#placements == #letters, "typewriter: the layout changed the number of nodes") run.options = new_options local motion = (duration <= 0) and INSTANT or { easing = easing, duration = duration } -- Tracking and leading are node properties that cannot be animated: the nodes -- get the new values at once (the positions below glide as usual). if options.tracking ~= nil and options.tracking ~= old_options.tracking then for _, letter in ipairs(letters) do gui.set_tracking(letter.node, options.tracking) end end if options.leading ~= nil and options.leading ~= old_options.leading then for _, letter in ipairs(letters) do gui.set_leading(letter.node, options.leading) end end -- Words that are completely shown move as one node. This has to happen before -- the loop below, which overwrites letter.pos and letter.scale. for _, word in ipairs(run.words) do if not word.node and word_is_shown(run, word) then group_word(run, word) end if word.node then move_word(run, word, placements[word.members[1]], motion) end end for n, placement in ipairs(placements) do local letter = letters[n] -- (also the text of the nodes of block fragments, which depends on the -- line breaks: a node may start or stop drawing a fragment) if placement.markup ~= letter.markup then gui.set_text(letter.node, placement.markup) letter.markup = placement.markup make_sprites(letter, self.node, self.images) end letter.hidden = placement.hidden if n >= run.next then -- Has not started yet, so it is invisible: put it where it will start -- from (start_letter() uses letter.pos). gui.set_position(letter.node, placement.pos) gui.set_scale(letter.node, placement.scale) elseif letter.word and letter.word.node then -- moved by the node of its word (above) elseif letter.hidden then -- draws nothing, so nobody sees it move move_letter(run, letter, placement, INSTANT) else -- replaces the fade in movement of a node that still has one move_letter(run, letter, placement, motion) end letter.pos, letter.scale = placement.pos, placement.scale end end --- Deletes the GUI textures that were created for sprites with an image file as -- src. Call it when the textbox is not used any more (e.g. in final()), after the -- text is gone: sprites that still show such an image lose their texture. function Textbox:release_images() for _, id in pairs(self.images) do if id then gui.delete_texture(id) end end self.images = {} end --- If the text is still typing (or still waiting for the old text to fade out), -- fades in all characters that have not begun yet right now, together as one -- group. Characters already fading keep going on their own. -- All tags that have not been reached are skipped. Use continue() to go -- on only up to the next plain . -- If the text is already done the callback is called immediately. -- @param callback optional function(textbox, text), called when the last character -- is fully faded in function Textbox:finish(callback) local run = self.run if not run or run.finished then if callback then callback(self, run and run.text) end return end if callback then run.callbacks[#run.callbacks + 1] = callback end local count = #run.letters if count == 0 then return end -- empty text: its own timer completes it stop_timer(run) run.waiting = nil finish_letters(run, count) end -- The index of the first letter, from run.next on, that is held back by a plain -- . nil if there is none. local function find_next_stop(run) for n = run.next, #run.letters do if is_stop(run.letters[n]) then return n end end end --- The "accept" button of a dialogue box. What it does depends on the state: -- * The text waits at a plain : the typing goes on. Returns false. -- * The text is still typing: it is finished like finish() does, but only up to -- the next plain (or to the end if there is none). The textbox then waits -- at that tag, the next call goes on. Returns false. -- * The text is completely there: it fades out (with the fadeout options of this -- text) and true is returned. This is the signal that the textbox is ended, so -- the next text can be typed, or the textbox hidden. The next type() starts -- at once, whatever fade_cross is, since there is no old text to wait for any more. -- Without any text (nothing typed yet, or already faded out) it returns true. -- @return true if the text is fading out now, false if it is (still) being shown function Textbox:continue() local run = self.run if not run then return true end if run.finished then fade_out_run(run, run.options) self.run = nil return true end if run.waiting then release_wait(run) return false end local count = #run.letters if count == 0 then return false end -- empty text: its own timer completes it local stop = find_next_stop(run) stop_timer(run) if stop then finish_letters(run, stop - 1) run.waiting = stop else finish_letters(run, count) end return false end return M