diff --git a/assets/flipnote-editor/Draw.png b/assets/flipnote-editor/Draw.png new file mode 100644 index 0000000..7e46e92 Binary files /dev/null and b/assets/flipnote-editor/Draw.png differ diff --git a/assets/flipnote-editor/Draw_selected.png b/assets/flipnote-editor/Draw_selected.png new file mode 100644 index 0000000..7738f0a Binary files /dev/null and b/assets/flipnote-editor/Draw_selected.png differ diff --git a/assets/flipnote-editor/Erase.png b/assets/flipnote-editor/Erase.png new file mode 100644 index 0000000..e112afa Binary files /dev/null and b/assets/flipnote-editor/Erase.png differ diff --git a/assets/flipnote-editor/Erase_selected.png b/assets/flipnote-editor/Erase_selected.png new file mode 100644 index 0000000..91c2fbc Binary files /dev/null and b/assets/flipnote-editor/Erase_selected.png differ diff --git a/assets/flipnote-editor/ImportImage.png b/assets/flipnote-editor/ImportImage.png new file mode 100644 index 0000000..17bde14 Binary files /dev/null and b/assets/flipnote-editor/ImportImage.png differ diff --git a/assets/flipnote-editor/Paint.png b/assets/flipnote-editor/Paint.png new file mode 100644 index 0000000..69c110a Binary files /dev/null and b/assets/flipnote-editor/Paint.png differ diff --git a/assets/flipnote-editor/Paint_selected.png b/assets/flipnote-editor/Paint_selected.png new file mode 100644 index 0000000..1ddaf75 Binary files /dev/null and b/assets/flipnote-editor/Paint_selected.png differ diff --git a/assets/flipnote-editor/arabic-text.js b/assets/flipnote-editor/arabic-text.js new file mode 100644 index 0000000..977f827 --- /dev/null +++ b/assets/flipnote-editor/arabic-text.js @@ -0,0 +1,213 @@ +(function (global) { + 'use strict'; + let data, bidi, loading; + const contains = value => /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/u.test(value); + function configure(value, factory) { data = value; bidi = factory(); } + function load() { + if (data && bidi) return Promise.resolve(); + if (!loading) loading = Promise.all([ + fetch('./assets/flipnote-editor/arabic-text.json?v=1.1').then(response => { + if (!response.ok) throw new Error('The Arabic text font could not load.'); + return response.json(); + }), + new Promise((resolve, reject) => { + if (global.bidi_js) { resolve(global.bidi_js); return; } + const script = document.createElement('script'); + script.src = './assets/flipnote-editor/bidi.min.js?v=1.0.3'; + script.onload = () => resolve(global.bidi_js); + script.onerror = reject; + document.head.append(script); + }), + ]).then(([value, factory]) => configure(value, factory)); + return loading; + } + + // Joining forms follow arabic-reshaper's MIT-licensed algorithm. + // Keep the same forms, ligatures and mark handling as the PPM renderer. + function reshape(text) { + text = text.normalize('NFC'); + const output = [], marks = new Map(), letters = data.letters; + for (const char of text) { + if (data.marks.includes(char)) { + const position = output.length - 1; + if (!marks.has(position)) marks.set(position, []); + marks.get(position).push(char); + } else if (!letters[char]) output.push([char, -1]); + else if (!output.length) output.push([char, 0]); + else { + const previous = output[output.length - 1]; + const a = letters[char], b = letters[previous[0]]; + if (previous[1] === -1 || !(a[3] || a[2]) || !(b[1] || b[2]) || (previous[1] === 3 && !b[2])) output.push([char, 0]); + else { previous[1] = previous[1] === 0 ? 1 : 2; output.push([char, 3]); } + } + if (output.length > 1 && output[output.length - 2][0] === '\u200d') output.splice(output.length - 2, 1); + } + if (output.length && output[output.length - 1][0] === '\u200d') output.pop(); + const clean = Array.from(text).filter(char => !data.marks.includes(char)).join(''); + for (const [word, forms] of data.ligatures) { + let start = clean.indexOf(word); + while (start !== -1) { + const end = start + word.length, first = output[start]?.[1], last = output[end - 1]?.[1]; + const form = first === 0 || first === 1 ? (last === 0 || last === 3 ? 0 : 1) : (last === 0 || last === 3 ? 3 : 2); + if (forms[form] && output[start]) { + output[start] = [forms[form], -1]; + for (let i = start + 1; i < end; i++) output[i] = ['', -1]; + } + start = clean.indexOf(word, end); + } + } + return (marks.get(-1) || []).join('') + output.map(([char, form], index) => (char && form !== -1 && form !== 0 ? letters[char][form] : char) + (marks.get(index) || []).join('')).join(''); + } + function visualClusters(text) { + const clusters = []; + for (const char of reshape(text)) { + if (data.marks.includes(char) && clusters.length) clusters[clusters.length - 1].marks += char; + else clusters.push({char, marks: ''}); + } + const base = clusters.map(cluster => cluster.char).join(''); + const levels = bidi.getEmbeddingLevels(base); + const order = bidi.getReorderedIndices(base, levels); + const display = bidi.getReorderedString(base, levels); + return order.map((index, position) => ({char: display[position], marks: clusters[index].marks})); + } + function visual(text) { + return text.split('\n').map(line => visualClusters(line).map(cluster => cluster.char + cluster.marks).join('')).join('\n'); + } + function markGlyphs(marks) { + const glyphs = []; + for (let index = 0; index < marks.length;) { + const match = data.mark_ligatures.find(([text]) => marks.startsWith(text, index)); + if (match) { glyphs.push(data.glyphs[match[1]]); index += match[0].length; } + else glyphs.push(data.glyphs[marks[index++]]); + } + return glyphs; + } + function placeMark(bitmap, base, previous) { + let position; + for (const lookup of data.positioning) { + const reference = lookup.marks[bitmap.name]; + if (!reference) continue; + const targets = lookup.kind === 'mark' ? previous.slice().reverse() : [base]; + for (const target of targets) { + let anchors = lookup.bases[target.bitmap.name]; + if (!anchors) continue; + if (lookup.kind === 'ligature') anchors = anchors[0]; + const anchor = anchors[reference[0]]; + if (!anchor) continue; + position = {bitmap, + x: target.x + Math.round((anchor[0] - reference[1]) * data.pixel_scale), + y: target.y - Math.round((anchor[1] - reference[2]) * data.pixel_scale)}; + break; + } + } + return position || {bitmap, x: base.x + Math.floor(base.bitmap.advance / 2), y: base.y}; + } + function line(text) { + const glyphs = []; + let pen = 0; + for (const cluster of visualClusters(text)) { + if (cluster.char === '\u200c' || cluster.char === '\u200d') continue; + const bitmap = data.glyphs[cluster.char]; + if (!bitmap) throw new Error(`The text fonts do not include “${cluster.char}”. Replace it to save this subtitle.`); + const base = {bitmap, x: pen, y: 0}; + glyphs.push(base); + const marks = []; + for (const mark of markGlyphs(cluster.marks)) { + const placed = placeMark(mark, base, marks); + glyphs.push(placed); marks.push(placed); + } + pen += bitmap.advance; + } + let left = 0, right = pen, above = 11, below = 1; + for (const glyph of glyphs) { + for (const [_dy, dx, count] of glyph.bitmap.spans) { + left = Math.min(left, glyph.x + dx); right = Math.max(right, glyph.x + dx + count); + } + above = Math.max(above, -glyph.bitmap.top - glyph.y); + below = Math.max(below, glyph.bitmap.bottom + glyph.y); + } + if (left) glyphs.forEach(glyph => { glyph.x -= left; }); + return {glyphs, advance: pen, width: right - left, above, height: Math.max(14, above + below + 2)}; + } + function paint(mask, width, height, glyph, x, baseline) { + for (const [dy, dx, count] of glyph.spans) { + const y = baseline + dy; + if (y < 0 || y >= height) continue; + const start = Math.max(0, x + dx), end = Math.min(width, x + dx + count); + if (end > start) mask.fill(1, y * width + start, y * width + end); + } + } + function rasterize(glyphs, width, height) { + const mask = new Uint8Array(width * height); + for (const glyph of glyphs) paint(mask, width, height, glyph.bitmap, glyph.x, glyph.baseline); + return mask; + } + function wrap(text, width) { + const lines = []; + for (const paragraph of text.split('\n')) { + let current = ''; + for (const word of paragraph.split(' ')) { + if (current && line((current + ' ' + word).trim()).width > width) { lines.push(current); current = ''; } + for (const char of graphemes((current ? ' ' : '') + word)) { + if (current && line(current + char).width > width) { lines.push(current.trimEnd()); current = ''; } + current += char; + } + } + lines.push(current.trim()); + } + return lines; + } + function graphemes(text) { + const groups = []; + for (const char of text) { + if (groups.length && (/\p{M}/u.test(char) || char === '\u200c' || char === '\u200d')) groups[groups.length - 1] += char; + else groups.push(char); + } + return groups; + } + function layoutText(value, anchor, width, caretIndex = value.length) { + const glyphs = []; + const rtl = bidi.getEmbeddingLevels(value).paragraphs[0]?.level % 2 === 1; + const available = Math.max(1, rtl ? anchor.x : width - anchor.x); + function editorLines(text) { + const result = []; + for (const paragraph of text.replace(/\t/g, ' ').split('\n')) { + let current = ''; + for (const char of graphemes(paragraph)) { + if (current && line(current + char).width > available) { result.push(current); current = ''; } + current += char; + } + result.push(current); + } + return result; + } + const lines = editorLines(value); + let top = anchor.y, caret = {x: anchor.x, y: top, height: 12}; + const prefix = value.slice(0, caretIndex); + const before = editorLines(prefix); + lines.forEach((text, index) => { + const shaped = line(text); + let x = rtl ? anchor.x - shaped.width : anchor.x; + for (const placed of shaped.glyphs) glyphs.push({bitmap: placed.bitmap, x: x + placed.x, baseline: top + shaped.above + placed.y}); + if (index === before.length - 1) { + const advance = line(before[index] || '').advance; + caret = {x: rtl ? anchor.x - advance : anchor.x + advance, y: top, height: shaped.height - 2}; + } + top += shaped.height; + }); + return {glyphs, caret, arabic: true}; + } + function subtitleMask(value) { + const lines = wrap(value, 244); + if (lines.length > 2) throw new Error('Keep each subtitle to two lines. Split longer text into another subtitle.'); + const shaped = lines.map(line), glyphs = []; + let top = 192 - 4 - shaped.reduce((sum, value) => sum + value.height, 0) + 1; + for (const value of shaped) { + let x = Math.floor((256 - value.width) / 2); + for (const placed of value.glyphs) glyphs.push({bitmap: placed.bitmap, x: x + placed.x, baseline: top + value.above + placed.y}); + top += value.height; + } + return {mask: rasterize(glyphs, 256, 192), lines}; + } + global.FlipnoteArabicText = {load, configure, contains, visual, line, layoutText, rasterize, subtitleMask}; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/assets/flipnote-editor/arabic-text.json b/assets/flipnote-editor/arabic-text.json new file mode 100644 index 0000000..d4bc433 --- /dev/null +++ b/assets/flipnote-editor/arabic-text.json @@ -0,0 +1 @@ +{"version":2,"glyphs":{" ":{"name":"space","advance":5,"spans":[],"top":0,"bottom":0},"!":{"name":"exclam","advance":2,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"\"":{"name":"quotedbl","advance":6,"spans":[[-9,2,1],[-9,4,1],[-8,1,1],[-8,3,1],[-7,0,1],[-7,2,1]],"top":-9,"bottom":-6},"#":{"name":"numbersign","advance":8,"spans":[[-9,3,1],[-9,5,1],[-8,3,1],[-8,5,1],[-7,1,6],[-6,2,1],[-6,4,1],[-5,2,1],[-5,4,1],[-4,2,1],[-4,4,1],[-3,0,6],[-2,1,1],[-2,3,1],[-1,1,1],[-1,3,1]],"top":-9,"bottom":0},"%":{"name":"percent","advance":8,"spans":[[-9,1,1],[-9,5,1],[-8,0,1],[-8,2,1],[-8,5,1],[-7,0,1],[-7,2,1],[-7,4,1],[-6,1,1],[-6,4,1],[-5,3,1],[-4,2,1],[-4,5,1],[-3,2,1],[-3,4,1],[-3,6,1],[-2,1,1],[-2,4,1],[-2,6,1],[-1,1,1],[-1,5,1]],"top":-9,"bottom":0},"'":{"name":"quotesingle","advance":2,"spans":[[-9,0,1],[-8,0,1]],"top":-9,"bottom":-7},"(":{"name":"parenleft","advance":4,"spans":[[-9,2,1],[-8,1,1],[-7,1,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,1,1],[-2,1,1],[-1,2,1]],"top":-9,"bottom":0},")":{"name":"parenright","advance":4,"spans":[[-9,0,1],[-8,1,1],[-7,1,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,1,1],[-2,1,1],[-1,0,1]],"top":-9,"bottom":0},"*":{"name":"asterisk","advance":8,"spans":[[-8,3,1],[-7,0,1],[-7,3,1],[-7,6,1],[-6,1,1],[-6,3,1],[-6,5,1],[-5,2,3],[-4,1,1],[-4,3,1],[-4,5,1],[-3,0,1],[-3,3,1],[-3,6,1],[-2,3,1]],"top":-8,"bottom":-1},"+":{"name":"plus","advance":6,"spans":[[-7,2,1],[-6,2,1],[-5,0,5],[-4,2,1],[-3,2,1]],"top":-7,"bottom":-2},",":{"name":"comma","advance":3,"spans":[[-1,1,1],[0,0,1]],"top":-1,"bottom":1},"-":{"name":"hyphen","advance":6,"spans":[[-5,0,5]],"top":-5,"bottom":-4},".":{"name":"period","advance":2,"spans":[[-1,0,1]],"top":-1,"bottom":0},"/":{"name":"slash","advance":4,"spans":[[-9,2,1],[-8,2,1],[-7,2,1],[-6,1,1],[-5,1,1],[-4,1,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"0":{"name":"zero","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,3,2],[-6,0,1],[-6,3,2],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,2],[-4,4,1],[-3,0,2],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"1":{"name":"one","advance":3,"spans":[[-9,0,2],[-8,1,1],[-7,1,1],[-6,1,1],[-5,1,1],[-4,1,1],[-3,1,1],[-2,1,1],[-1,1,1]],"top":-9,"bottom":0},"2":{"name":"two","advance":6,"spans":[[-9,0,4],[-8,4,1],[-7,4,1],[-6,4,1],[-5,1,3],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,5]],"top":-9,"bottom":0},"3":{"name":"three","advance":6,"spans":[[-9,0,4],[-8,4,1],[-7,4,1],[-6,4,1],[-5,1,3],[-4,4,1],[-3,4,1],[-2,4,1],[-1,0,4]],"top":-9,"bottom":0},"4":{"name":"four","advance":7,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,1,5],[-3,4,1],[-2,4,1],[-1,4,1]],"top":-9,"bottom":0},"5":{"name":"five","advance":6,"spans":[[-9,0,5],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,4],[-4,4,1],[-3,4,1],[-2,4,1],[-1,0,4]],"top":-9,"bottom":0},"6":{"name":"six","advance":6,"spans":[[-9,1,3],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"7":{"name":"seven","advance":6,"spans":[[-9,0,5],[-8,4,1],[-7,4,1],[-6,3,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"8":{"name":"eight","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,1,3],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"9":{"name":"nine","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,1,4],[-4,4,1],[-3,4,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},":":{"name":"colon","advance":2,"spans":[[-7,0,1],[-3,0,1]],"top":-7,"bottom":-2},";":{"name":"semicolon","advance":3,"spans":[[-7,1,1],[-3,1,1],[-2,0,1]],"top":-7,"bottom":-1},"<":{"name":"less","advance":4,"spans":[[-7,2,1],[-6,1,1],[-5,0,1],[-4,1,1],[-3,2,1]],"top":-7,"bottom":-2},"=":{"name":"equal","advance":6,"spans":[[-6,0,5],[-4,0,5]],"top":-6,"bottom":-3},">":{"name":"greater","advance":4,"spans":[[-7,0,1],[-6,1,1],[-5,2,1],[-4,1,1],[-3,0,1]],"top":-7,"bottom":-2},"?":{"name":"question","advance":5,"spans":[[-9,0,3],[-8,3,1],[-7,3,1],[-6,2,1],[-5,1,1],[-4,1,1],[-2,1,1],[-1,1,1]],"top":-9,"bottom":0},"[":{"name":"bracketleft","advance":4,"spans":[[-9,0,3],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,3]],"top":-9,"bottom":0},"\\":{"name":"backslash","advance":4,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,1,1],[-5,1,1],[-4,1,1],[-3,2,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"]":{"name":"bracketright","advance":4,"spans":[[-9,0,3],[-8,2,1],[-7,2,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,0,3]],"top":-9,"bottom":0},"^":{"name":"asciicircum","advance":4,"spans":[[-9,1,1],[-8,0,1],[-8,2,1]],"top":-9,"bottom":-7},"_":{"name":"underscore","advance":6,"spans":[[-1,0,5]],"top":-1,"bottom":0},"{":{"name":"braceleft","advance":5,"spans":[[-9,2,2],[-8,1,1],[-7,1,1],[-6,1,1],[-5,0,1],[-4,1,1],[-3,1,1],[-2,1,1],[-1,2,2]],"top":-9,"bottom":0},"|":{"name":"bar","advance":2,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"}":{"name":"braceright","advance":5,"spans":[[-9,0,2],[-8,2,1],[-7,2,1],[-6,2,1],[-5,3,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,0,2]],"top":-9,"bottom":0},"¡":{"name":"exclamdown","advance":2,"spans":[[-9,0,1],[-8,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"«":{"name":"guillemotleft","advance":11,"spans":[[-10,3,2],[-10,9,1],[-9,3,2],[-9,9,1],[-8,1,2],[-8,7,1],[-7,1,2],[-7,7,1],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,3,2],[-4,9,1],[-3,3,2],[-3,9,1]],"top":-10,"bottom":-2},"­":{"name":"uni00AD","advance":8,"spans":[[-8,1,5],[-7,1,6],[-6,1,6],[-5,1,6]],"top":-8,"bottom":-4},"·":{"name":"periodcentered","advance":4,"spans":[[-6,1,2],[-5,1,2]],"top":-6,"bottom":-4},"»":{"name":"guillemotright","advance":11,"spans":[[-10,1,2],[-10,7,1],[-9,1,2],[-9,7,1],[-8,3,2],[-8,9,1],[-7,3,2],[-7,9,1],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,1,2],[-4,7,1],[-3,1,2],[-3,7,1]],"top":-10,"bottom":-2},"¿":{"name":"questiondown","advance":5,"spans":[[-9,2,1],[-8,2,1],[-6,2,1],[-5,2,1],[-4,1,1],[-3,0,1],[-2,0,1],[-1,1,3]],"top":-9,"bottom":0},"×":{"name":"multiply","advance":6,"spans":[[-7,0,1],[-7,4,1],[-6,1,1],[-6,3,1],[-5,2,1],[-4,1,1],[-4,3,1],[-3,0,1],[-3,4,1]],"top":-7,"bottom":-2},"÷":{"name":"divide","advance":6,"spans":[[-7,2,1],[-5,0,5],[-3,2,1]],"top":-7,"bottom":-2},"،":{"name":"uni060C","advance":6,"spans":[[-4,1,2],[-3,1,2],[-2,1,4],[-1,1,4]],"top":-4,"bottom":0},"ؕ":{"name":"uni0615","advance":0,"spans":[[-15,-4,2],[-14,-4,2],[-13,-4,2],[-12,-4,2],[-11,-2,4],[-10,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-15,"bottom":-6},"؛":{"name":"uni061B","advance":4,"spans":[[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-2,1,2],[-1,1,2]],"top":-11,"bottom":0},"؟":{"name":"uni061F","advance":9,"spans":[[-13,3,4],[-12,3,4],[-11,1,7],[-10,1,7],[-9,1,2],[-9,7,1],[-8,1,4],[-7,1,4],[-6,3,4],[-5,3,4],[-4,5,2],[-3,5,2],[-2,5,2],[-1,5,2],[2,5,2],[3,5,2]],"top":-13,"bottom":4},"ء":{"name":"uni0621","advance":9,"spans":[[-10,5,3],[-9,5,3],[-8,3,5],[-7,3,5],[-6,3,2],[-5,3,2],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-10,"bottom":0},"آ":{"name":"uni0622","advance":6,"spans":[[-19,3,2],[-18,3,2],[-17,1,2],[-16,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-19,"bottom":0},"أ":{"name":"uni0623","advance":6,"spans":[[-21,3,2],[-20,3,2],[-19,1,2],[-18,1,2],[-17,1,4],[-16,1,4],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-21,"bottom":0},"ؤ":{"name":"uni0624","advance":9,"spans":[[-17,5,2],[-16,5,2],[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-17,"bottom":6},"إ":{"name":"uni0625","advance":6,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2],[0,3,2],[1,3,2],[2,1,2],[3,1,2],[4,1,4],[5,1,4]],"top":-15,"bottom":6},"ئ":{"name":"uni0626","advance":15,"spans":[[-11,3,2],[-10,3,2],[-9,1,2],[-9,9,3],[-8,1,4],[-8,7,7],[-7,1,4],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-11,"bottom":6},"ا":{"name":"uni0627","advance":6,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-15,"bottom":0},"ب":{"name":"uni0628","advance":15,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11],[2,9,1],[3,9,1]],"top":-8,"bottom":4},"ة":{"name":"uni0629","advance":9,"spans":[[-13,1,2],[-13,5,2],[-12,1,2],[-12,5,1],[-9,3,4],[-8,2,5],[-7,1,7],[-6,1,2],[-6,6,2],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-13,"bottom":0},"ت":{"name":"uni062A","advance":15,"spans":[[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11]],"top":-11,"bottom":0},"ث":{"name":"uni062B","advance":15,"spans":[[-13,9,1],[-12,9,1],[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11]],"top":-13,"bottom":0},"ج":{"name":"uni062C","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,7],[2,10,2],[3,1,7],[3,10,2],[4,3,5],[5,3,5]],"top":-10,"bottom":6},"ح":{"name":"uni062D","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-10,"bottom":6},"خ":{"name":"uni062E","advance":13,"spans":[[-13,5,2],[-12,5,2],[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"د":{"name":"uni062F","advance":8,"spans":[[-10,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,6],[-3,1,6],[-2,1,4],[-1,1,4]],"top":-10,"bottom":0},"ذ":{"name":"uni0630","advance":8,"spans":[[-13,3,2],[-12,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,6],[-3,1,6],[-2,1,4],[-1,1,4]],"top":-13,"bottom":0},"ر":{"name":"uni0631","advance":6,"spans":[[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,2],[-3,3,2],[-2,3,2],[-1,3,2],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-7,"bottom":6},"ز":{"name":"uni0632","advance":6,"spans":[[-11,3,2],[-10,3,2],[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,2],[-3,3,2],[-2,3,2],[-1,3,2],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-11,"bottom":6},"س":{"name":"uni0633","advance":17,"spans":[[-10,15,1],[-9,14,2],[-8,7,1],[-8,11,1],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,9],[-3,3,2],[-3,7,9],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ش":{"name":"uni0634","advance":17,"spans":[[-13,10,2],[-12,10,2],[-11,9,1],[-11,12,2],[-10,9,1],[-10,12,2],[-9,14,2],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,9],[-3,3,2],[-3,7,9],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ص":{"name":"uni0635","advance":21,"spans":[[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,13],[-3,3,2],[-3,7,13],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-9,"bottom":6},"ض":{"name":"uni0636","advance":21,"spans":[[-13,12,2],[-12,13,1],[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,13],[-3,3,2],[-3,7,13],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ط":{"name":"uni0637","advance":17,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,15],[-3,1,15],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ظ":{"name":"uni0638","advance":17,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-13,9,1],[-12,3,4],[-12,9,1],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,15],[-3,1,15],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ع":{"name":"uni0639","advance":13,"spans":[[-10,5,3],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-5,5,2],[-4,3,9],[-3,3,9],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-10,"bottom":6},"غ":{"name":"uni063A","advance":13,"spans":[[-13,5,2],[-12,5,2],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-5,5,2],[-4,3,9],[-3,3,9],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ـ":{"name":"uni0640","advance":4,"spans":[[-4,0,5],[-3,0,5],[-2,0,5],[-1,0,5]],"top":-4,"bottom":0},"ف":{"name":"uni0641","advance":19,"spans":[[-13,14,2],[-12,14,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,17],[-3,1,17],[-2,3,15],[-1,3,15]],"top":-13,"bottom":0},"ق":{"name":"uni0642","advance":13,"spans":[[-13,7,1],[-13,10,2],[-12,7,1],[-12,10,2],[-9,7,5],[-8,5,7],[-7,5,7],[-6,5,2],[-6,10,2],[-5,5,2],[-5,10,2],[-4,1,2],[-4,5,7],[-3,1,2],[-3,5,7],[-2,1,2],[-2,5,7],[-1,1,2],[-1,5,7],[0,1,2],[0,10,2],[1,1,2],[1,10,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ك":{"name":"uni0643","advance":15,"spans":[[-15,12,2],[-14,12,2],[-13,10,4],[-12,10,4],[-11,7,1],[-11,12,2],[-10,7,1],[-10,12,2],[-9,5,2],[-9,12,2],[-8,3,7],[-8,12,2],[-7,3,7],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,9],[-1,3,9]],"top":-15,"bottom":0},"ل":{"name":"uni0644","advance":9,"spans":[[-15,7,1],[-14,7,1],[-13,5,3],[-12,5,3],[-11,7,1],[-10,7,1],[-9,7,1],[-8,7,1],[-7,7,1],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,1],[-3,3,2],[-3,7,1],[-2,1,2],[-2,7,1],[-1,1,2],[-1,7,1],[0,1,2],[0,7,1],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-15,"bottom":6},"م":{"name":"uni0645","advance":11,"spans":[[-10,5,3],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-6,9,1],[-5,5,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,2],[3,1,2],[4,1,2],[5,1,2]],"top":-10,"bottom":6},"ن":{"name":"uni0646","advance":11,"spans":[[-8,5,2],[-7,5,2],[-6,9,1],[-5,9,1],[-4,3,2],[-4,9,1],[-3,3,2],[-3,9,1],[-2,1,2],[-2,9,1],[-1,1,2],[-1,9,1],[0,1,2],[0,9,1],[1,1,2],[1,9,1],[2,1,9],[3,1,9],[4,3,5],[5,3,5]],"top":-8,"bottom":6},"ه":{"name":"uni0647","advance":9,"spans":[[-10,3,3],[-9,3,4],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-10,"bottom":0},"و":{"name":"uni0648","advance":9,"spans":[[-10,3,5],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ى":{"name":"uni0649","advance":15,"spans":[[-10,9,3],[-9,9,3],[-8,7,7],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-10,"bottom":6},"ي":{"name":"uni064A","advance":15,"spans":[[-10,9,3],[-9,9,3],[-8,7,7],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-10,"bottom":10},"ً":{"name":"uni064B","advance":0,"spans":[[-11,-2,4],[-10,-2,4],[-8,-2,4],[-7,-2,4]],"top":-11,"bottom":-6},"ٌ":{"name":"uni064C","advance":0,"spans":[[-13,0,2],[-12,0,2],[-11,-2,4],[-10,-2,4],[-9,0,2],[-8,-2,4],[-7,-2,4]],"top":-13,"bottom":-6},"ٍ":{"name":"uni064D","advance":0,"spans":[[-2,-2,4],[-1,-2,4],[2,-2,4],[3,-2,4]],"top":-2,"bottom":4},"َ":{"name":"uni064E","advance":0,"spans":[[-8,-2,4],[-7,-2,4]],"top":-8,"bottom":-6},"ُ":{"name":"uni064F","advance":0,"spans":[[-10,0,2],[-9,0,2],[-8,-2,4],[-7,-2,4]],"top":-10,"bottom":-6},"ِ":{"name":"uni0650","advance":0,"spans":[[-2,-2,4],[-1,-2,4]],"top":-2,"bottom":0},"ّ":{"name":"uni0651","advance":0,"spans":[[-10,-4,2],[-10,0,2],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-10,"bottom":-6},"ْ":{"name":"uni0652","advance":0,"spans":[[-11,-2,2],[-10,-4,6],[-9,-4,2],[-9,0,2],[-8,-2,2],[-7,-2,2]],"top":-11,"bottom":-6},"ٓ":{"name":"uni0653","advance":0,"spans":[[-10,0,2],[-9,0,2],[-8,-2,2],[-7,-2,2]],"top":-10,"bottom":-6},"ٔ":{"name":"uni0654","advance":0,"spans":[[-11,0,2],[-10,0,2],[-9,-2,2],[-8,-2,4],[-7,-2,4]],"top":-11,"bottom":-6},"ٖ":{"name":"uni0656","advance":0,"spans":[[-2,0,2],[-1,0,2],[0,0,2],[1,0,2],[2,0,2],[3,0,2]],"top":-2,"bottom":4},"ٗ":{"name":"uni0657","advance":0,"spans":[[-10,-2,4],[-9,-2,4],[-8,-2,2],[-7,-2,2]],"top":-10,"bottom":-6},"٘":{"name":"uni0658","advance":0,"spans":[],"top":0,"bottom":0},"٠":{"name":"uni0660","advance":6,"spans":[[-6,1,4],[-5,1,4],[-4,1,4],[-3,1,4]],"top":-6,"bottom":-2},"١":{"name":"uni0661","advance":4,"spans":[[-13,1,2],[-12,1,2],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-13,"bottom":0},"٢":{"name":"uni0662","advance":8,"spans":[[-14,1,1],[-14,5,1],[-13,1,2],[-13,5,2],[-12,1,2],[-12,5,2],[-11,1,2],[-11,5,2],[-10,1,6],[-9,1,6],[-8,1,6],[-7,1,6],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-14,"bottom":0},"٣":{"name":"uni0663","advance":11,"spans":[[-14,1,1],[-14,5,1],[-14,9,1],[-13,1,2],[-13,5,2],[-13,9,1],[-12,1,2],[-12,5,2],[-12,9,1],[-11,1,2],[-11,5,2],[-11,9,1],[-10,1,9],[-9,1,9],[-8,1,9],[-7,1,9],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-14,"bottom":0},"٤":{"name":"uni0664","advance":8,"spans":[[-13,3,4],[-12,3,4],[-11,1,2],[-10,1,2],[-9,3,4],[-8,1,6],[-7,1,6],[-6,1,2],[-5,1,2],[-4,1,5],[-3,1,6],[-2,1,6],[-1,1,6]],"top":-13,"bottom":0},"٥":{"name":"uni0665","advance":8,"spans":[[-13,3,2],[-12,3,2],[-11,1,6],[-10,1,6],[-9,1,2],[-9,5,2],[-8,1,2],[-8,5,2],[-7,1,2],[-7,5,2],[-6,1,2],[-6,5,2],[-5,1,2],[-5,5,2],[-4,1,6],[-3,1,6],[-2,1,6],[-1,1,6]],"top":-13,"bottom":0},"٦":{"name":"uni0666","advance":8,"spans":[[-13,1,6],[-12,1,6],[-11,1,6],[-10,1,6],[-9,5,2],[-8,5,2],[-7,5,2],[-6,5,2],[-5,5,2],[-4,5,2],[-3,5,2],[-2,5,2],[-1,5,2]],"top":-13,"bottom":0},"٧":{"name":"uni0667","advance":8,"spans":[[-13,1,2],[-13,5,2],[-12,1,2],[-12,5,2],[-11,1,2],[-11,5,2],[-10,1,2],[-10,5,2],[-9,1,2],[-9,5,2],[-8,1,2],[-8,5,2],[-7,1,2],[-7,5,2],[-6,1,6],[-5,1,6],[-4,1,6],[-3,1,6],[-2,3,2],[-1,3,2]],"top":-13,"bottom":0},"٨":{"name":"uni0668","advance":8,"spans":[[-13,3,2],[-12,3,2],[-11,1,6],[-10,1,6],[-9,1,6],[-8,1,6],[-7,1,2],[-7,5,2],[-6,1,2],[-6,5,2],[-5,1,2],[-5,5,2],[-4,1,2],[-4,5,2],[-3,1,2],[-3,5,2],[-2,1,2],[-2,5,2],[-1,1,2],[-1,5,2]],"top":-13,"bottom":0},"٩":{"name":"uni0669","advance":9,"spans":[[-13,3,5],[-12,3,5],[-11,1,2],[-11,7,1],[-10,1,7],[-9,1,7],[-8,1,7],[-7,1,7],[-6,7,1],[-5,7,1],[-4,7,1],[-3,7,1],[-2,7,1],[-1,7,1]],"top":-13,"bottom":0},"٪":{"name":"uni066A","advance":11,"spans":[[-13,7,1],[-12,7,1],[-11,7,1],[-10,1,2],[-10,5,3],[-9,1,2],[-9,5,2],[-8,5,2],[-7,5,2],[-6,5,2],[-6,9,1],[-5,5,2],[-5,8,2],[-4,3,2],[-3,3,2],[-2,3,2],[-1,3,2]],"top":-13,"bottom":0},"٫":{"name":"uni066B","advance":4,"spans":[[-2,1,2],[-1,1,2],[0,1,2],[1,1,2]],"top":-2,"bottom":2},"٬":{"name":"uni066C","advance":4,"spans":[[-2,1,2],[-1,1,2],[0,1,2],[1,1,2]],"top":-2,"bottom":2},"٭":{"name":"uni066D","advance":11,"spans":[[-10,1,2],[-10,5,2],[-10,9,1],[-9,1,2],[-9,5,2],[-9,9,1],[-8,3,5],[-7,3,5],[-6,5,2],[-5,5,2],[-4,3,5],[-3,3,5],[-2,1,2],[-2,5,2],[-2,9,1],[-1,1,2],[-1,5,2],[-1,9,1]],"top":-10,"bottom":0},"ٰ":{"name":"uni0670","advance":0,"spans":[[-12,0,2],[-11,0,2],[-10,0,2],[-9,0,2],[-8,0,2],[-7,0,2]],"top":-12,"bottom":-6},"پ":{"name":"uni067E","advance":15,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11],[2,7,1],[2,10,2],[3,7,1],[3,10,2],[4,9,1],[5,9,1]],"top":-8,"bottom":6},"چ":{"name":"uni0686","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,4],[2,7,1],[2,10,2],[3,1,4],[3,7,1],[3,10,2],[4,3,4],[4,9,1],[5,3,4],[5,9,1]],"top":-10,"bottom":6},"ڤ":{"name":"uni06A4","advance":19,"spans":[[-15,14,2],[-14,14,2],[-13,12,2],[-13,16,2],[-12,12,2],[-12,16,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,17],[-3,1,17],[-2,3,15],[-1,3,15]],"top":-15,"bottom":0},"۔":{"name":"uni06D4","advance":4,"spans":[[-2,1,2],[-1,1,2]],"top":-2,"bottom":0},"۰":{"name":"uni06F0","advance":6,"spans":[],"top":0,"bottom":0},"۱":{"name":"uni06F1","advance":7,"spans":[],"top":0,"bottom":0},"۲":{"name":"uni06F2","advance":13,"spans":[],"top":0,"bottom":0},"۳":{"name":"uni06F3","advance":14,"spans":[],"top":0,"bottom":0},"۴":{"name":"uni06F4","advance":8,"spans":[],"top":0,"bottom":0},"۵":{"name":"uni06F5","advance":14,"spans":[],"top":0,"bottom":0},"۶":{"name":"uni06F6","advance":13,"spans":[],"top":0,"bottom":0},"۷":{"name":"uni06F7","advance":11,"spans":[],"top":0,"bottom":0},"۸":{"name":"uni06F8","advance":13,"spans":[],"top":0,"bottom":0},"۹":{"name":"uni06F9","advance":13,"spans":[],"top":0,"bottom":0},"–":{"name":"endash","advance":9,"spans":[[-8,1,7],[-7,1,7],[-6,1,7],[-5,1,7]],"top":-8,"bottom":-4},"—":{"name":"emdash","advance":11,"spans":[[-8,1,9],[-7,1,9],[-6,1,9],[-5,1,9]],"top":-8,"bottom":-4},"‘":{"name":"quoteleft","advance":3,"spans":[[-9,1,1],[-8,0,1]],"top":-9,"bottom":-7},"’":{"name":"quoteright","advance":3,"spans":[[-9,1,1],[-8,0,1]],"top":-9,"bottom":-7},"‚":{"name":"quotesinglbase","advance":4,"spans":[[-2,1,2],[-1,1,2],[0,1,2],[1,1,2]],"top":-2,"bottom":2},"“":{"name":"quotedblleft","advance":5,"spans":[[-9,1,1],[-9,3,1],[-8,0,1],[-8,2,1]],"top":-9,"bottom":-7},"”":{"name":"quotedblright","advance":5,"spans":[[-9,1,1],[-9,3,1],[-8,0,1],[-8,2,1]],"top":-9,"bottom":-7},"„":{"name":"quotedblbase","advance":8,"spans":[[-2,1,2],[-2,5,2],[-1,1,2],[-1,5,2],[0,1,2],[0,5,2],[1,1,2],[1,5,1]],"top":-2,"bottom":2},"•":{"name":"bullet","advance":5,"spans":[[-6,1,2],[-5,0,4],[-4,0,4],[-3,1,2]],"top":-6,"bottom":-2},"…":{"name":"ellipsis","advance":6,"spans":[[-1,0,1],[-1,2,1],[-1,4,1]],"top":-1,"bottom":0},"‰":{"name":"perthousand","advance":19,"spans":[[-13,3,2],[-13,9,1],[-12,3,2],[-12,8,2],[-11,1,2],[-11,5,2],[-11,8,2],[-10,1,9],[-9,3,2],[-9,7,1],[-8,7,1],[-7,7,1],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,5,2],[-4,9,1],[-4,12,2],[-4,16,2],[-3,5,2],[-3,8,2],[-3,12,2],[-3,16,2],[-2,5,2],[-2,10,2],[-2,14,2],[-1,5,2],[-1,10,2],[-1,14,2]],"top":-13,"bottom":0},"‹":{"name":"guilsinglleft","advance":6,"spans":[[-10,3,2],[-9,3,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,3,2],[-3,3,2]],"top":-10,"bottom":-2},"›":{"name":"guilsinglright","advance":6,"spans":[[-10,1,2],[-9,1,2],[-8,3,2],[-7,3,2],[-6,3,2],[-5,3,2],[-4,1,2],[-3,1,2]],"top":-10,"bottom":-2},"−":{"name":"minus","advance":10,"spans":[[-8,1,7],[-7,1,7],[-6,1,7],[-5,1,7]],"top":-8,"bottom":-4},"≠":{"name":"notequal","advance":11,"spans":[[-12,7,1],[-11,7,1],[-10,1,9],[-9,1,9],[-8,1,9],[-7,1,9],[-6,5,2],[-5,5,2],[-4,1,9],[-3,1,9],[-2,1,9],[-1,1,9],[0,3,2],[1,3,2]],"top":-12,"bottom":2},"ﭗ":{"name":"uniFB57","advance":14,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,14],[-3,1,14],[-2,3,12],[-1,3,12],[2,7,1],[2,10,2],[3,7,1],[3,10,2],[4,9,1],[5,9,1]],"top":-8,"bottom":6},"ﭘ":{"name":"uniFB58","advance":9,"spans":[[-8,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,8],[-3,0,8],[-2,0,8],[-1,0,8],[2,2,2],[2,6,2],[3,2,2],[3,6,1],[4,4,2],[5,4,2]],"top":-8,"bottom":6},"ﭙ":{"name":"uniFB59","advance":8,"spans":[[-8,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9],[2,2,2],[2,6,2],[3,2,2],[3,6,1],[4,4,2],[5,4,2]],"top":-8,"bottom":6},"ﭫ":{"name":"uniFB6B","advance":18,"spans":[[-15,14,2],[-14,14,2],[-13,12,2],[-13,16,2],[-12,12,2],[-12,16,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,18],[-3,1,18],[-2,3,16],[-1,3,16]],"top":-15,"bottom":0},"ﭬ":{"name":"uniFB6C","advance":10,"spans":[[-15,6,2],[-14,6,1],[-13,4,2],[-13,8,1],[-12,4,2],[-12,8,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-15,"bottom":0},"ﭭ":{"name":"uniFB6D","advance":9,"spans":[[-15,6,2],[-14,6,1],[-13,4,2],[-13,8,1],[-12,4,2],[-12,8,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,11],[-3,0,11],[-2,0,11],[-1,0,11]],"top":-15,"bottom":0},"ﭻ":{"name":"uniFB7B","advance":12,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,11],[-3,3,11],[-2,1,7],[-2,10,4],[-1,1,7],[-1,10,4],[0,1,2],[1,1,2],[2,1,4],[2,7,1],[2,10,2],[3,1,4],[3,7,1],[3,10,2],[4,3,4],[4,9,1],[5,3,4],[5,9,1]],"top":-10,"bottom":6},"ﭼ":{"name":"uniFB7C","advance":14,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9],[2,4,2],[2,8,1],[3,4,2],[3,8,1],[4,6,2],[5,6,1]],"top":-10,"bottom":6},"ﭽ":{"name":"uniFB7D","advance":13,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,15],[-3,0,15],[-2,0,9],[-2,11,4],[-1,0,9],[-1,11,4],[2,4,2],[2,8,1],[3,4,2],[3,8,1],[4,6,2],[5,6,1]],"top":-10,"bottom":6},"ﰉ":{"name":"uniFC09","advance":19,"spans":[[-8,16,2],[-7,16,2],[-6,7,1],[-6,16,2],[-5,7,1],[-5,16,2],[-4,3,2],[-4,7,11],[-3,3,2],[-3,7,11],[-2,1,2],[-2,9,9],[-1,1,2],[-1,9,9],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[3,1,13],[3,16,2],[4,3,9],[5,3,9]],"top":-8,"bottom":6},"ﰊ":{"name":"uniFC0A","advance":19,"spans":[[-8,16,2],[-7,16,2],[-6,7,1],[-6,16,2],[-5,7,1],[-5,16,2],[-4,3,2],[-4,7,11],[-3,3,2],[-3,7,11],[-2,1,2],[-2,9,9],[-1,1,2],[-1,9,9],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[3,1,13],[3,16,2],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-8,"bottom":10},"ﱙ":{"name":"uniFC59","advance":23,"spans":[[-8,20,1],[-7,20,2],[-6,7,1],[-6,20,2],[-5,7,1],[-5,20,2],[-4,3,2],[-4,7,15],[-3,3,2],[-3,7,15],[-2,1,2],[-2,9,13],[-1,1,2],[-1,9,13],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[2,20,2],[3,1,13],[3,16,2],[3,20,2],[4,3,9],[5,3,9]],"top":-8,"bottom":6},"ﱚ":{"name":"uniFC5A","advance":23,"spans":[[-8,20,1],[-7,20,2],[-6,7,1],[-6,20,2],[-5,7,1],[-5,20,2],[-4,3,2],[-4,7,15],[-3,3,2],[-3,7,15],[-2,1,2],[-2,9,13],[-1,1,2],[-1,9,13],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[2,20,2],[3,1,13],[3,16,2],[3,20,2],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-8,"bottom":10},"ﱮ":{"name":"uniFC6E","advance":18,"spans":[[-8,16,2],[-7,16,2],[-6,7,1],[-6,16,2],[-5,7,1],[-5,16,2],[-4,3,2],[-4,7,12],[-3,3,2],[-3,7,12],[-2,1,2],[-2,9,10],[-1,1,2],[-1,9,10],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[3,1,13],[3,16,2],[4,3,9],[5,3,9]],"top":-8,"bottom":6},"ﱯ":{"name":"uniFC6F","advance":18,"spans":[[-8,16,2],[-7,16,2],[-6,7,1],[-6,16,2],[-5,7,1],[-5,16,2],[-4,3,2],[-4,7,12],[-3,3,2],[-3,7,12],[-2,1,2],[-2,9,10],[-1,1,2],[-1,9,10],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[3,1,13],[3,16,2],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-8,"bottom":10},"ﲕ":{"name":"uniFC95","advance":22,"spans":[[-8,20,1],[-7,20,2],[-6,7,1],[-6,20,2],[-5,7,1],[-5,20,2],[-4,3,2],[-4,7,16],[-3,3,2],[-3,7,16],[-2,1,2],[-2,9,14],[-1,1,2],[-1,9,14],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[2,20,2],[3,1,13],[3,16,2],[3,20,2],[4,3,9],[5,3,9]],"top":-8,"bottom":6},"ﲖ":{"name":"uniFC96","advance":22,"spans":[[-8,20,1],[-7,20,2],[-6,7,1],[-6,20,2],[-5,7,1],[-5,20,2],[-4,3,2],[-4,7,16],[-3,3,2],[-3,7,16],[-2,1,2],[-2,9,14],[-1,1,2],[-1,9,14],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[2,16,2],[2,20,2],[3,1,13],[3,16,2],[3,20,2],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-8,"bottom":10},"﴾":{"name":"uniFD3E","advance":13,"spans":[[-15,9,3],[-14,9,3],[-13,7,5],[-12,7,5],[-11,7,1],[-10,7,1],[-9,7,1],[-8,3,2],[-8,7,1],[-7,3,2],[-7,7,1],[-6,1,2],[-6,5,2],[-5,1,2],[-5,5,2],[-4,3,2],[-4,7,1],[-3,3,2],[-3,7,1],[-2,7,1],[-1,7,1],[0,7,1],[1,7,1],[2,7,5],[3,7,5],[4,9,3],[5,9,3]],"top":-15,"bottom":6},"﴿":{"name":"uniFD3F","advance":13,"spans":[[-15,1,4],[-14,1,4],[-13,1,6],[-12,1,6],[-11,5,2],[-10,5,2],[-9,5,2],[-8,5,2],[-8,9,1],[-7,5,2],[-7,9,1],[-6,7,1],[-6,10,2],[-5,7,1],[-5,10,2],[-4,5,2],[-4,9,1],[-3,5,2],[-3,9,1],[-2,5,2],[-1,5,2],[0,5,2],[1,5,2],[2,1,6],[3,1,6],[4,1,4],[5,1,4]],"top":-15,"bottom":6},"ﷲ":{"name":"uniFDF2","advance":27,"spans":[[-28,12,2],[-27,12,2],[-26,12,2],[-25,12,2],[-24,12,2],[-21,11,1],[-21,14,2],[-20,10,2],[-20,14,2],[-19,10,6],[-18,10,6],[-15,12,2],[-15,18,2],[-15,22,2],[-14,12,2],[-14,18,2],[-14,22,2],[-13,10,4],[-13,16,4],[-13,22,4],[-12,10,4],[-12,16,4],[-12,22,3],[-11,7,1],[-11,12,2],[-11,18,2],[-11,22,2],[-10,6,2],[-10,12,2],[-10,18,2],[-10,22,2],[-9,3,5],[-9,12,2],[-9,18,2],[-9,22,2],[-8,1,7],[-8,12,2],[-8,18,2],[-8,22,2],[-7,1,7],[-7,12,2],[-7,18,2],[-7,22,2],[-6,1,2],[-6,7,1],[-6,12,2],[-6,18,2],[-6,22,2],[-5,1,2],[-5,7,1],[-5,12,2],[-5,18,2],[-5,22,2],[-4,1,19],[-4,22,2],[-3,1,19],[-3,22,2],[-2,3,4],[-2,9,11],[-2,22,2],[-1,3,4],[-1,9,11],[-1,22,2]],"top":-28,"bottom":0},"ﺂ":{"name":"uniFE82","advance":5,"spans":[[-19,3,2],[-18,3,2],[-17,1,2],[-16,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,5],[-3,1,5],[-2,1,5],[-1,1,5]],"top":-19,"bottom":0},"ﺄ":{"name":"uniFE84","advance":5,"spans":[[-21,3,2],[-20,3,2],[-19,1,2],[-18,1,2],[-17,1,4],[-16,1,4],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,5],[-3,1,5],[-2,1,5],[-1,1,5]],"top":-21,"bottom":0},"ﺆ":{"name":"uniFE86","advance":9,"spans":[[-17,5,2],[-16,5,2],[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,9],[-3,1,9],[-2,1,9],[-1,1,9],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-17,"bottom":6},"ﺈ":{"name":"uniFE88","advance":5,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,2],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,5],[-3,1,5],[-2,1,5],[-1,1,5],[0,3,2],[1,3,2],[2,1,2],[3,1,2],[4,1,4]],"top":-15,"bottom":5},"ﺊ":{"name":"uniFE8A","advance":12,"spans":[[-11,3,2],[-10,3,2],[-9,1,2],[-8,1,4],[-7,1,4],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,7],[-3,3,2],[-3,7,7],[-2,1,2],[-2,9,5],[-1,1,2],[-1,9,5],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-11,"bottom":6},"ﺋ":{"name":"uniFE8B","advance":7,"spans":[[-15,4,2],[-14,4,2],[-13,2,2],[-12,2,2],[-11,2,4],[-10,2,4],[-7,4,2],[-6,4,2],[-5,4,2],[-4,0,6],[-3,0,6],[-2,0,6],[-1,0,6]],"top":-15,"bottom":0},"ﺌ":{"name":"uniFE8C","advance":6,"spans":[[-15,4,2],[-14,4,2],[-13,2,2],[-12,2,2],[-11,2,4],[-10,2,4],[-7,4,2],[-6,4,2],[-5,4,2],[-4,0,7],[-3,0,7],[-2,0,7],[-1,0,7]],"top":-15,"bottom":0},"ﺎ":{"name":"uniFE8E","advance":5,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,5],[-3,1,5],[-2,1,5],[-1,1,5]],"top":-15,"bottom":0},"ﺐ":{"name":"uniFE90","advance":14,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,14],[-3,1,14],[-2,3,12],[-1,3,12],[2,9,1],[3,9,1]],"top":-8,"bottom":4},"ﺑ":{"name":"uniFE91","advance":5,"spans":[[-8,2,1],[-7,2,2],[-6,2,2],[-5,2,2],[-4,0,4],[-3,0,4],[-2,0,4],[-1,0,4],[2,2,2],[3,2,2]],"top":-8,"bottom":4},"ﺒ":{"name":"uniFE92","advance":4,"spans":[[-8,2,1],[-7,2,2],[-6,2,2],[-5,2,2],[-4,0,5],[-3,0,5],[-2,0,5],[-1,0,5],[2,2,2],[3,2,2]],"top":-8,"bottom":4},"ﺔ":{"name":"uniFE94","advance":9,"spans":[[-13,1,2],[-13,5,2],[-12,1,2],[-12,5,2],[-11,7,1],[-10,6,2],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,9],[-3,1,9],[-2,3,4],[-1,3,4]],"top":-13,"bottom":0},"ﺖ":{"name":"uniFE96","advance":14,"spans":[[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,14],[-3,1,14],[-2,3,12],[-1,3,12]],"top":-11,"bottom":0},"ﺗ":{"name":"uniFE97","advance":9,"spans":[[-11,2,2],[-11,6,2],[-10,2,2],[-10,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,8],[-3,0,8],[-2,0,8],[-1,0,8]],"top":-11,"bottom":0},"ﺘ":{"name":"uniFE98","advance":8,"spans":[[-11,2,2],[-11,6,2],[-10,2,2],[-10,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-11,"bottom":0},"ﺚ":{"name":"uniFE9A","advance":14,"spans":[[-13,9,1],[-12,9,1],[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,14],[-3,1,14],[-2,3,12],[-1,3,12]],"top":-13,"bottom":0},"ﺛ":{"name":"uniFE9B","advance":9,"spans":[[-13,4,2],[-12,4,2],[-11,2,2],[-11,6,2],[-10,2,2],[-10,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,8],[-3,0,8],[-2,0,8],[-1,0,8]],"top":-13,"bottom":0},"ﺜ":{"name":"uniFE9C","advance":8,"spans":[[-13,4,2],[-12,4,2],[-11,2,2],[-11,6,2],[-10,2,2],[-10,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﺞ":{"name":"uniFE9E","advance":12,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,11],[-3,3,11],[-2,1,7],[-2,10,4],[-1,1,7],[-1,10,4],[0,1,2],[1,1,2],[2,1,7],[2,10,2],[3,1,7],[3,10,2],[4,3,5],[5,3,5]],"top":-10,"bottom":6},"ﺟ":{"name":"uniFE9F","advance":14,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9],[2,6,2],[3,6,1]],"top":-10,"bottom":4},"ﺠ":{"name":"uniFEA0","advance":13,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,15],[-3,0,15],[-2,0,9],[-2,11,4],[-1,0,9],[-1,11,4],[2,6,2],[3,6,1]],"top":-10,"bottom":4},"ﺢ":{"name":"uniFEA2","advance":12,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,11],[-3,3,11],[-2,1,7],[-2,10,4],[-1,1,7],[-1,10,4],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-10,"bottom":6},"ﺣ":{"name":"uniFEA3","advance":14,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9]],"top":-10,"bottom":0},"ﺤ":{"name":"uniFEA4","advance":13,"spans":[[-10,4,5],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,15],[-3,0,15],[-2,0,9],[-2,11,4],[-1,0,9],[-1,11,4]],"top":-10,"bottom":0},"ﺦ":{"name":"uniFEA6","advance":12,"spans":[[-13,5,2],[-12,5,2],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,11],[-3,3,11],[-2,1,7],[-2,10,4],[-1,1,7],[-1,10,4],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ﺧ":{"name":"uniFEA7","advance":14,"spans":[[-13,6,2],[-12,6,1],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﺨ":{"name":"uniFEA8","advance":13,"spans":[[-13,6,2],[-12,6,1],[-9,4,5],[-8,2,11],[-7,2,11],[-6,9,2],[-5,9,2],[-4,0,15],[-3,0,15],[-2,0,9],[-2,11,4],[-1,0,9],[-1,11,4]],"top":-13,"bottom":0},"ﺪ":{"name":"uniFEAA","advance":7,"spans":[[-10,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,7],[-3,1,7],[-2,1,4],[-1,1,4]],"top":-10,"bottom":0},"ﺬ":{"name":"uniFEAC","advance":7,"spans":[[-13,3,2],[-12,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,7],[-3,1,7],[-2,1,4],[-1,1,4]],"top":-13,"bottom":0},"ﺮ":{"name":"uniFEAE","advance":5,"spans":[[-8,3,1],[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,3],[-3,3,3],[-2,3,3],[-1,3,3],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-8,"bottom":6},"ﺰ":{"name":"uniFEB0","advance":5,"spans":[[-11,3,2],[-10,3,2],[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,3],[-3,3,3],[-2,3,3],[-1,3,3],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-11,"bottom":6},"ﺲ":{"name":"uniFEB2","advance":16,"spans":[[-10,15,1],[-9,14,2],[-8,7,1],[-8,11,1],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,10],[-3,3,2],[-3,7,10],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ﺳ":{"name":"uniFEB3","advance":12,"spans":[[-10,10,1],[-9,9,2],[-8,2,2],[-8,6,1],[-8,9,2],[-7,2,2],[-7,6,2],[-7,9,2],[-6,2,2],[-6,6,2],[-6,9,2],[-5,2,2],[-5,6,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9]],"top":-10,"bottom":0},"ﺴ":{"name":"uniFEB4","advance":11,"spans":[[-10,10,1],[-9,9,2],[-8,2,2],[-8,6,1],[-8,9,2],[-7,2,2],[-7,6,2],[-7,9,2],[-6,2,2],[-6,6,2],[-6,9,2],[-5,2,2],[-5,6,2],[-5,9,2],[-4,0,13],[-3,0,13],[-2,0,9],[-1,0,9]],"top":-10,"bottom":0},"ﺶ":{"name":"uniFEB6","advance":16,"spans":[[-13,10,2],[-12,10,2],[-11,9,1],[-11,12,2],[-10,9,1],[-10,12,2],[-9,14,2],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,10],[-3,3,2],[-3,7,10],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ﺷ":{"name":"uniFEB7","advance":12,"spans":[[-13,6,2],[-12,6,2],[-11,4,2],[-11,8,1],[-10,9,2],[-9,9,2],[-8,2,2],[-8,6,1],[-8,9,2],[-7,2,2],[-7,6,2],[-7,9,2],[-6,2,2],[-6,6,2],[-6,9,2],[-5,2,2],[-5,6,2],[-5,9,2],[-4,0,11],[-3,0,11],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﺸ":{"name":"uniFEB8","advance":11,"spans":[[-13,6,2],[-12,6,1],[-11,4,2],[-11,8,1],[-10,4,2],[-10,8,1],[-9,9,2],[-8,9,2],[-7,2,2],[-7,6,2],[-7,9,2],[-6,2,2],[-6,6,2],[-6,9,2],[-5,2,2],[-5,6,2],[-5,9,2],[-4,0,13],[-3,0,13],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﺺ":{"name":"uniFEBA","advance":20,"spans":[[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,14],[-3,3,2],[-3,7,14],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-9,"bottom":6},"ﺻ":{"name":"uniFEBB","advance":16,"spans":[[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,2],[-2,4,9],[-1,0,2],[-1,4,9]],"top":-9,"bottom":0},"ﺼ":{"name":"uniFEBC","advance":15,"spans":[[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,2],[-2,4,9],[-1,0,2],[-1,4,9]],"top":-9,"bottom":0},"ﺾ":{"name":"uniFEBE","advance":20,"spans":[[-13,12,2],[-12,13,1],[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,14],[-3,3,2],[-3,7,14],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ﺿ":{"name":"uniFEBF","advance":16,"spans":[[-13,8,1],[-12,8,1],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,2],[-2,4,9],[-1,0,2],[-1,4,9]],"top":-13,"bottom":0},"ﻀ":{"name":"uniFEC0","advance":15,"spans":[[-13,8,1],[-12,8,1],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,2],[-2,4,9],[-1,0,2],[-1,4,9]],"top":-13,"bottom":0},"ﻂ":{"name":"uniFEC2","advance":16,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,16],[-3,1,16],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ﻃ":{"name":"uniFEC3","advance":16,"spans":[[-15,2,2],[-14,2,2],[-13,2,4],[-12,2,4],[-11,2,2],[-10,2,2],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻄ":{"name":"uniFEC4","advance":15,"spans":[[-15,2,2],[-14,2,2],[-13,2,4],[-12,2,4],[-11,2,2],[-10,2,2],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻆ":{"name":"uniFEC6","advance":16,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-13,9,1],[-12,3,4],[-12,9,1],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,16],[-3,1,16],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ﻇ":{"name":"uniFEC7","advance":16,"spans":[[-15,2,2],[-14,2,2],[-13,2,4],[-13,8,1],[-12,2,4],[-12,8,1],[-11,2,2],[-10,2,2],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻈ":{"name":"uniFEC8","advance":15,"spans":[[-15,2,2],[-14,2,2],[-13,2,4],[-13,8,1],[-12,2,4],[-12,8,1],[-11,2,2],[-10,2,2],[-9,2,2],[-9,6,9],[-8,2,13],[-7,2,13],[-6,2,2],[-6,13,2],[-5,2,2],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻊ":{"name":"uniFECA","advance":10,"spans":[[-10,3,7],[-9,3,7],[-8,5,5],[-7,5,5],[-6,7,2],[-5,7,1],[-4,3,9],[-3,3,9],[-2,1,6],[-2,9,3],[-1,1,6],[-1,9,3],[0,1,2],[1,1,2],[2,1,9],[3,1,9],[4,3,5],[5,3,5]],"top":-10,"bottom":6},"ﻋ":{"name":"uniFECB","advance":10,"spans":[[-10,2,4],[-9,2,4],[-8,2,5],[-7,2,6],[-6,2,2],[-5,2,2],[-4,0,9],[-3,0,9],[-2,0,8],[-1,0,7]],"top":-10,"bottom":0},"ﻌ":{"name":"uniFECC","advance":9,"spans":[[-10,2,7],[-9,2,7],[-8,4,5],[-7,4,5],[-6,6,2],[-5,6,2],[-4,0,11],[-3,0,11],[-2,0,6],[-2,8,3],[-1,0,6],[-1,8,3]],"top":-10,"bottom":0},"ﻎ":{"name":"uniFECE","advance":10,"spans":[[-13,7,1],[-12,7,1],[-10,3,7],[-9,3,7],[-8,5,5],[-7,5,5],[-6,7,2],[-5,7,1],[-4,3,9],[-3,3,9],[-2,1,6],[-2,9,3],[-1,1,6],[-1,9,3],[0,1,2],[1,1,2],[2,1,9],[3,1,9],[4,3,5],[5,3,5]],"top":-13,"bottom":6},"ﻏ":{"name":"uniFECF","advance":10,"spans":[[-13,2,2],[-12,2,2],[-9,2,4],[-8,2,5],[-7,2,6],[-6,2,2],[-5,2,2],[-4,0,9],[-3,0,9],[-2,0,8],[-1,0,7]],"top":-13,"bottom":0},"ﻐ":{"name":"uniFED0","advance":9,"spans":[[-13,6,2],[-12,6,1],[-10,2,7],[-9,2,7],[-8,4,5],[-7,4,5],[-6,6,2],[-5,6,2],[-4,0,11],[-3,0,11],[-2,0,6],[-2,8,3],[-1,0,6],[-1,8,3]],"top":-13,"bottom":0},"ﻒ":{"name":"uniFED2","advance":18,"spans":[[-13,14,2],[-12,14,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,18],[-3,1,18],[-2,3,16],[-1,3,16]],"top":-13,"bottom":0},"ﻓ":{"name":"uniFED3","advance":10,"spans":[[-13,6,2],[-12,6,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﻔ":{"name":"uniFED4","advance":9,"spans":[[-13,6,2],[-12,6,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,11],[-3,0,11],[-2,0,11],[-1,0,11]],"top":-13,"bottom":0},"ﻖ":{"name":"uniFED6","advance":12,"spans":[[-13,7,1],[-13,10,2],[-12,7,1],[-12,10,2],[-9,7,5],[-8,5,7],[-7,5,7],[-6,5,2],[-6,10,2],[-5,5,2],[-5,10,2],[-4,1,2],[-4,5,8],[-3,1,2],[-3,5,9],[-2,1,2],[-2,5,9],[-1,1,2],[-1,5,9],[0,1,2],[0,10,2],[1,1,2],[1,10,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ﻗ":{"name":"uniFED7","advance":10,"spans":[[-13,4,2],[-13,8,1],[-12,4,2],[-12,8,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-13,"bottom":0},"ﻘ":{"name":"uniFED8","advance":9,"spans":[[-13,4,2],[-13,8,1],[-12,4,2],[-12,8,1],[-9,4,5],[-8,2,7],[-7,2,7],[-6,2,2],[-6,8,1],[-5,2,2],[-5,8,1],[-4,0,11],[-3,0,11],[-2,0,11],[-1,0,11]],"top":-13,"bottom":0},"ﻚ":{"name":"uniFEDA","advance":14,"spans":[[-15,12,2],[-14,12,2],[-13,10,4],[-12,10,4],[-11,7,1],[-11,12,2],[-10,7,1],[-10,12,2],[-9,5,2],[-9,12,2],[-8,3,7],[-8,12,2],[-7,3,7],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,14],[-3,1,14],[-2,3,9],[-1,3,9]],"top":-15,"bottom":0},"ﻛ":{"name":"uniFEDB","advance":16,"spans":[[-15,4,7],[-14,4,7],[-13,2,9],[-12,2,9],[-11,2,2],[-10,2,2],[-9,2,11],[-8,2,13],[-7,2,13],[-6,13,2],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻜ":{"name":"uniFEDC","advance":15,"spans":[[-15,4,7],[-14,4,7],[-13,2,9],[-12,2,9],[-11,2,2],[-10,2,2],[-9,2,11],[-8,2,13],[-7,2,13],[-6,13,2],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,13],[-1,0,13]],"top":-15,"bottom":0},"ﻞ":{"name":"uniFEDE","advance":9,"spans":[[-15,7,1],[-14,7,1],[-13,5,3],[-12,5,3],[-11,7,1],[-10,7,1],[-9,7,1],[-8,7,1],[-7,7,1],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,3],[-3,3,2],[-3,7,3],[-2,1,2],[-2,7,3],[-1,1,2],[-1,7,3],[0,1,2],[0,7,1],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-15,"bottom":6},"ﻟ":{"name":"uniFEDF","advance":7,"spans":[[-15,4,2],[-14,4,2],[-13,2,4],[-12,2,4],[-11,4,2],[-10,4,2],[-9,4,2],[-8,4,2],[-7,4,2],[-6,4,2],[-5,4,2],[-4,0,6],[-3,0,6],[-2,0,6],[-1,0,6]],"top":-15,"bottom":0},"ﻠ":{"name":"uniFEE0","advance":6,"spans":[[-15,4,2],[-14,4,2],[-13,2,4],[-12,2,4],[-11,4,2],[-10,4,2],[-9,4,2],[-8,4,2],[-7,4,2],[-6,4,2],[-5,4,2],[-4,0,7],[-3,0,7],[-2,0,7],[-1,0,7]],"top":-15,"bottom":0},"ﻢ":{"name":"uniFEE2","advance":10,"spans":[[-10,5,3],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-6,9,1],[-5,5,2],[-5,9,1],[-4,3,9],[-3,3,9],[-2,1,11],[-1,1,11],[0,1,2],[1,1,2],[2,1,2],[3,1,2],[4,1,2],[5,1,2]],"top":-10,"bottom":6},"ﻣ":{"name":"uniFEE3","advance":9,"spans":[[-10,2,4],[-9,2,4],[-8,2,5],[-7,2,6],[-6,2,2],[-6,6,2],[-5,2,2],[-5,6,2],[-4,0,8],[-3,0,8],[-2,0,8],[-1,0,8]],"top":-10,"bottom":0},"ﻤ":{"name":"uniFEE4","advance":8,"spans":[[-10,2,4],[-9,2,4],[-8,2,5],[-7,2,6],[-6,2,2],[-6,6,2],[-5,2,2],[-5,6,2],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-10,"bottom":0},"ﻦ":{"name":"uniFEE6","advance":10,"spans":[[-8,5,2],[-7,5,2],[-6,9,1],[-5,9,1],[-4,3,2],[-4,9,3],[-3,3,2],[-3,9,3],[-2,1,2],[-2,9,3],[-1,1,2],[-1,9,3],[0,1,2],[0,9,1],[1,1,2],[1,9,1],[2,1,9],[3,1,9],[4,3,5],[5,3,5]],"top":-8,"bottom":6},"ﻧ":{"name":"uniFEE7","advance":5,"spans":[[-11,2,2],[-10,2,2],[-7,2,2],[-6,2,2],[-5,2,2],[-4,0,4],[-3,0,4],[-2,0,4],[-1,0,4]],"top":-11,"bottom":0},"ﻨ":{"name":"uniFEE8","advance":4,"spans":[[-11,2,2],[-10,2,2],[-7,2,2],[-6,2,2],[-5,2,2],[-4,0,5],[-3,0,5],[-2,0,5],[-1,0,5]],"top":-11,"bottom":0},"ﻪ":{"name":"uniFEEA","advance":9,"spans":[[-12,7,1],[-11,7,1],[-10,3,5],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,9],[-3,1,9],[-2,3,4],[-1,3,4]],"top":-12,"bottom":0},"ﻫ":{"name":"uniFEEB","advance":16,"spans":[[-10,4,9],[-9,4,9],[-8,2,13],[-7,2,13],[-6,4,2],[-6,8,1],[-6,13,2],[-5,4,2],[-5,8,1],[-5,13,2],[-4,0,15],[-3,0,15],[-2,0,13],[-1,0,13]],"top":-10,"bottom":0},"ﻬ":{"name":"uniFEEC","advance":15,"spans":[[-10,4,9],[-9,4,9],[-8,2,13],[-7,2,13],[-6,4,2],[-6,8,1],[-6,13,2],[-5,4,2],[-5,8,1],[-5,13,2],[-4,0,16],[-3,0,16],[-2,0,16],[-1,0,16]],"top":-10,"bottom":0},"ﻮ":{"name":"uniFEEE","advance":9,"spans":[[-10,3,5],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,9],[-3,1,9],[-2,1,9],[-1,1,9],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ﻰ":{"name":"uniFEF0","advance":12,"spans":[[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,7],[-3,3,2],[-3,7,7],[-2,1,2],[-2,9,5],[-1,1,2],[-1,9,5],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-6,"bottom":6},"ﻲ":{"name":"uniFEF2","advance":12,"spans":[[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,7],[-3,3,2],[-3,7,7],[-2,1,2],[-2,9,5],[-1,1,2],[-1,9,5],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-6,"bottom":10},"ﻳ":{"name":"uniFEF3","advance":9,"spans":[[-8,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,8],[-3,0,8],[-2,0,8],[-1,0,8],[2,2,2],[2,6,2],[3,2,2],[3,6,1]],"top":-8,"bottom":4},"ﻴ":{"name":"uniFEF4","advance":8,"spans":[[-8,6,1],[-7,6,2],[-6,6,2],[-5,6,2],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9],[2,2,2],[2,6,2],[3,2,2],[3,6,1]],"top":-8,"bottom":4},"ﻵ":{"name":"uniFEF5","advance":11,"spans":[[-19,3,2],[-18,3,2],[-17,1,2],[-16,1,2],[-15,3,2],[-15,9,1],[-14,3,2],[-14,9,1],[-13,1,4],[-13,7,3],[-12,1,4],[-12,7,3],[-11,3,2],[-11,9,1],[-10,3,7],[-9,5,4],[-8,3,7],[-7,3,7],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,3,7],[-1,3,7]],"top":-19,"bottom":0},"ﻶ":{"name":"uniFEF6","advance":10,"spans":[[-19,5,2],[-18,5,2],[-17,3,2],[-16,3,2],[-15,9,1],[-14,9,1],[-13,3,2],[-13,7,3],[-12,3,2],[-12,7,3],[-11,1,4],[-11,9,1],[-10,1,4],[-10,9,1],[-9,3,2],[-9,9,1],[-8,3,2],[-8,9,1],[-7,3,2],[-7,9,1],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,1,11],[-3,1,11],[-2,3,5],[-1,3,5]],"top":-19,"bottom":0},"ﻷ":{"name":"uniFEF7","advance":11,"spans":[[-21,3,2],[-20,3,2],[-19,1,2],[-18,1,2],[-17,1,4],[-16,1,4],[-15,9,1],[-14,9,1],[-13,1,4],[-13,7,3],[-12,1,4],[-12,7,3],[-11,3,2],[-11,9,1],[-10,3,7],[-9,5,3],[-8,3,7],[-7,3,7],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,3,7],[-1,3,7]],"top":-21,"bottom":0},"ﻸ":{"name":"uniFEF8","advance":10,"spans":[[-21,5,2],[-20,5,2],[-19,3,2],[-18,3,2],[-17,3,4],[-16,3,4],[-15,9,1],[-14,9,1],[-13,3,2],[-13,7,3],[-12,3,2],[-12,7,3],[-11,1,4],[-11,9,1],[-10,1,4],[-10,9,1],[-9,3,2],[-9,9,1],[-8,3,2],[-8,9,1],[-7,3,2],[-7,9,1],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,1,11],[-3,1,11],[-2,3,5],[-1,3,5]],"top":-21,"bottom":0},"ﻹ":{"name":"uniFEF9","advance":11,"spans":[[-15,3,2],[-15,9,1],[-14,3,2],[-14,9,1],[-13,1,4],[-13,7,3],[-12,1,4],[-12,7,3],[-11,3,2],[-11,9,1],[-9,5,3],[-8,3,7],[-7,3,7],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,3,7],[-1,3,7],[0,5,2],[1,5,2],[2,3,2],[3,3,2],[4,3,4],[5,3,4]],"top":-15,"bottom":6},"ﻺ":{"name":"uniFEFA","advance":10,"spans":[[-15,9,1],[-14,9,1],[-13,3,2],[-13,7,3],[-12,3,2],[-12,7,3],[-11,1,4],[-11,9,1],[-10,1,4],[-10,9,1],[-9,3,2],[-9,9,1],[-8,3,2],[-8,9,1],[-7,3,2],[-7,9,1],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,1,11],[-3,1,11],[-2,3,5],[-1,3,5],[0,3,2],[1,3,2],[2,1,2],[3,1,2],[4,1,4],[5,1,4]],"top":-15,"bottom":6},"ﻻ":{"name":"uniFEFB","advance":11,"spans":[[-15,3,2],[-15,9,1],[-14,3,2],[-14,9,1],[-13,1,4],[-13,7,3],[-12,1,4],[-12,7,3],[-11,3,2],[-11,9,1],[-9,5,3],[-8,3,7],[-7,3,7],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,3,7],[-1,3,7]],"top":-15,"bottom":0},"ﻼ":{"name":"uniFEFC","advance":10,"spans":[[-15,9,1],[-14,9,1],[-13,3,2],[-13,7,3],[-12,3,2],[-12,7,3],[-11,1,4],[-11,9,1],[-10,1,4],[-10,9,1],[-9,3,2],[-9,9,1],[-8,3,2],[-8,9,1],[-7,3,2],[-7,9,1],[-6,3,2],[-6,9,1],[-5,3,2],[-5,9,1],[-4,1,11],[-3,1,11],[-2,3,5],[-1,3,5]],"top":-15,"bottom":0},"ﺀ":{"name":"uni0621","advance":9,"spans":[[-10,5,3],[-9,5,3],[-8,3,5],[-7,3,5],[-6,3,2],[-5,3,2],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-10,"bottom":0},"ﺁ":{"name":"uni0622","advance":6,"spans":[[-19,3,2],[-18,3,2],[-17,1,2],[-16,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-19,"bottom":0},"ﺃ":{"name":"uni0623","advance":6,"spans":[[-21,3,2],[-20,3,2],[-19,1,2],[-18,1,2],[-17,1,4],[-16,1,4],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-21,"bottom":0},"ﺅ":{"name":"uni0624","advance":9,"spans":[[-17,5,2],[-16,5,2],[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-17,"bottom":6},"ﺇ":{"name":"uni0625","advance":6,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2],[0,3,2],[1,3,2],[2,1,2],[3,1,2],[4,1,4],[5,1,4]],"top":-15,"bottom":6},"ﺉ":{"name":"uni0626","advance":15,"spans":[[-11,3,2],[-10,3,2],[-9,1,2],[-9,9,3],[-8,1,4],[-8,7,7],[-7,1,4],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-11,"bottom":6},"ﺍ":{"name":"uni0627","advance":6,"spans":[[-15,1,2],[-14,1,2],[-13,1,4],[-12,1,4],[-11,1,2],[-10,1,2],[-9,1,2],[-8,1,2],[-7,1,2],[-6,1,2],[-5,1,2],[-4,1,2],[-3,1,2],[-2,1,2],[-1,1,2]],"top":-15,"bottom":0},"ﺏ":{"name":"uni0628","advance":15,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11],[2,9,1],[3,9,1]],"top":-8,"bottom":4},"ﺓ":{"name":"uni0629","advance":9,"spans":[[-13,1,2],[-13,5,2],[-12,1,2],[-12,5,1],[-9,3,4],[-8,2,5],[-7,1,7],[-6,1,2],[-6,6,2],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-13,"bottom":0},"ﺕ":{"name":"uni062A","advance":15,"spans":[[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11]],"top":-11,"bottom":0},"ﺙ":{"name":"uni062B","advance":15,"spans":[[-13,9,1],[-12,9,1],[-11,7,1],[-11,10,2],[-10,7,1],[-10,10,2],[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11]],"top":-13,"bottom":0},"ﺝ":{"name":"uni062C","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,7],[2,10,2],[3,1,7],[3,10,2],[4,3,5],[5,3,5]],"top":-10,"bottom":6},"ﺡ":{"name":"uni062D","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-10,"bottom":6},"ﺥ":{"name":"uni062E","advance":13,"spans":[[-13,5,2],[-12,5,2],[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ﺩ":{"name":"uni062F","advance":8,"spans":[[-10,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,6],[-3,1,6],[-2,1,4],[-1,1,4]],"top":-10,"bottom":0},"ﺫ":{"name":"uni0630","advance":8,"spans":[[-13,3,2],[-12,3,2],[-9,3,2],[-8,3,4],[-7,3,4],[-6,5,2],[-5,5,2],[-4,1,6],[-3,1,6],[-2,1,4],[-1,1,4]],"top":-13,"bottom":0},"ﺭ":{"name":"uni0631","advance":6,"spans":[[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,2],[-3,3,2],[-2,3,2],[-1,3,2],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-7,"bottom":6},"ﺯ":{"name":"uni0632","advance":6,"spans":[[-11,3,2],[-10,3,2],[-7,3,2],[-6,3,2],[-5,3,2],[-4,3,2],[-3,3,2],[-2,3,2],[-1,3,2],[0,3,2],[1,3,2],[2,-1,6],[3,-1,6],[4,1,4],[5,1,4]],"top":-11,"bottom":6},"ﺱ":{"name":"uni0633","advance":17,"spans":[[-10,15,1],[-9,14,2],[-8,7,1],[-8,11,1],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,9],[-3,3,2],[-3,7,9],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ﺵ":{"name":"uni0634","advance":17,"spans":[[-13,10,2],[-12,10,2],[-11,9,1],[-11,12,2],[-10,9,1],[-10,12,2],[-9,14,2],[-8,14,2],[-7,7,1],[-7,10,2],[-7,14,2],[-6,7,1],[-6,10,2],[-6,14,2],[-5,7,1],[-5,10,2],[-5,14,2],[-4,3,2],[-4,7,9],[-3,3,2],[-3,7,9],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ﺹ":{"name":"uni0635","advance":21,"spans":[[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,13],[-3,3,2],[-3,7,13],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-9,"bottom":6},"ﺽ":{"name":"uni0636","advance":21,"spans":[[-13,12,2],[-12,13,1],[-9,7,1],[-9,10,10],[-8,7,13],[-7,7,13],[-6,7,2],[-6,18,2],[-5,7,1],[-5,18,2],[-4,3,2],[-4,7,13],[-3,3,2],[-3,7,13],[-2,1,2],[-2,7,11],[-1,1,2],[-1,7,11],[0,1,2],[0,7,2],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-13,"bottom":6},"ﻁ":{"name":"uni0637","advance":17,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-12,3,4],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,15],[-3,1,15],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ﻅ":{"name":"uni0638","advance":17,"spans":[[-15,3,2],[-14,3,2],[-13,3,4],[-13,9,1],[-12,3,4],[-12,9,1],[-11,3,2],[-10,3,2],[-9,3,2],[-9,7,9],[-8,3,13],[-7,3,13],[-6,3,2],[-6,14,2],[-5,3,2],[-5,14,2],[-4,1,15],[-3,1,15],[-2,3,11],[-1,3,11]],"top":-15,"bottom":0},"ﻉ":{"name":"uni0639","advance":13,"spans":[[-10,5,3],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-5,5,2],[-4,3,9],[-3,3,9],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-10,"bottom":6},"ﻍ":{"name":"uni063A","advance":13,"spans":[[-13,5,2],[-12,5,2],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-5,5,2],[-4,3,9],[-3,3,9],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ﻑ":{"name":"uni0641","advance":19,"spans":[[-13,14,2],[-12,14,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,17],[-3,1,17],[-2,3,15],[-1,3,15]],"top":-13,"bottom":0},"ﻕ":{"name":"uni0642","advance":13,"spans":[[-13,7,1],[-13,10,2],[-12,7,1],[-12,10,2],[-9,7,5],[-8,5,7],[-7,5,7],[-6,5,2],[-6,10,2],[-5,5,2],[-5,10,2],[-4,1,2],[-4,5,7],[-3,1,2],[-3,5,7],[-2,1,2],[-2,5,7],[-1,1,2],[-1,5,7],[0,1,2],[0,10,2],[1,1,2],[1,10,2],[2,1,11],[3,1,11],[4,3,7],[5,3,7]],"top":-13,"bottom":6},"ﻙ":{"name":"uni0643","advance":15,"spans":[[-15,12,2],[-14,12,2],[-13,10,4],[-12,10,4],[-11,7,1],[-11,12,2],[-10,7,1],[-10,12,2],[-9,5,2],[-9,12,2],[-8,3,7],[-8,12,2],[-7,3,7],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,9],[-1,3,9]],"top":-15,"bottom":0},"ﻝ":{"name":"uni0644","advance":9,"spans":[[-15,7,1],[-14,7,1],[-13,5,3],[-12,5,3],[-11,7,1],[-10,7,1],[-9,7,1],[-8,7,1],[-7,7,1],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,1],[-3,3,2],[-3,7,1],[-2,1,2],[-2,7,1],[-1,1,2],[-1,7,1],[0,1,2],[0,7,1],[1,1,2],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-15,"bottom":6},"ﻡ":{"name":"uni0645","advance":11,"spans":[[-10,5,3],[-9,5,3],[-8,5,5],[-7,5,5],[-6,5,2],[-6,9,1],[-5,5,2],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,9],[-1,1,9],[0,1,2],[1,1,2],[2,1,2],[3,1,2],[4,1,2],[5,1,2]],"top":-10,"bottom":6},"ﻥ":{"name":"uni0646","advance":11,"spans":[[-8,5,2],[-7,5,2],[-6,9,1],[-5,9,1],[-4,3,2],[-4,9,1],[-3,3,2],[-3,9,1],[-2,1,2],[-2,9,1],[-1,1,2],[-1,9,1],[0,1,2],[0,9,1],[1,1,2],[1,9,1],[2,1,9],[3,1,9],[4,3,5],[5,3,5]],"top":-8,"bottom":6},"ﻩ":{"name":"uni0647","advance":9,"spans":[[-10,3,3],[-9,3,4],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7]],"top":-10,"bottom":0},"ﻭ":{"name":"uni0648","advance":9,"spans":[[-10,3,5],[-9,3,5],[-8,1,7],[-7,1,7],[-6,1,2],[-6,7,1],[-5,1,2],[-5,7,1],[-4,1,7],[-3,1,7],[-2,1,7],[-1,1,7],[0,7,1],[1,7,1],[2,1,7],[3,1,7],[4,3,4],[5,3,3]],"top":-10,"bottom":6},"ﻯ":{"name":"uni0649","advance":15,"spans":[[-10,9,3],[-9,9,3],[-8,7,7],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9]],"top":-10,"bottom":6},"ﻱ":{"name":"uni064A","advance":15,"spans":[[-10,9,3],[-9,9,3],[-8,7,7],[-7,7,7],[-6,7,1],[-5,7,1],[-4,3,2],[-4,7,5],[-3,3,2],[-3,7,5],[-2,1,2],[-2,7,7],[-1,1,2],[-1,7,7],[0,1,2],[0,12,2],[1,1,2],[1,12,2],[2,1,13],[3,1,13],[4,3,9],[5,3,9],[8,5,2],[8,9,1],[9,5,2],[9,9,1]],"top":-10,"bottom":10},"ﭖ":{"name":"uni067E","advance":15,"spans":[[-8,3,2],[-8,12,2],[-7,3,2],[-7,12,2],[-6,1,2],[-6,12,2],[-5,1,2],[-5,12,2],[-4,1,13],[-3,1,13],[-2,3,11],[-1,3,11],[2,7,1],[2,10,2],[3,7,1],[3,10,2],[4,9,1],[5,9,1]],"top":-8,"bottom":6},"ﭺ":{"name":"uni0686","advance":13,"spans":[[-10,3,5],[-9,3,5],[-8,1,11],[-7,1,11],[-6,9,1],[-5,9,1],[-4,3,7],[-3,3,7],[-2,1,7],[-1,1,7],[0,1,2],[1,1,2],[2,1,4],[2,7,1],[2,10,2],[3,1,4],[3,7,1],[3,10,2],[4,3,4],[4,9,1],[5,3,4],[5,9,1]],"top":-10,"bottom":6},"ﭪ":{"name":"uni06A4","advance":19,"spans":[[-15,14,2],[-14,14,2],[-13,12,2],[-13,16,2],[-12,12,2],[-12,16,2],[-9,12,6],[-8,3,2],[-8,10,8],[-7,3,2],[-7,10,8],[-6,1,2],[-6,10,2],[-6,16,2],[-5,1,2],[-5,10,2],[-5,16,2],[-4,1,17],[-3,1,17],[-2,3,15],[-1,3,15]],"top":-15,"bottom":0},"glyph:shaddaAlefabovear":{"name":"shaddaAlefabovear","advance":0,"spans":[[-17,-2,2],[-16,-2,2],[-15,-2,2],[-14,-2,2],[-13,-2,2],[-10,-4,2],[-10,0,2],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-17,"bottom":-6},"glyph:shaddaFathatanar":{"name":"shaddaFathatanar","advance":0,"spans":[[-17,-2,4],[-16,-2,4],[-13,-2,4],[-12,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-17,"bottom":-6},"glyph:shaddaDammatanar":{"name":"shaddaDammatanar","advance":0,"spans":[[-19,0,2],[-18,0,2],[-17,-2,4],[-16,-2,4],[-15,0,2],[-14,0,2],[-13,-2,4],[-12,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-19,"bottom":-6},"glyph:shaddaKasratanar":{"name":"shaddaKasratanar","advance":0,"spans":[[-17,-2,4],[-16,-2,4],[-13,-2,4],[-12,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-17,"bottom":-6},"glyph:shaddaFathaar":{"name":"shaddaFathaar","advance":0,"spans":[[-13,-2,4],[-12,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-13,"bottom":-6},"glyph:shaddaDammaar":{"name":"shaddaDammaar","advance":0,"spans":[[-15,0,2],[-14,0,2],[-13,-2,4],[-12,-2,4],[-9,-4,2],[-9,0,2],[-8,-4,6],[-7,-4,6]],"top":-15,"bottom":-6},"glyph:shaddaKasraar":{"name":"shaddaKasraar","advance":0,"spans":[[-13,-4,2],[-13,0,2],[-12,-4,2],[-12,0,2],[-11,-4,6],[-10,-4,6],[-8,-2,4],[-7,-2,4]],"top":-13,"bottom":-6},"$":{"name":"dollar","advance":6,"spans":[[-9,2,1],[-8,1,3],[-7,0,1],[-7,2,1],[-7,4,1],[-6,0,1],[-6,2,1],[-5,1,3],[-4,2,1],[-4,4,1],[-3,0,1],[-3,2,1],[-3,4,1],[-2,1,3],[-1,2,1]],"top":-9,"bottom":0},"&":{"name":"ampersand","advance":7,"spans":[[-9,2,1],[-8,1,1],[-8,3,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,2,1],[-4,1,1],[-4,3,1],[-4,5,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3],[-1,5,1]],"top":-9,"bottom":0},"@":{"name":"at","advance":8,"spans":[[-9,2,3],[-8,1,1],[-8,5,1],[-7,0,1],[-7,3,2],[-7,6,1],[-6,0,1],[-6,2,1],[-6,4,1],[-6,6,1],[-5,0,1],[-5,2,1],[-5,4,1],[-5,6,1],[-4,0,1],[-4,2,1],[-4,4,1],[-4,6,1],[-3,0,1],[-3,3,3],[-2,1,1],[-1,2,4]],"top":-9,"bottom":0},"A":{"name":"A","advance":6,"spans":[[-9,2,1],[-8,2,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,0,5],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"B":{"name":"B","advance":6,"spans":[[-9,0,4],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,4],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,4]],"top":-9,"bottom":0},"C":{"name":"C","advance":6,"spans":[[-9,1,4],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-9,"bottom":0},"D":{"name":"D","advance":6,"spans":[[-9,0,4],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,4]],"top":-9,"bottom":0},"E":{"name":"E","advance":5,"spans":[[-9,0,4],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-9,"bottom":0},"F":{"name":"F","advance":5,"spans":[[-9,0,4],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"G":{"name":"G","advance":6,"spans":[[-9,1,4],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-5,2,3],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"H":{"name":"H","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,5],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"I":{"name":"I","advance":2,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"J":{"name":"J","advance":5,"spans":[[-9,3,1],[-8,3,1],[-7,3,1],[-6,3,1],[-5,3,1],[-4,3,1],[-3,3,1],[-2,3,1],[-1,0,3]],"top":-9,"bottom":0},"K":{"name":"K","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,3,1],[-7,0,1],[-7,3,1],[-6,0,1],[-6,2,1],[-5,0,2],[-4,0,1],[-4,2,1],[-3,0,1],[-3,3,1],[-2,0,1],[-2,3,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"L":{"name":"L","advance":5,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-9,"bottom":0},"M":{"name":"M","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,2],[-7,3,2],[-6,0,2],[-6,3,2],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,1],[-4,2,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"N":{"name":"N","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,2],[-8,4,1],[-7,0,2],[-7,4,1],[-6,0,1],[-6,2,1],[-6,4,1],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,1],[-4,3,2],[-3,0,1],[-3,3,2],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"O":{"name":"O","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"P":{"name":"P","advance":6,"spans":[[-9,0,4],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"Q":{"name":"Q","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,2,1],[-2,4,1],[-1,1,3],[0,3,2]],"top":-9,"bottom":1},"R":{"name":"R","advance":6,"spans":[[-9,0,4],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,4],[-4,0,1],[-4,3,1],[-3,0,1],[-3,3,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"S":{"name":"S","advance":5,"spans":[[-9,1,3],[-8,0,1],[-7,0,1],[-6,0,1],[-5,1,2],[-4,3,1],[-3,3,1],[-2,3,1],[-1,0,3]],"top":-9,"bottom":0},"T":{"name":"T","advance":6,"spans":[[-9,0,5],[-8,2,1],[-7,2,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"U":{"name":"U","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-9,"bottom":0},"V":{"name":"V","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,1,1],[-4,3,1],[-3,1,1],[-3,3,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"W":{"name":"W","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,2,1],[-7,4,1],[-6,0,1],[-6,2,1],[-6,4,1],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,1],[-4,2,1],[-4,4,1],[-3,1,1],[-3,3,1],[-2,1,1],[-2,3,1],[-1,1,1],[-1,3,1]],"top":-9,"bottom":0},"X":{"name":"X","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,2,1],[-4,1,1],[-4,3,1],[-3,1,1],[-3,3,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"Y":{"name":"Y","advance":6,"spans":[[-9,0,1],[-9,4,1],[-8,0,1],[-8,4,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"Z":{"name":"Z","advance":5,"spans":[[-9,0,4],[-8,3,1],[-7,2,1],[-6,2,1],[-5,1,2],[-4,1,1],[-3,1,1],[-2,0,1],[-1,0,4]],"top":-9,"bottom":0},"a":{"name":"a","advance":6,"spans":[[-6,0,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-6,"bottom":0},"b":{"name":"b","advance":6,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,4]],"top":-9,"bottom":0},"c":{"name":"c","advance":6,"spans":[[-6,1,4],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-6,"bottom":0},"d":{"name":"d","advance":6,"spans":[[-9,4,1],[-8,4,1],[-7,4,1],[-6,1,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-9,"bottom":0},"e":{"name":"e","advance":6,"spans":[[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,5],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-6,"bottom":0},"f":{"name":"f","advance":5,"spans":[[-9,2,2],[-8,1,1],[-7,1,1],[-6,0,4],[-5,1,1],[-4,1,1],[-3,1,1],[-2,1,1],[-1,1,1]],"top":-9,"bottom":0},"g":{"name":"g","advance":6,"spans":[[-6,1,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,1,4],[-1,4,1],[0,0,4]],"top":-6,"bottom":1},"h":{"name":"h","advance":6,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-9,"bottom":0},"i":{"name":"i","advance":2,"spans":[[-8,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-8,"bottom":0},"j":{"name":"j","advance":4,"spans":[[-8,2,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,2,1],[0,0,2]],"top":-8,"bottom":1},"k":{"name":"k","advance":5,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-6,3,1],[-5,0,1],[-5,2,1],[-4,0,2],[-3,0,1],[-3,2,1],[-2,0,1],[-2,3,1],[-1,0,1],[-1,3,1]],"top":-9,"bottom":0},"l":{"name":"l","advance":3,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,1]],"top":-9,"bottom":0},"m":{"name":"m","advance":8,"spans":[[-6,0,6],[-5,0,1],[-5,3,1],[-5,6,1],[-4,0,1],[-4,3,1],[-4,6,1],[-3,0,1],[-3,3,1],[-3,6,1],[-2,0,1],[-2,3,1],[-2,6,1],[-1,0,1],[-1,3,1],[-1,6,1]],"top":-6,"bottom":0},"n":{"name":"n","advance":6,"spans":[[-6,0,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-6,"bottom":0},"o":{"name":"o","advance":6,"spans":[[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-6,"bottom":0},"p":{"name":"p","advance":6,"spans":[[-6,0,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,4],[-1,0,1],[0,0,1]],"top":-6,"bottom":1},"q":{"name":"q","advance":6,"spans":[[-6,1,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,1,4],[-1,4,1],[0,4,1]],"top":-6,"bottom":1},"r":{"name":"r","advance":5,"spans":[[-6,0,1],[-6,2,2],[-5,0,2],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-6,"bottom":0},"s":{"name":"s","advance":5,"spans":[[-6,1,3],[-5,0,1],[-4,1,2],[-3,3,1],[-2,3,1],[-1,0,3]],"top":-6,"bottom":0},"t":{"name":"t","advance":5,"spans":[[-8,1,1],[-7,1,1],[-6,0,4],[-5,1,1],[-4,1,1],[-3,1,1],[-2,1,1],[-1,2,2]],"top":-8,"bottom":0},"u":{"name":"u","advance":6,"spans":[[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-6,"bottom":0},"v":{"name":"v","advance":6,"spans":[[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,1,1],[-4,3,1],[-3,1,1],[-3,3,1],[-2,2,1],[-1,2,1]],"top":-6,"bottom":0},"w":{"name":"w","advance":6,"spans":[[-6,0,1],[-6,4,1],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,1],[-4,2,1],[-4,4,1],[-3,0,1],[-3,2,1],[-3,4,1],[-2,1,1],[-2,3,1],[-1,1,1],[-1,3,1]],"top":-6,"bottom":0},"x":{"name":"x","advance":6,"spans":[[-6,0,1],[-6,4,1],[-5,1,1],[-5,3,1],[-4,2,1],[-3,2,1],[-2,1,1],[-2,3,1],[-1,0,1],[-1,4,1]],"top":-6,"bottom":0},"y":{"name":"y","advance":6,"spans":[[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,1,1],[-4,3,1],[-3,1,1],[-3,3,1],[-2,2,1],[-1,2,1],[0,0,2]],"top":-6,"bottom":1},"z":{"name":"z","advance":6,"spans":[[-6,0,5],[-5,3,1],[-4,2,1],[-3,1,1],[-2,0,1],[-1,0,5]],"top":-6,"bottom":0},"~":{"name":"asciitilde","advance":6,"spans":[[-9,1,1],[-8,0,1],[-8,2,1],[-8,4,1],[-7,3,1]],"top":-9,"bottom":-6}," ":{"name":"nonbreakingspace","advance":5,"spans":[],"top":0,"bottom":0},"¢":{"name":"cent","advance":7,"spans":[[-9,3,1],[-8,1,4],[-7,0,1],[-7,3,1],[-7,5,1],[-6,0,1],[-6,2,1],[-5,0,1],[-5,2,1],[-4,0,1],[-4,2,1],[-3,0,2],[-3,5,1],[-2,1,4],[-1,1,1]],"top":-9,"bottom":0},"£":{"name":"sterling","advance":6,"spans":[[-9,2,2],[-8,1,1],[-8,4,1],[-7,1,1],[-6,2,1],[-5,0,5],[-4,2,1],[-3,1,1],[-2,0,1],[-1,0,5]],"top":-9,"bottom":0},"©":{"name":"copyright","advance":10,"spans":[[-9,2,5],[-8,1,1],[-8,7,1],[-7,0,1],[-7,3,3],[-7,8,1],[-6,0,1],[-6,2,1],[-6,6,1],[-6,8,1],[-5,0,1],[-5,2,1],[-5,8,1],[-4,0,1],[-4,2,1],[-4,6,1],[-4,8,1],[-3,0,1],[-3,3,3],[-3,8,1],[-2,1,1],[-2,7,1],[-1,2,5]],"top":-9,"bottom":0},"®":{"name":"registered","advance":10,"spans":[[-9,2,5],[-8,1,1],[-8,7,1],[-7,0,1],[-7,2,4],[-7,8,1],[-6,0,1],[-6,2,1],[-6,6,1],[-6,8,1],[-5,0,1],[-5,2,4],[-5,8,1],[-4,0,1],[-4,2,1],[-4,5,1],[-4,8,1],[-3,0,1],[-3,2,1],[-3,6,1],[-3,8,1],[-2,1,1],[-2,7,1],[-1,2,5]],"top":-9,"bottom":0},"°":{"name":"degree","advance":4,"spans":[[-9,1,1],[-8,0,1],[-8,2,1],[-7,1,1]],"top":-9,"bottom":-6},"±":{"name":"plusminus","advance":6,"spans":[[-8,2,1],[-7,2,1],[-6,0,5],[-5,2,1],[-4,2,1],[-2,0,5]],"top":-8,"bottom":-1},"´":{"name":"acute","advance":3,"spans":[[-9,1,1],[-8,0,1]],"top":-9,"bottom":-7},"À":{"name":"Agrave","advance":6,"spans":[[-12,2,1],[-10,3,1],[-8,2,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,0,5],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-12,"bottom":0},"Á":{"name":"Aacute","advance":6,"spans":[[-12,2,1],[-10,1,1],[-8,2,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,0,5],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-12,"bottom":0},"Â":{"name":"Acircumflex","advance":6,"spans":[[-12,2,1],[-10,1,1],[-10,3,1],[-8,2,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,0,5],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-12,"bottom":0},"Ä":{"name":"Adieresis","advance":6,"spans":[[-10,1,1],[-10,3,1],[-8,2,1],[-7,1,1],[-7,3,1],[-6,1,1],[-6,3,1],[-5,1,1],[-5,3,1],[-4,0,5],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-10,"bottom":0},"Ç":{"name":"Ccedilla","advance":5,"spans":[[-9,1,3],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,1,3],[-1,2,1],[0,1,1]],"top":-9,"bottom":1},"È":{"name":"Egrave","advance":5,"spans":[[-12,1,1],[-10,2,1],[-8,0,4],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-12,"bottom":0},"É":{"name":"Eacute","advance":5,"spans":[[-12,2,1],[-10,1,1],[-8,0,4],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-12,"bottom":0},"Ê":{"name":"Ecircumflex","advance":5,"spans":[[-12,2,1],[-10,1,1],[-10,3,1],[-8,0,4],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-12,"bottom":0},"Ë":{"name":"Edieresis","advance":5,"spans":[[-10,1,1],[-10,3,1],[-8,0,4],[-7,0,1],[-6,0,1],[-5,0,4],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,4]],"top":-10,"bottom":0},"Ì":{"name":"Igrave","advance":2,"spans":[[-12,-1,1],[-10,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-12,"bottom":0},"Í":{"name":"Iacute","advance":2,"spans":[[-12,1,1],[-10,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-12,"bottom":0},"Î":{"name":"Icircumflex","advance":2,"spans":[[-12,0,1],[-10,-1,1],[-10,1,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-12,"bottom":0},"Ï":{"name":"Idieresis","advance":2,"spans":[[-10,-1,1],[-10,1,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-10,"bottom":0},"Ñ":{"name":"Ntilde","advance":6,"spans":[[-12,2,1],[-12,4,1],[-10,1,1],[-10,3,1],[-8,0,1],[-8,4,1],[-7,0,2],[-7,4,1],[-6,0,2],[-6,4,1],[-5,0,1],[-5,2,1],[-5,4,1],[-4,0,1],[-4,2,1],[-4,4,1],[-3,0,1],[-3,3,2],[-2,0,1],[-2,3,2],[-1,0,1],[-1,4,1]],"top":-12,"bottom":0},"Ò":{"name":"Ograve","advance":6,"spans":[[-12,1,1],[-10,2,1],[-8,1,3],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Ó":{"name":"Oacute","advance":6,"spans":[[-12,3,1],[-10,2,1],[-8,1,3],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Ô":{"name":"Ocircumflex","advance":6,"spans":[[-12,2,1],[-10,1,1],[-10,3,1],[-8,1,3],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Ö":{"name":"Odieresis","advance":6,"spans":[[-11,1,1],[-11,3,1],[-8,1,3],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-11,"bottom":0},"Ù":{"name":"Ugrave","advance":6,"spans":[[-12,1,1],[-10,2,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Ú":{"name":"Uacute","advance":6,"spans":[[-12,3,1],[-10,2,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Û":{"name":"Ucircumflex","advance":6,"spans":[[-12,2,1],[-10,1,1],[-10,3,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-12,"bottom":0},"Ü":{"name":"Udieresis","advance":6,"spans":[[-10,1,1],[-10,3,1],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-10,"bottom":0},"ß":{"name":"germandbls","advance":6,"spans":[[-9,1,3],[-8,0,1],[-8,4,1],[-7,0,1],[-7,4,1],[-6,0,1],[-6,2,2],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,2,2],[-1,0,1]],"top":-9,"bottom":0},"à":{"name":"agrave","advance":6,"spans":[[-10,2,1],[-8,3,1],[-6,0,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-10,"bottom":0},"á":{"name":"aacute","advance":6,"spans":[[-10,3,1],[-8,2,1],[-6,0,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-10,"bottom":0},"â":{"name":"acircumflex","advance":6,"spans":[[-10,2,1],[-8,1,1],[-8,3,1],[-6,0,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-10,"bottom":0},"ä":{"name":"adieresis","advance":6,"spans":[[-8,1,1],[-8,3,1],[-6,0,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-8,"bottom":0},"ç":{"name":"ccedilla","advance":5,"spans":[[-6,1,3],[-5,0,1],[-4,0,1],[-3,0,1],[-2,1,3],[-1,2,1],[0,1,1]],"top":-6,"bottom":1},"è":{"name":"egrave","advance":6,"spans":[[-10,1,1],[-8,2,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,5],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-10,"bottom":0},"é":{"name":"eacute","advance":6,"spans":[[-10,3,1],[-8,2,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,5],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-10,"bottom":0},"ê":{"name":"ecircumflex","advance":6,"spans":[[-10,2,1],[-8,1,1],[-8,3,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,5],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-10,"bottom":0},"ë":{"name":"edieresis","advance":6,"spans":[[-8,1,1],[-8,3,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,5],[-3,0,1],[-2,0,1],[-1,1,4]],"top":-8,"bottom":0},"ì":{"name":"igrave","advance":2,"spans":[[-10,-1,1],[-8,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-10,"bottom":0},"í":{"name":"iacute","advance":2,"spans":[[-10,1,1],[-8,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-10,"bottom":0},"î":{"name":"icircumflex","advance":2,"spans":[[-10,0,1],[-8,-1,1],[-8,1,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-10,"bottom":0},"ï":{"name":"idieresis","advance":2,"spans":[[-8,-1,1],[-8,1,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-8,"bottom":0},"ñ":{"name":"ntilde","advance":6,"spans":[[-10,2,1],[-10,4,1],[-8,1,1],[-8,3,1],[-6,0,4],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,0,1],[-1,4,1]],"top":-10,"bottom":0},"ò":{"name":"ograve","advance":6,"spans":[[-10,1,1],[-8,2,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-10,"bottom":0},"ó":{"name":"oacute","advance":6,"spans":[[-10,3,1],[-8,2,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-10,"bottom":0},"ô":{"name":"ocircumflex","advance":6,"spans":[[-10,2,1],[-8,1,1],[-8,3,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-10,"bottom":0},"ö":{"name":"odieresis","advance":6,"spans":[[-8,1,1],[-8,3,1],[-6,1,3],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,3]],"top":-8,"bottom":0},"ù":{"name":"ugrave","advance":6,"spans":[[-9,1,1],[-7,2,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-9,"bottom":0},"ú":{"name":"uacute","advance":6,"spans":[[-9,3,1],[-7,2,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-9,"bottom":0},"û":{"name":"ucircumflex","advance":6,"spans":[[-10,2,1],[-8,1,1],[-8,3,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-10,"bottom":0},"ü":{"name":"udieresis","advance":6,"spans":[[-8,1,1],[-8,3,1],[-6,0,1],[-6,4,1],[-5,0,1],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,1,4]],"top":-8,"bottom":0},"Œ":{"name":"OE","advance":8,"spans":[[-9,1,2],[-9,4,3],[-8,0,1],[-8,3,1],[-7,0,1],[-7,3,1],[-6,0,1],[-6,3,1],[-5,0,1],[-5,3,4],[-4,0,1],[-4,3,1],[-3,0,1],[-3,3,1],[-2,0,1],[-2,3,1],[-1,1,2],[-1,4,3]],"top":-9,"bottom":0},"œ":{"name":"oe","advance":8,"spans":[[-6,1,2],[-6,4,2],[-5,0,1],[-5,3,1],[-5,6,1],[-4,0,1],[-4,3,4],[-3,0,1],[-3,3,1],[-2,0,1],[-2,3,1],[-1,1,2],[-1,4,3]],"top":-6,"bottom":0},"※":{"name":"referencemark","advance":10,"spans":[[-9,0,1],[-9,4,1],[-9,8,1],[-8,1,1],[-8,4,1],[-8,7,1],[-7,2,1],[-7,6,1],[-6,3,1],[-6,5,1],[-5,0,2],[-5,4,1],[-5,7,2],[-4,3,1],[-4,5,1],[-3,2,1],[-3,6,1],[-2,1,1],[-2,4,1],[-2,7,1],[-1,0,1],[-1,4,1],[-1,8,1]],"top":-9,"bottom":0},"€":{"name":"Euro","advance":7,"spans":[[-9,3,2],[-8,2,1],[-8,5,1],[-7,1,1],[-6,0,4],[-5,1,1],[-4,0,4],[-3,1,1],[-2,2,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"™":{"name":"trademark","advance":10,"spans":[[-9,0,3],[-9,4,1],[-9,8,1],[-8,1,1],[-8,4,2],[-8,7,2],[-7,1,1],[-7,4,1],[-7,6,1],[-7,8,1],[-6,1,1],[-6,4,1],[-6,8,1]],"top":-9,"bottom":-5},"←":{"name":"arrowleft","advance":10,"spans":[[-7,2,1],[-6,1,1],[-5,0,9],[-4,1,1],[-3,2,1]],"top":-7,"bottom":-2},"↑":{"name":"arrowup","advance":6,"spans":[[-9,2,1],[-8,1,3],[-7,0,1],[-7,2,1],[-7,4,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,2,1],[-2,2,1],[-1,2,1]],"top":-9,"bottom":0},"→":{"name":"arrowright","advance":10,"spans":[[-7,6,1],[-6,7,1],[-5,0,9],[-4,7,1],[-3,6,1]],"top":-7,"bottom":-2},"↓":{"name":"arrowdown","advance":6,"spans":[[-9,2,1],[-8,2,1],[-7,2,1],[-6,2,1],[-5,2,1],[-4,2,1],[-3,0,1],[-3,2,1],[-3,4,1],[-2,1,3],[-1,2,1]],"top":-9,"bottom":0},"∞":{"name":"infinity","advance":10,"spans":[[-7,1,2],[-7,6,2],[-6,0,1],[-6,3,1],[-6,5,1],[-6,8,1],[-5,0,1],[-5,4,1],[-5,8,1],[-4,0,1],[-4,3,1],[-4,5,1],[-4,8,1],[-3,1,2],[-3,6,2]],"top":-7,"bottom":-2},"∱":{"name":"uni2231","advance":10,"spans":[[-10,2,1],[-10,6,1],[-9,1,3],[-9,5,3],[-8,0,9],[-7,1,1],[-7,7,1],[-6,0,1],[-6,4,1],[-6,8,1],[-5,0,1],[-5,4,1],[-5,8,1],[-4,0,1],[-4,4,3],[-4,8,1],[-3,0,1],[-3,8,1],[-2,1,1],[-2,7,1],[-1,2,5]],"top":-10,"bottom":0},"∴":{"name":"therefore","advance":9,"spans":[[-9,3,2],[-8,3,2],[-3,0,2],[-3,6,2],[-2,0,2],[-2,6,2]],"top":-9,"bottom":-1},"①":{"name":"onecircle","advance":10,"spans":[[-9,0,9],[-8,0,4],[-8,5,4],[-7,0,4],[-7,5,4],[-6,0,4],[-6,5,4],[-5,0,4],[-5,5,4],[-4,0,4],[-4,5,4],[-3,0,9],[-2,0,4],[-2,5,4],[-1,0,9]],"top":-9,"bottom":0},"⑦":{"name":"sevencircle","advance":10,"spans":[[-9,0,9],[-8,0,3],[-8,6,3],[-7,0,2],[-7,3,3],[-7,7,2],[-6,0,6],[-6,7,2],[-5,0,5],[-5,6,3],[-4,0,4],[-4,5,4],[-3,0,9],[-2,0,4],[-2,5,4],[-1,0,9]],"top":-9,"bottom":0},"Ⓐ":{"name":"Acircle","advance":10,"spans":[[-9,2,5],[-8,1,7],[-7,0,4],[-7,5,4],[-6,0,3],[-6,4,1],[-6,6,3],[-5,0,3],[-5,4,1],[-5,6,3],[-4,0,2],[-4,7,2],[-3,0,2],[-3,3,3],[-3,7,2],[-2,1,7],[-1,2,5]],"top":-9,"bottom":0},"Ⓑ":{"name":"Bcircle","advance":10,"spans":[[-9,2,5],[-8,1,7],[-7,0,2],[-7,6,3],[-6,0,2],[-6,3,3],[-6,7,2],[-5,0,2],[-5,6,3],[-4,0,2],[-4,3,3],[-4,7,2],[-3,0,2],[-3,6,3],[-2,1,7],[-1,2,5]],"top":-9,"bottom":0},"Ⓛ":{"name":"Lcircle","advance":10,"spans":[[-9,3,6],[-8,2,7],[-7,1,2],[-7,4,5],[-6,0,3],[-6,4,5],[-5,0,3],[-5,4,5],[-4,0,3],[-4,4,5],[-3,0,3],[-3,7,2],[-2,0,9],[-1,0,9]],"top":-9,"bottom":0},"Ⓡ":{"name":"Rcircle","advance":10,"spans":[[-9,0,6],[-8,0,7],[-7,0,2],[-7,6,2],[-6,0,2],[-6,3,3],[-6,7,2],[-5,0,2],[-5,6,3],[-4,0,2],[-4,3,2],[-4,6,3],[-3,0,2],[-3,3,3],[-3,7,2],[-2,0,9],[-1,0,9]],"top":-9,"bottom":0},"Ⓧ":{"name":"Xcircle","advance":10,"spans":[[-9,2,5],[-8,1,7],[-7,0,2],[-7,3,3],[-7,7,2],[-6,0,3],[-6,4,1],[-6,6,3],[-5,0,4],[-5,5,4],[-4,0,3],[-4,4,1],[-4,6,3],[-3,0,2],[-3,3,3],[-3,7,2],[-2,1,7],[-1,2,5]],"top":-9,"bottom":0},"Ⓨ":{"name":"Ycircle","advance":10,"spans":[[-9,2,5],[-8,1,7],[-7,0,2],[-7,3,3],[-7,7,2],[-6,0,3],[-6,4,1],[-6,6,3],[-5,0,4],[-5,5,4],[-4,0,4],[-4,5,4],[-3,0,4],[-3,5,4],[-2,1,7],[-1,2,5]],"top":-9,"bottom":0},"■":{"name":"blacksquare","advance":10,"spans":[[-9,0,9],[-8,0,9],[-7,0,9],[-6,0,9],[-5,0,9],[-4,0,9],[-3,0,9],[-2,0,9],[-1,0,9]],"top":-9,"bottom":0},"□":{"name":"H22073","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,0,1],[-7,8,1],[-6,0,1],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,8,1],[-3,0,1],[-3,8,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"▲":{"name":"blackuppointingtriangle","advance":10,"spans":[[-9,4,1],[-8,3,3],[-7,3,3],[-6,2,5],[-5,2,5],[-4,1,7],[-3,1,7],[-2,0,9],[-1,0,9]],"top":-9,"bottom":0},"△":{"name":"whiteuppointingtriangle","advance":10,"spans":[[-9,4,1],[-8,3,1],[-8,5,1],[-7,3,1],[-7,5,1],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,7,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"▼":{"name":"blackdownpointingtriangle","advance":10,"spans":[[-9,0,9],[-8,0,9],[-7,1,7],[-6,1,7],[-5,2,5],[-4,2,5],[-3,3,3],[-2,3,3],[-1,4,1]],"top":-9,"bottom":0},"▽":{"name":"whitedownpointingtriangle","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,1,1],[-7,7,1],[-6,1,1],[-6,7,1],[-5,2,1],[-5,6,1],[-4,2,1],[-4,6,1],[-3,3,1],[-3,5,1],[-2,3,1],[-2,5,1],[-1,4,1]],"top":-9,"bottom":0},"◆":{"name":"blackdiamond","advance":10,"spans":[[-9,4,1],[-8,3,3],[-7,2,5],[-6,1,7],[-5,0,9],[-4,1,7],[-3,2,5],[-2,3,3],[-1,4,1]],"top":-9,"bottom":0},"◇":{"name":"whitediamond","advance":10,"spans":[[-9,4,1],[-8,3,1],[-8,5,1],[-7,2,1],[-7,6,1],[-6,1,1],[-6,7,1],[-5,0,1],[-5,8,1],[-4,1,1],[-4,7,1],[-3,2,1],[-3,6,1],[-2,3,1],[-2,5,1],[-1,4,1]],"top":-9,"bottom":0},"○":{"name":"circle","advance":10,"spans":[[-9,2,5],[-8,1,1],[-8,7,1],[-7,0,1],[-7,8,1],[-6,0,1],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,8,1],[-3,0,1],[-3,8,1],[-2,1,1],[-2,7,1],[-1,2,5]],"top":-9,"bottom":0},"●":{"name":"blackcircle","advance":10,"spans":[[-9,2,5],[-8,1,7],[-7,0,9],[-6,0,9],[-5,0,9],[-4,0,9],[-3,0,9],[-2,1,7],[-1,2,5]],"top":-9,"bottom":0},"☁":{"name":"uni2601","advance":10,"spans":[[-9,3,3],[-8,2,1],[-8,6,1],[-7,1,2],[-7,6,2],[-6,0,1],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,5,1],[-4,8,1],[-3,1,1],[-3,4,1],[-3,7,1],[-2,2,2],[-2,5,2]],"top":-9,"bottom":-1},"☂":{"name":"uni2602","advance":10,"spans":[[-9,4,1],[-8,2,5],[-7,1,7],[-6,0,9],[-5,4,1],[-4,4,1],[-3,4,1],[-2,4,1],[-2,6,1],[-1,5,1]],"top":-9,"bottom":0},"☃":{"name":"uni2603","advance":8,"spans":[[-9,2,3],[-8,1,1],[-8,5,1],[-7,1,1],[-7,5,1],[-6,1,2],[-6,4,2],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,1,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"★":{"name":"blackstar","advance":10,"spans":[[-9,4,1],[-8,4,1],[-7,3,3],[-6,0,9],[-5,2,5],[-4,3,3],[-3,2,2],[-3,5,2],[-2,2,1],[-2,6,1],[-1,1,1],[-1,7,1]],"top":-9,"bottom":0},"☆":{"name":"whitestar","advance":10,"spans":[[-9,4,1],[-8,4,1],[-7,3,1],[-7,5,1],[-6,0,4],[-6,5,4],[-5,1,1],[-5,7,1],[-4,2,1],[-4,4,1],[-4,6,1],[-3,2,2],[-3,5,2],[-2,2,1],[-2,6,1],[-1,1,1],[-1,7,1]],"top":-9,"bottom":0},"☎":{"name":"telephoneblack","advance":6,"spans":[[-9,4,1],[-8,0,5],[-7,0,1],[-7,4,1],[-6,0,1],[-6,4,1],[-5,0,5],[-4,0,1],[-4,2,1],[-4,4,1],[-3,0,2],[-3,3,2],[-2,0,1],[-2,2,1],[-2,4,1],[-1,0,5]],"top":-9,"bottom":0},"☓":{"name":"uni2613","advance":10,"spans":[[-9,0,1],[-9,8,1],[-8,1,1],[-8,7,1],[-7,2,1],[-7,6,1],[-6,3,1],[-6,5,1],[-5,4,1],[-4,3,1],[-4,5,1],[-3,2,1],[-3,6,1],[-2,1,1],[-2,7,1],[-1,0,1],[-1,8,1]],"top":-9,"bottom":0},"☹":{"name":"uni2639","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,0,1],[-7,8,1],[-6,0,1],[-6,3,1],[-6,5,1],[-6,8,1],[-5,0,1],[-5,2,1],[-5,6,1],[-5,8,1],[-4,0,1],[-4,8,1],[-3,0,1],[-3,4,1],[-3,8,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"☺":{"name":"smileface","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,0,1],[-7,3,1],[-7,5,1],[-7,8,1],[-6,0,1],[-6,3,1],[-6,5,1],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,2,1],[-4,6,1],[-4,8,1],[-3,0,1],[-3,3,3],[-3,8,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"☻":{"name":"blacksmilingface","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,0,1],[-7,2,1],[-7,6,1],[-7,8,1],[-6,0,1],[-6,3,1],[-6,5,1],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,3,3],[-4,8,1],[-3,0,1],[-3,2,1],[-3,6,1],[-3,8,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"☼":{"name":"compass","advance":10,"spans":[[-9,4,1],[-8,1,1],[-8,7,1],[-7,3,3],[-6,2,1],[-6,6,1],[-5,0,1],[-5,2,1],[-5,6,1],[-5,8,1],[-4,2,1],[-4,6,1],[-3,3,3],[-2,1,1],[-2,7,1],[-1,4,1]],"top":-9,"bottom":0},"♠":{"name":"spade","advance":10,"spans":[[-9,4,1],[-8,3,3],[-7,2,5],[-6,1,7],[-5,0,9],[-4,0,9],[-3,1,2],[-3,4,1],[-3,6,2],[-2,3,3],[-1,2,5]],"top":-9,"bottom":0},"♣":{"name":"club","advance":10,"spans":[[-9,3,3],[-8,2,5],[-7,3,3],[-6,1,2],[-6,4,1],[-6,6,2],[-5,0,9],[-4,0,9],[-3,1,2],[-3,4,1],[-3,6,2],[-2,3,3],[-1,2,5]],"top":-9,"bottom":0},"♥":{"name":"heart","advance":10,"spans":[[-8,1,2],[-8,6,2],[-7,0,4],[-7,5,4],[-6,0,9],[-5,0,9],[-4,1,7],[-3,2,5],[-2,3,3],[-1,4,1]],"top":-8,"bottom":0},"♦":{"name":"diamond","advance":10,"spans":[[-9,4,1],[-8,3,3],[-7,2,5],[-6,1,7],[-5,0,9],[-4,1,7],[-3,2,5],[-2,3,3],[-1,4,1]],"top":-9,"bottom":0},"♩":{"name":"quarternote","advance":7,"spans":[[-9,3,1],[-8,3,2],[-7,3,1],[-7,5,1],[-6,3,1],[-6,5,1],[-5,3,1],[-5,5,1],[-4,3,1],[-3,1,3],[-2,0,3],[-1,0,2]],"top":-9,"bottom":0},"♭":{"name":"musicflatsign","advance":6,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-6,2,2],[-5,0,2],[-5,4,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,3,1],[-2,0,1],[-2,2,1],[-1,0,2]],"top":-9,"bottom":0},"♯":{"name":"musicsharpsign","advance":8,"spans":[[-9,3,1],[-9,5,1],[-8,3,1],[-8,5,1],[-7,1,6],[-6,2,1],[-6,4,1],[-5,2,1],[-5,4,1],[-4,2,1],[-4,4,1],[-3,0,6],[-2,1,1],[-2,3,1],[-1,1,1],[-1,3,1]],"top":-9,"bottom":0},"⚀":{"name":"uni2680","advance":10,"spans":[[-9,0,9],[-8,0,1],[-8,8,1],[-7,0,1],[-7,8,1],[-6,0,1],[-6,2,2],[-6,5,2],[-6,8,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,8,1],[-3,0,1],[-3,3,3],[-3,8,1],[-2,0,1],[-2,8,1],[-1,0,9]],"top":-9,"bottom":0},"✉":{"name":"uni2709","advance":10,"spans":[[-8,0,9],[-7,0,2],[-7,7,2],[-6,0,1],[-6,2,1],[-6,6,1],[-6,8,1],[-5,0,1],[-5,3,1],[-5,5,1],[-5,8,1],[-4,0,1],[-4,4,1],[-4,8,1],[-3,0,1],[-3,8,1],[-2,0,9]],"top":-8,"bottom":-1},"✚":{"name":"uni271A","advance":10,"spans":[[-9,3,3],[-8,3,3],[-7,3,3],[-6,0,9],[-5,0,4],[-5,5,4],[-4,0,9],[-3,3,3],[-2,3,3],[-1,3,3]],"top":-9,"bottom":0},"➡":{"name":"uni27A1","advance":10,"spans":[[-9,2,3],[-8,3,3],[-7,4,3],[-6,0,8],[-5,0,9],[-4,0,8],[-3,4,3],[-2,3,3],[-1,2,3]],"top":-9,"bottom":0},"⦾":{"name":"uni29BE","advance":10,"spans":[[-9,2,5],[-8,1,1],[-8,7,1],[-7,0,1],[-7,3,3],[-7,8,1],[-6,0,1],[-6,2,1],[-6,6,1],[-6,8,1],[-5,0,1],[-5,2,1],[-5,6,1],[-5,8,1],[-4,0,1],[-4,2,1],[-4,6,1],[-4,8,1],[-3,0,1],[-3,3,3],[-3,8,1],[-2,1,1],[-2,7,1],[-1,2,5]],"top":-9,"bottom":0},"⬅":{"name":"uni2B05","advance":10,"spans":[[-9,4,3],[-8,3,3],[-7,2,3],[-6,1,8],[-5,0,9],[-4,1,8],[-3,2,3],[-2,3,3],[-1,4,3]],"top":-9,"bottom":0},"⬆":{"name":"uni2B06","advance":10,"spans":[[-9,4,1],[-8,3,3],[-7,2,5],[-6,1,7],[-5,0,9],[-4,0,2],[-4,3,3],[-4,7,2],[-3,0,1],[-3,3,3],[-3,8,1],[-2,3,3],[-1,3,3]],"top":-9,"bottom":0},"⬇":{"name":"uni2B07","advance":10,"spans":[[-9,3,3],[-8,3,3],[-7,0,1],[-7,3,3],[-7,8,1],[-6,0,2],[-6,3,3],[-6,7,2],[-5,0,9],[-4,1,7],[-3,2,5],[-2,3,3],[-1,4,1]],"top":-9,"bottom":0}," ":{"name":"ideographicspace","advance":16,"spans":[],"top":0,"bottom":0},"、":{"name":"ideographiccomma","advance":3,"spans":[[-3,0,1],[-2,1,1],[-1,1,1]],"top":-3,"bottom":0},"。":{"name":"ideographicperiod","advance":5,"spans":[[-4,1,2],[-3,0,1],[-3,3,1],[-2,0,1],[-2,3,1],[-1,1,2]],"top":-4,"bottom":0},"「":{"name":"cornerbracketleft","advance":5,"spans":[[-9,0,4],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1]],"top":-9,"bottom":-2},"」":{"name":"cornerbracketright","advance":5,"spans":[[-7,3,1],[-6,3,1],[-5,3,1],[-4,3,1],[-3,3,1],[-2,3,1],[-1,0,4]],"top":-7,"bottom":0},"〒":{"name":"postalmark","advance":10,"spans":[[-9,0,9],[-6,0,9],[-5,4,1],[-4,4,1],[-3,4,1],[-2,4,1],[-1,4,1]],"top":-9,"bottom":0},"ぁ":{"name":"asmallhiragana","advance":9,"spans":[[-8,3,1],[-7,1,5],[-6,3,1],[-5,2,5],[-4,1,1],[-4,3,1],[-4,5,1],[-4,7,1],[-3,0,1],[-3,3,2],[-3,7,1],[-2,0,1],[-2,3,1],[-2,7,1],[-1,1,2],[-1,5,2]],"top":-8,"bottom":0},"あ":{"name":"ahiragana","advance":10,"spans":[[-9,3,1],[-8,1,7],[-7,3,1],[-6,2,5],[-5,1,1],[-5,3,1],[-5,5,1],[-5,7,1],[-4,0,1],[-4,3,1],[-4,5,1],[-4,8,1],[-3,0,1],[-3,3,2],[-3,8,1],[-2,0,1],[-2,3,1],[-2,8,1],[-1,1,2],[-1,6,2]],"top":-9,"bottom":0},"ぃ":{"name":"ismallhiragana","advance":8,"spans":[[-7,0,1],[-7,5,1],[-6,0,1],[-6,5,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,2,1],[-3,6,1],[-2,0,1],[-2,2,1],[-2,6,1],[-1,1,1]],"top":-7,"bottom":0},"い":{"name":"ihiragana","advance":10,"spans":[[-8,0,1],[-8,6,1],[-7,0,1],[-7,7,1],[-6,0,1],[-6,7,1],[-5,0,1],[-5,8,1],[-4,0,1],[-4,8,1],[-3,0,1],[-3,3,1],[-3,8,1],[-2,1,1],[-2,3,1],[-2,8,1],[-1,1,2]],"top":-8,"bottom":0},"ぅ":{"name":"usmallhiragana","advance":8,"spans":[[-8,1,4],[-6,1,5],[-5,0,1],[-5,6,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,1,3]],"top":-8,"bottom":0},"う":{"name":"uhiragana","advance":9,"spans":[[-9,2,4],[-7,2,5],[-6,0,2],[-6,7,1],[-5,7,1],[-4,7,1],[-3,7,1],[-2,5,2],[-1,2,3]],"top":-9,"bottom":0},"ぇ":{"name":"esmallhiragana","advance":8,"spans":[[-8,1,4],[-6,0,6],[-5,4,1],[-4,3,1],[-3,2,1],[-3,4,1],[-2,1,1],[-2,4,1],[-1,0,1],[-1,5,2]],"top":-8,"bottom":0},"え":{"name":"ehiragana","advance":10,"spans":[[-9,2,4],[-7,0,7],[-6,5,1],[-5,4,1],[-4,3,2],[-3,2,1],[-3,5,1],[-2,1,1],[-2,5,1],[-1,0,1],[-1,6,3]],"top":-9,"bottom":0},"ぉ":{"name":"osmallhiragana","advance":10,"spans":[[-8,3,1],[-7,1,5],[-7,7,1],[-6,3,1],[-6,8,1],[-5,2,5],[-4,1,1],[-4,3,1],[-4,7,1],[-3,0,1],[-3,3,1],[-3,7,1],[-2,0,1],[-2,3,1],[-2,7,1],[-1,1,2],[-1,5,2]],"top":-8,"bottom":0},"お":{"name":"ohiragana","advance":10,"spans":[[-9,3,1],[-9,6,1],[-8,1,5],[-8,7,1],[-7,3,1],[-7,8,1],[-6,2,5],[-5,1,1],[-5,3,1],[-5,7,1],[-4,0,1],[-4,3,1],[-4,8,1],[-3,0,1],[-3,3,1],[-3,8,1],[-2,0,1],[-2,3,1],[-2,8,1],[-1,1,2],[-1,6,2]],"top":-9,"bottom":0},"か":{"name":"kahiragana","advance":10,"spans":[[-9,2,1],[-8,2,1],[-8,6,1],[-7,0,5],[-7,7,1],[-6,2,1],[-6,5,1],[-6,8,1],[-5,2,1],[-5,5,1],[-5,8,1],[-4,2,1],[-4,5,1],[-3,1,1],[-3,5,1],[-2,1,1],[-2,5,1],[-1,0,1],[-1,3,2]],"top":-9,"bottom":0},"が":{"name":"gahiragana","advance":10,"spans":[[-9,2,1],[-9,7,2],[-8,2,1],[-8,6,1],[-7,0,5],[-7,7,1],[-6,2,1],[-6,5,1],[-6,8,1],[-5,2,1],[-5,5,1],[-5,8,1],[-4,2,1],[-4,5,1],[-3,1,1],[-3,5,1],[-2,1,1],[-2,5,1],[-1,0,1],[-1,3,2]],"top":-9,"bottom":0},"き":{"name":"kihiragana","advance":10,"spans":[[-9,4,1],[-8,1,7],[-7,5,1],[-6,0,9],[-5,6,1],[-4,2,6],[-3,1,1],[-3,7,1],[-2,1,1],[-1,2,5]],"top":-9,"bottom":0},"ぎ":{"name":"gihiragana","advance":10,"spans":[[-9,4,1],[-9,6,1],[-9,8,1],[-8,1,6],[-8,8,1],[-7,5,1],[-6,0,9],[-5,6,1],[-4,2,6],[-3,1,1],[-3,7,1],[-2,1,1],[-1,2,5]],"top":-9,"bottom":0},"く":{"name":"kuhiragana","advance":8,"spans":[[-9,6,1],[-8,5,1],[-7,3,2],[-6,1,2],[-5,0,1],[-4,1,2],[-3,3,2],[-2,5,1],[-1,6,1]],"top":-9,"bottom":0},"ぐ":{"name":"guhiragana","advance":10,"spans":[[-9,6,1],[-8,5,1],[-7,3,2],[-7,6,1],[-7,8,1],[-6,1,2],[-6,6,1],[-6,8,1],[-5,0,1],[-4,1,2],[-3,3,2],[-2,5,1],[-1,6,1]],"top":-9,"bottom":0},"け":{"name":"kehiragana","advance":10,"spans":[[-9,0,1],[-9,6,1],[-8,0,1],[-8,6,1],[-7,0,1],[-7,2,7],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,0,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"げ":{"name":"gehiragana","advance":10,"spans":[[-9,0,1],[-9,6,1],[-9,8,1],[-8,0,1],[-8,6,1],[-8,8,1],[-7,0,1],[-7,2,6],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,0,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"こ":{"name":"kohiragana","advance":9,"spans":[[-8,1,6],[-4,1,1],[-3,0,1],[-2,0,1],[-1,1,7]],"top":-8,"bottom":0},"ご":{"name":"gohiragana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,1,4],[-8,6,1],[-8,8,1],[-4,1,1],[-3,0,1],[-2,0,1],[-1,1,7]],"top":-9,"bottom":0},"さ":{"name":"sahiragana","advance":9,"spans":[[-9,5,1],[-8,0,8],[-7,5,1],[-6,5,1],[-5,1,6],[-4,0,1],[-4,6,1],[-3,0,1],[-2,0,1],[-1,1,6]],"top":-9,"bottom":0},"ざ":{"name":"zahiragana","advance":10,"spans":[[-9,4,1],[-9,6,1],[-9,8,1],[-8,0,7],[-8,8,1],[-7,5,1],[-6,5,1],[-5,1,6],[-4,0,1],[-4,6,1],[-3,0,1],[-2,0,1],[-1,1,6]],"top":-9,"bottom":0},"し":{"name":"sihiragana","advance":8,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,0,1],[-2,5,1],[-1,1,4]],"top":-9,"bottom":0},"じ":{"name":"zihiragana","advance":8,"spans":[[-9,0,1],[-8,0,1],[-8,4,1],[-8,6,1],[-7,0,1],[-7,4,1],[-7,6,1],[-6,0,1],[-5,0,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,0,1],[-2,5,1],[-1,1,4]],"top":-9,"bottom":0},"す":{"name":"suhiragana","advance":9,"spans":[[-9,5,1],[-8,0,8],[-7,5,1],[-6,2,4],[-5,1,1],[-5,5,1],[-4,1,1],[-4,5,1],[-3,2,4],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ず":{"name":"zuhiragana","advance":10,"spans":[[-9,5,2],[-9,8,1],[-8,0,9],[-7,5,1],[-6,2,4],[-5,1,1],[-5,5,1],[-4,1,1],[-4,5,1],[-3,2,4],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"せ":{"name":"sehiragana","advance":10,"spans":[[-9,1,1],[-9,6,1],[-8,1,1],[-8,6,1],[-7,0,9],[-6,1,1],[-6,6,1],[-5,1,1],[-5,6,1],[-4,1,1],[-4,4,2],[-3,1,1],[-2,1,1],[-1,2,6]],"top":-9,"bottom":0},"ぜ":{"name":"zehiragana","advance":10,"spans":[[-9,1,1],[-9,6,1],[-9,8,1],[-8,1,1],[-8,6,1],[-8,8,1],[-7,0,9],[-6,1,1],[-6,6,1],[-5,1,1],[-5,6,1],[-4,1,1],[-4,4,2],[-3,1,1],[-2,1,1],[-1,2,6]],"top":-9,"bottom":0},"そ":{"name":"sohiragana","advance":10,"spans":[[-9,1,6],[-8,4,1],[-7,3,1],[-6,0,9],[-5,4,1],[-4,3,1],[-3,3,1],[-2,3,1],[-1,4,3]],"top":-9,"bottom":0},"ぞ":{"name":"zohiragana","advance":10,"spans":[[-9,1,6],[-9,8,1],[-8,4,1],[-8,6,1],[-8,8,1],[-7,3,1],[-6,0,9],[-5,4,1],[-4,3,1],[-3,3,1],[-2,3,1],[-1,4,3]],"top":-9,"bottom":0},"た":{"name":"tahiragana","advance":10,"spans":[[-9,3,1],[-8,1,7],[-7,3,1],[-6,3,1],[-6,5,4],[-5,2,1],[-4,2,1],[-3,2,1],[-3,5,1],[-2,1,1],[-2,4,1],[-1,1,1],[-1,5,4]],"top":-9,"bottom":0},"だ":{"name":"dahiragana","advance":11,"spans":[[-9,3,1],[-9,7,1],[-9,9,1],[-8,1,5],[-8,7,1],[-8,9,1],[-7,3,1],[-6,3,1],[-6,5,4],[-5,2,1],[-4,2,1],[-3,2,1],[-3,5,1],[-2,1,1],[-2,4,1],[-1,1,1],[-1,5,4]],"top":-9,"bottom":0},"ち":{"name":"tihiragana","advance":9,"spans":[[-9,3,1],[-8,0,8],[-7,2,1],[-6,2,1],[-5,1,6],[-4,1,1],[-4,7,1],[-3,7,1],[-2,7,1],[-1,1,6]],"top":-9,"bottom":0},"ぢ":{"name":"dihiragana","advance":10,"spans":[[-9,3,1],[-9,6,1],[-9,8,1],[-8,0,7],[-8,8,1],[-7,2,1],[-6,2,1],[-5,1,6],[-4,1,1],[-4,7,1],[-3,7,1],[-2,7,1],[-1,1,6]],"top":-9,"bottom":0},"っ":{"name":"tusmallhiragana","advance":9,"spans":[[-7,2,5],[-6,0,2],[-6,7,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,2,4]],"top":-7,"bottom":-1},"つ":{"name":"tuhiragana","advance":10,"spans":[[-8,2,5],[-7,0,2],[-7,7,1],[-6,8,1],[-5,8,1],[-4,8,1],[-3,8,1],[-2,6,2],[-1,2,4]],"top":-8,"bottom":0},"づ":{"name":"duhiragana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,2,5],[-8,8,1],[-7,0,2],[-7,7,1],[-6,8,1],[-5,8,1],[-4,8,1],[-3,8,1],[-2,6,2],[-1,2,4]],"top":-9,"bottom":0},"て":{"name":"tehiragana","advance":10,"spans":[[-8,0,9],[-7,5,1],[-6,4,1],[-5,3,1],[-4,3,1],[-3,3,1],[-2,4,1],[-1,5,3]],"top":-8,"bottom":0},"で":{"name":"dehiragana","advance":11,"spans":[[-8,0,9],[-7,5,1],[-6,4,1],[-6,7,1],[-6,9,1],[-5,3,1],[-5,7,1],[-5,9,1],[-4,3,1],[-3,3,1],[-2,4,1],[-1,5,3]],"top":-8,"bottom":0},"と":{"name":"tohiragana","advance":9,"spans":[[-9,1,1],[-8,1,1],[-8,6,2],[-7,2,1],[-7,4,2],[-6,2,2],[-5,1,1],[-4,0,1],[-3,0,1],[-2,1,1],[-1,2,6]],"top":-9,"bottom":0},"ど":{"name":"dohiragana","advance":10,"spans":[[-9,1,1],[-9,6,1],[-9,8,1],[-8,1,1],[-8,6,1],[-8,8,1],[-7,2,1],[-7,4,2],[-6,2,2],[-5,1,1],[-4,0,1],[-3,0,1],[-2,1,1],[-1,2,6]],"top":-9,"bottom":0},"な":{"name":"nahiragana","advance":11,"spans":[[-9,3,1],[-8,1,4],[-8,6,3],[-7,3,1],[-7,9,1],[-6,3,1],[-6,7,1],[-5,2,1],[-5,7,1],[-4,2,1],[-4,5,3],[-3,2,1],[-3,4,1],[-3,7,2],[-2,1,1],[-2,4,1],[-2,7,1],[-2,9,1],[-1,1,1],[-1,5,2]],"top":-9,"bottom":0},"に":{"name":"nihiragana","advance":10,"spans":[[-9,0,1],[-8,0,1],[-8,3,6],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-4,4,1],[-3,0,1],[-3,3,1],[-2,0,1],[-2,3,1],[-1,0,1],[-1,4,5]],"top":-9,"bottom":0},"ぬ":{"name":"nuhiragana","advance":10,"spans":[[-9,2,1],[-9,5,1],[-8,2,5],[-7,1,2],[-7,5,1],[-7,7,1],[-6,0,1],[-6,2,1],[-6,4,1],[-6,8,1],[-5,0,1],[-5,3,2],[-5,8,1],[-4,0,1],[-4,3,1],[-4,8,1],[-3,0,1],[-3,3,1],[-3,5,2],[-3,8,1],[-2,0,1],[-2,2,1],[-2,4,1],[-2,7,1],[-1,1,1],[-1,5,2],[-1,8,1]],"top":-9,"bottom":0},"ね":{"name":"nehiragana","advance":10,"spans":[[-9,2,1],[-8,0,3],[-8,4,3],[-7,2,2],[-7,7,1],[-6,2,1],[-6,8,1],[-5,1,2],[-5,8,1],[-4,0,1],[-4,2,1],[-4,8,1],[-3,0,1],[-3,2,1],[-3,5,2],[-3,8,1],[-2,2,1],[-2,4,1],[-2,7,1],[-1,2,1],[-1,5,2],[-1,8,1]],"top":-9,"bottom":0},"の":{"name":"nohiragana","advance":10,"spans":[[-8,2,5],[-7,1,1],[-7,4,1],[-7,7,1],[-6,0,1],[-6,4,1],[-6,8,1],[-5,0,1],[-5,4,1],[-5,8,1],[-4,0,1],[-4,4,1],[-4,8,1],[-3,0,1],[-3,3,1],[-3,8,1],[-2,0,1],[-2,3,1],[-2,7,1],[-1,1,2],[-1,5,2]],"top":-8,"bottom":0},"は":{"name":"hahiragana","advance":10,"spans":[[-9,0,1],[-9,6,1],[-8,0,1],[-8,6,1],[-7,0,1],[-7,3,6],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ば":{"name":"bahiragana","advance":10,"spans":[[-9,0,1],[-9,6,1],[-9,8,1],[-8,0,1],[-8,6,1],[-8,8,1],[-7,0,1],[-7,3,6],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ぱ":{"name":"pahiragana","advance":11,"spans":[[-9,0,1],[-9,6,1],[-9,8,1],[-8,0,1],[-8,6,2],[-8,9,1],[-7,0,1],[-7,3,4],[-7,8,1],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ひ":{"name":"hihiragana","advance":10,"spans":[[-9,0,4],[-9,5,1],[-8,2,1],[-8,5,1],[-7,1,1],[-7,6,1],[-6,0,1],[-6,6,3],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,1,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"び":{"name":"bihiragana","advance":10,"spans":[[-9,0,4],[-9,6,1],[-9,8,1],[-8,2,1],[-8,6,1],[-8,8,1],[-7,1,1],[-7,5,1],[-6,0,1],[-6,6,2],[-5,0,1],[-5,6,1],[-5,8,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,1,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ぴ":{"name":"pihiragana","advance":10,"spans":[[-9,0,4],[-9,7,1],[-8,2,1],[-8,6,1],[-8,8,1],[-7,1,1],[-7,5,1],[-7,7,1],[-6,0,1],[-6,6,2],[-5,0,1],[-5,6,1],[-5,8,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,6,1],[-2,1,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ふ":{"name":"huhiragana","advance":10,"spans":[[-9,2,4],[-8,4,1],[-7,3,1],[-6,3,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,5,1],[-4,7,1],[-3,0,1],[-3,5,1],[-3,8,1],[-2,0,1],[-2,5,1],[-2,8,1],[-1,2,3]],"top":-9,"bottom":0},"ぶ":{"name":"buhiragana","advance":10,"spans":[[-9,2,4],[-8,4,1],[-8,6,1],[-8,8,1],[-7,3,1],[-7,6,1],[-7,8,1],[-6,3,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,5,1],[-4,7,1],[-3,0,1],[-3,5,1],[-3,8,1],[-2,0,1],[-2,5,1],[-2,8,1],[-1,2,3]],"top":-9,"bottom":0},"ぷ":{"name":"puhiragana","advance":10,"spans":[[-9,2,4],[-9,7,1],[-8,4,1],[-8,6,1],[-8,8,1],[-7,3,1],[-7,7,1],[-6,3,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,5,1],[-4,7,1],[-3,0,1],[-3,5,1],[-3,8,1],[-2,0,1],[-2,5,1],[-2,8,1],[-1,2,3]],"top":-9,"bottom":0},"へ":{"name":"hehiragana","advance":10,"spans":[[-8,3,1],[-7,2,1],[-7,4,1],[-6,1,1],[-6,5,1],[-5,1,1],[-5,5,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,7,1],[-2,8,1]],"top":-8,"bottom":-1},"べ":{"name":"behiragana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,3,1],[-8,6,1],[-8,8,1],[-7,2,1],[-7,4,1],[-6,1,1],[-6,5,1],[-5,1,1],[-5,5,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,7,1],[-2,8,1]],"top":-9,"bottom":-1},"ぺ":{"name":"pehiragana","advance":10,"spans":[[-9,7,1],[-8,3,1],[-8,6,1],[-8,8,1],[-7,2,1],[-7,4,1],[-7,7,1],[-6,1,1],[-6,5,1],[-5,1,1],[-5,5,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,7,1],[-2,8,1]],"top":-9,"bottom":-1},"ほ":{"name":"hohiragana","advance":10,"spans":[[-9,0,1],[-9,3,6],[-8,0,1],[-8,6,1],[-7,0,1],[-7,3,6],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ぼ":{"name":"bohiragana","advance":11,"spans":[[-9,0,1],[-9,3,5],[-9,9,1],[-8,0,1],[-8,6,1],[-8,9,1],[-7,0,1],[-7,3,6],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ぽ":{"name":"pohiragana","advance":11,"spans":[[-9,0,1],[-9,3,4],[-9,8,1],[-8,0,1],[-8,6,2],[-8,9,1],[-7,0,1],[-7,3,4],[-7,8,1],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,4,3],[-3,0,1],[-3,3,1],[-3,6,2],[-2,0,1],[-2,3,1],[-2,6,1],[-2,8,1],[-1,0,1],[-1,4,2]],"top":-9,"bottom":0},"ま":{"name":"mahiragana","advance":10,"spans":[[-9,5,1],[-8,1,7],[-7,5,1],[-6,0,9],[-5,5,1],[-4,2,4],[-3,1,1],[-3,5,2],[-2,1,1],[-2,5,1],[-2,7,1],[-1,2,3]],"top":-9,"bottom":0},"み":{"name":"mihiragana","advance":10,"spans":[[-9,1,4],[-8,4,1],[-7,4,1],[-7,7,1],[-6,2,4],[-6,7,1],[-5,1,1],[-5,4,1],[-5,6,2],[-4,0,1],[-4,3,1],[-4,7,2],[-3,0,1],[-3,3,1],[-3,7,1],[-2,1,2],[-2,6,1],[-1,5,1]],"top":-9,"bottom":0},"む":{"name":"muhiragana","advance":11,"spans":[[-9,3,1],[-8,3,1],[-8,8,1],[-7,1,5],[-7,9,1],[-6,3,1],[-5,2,2],[-4,1,1],[-4,3,1],[-4,8,1],[-3,1,1],[-3,3,1],[-3,8,1],[-2,2,2],[-2,8,1],[-1,3,5]],"top":-9,"bottom":0},"め":{"name":"mehiragana","advance":10,"spans":[[-9,2,1],[-9,5,1],[-8,2,5],[-7,1,2],[-7,5,1],[-7,7,1],[-6,0,1],[-6,2,1],[-6,5,1],[-6,8,1],[-5,0,1],[-5,2,1],[-5,4,1],[-5,8,1],[-4,0,1],[-4,3,2],[-4,8,1],[-3,0,1],[-3,3,1],[-3,8,1],[-2,1,2],[-2,7,1],[-1,5,2]],"top":-9,"bottom":0},"も":{"name":"mohiragana","advance":9,"spans":[[-9,2,1],[-8,0,6],[-7,2,1],[-6,2,1],[-5,0,6],[-4,2,1],[-4,7,1],[-3,2,1],[-3,7,1],[-2,2,1],[-2,7,1],[-1,3,4]],"top":-9,"bottom":0},"ゃ":{"name":"yasmallhiragana","advance":9,"spans":[[-8,1,1],[-8,4,1],[-7,1,1],[-7,3,4],[-6,0,3],[-6,4,1],[-6,7,1],[-5,2,1],[-5,7,1],[-4,2,1],[-4,7,1],[-3,3,1],[-3,5,2],[-2,3,1],[-1,3,1]],"top":-8,"bottom":0},"や":{"name":"yahiragana","advance":11,"spans":[[-9,2,1],[-9,5,1],[-8,2,1],[-8,5,1],[-8,7,2],[-7,3,2],[-7,6,1],[-7,9,1],[-6,0,4],[-6,6,1],[-6,9,1],[-5,2,1],[-5,9,1],[-4,2,1],[-4,6,3],[-3,3,1],[-2,3,1],[-1,3,1]],"top":-9,"bottom":0},"ゅ":{"name":"yusmallhiragana","advance":9,"spans":[[-8,0,1],[-8,4,1],[-7,0,1],[-7,3,4],[-6,0,1],[-6,2,1],[-6,4,1],[-6,7,1],[-5,0,2],[-5,4,1],[-5,7,1],[-4,0,1],[-4,4,1],[-4,7,1],[-3,0,1],[-3,2,5],[-2,4,1],[-1,2,2]],"top":-8,"bottom":0},"ゆ":{"name":"yuhiragana","advance":10,"spans":[[-9,5,1],[-8,0,1],[-8,3,5],[-7,0,1],[-7,2,1],[-7,5,1],[-7,8,1],[-6,0,2],[-6,5,1],[-6,8,1],[-5,0,1],[-5,2,1],[-5,5,1],[-5,8,1],[-4,0,1],[-4,2,1],[-4,5,1],[-4,8,1],[-3,0,1],[-3,3,5],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"ょ":{"name":"yosmallhiragana","advance":9,"spans":[[-8,4,1],[-7,4,1],[-6,4,4],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,2],[-2,0,1],[-2,4,1],[-2,6,1],[-1,1,3],[-1,7,1]],"top":-8,"bottom":0},"よ":{"name":"yohiragana","advance":9,"spans":[[-9,4,1],[-8,4,1],[-7,4,4],[-6,4,1],[-5,4,1],[-4,1,4],[-3,0,1],[-3,4,2],[-2,0,1],[-2,4,1],[-2,6,1],[-1,1,3],[-1,7,1]],"top":-9,"bottom":0},"ら":{"name":"rahiragana","advance":9,"spans":[[-9,3,2],[-8,1,1],[-8,5,2],[-7,1,1],[-6,0,1],[-6,2,4],[-5,0,2],[-5,6,1],[-4,0,1],[-4,7,1],[-3,7,1],[-2,6,1],[-1,2,4]],"top":-9,"bottom":0},"り":{"name":"rihiragana","advance":8,"spans":[[-9,0,1],[-9,3,2],[-8,0,1],[-8,2,1],[-8,5,1],[-7,0,2],[-7,6,1],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,6,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"る":{"name":"ruhiragana","advance":10,"spans":[[-9,1,7],[-8,5,1],[-7,3,2],[-6,2,5],[-5,1,1],[-5,7,1],[-4,0,1],[-4,8,1],[-3,3,2],[-3,8,1],[-2,2,1],[-2,5,1],[-2,7,1],[-1,3,4]],"top":-9,"bottom":0},"れ":{"name":"rehiragana","advance":10,"spans":[[-9,2,1],[-8,2,1],[-8,4,2],[-7,0,4],[-7,6,1],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,1,2],[-4,6,1],[-3,0,1],[-3,2,1],[-3,6,1],[-3,8,1],[-2,2,1],[-2,6,2],[-1,2,1]],"top":-9,"bottom":0},"ろ":{"name":"rohiragana","advance":10,"spans":[[-9,1,6],[-8,4,1],[-7,3,1],[-6,2,5],[-5,1,1],[-5,7,1],[-4,0,1],[-4,8,1],[-3,8,1],[-2,7,1],[-1,2,5]],"top":-9,"bottom":0},"ゎ":{"name":"wasmallhiragana","advance":9,"spans":[[-8,2,1],[-7,2,1],[-7,4,3],[-6,0,4],[-6,7,1],[-5,2,1],[-5,7,1],[-4,1,2],[-4,7,1],[-3,0,1],[-3,2,1],[-3,7,1],[-2,2,1],[-2,6,1],[-1,2,1],[-1,5,1]],"top":-8,"bottom":0},"わ":{"name":"wahiragana","advance":10,"spans":[[-9,2,1],[-8,0,3],[-8,4,3],[-7,2,2],[-7,7,1],[-6,2,1],[-6,8,1],[-5,1,2],[-5,8,1],[-4,0,1],[-4,2,1],[-4,8,1],[-3,0,1],[-3,2,1],[-3,8,1],[-2,2,1],[-2,7,1],[-1,2,1],[-1,5,2]],"top":-9,"bottom":0},"を":{"name":"wohiragana","advance":10,"spans":[[-9,4,1],[-8,1,7],[-7,3,1],[-6,2,4],[-6,7,2],[-5,1,1],[-5,5,2],[-4,0,1],[-4,3,2],[-4,6,1],[-3,2,1],[-3,6,1],[-2,2,1],[-1,3,6]],"top":-9,"bottom":0},"ん":{"name":"nhiragana","advance":10,"spans":[[-9,3,1],[-8,3,1],[-7,2,1],[-6,2,1],[-5,1,1],[-5,3,1],[-4,1,2],[-4,4,1],[-4,8,1],[-3,1,1],[-3,4,1],[-3,8,1],[-2,0,1],[-2,4,1],[-2,7,1],[-1,0,1],[-1,5,2]],"top":-9,"bottom":0},"゛":{"name":"voicedmarkkana","advance":5,"spans":[[-9,2,1],[-8,0,1],[-8,3,1],[-7,1,1],[-7,3,1],[-6,1,1]],"top":-9,"bottom":-5},"゜":{"name":"semivoicedmarkkana","advance":5,"spans":[[-9,1,2],[-8,0,1],[-8,3,1],[-7,0,1],[-7,3,1],[-6,1,2]],"top":-9,"bottom":-5},"ァ":{"name":"asmallkatakana","advance":9,"spans":[[-8,0,8],[-7,7,1],[-6,3,1],[-6,7,1],[-5,3,1],[-5,6,1],[-4,3,1],[-4,5,1],[-3,3,1],[-2,2,1],[-1,1,1]],"top":-8,"bottom":0},"ア":{"name":"akatakana","advance":9,"spans":[[-9,0,8],[-8,7,1],[-7,3,1],[-7,7,1],[-6,3,1],[-6,7,1],[-5,3,1],[-5,6,1],[-4,3,1],[-4,5,1],[-3,3,1],[-2,2,1],[-1,1,1]],"top":-9,"bottom":0},"ィ":{"name":"ismallkatakana","advance":8,"spans":[[-8,6,1],[-7,5,1],[-6,3,2],[-5,0,3],[-5,4,1],[-4,4,1],[-3,4,1],[-2,4,1],[-1,4,1]],"top":-8,"bottom":0},"イ":{"name":"ikatakana","advance":8,"spans":[[-9,6,1],[-8,5,1],[-7,4,1],[-6,2,3],[-5,0,2],[-5,4,1],[-4,4,1],[-3,4,1],[-2,4,1],[-1,4,1]],"top":-9,"bottom":0},"ゥ":{"name":"usmallkatakana","advance":8,"spans":[[-8,3,1],[-7,0,7],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,6,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-8,"bottom":0},"ウ":{"name":"ukatakana","advance":9,"spans":[[-9,3,1],[-8,0,8],[-7,0,1],[-7,7,1],[-6,0,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"ェ":{"name":"esmallkatakana","advance":8,"spans":[[-7,1,5],[-6,3,1],[-5,3,1],[-4,3,1],[-3,3,1],[-2,0,7]],"top":-7,"bottom":-1},"エ":{"name":"ekatakana","advance":10,"spans":[[-8,1,7],[-7,4,1],[-6,4,1],[-5,4,1],[-4,4,1],[-3,4,1],[-2,0,9]],"top":-8,"bottom":-1},"ォ":{"name":"osmallkatakana","advance":8,"spans":[[-8,4,1],[-7,4,1],[-6,0,7],[-5,3,2],[-4,2,1],[-4,4,1],[-3,1,1],[-3,4,1],[-2,0,1],[-2,4,1],[-1,3,2]],"top":-8,"bottom":0},"オ":{"name":"okatakana","advance":10,"spans":[[-9,6,1],[-8,6,1],[-7,0,9],[-6,4,1],[-6,6,1],[-5,3,1],[-5,6,1],[-4,2,1],[-4,6,1],[-3,1,1],[-3,6,1],[-2,0,1],[-2,6,1],[-1,4,2]],"top":-9,"bottom":0},"カ":{"name":"kakatakana","advance":10,"spans":[[-9,3,1],[-8,3,1],[-7,0,9],[-6,3,1],[-6,8,1],[-5,3,1],[-5,8,1],[-4,2,1],[-4,8,1],[-3,2,1],[-3,8,1],[-2,1,1],[-2,8,1],[-1,0,1],[-1,5,3]],"top":-9,"bottom":0},"ガ":{"name":"gakatakana","advance":11,"spans":[[-9,3,1],[-9,7,1],[-9,9,1],[-8,3,1],[-8,7,1],[-8,9,1],[-7,0,9],[-6,3,1],[-6,8,1],[-5,3,1],[-5,8,1],[-4,2,1],[-4,8,1],[-3,2,1],[-3,8,1],[-2,1,1],[-2,8,1],[-1,0,1],[-1,5,3]],"top":-9,"bottom":0},"キ":{"name":"kikatakana","advance":9,"spans":[[-9,3,1],[-8,3,1],[-7,0,8],[-6,4,1],[-5,4,1],[-4,0,8],[-3,5,1],[-2,5,1],[-1,5,1]],"top":-9,"bottom":0},"ギ":{"name":"gikatakana","advance":11,"spans":[[-9,3,1],[-9,7,1],[-9,9,1],[-8,3,1],[-8,7,1],[-8,9,1],[-7,0,8],[-6,4,1],[-5,4,1],[-4,0,8],[-3,5,1],[-2,5,1],[-1,5,1]],"top":-9,"bottom":0},"ク":{"name":"kukatakana","advance":9,"spans":[[-9,2,1],[-8,2,6],[-7,2,1],[-7,7,1],[-6,1,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,1,3]],"top":-9,"bottom":0},"グ":{"name":"gukatakana","advance":10,"spans":[[-9,2,1],[-9,6,1],[-9,8,1],[-8,2,5],[-8,8,1],[-7,2,1],[-7,7,1],[-6,1,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,1,3]],"top":-9,"bottom":0},"ケ":{"name":"kekatakana","advance":11,"spans":[[-9,2,1],[-8,2,1],[-7,2,8],[-6,1,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"ゲ":{"name":"gekatakana","advance":11,"spans":[[-9,2,1],[-9,7,1],[-9,9,1],[-8,2,1],[-8,7,1],[-8,9,1],[-7,2,8],[-6,1,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,3,2]],"top":-9,"bottom":0},"コ":{"name":"kokatakana","advance":9,"spans":[[-8,0,8],[-7,7,1],[-6,7,1],[-5,7,1],[-4,7,1],[-3,7,1],[-2,7,1],[-1,0,8]],"top":-8,"bottom":0},"ゴ":{"name":"gokatakana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,0,7],[-8,8,1],[-7,6,1],[-6,6,1],[-5,6,1],[-4,6,1],[-3,6,1],[-2,6,1],[-1,0,7]],"top":-9,"bottom":0},"サ":{"name":"sakatakana","advance":10,"spans":[[-9,2,1],[-9,6,1],[-8,2,1],[-8,6,1],[-7,0,9],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,2,1],[-4,6,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ザ":{"name":"zakatakana","advance":10,"spans":[[-9,2,1],[-9,6,1],[-9,8,1],[-8,2,1],[-8,6,1],[-8,8,1],[-7,0,9],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,2,1],[-4,6,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"シ":{"name":"sikatakana","advance":9,"spans":[[-9,1,2],[-8,3,1],[-6,0,2],[-6,7,1],[-5,2,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,0,4]],"top":-9,"bottom":0},"ジ":{"name":"zikatakana","advance":9,"spans":[[-9,1,2],[-9,5,1],[-9,7,1],[-8,3,1],[-8,5,1],[-8,7,1],[-6,0,2],[-6,7,1],[-5,2,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,0,4]],"top":-9,"bottom":0},"ス":{"name":"sukatakana","advance":10,"spans":[[-9,0,7],[-8,6,1],[-7,6,1],[-6,6,1],[-5,5,1],[-4,4,2],[-3,3,1],[-3,6,1],[-2,2,1],[-2,7,1],[-1,0,2],[-1,8,1]],"top":-9,"bottom":0},"ズ":{"name":"zukatakana","advance":10,"spans":[[-9,0,7],[-9,8,1],[-8,6,1],[-8,8,1],[-7,6,1],[-6,6,1],[-5,5,1],[-4,4,2],[-3,3,1],[-3,6,1],[-2,2,1],[-2,7,1],[-1,0,2],[-1,8,1]],"top":-9,"bottom":0},"セ":{"name":"sekatakana","advance":11,"spans":[[-9,3,1],[-8,3,1],[-7,3,1],[-7,5,5],[-6,1,4],[-6,9,1],[-5,3,1],[-5,8,1],[-4,3,1],[-4,7,1],[-3,3,1],[-2,3,1],[-1,4,5]],"top":-9,"bottom":0},"ゼ":{"name":"zekatakana","advance":11,"spans":[[-9,3,1],[-9,7,1],[-9,9,1],[-8,3,1],[-8,7,1],[-8,9,1],[-7,3,1],[-7,5,5],[-6,1,4],[-6,9,1],[-5,3,1],[-5,8,1],[-4,3,1],[-4,7,1],[-3,3,1],[-2,3,1],[-1,4,5]],"top":-9,"bottom":0},"ソ":{"name":"sokatakana","advance":10,"spans":[[-9,0,1],[-9,8,1],[-8,1,1],[-8,8,1],[-7,1,1],[-7,8,1],[-6,7,1],[-5,7,1],[-4,6,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-9,"bottom":0},"ゾ":{"name":"zokatakana","advance":11,"spans":[[-10,7,1],[-10,9,1],[-9,0,1],[-9,7,1],[-9,9,1],[-8,1,1],[-8,8,1],[-7,1,1],[-7,8,1],[-6,7,1],[-5,6,1],[-4,6,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-10,"bottom":0},"タ":{"name":"takatakana","advance":10,"spans":[[-9,2,1],[-8,2,7],[-7,1,1],[-7,8,1],[-6,0,1],[-6,2,1],[-6,8,1],[-5,3,1],[-5,7,1],[-4,4,1],[-4,7,1],[-3,5,2],[-2,4,2],[-1,1,3]],"top":-9,"bottom":0},"ダ":{"name":"dakatakana","advance":11,"spans":[[-9,2,1],[-9,7,1],[-9,9,1],[-8,2,6],[-8,9,1],[-7,1,1],[-7,8,1],[-6,0,1],[-6,2,1],[-6,8,1],[-5,3,1],[-5,7,1],[-4,4,1],[-4,7,1],[-3,5,2],[-2,4,2],[-1,1,3]],"top":-9,"bottom":0},"チ":{"name":"tikatakana","advance":10,"spans":[[-9,7,1],[-8,1,6],[-7,4,1],[-6,4,1],[-5,0,9],[-4,4,1],[-3,4,1],[-2,3,1],[-1,1,2]],"top":-9,"bottom":0},"ヂ":{"name":"dikatakana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,1,6],[-8,8,1],[-7,4,1],[-6,4,1],[-5,0,9],[-4,4,1],[-3,4,1],[-2,3,1],[-1,1,2]],"top":-9,"bottom":0},"ッ":{"name":"tusmallkatakana","advance":9,"spans":[[-7,0,1],[-7,3,1],[-7,7,1],[-6,1,1],[-6,4,1],[-6,7,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,2,2]],"top":-7,"bottom":0},"ツ":{"name":"tukatakana","advance":10,"spans":[[-8,0,1],[-8,3,1],[-8,8,1],[-7,1,1],[-7,4,1],[-7,8,1],[-6,1,1],[-6,4,1],[-6,8,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,4,2],[-1,2,2]],"top":-8,"bottom":0},"ヅ":{"name":"dukatakana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,0,1],[-8,3,1],[-8,6,1],[-8,8,1],[-7,1,1],[-7,4,1],[-6,1,1],[-6,4,1],[-6,8,1],[-5,8,1],[-4,7,1],[-3,6,1],[-2,4,2],[-1,2,2]],"top":-9,"bottom":0},"テ":{"name":"tekatakana","advance":10,"spans":[[-9,1,7],[-6,0,9],[-5,5,1],[-4,5,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-9,"bottom":0},"デ":{"name":"dekatakana","advance":10,"spans":[[-9,1,6],[-9,8,1],[-8,6,1],[-8,8,1],[-6,0,9],[-5,5,1],[-4,5,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-9,"bottom":0},"ト":{"name":"tokatakana","advance":7,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,3],[-5,0,1],[-5,3,2],[-4,0,1],[-4,5,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"ド":{"name":"dokatakana","advance":8,"spans":[[-9,0,1],[-9,4,1],[-9,6,1],[-8,0,1],[-8,4,1],[-8,6,1],[-7,0,1],[-6,0,3],[-5,0,1],[-5,3,2],[-4,0,1],[-4,5,1],[-3,0,1],[-2,0,1],[-1,0,1]],"top":-9,"bottom":0},"ナ":{"name":"nakatakana","advance":10,"spans":[[-9,4,1],[-8,4,1],[-7,0,9],[-6,4,1],[-5,4,1],[-4,4,1],[-3,4,1],[-2,3,1],[-1,1,2]],"top":-9,"bottom":0},"ニ":{"name":"nikatakana","advance":9,"spans":[[-8,1,6],[-2,0,8]],"top":-8,"bottom":-1},"ヌ":{"name":"nukatakana","advance":10,"spans":[[-9,2,7],[-8,8,1],[-7,8,1],[-6,3,2],[-6,7,1],[-5,5,1],[-5,7,1],[-4,6,1],[-3,5,1],[-3,7,1],[-2,3,2],[-2,8,1],[-1,1,2]],"top":-9,"bottom":0},"ネ":{"name":"nekatakana","advance":10,"spans":[[-9,4,1],[-8,1,7],[-7,7,1],[-6,6,1],[-5,4,2],[-4,2,3],[-4,6,1],[-3,0,2],[-3,4,1],[-3,7,2],[-2,4,1],[-1,4,1]],"top":-9,"bottom":0},"ノ":{"name":"nokatakana","advance":8,"spans":[[-9,6,1],[-8,6,1],[-7,6,1],[-6,6,1],[-5,5,1],[-4,5,1],[-3,4,1],[-2,2,2],[-1,0,2]],"top":-9,"bottom":0},"ハ":{"name":"hakatakana","advance":10,"spans":[[-9,2,1],[-9,6,1],[-8,2,1],[-8,6,1],[-7,2,1],[-7,6,1],[-6,2,1],[-6,6,1],[-5,1,1],[-5,7,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,7,1],[-2,0,1],[-2,8,1],[-1,0,1],[-1,8,1]],"top":-9,"bottom":0},"バ":{"name":"bakatakana","advance":10,"spans":[[-9,2,1],[-9,6,1],[-9,8,1],[-8,2,1],[-8,6,1],[-8,8,1],[-7,2,1],[-7,6,1],[-6,2,1],[-6,6,1],[-5,1,1],[-5,7,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,7,1],[-2,0,1],[-2,8,1],[-1,0,1],[-1,8,1]],"top":-9,"bottom":0},"パ":{"name":"pakatakana","advance":10,"spans":[[-9,2,1],[-9,6,2],[-8,2,1],[-8,6,1],[-8,8,1],[-7,2,1],[-7,6,2],[-6,2,1],[-6,6,1],[-5,1,1],[-5,7,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,7,1],[-2,0,1],[-2,8,1],[-1,0,1],[-1,8,1]],"top":-9,"bottom":0},"ヒ":{"name":"hikatakana","advance":9,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-7,5,3],[-6,0,5],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,7]],"top":-9,"bottom":0},"ビ":{"name":"bikatakana","advance":9,"spans":[[-9,0,1],[-9,5,1],[-9,7,1],[-8,0,1],[-8,5,1],[-8,7,1],[-7,0,1],[-7,5,2],[-6,0,5],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,7]],"top":-9,"bottom":0},"ピ":{"name":"pikatakana","advance":9,"spans":[[-9,0,1],[-9,6,1],[-8,0,1],[-8,5,1],[-8,7,1],[-7,0,1],[-7,4,3],[-6,0,4],[-5,0,1],[-4,0,1],[-3,0,1],[-2,0,1],[-1,1,7]],"top":-9,"bottom":0},"フ":{"name":"hukatakana","advance":9,"spans":[[-8,0,8],[-7,7,1],[-6,7,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-8,"bottom":0},"ブ":{"name":"bukatakana","advance":10,"spans":[[-9,6,1],[-9,8,1],[-8,0,7],[-8,8,1],[-7,7,1],[-6,7,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"プ":{"name":"pukatakana","advance":10,"spans":[[-9,7,1],[-8,0,7],[-8,8,1],[-7,7,1],[-6,7,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ヘ":{"name":"hekatakana","advance":11,"spans":[[-8,4,1],[-7,3,1],[-7,5,1],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,8,1],[-2,9,1]],"top":-8,"bottom":-1},"ベ":{"name":"bekatakana","advance":11,"spans":[[-9,7,1],[-9,9,1],[-8,4,1],[-8,7,1],[-8,9,1],[-7,3,1],[-7,5,1],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,8,1],[-2,9,1]],"top":-9,"bottom":-1},"ペ":{"name":"pekatakana","advance":11,"spans":[[-9,8,1],[-8,4,1],[-8,7,1],[-8,9,1],[-7,3,1],[-7,5,1],[-7,8,1],[-6,2,1],[-6,6,1],[-5,2,1],[-5,6,1],[-4,1,1],[-4,7,1],[-3,1,1],[-3,8,1],[-2,9,1]],"top":-9,"bottom":-1},"ホ":{"name":"hokatakana","advance":10,"spans":[[-9,4,1],[-8,4,1],[-7,0,9],[-6,4,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,4,1],[-4,7,1],[-3,0,1],[-3,4,1],[-3,8,1],[-2,0,1],[-2,4,1],[-2,8,1],[-1,4,1]],"top":-9,"bottom":0},"ボ":{"name":"bokatakana","advance":10,"spans":[[-9,4,1],[-9,6,1],[-9,8,1],[-8,4,1],[-8,6,1],[-8,8,1],[-7,0,9],[-6,4,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,4,1],[-4,7,1],[-3,0,1],[-3,4,1],[-3,8,1],[-2,0,1],[-2,4,1],[-2,8,1],[-1,4,1]],"top":-9,"bottom":0},"ポ":{"name":"pokatakana","advance":10,"spans":[[-9,4,1],[-9,7,1],[-8,4,1],[-8,6,1],[-8,8,1],[-7,0,9],[-6,4,1],[-5,1,1],[-5,4,1],[-5,7,1],[-4,1,1],[-4,4,1],[-4,7,1],[-3,0,1],[-3,4,1],[-3,8,1],[-2,0,1],[-2,4,1],[-2,8,1],[-1,4,1]],"top":-9,"bottom":0},"マ":{"name":"makatakana","advance":10,"spans":[[-8,0,9],[-7,8,1],[-6,7,1],[-5,6,1],[-4,1,2],[-4,5,1],[-3,3,2],[-2,4,1],[-1,5,1]],"top":-8,"bottom":0},"ミ":{"name":"mikatakana","advance":9,"spans":[[-9,1,3],[-8,4,3],[-7,7,1],[-6,1,3],[-5,4,3],[-4,7,1],[-3,0,4],[-2,4,3],[-1,7,1]],"top":-9,"bottom":0},"ム":{"name":"mukatakana","advance":10,"spans":[[-9,3,1],[-8,3,1],[-7,3,1],[-6,2,1],[-5,2,1],[-5,6,1],[-4,2,1],[-4,7,1],[-3,1,1],[-3,6,2],[-2,0,6],[-2,8,1],[-1,8,1]],"top":-9,"bottom":0},"メ":{"name":"mekatakana","advance":9,"spans":[[-9,6,1],[-8,6,1],[-7,2,1],[-7,6,1],[-6,3,1],[-6,5,1],[-5,4,2],[-4,4,2],[-3,3,1],[-3,6,1],[-2,2,1],[-2,7,1],[-1,0,2]],"top":-9,"bottom":0},"モ":{"name":"mokatakana","advance":10,"spans":[[-9,1,7],[-8,3,1],[-7,3,1],[-6,0,9],[-5,3,1],[-4,3,1],[-3,3,1],[-2,3,1],[-1,4,4]],"top":-9,"bottom":0},"ャ":{"name":"yasmallkatakana","advance":9,"spans":[[-8,1,1],[-7,2,1],[-7,4,4],[-6,0,4],[-6,7,1],[-5,2,1],[-5,7,1],[-4,3,1],[-4,6,1],[-3,3,1],[-2,4,1],[-1,4,1]],"top":-8,"bottom":0},"ヤ":{"name":"yakatakana","advance":10,"spans":[[-9,1,1],[-8,2,1],[-8,5,4],[-7,2,3],[-7,8,1],[-6,0,3],[-6,8,1],[-5,3,1],[-5,7,1],[-4,3,1],[-4,6,1],[-3,3,1],[-2,4,1],[-1,4,1]],"top":-9,"bottom":0},"ュ":{"name":"yusmallkatakana","advance":9,"spans":[[-7,1,6],[-6,6,1],[-5,6,1],[-4,6,1],[-3,5,1],[-2,5,1],[-1,0,8]],"top":-7,"bottom":0},"ユ":{"name":"yukatakana","advance":10,"spans":[[-8,1,7],[-7,7,1],[-6,7,1],[-5,7,1],[-4,6,1],[-3,6,1],[-2,6,1],[-1,0,9]],"top":-8,"bottom":0},"ョ":{"name":"yosmallkatakana","advance":7,"spans":[[-7,0,6],[-6,5,1],[-5,5,1],[-4,0,6],[-3,5,1],[-2,5,1],[-1,0,6]],"top":-7,"bottom":0},"ヨ":{"name":"yokatakana","advance":8,"spans":[[-9,0,7],[-8,6,1],[-7,6,1],[-6,6,1],[-5,0,7],[-4,6,1],[-3,6,1],[-2,6,1],[-1,0,7]],"top":-9,"bottom":0},"ラ":{"name":"rakatakana","advance":9,"spans":[[-9,1,6],[-6,0,8],[-5,7,1],[-4,6,1],[-3,6,1],[-2,4,2],[-1,2,2]],"top":-9,"bottom":0},"リ":{"name":"rikatakana","advance":8,"spans":[[-9,0,1],[-9,6,1],[-8,0,1],[-8,6,1],[-7,0,1],[-7,6,1],[-6,0,1],[-6,6,1],[-5,0,1],[-5,6,1],[-4,6,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ル":{"name":"rukatakana","advance":10,"spans":[[-9,2,1],[-9,5,1],[-8,2,1],[-8,5,1],[-7,2,1],[-7,5,1],[-6,2,1],[-6,5,1],[-5,2,1],[-5,5,1],[-5,8,1],[-4,2,1],[-4,5,1],[-4,8,1],[-3,1,1],[-3,5,1],[-3,8,1],[-2,1,1],[-2,5,1],[-2,7,1],[-1,0,1],[-1,5,2]],"top":-9,"bottom":0},"レ":{"name":"rekatakana","advance":8,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-5,6,1],[-4,0,1],[-4,6,1],[-3,0,1],[-3,5,1],[-2,0,1],[-2,3,2],[-1,0,3]],"top":-9,"bottom":0},"ロ":{"name":"rokatakana","advance":9,"spans":[[-9,0,8],[-8,0,1],[-8,7,1],[-7,0,1],[-7,7,1],[-6,0,1],[-6,7,1],[-5,0,1],[-5,7,1],[-4,0,1],[-4,7,1],[-3,0,1],[-3,7,1],[-2,0,8]],"top":-9,"bottom":-1},"ヮ":{"name":"wasmallkatakana","advance":7,"spans":[[-7,0,6],[-6,0,1],[-6,5,1],[-5,0,1],[-5,5,1],[-4,5,1],[-3,5,1],[-2,4,1],[-1,1,3]],"top":-7,"bottom":0},"ワ":{"name":"wakatakana","advance":9,"spans":[[-9,0,8],[-8,0,1],[-8,7,1],[-7,0,1],[-7,7,1],[-6,0,1],[-6,7,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ヲ":{"name":"wokatakana","advance":9,"spans":[[-9,0,8],[-8,7,1],[-7,7,1],[-6,0,8],[-5,7,1],[-4,7,1],[-3,6,1],[-2,5,1],[-1,2,3]],"top":-9,"bottom":0},"ン":{"name":"nkatakana","advance":10,"spans":[[-8,0,2],[-7,2,1],[-7,8,1],[-6,8,1],[-5,7,1],[-4,7,1],[-3,6,1],[-2,4,2],[-1,1,3]],"top":-8,"bottom":0},"ヵ":{"name":"kasmallkatakana","advance":8,"spans":[[-8,2,1],[-7,2,1],[-6,0,7],[-5,2,1],[-5,6,1],[-4,2,1],[-4,6,1],[-3,2,1],[-3,6,1],[-2,1,1],[-2,6,1],[-1,0,1],[-1,4,2]],"top":-8,"bottom":0},"ヶ":{"name":"kesmallkatakana","advance":9,"spans":[[-8,2,1],[-7,2,1],[-6,2,6],[-5,1,1],[-5,5,1],[-4,0,1],[-4,5,1],[-3,5,1],[-2,4,1],[-1,2,2]],"top":-8,"bottom":0},"!":{"name":"exclammonospace","advance":2,"spans":[[-9,0,1],[-8,0,1],[-7,0,1],[-6,0,1],[-5,0,1],[-4,0,1],[-3,0,1],[-1,0,1]],"top":-9,"bottom":0},"/":{"name":"slashmonospace","advance":10,"spans":[[-9,8,1],[-8,7,1],[-7,6,1],[-6,5,1],[-5,4,1],[-4,3,1],[-3,2,1],[-2,1,1],[-1,0,1]],"top":-9,"bottom":0},"?":{"name":"questionmonospace","advance":7,"spans":[[-9,1,4],[-8,0,1],[-8,5,1],[-7,0,1],[-7,5,1],[-6,5,1],[-5,4,1],[-4,3,1],[-3,3,1],[-1,3,1]],"top":-9,"bottom":0},"\":{"name":"backslashmonospace","advance":10,"spans":[[-9,0,1],[-8,1,1],[-7,2,1],[-6,3,1],[-5,4,1],[-4,5,1],[-3,6,1],[-2,7,1],[-1,8,1]],"top":-9,"bottom":0},"~":{"name":"asciitildemonospace","advance":10,"spans":[[-6,1,4],[-6,8,1],[-5,0,1],[-5,5,3]],"top":-6,"bottom":-4}},"letters":{"ء":["ﺀ","","",""],"آ":["ﺁ","","","ﺂ"],"أ":["ﺃ","","","ﺄ"],"ؤ":["ﺅ","","","ﺆ"],"إ":["ﺇ","","","ﺈ"],"ئ":["ﺉ","ﺋ","ﺌ","ﺊ"],"ا":["ﺍ","","","ﺎ"],"ب":["ﺏ","ﺑ","ﺒ","ﺐ"],"ة":["ﺓ","","","ﺔ"],"ت":["ﺕ","ﺗ","ﺘ","ﺖ"],"ث":["ﺙ","ﺛ","ﺜ","ﺚ"],"ج":["ﺝ","ﺟ","ﺠ","ﺞ"],"ح":["ﺡ","ﺣ","ﺤ","ﺢ"],"خ":["ﺥ","ﺧ","ﺨ","ﺦ"],"د":["ﺩ","","","ﺪ"],"ذ":["ﺫ","","","ﺬ"],"ر":["ﺭ","","","ﺮ"],"ز":["ﺯ","","","ﺰ"],"س":["ﺱ","ﺳ","ﺴ","ﺲ"],"ش":["ﺵ","ﺷ","ﺸ","ﺶ"],"ص":["ﺹ","ﺻ","ﺼ","ﺺ"],"ض":["ﺽ","ﺿ","ﻀ","ﺾ"],"ط":["ﻁ","ﻃ","ﻄ","ﻂ"],"ظ":["ﻅ","ﻇ","ﻈ","ﻆ"],"ع":["ﻉ","ﻋ","ﻌ","ﻊ"],"غ":["ﻍ","ﻏ","ﻐ","ﻎ"],"ـ":["ـ","ـ","ـ","ـ"],"ف":["ﻑ","ﻓ","ﻔ","ﻒ"],"ق":["ﻕ","ﻗ","ﻘ","ﻖ"],"ك":["ﻙ","ﻛ","ﻜ","ﻚ"],"ل":["ﻝ","ﻟ","ﻠ","ﻞ"],"م":["ﻡ","ﻣ","ﻤ","ﻢ"],"ن":["ﻥ","ﻧ","ﻨ","ﻦ"],"ه":["ﻩ","ﻫ","ﻬ","ﻪ"],"و":["ﻭ","","","ﻮ"],"ى":["ﻯ","","","ﻰ"],"ي":["ﻱ","ﻳ","ﻴ","ﻲ"],"پ":["ﭖ","ﭘ","ﭙ","ﭗ"],"چ":["ﭺ","ﭼ","ﭽ","ﭻ"],"ڤ":["ﭪ","ﭬ","ﭭ","ﭫ"],"‍":["‍","‍","‍","‍"]},"ligatures":[["لا",["ﻻ","","","ﻼ"]],["لأ",["ﻷ","","","ﻸ"]],["لإ",["ﻹ","","","ﻺ"]],["لآ",["ﻵ","","","ﻶ"]]],"configuration":{"delete_harakat":false,"shift_harakat_position":false,"use_unshaped_instead_of_isolated":true,"support_ligatures":true,"ARABIC LIGATURE BISMILLAH AR-RAHMAN AR-RAHEEM":false,"ARABIC LIGATURE JALLAJALALOUHOU":false,"ARABIC LIGATURE SALLALLAHOU ALAYHE WASALLAM":false,"ARABIC LIGATURE ALLAH":false,"ARABIC LIGATURE AKBAR":false,"ARABIC LIGATURE ALAYHE":false,"ARABIC LIGATURE MOHAMMAD":false,"ARABIC LIGATURE RASOUL":false,"ARABIC LIGATURE SALAM":false,"ARABIC LIGATURE SALLA":false,"ARABIC LIGATURE WASALLAM":false,"RIAL SIGN":false,"ARABIC LIGATURE AIN WITH ALEF MAKSURA":false,"ARABIC LIGATURE AIN WITH JEEM":false,"ARABIC LIGATURE AIN WITH JEEM WITH MEEM":false,"ARABIC LIGATURE AIN WITH MEEM":false,"ARABIC LIGATURE AIN WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE AIN WITH MEEM WITH MEEM":false,"ARABIC LIGATURE AIN WITH MEEM WITH YEH":false,"ARABIC LIGATURE AIN WITH YEH":false,"ARABIC LIGATURE ALEF MAKSURA WITH SUPERSCRIPT ALEF":false,"ARABIC LIGATURE ALEF WITH FATHATAN":false,"ARABIC LIGATURE BEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE BEH WITH HAH":false,"ARABIC LIGATURE BEH WITH HAH WITH YEH":false,"ARABIC LIGATURE BEH WITH HEH":false,"ARABIC LIGATURE BEH WITH JEEM":false,"ARABIC LIGATURE BEH WITH KHAH":false,"ARABIC LIGATURE BEH WITH KHAH WITH YEH":false,"ARABIC LIGATURE BEH WITH MEEM":false,"ARABIC LIGATURE BEH WITH NOON":false,"ARABIC LIGATURE BEH WITH REH":false,"ARABIC LIGATURE BEH WITH YEH":false,"ARABIC LIGATURE BEH WITH ZAIN":false,"ARABIC LIGATURE DAD WITH ALEF MAKSURA":false,"ARABIC LIGATURE DAD WITH HAH":false,"ARABIC LIGATURE DAD WITH HAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE DAD WITH HAH WITH YEH":false,"ARABIC LIGATURE DAD WITH JEEM":false,"ARABIC LIGATURE DAD WITH KHAH":false,"ARABIC LIGATURE DAD WITH KHAH WITH MEEM":false,"ARABIC LIGATURE DAD WITH MEEM":false,"ARABIC LIGATURE DAD WITH REH":false,"ARABIC LIGATURE DAD WITH YEH":false,"ARABIC LIGATURE FEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE FEH WITH HAH":false,"ARABIC LIGATURE FEH WITH JEEM":false,"ARABIC LIGATURE FEH WITH KHAH":false,"ARABIC LIGATURE FEH WITH KHAH WITH MEEM":false,"ARABIC LIGATURE FEH WITH MEEM":false,"ARABIC LIGATURE FEH WITH MEEM WITH YEH":false,"ARABIC LIGATURE FEH WITH YEH":false,"ARABIC LIGATURE GHAIN WITH ALEF MAKSURA":false,"ARABIC LIGATURE GHAIN WITH JEEM":false,"ARABIC LIGATURE GHAIN WITH MEEM":false,"ARABIC LIGATURE GHAIN WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE GHAIN WITH MEEM WITH MEEM":false,"ARABIC LIGATURE GHAIN WITH MEEM WITH YEH":false,"ARABIC LIGATURE GHAIN WITH YEH":false,"ARABIC LIGATURE HAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE HAH WITH JEEM":false,"ARABIC LIGATURE HAH WITH JEEM WITH YEH":false,"ARABIC LIGATURE HAH WITH MEEM":false,"ARABIC LIGATURE HAH WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE HAH WITH MEEM WITH YEH":false,"ARABIC LIGATURE HAH WITH YEH":false,"ARABIC LIGATURE HEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE HEH WITH JEEM":false,"ARABIC LIGATURE HEH WITH MEEM":false,"ARABIC LIGATURE HEH WITH MEEM WITH JEEM":false,"ARABIC LIGATURE HEH WITH MEEM WITH MEEM":false,"ARABIC LIGATURE HEH WITH SUPERSCRIPT ALEF":false,"ARABIC LIGATURE HEH WITH YEH":false,"ARABIC LIGATURE JEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE JEEM WITH HAH":false,"ARABIC LIGATURE JEEM WITH HAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE JEEM WITH HAH WITH YEH":false,"ARABIC LIGATURE JEEM WITH MEEM":false,"ARABIC LIGATURE JEEM WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE JEEM WITH MEEM WITH HAH":false,"ARABIC LIGATURE JEEM WITH MEEM WITH YEH":false,"ARABIC LIGATURE JEEM WITH YEH":false,"ARABIC LIGATURE KAF WITH ALEF":false,"ARABIC LIGATURE KAF WITH ALEF MAKSURA":false,"ARABIC LIGATURE KAF WITH HAH":false,"ARABIC LIGATURE KAF WITH JEEM":false,"ARABIC LIGATURE KAF WITH KHAH":false,"ARABIC LIGATURE KAF WITH LAM":false,"ARABIC LIGATURE KAF WITH MEEM":false,"ARABIC LIGATURE KAF WITH MEEM WITH MEEM":false,"ARABIC LIGATURE KAF WITH MEEM WITH YEH":false,"ARABIC LIGATURE KAF WITH YEH":false,"ARABIC LIGATURE KHAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE KHAH WITH HAH":false,"ARABIC LIGATURE KHAH WITH JEEM":false,"ARABIC LIGATURE KHAH WITH MEEM":false,"ARABIC LIGATURE KHAH WITH YEH":false,"ARABIC LIGATURE LAM WITH ALEF":true,"ARABIC LIGATURE LAM WITH ALEF MAKSURA":false,"ARABIC LIGATURE LAM WITH ALEF WITH HAMZA ABOVE":true,"ARABIC LIGATURE LAM WITH ALEF WITH HAMZA BELOW":true,"ARABIC LIGATURE LAM WITH ALEF WITH MADDA ABOVE":true,"ARABIC LIGATURE LAM WITH HAH":false,"ARABIC LIGATURE LAM WITH HAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE LAM WITH HAH WITH MEEM":false,"ARABIC LIGATURE LAM WITH HAH WITH YEH":false,"ARABIC LIGATURE LAM WITH HEH":false,"ARABIC LIGATURE LAM WITH JEEM":false,"ARABIC LIGATURE LAM WITH JEEM WITH JEEM":false,"ARABIC LIGATURE LAM WITH JEEM WITH MEEM":false,"ARABIC LIGATURE LAM WITH JEEM WITH YEH":false,"ARABIC LIGATURE LAM WITH KHAH":false,"ARABIC LIGATURE LAM WITH KHAH WITH MEEM":false,"ARABIC LIGATURE LAM WITH MEEM":false,"ARABIC LIGATURE LAM WITH MEEM WITH HAH":false,"ARABIC LIGATURE LAM WITH MEEM WITH YEH":false,"ARABIC LIGATURE LAM WITH YEH":false,"ARABIC LIGATURE MEEM WITH ALEF":false,"ARABIC LIGATURE MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE MEEM WITH HAH":false,"ARABIC LIGATURE MEEM WITH HAH WITH JEEM":false,"ARABIC LIGATURE MEEM WITH HAH WITH MEEM":false,"ARABIC LIGATURE MEEM WITH HAH WITH YEH":false,"ARABIC LIGATURE MEEM WITH JEEM":false,"ARABIC LIGATURE MEEM WITH JEEM WITH HAH":false,"ARABIC LIGATURE MEEM WITH JEEM WITH KHAH":false,"ARABIC LIGATURE MEEM WITH JEEM WITH MEEM":false,"ARABIC LIGATURE MEEM WITH JEEM WITH YEH":false,"ARABIC LIGATURE MEEM WITH KHAH":false,"ARABIC LIGATURE MEEM WITH KHAH WITH JEEM":false,"ARABIC LIGATURE MEEM WITH KHAH WITH MEEM":false,"ARABIC LIGATURE MEEM WITH KHAH WITH YEH":false,"ARABIC LIGATURE MEEM WITH MEEM":false,"ARABIC LIGATURE MEEM WITH MEEM WITH YEH":false,"ARABIC LIGATURE MEEM WITH YEH":false,"ARABIC LIGATURE NOON WITH ALEF MAKSURA":false,"ARABIC LIGATURE NOON WITH HAH":false,"ARABIC LIGATURE NOON WITH HAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE NOON WITH HAH WITH MEEM":false,"ARABIC LIGATURE NOON WITH HAH WITH YEH":false,"ARABIC LIGATURE NOON WITH HEH":false,"ARABIC LIGATURE NOON WITH JEEM":false,"ARABIC LIGATURE NOON WITH JEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE NOON WITH JEEM WITH HAH":false,"ARABIC LIGATURE NOON WITH JEEM WITH MEEM":false,"ARABIC LIGATURE NOON WITH JEEM WITH YEH":false,"ARABIC LIGATURE NOON WITH KHAH":false,"ARABIC LIGATURE NOON WITH MEEM":false,"ARABIC LIGATURE NOON WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE NOON WITH MEEM WITH YEH":false,"ARABIC LIGATURE NOON WITH NOON":false,"ARABIC LIGATURE NOON WITH REH":false,"ARABIC LIGATURE NOON WITH YEH":false,"ARABIC LIGATURE NOON WITH ZAIN":false,"ARABIC LIGATURE QAF WITH ALEF MAKSURA":false,"ARABIC LIGATURE QAF WITH HAH":false,"ARABIC LIGATURE QAF WITH MEEM":false,"ARABIC LIGATURE QAF WITH MEEM WITH HAH":false,"ARABIC LIGATURE QAF WITH MEEM WITH MEEM":false,"ARABIC LIGATURE QAF WITH MEEM WITH YEH":false,"ARABIC LIGATURE QAF WITH YEH":false,"ARABIC LIGATURE QALA USED AS KORANIC STOP SIGN":false,"ARABIC LIGATURE REH WITH SUPERSCRIPT ALEF":false,"ARABIC LIGATURE SAD WITH ALEF MAKSURA":false,"ARABIC LIGATURE SAD WITH HAH":false,"ARABIC LIGATURE SAD WITH HAH WITH HAH":false,"ARABIC LIGATURE SAD WITH HAH WITH YEH":false,"ARABIC LIGATURE SAD WITH KHAH":false,"ARABIC LIGATURE SAD WITH MEEM":false,"ARABIC LIGATURE SAD WITH MEEM WITH MEEM":false,"ARABIC LIGATURE SAD WITH REH":false,"ARABIC LIGATURE SAD WITH YEH":false,"ARABIC LIGATURE SALLA USED AS KORANIC STOP SIGN":false,"ARABIC LIGATURE SEEN WITH ALEF MAKSURA":false,"ARABIC LIGATURE SEEN WITH HAH":false,"ARABIC LIGATURE SEEN WITH HAH WITH JEEM":false,"ARABIC LIGATURE SEEN WITH HEH":false,"ARABIC LIGATURE SEEN WITH JEEM":false,"ARABIC LIGATURE SEEN WITH JEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE SEEN WITH JEEM WITH HAH":false,"ARABIC LIGATURE SEEN WITH KHAH":false,"ARABIC LIGATURE SEEN WITH KHAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE SEEN WITH KHAH WITH YEH":false,"ARABIC LIGATURE SEEN WITH MEEM":false,"ARABIC LIGATURE SEEN WITH MEEM WITH HAH":false,"ARABIC LIGATURE SEEN WITH MEEM WITH JEEM":false,"ARABIC LIGATURE SEEN WITH MEEM WITH MEEM":false,"ARABIC LIGATURE SEEN WITH REH":false,"ARABIC LIGATURE SEEN WITH YEH":false,"ARABIC LIGATURE SHADDA WITH DAMMATAN ISOLATED FORM":false,"ARABIC LIGATURE SHADDA WITH KASRATAN ISOLATED FORM":false,"ARABIC LIGATURE SHADDA WITH FATHA ISOLATED FORM":false,"ARABIC LIGATURE SHADDA WITH DAMMA ISOLATED FORM":false,"ARABIC LIGATURE SHADDA WITH KASRA ISOLATED FORM":false,"ARABIC LIGATURE SHADDA WITH SUPERSCRIPT ALEF":false,"ARABIC LIGATURE SHADDA WITH FATHA MEDIAL FORM":false,"ARABIC LIGATURE SHADDA WITH DAMMA MEDIAL FORM":false,"ARABIC LIGATURE SHADDA WITH KASRA MEDIAL FORM":false,"ARABIC LIGATURE SHADDA WITH FATHA":false,"ARABIC LIGATURE SHADDA WITH DAMMA":false,"ARABIC LIGATURE SHADDA WITH KASRA":false,"ARABIC LIGATURE SHEEN WITH ALEF MAKSURA":false,"ARABIC LIGATURE SHEEN WITH HAH":false,"ARABIC LIGATURE SHEEN WITH HAH WITH MEEM":false,"ARABIC LIGATURE SHEEN WITH HAH WITH YEH":false,"ARABIC LIGATURE SHEEN WITH HEH":false,"ARABIC LIGATURE SHEEN WITH JEEM":false,"ARABIC LIGATURE SHEEN WITH JEEM WITH YEH":false,"ARABIC LIGATURE SHEEN WITH KHAH":false,"ARABIC LIGATURE SHEEN WITH MEEM":false,"ARABIC LIGATURE SHEEN WITH MEEM WITH KHAH":false,"ARABIC LIGATURE SHEEN WITH MEEM WITH MEEM":false,"ARABIC LIGATURE SHEEN WITH REH":false,"ARABIC LIGATURE SHEEN WITH YEH":false,"ARABIC LIGATURE TAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE TAH WITH HAH":false,"ARABIC LIGATURE TAH WITH MEEM":false,"ARABIC LIGATURE TAH WITH MEEM WITH HAH":false,"ARABIC LIGATURE TAH WITH MEEM WITH MEEM":false,"ARABIC LIGATURE TAH WITH MEEM WITH YEH":false,"ARABIC LIGATURE TAH WITH YEH":false,"ARABIC LIGATURE TEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE TEH WITH HAH":false,"ARABIC LIGATURE TEH WITH HAH WITH JEEM":false,"ARABIC LIGATURE TEH WITH HAH WITH MEEM":false,"ARABIC LIGATURE TEH WITH HEH":false,"ARABIC LIGATURE TEH WITH JEEM":false,"ARABIC LIGATURE TEH WITH JEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE TEH WITH JEEM WITH MEEM":false,"ARABIC LIGATURE TEH WITH JEEM WITH YEH":false,"ARABIC LIGATURE TEH WITH KHAH":false,"ARABIC LIGATURE TEH WITH KHAH WITH ALEF MAKSURA":false,"ARABIC LIGATURE TEH WITH KHAH WITH MEEM":false,"ARABIC LIGATURE TEH WITH KHAH WITH YEH":false,"ARABIC LIGATURE TEH WITH MEEM":false,"ARABIC LIGATURE TEH WITH MEEM WITH ALEF MAKSURA":false,"ARABIC LIGATURE TEH WITH MEEM WITH HAH":false,"ARABIC LIGATURE TEH WITH MEEM WITH JEEM":false,"ARABIC LIGATURE TEH WITH MEEM WITH KHAH":false,"ARABIC LIGATURE TEH WITH MEEM WITH YEH":false,"ARABIC LIGATURE TEH WITH NOON":false,"ARABIC LIGATURE TEH WITH REH":false,"ARABIC LIGATURE TEH WITH YEH":false,"ARABIC LIGATURE TEH WITH ZAIN":false,"ARABIC LIGATURE THAL WITH SUPERSCRIPT ALEF":false,"ARABIC LIGATURE THEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE THEH WITH HEH":false,"ARABIC LIGATURE THEH WITH JEEM":false,"ARABIC LIGATURE THEH WITH MEEM":false,"ARABIC LIGATURE THEH WITH NOON":false,"ARABIC LIGATURE THEH WITH REH":false,"ARABIC LIGATURE THEH WITH YEH":false,"ARABIC LIGATURE THEH WITH ZAIN":false,"ARABIC LIGATURE UIGHUR KIRGHIZ YEH WITH HAMZA ABOVE WITH ALEF MAKSURA":false,"ARABIC LIGATURE YEH WITH ALEF MAKSURA":false,"ARABIC LIGATURE YEH WITH HAH":false,"ARABIC LIGATURE YEH WITH HAH WITH YEH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH AE":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH ALEF":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH ALEF MAKSURA":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH E":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH HAH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH HEH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH JEEM":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH KHAH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH MEEM":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH NOON":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH OE":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH REH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH U":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH WAW":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH YEH":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH YU":false,"ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH ZAIN":false,"ARABIC LIGATURE YEH WITH HEH":false,"ARABIC LIGATURE YEH WITH JEEM":false,"ARABIC LIGATURE YEH WITH JEEM WITH YEH":false,"ARABIC LIGATURE YEH WITH KHAH":false,"ARABIC LIGATURE YEH WITH MEEM":false,"ARABIC LIGATURE YEH WITH MEEM WITH MEEM":false,"ARABIC LIGATURE YEH WITH MEEM WITH YEH":false,"ARABIC LIGATURE YEH WITH NOON":false,"ARABIC LIGATURE YEH WITH REH":false,"ARABIC LIGATURE YEH WITH YEH":false,"ARABIC LIGATURE YEH WITH ZAIN":false,"ARABIC LIGATURE ZAH WITH MEEM":false},"supported":" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_abcdefghijklmnopqrstuvwxyz{|}~ ¡¢£©«­®°±´·»¿ÀÁÂÄÇÈÉÊËÌÍÎÏÑÒÓÔÖ×ÙÚÛÜßàáâäçèéêëìíîïñòóôö÷ùúûüŒœ،ؕ؛؟ءآأؤإئابةتثجحخدذرزسشصضطظعغـفقكلمنهوىيًٌٍَُِّْٖٓٔٗ٘٠١٢٣٤٥٦٧٨٩٪٫٬٭ٰپچڤ۔۰۱۲۳۴۵۶۷۸۹–—‘’‚“”„•…‰‹›※€™←↑→↓−∞∱∴≠①⑦ⒶⒷⓁⓇⓍⓎ■□▲△▼▽◆◇○●☁☂☃★☆☎☓☹☺☻☼♠♣♥♦♩♭♯⚀✉✚➡⦾⬅⬆⬇ 、。「」〒ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわをん゛゜ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヲンヵヶﭖﭗﭘﭙﭪﭫﭬﭭﭺﭻﭼﭽﰉﰊﱙﱚﱮﱯﲕﲖ﴾﴿ﷲﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ!/?\~","marks":"ًٌٍَُِّْٰٖؕٓٔٗ٘","mark_ligatures":[["ّٰ","glyph:shaddaAlefabovear"],["ًّ","glyph:shaddaFathatanar"],["ٌّ","glyph:shaddaDammatanar"],["ٍّ","glyph:shaddaKasratanar"],["َّ","glyph:shaddaFathaar"],["ُّ","glyph:shaddaDammaar"],["ِّ","glyph:shaddaKasraar"],["ّٰ","glyph:shaddaAlefabovear"],["ًّ","glyph:shaddaFathatanar"],["ٌّ","glyph:shaddaDammatanar"],["ٍّ","glyph:shaddaKasratanar"],["َّ","glyph:shaddaFathaar"],["ُّ","glyph:shaddaDammaar"],["ِّ","glyph:shaddaKasraar"]],"positioning":[{"kind":"base","marks":{"uni0615":[1,0,284],"uni0670":[1,0,284],"uni0656":[1,0,284],"uni0654":[1,0,284],"uni064B":[1,0,284],"uni064C":[1,0,284],"uni064D":[0,0,284],"uni064E":[1,0,284],"uni064F":[1,0,284],"uni0650":[0,0,284],"uni0651":[1,0,284],"uni0652":[1,0,284],"uni0653":[1,0,284],"shaddaAlefabovear":[1,0,284],"shaddaDammaar":[1,0,284],"shaddaDammatanar":[1,0,284],"shaddaFathaar":[1,0,284],"shaddaFathatanar":[1,0,255],"shaddaKasraar":[1,0,284],"shaddaKasratanar":[1,0,284],"uni0658":[1,0,850],"uni0657":[1,0,284]},"bases":{"uni0621":[[354,0],[354,710]],"uni0627":[[213,0],[213,1134]],"uniFE8E":[[213,0],[213,1134]],"uni0623":[[213,0],[213,1559]],"uni0623.001":[[213,0],[213,1559]],"uniFE84":[[213,0],[213,1559]],"uni0625":[[213,-425],[213,1134]],"uniFE88":[[211,-425],[211,1134]],"uni0622":[[213,0],[213,1417]],"uniFE82":[[213,0],[213,1417]],"uni0628":[[780,-284],[354,567]],"uniFE90":[[780,-284],[354,567]],"uniFE92":[[142,-284],[142,567]],"uniFE91":[[142,-284],[142,567]],"uni067E":[[780,-426],[354,567]],"uniFB57":[[780,-426],[354,567]],"uniFB59":[[283,-426],[425,566]],"uniFB58":[[283,-426],[425,566]],"uni062A":[[780,0],[780,850]],"uniFE96":[[780,0],[780,852]],"uniFE98":[[283,0],[425,851]],"uniFE98.ss01":[[142,0],[141,1136]],"uniFE97":[[283,0],[425,851]],"uniFE97.ss01":[[142,0],[141,1136]],"uni062B":[[780,0],[780,992]],"uniFE9A":[[780,0],[780,992]],"uniFE9C":[[283,0],[425,993]],"uniFE9C.ss01":[[142,0],[141,1420]],"uniFE9B":[[283,0],[425,993]],"uniFE9B.ss01":[[142,0],[141,1420]],"uni062C":[[638,-426],[496,709]],"uniFE9E":[[638,-426],[496,709]],"uniFEA0":[[425,-285],[425,709]],"uniFE9F":[[425,-285],[425,709]],"uni0686":[[780,-426],[496,709]],"uniFB7B":[[780,-426],[496,709]],"uniFB7D":[[425,-426],[425,709]],"uniFB7C":[[425,-426],[425,709]],"uni062D":[[496,-426],[496,709]],"uniFEA2":[[496,-426],[496,709]],"uniFEA4":[[425,0],[425,709]],"uniFEA3":[[425,0],[425,709]],"uni062E":[[496,-426],[496,992]],"uniFEA6":[[496,-426],[496,992]],"uniFEA8":[[425,0],[425,993]],"uniFEA7":[[425,0],[425,993]],"uni062F":[[213,0],[354,710]],"uniFEAA":[[213,0],[354,710]],"uni0630":[[213,0],[354,992]],"uniFEAC":[[213,0],[354,992]],"uni0631":[[212,-425],[212,569]],"uniFEAE":[[212,-425],[212,569]],"uni0632":[[212,-425],[212,852]],"uniFEB0":[[212,-425],[212,852]],"uni0633":[[354,-426],[779,567]],"uniFEB2":[[354,-426],[779,567]],"uniFEB4":[[425,0],[425,992]],"uniFEB3":[[425,0],[425,567]],"uni0634":[[354,-426],[780,992]],"uniFEB6":[[354,-426],[779,992]],"uniFEB8":[[425,0],[425,992]],"uniFEB7":[[425,0],[425,992]],"uni0635":[[354,-425],[921,710]],"uniFEBA":[[354,-425],[921,710]],"uniFEBC":[[567,0],[567,710]],"uniFEBB":[[567,0],[567,710]],"uni0636":[[354,-425],[921,991]],"uniFEBE":[[354,-425],[921,991]],"uniFEC0":[[567,0],[567,992]],"uniFEBF":[[567,0],[567,992]],"uni0637":[[638,0],[354,1134]],"uniFEC2":[[638,0],[354,1134]],"uniFEC4":[[567,0],[283,1134]],"uniFEC3":[[567,0],[283,1134]],"uni0638":[[638,0],[638,992]],"uniFEC6":[[638,0],[638,992]],"uniFEC8":[[567,0],[567,992]],"uniFEC7":[[567,0],[567,992]],"uni0639":[[496,-426],[496,710]],"uniFECA":[[496,-426],[496,710]],"uniFECC":[[425,0],[425,710]],"uniFECB":[[283,0],[283,710]],"uni063A":[[496,-426],[496,992]],"uniFECE":[[496,-426],[496,992]],"uniFED0":[[425,0],[425,992]],"uniFECF":[[283,0],[283,992]],"uni0641":[[1062,0],[1062,992]],"uniFED2":[[1062,0],[1062,992]],"uniFED4":[[425,0],[425,992]],"uniFED3":[[425,0],[425,992]],"uni06A4":[[1063,0],[1063,1134]],"uniFB6B":[[1063,0],[1063,1134]],"uniFB6D":[[425,0],[425,1134]],"uniFB6C":[[425,0],[425,1134]],"uni0642":[[496,-426],[638,992]],"uniFED6":[[496,-426],[638,992]],"uniFED8":[[425,0],[426,992]],"uniFED7":[[425,0],[425,993]],"uni0643":[[638,0],[496,851]],"uniFEDA":[[638,0],[496,851]],"uniFEDC":[[567,0],[567,1134]],"uniFEDB":[[567,0],[567,1134]],"uni0644":[[354,-426],[496,1134]],"uniFEDE":[[354,-426],[496,1135]],"uniFEE0":[[283,0],[283,1134]],"uniFEE0.alt":[[283,0],[283,1134]],"uniFEDF":[[283,0],[283,1134]],"uniFEDF.alt":[[275,0],[283,1134]],"uni0645":[[213,-426],[496,710]],"uniFEE2":[[213,-426],[496,710]],"uniFEE4":[[425,0],[425,710]],"uniFEE3":[[425,0],[425,710]],"uni0646":[[496,-426],[354,568]],"uniFEE6":[[496,-426],[354,568]],"uniFEE8":[[142,0],[142,852]],"uniFEE7":[[142,0],[142,852]],"uni0647":[[354,0],[354,710]],"uniFEEA":[[354,0],[213,710]],"uniFEEC":[[567,0],[567,710]],"uniFEEB":[[567,0],[567,710]],"uni0629":[[354,0],[354,992]],"uniFE94":[[354,0],[354,993]],"uni0648":[[354,-426],[354,710]],"uniFEEE":[[354,-426],[354,710]],"uni0624":[[354,-426],[354,1275]],"uniFE86":[[354,-426],[354,1275]],"uni0649":[[638,-426],[780,710]],"uniFEF0":[[638,-426],[496,426]],"uni064A":[[638,-709],[780,710]],"uniFEF2":[[638,-709],[496,426]],"uniFEF4":[[283,-284],[425,566]],"uniFEF4.ss01":[[141,-568],[142,567]],"uniFEF3":[[283,-284],[425,566]],"uniFEF3.ss01":[[141,-568],[142,567]],"uni0626":[[638,-426],[213,852]],"uniFE8A":[[638,-426],[213,852]],"uniFE8C":[[283,0],[280,1121]],"uniFE8B":[[283,0],[280,1121]],"uni0640":[[142,0],[142,284]]}},{"kind":"ligature","marks":{"uni0615":[0,0,284],"uni0670":[0,0,284],"uni0656":[0,0,284],"uni0654":[0,0,284],"uni064B":[0,0,284],"uni064C":[0,0,284],"uni064D":[1,0,284],"uni064E":[0,0,284],"uni064F":[0,0,284],"uni0650":[1,0,284],"uni0651":[0,0,284],"uni0652":[0,0,284],"uni0653":[0,0,284],"shaddaAlefabovear":[0,0,284],"shaddaDammaar":[0,0,284],"shaddaDammatanar":[0,0,284],"shaddaFathaar":[0,0,284],"shaddaFathatanar":[0,0,255],"shaddaKasraar":[0,0,284],"shaddaKasratanar":[0,0,284],"uni0658":[0,0,850],"uni0657":[0,0,284]},"bases":{"uniFEFB":[[[354,1134],[496,0]],[[354,1134],[496,0]]],"uniFEFC":[[[354,992],[354,0]],[[354,992],[354,0]]],"uniFEF7":[[[354,1559],[496,0]],[[354,1559],[496,0]]],"uniFEF8":[[[496,1559],[354,0]],[[496,1559],[354,0]]],"uniFEF9":[[[354,1134],[496,-426]],[[354,1134],[496,-426]]],"uniFEFA":[[[354,992],[354,-426]],[[354,992],[354,-426]]],"uniFEF5":[[[355,1418],[497,0]],[[355,1418],[497,0]]],"uniFEF6":[[[354,1418],[354,0]],[[354,1418],[354,0]]],"uniFC09":[[[1205,567],[1205,-284]],[[496,426],[638,-426]]],"uniFC6E":[[[1205,567],[1205,-284]],[[496,426],[638,-426]]],"uniFC0A":[[[1205,567],[1205,-284]],[[496,426],[638,-709]]],"uniFC6F":[[[1205,567],[1205,-284]],[[496,426],[638,-709]]],"uni06280626":[[[1205,567],[1205,-284]],[[354,852],[638,-426]]],"uni06280626.fina":[[[1205,567],[1205,-284]],[[354,852],[638,-426]]],"uni067E0649":[[[1488,566],[1346,-426]],[[496,426],[638,-426]]],"uni067E0649.fina":[[[1488,566],[1346,-426]],[[496,426],[638,-426]]],"uni067E064A":[[[1488,566],[1488,-426]],[[496,426],[638,-709]]],"uni067E064A.fina":[[[1488,566],[1488,-426]],[[496,426],[638,-709]]],"uni067E0626":[[[1488,566],[1488,-426]],[[354,852],[638,-426]]],"uni067E0626.fina":[[[1488,566],[1488,-426]],[[354,852],[638,-426]]],"uniFC59":[[[1488,566],[1488,-284]],[[496,426],[638,-426]]],"uniFC95":[[[1488,566],[1488,-284]],[[496,426],[638,-426]]],"uni064A0626":[[[1488,566],[1488,-284]],[[348,830],[638,-426]]],"uni064A0626.fina":[[[1488,566],[1488,-284]],[[348,830],[638,-426]]]}},{"kind":"mark","marks":{"uni064D":[0,0,284],"uni0650":[0,0,284]},"bases":{"uni064D":[[0,284]],"uni0650":[[0,284]]}},{"kind":"mark","marks":{"uni0615":[0,0,284],"uni0670":[0,0,284],"uni0656":[0,0,284],"uni0654":[0,0,284],"uni064B":[0,0,284],"uni064C":[0,0,284],"uni064E":[0,0,284],"uni064F":[0,0,284],"uni0651":[0,0,284],"uni0652":[0,0,284],"uni0653":[0,0,284],"shaddaAlefabovear":[0,0,284],"shaddaDammaar":[0,0,284],"shaddaDammatanar":[0,0,284],"shaddaFathaar":[0,0,284],"shaddaFathatanar":[0,0,255],"shaddaKasraar":[0,0,284],"shaddaKasratanar":[0,0,284],"uni0658":[0,0,850],"uni0657":[0,0,284]},"bases":{"uni0670":[[0,284]],"uni0656":[[0,284]],"uni0654":[[0,284]],"uni064B":[[0,284]],"uni064C":[[0,284]],"uni064E":[[0,284]],"uni064F":[[0,284]],"uni0651":[[0,284]],"uni0652":[[0,284]],"uni0653":[[0,284]],"shaddaAlefabovear":[[0,284]],"shaddaDammaar":[[0,284]],"shaddaDammatanar":[[0,284]],"shaddaFathaar":[[0,284]],"shaddaFathatanar":[[0,256]],"shaddaKasraar":[[0,284]],"shaddaKasratanar":[[0,284]],"uni0658":[[0,850]],"uni0657":[[0,284]]}}],"pixel_scale":0.013333333333333334} \ No newline at end of file diff --git a/assets/flipnote-editor/base.png b/assets/flipnote-editor/base.png new file mode 100644 index 0000000..47217fc Binary files /dev/null and b/assets/flipnote-editor/base.png differ diff --git a/assets/flipnote-editor/bidi-LICENSE.txt b/assets/flipnote-editor/bidi-LICENSE.txt new file mode 100644 index 0000000..2dabff0 --- /dev/null +++ b/assets/flipnote-editor/bidi-LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2021 Jason Johnston + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/assets/flipnote-editor/bidi.min.js b/assets/flipnote-editor/bidi.min.js new file mode 100644 index 0000000..bd0ca4d --- /dev/null +++ b/assets/flipnote-editor/bidi.min.js @@ -0,0 +1 @@ +!function(r,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(r="undefined"!=typeof globalThis?globalThis:r||self).bidi_js=e()}(this,(function(){"use strict";return function(){return function(r){var e={R:"13k,1a,2,3,3,2+1j,ch+16,a+1,5+2,2+n,5,a,4,6+16,4+3,h+1b,4mo,179q,2+9,2+11,2i9+7y,2+68,4,3+4,5+13,4+3,2+4k,3+29,8+cf,1t+7z,w+17,3+3m,1t+3z,16o1+5r,8+30,8+mc,29+1r,29+4v,75+73",EN:"1c+9,3d+1,6,187+9,513,4+5,7+9,sf+j,175h+9,qw+q,161f+1d,4xt+a,25i+9",ES:"17,2,6dp+1,f+1,av,16vr,mx+1,4o,2",ET:"z+2,3h+3,b+1,ym,3e+1,2o,p4+1,8,6u,7c,g6,1wc,1n9+4,30+1b,2n,6d,qhx+1,h0m,a+1,49+2,63+1,4+1,6bb+3,12jj",AN:"16o+5,2j+9,2+1,35,ed,1ff2+9,87+u",CS:"18,2+1,b,2u,12k,55v,l,17v0,2,3,53,2+1,b",B:"a,3,f+2,2v,690",S:"9,2,k",WS:"c,k,4f4,1vk+a,u,1j,335",ON:"x+1,4+4,h+5,r+5,r+3,z,5+3,2+1,2+1,5,2+2,3+4,o,w,ci+1,8+d,3+d,6+8,2+g,39+1,9,6+1,2,33,b8,3+1,3c+1,7+1,5r,b,7h+3,sa+5,2,3i+6,jg+3,ur+9,2v,ij+1,9g+9,7+a,8m,4+1,49+x,14u,2+2,c+2,e+2,e+2,e+1,i+n,e+e,2+p,u+2,e+2,36+1,2+3,2+1,b,2+2,6+5,2,2,2,h+1,5+4,6+3,3+f,16+2,5+3l,3+81,1y+p,2+40,q+a,m+13,2r+ch,2+9e,75+hf,3+v,2+2w,6e+5,f+6,75+2a,1a+p,2+2g,d+5x,r+b,6+3,4+o,g,6+1,6+2,2k+1,4,2j,5h+z,1m+1,1e+f,t+2,1f+e,d+3,4o+3,2s+1,w,535+1r,h3l+1i,93+2,2s,b+1,3l+x,2v,4g+3,21+3,kz+1,g5v+1,5a,j+9,n+v,2,3,2+8,2+1,3+2,2,3,46+1,4+4,h+5,r+5,r+a,3h+2,4+6,b+4,78,1r+24,4+c,4,1hb,ey+6,103+j,16j+c,1ux+7,5+g,fsh,jdq+1t,4,57+2e,p1,1m,1m,1m,1m,4kt+1,7j+17,5+2r,d+e,3+e,2+e,2+10,m+4,w,1n+5,1q,4z+5,4b+rb,9+c,4+c,4+37,d+2g,8+b,l+b,5+1j,9+9,7+13,9+t,3+1,27+3c,2+29,2+3q,d+d,3+4,4+2,6+6,a+o,8+6,a+2,e+6,16+42,2+1i",BN:"0+8,6+d,2s+5,2+p,e,4m9,1kt+2,2b+5,5+5,17q9+v,7k,6p+8,6+1,119d+3,440+7,96s+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+1,1ekf+75,6p+2rz,1ben+1,1ekf+1,1ekf+1",NSM:"lc+33,7o+6,7c+18,2,2+1,2+1,2,21+a,1d+k,h,2u+6,3+5,3+1,2+3,10,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,g+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+g,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,k1+w,2db+2,3y,2p+v,ff+3,30+1,n9x+3,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,r2,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+5,3+1,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2d+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,f0c+4,1o+6,t5,1s+3,2a,f5l+1,43t+2,i+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,gzhy+6n",AL:"16w,3,2,e+1b,z+2,2+2s,g+1,8+1,b+m,2+t,s+2i,c+e,4h+f,1d+1e,1bwe+dp,3+3z,x+c,2+1,35+3y,2rm+z,5+7,b+5,dt+l,c+u,17nl+27,1t+27,4x+6n,3+d",LRO:"6ct",RLO:"6cu",LRE:"6cq",RLE:"6cr",PDF:"6cs",LRI:"6ee",RLI:"6ef",FSI:"6eg",PDI:"6eh"},f={},a={};f.L=1,a[1]="L",Object.keys(e).forEach((function(r,e){f[r]=1<"),u=t[0],l=t[1];u=String.fromCodePoint(a+=parseInt(u,36)),l=String.fromCodePoint(a+=parseInt(l,36)),n.set(u,l),e&&i.set(l,u)}})),{map:n,reverseMap:i}}function m(){if(!c){var r=k(s,!0),e=r.map,f=r.reverseMap;c=e,d=f,b=k(h,!1).map}}function j(r){return m(),c.get(r)||null}function g(r){return m(),d.get(r)||null}function p(r){return m(),b.get(r)||null}var q=f.L,w=f.R,y=f.EN,x=f.ES,_=f.ET,M=f.AN,z=f.CS,I=f.B,L=f.S,S=f.ON,R=f.BN,O=f.NSM,E=f.AL,N=f.LRO,T=f.RLO,A=f.LRE,D=f.RLE,P=f.PDF,W=f.LRI,B=f.RLI,F=f.FSI,U=f.PDI;var C;function G(r){return function(){if(!C){var r=k("14>1,j>2,t>2,u>2,1a>g,2v3>1,1>1,1ge>1,1wd>1,b>1,1j>1,f>1,ai>3,-2>3,+1,8>1k0,-1jq>1y7,-1y6>1hf,-1he>1h6,-1h5>1ha,-1h8>1qi,-1pu>1,6>3u,-3s>7,6>1,1>1,f>1,1>1,+2,3>1,1>1,+13,4>1,1>1,6>1eo,-1ee>1,3>1mg,-1me>1mk,-1mj>1mi,-1mg>1mi,-1md>1,1>1,+2,1>10k,-103>1,1>1,4>1,5>1,1>1,+10,3>1,1>8,-7>8,+1,-6>7,+1,a>1,1>1,u>1,u6>1,1>1,+5,26>1,1>1,2>1,2>2,8>1,7>1,4>1,1>1,+5,b8>1,1>1,+3,1>3,-2>1,2>1,1>1,+2,c>1,3>1,1>1,+2,h>1,3>1,a>1,1>1,2>1,3>1,1>1,d>1,f>1,3>1,1a>1,1>1,6>1,7>1,13>1,k>1,1>1,+19,4>1,1>1,+2,2>1,1>1,+18,m>1,a>1,1>1,lk>1,1>1,4>1,2>1,f>1,3>1,1>1,+3,db>1,1>1,+3,3>1,1>1,+2,14qm>1,1>1,+1,6>1,4j>1,j>2,t>2,u>2,2>1,+1",!0),e=r.map;r.reverseMap.forEach((function(r,f){e.set(f,r)})),C=e}}(),C.get(r)||null}function H(r,e,f,a){var n=r.length;f=Math.max(0,null==f?0:+f),a=Math.min(n-1,null==a?n-1:+a);var i=[];return e.paragraphs.forEach((function(n){var v=Math.max(f,n.start),o=Math.min(a,n.end);if(v=v&&l(r[c])&t;c--)u[c]=n.level;for(var d=n.level,b=1/0,s=0;sd&&(d=h),h=b;k--)for(var m=0;m=k){for(var j=m;m+1=k;)m++;m>j&&i.push([j+v,m+v])}}})),i}function J(r,e,f,a){for(var n=H(r,e,f,a),i=[],v=0;v0)Q--;else if(X>0){for(V=0;!J[J.length-1].o;)J.pop();var fr=J[J.length-1].t;null!=fr&&(b.set(fr,Y),b.set(Y,fr)),J.pop(),X--}K=J[J.length-1],d[Y]=K.i,K.v&&c(Y,K.v)}else Z&P?(0===Q&&(V>0?V--:!K.o&&J.length>1&&(J.pop(),K=J[J.length-1])),d[Y]=K.i):Z&I&&(d[Y]=h.level);else d[Y]=K.i,K.v&&Z!==R&&c(Y,K.v)}for(var ar=[],nr=null,ir=h.start;ir<=h.end;ir++){var vr=f[ir];if(!(vr&o)){var or=d[ir],tr=vr&n,ur=vr===U;nr&&or===nr.i?(nr.u=ir,nr.l=tr):ar.push(nr={h:ir,u:ir,i:or,k:ur,l:tr})}}for(var lr=[],cr=0;cr=0;wr--)if(!(f[wr]&o)){qr=d[wr];break}var yr=kr[kr.length-1],xr=d[yr],_r=h.level;if(!(f[yr]&n))for(var Mr=yr+1;Mr<=h.end;Mr++)if(!(f[Mr]&o)){_r=d[Mr];break}lr.push({m:kr,j:Math.max(qr,pr)%2?w:q,g:Math.max(_r,xr)%2?w:q})}}for(var zr=0;zr=0;Ar--)if(!(f[Lr[Ar]]&o)){Tr=f[Lr[Ar]];break}c(Nr,Tr&(n|U)?S:Tr)}}if(u.get(y))for(var Dr=0;Dr=-1;Wr--){var Br=-1===Wr?Sr:f[Lr[Wr]];if(Br&i){Br===E&&c(Pr,M);break}}}if(u.get(E))for(var Fr=0;Fr=0&&(Hr=f[Lr[Kr]])&o;Kr--);for(var Qr=Cr+1;Qr=0&&f[Lr[Yr]]&(_|o);Yr--)c(Lr[Yr],y);for(Vr++;Vr=0&&f[Lr[re]]&o;re--)c(Lr[re],S);for(var ee=Zr+1;ee=0;be--){var se=ue[be].char;if(se===de||se===g(p(ce))||j(p(se))===ce){te.push([ue[be].seqIndex,le]),ue.length=be;break}}}te.sort((function(r,e){return r[0]-e[0]}));for(var he=0;he=0;xe--){var _e=Lr[xe];if(f[_e]&oe){var Me=f[_e]&ve?w:q;pe=Me!==Or?Me:Or;break}}}if(pe){if(f[Lr[me]]=f[Lr[je]]=pe,pe!==Or)for(var ze=me+1;ze=0;Ee--){if(!(f[Lr[Ee]]&o)){Oe=f[Lr[Ee]]&ve?w:q;break}Se=Ee}for(var Ne=Rr,Te=Le+1;Te=0&&l(r[Be])&t;Be--)d[Be]=h.level}}return{levels:d,paragraphs:s};function Fe(e,a){for(var i=e;i> 3) & 1; + rngState = old >> 1; + if (bit0 === bit3) rngState |= 0x8000; + rngState &= 0xffff; + return rngState; + } + + function randomRange(range) { + const safeRange = Math.max(1, Math.floor(range)); + return random16() % safeRange; + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function inBounds(x, y) { + return x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT; + } + + function indexOf(x, y) { + return y * WIDTH + x; + } + + function writePixel(mask, x, y, value) { + if (!inBounds(x, y)) return; + mask[indexOf(x, y)] = value ? 1 : 0; + } + + function setPixel(mask, x, y) { + writePixel(mask, x, y, 1); + } + + function clearPixel(mask, x, y) { + writePixel(mask, x, y, 0); + } + + function lineSteps(x0, y0, x1, y1, includeStart) { + let x = Math.round(x0); + let y = Math.round(y0); + const tx = Math.round(x1); + const ty = Math.round(y1); + const points = []; + const dx = Math.abs(tx - x); + const sx = x < tx ? 1 : -1; + const dyAbs = Math.abs(ty - y); + const sy = y < ty ? 1 : -1; + let err = dx - dyAbs; + let first = true; + let previousX = x; + let previousY = y; + + while (true) { + if (includeStart || !first) { + points.push({ + x, + y, + sx: first ? 0 : x - previousX, + sy: first ? 0 : y - previousY, + }); + } + if (x === tx && y === ty) break; + first = false; + previousX = x; + previousY = y; + const e2 = 2 * err; + if (e2 > -dyAbs) { + err -= dyAbs; + x += sx; + } + if (e2 < dx) { + err += dx; + y += sy; + } + } + return points; + } + + function stampSquare(mask, x, y, radius, value) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + writePixel(mask, x + dx, y + dy, value); + } + } + } + + function stampMask(mask, rows, x, y, value) { + const height = rows.length; + const width = rows[0].length; + const ox = Math.floor(width / 2); + const oy = Math.floor(height / 2); + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + if (rows[row][col] === '#') writePixel(mask, x + col - ox, y + row - oy, value); + } + } + } + + function stampDirectionAware(mask, point, value) { + const x = point.x; + const y = point.y; + const sx = point.sx || 0; + const sy = point.sy || 0; + writePixel(mask, x, y, value); + writePixel(mask, x + sy, y, value); + writePixel(mask, x, y + sx, value); + } + + function stampDirectionalEqual(mask, point) { + const x = point.x; + const y = point.y; + const sx = point.sx || 0; + const sy = point.sy || 0; + const horizontalMotion = sx !== 0 && sy === 0; + const verticalMotion = sy !== 0 && sx === 0; + + if (horizontalMotion) { + setPixel(mask, x, y - 1); + setPixel(mask, x, y + 1); + } else if (verticalMotion) { + setPixel(mask, x - 1, y); + setPixel(mask, x + 1, y); + } else if ((sx === sy) || (sx === 0 && sy === 0)) { + setPixel(mask, x - 1, y); + setPixel(mask, x + 1, y); + } else { + setPixel(mask, x, y - 1); + setPixel(mask, x, y + 1); + } + + clearPixel(mask, x, y); + if (Math.abs(sx) >= Math.abs(sy)) setPixel(mask, x, y + (sx || 1)); + else setPixel(mask, x + (sy || 1), y); + } + + function stampSquiggle(mask, point, state) { + const qx = point.x & ~1; + const qy = point.y & ~1; + const anchor = state.squiggleAnchor; + if (anchor && (anchor.x !== qx || anchor.y !== qy)) { + const mx = ((anchor.x + qx) >> 1) & ~1; + const my = ((anchor.y + qy) >> 1) & ~1; + stampSquare(mask, mx, my, 0, 1); + setPixel(mask, mx + 1, my); + setPixel(mask, mx, my + 1); + setPixel(mask, mx + 1, my + 1); + } + setPixel(mask, qx, qy); + setPixel(mask, qx + 1, qy); + setPixel(mask, qx, qy + 1); + setPixel(mask, qx + 1, qy + 1); + state.squiggleAnchor = {x: qx, y: qy}; + } + + function airbrushCandidatePass(segmentMagnitude, large) { + // The ROM consumes the LFSR while walking candidate pixels and folds + // segment delta into its skip logic. The supplied notes do not include + // the exact branch constants, so preserve both deterministic LFSR call + // order and motion sensitivity here pending the direct routine port. + const base = large ? 72 : 98; + const motionPenalty = Math.min(38, Math.max(0, segmentMagnitude - 1) * (large ? 2 : 4)); + return (random16() & 0xff) < Math.max(24, base - motionPenalty); + } + + function stampStochasticMask(mask, rows, point, segmentMagnitude) { + const height = rows.length; + const width = rows[0].length; + const ox = Math.floor(width / 2); + const oy = Math.floor(height / 2); + const large = width > 5; + for (let row = 0; row < height; row++) { + for (let col = 0; col < width; col++) { + if (rows[row][col] !== '#') continue; + if (!airbrushCandidatePass(segmentMagnitude, large)) continue; + setPixel(mask, point.x + col - ox, point.y + row - oy); + } + } + } + + function randomOffset(mode) { + return mode ? randomRange(10) - 5 : randomRange(5) - 2; + } + + function drawOnePixelLine(mask, x0, y0, x1, y1) { + for (const point of lineSteps(x0, y0, x1, y1, true)) setPixel(mask, point.x, point.y); + } + + function drawJitterSegment(mask, x0, y0, x1, y1, mode) { + const ax = clamp(Math.round(x0) + randomOffset(mode), 0, WIDTH - 1); + const ay = clamp(Math.round(y0) + randomOffset(mode), 0, HEIGHT - 1); + const bx = clamp(Math.round(x1) + randomOffset(mode), 0, WIDTH - 1); + const by = clamp(Math.round(y1) + randomOffset(mode), 0, HEIGHT - 1); + drawOnePixelLine(mask, ax, ay, bx, by); + } + + function sprayClass(dx, dy, auxiliaryValue) { + const dynamic = Number(auxiliaryValue) || 0; + if (dynamic) return clamp(Math.ceil(Math.abs(dynamic) / 120), 1, 6); + const d2 = dx * dx + dy * dy; + if (d2 < 4) return 6; + if (d2 < 8) return 5; + if (d2 < 12) return 4; + if (d2 < 24) return 3; + if (d2 < 48) return 2; + return 1; + } + + function drawComplexSpray(mask, x0, y0, x1, y1, mode, auxiliaryValue) { + const dx = x1 - x0; + const dy = y1 - y0; + const cls = sprayClass(dx, dy, auxiliaryValue); + const count = cls + (mode ? 3 : 2); + const spread = (7 - cls) + (mode ? 5 : 2); + for (let i = 0; i < count; i++) { + const scale = mode && (i & 1) ? 2 : 1; + const ax = clamp(Math.round(x0) + (randomRange(spread * 2 + 1) - spread) * scale, 0, WIDTH - 1); + const ay = clamp(Math.round(y0) + (randomRange(spread * 2 + 1) - spread) * scale, 0, HEIGHT - 1); + const bx = clamp(Math.round(x1) + (randomRange(spread * 2 + 1) - spread) * scale, 0, WIDTH - 1); + const by = clamp(Math.round(y1) + (randomRange(spread * 2 + 1) - spread) * scale, 0, HEIGHT - 1); + drawOnePixelLine(mask, ax, ay, bx, by); + } + } + + function paintPattern(id, x, y, motif) { + switch (id & 7) { + case 0: + return x % 3 === 1 && y % 3 === 1; + case 1: + return (x & 1) === 0 && (y & 1) === 0; + case 2: + return (x & 1) === 1; + case 3: + return (y & 1) === 1; + case 4: + return ((x + y) & 1) === 1; + case 5: + return (x & 1) !== 0 || (y & 1) !== 0; + case 6: + return !(x % 3 === 1 && y % 3 === 1); + case 7: + if (motif && motif.data && motif.width && motif.height) { + const mx = ((x % motif.width) + motif.width) % motif.width; + const my = ((y % motif.height) + motif.height) % motif.height; + return !!motif.data[my * motif.width + mx]; + } + // The supplied excerpt identifies slot 7 as a separate action + // but does not include its editor/capture routine. Keep the + // restored editor's neutral criss-cross motif until that ROM + // routine is supplied/ported. + return ((x - y) % 5 === 0) || ((x + y) % 5 === 0); + default: + return false; + } + } + + function stampPaint(mask, logicalId, x, y, options) { + const baseId = logicalId & 7; + const expert = logicalId >= 8; + const paintState = Number(options && options.paintState); + const radius = paintState === 2 ? 3 : (expert ? 7 : 1); + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const px = x + dx; + const py = y + dy; + if (!inBounds(px, py)) continue; + if (paintPattern(baseId, px, py, options && options.motif)) setPixel(mask, px, py); + } + } + } + + + // Bucket-style patterned fill. The connected transparent region is found + // from the pre-fill layer state so setting patterned pixels cannot split + // the traversal while it runs. Slots 0-6 reuse Paint 1-7's absolute-canvas + // predicates. Slot 7 is deliberately solid, per the editor UI spec. + function floodFillPattern(mask, slot, startX, startY, options) { + const sx = Math.round(startX); + const sy = Math.round(startY); + if (!inBounds(sx, sy)) return 0; + const startIndex = sy * WIDTH + sx; + // Normally the current layer is both the destination and the fill + // boundary. Shift-fill can supply the other layer as boundaryMask so + // its ink contains the flood while the current layer still receives + // the selected fill pattern/colour. + const suppliedBoundary = options && options.boundaryMask; + const boundaryMask = suppliedBoundary && suppliedBoundary.length === mask.length + ? suppliedBoundary + : mask; + if (boundaryMask[startIndex]) return 0; + + const original = new Uint8Array(boundaryMask); + const visited = new Uint8Array(WIDTH * HEIGHT); + const queueX = new Int16Array(WIDTH * HEIGHT); + const queueY = new Int16Array(WIDTH * HEIGHT); + let head = 0; + let tail = 0; + let changed = 0; + queueX[tail] = sx; + queueY[tail] = sy; + tail++; + visited[startIndex] = 1; + + const patternId = clamp(Math.round(Number(slot) || 0), 0, 7); + const solid = patternId === 7; + const motif = options && options.motif; + + while (head < tail) { + const x = queueX[head]; + const y = queueY[head]; + head++; + const index = y * WIDTH + x; + + if (solid || paintPattern(patternId, x, y, motif)) { + if (!mask[index]) { + mask[index] = 1; + changed++; + } + } + + if (x > 0) { + const n = index - 1; + if (!visited[n] && !original[n]) { + visited[n] = 1; + queueX[tail] = x - 1; + queueY[tail] = y; + tail++; + } + } + if (x + 1 < WIDTH) { + const n = index + 1; + if (!visited[n] && !original[n]) { + visited[n] = 1; + queueX[tail] = x + 1; + queueY[tail] = y; + tail++; + } + } + if (y > 0) { + const n = index - WIDTH; + if (!visited[n] && !original[n]) { + visited[n] = 1; + queueX[tail] = x; + queueY[tail] = y - 1; + tail++; + } + } + if (y + 1 < HEIGHT) { + const n = index + WIDTH; + if (!visited[n] && !original[n]) { + visited[n] = 1; + queueX[tail] = x; + queueY[tail] = y + 1; + tail++; + } + } + } + return changed; + } + + function eraserContains(slot, dx, dy) { + switch (slot & 7) { + case 0: return dx === 0 && dy === 0; + case 1: return Math.abs(dx) <= 1 && Math.abs(dy) <= 1; + case 2: return dx * dx + dy * dy <= 12; + case 3: return dx * dx + dy * dy <= 42; + case 4: return Math.abs(dx) <= 4 && Math.abs(dy) <= 4; + case 5: return Math.abs(dx) <= 7 && Math.abs(dy) <= 7; + case 6: return Math.abs(dx) <= 1 && Math.abs(dy) <= 6; + case 7: return Math.abs(dy) <= 1 && Math.abs(dx) <= 6; + default: return false; + } + } + + function eraserRadius(slot) { + return [0, 1, 3, 6, 4, 7, 6, 6][slot & 7]; + } + + function stampEraser(mask, logicalId, x, y, options) { + const slot = logicalId & 7; + const expert = logicalId >= 8; + const radius = eraserRadius(slot); + // Advanced Erase keeps the base Eraser geometry, but its candidate + // pixels use the most recently used Paint texture. Paint 5 + // (checkerboard / pattern id 4) is the fallback until Paint has + // actually been used or selected in this editor session. + const requestedPattern = Number(options && options.eraserPatternId); + const eraserPatternId = Number.isFinite(requestedPattern) + ? clamp(Math.round(requestedPattern), 0, 7) + : 4; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (!eraserContains(slot, dx, dy)) continue; + const px = x + dx; + const py = y + dy; + if (expert && !paintPattern(eraserPatternId, px, py, options && options.motif)) continue; + clearPixel(mask, px, py); + } + } + } + + function createStrokeState() { + return { + squiggleAnchor: null, + ringPrevious: null, + }; + } + + function stampPenPoint(mask, logicalId, point, state, segmentMagnitude, options) { + switch (logicalId) { + case 0: + setPixel(mask, point.x, point.y); + break; + case 1: + stampDirectionAware(mask, point, 1); + break; + case 2: + stampSquare(mask, point.x, point.y, 1, 1); + break; + case 3: + stampSquiggle(mask, point, state); + break; + case 4: + stampDirectionalEqual(mask, point); + break; + case 5: + stampStochasticMask(mask, AIRBRUSH_SMALL_MASK, point, segmentMagnitude); + break; + case 8: + stampSquare(mask, point.x, point.y, 2, 1); + break; + case 9: + stampSquare(mask, point.x, point.y, 3, 1); + break; + case 10: + stampSquare(mask, point.x, point.y, 4, 1); + break; + case 11: { + const aux = Number(options && options.auxiliaryValue) || 0; + const d2 = segmentMagnitude * segmentMagnitude; + let cls; + if (aux) cls = clamp(Math.ceil(Math.abs(aux) / 120), 1, 3); + else if (d2 < 4) cls = 3; + else if (d2 < 32) cls = 2; + else cls = 1; + if (cls === 1) setPixel(mask, point.x, point.y); + else if (cls === 2) stampDirectionAware(mask, point, 1); + else stampSquare(mask, point.x, point.y, 1, 1); + break; + } + case 12: { + const previous = state.ringPrevious || {x: point.x, y: point.y}; + stampMask(mask, RING_OUTER_MASK, point.x, point.y, 1); + stampMask(mask, RING_INNER_MASK, previous.x, previous.y, 0); + state.ringPrevious = {x: point.x, y: point.y}; + break; + } + case 13: + stampStochasticMask(mask, AIRBRUSH_LARGE_MASK, point, segmentMagnitude); + break; + default: + setPixel(mask, point.x, point.y); + break; + } + } + + function applyStrokeStart(mask, kind, logicalId, x, y, state, options) { + const point = {x: Math.round(x), y: Math.round(y), sx: 0, sy: 0}; + const strokeState = state || createStrokeState(); + if (kind === 'paint') stampPaint(mask, logicalId, point.x, point.y, options); + else if (kind === 'eraser') stampEraser(mask, logicalId, point.x, point.y, options); + else if (logicalId === 6 || logicalId === 14) { + setPixel(mask, point.x, point.y); + } else if (logicalId === 7 || logicalId === 15) { + setPixel(mask, point.x, point.y); + } else { + stampPenPoint(mask, logicalId, point, strokeState, 0, options); + } + return strokeState; + } + + function applyStrokeSegment(mask, kind, logicalId, x0, y0, x1, y1, state, options) { + const strokeState = state || createStrokeState(); + const dx = Math.round(x1) - Math.round(x0); + const dy = Math.round(y1) - Math.round(y0); + const segmentMagnitude = Math.max(Math.abs(dx), Math.abs(dy)); + + if (kind === 'pen' && (logicalId === 6 || logicalId === 14)) { + drawJitterSegment(mask, x0, y0, x1, y1, logicalId === 14 ? 1 : 0); + return strokeState; + } + if (kind === 'pen' && (logicalId === 7 || logicalId === 15)) { + drawComplexSpray(mask, x0, y0, x1, y1, logicalId === 15 ? 1 : 0, options && options.auxiliaryValue); + return strokeState; + } + + const points = lineSteps(x0, y0, x1, y1, false); + for (const point of points) { + if (kind === 'paint') stampPaint(mask, logicalId, point.x, point.y, options); + else if (kind === 'eraser') stampEraser(mask, logicalId, point.x, point.y, options); + else stampPenPoint(mask, logicalId, point, strokeState, segmentMagnitude, options); + } + return strokeState; + } + + function logicalId(slot, expert) { + return clamp(Math.round(Number(slot) || 0), 0, 7) + (expert ? 8 : 0); + } + + function toolName(kind, logical) { + const id = clamp(Math.round(Number(logical) || 0), 0, 15); + if (kind === 'paint') return PAINT_NAMES[id & 7]; + if (kind === 'fill') return FILL_NAMES[id & 7]; + if (kind === 'eraser') return ERASER_NAMES[id & 7]; + return PEN_NAMES[id]; + } + + global.FlipnoteDrawingEngine = Object.freeze({ + WIDTH, + HEIGHT, + RNG_INITIAL_STATE, + PEN_NAMES: PEN_NAMES.slice(), + PAINT_NAMES: PAINT_NAMES.slice(), + FILL_NAMES: FILL_NAMES.slice(), + ERASER_NAMES: ERASER_NAMES.slice(), + resetRandom, + getRandomState, + random16, + lineSteps, + paintPattern, + floodFillPattern, + logicalId, + toolName, + createStrokeState, + applyStrokeStart, + applyStrokeSegment, + }); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/assets/flipnote-editor/earsHurt.png b/assets/flipnote-editor/earsHurt.png new file mode 100644 index 0000000..726e124 Binary files /dev/null and b/assets/flipnote-editor/earsHurt.png differ diff --git a/assets/flipnote-editor/editor.js b/assets/flipnote-editor/editor.js new file mode 100644 index 0000000..87f7672 --- /dev/null +++ b/assets/flipnote-editor/editor.js @@ -0,0 +1,7265 @@ +const filenameElement = document.getElementById('filename'); + +// Track whether this editor session has made any user-visible change. The +// browser's native beforeunload confirmation is only armed after the first +// opening baseline, not as an edit. +let editorHasUnsavedChanges = false; +let editorDirtyTrackingReady = false; +let editorDirtyTrackingSuspended = 0; + +function markEditorDirty() { + if (!editorDirtyTrackingReady || editorDirtyTrackingSuspended > 0) return; + editorHasUnsavedChanges = true; +} + +function resetEditorDirtyBaseline() { + editorHasUnsavedChanges = false; +} + +const defaultTitle = 'Untitled flipnote - Flipnote Editor'; +document.title = defaultTitle; +const EDITOR_SHORTCUT_STORAGE_KEY = 'flipnote-editor.flipnote-editor.shortcuts.v1'; +const EDITOR_SHORTCUTS = [ + ['playPause', 'Play / pause', 'Space', 'global'], + ['alternateActions', 'Advanced / alternate actions (hold)', 'Shift', 'modifier'], + ['closeDialog', 'Close an editor window', 'Escape', 'dialog'], + ['confirmTitle', 'Finish editing the title', 'Enter', 'title'], + ['previousFrame', 'Previous page', 'ArrowLeft', 'stopped'], + ['nextFrame', 'Next page', 'ArrowRight', 'stopped'], + ['nudgeLeft', 'Nudge left', 'Shift+ArrowLeft', 'editing'], + ['nudgeRight', 'Nudge right', 'Shift+ArrowRight', 'editing'], + ['nudgeUp', 'Nudge up', 'Shift+ArrowUp', 'editing'], + ['nudgeDown', 'Nudge down', 'Shift+ArrowDown', 'editing'], + ['undo', 'Undo', 'U', 'editing'], + ['redo', 'Redo', 'R', 'editing'], + ['clear', 'Clear active layer', 'C', 'editing'], + ['clearAlt', 'Clear active layer (second shortcut)', 'Delete', 'editing'], + ['cycleTool', 'Cycle drawing tools', 'T', 'editing'], + ['text', 'Text tool', 'Shift+T', 'editing'], + ['cycleInk', 'Cycle layer colour', 'X', 'editing'], + ['draw', 'Draw tool', 'P', 'editing'], + ['paint', 'Paint tool', 'B', 'editing'], + ['erase', 'Erase tool', 'E', 'editing'], + ['fill', 'Fill tool', 'F', 'editing'], + ...Array.from({length: 8}, (_, index) => [`slot${index + 1}`, `Tool slot ${index + 1}`, String(index + 1), 'editing']), + ...Array.from({length: 3}, (_, index) => [`sound${index + 1}`, `Play Sound ${index + 1} while playing`, String(index + 1), 'playing']), + ['speedDown', 'Decrease speed (focused speed control)', 'ArrowDown', 'speed'], + ['speedDownAlt', 'Decrease speed (second shortcut)', 'ArrowLeft', 'speed'], + ['speedUp', 'Increase speed (focused speed control)', 'ArrowUp', 'speed'], + ['speedUpAlt', 'Increase speed (second shortcut)', 'ArrowRight', 'speed'], + ['speedFirst', 'Speed 1 (focused speed control)', 'Home', 'speed'], + ['speedLast', 'Speed 8 (focused speed control)', 'End', 'speed'], +]; +const editorShortcutDefaults = Object.fromEntries(EDITOR_SHORTCUTS.map(([id, , key]) => [id, key])); +let editorShortcutBindings = {...editorShortcutDefaults}; +function shortcutFromEvent(event) { + if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) return ''; + const key = event.code === 'Space' ? 'Space' : event.key.length === 1 ? event.key.toUpperCase() : event.key; + return [event.ctrlKey && 'Ctrl', event.metaKey && 'Meta', event.altKey && 'Alt', event.shiftKey && 'Shift', key].filter(Boolean).join('+'); +} +function shortcutAction(event, group) { + const pressed = shortcutFromEvent(event); + if (!pressed) return ''; + const match = EDITOR_SHORTCUTS.find(([id, , , context]) => context === group && editorShortcutBindings[id] === pressed); + return match ? match[0] : ''; +} +let editorAlternateHeld = false; +function editorAlternateKeyEvent(event) { + if (!event || typeof event.key !== 'string') return false; + if (!event.isTrusted && event.key === 'Shift') return true; // Mobile Shift button. + const binding = editorShortcutBindings.alternateActions; + return binding === event.key || binding === shortcutFromEvent(event); +} +function editorAlternateReleaseEvent(event) { + if (!event || typeof event.key !== 'string') return false; + if (!event.isTrusted && event.key === 'Shift') return true; + const key = event.key === 'Control' ? 'Ctrl' : event.key.length === 1 ? event.key.toUpperCase() : event.key; + return editorShortcutBindings.alternateActions.split('+').includes(key); +} +function editorAlternateActive(event) { + return editorAlternateHeld || (event && editorAlternateKeyEvent(event)); +} +document.addEventListener('keydown', (event) => { + if (editorAlternateKeyEvent(event)) editorAlternateHeld = true; +}, true); +document.addEventListener('keyup', (event) => { + if (editorAlternateReleaseEvent(event)) editorAlternateHeld = false; +}, true); +window.addEventListener('blur', () => { editorAlternateHeld = false; }); +function saveEditorShortcuts() { + try { localStorage.setItem(EDITOR_SHORTCUT_STORAGE_KEY, JSON.stringify(editorShortcutBindings)); } catch (error) {} +} +try { + const saved = JSON.parse(localStorage.getItem(EDITOR_SHORTCUT_STORAGE_KEY) || '{}'); + if (saved && typeof saved === 'object' && !Array.isArray(saved)) { + for (const [id, key] of Object.entries(saved)) { + if (id in editorShortcutDefaults && typeof key === 'string' && key.length <= 40) editorShortcutBindings[id] = key; + } + } +} catch (error) {} +function editorShortcutsBlocked() { + return !!document.querySelector('.flipnote-editor-info-modal:not([hidden]), .flipnote-editor-pattern-modal:not([hidden]), .flipnote-editor-image-import-modal:not([hidden]), .flipnote-editor-audio-trim-modal:not([hidden])'); +} +// Initialize the restored legacy editor after the page has loaded. +window.addEventListener('load', () => { + canvas.setTool('pen1'); +}); + +let noteInfo = {title: "Untitled flipnote"} + +// Flipnote Studio pages have two independent monochrome drawing layers. The +// ink colour belongs to the layer (and therefore to the page), not to each +// individual stroke. +const FLIPNOTE_INKS = { + black: [0, 0, 0], + red: [255, 53, 77], + blue: [88, 92, 255], +}; +const FLIPNOTE_INK_ORDER = ['black', 'red', 'blue']; +const FLIPNOTE_SELECTION_YELLOW = '#02D720'; +// Flipnote Studio's Resize tool is a fixed one-step transform: Shrink is +// one-half size and the shoulder-button variant Enlarge is double size. +const FLIPNOTE_RESIZE_SHRINK_FACTOR = 0.5; +const FLIPNOTE_RESIZE_GROW_FACTOR = 2; + +// Mobile/touch support is deliberately capability-gated so desktop behavior +// remains identical. Touch devices get long-press access to Shift-only actions. +const FLIPNOTE_TOUCH_UI = (() => { + try { + return (navigator.maxTouchPoints || 0) > 0 && window.matchMedia('(pointer: coarse)').matches; + } catch (error) { + return false; + } +})(); + +function installFlipnoteTouchLongPress(element, callback, delay = 520) { + if (!FLIPNOTE_TOUCH_UI || !element || typeof callback !== 'function') return; + let timer = null; + let fired = false; + let pointerId = null; + let startX = 0; + let startY = 0; + let lastTouchStartAt = 0; + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + element.addEventListener('pointerdown', (event) => { + if (event.pointerType !== 'touch' || event.button !== 0) return; + clearTimer(); + fired = false; + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + lastTouchStartAt = performance.now(); + timer = window.setTimeout(() => { + timer = null; + fired = true; + element.classList.add('is-touch-long-press'); + try { if (navigator.vibrate) navigator.vibrate(16); } catch (error) {} + callback(event); + window.setTimeout(() => element.classList.remove('is-touch-long-press'), 180); + }, delay); + }); + ['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) => { + element.addEventListener(type, (event) => { + if (pointerId !== null && event.pointerId !== undefined && event.pointerId !== pointerId) return; + clearTimer(); + pointerId = null; + }); + }); + element.addEventListener('pointermove', (event) => { + if (event.pointerType !== 'touch' || pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - startX, event.clientY - startY) > 10) clearTimer(); + }); + element.addEventListener('click', (event) => { + if (!fired) return; + fired = false; + event.preventDefault(); + event.stopImmediatePropagation(); + }, true); + element.addEventListener('contextmenu', (event) => { + if (performance.now() - lastTouchStartAt < 1200) event.preventDefault(); + }); +} +const FLIPNOTE_PAPERS = { + white: [255, 255, 255], + black: [0, 0, 0], +}; + +const FLIPNOTE_CUSTOM_PATTERN_SIZE = 8; +const FLIPNOTE_CUSTOM_PATTERN_STORAGE_KEY = 'flipnote-editor.flipnote.customPaintPattern.v1'; + +function defaultCustomPaintPattern() { + const size = FLIPNOTE_CUSTOM_PATTERN_SIZE; + const data = new Uint8Array(size * size); + // Match the editor's existing provisional custom-motif fallback as closely + // as possible, but make it a real editable/repeating 8x8 binary tile. + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (((x - y) % 5 === 0) || ((x + y) % 5 === 0)) data[y * size + x] = 1; + } + } + return {width: size, height: size, data}; +} + +function loadCustomPaintPattern() { + const fallback = defaultCustomPaintPattern(); + try { + const saved = JSON.parse(window.localStorage.getItem(FLIPNOTE_CUSTOM_PATTERN_STORAGE_KEY) || 'null'); + if (!saved || saved.width !== FLIPNOTE_CUSTOM_PATTERN_SIZE || saved.height !== FLIPNOTE_CUSTOM_PATTERN_SIZE || !Array.isArray(saved.data)) return fallback; + if (saved.data.length !== FLIPNOTE_CUSTOM_PATTERN_SIZE * FLIPNOTE_CUSTOM_PATTERN_SIZE) return fallback; + return { + width: FLIPNOTE_CUSTOM_PATTERN_SIZE, + height: FLIPNOTE_CUSTOM_PATTERN_SIZE, + data: Uint8Array.from(saved.data, (value) => value ? 1 : 0), + }; + } catch (error) { + return fallback; + } +} + +function saveCustomPaintPattern(motif) { + try { + window.localStorage.setItem(FLIPNOTE_CUSTOM_PATTERN_STORAGE_KEY, JSON.stringify({ + width: motif.width, + height: motif.height, + data: Array.from(motif.data, (value) => value ? 1 : 0), + })); + } catch (error) { + // Local persistence is a convenience only; the editor must keep working + // even when storage is disabled/private. + } +} + +const FLIPNOTE_HISTORY_SPRITES = { + undo: './assets/flipnote-editor/undo.png', + undoDisabled: './assets/flipnote-editor/undo_grey.png', + redo: './assets/flipnote-editor/redo.png', + redoDisabled: './assets/flipnote-editor/redo_grey.png', +}; + +const FLIPNOTE_SPEEDS = [ + { level: 1, fps: 0.4985, src: './assets/flipnote-editor/speed1.png' }, + { level: 2, fps: 1, src: './assets/flipnote-editor/speed2.png' }, + { level: 3, fps: 2, src: './assets/flipnote-editor/speed3.png' }, + { level: 4, fps: 4, src: './assets/flipnote-editor/speed4.png' }, + { level: 5, fps: 5.9972, src: './assets/flipnote-editor/speed5.png' }, + { level: 6, fps: 11.986, src: './assets/flipnote-editor/speed6.png' }, + { level: 7, fps: 19.9439, src: './assets/flipnote-editor/speed7.png' }, + { level: 8, fps: 29.9275, src: './assets/flipnote-editor/speed8.png' }, +]; +const FLIPNOTE_SPEED_HITBOXES = [ + { level: 1, x: 1, y: 1, width: 29, height: 18 }, + { level: 2, x: 30, y: 2, width: 8, height: 17 }, + { level: 3, x: 38, y: 2, width: 8, height: 17 }, + { level: 4, x: 46, y: 2, width: 8, height: 17 }, + { level: 5, x: 54, y: 2, width: 8, height: 17 }, + { level: 6, x: 62, y: 2, width: 8, height: 17 }, + { level: 7, x: 70, y: 1, width: 8, height: 17 }, + { level: 8, x: 78, y: 2, width: 15, height: 17 }, +]; +const FLIPNOTE_LOOP_SPRITES = { + enabled: './assets/flipnote-editor/loop_enabled.png', + disabled: './assets/flipnote-editor/loop_disabled.png', +}; + +function speedSetting(level) { + const safeLevel = Math.max(1, Math.min(8, Math.round(Number(level) || 1))); + return FLIPNOTE_SPEEDS[safeLevel - 1]; +} + +function updatePlaybackSettingsUI(timelineInstance) { + if (!timelineInstance) return; + const speed = speedSetting(timelineInstance.speedLevel); + const speedControl = document.getElementById('flipnote-speed-control'); + const speedImage = document.getElementById('flipnote-speed-image'); + const loopButton = document.getElementById('flipnote-loop-toggle'); + const loopImage = document.getElementById('flipnote-loop-image'); + if (speedControl) { + speedControl.setAttribute('aria-valuenow', String(speed.level)); + speedControl.setAttribute('aria-valuetext', `Speed ${speed.level}, ${speed.fps} FPS`); + speedControl.title = `Speed ${speed.level} · ${speed.fps} FPS`; + } + if (speedImage) { + if (speedImage.getAttribute('src') !== speed.src) speedImage.src = speed.src; + speedImage.alt = `Speed ${speed.level}`; + } + if (loopButton) { + loopButton.setAttribute('aria-pressed', timelineInstance.loopEnabled ? 'true' : 'false'); + loopButton.title = timelineInstance.loopEnabled ? 'Loop enabled' : 'Loop disabled'; + } + const loopSrc = timelineInstance.loopEnabled ? FLIPNOTE_LOOP_SPRITES.enabled : FLIPNOTE_LOOP_SPRITES.disabled; + if (loopImage) { + if (loopImage.getAttribute('src') !== loopSrc) loopImage.src = loopSrc; + loopImage.alt = timelineInstance.loopEnabled ? 'Loop enabled' : 'Loop disabled'; + } +} + +function syncEditorPlaybackState(timelineInstance) { + if (!timelineInstance) return; + const workspace = document.querySelector('[data-legacy-flipnote-editor]'); + const controls = document.querySelector('.flipnote-editor-controls'); + const speedRow = document.querySelector('.flipnote-editor-stage-speed-row'); + const filmstripTimeline = document.getElementById('timeline-container'); + const pageToolsTray = document.getElementById('flipnote-page-tools-tray'); + const canvasElement = document.getElementById('flipnote-canvas'); + const navIds = ['first-frame', 'prev-frame', 'next-frame', 'last-frame']; + const playing = !!timelineInstance.playing; + const fps = Math.max(0.01, Number(timelineInstance.framerate) || 12); + + // One full white+black sprocket pair travels right in twelve animation + // frames. That makes the decorative film speed track the Flipnote's + // actual playback FPS while keeping the movement visually readable. + const filmDuration = Math.max(0.18, Math.min(30, 12 / fps)); + if (workspace) { + workspace.classList.toggle('is-playing', playing); + workspace.style.setProperty('--flipnote-filmstrip-duration', `${filmDuration.toFixed(4)}s`); + } + + // Inert is the hard interaction lock; the matching CSS provides the + // greyscale visual state. Play/Stop remains outside these inert groups. + // Keep only Page 4 itself non-inert so SE1/SE2/SE3 can be performed live. + if (controls) { + controls.inert = false; + Array.from(controls.children).forEach((child) => { + child.inert = playing && !child.classList.contains('flipnote-editor-audio-page'); + }); + } + if (speedRow) speedRow.inert = playing; + if (filmstripTimeline) filmstripTimeline.inert = playing; + if (pageToolsTray) pageToolsTray.inert = playing; + navIds.forEach((id) => { + const element = document.getElementById(id); + if (element) { + element.inert = playing; + element.setAttribute('aria-disabled', playing ? 'true' : 'false'); + } + }); + if (canvasElement) canvasElement.inert = playing; + const importImageButton = document.getElementById('flipnote-import-image-button'); + const importImageInput = document.getElementById('flipnote-image-file-input'); + if (importImageButton) { + importImageButton.classList.toggle('is-disabled', playing); + importImageButton.setAttribute('aria-disabled', playing ? 'true' : 'false'); + importImageButton.tabIndex = playing ? -1 : 0; + } + if (importImageInput) importImageInput.disabled = playing; + + // Keyboard shortcuts use this flag too, so playback blocks editing even + // when the user had an editor control focused before pressing Play. + if (typeof canvas !== 'undefined' && canvas) { + canvas.allowEditing = !playing; + if (playing && canvas.drawing) canvas.finishStroke(); + canvas.renderCurrentFrame(); + } +} + +function updateHistoryButtons(frame) { + const undo = document.getElementById('undoButton'); + const redo = document.getElementById('redoButton'); + const canUndo = !!frame && frame.undoPointer > 0; + const canRedo = !!frame && frame.undoPointer < frame.undoBuffer.length - 1; + if (undo) { + undo.src = canUndo ? FLIPNOTE_HISTORY_SPRITES.undo : FLIPNOTE_HISTORY_SPRITES.undoDisabled; + undo.classList.toggle('is-disabled', !canUndo); + undo.setAttribute('aria-disabled', canUndo ? 'false' : 'true'); + } + if (redo) { + redo.src = canRedo ? FLIPNOTE_HISTORY_SPRITES.redo : FLIPNOTE_HISTORY_SPRITES.redoDisabled; + redo.classList.toggle('is-disabled', !canRedo); + redo.setAttribute('aria-disabled', canRedo ? 'false' : 'true'); + } +} + +function normalizePaperColor(value) { + return value === 'black' ? 'black' : 'white'; +} + +// Flipnote's monochrome ink is interpreted against the page paper: the same +// stored layer colour is black on white paper and white on black paper. +function resolveFrameInk(frame, inkName) { + if (inkName === 'black') return frame && frame.paperColor === 'black' ? [255, 255, 255] : [0, 0, 0]; + return FLIPNOTE_INKS[inkName] || FLIPNOTE_INKS.black; +} + +function resolveFramePaper(frame) { + return FLIPNOTE_PAPERS[normalizePaperColor(frame && frame.paperColor)]; +} +const LIGHTBOX_PAGES_STORAGE_KEY = 'flipnote-editor.flipnote.pagesToLightbox'; +let lightboxEnabled = false; +let cloneEnabled = false; +let activeToolTab = 1; + +function pagesToLightbox() { + let value = 1; + try { + value = Number(window.localStorage.getItem(LIGHTBOX_PAGES_STORAGE_KEY) || 1); + } catch (error) { + value = 1; + } + return Math.max(1, Math.min(4, Number.isFinite(value) ? Math.round(value) : 1)); +} + +function cloneMask(mask) { + return new Uint8Array(mask || (256 * 192)); +} + +function normalizeFrameSoundFlags(value) { + const source = value && typeof value === 'object' ? value : {}; + return { + sound1: !!source.sound1, + sound2: !!source.sound2, + sound3: !!source.sound3, + }; +} + +function frameSnapshot(frame) { + return { + layers: [cloneMask(frame.layers[0]), cloneMask(frame.layers[1])], + colors: [frame.colors[0], frame.colors[1]], + paperColor: normalizePaperColor(frame.paperColor), + }; +} + +// Flipnote Space mirrors Flipnote Studio's editor strategy: measure the actual +// compressed frame representation at edit commit points instead of serialising +// a complete PPM for every pointer sample. Editor uses that exact running +// frame-byte count together with the fixed PPM structures/current audio to +// enforce the 1,024 KB whole-file limit used by the server encoder. +const FLIPNOTE_EDITOR_MAX_PAGES = 999; +const FLIPNOTE_SPACE_MAX_BYTES = 1024 * 1024; +const FLIPNOTE_SPACE_HEADER_BYTES = 0x6A0; +const FLIPNOTE_SPACE_SIGNATURE_TRAILER_BYTES = 0x80 + 0x10; +const FLIPNOTE_SPACE_SOUND_HEADER_BYTES = 32; +const FLIPNOTE_SPACE_ADPCM_STATE_BYTES = 4; +const FLIPNOTE_SPACE_AUDIO_SAMPLE_RATE = 8192; +const FLIPNOTE_SPACE_MAX_BGM_BYTES = 245408; +let flipnoteSpaceFrameCache = new WeakMap(); +let flipnoteSpaceEncodedFrameBytes = 0; +let flipnoteSpaceAudioTracks = null; +let flipnoteSpaceNoticeTimer = null; + +function flipnoteSpacePackedLayerByteLength(mask) { + let payloadBytes = 0; + const source = mask || new Uint8Array(256 * 192); + for (let y = 0; y < 192; y += 1) { + const rowBase = y * 256; + let nonzero = 0; + let nonff = 0; + let any = false; + for (let chunk = 0; chunk < 32; chunk += 1) { + let value = 0; + const chunkBase = rowBase + (chunk * 8); + for (let bit = 0; bit < 8; bit += 1) { + if (source[chunkBase + bit]) value |= (1 << bit); + } + if (value !== 0) { nonzero += 1; any = true; } + if (value !== 0xFF) nonff += 1; + } + if (!any) continue; + const normalBytes = 4 + nonzero; + const invertedBytes = 4 + nonff; + if (normalBytes <= invertedBytes && normalBytes < 32) payloadBytes += normalBytes; + else if (invertedBytes < 32) payloadBytes += invertedBytes; + else payloadBytes += 32; + } + // 48 bytes hold the two-bit line encoding tags for all 192 rows. + return 48 + payloadBytes; +} + +function flipnoteSpaceFrameByteLength(frame) { + if (!frame) return 0; + // One PPM frame header byte followed by both line-tag blocks/payloads. + return 1 + + flipnoteSpacePackedLayerByteLength(frame.layers && frame.layers[0]) + + flipnoteSpacePackedLayerByteLength(frame.layers && frame.layers[1]); +} + +function rebuildFlipnoteSpaceFrameCache(frames) { + flipnoteSpaceFrameCache = new WeakMap(); + flipnoteSpaceEncodedFrameBytes = 0; + (Array.isArray(frames) ? frames : []).forEach((frame) => { + if (!frame) return; + const size = flipnoteSpaceFrameByteLength(frame); + flipnoteSpaceFrameCache.set(frame, size); + flipnoteSpaceEncodedFrameBytes += size; + }); +} + +function flipnoteSpaceRegisterFrame(frame) { + if (!frame) return 0; + const size = flipnoteSpaceFrameByteLength(frame); + const previous = flipnoteSpaceFrameCache.get(frame) || 0; + flipnoteSpaceFrameCache.set(frame, size); + flipnoteSpaceEncodedFrameBytes += size - previous; + return size; +} + +function flipnoteSpaceForgetFrame(frame) { + if (!frame) return; + const previous = flipnoteSpaceFrameCache.get(frame) || 0; + flipnoteSpaceEncodedFrameBytes = Math.max(0, flipnoteSpaceEncodedFrameBytes - previous); + flipnoteSpaceFrameCache.delete(frame); +} + +function flipnoteSpaceStoredAudioDuration(key, track, speedLevel) { + if (!track) return 0; + const duration = Math.max(0, Number(track.duration) || 0); + if (key !== 'music') return duration; + const speed6FileImport = Number(speedLevel) === 6 && String(track.sourceKind || '').toLowerCase() === 'file_import'; + if (!speed6FileImport) return duration; + const doubled = duration * 2; + const maxSeconds = (FLIPNOTE_SPACE_MAX_BGM_BYTES / 4096) + 0.02; + return doubled <= maxSeconds ? doubled : duration; +} + +function flipnoteSpaceAudioByteLength(key, track, speedLevel) { + if (!track) return 0; + const duration = flipnoteSpaceStoredAudioDuration(key, track, speedLevel); + if (!(duration > 0)) return 0; + // Server output is 8,192 Hz mono IMA ADPCM: two samples per payload byte, + // preceded by the four-byte predictor/index state header. + return FLIPNOTE_SPACE_ADPCM_STATE_BYTES + + Math.ceil((duration * FLIPNOTE_SPACE_AUDIO_SAMPLE_RATE) / 2); +} + +function flipnoteSpacePpmByteLength(timelineInstance, options = {}) { + const instance = timelineInstance; + const frameCount = Math.max(1, Number(options.frameCount ?? (instance && instance.frames ? instance.frames.length : 1)) || 1); + const animationFrameBytes = Math.max(0, Number(options.animationFrameBytes ?? flipnoteSpaceEncodedFrameBytes) || 0); + const speedLevel = Math.max(1, Math.min(8, Number(options.speedLevel ?? (instance && instance.speedLevel) ?? 6) || 6)); + const tracks = options.audioTracks || flipnoteSpaceAudioTracks || {}; + let animationBytes = 8 + (frameCount * 4) + animationFrameBytes; + animationBytes += (4 - (animationBytes % 4)) % 4; + const soundPadding = (4 - ((FLIPNOTE_SPACE_HEADER_BYTES + animationBytes + frameCount) % 4)) % 4; + const audioBytes = ['music', 'sound1', 'sound2', 'sound3'].reduce( + (total, key) => total + flipnoteSpaceAudioByteLength(key, tracks[key], speedLevel), + 0, + ); + return FLIPNOTE_SPACE_HEADER_BYTES + + animationBytes + + frameCount + + soundPadding + + FLIPNOTE_SPACE_SOUND_HEADER_BYTES + + audioBytes + + FLIPNOTE_SPACE_SIGNATURE_TRAILER_BYTES; +} + +function renderFlipnoteSpaceMeter(timelineInstance) { + const meter = document.getElementById('flipnote-space-meter'); + const fill = document.getElementById('flipnote-space-meter-fill'); + const value = document.getElementById('flipnote-space-meter-value'); + if (!meter || !fill || !timelineInstance) return; + const used = flipnoteSpacePpmByteLength(timelineInstance); + const ratio = Math.max(0, Math.min(1, used / FLIPNOTE_SPACE_MAX_BYTES)); + const percent = ratio * 100; + fill.style.height = `${percent.toFixed(3)}%`; + meter.setAttribute('aria-valuenow', String(Math.min(FLIPNOTE_SPACE_MAX_BYTES, Math.round(used)))); + meter.classList.toggle('is-near-limit', ratio >= 0.9); + meter.classList.toggle('is-full', used >= FLIPNOTE_SPACE_MAX_BYTES); + const usedKb = Math.min(1024, Math.ceil(used / 1024)); + if (value) value.textContent = used >= FLIPNOTE_SPACE_MAX_BYTES ? 'FULL' : `${usedKb} KB`; + meter.title = `Flipnote Space: ${usedKb} KB of 1,024 KB used`; +} + +function notifyFlipnoteSpaceLimit(timelineInstance) { + renderFlipnoteSpaceMeter(timelineInstance); + const meter = document.getElementById('flipnote-space-meter'); + const status = document.getElementById('flipnote-space-status'); + if (status) status.textContent = 'Flipnote Space is full. Remove pages, audio, or drawing detail before adding more.'; + if (!meter) return; + meter.classList.add('is-limit-hit'); + if (flipnoteSpaceNoticeTimer) clearTimeout(flipnoteSpaceNoticeTimer); + flipnoteSpaceNoticeTimer = setTimeout(() => meter.classList.remove('is-limit-hit'), 900); +} + +function notifyFlipnotePageLimit(timelineInstance) { + renderFlipnoteSpaceMeter(timelineInstance); + const meter = document.getElementById('flipnote-space-meter'); + const status = document.getElementById('flipnote-space-status'); + if (status) status.textContent = `Flipnote Editor supports up to ${FLIPNOTE_EDITOR_MAX_PAGES} pages. Remove a page before adding another.`; + if (!meter) return; + meter.classList.add('is-limit-hit'); + if (flipnoteSpaceNoticeTimer) clearTimeout(flipnoteSpaceNoticeTimer); + flipnoteSpaceNoticeTimer = setTimeout(() => meter.classList.remove('is-limit-hit'), 900); +} + +function flipnoteSpaceCanFit(timelineInstance, options = {}) { + return flipnoteSpacePpmByteLength(timelineInstance, options) <= FLIPNOTE_SPACE_MAX_BYTES; +} + +function flipnoteSpaceAllowsProjection(timelineInstance, options = {}) { + const projected = flipnoteSpacePpmByteLength(timelineInstance, options); + if (projected <= FLIPNOTE_SPACE_MAX_BYTES) return true; + // An imported PPM can recompress slightly larger in the editor because the + // browser/server editor path emits independent native frames. Never trap + // such a project above the limit: allow edits that strictly reduce size so + // the user can work it back under 1,024 KB. + return projected < flipnoteSpacePpmByteLength(timelineInstance); +} + +function flipnoteSpaceCanAddFrame(timelineInstance, frame) { + const size = flipnoteSpaceFrameByteLength(frame); + return flipnoteSpaceCanFit(timelineInstance, { + frameCount: timelineInstance.frames.length + 1, + animationFrameBytes: flipnoteSpaceEncodedFrameBytes + size, + }); +} + +// Frame stores the two real drawing layers, their per-page colours, and an +// undo buffer containing complete layer snapshots. +let editorLayerVisibility = [true, true]; + +class Frame { + constructor(copyFrom) { + if (copyFrom instanceof Frame) { + this.layers = [cloneMask(copyFrom.layers[0]), cloneMask(copyFrom.layers[1])]; + this.colors = [copyFrom.colors[0], copyFrom.colors[1]]; + } else if (copyFrom && Array.isArray(copyFrom.layers)) { + this.layers = [cloneMask(copyFrom.layers[0]), cloneMask(copyFrom.layers[1])]; + this.colors = [copyFrom.colors?.[0] || 'black', copyFrom.colors?.[1] || 'red']; + } else { + this.layers = [new Uint8Array(256 * 192), new Uint8Array(256 * 192)]; + this.colors = ['black', 'red']; + } + // Paper is page-local PPM-style state. Blank pages default to white; + // callers can seed black paper without copying any drawing data. + this.paperColor = normalizePaperColor(copyFrom && copyFrom.paperColor); + // SE1/SE2/SE3 are page-local Flipnote triggers. They are deliberately + // separate from drawing undo history: tapping a sound during playback + // stamps that sound onto the current page without changing artwork. + this.soundFlags = normalizeFrameSoundFlags(copyFrom && copyFrom.soundFlags); + this.thumbnailData = []; + this.undoBuffer = []; + this.undoPointer = -1; + this.undoMax = 255; + } + getData() { + return this.compositeImageData(); + } + setData(data) { + // Kept for compatibility with the restored editor API. New editor + // operations update layer masks directly instead of flattening here. + if (data instanceof Frame) { + this.layers = [cloneMask(data.layers[0]), cloneMask(data.layers[1])]; + this.colors = [data.colors[0], data.colors[1]]; + this.paperColor = normalizePaperColor(data.paperColor); + this.soundFlags = normalizeFrameSoundFlags(data.soundFlags); + } + } + compositeImageData() { + const out = new Uint8ClampedArray(256 * 192 * 4); + const layer1Color = resolveFrameInk(this, this.colors[0]); + const layer2Color = resolveFrameInk(this, this.colors[1]); + const paper = resolveFramePaper(this); + for (let i = 0; i < 256 * 192; i++) { + let r = paper[0], g = paper[1], b = paper[2]; + // Layer 1 sits above Layer 2, matching Flipnote's compositing. + if (editorLayerVisibility[1] && this.layers[1][i]) [r, g, b] = layer2Color; + if (editorLayerVisibility[0] && this.layers[0][i]) [r, g, b] = layer1Color; + const p = i * 4; + out[p] = r; + out[p + 1] = g; + out[p + 2] = b; + out[p + 3] = 255; + } + return new ImageData(out, 256, 192); + } + pushState() { + markEditorDirty(); + if (this.undoPointer < this.undoBuffer.length - 1) { + this.undoBuffer.splice(this.undoPointer + 1); + } + this.undoBuffer.push(frameSnapshot(this)); + if (this.undoBuffer.length > this.undoMax) this.undoBuffer.shift(); + this.undoPointer = this.undoBuffer.length - 1; + updateHistoryButtons(this); + } + restoreSnapshot(snapshot) { + if (!snapshot) return; + this.layers = [cloneMask(snapshot.layers[0]), cloneMask(snapshot.layers[1])]; + this.colors = [snapshot.colors[0], snapshot.colors[1]]; + this.paperColor = normalizePaperColor(snapshot.paperColor); + canvas.renderCurrentFrame(); + timeline.createThumbnails(); + updateLayerPanel(); + updatePaperToolState(); + updateHistoryButtons(this); + if (typeof timeline !== 'undefined' && timeline) { + flipnoteSpaceRegisterFrame(this); + renderFlipnoteSpaceMeter(timeline); + } + } + undo() { + if (this.undoPointer > 0) { + const target = this.undoBuffer[this.undoPointer - 1]; + const currentSize = flipnoteSpaceFrameCache.get(this) || flipnoteSpaceFrameByteLength(this); + const targetSize = flipnoteSpaceFrameByteLength(target); + if (!flipnoteSpaceAllowsProjection(timeline, { + animationFrameBytes: flipnoteSpaceEncodedFrameBytes - currentSize + targetSize, + })) { + notifyFlipnoteSpaceLimit(timeline); + updateHistoryButtons(this); + return; + } + markEditorDirty(); + this.undoPointer--; + this.restoreSnapshot(target); + } else { + updateHistoryButtons(this); + } + } + redo() { + if (this.undoPointer < this.undoBuffer.length - 1) { + const target = this.undoBuffer[this.undoPointer + 1]; + const currentSize = flipnoteSpaceFrameCache.get(this) || flipnoteSpaceFrameByteLength(this); + const targetSize = flipnoteSpaceFrameByteLength(target); + if (!flipnoteSpaceAllowsProjection(timeline, { + animationFrameBytes: flipnoteSpaceEncodedFrameBytes - currentSize + targetSize, + })) { + notifyFlipnoteSpaceLimit(timeline); + updateHistoryButtons(this); + return; + } + markEditorDirty(); + this.undoPointer++; + this.restoreSnapshot(target); + } else { + updateHistoryButtons(this); + } + } + setUndoPointer(pointer) { + this.undoPointer = Math.max(0, Math.min(this.undoBuffer.length - 1, pointer)); + updateHistoryButtons(this); + } +} + +// Timeline owns pages and composites the two layers for thumbnails/playback. +class Timeline { + constructor(timelineElement) { + this.timelineElement = timelineElement; + this.frameData = []; + this.frames = []; + this.speedLevel = 6; + this.framerate = speedSetting(this.speedLevel).fps; + this.loopEnabled = true; + this.currentFrame = 0; + this.lastFrame = 0; + this.playing = false; + this.playInterval = null; + // Page Tools clipboard: copies the two drawing layers and their layer + // colours. Paper/audio stay page-local when pasted. + this.pageClipboard = null; + // Native frame dragging keeps the actual Frame object intact while its + // place in the timeline changes. The source slot gets a dotted lift + // marker until the frame is dropped. + this.frameDrag = null; + this.frameDragClickSuppressedUntil = 0; + this.addFrame(); + + this.timelineElement.addEventListener('dragover', (event) => { + if (!this.frameDrag || this.playing) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + const slot = this.frameDropSlotFromClientX(event.clientX); + this.frameDrag.dropSlot = slot; + this.updateFrameDropMarker(slot); + }); + this.timelineElement.addEventListener('drop', (event) => { + if (!this.frameDrag || this.playing) return; + event.preventDefault(); + const slot = this.frameDropSlotFromClientX(event.clientX); + this.finishFrameDrag(slot); + }); + this.timelineElement.addEventListener('dragleave', (event) => { + if (!this.frameDrag) return; + const next = event.relatedTarget; + if (next instanceof Node && this.timelineElement.contains(next)) return; + this.removeFrameDropMarker(); + }); + + document.addEventListener('keydown', (event) => { + if (editorShortcutsBlocked()) return; + const target = event.target instanceof Element ? event.target : null; + const typingText = !!(target && target.matches('input, textarea, select, [contenteditable="true"]')); + if (typingText) return; + if (editorAlternateKeyEvent(event)) this.shiftPressed = true; + if (this.playing || (target && target.closest('#flipnote-speed-control'))) return; + const action = shortcutAction(event, 'stopped'); + if (action !== 'nextFrame' && action !== 'previousFrame') return; + event.preventDefault(); + this.setActiveFrame(this.currentFrame + (action === 'nextFrame' ? 1 : -1)); + this.updateThumbnailSelection(this.currentFrame); + }); + document.addEventListener('keyup', (event) => { + if (editorAlternateReleaseEvent(event)) this.shiftPressed = false; + }); + } + + addFrame(copyData, index) { + if (this.frames.length >= FLIPNOTE_EDITOR_MAX_PAGES) { + notifyFlipnotePageLimit(this); + return -1; + } + if (index === undefined) index = this.frames.length; + // A normal blank page inherits only the previous page's paper colour. + // Clone mode passes a full Frame and therefore copies drawing + colours too. + if (copyData === undefined && index > 0 && this.frames[index - 1]) { + copyData = {paperColor: this.frames[index - 1].paperColor}; + } + const newFrame = copyData instanceof Frame ? new Frame(copyData) : new Frame(copyData); + if (this.frames.length && !flipnoteSpaceCanAddFrame(this, newFrame)) { + notifyFlipnoteSpaceLimit(this); + return -1; + } + this.frames.splice(index, 0, newFrame); + flipnoteSpaceRegisterFrame(newFrame); + newFrame.pushState(); + this.createThumbnails(); + renderFlipnoteSpaceMeter(this); + return index; + } + + updateFrame(index) { + const frame = this.frames[index]; + if (!frame) return false; + const previousSize = flipnoteSpaceFrameCache.get(frame) || flipnoteSpaceFrameByteLength(frame); + const nextSize = flipnoteSpaceFrameByteLength(frame); + const projectedAnimationBytes = flipnoteSpaceEncodedFrameBytes - previousSize + nextSize; + if (!flipnoteSpaceAllowsProjection(this, {animationFrameBytes: projectedAnimationBytes})) { + const acceptedSnapshot = frame.undoBuffer[frame.undoPointer]; + if (acceptedSnapshot) frame.restoreSnapshot(acceptedSnapshot); + notifyFlipnoteSpaceLimit(this); + return false; + } + flipnoteSpaceFrameCache.set(frame, nextSize); + flipnoteSpaceEncodedFrameBytes = projectedAnimationBytes; + frame.pushState(); + this.createThumbnails(); + renderFlipnoteSpaceMeter(this); + return true; + } + removeFrame(index) { + if (index >= 0 && index < this.frames.length) { + markEditorDirty(); + flipnoteSpaceForgetFrame(this.frames[index]); + this.frames.splice(index, 1); + } + this.currentFrame = Math.max(0, Math.min(this.currentFrame, this.frames.length - 1)); + this.createThumbnails(); + renderFlipnoteSpaceMeter(this); + } + getFrame(index) { + return index >= 0 && index < this.frames.length ? this.frames[index] : null; + } + getAllFrames() { + return this.frames; + } + copyPage(index = this.currentFrame) { + const frame = this.getFrame(index); + if (!frame) return false; + this.pageClipboard = { + layers: [cloneMask(frame.layers[0]), cloneMask(frame.layers[1])], + colors: [frame.colors[0], frame.colors[1]], + }; + return true; + } + pastePage(index = this.currentFrame) { + const frame = this.getFrame(index); + const clip = this.pageClipboard; + if (!frame || !clip || this.playing) return false; + frame.layers = [cloneMask(clip.layers[0]), cloneMask(clip.layers[1])]; + frame.colors = [clip.colors[0], clip.colors[1]]; + if (typeof canvas !== 'undefined' && canvas) { + if (typeof canvas.clearSelectionForFrameChange === 'function') canvas.clearSelectionForFrameChange(); + canvas.renderCurrentFrame(); + } + this.updateFrame(index); + if (index === this.currentFrame) { + updateLayerPanel(); + updateHistoryButtons(frame); + } + return true; + } + ensureFrameVisible(index = this.currentFrame) { + const frameElement = this.timelineElement.querySelector(`.timeline-thumbnail[data-frame-index="${index}"]`); + if (!frameElement) return; + const padding = 8; + const viewLeft = this.timelineElement.scrollLeft; + const viewRight = viewLeft + this.timelineElement.clientWidth; + const itemLeft = frameElement.offsetLeft; + const itemRight = itemLeft + frameElement.offsetWidth; + if (itemLeft < viewLeft + padding) { + this.timelineElement.scrollLeft = Math.max(0, itemLeft - padding); + } else if (itemRight > viewRight - padding) { + this.timelineElement.scrollLeft = Math.min( + Math.max(0, this.timelineElement.scrollWidth - this.timelineElement.clientWidth), + itemRight - this.timelineElement.clientWidth + padding + ); + } + } + frameDropSlotFromClientX(clientX) { + const thumbnails = Array.from(this.timelineElement.querySelectorAll('.timeline-thumbnail')); + if (!thumbnails.length) return 0; + for (let index = 0; index < thumbnails.length; index++) { + const rect = thumbnails[index].getBoundingClientRect(); + if (clientX < rect.left + rect.width / 2) return index; + } + return thumbnails.length; + } + removeFrameDropMarker() { + const marker = this.timelineElement.querySelector('.timeline-frame-drop-marker'); + if (marker) marker.remove(); + } + updateFrameDropMarker(slot) { + const thumbnails = Array.from(this.timelineElement.querySelectorAll('.timeline-thumbnail')); + if (!thumbnails.length) return; + let marker = this.timelineElement.querySelector('.timeline-frame-drop-marker'); + if (!marker) { + marker = document.createElement('div'); + marker.className = 'timeline-frame-drop-marker'; + this.timelineElement.appendChild(marker); + } + const safeSlot = Math.max(0, Math.min(thumbnails.length, Number(slot) || 0)); + let left; + if (safeSlot < thumbnails.length) { + left = thumbnails[safeSlot].offsetLeft - 5; + } else { + const last = thumbnails[thumbnails.length - 1]; + left = last.offsetLeft + last.offsetWidth + 5; + } + marker.style.left = `${Math.max(0, left)}px`; + } + beginFrameDrag(index, thumb, event) { + if (this.playing || index < 0 || index >= this.frames.length) { + event.preventDefault(); + return; + } + const placeholder = document.createElement('div'); + placeholder.className = 'timeline-frame-lift-placeholder'; + placeholder.style.left = `${thumb.offsetLeft}px`; + placeholder.style.top = `${thumb.offsetTop}px`; + placeholder.style.width = `${thumb.offsetWidth}px`; + placeholder.style.height = `${thumb.offsetHeight}px`; + this.timelineElement.appendChild(placeholder); + this.frameDrag = { + sourceIndex: index, + sourceFrame: this.frames[index], + sourceElement: thumb, + placeholder, + dropSlot: index, + }; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', `flipnote-editor-frame:${index}`); + } + // Let the browser capture the normal thumbnail as its drag ghost first; + // then leave only the dotted source slot behind in the filmstrip. + requestAnimationFrame(() => { + if (this.frameDrag && this.frameDrag.sourceElement === thumb) { + thumb.classList.add('is-frame-drag-source'); + } + }); + this.updateFrameDropMarker(index); + } + cleanupFrameDrag() { + const drag = this.frameDrag; + if (drag && drag.sourceElement) drag.sourceElement.classList.remove('is-frame-drag-source'); + if (drag && drag.placeholder && drag.placeholder.isConnected) drag.placeholder.remove(); + this.removeFrameDropMarker(); + this.frameDrag = null; + } + finishFrameDrag(slot) { + const drag = this.frameDrag; + if (!drag) return; + const sourceIndex = drag.sourceIndex; + const frameCount = this.frames.length; + const safeSlot = Math.max(0, Math.min(frameCount, Number(slot) || 0)); + let destination = safeSlot > sourceIndex ? safeSlot - 1 : safeSlot; + destination = Math.max(0, Math.min(frameCount - 1, destination)); + const activeFrame = this.frames[this.currentFrame] || null; + const movedFrame = drag.sourceFrame; + this.cleanupFrameDrag(); + this.frameDragClickSuppressedUntil = performance.now() + 180; + + if (movedFrame && destination !== sourceIndex) { + const actualSource = this.frames.indexOf(movedFrame); + if (actualSource !== -1) { + this.frames.splice(actualSource, 1); + this.frames.splice(destination, 0, movedFrame); + markEditorDirty(); + } + } + if (activeFrame) { + const activeIndex = this.frames.indexOf(activeFrame); + if (activeIndex !== -1) this.currentFrame = activeIndex; + } + this.createThumbnails(); + const counter = document.getElementById('frame-counter'); + if (counter) counter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`; + this.updateThumbnailSelection(this.currentFrame); + if (window.flipnoteEditorAudioReady && typeof updateAudioFillBars === 'function') updateAudioFillBars(); + } + setActiveFrame(index) { + if (canvas.textTool) canvas.textTool.finish(); + openColorPickerLayer = null; + const previousFrame = this.currentFrame; + if (index >= 0 && index < this.frames.length) { + this.currentFrame = index; + } else if (index >= this.frames.length) { + const sourceFrame = cloneEnabled ? this.frames[this.currentFrame] : undefined; + const addedIndex = this.addFrame(sourceFrame); + if (addedIndex >= 0) this.currentFrame = this.frames.length - 1; + } + if (typeof canvas !== 'undefined' && canvas && previousFrame !== this.currentFrame && typeof canvas.clearSelectionForFrameChange === 'function') { + canvas.clearSelectionForFrameChange(); + } + canvas.renderCurrentFrame(); + updateLayerPanel(); + updatePaperToolState(); + updateHistoryButtons(this.getFrame(this.currentFrame)); + document.getElementById('frame-counter').textContent = `${this.currentFrame + 1} / ${this.frames.length}`; + this.updateThumbnailSelection(this.currentFrame); + if (window.flipnoteEditorAudioReady && typeof updateAudioFillBars === 'function') updateAudioFillBars(); + if (this.playing && typeof playStampedSoundEffectsForFrame === 'function') { + playStampedSoundEffectsForFrame(this.currentFrame); + } + } + stopPlayback() { + this.playing = false; + clearInterval(this.playInterval); + if (typeof stopFlipnoteTimelineAudio === 'function') stopFlipnoteTimelineAudio(); + this.playInterval = null; + const playPauseImage = document.getElementById('play-pause'); + if (playPauseImage) { + playPauseImage.title = 'Play'; + playPauseImage.alt = 'Play'; + playPauseImage.src = './assets/flipnote-editor/play.png'; + } + syncEditorPlaybackState(this); + canvas.renderCurrentFrame(); + } + restartPlaybackInterval() { + clearInterval(this.playInterval); + if (!this.playing) return; + this.playInterval = setInterval(() => { + if (!this.frames.length) return; + if (this.currentFrame >= this.frames.length - 1) { + if (this.loopEnabled) { + if (typeof stopTimelineSoundEffects === 'function') stopTimelineSoundEffects(); + this.setActiveFrame(0); + if (typeof restartFlipnoteTimelineMusic === 'function') restartFlipnoteTimelineMusic(0); + } else { + this.stopPlayback(); + } + return; + } + this.setActiveFrame(this.currentFrame + 1); + }, 1000 / this.framerate); + } + setSpeedLevel(level) { + const speed = speedSetting(level); + if (this.speedLevel === speed.level && this.framerate === speed.fps) return; + if (!flipnoteSpaceAllowsProjection(this, {speedLevel: speed.level})) { + notifyFlipnoteSpaceLimit(this); + return; + } + markEditorDirty(); + this.speedLevel = speed.level; + this.framerate = speed.fps; + updatePlaybackSettingsUI(this); + syncEditorPlaybackState(this); + if (typeof updateAudioFillBars === 'function') updateAudioFillBars(); + if (typeof syncActiveAudioPreviewPlaybackRate === 'function') syncActiveAudioPreviewPlaybackRate(); + renderFlipnoteSpaceMeter(this); + if (this.playing) { + this.restartPlaybackInterval(); + if (typeof restartFlipnoteTimelineMusic === 'function') restartFlipnoteTimelineMusic(this.currentFrame); + } + } + setLoopEnabled(enabled) { + const nextEnabled = !!enabled; + if (this.loopEnabled === nextEnabled) return; + markEditorDirty(); + this.loopEnabled = nextEnabled; + updatePlaybackSettingsUI(this); + } + togglePlayPause() { + if (canvas.textTool) canvas.textTool.finish(); + const playPauseImage = document.getElementById('play-pause'); + if (this.playing) { + this.stopPlayback(); + } else { + if (typeof stopFlipnoteEditorAudio === 'function') stopFlipnoteEditorAudio(); + this.playing = true; + if (playPauseImage) { + playPauseImage.title = 'Stop'; + playPauseImage.alt = 'Stop'; + playPauseImage.src = './assets/flipnote-editor/stop.png'; + } + // Light-box is an editing aid only. Remove ghosts immediately when + // playback starts, before the first playback tick. Playback also + // hard-locks every editor/playback control except this Stop button. + syncEditorPlaybackState(this); + canvas.renderCurrentFrame(); + this.ensureFrameVisible(this.currentFrame); + if (typeof startFlipnoteTimelineAudio === 'function') startFlipnoteTimelineAudio(this.currentFrame); + this.restartPlaybackInterval(); + } + } + createThumbnails() { + this.cleanupFrameDrag(); + this.timelineElement.textContent = ''; + this.frames.forEach((frame, idx) => { + const thumbCanvas = document.createElement('canvas'); + thumbCanvas.classList.add('timeline-thumbnail'); + thumbCanvas.setAttribute('data-frame-index', idx); + if (idx === this.currentFrame) thumbCanvas.classList.add('selected'); + thumbCanvas.draggable = !this.playing; + thumbCanvas.addEventListener('click', () => { + if (performance.now() < this.frameDragClickSuppressedUntil) return; + this.setActiveFrame(idx); + }); + thumbCanvas.addEventListener('dragstart', (event) => this.beginFrameDrag(idx, thumbCanvas, event)); + thumbCanvas.addEventListener('dragend', () => { + if (!this.frameDrag) return; + this.cleanupFrameDrag(); + this.frameDragClickSuppressedUntil = performance.now() + 180; + this.ensureFrameVisible(this.currentFrame); + }); + const thumbctx = thumbCanvas.getContext('2d'); + thumbCanvas.width = 256; + thumbCanvas.height = 192; + thumbctx.putImageData(frame.compositeImageData(), 0, 0); + this.timelineElement.appendChild(thumbCanvas); + }); + // Re-rendering thumbnails happens after page edits, undo/redo, inserts, + // imports and other meaningful changes. Return to the current page then, + // but never fight a manual timeline scroll while nothing is changing. + this.ensureFrameVisible(this.currentFrame); + } + updateThumbnailSelection(idx) { + const lastElement = this.timelineElement.querySelector('.selected'); + if (lastElement) lastElement.classList.remove('selected'); + const newElement = this.timelineElement.querySelector(`.timeline-thumbnail[data-frame-index="${idx}"]`); + if (newElement) newElement.classList.add('selected'); + // Any page change re-enables timeline following. A manual scroll is left + // alone until the current page changes or another edit recreates thumbs. + this.ensureFrameVisible(idx); + } +} + +// CanvasEditor edits one selected layer at a time, while the visible canvas is +// always a composite of both layers. +class CanvasEditor { + constructor(canvasElement) { + if (!window.FlipnoteDrawingEngine) throw new Error('Flipnote drawing engine did not load'); + this.engine = window.FlipnoteDrawingEngine; + this.canvasElement = canvasElement; + this.ctx = canvasElement.getContext('2d', {alpha:false}); + this.ctx.imageSmoothingEnabled = false; + this.width = this.engine.WIDTH; + this.height = this.engine.HEIGHT; + this.drawing = false; + this.allowEditing = true; + this.currentToolFamily = 'pen'; + this.selectedToolSlots = {pen: 0, paint: 0, eraser: 0, fill: 0}; + // Advanced Erase follows the most recently used Paint texture. Until + // Paint has actually been chosen/used, Flipnote-style Paint 5 is the + // fallback pattern. + this.lastPaintPatternSlot = 4; + // Shift is still a live Advanced modifier, but a tool explicitly chosen + // while Shift is held stays Advanced after Shift is released. Each + // family remembers that choice independently until another tool in the + // same family is selected. + this.selectedToolAdvanced = {pen: false, paint: false, eraser: false, fill: false}; + this.customPaintMotif = loadCustomPaintPattern(); + this.expertTools = false; + this.activeLayer = 0; + this.cursorPosition = {x: 0, y: 0}; + this.pointerInsideCanvas = false; + this.cursorPreviewMask = new Uint8Array(this.width * this.height); + this.canvasElement.classList.add('flipnote-brush-preview-cursor'); + this.lastPosition = {x: null, y: null}; + this.strokeState = null; + this.selectionMode = false; + this.selectionRect = null; + this.selectionMask = new Uint8Array(this.width * this.height); + this.selectionPixelCount = 0; + this.selectionDrag = null; + this.selectionMove = null; + this.selectionClipboard = null; + // Long-pressing Select on touch arms a one-shot rectangle selection, + // mirroring the desktop Shift modifier without changing mouse behavior. + this.mobileRectangleSelection = false; + this.rectangleSelectionMode = false; + this.selectionTransformGeometry = null; + this.ctx.fillStyle = 'white'; + this.ctx.fillRect(0, 0, this.width, this.height); + this.suppressLightbox = false; + this.inkBuffer = document.createElement('canvas'); + this.inkBuffer.width = this.width; + this.inkBuffer.height = this.height; + this.inkBufferCtx = this.inkBuffer.getContext('2d'); + this.inkBufferCtx.imageSmoothingEnabled = false; + + // Right-click temporarily switches the editor to the retained Eraser + // and restores the user's previous tool as soon as the button is released. + // Until an Eraser has actually been chosen/used in this session, the + // right-click fallback is Erase 2 (slot index 1). + this.rightEraseActive = false; + this.rightErasePreviousTool = null; + this.hasRetainedEraserChoice = false; + + this.canvasElement.addEventListener('pointerdown', (e) => { + const rightErase = e.button === 2; + if (e.button !== 0 && !rightErase) return; + if (!this.allowEditing) return; + e.preventDefault(); + if (!rightErase && this.textTool && this.textTool.enabled) { + void this.textTool.begin(this.getCanvasCoords(e)); + return; + } + if (this.textTool) this.textTool.finish(); + // Keep keyboard shortcuts live after the user clicks/draws on the canvas. + // pointerdown is prevented for drawing, so focus must be applied explicitly. + if (typeof this.canvasElement.focus === 'function') this.canvasElement.focus({preventScroll: true}); + this.cursorPosition = this.getCanvasCoords(e); + + // Right-click always erases. While the right button is held, make the + // Eraser the editor's *actual* current/selected tool so the toolbar and + // cursor show exactly what is being used. Mouse-up restores the prior tool. + // If no Eraser has been chosen/used yet, start with Flipnote Erase 2. + if (rightErase) { + const previousRetainedChoice = (typeof retainedToolChoice !== 'undefined' && retainedToolChoice) + ? {...retainedToolChoice} + : null; + this.rightErasePreviousTool = { + family: this.currentToolFamily, + retainedChoice: previousRetainedChoice, + }; + if (!this.hasRetainedEraserChoice) { + this.selectedToolSlots.eraser = 1; + this.selectedToolAdvanced.eraser = false; + } + this.hasRetainedEraserChoice = true; + this.rightEraseActive = true; + this.currentToolFamily = 'eraser'; + this.canvasElement.classList.remove('pen1-cursor', 'pen2-cursor', 'eraser-cursor', 'checker-cursor', 'paint-cursor', 'fill-cursor'); + this.canvasElement.classList.add('eraser-cursor'); + if (typeof retainedToolChoice !== 'undefined') { + retainedToolChoice = { + family: 'eraser', + slot: this.selectedToolSlots.eraser, + advanced: !!this.selectedToolAdvanced.eraser, + }; + } + if (typeof updateDrawingToolUI === 'function') updateDrawingToolUI(); + this.drawing = true; + this.updateCursorMode(); + if (this.canvasElement.setPointerCapture) this.canvasElement.setPointerCapture(e.pointerId); + this.lastPosition = {x: this.cursorPosition.x, y: this.cursorPosition.y}; + this.strokeState = this.engine.createStrokeState(); + const family = 'eraser'; + this.engine.applyStrokeStart( + this.currentMask(), + family, + this.logicalToolId(family), + this.cursorPosition.x, + this.cursorPosition.y, + this.strokeState, + this.toolEngineOptions(family) + ); + this.renderCurrentFrame(); + return; + } + + if (this.selectionMode) { + const x = this.cursorPosition.x; + const y = this.cursorPosition.y; + if (this.rectangleSelectionMode && this.hasSelection()) { + const handle = this.selectionTransformHandleAt(x, y); + if (handle) { + this.beginSelectionHandleDrag(handle, x, y); + if (this.canvasElement.setPointerCapture) this.canvasElement.setPointerCapture(e.pointerId); + this.renderCurrentFrame(); + return; + } + } + // Flipnote-style Select is a freehand lasso by default. Shift + // temporarily switches it to a rectangle. On touch, dragging + // from inside an existing selection moves it directly, because + // phones do not have a Shift key. A long-press on Select arms a + // one-shot rectangle selection for the same reason. + const touchMovesSelection = e.pointerType === 'touch' + && !this.mobileRectangleSelection + && !this.rectangleSelectionMode + && this.hasSelection() + && this.selectionContains(x, y); + if ((editorAlternateActive(e) || touchMovesSelection) && this.hasSelection() && this.selectionContains(x, y)) { + this.beginSelectionMove(x, y); + } else { + this.beginSelectionDrag(x, y, !!(editorAlternateActive(e) || this.mobileRectangleSelection || this.rectangleSelectionMode)); + } + if (this.canvasElement.setPointerCapture) this.canvasElement.setPointerCapture(e.pointerId); + this.renderCurrentFrame(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return; + } + + // Fill is a one-click bucket operation, not a continuous stroke. + // Slots 1-7 reuse the matching Paint pattern; Fill 8 is solid. + if (this.currentToolFamily === 'fill') { + const fillOptions = this.toolEngineOptions(); + // Shift-fill paints the active layer/colour, but treats the + // opposite layer as the flood boundary. This makes it possible + // to ink a region on one layer using linework on the other as + // the enclosing border. + if (editorAlternateActive(e)) fillOptions.boundaryMask = this.otherMask(); + const changed = this.engine.floodFillPattern( + this.currentMask(), + this.selectedToolSlots.fill, + this.cursorPosition.x, + this.cursorPosition.y, + fillOptions + ); + if (changed) { + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + } + return; + } + + // "Previously used Paint" means the Paint slot that most + // recently entered actual drawing use. This also covers keyboard + // switching to Paint without opening the palette first. + if (this.currentToolFamily === 'paint') { + this.lastPaintPatternSlot = this.selectedToolSlots.paint; + } + if (this.currentToolFamily === 'eraser') { + this.hasRetainedEraserChoice = true; + } + this.drawing = true; + if (this.canvasElement.setPointerCapture) this.canvasElement.setPointerCapture(e.pointerId); + this.lastPosition = {x: this.cursorPosition.x, y: this.cursorPosition.y}; + this.strokeState = this.engine.createStrokeState(); + this.engine.applyStrokeStart( + this.currentMask(), + this.currentToolFamily, + this.logicalToolId(), + this.cursorPosition.x, + this.cursorPosition.y, + this.strokeState, + this.toolEngineOptions() + ); + this.renderCurrentFrame(); + }); + this.canvasElement.addEventListener('pointerenter', (e) => { + this.pointerInsideCanvas = true; + this.cursorPosition = this.getCanvasCoords(e); + if (!this.drawing) this.renderCurrentFrame(); + }); + this.canvasElement.addEventListener('pointerleave', () => { + if (this.drawing) return; + this.pointerInsideCanvas = false; + this.renderCurrentFrame(); + }); + this.canvasElement.addEventListener('pointermove', (e) => { + this.pointerInsideCanvas = true; + this.cursorPosition = this.getCanvasCoords(e); + if (this.selectionDrag) { + e.preventDefault(); + const drag = this.selectionDrag; + if (drag.type === 'move') { + this.updateSelectionMove(this.cursorPosition.x, this.cursorPosition.y); + } else if (drag.type === 'resize' || drag.type === 'rotate') { + this.updateSelectionHandleDrag(this.cursorPosition.x, this.cursorPosition.y); + } else if (drag.type === 'rect') { + drag.currentX = this.cursorPosition.x; + drag.currentY = this.cursorPosition.y; + this.selectionRect = this.normalizeSelectionRect(drag.anchorX, drag.anchorY, drag.currentX, drag.currentY); + } else if (drag.type === 'lasso') { + drag.currentX = this.cursorPosition.x; + drag.currentY = this.cursorPosition.y; + const last = drag.points[drag.points.length - 1]; + if (!last || last.x !== this.cursorPosition.x || last.y !== this.cursorPosition.y) { + drag.points.push({x: this.cursorPosition.x, y: this.cursorPosition.y}); + } + } + this.renderCurrentFrame(); + return; + } + if (!this.drawing) { + this.renderCurrentFrame(); + return; + } + e.preventDefault(); + if (this.cursorPosition.x === this.lastPosition.x && this.cursorPosition.y === this.lastPosition.y) return; + const family = this.rightEraseActive ? 'eraser' : this.currentToolFamily; + this.engine.applyStrokeSegment( + this.currentMask(), + family, + this.logicalToolId(family), + this.lastPosition.x, + this.lastPosition.y, + this.cursorPosition.x, + this.cursorPosition.y, + this.strokeState, + this.toolEngineOptions(family) + ); + this.lastPosition = {x: this.cursorPosition.x, y: this.cursorPosition.y}; + this.renderCurrentFrame(); + }); + const finishPointerStroke = (e) => { + if (this.selectionDrag) { + if (e && this.canvasElement.hasPointerCapture && this.canvasElement.hasPointerCapture(e.pointerId)) { + this.canvasElement.releasePointerCapture(e.pointerId); + } + this.finishSelection(); + return; + } + if (!this.drawing) return; + if (e && this.canvasElement.hasPointerCapture && this.canvasElement.hasPointerCapture(e.pointerId)) { + this.canvasElement.releasePointerCapture(e.pointerId); + } + this.finishStroke(); + }; + this.canvasElement.addEventListener('pointerup', finishPointerStroke); + this.canvasElement.addEventListener('pointercancel', finishPointerStroke); + this.canvasElement.addEventListener('lostpointercapture', () => { + if (this.selectionDrag) this.finishSelection(); + else if (this.drawing) this.finishStroke(); + }); + + document.addEventListener('keydown', (e) => { + if (editorShortcutsBlocked()) return; + const target = e.target instanceof Element ? e.target : null; + // Editor shortcuts are global within the editor: focusing the canvas, + // a toolbar button, or the page background must not change which keys + // work. Only real text-entry controls keep their normal keystrokes. + const typingText = !!(target && target.matches('input, textarea, select, [contenteditable="true"]')); + if (typingText) return; + + // Space is always Play/Pause (unless the user is typing). Handle it + // before allowEditing because playback itself sets allowEditing=false, + // and Space still needs to be able to stop the animation. + if (shortcutAction(e, 'global') === 'playPause') { + e.preventDefault(); + if (!e.repeat) timeline.togglePlayPause(); + return; + } + + // The focused speed slider owns its remappable keys. + if (target && target.closest('#flipnote-speed-control')) return; + + if (editorAlternateKeyEvent(e) && this.allowEditing) { + if (!this.selectionMode) this.setExpertTools(true); + return; + } + + if (!this.allowEditing) return; + + const nudge = shortcutAction(e, 'editing'); + if (nudge.startsWith('nudge')) { + e.preventDefault(); + const dx = nudge === 'nudgeLeft' ? -1 : (nudge === 'nudgeRight' ? 1 : 0); + const dy = nudge === 'nudgeUp' ? -1 : (nudge === 'nudgeDown' ? 1 : 0); + this.nudgeSelectionOrLayer(dx, dy); + return; + } + if (!nudge) return; + e.preventDefault(); + switch (nudge) { + case 'undo': + timeline.getFrame(timeline.currentFrame).undo(); + break; + case 'redo': + timeline.getFrame(timeline.currentFrame).redo(); + break; + case 'clear': + case 'clearAlt': + if (e.repeat) break; + this.clearCurrentFrame(!!e.shiftKey); + break; + case 'cycleTool': + if (e.repeat) break; + this.cycleLastToolFamily(); + break; + case 'text': + if (!e.repeat && this.textTool) this.textTool.toggle(); + break; + case 'cycleInk': + if (e.repeat) break; + this.cycleActiveLayerColor(); + break; + case 'draw': + this.setToolFamily('pen'); + break; + case 'paint': + this.setToolFamily('paint'); + break; + case 'erase': + this.setToolFamily('eraser'); + break; + case 'fill': + this.setToolFamily('fill'); + break; + default: + if (/^slot[1-8]$/.test(nudge)) this.setToolSlot(Number(nudge.slice(4)) - 1); + break; + } + }); + document.addEventListener('keyup', (e) => { + if (editorAlternateReleaseEvent(e)) this.setExpertTools(false); + }); + window.addEventListener('blur', () => this.setExpertTools(false)); + + // Keep the marching selection outline animated even when the pointer is + // completely still. renderSelectionOverlay() derives its phase from + // time, so a small redraw tick is all that is required. + this.selectionAnimationTimer = window.setInterval(() => { + if (this.selectionMode || this.selectionDrag || this.hasSelection() || (this.textTool && this.textTool.session)) { + this.renderCurrentFrame(); + } + }, 100); + } + normalizeSelectionRect(x0, y0, x1, y1) { + const minX = Math.max(0, Math.min(x0, x1)); + const minY = Math.max(0, Math.min(y0, y1)); + const maxX = Math.min(this.width - 1, Math.max(x0, x1)); + const maxY = Math.min(this.height - 1, Math.max(y0, y1)); + return { + x: minX, + y: minY, + width: Math.max(1, maxX - minX + 1), + height: Math.max(1, maxY - minY + 1), + }; + } + selectionBoundsFromMask(mask = this.selectionMask) { + let minX = this.width; + let minY = this.height; + let maxX = -1; + let maxY = -1; + let count = 0; + for (let i = 0; i < mask.length; i++) { + if (!mask[i]) continue; + count += 1; + const x = i % this.width; + const y = Math.floor(i / this.width); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + if (!count) return {rect: null, count: 0}; + return { + rect: {x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1}, + count, + }; + } + applySelectionMask(mask) { + this.selectionMask.fill(0); + if (mask) this.selectionMask.set(mask); + const bounds = this.selectionBoundsFromMask(this.selectionMask); + this.selectionRect = bounds.rect; + this.selectionPixelCount = bounds.count; + this.selectionTransformGeometry = null; + } + hasSelection() { + return this.selectionPixelCount > 0 && !!this.selectionRect; + } + selectionContains(x, y) { + const sx = Math.round(x); + const sy = Math.round(y); + if (sx < 0 || sy < 0 || sx >= this.width || sy >= this.height) return false; + return !!this.selectionMask[sy * this.width + sx]; + } + setSelectionMode(enabled) { + const next = !!enabled; + if (next && this.textTool) this.textTool.disable(); + this.selectionMode = next; + if (!next) { + this.selectionDrag = null; + this.selectionMove = null; + this.mobileRectangleSelection = false; + this.rectangleSelectionMode = false; + this.selectionTransformGeometry = null; + } + this.updateCursorMode(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + this.renderCurrentFrame(); + } + clearSelection() { + this.selectionMode = false; + this.selectionDrag = null; + this.selectionMove = null; + this.mobileRectangleSelection = false; + this.rectangleSelectionMode = false; + this.selectionTransformGeometry = null; + this.selectionMask.fill(0); + this.selectionPixelCount = 0; + this.selectionRect = null; + this.updateCursorMode(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + this.renderCurrentFrame(); + } + clearSelectionForFrameChange() { + this.selectionMode = false; + this.selectionDrag = null; + this.selectionMove = null; + this.mobileRectangleSelection = false; + this.rectangleSelectionMode = false; + this.selectionTransformGeometry = null; + this.selectionMask.fill(0); + this.selectionPixelCount = 0; + this.selectionRect = null; + this.updateCursorMode(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + } + beginSelectionDrag(x, y, rectangular = false) { + this.selectionMove = null; + this.selectionTransformGeometry = null; + this.selectionMask.fill(0); + this.selectionPixelCount = 0; + this.selectionRect = {x, y, width: 1, height: 1}; + this.selectionDrag = rectangular + ? {type: 'rect', anchorX: x, anchorY: y, currentX: x, currentY: y} + : {type: 'lasso', points: [{x, y}], currentX: x, currentY: y}; + } + beginSelectionMove(x, y) { + if (!this.hasSelection()) return false; + const baseMask = cloneMask(this.currentMask()); + const sourceSelection = cloneMask(this.selectionMask); + let sourceInkCount = 0; + for (let i = 0; i < sourceSelection.length; i++) { + if (sourceSelection[i] && baseMask[i]) sourceInkCount += 1; + } + this.selectionDrag = { + type: 'move', + anchorX: x, + anchorY: y, + currentX: x, + currentY: y, + baseMask, + sourceSelection, + sourceInkCount, + geometry: this.selectionTransformGeometry ? {...this.selectionTransformGeometry} : null, + dx: 0, + dy: 0, + }; + this.selectionMove = this.selectionDrag; + return true; + } + updateSelectionMove(x, y) { + const move = this.selectionDrag; + if (!move || move.type !== 'move') return; + const dx = Math.round(x - move.anchorX); + const dy = Math.round(y - move.anchorY); + move.currentX = x; + move.currentY = y; + if (dx === move.dx && dy === move.dy) return; + move.dx = dx; + move.dy = dy; + const target = this.currentMask(); + target.set(move.baseMask); + for (let i = 0; i < move.sourceSelection.length; i++) { + if (move.sourceSelection[i] && move.baseMask[i]) target[i] = 0; + } + const translated = new Uint8Array(this.width * this.height); + for (let i = 0; i < move.sourceSelection.length; i++) { + if (!move.sourceSelection[i]) continue; + const x0 = i % this.width; + const y0 = Math.floor(i / this.width); + const nx = x0 + dx; + const ny = y0 + dy; + if (nx < 0 || ny < 0 || nx >= this.width || ny >= this.height) continue; + const ni = ny * this.width + nx; + translated[ni] = 1; + if (move.baseMask[i]) target[ni] = 1; + } + this.applySelectionMask(translated); + if (move.geometry) this.selectionTransformGeometry = {...move.geometry, cx: move.geometry.cx + dx, cy: move.geometry.cy + dy}; + } + rasterLineIntoMask(mask, x0, y0, x1, y1) { + let ax = Math.round(x0); + let ay = Math.round(y0); + const bx = Math.round(x1); + const by = Math.round(y1); + const dx = Math.abs(bx - ax); + const sx = ax < bx ? 1 : -1; + const dy = -Math.abs(by - ay); + const sy = ay < by ? 1 : -1; + let err = dx + dy; + while (true) { + if (ax >= 0 && ay >= 0 && ax < this.width && ay < this.height) mask[ay * this.width + ax] = 1; + if (ax === bx && ay === by) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; ax += sx; } + if (e2 <= dx) { err += dx; ay += sy; } + } + } + lassoMaskFromPoints(points) { + const mask = new Uint8Array(this.width * this.height); + if (!Array.isArray(points) || !points.length) return mask; + if (points.length < 3) { + for (let i = 1; i < points.length; i++) { + this.rasterLineIntoMask(mask, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y); + } + if (points.length === 1) mask[points[0].y * this.width + points[0].x] = 1; + return mask; + } + let minX = this.width - 1; + let maxX = 0; + let minY = this.height - 1; + let maxY = 0; + points.forEach((point) => { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + }); + minX = Math.max(0, Math.floor(minX)); + maxX = Math.min(this.width - 1, Math.ceil(maxX)); + minY = Math.max(0, Math.floor(minY)); + maxY = Math.min(this.height - 1, Math.ceil(maxY)); + const insidePolygon = (px, py) => { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const xi = points[i].x; + const yi = points[i].y; + const xj = points[j].x; + const yj = points[j].y; + const crosses = ((yi > py) !== (yj > py)) + && (px < ((xj - xi) * (py - yi)) / ((yj - yi) || 1e-9) + xi); + if (crosses) inside = !inside; + } + return inside; + }; + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + if (insidePolygon(x + 0.5, y + 0.5)) mask[y * this.width + x] = 1; + } + } + for (let i = 1; i < points.length; i++) { + this.rasterLineIntoMask(mask, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y); + } + this.rasterLineIntoMask(mask, points[points.length - 1].x, points[points.length - 1].y, points[0].x, points[0].y); + return mask; + } + rectangleMask(rect) { + const mask = new Uint8Array(this.width * this.height); + if (!rect) return mask; + for (let y = rect.y; y < rect.y + rect.height; y++) { + const row = y * this.width; + for (let x = rect.x; x < rect.x + rect.width; x++) mask[row + x] = 1; + } + return mask; + } + selectBoundingRectangle() { + if (!this.hasSelection()) return false; + this.selectionDrag = null; + this.selectionMove = null; + this.applySelectionMask(this.rectangleMask(this.selectionRect)); + this.selectionTransformGeometry = this.selectionGeometryFromRect(this.selectionRect); + this.selectionMode = true; + this.rectangleSelectionMode = true; + this.mobileRectangleSelection = false; + this.updateCursorMode(); + this.renderCurrentFrame(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return true; + } + selectionTransformHandles() { + if (!this.rectangleSelectionMode || !this.hasSelection()) return []; + const geometry = this.selectionTransformGeometry || this.selectionGeometryFromRect(this.selectionRect); + const halfWidth = (geometry.width - 1) / 2; + const halfHeight = (geometry.height - 1) / 2; + const cos = Math.cos(geometry.angle), sin = Math.sin(geometry.angle); + const point = (localX, localY) => ({ + x: geometry.cx + localX * cos - localY * sin, + y: geometry.cy + localX * sin + localY * cos, + }); + const handles = []; + for (const [row, localY] of [['n', -halfHeight], ['', 0], ['s', halfHeight]]) { + for (const [column, localX] of [['w', -halfWidth], ['', 0], ['e', halfWidth]]) { + if (row || column) handles.push({name: row + column, ...point(localX, localY)}); + } + } + handles.push({name: 'rotate', ...point(0, -halfHeight - 11)}); + return handles; + } + selectionGeometryFromRect(rect) { + return { + cx: rect.x + (rect.width - 1) / 2, + cy: rect.y + (rect.height - 1) / 2, + width: rect.width, height: rect.height, angle: 0, + }; + } + selectionTransformHandleAt(x, y) { + const handles = this.selectionTransformHandles(); + let nearest = null; + let distance = Infinity; + for (const handle of handles.slice().reverse()) { + const d = Math.hypot(x - handle.x, y - handle.y); + if (d <= 5 && d < distance) { nearest = handle.name; distance = d; } + } + return nearest; + } + beginSelectionHandleDrag(handle, x, y) { + const geometry = {...(this.selectionTransformGeometry || this.selectionGeometryFromRect(this.selectionRect))}; + this.selectionDrag = { + type: handle === 'rotate' ? 'rotate' : 'resize', handle, geometry, + sourceLayer: cloneMask(this.currentMask()), + sourceSelection: cloneMask(this.selectionMask), + startAngle: Math.atan2(y - geometry.cy, x - geometry.cx), + changed: false, + }; + } + updateSelectionHandleDrag(x, y) { + const drag = this.selectionDrag; + if (!drag || (drag.type !== 'resize' && drag.type !== 'rotate')) return; + const source = drag.geometry; + const geometry = {...source}; + if (drag.type === 'rotate') { + geometry.angle = source.angle + Math.atan2(y - source.cy, x - source.cx) - drag.startAngle; + } else { + const cos = Math.cos(source.angle), sin = Math.sin(source.angle); + const localX = (x - source.cx) * cos + (y - source.cy) * sin; + const localY = -(x - source.cx) * sin + (y - source.cy) * cos; + let left = -(source.width - 1) / 2, right = (source.width - 1) / 2; + let top = -(source.height - 1) / 2, bottom = (source.height - 1) / 2; + if (drag.handle.includes('w')) left = Math.min(localX, right); + if (drag.handle.includes('e')) right = Math.max(localX, left); + if (drag.handle.includes('n')) top = Math.min(localY, bottom); + if (drag.handle.includes('s')) bottom = Math.max(localY, top); + geometry.width = Math.max(1, right - left + 1); + geometry.height = Math.max(1, bottom - top + 1); + const centerX = (left + right) / 2, centerY = (top + bottom) / 2; + geometry.cx = source.cx + centerX * cos - centerY * sin; + geometry.cy = source.cy + centerX * sin + centerY * cos; + } + const target = this.currentMask(); + target.set(drag.sourceLayer); + for (let i = 0; i < drag.sourceSelection.length; i++) { + if (drag.sourceSelection[i]) target[i] = 0; + } + const transformedSelection = new Uint8Array(this.width * this.height); + const targetCos = Math.cos(geometry.angle), targetSin = Math.sin(geometry.angle); + const sourceCos = Math.cos(source.angle), sourceSin = Math.sin(source.angle); + const halfWidth = geometry.width / 2, halfHeight = geometry.height / 2; + const corners = [ + [-halfWidth, -halfHeight], [halfWidth, -halfHeight], + [-halfWidth, halfHeight], [halfWidth, halfHeight], + ].map(([px, py]) => [geometry.cx + px * targetCos - py * targetSin, geometry.cy + px * targetSin + py * targetCos]); + const left = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[0])))); + const right = Math.min(this.width - 1, Math.ceil(Math.max(...corners.map((point) => point[0])))); + const top = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[1])))); + const bottom = Math.min(this.height - 1, Math.ceil(Math.max(...corners.map((point) => point[1])))); + for (let dy = top; dy <= bottom; dy++) { + for (let dx = left; dx <= right; dx++) { + const localX = (dx - geometry.cx) * targetCos + (dy - geometry.cy) * targetSin; + const localY = -(dx - geometry.cx) * targetSin + (dy - geometry.cy) * targetCos; + if (Math.abs(localX) > halfWidth || Math.abs(localY) > halfHeight) continue; + const scaledX = localX * source.width / geometry.width; + const scaledY = localY * source.height / geometry.height; + const sx = Math.round(source.cx + scaledX * sourceCos - scaledY * sourceSin); + const sy = Math.round(source.cy + scaledX * sourceSin + scaledY * sourceCos); + if (sx < 0 || sy < 0 || sx >= this.width || sy >= this.height) continue; + const sourceIndex = sy * this.width + sx; + if (!drag.sourceSelection[sourceIndex]) continue; + const destIndex = dy * this.width + dx; + transformedSelection[destIndex] = 1; + if (drag.sourceLayer[sourceIndex]) target[destIndex] = 1; + } + } + this.applySelectionMask(transformedSelection); + this.selectionTransformGeometry = geometry; + drag.changed = true; + this.renderCurrentFrame(); + } + invertSelection(swapLayerColors = false) { + if (!this.hasSelection() || !this.allowEditing || timeline.playing) return false; + if (swapLayerColors) { + const frame = this.currentFrame(); + if (frame.colors[0] === frame.colors[1]) return false; + let changed = false; + for (let i = 0; i < this.selectionMask.length; i++) { + if (!this.selectionMask[i] || frame.layers[0][i] === frame.layers[1][i]) continue; + [frame.layers[0][i], frame.layers[1][i]] = [frame.layers[1][i], frame.layers[0][i]]; + changed = true; + } + if (!changed) return false; + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + return true; + } + const mask = this.currentMask(); + for (let i = 0; i < this.selectionMask.length; i++) { + if (this.selectionMask[i]) mask[i] = mask[i] ? 0 : 1; + } + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + return true; + } + finishSelection() { + const drag = this.selectionDrag; + if (!drag) return; + if (drag.type === 'resize' || drag.type === 'rotate') { + this.selectionDrag = null; + if (drag.changed) timeline.updateFrame(timeline.currentFrame); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + this.renderCurrentFrame(); + return; + } + if (drag.type === 'move') { + const moved = drag.dx !== 0 || drag.dy !== 0; + const changedInk = moved && drag.sourceInkCount > 0; + this.selectionDrag = null; + this.selectionMove = null; + this.updateCursorMode(); + if (changedInk) timeline.updateFrame(timeline.currentFrame); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + this.renderCurrentFrame(); + return; + } + let mask; + if (drag.type === 'rect') { + const rect = this.normalizeSelectionRect(drag.anchorX, drag.anchorY, drag.currentX, drag.currentY); + mask = this.rectangleMask(rect); + } else { + mask = this.lassoMaskFromPoints(drag.points || []); + } + this.selectionDrag = null; + this.mobileRectangleSelection = false; + this.applySelectionMask(mask); + if (this.rectangleSelectionMode && this.hasSelection()) { + this.selectionTransformGeometry = this.selectionGeometryFromRect(this.selectionRect); + } + this.updateCursorMode(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + this.renderCurrentFrame(); + } + copySelection(cut = false) { + if (!this.hasSelection()) return false; + const rect = this.selectionRect; + const source = this.currentMask(); + const data = new Uint8Array(rect.width * rect.height); + const shape = new Uint8Array(rect.width * rect.height); + for (let y = 0; y < rect.height; y++) { + for (let x = 0; x < rect.width; x++) { + const srcIndex = (rect.y + y) * this.width + (rect.x + x); + const localIndex = y * rect.width + x; + if (!this.selectionMask[srcIndex]) continue; + shape[localIndex] = 1; + data[localIndex] = source[srcIndex] ? 1 : 0; + } + } + this.selectionClipboard = { + width: rect.width, + height: rect.height, + data, + shape, + sourceX: rect.x, + sourceY: rect.y, + }; + if (cut) { + let changed = false; + for (let y = 0; y < rect.height; y++) { + for (let x = 0; x < rect.width; x++) { + const localIndex = y * rect.width + x; + if (!shape[localIndex] || !data[localIndex]) continue; + const srcIndex = (rect.y + y) * this.width + (rect.x + x); + if (source[srcIndex]) { + source[srcIndex] = 0; + changed = true; + } + } + } + if (changed) { + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + } + } + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return true; + } + pasteSelection() { + const clip = this.selectionClipboard; + if (!clip) return false; + const targetAnchor = this.hasSelection() ? this.selectionRect : {x: clip.sourceX, y: clip.sourceY}; + const targetX = Math.max(0, Math.min(this.width - 1, targetAnchor.x)); + const targetY = Math.max(0, Math.min(this.height - 1, targetAnchor.y)); + const target = this.currentMask(); + const pastedSelection = new Uint8Array(this.width * this.height); + let changed = false; + for (let y = 0; y < clip.height; y++) { + for (let x = 0; x < clip.width; x++) { + const localIndex = y * clip.width + x; + if (!clip.shape[localIndex]) continue; + const destX = targetX + x; + const destY = targetY + y; + if (destX < 0 || destY < 0 || destX >= this.width || destY >= this.height) continue; + const destIndex = destY * this.width + destX; + pastedSelection[destIndex] = 1; + if (clip.data[localIndex] && !target[destIndex]) { + target[destIndex] = 1; + changed = true; + } + } + } + this.applySelectionMask(pastedSelection); + this.selectionMode = true; + if (changed) timeline.updateFrame(timeline.currentFrame); + this.renderCurrentFrame(); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return true; + } + selectionTransformRegion() { + if (this.hasSelection()) { + return { + selected: true, + rect: {...this.selectionRect}, + shape: cloneMask(this.selectionMask), + }; + } + return { + selected: false, + rect: {x: 0, y: 0, width: this.width, height: this.height}, + shape: null, + }; + } + applySelectionOrLayerTransform(kind) { + const region = this.selectionTransformRegion(); + const rect = region.rect; + const sourceLayer = cloneMask(this.currentMask()); + const targetLayer = this.currentMask(); + const sourceWidth = rect.width; + const sourceHeight = rect.height; + let destWidth = sourceWidth; + let destHeight = sourceHeight; + let mapSource; + + if (kind === 'shrink' || kind === 'grow') { + const factor = kind === 'grow' ? FLIPNOTE_RESIZE_GROW_FACTOR : FLIPNOTE_RESIZE_SHRINK_FACTOR; + destWidth = Math.max(1, Math.round(sourceWidth * factor)); + destHeight = Math.max(1, Math.round(sourceHeight * factor)); + mapSource = (dx, dy) => ({ + x: Math.min(sourceWidth - 1, Math.floor(dx / factor)), + y: Math.min(sourceHeight - 1, Math.floor(dy / factor)), + }); + } else if (kind === 'flip-x') { + mapSource = (dx, dy) => ({x: sourceWidth - 1 - dx, y: dy}); + } else if (kind === 'flip-y') { + mapSource = (dx, dy) => ({x: dx, y: sourceHeight - 1 - dy}); + } else if (kind === 'rotate-cw') { + destWidth = sourceHeight; + destHeight = sourceWidth; + mapSource = (dx, dy) => ({x: dy, y: sourceHeight - 1 - dx}); + } else if (kind === 'rotate-ccw') { + destWidth = sourceHeight; + destHeight = sourceWidth; + mapSource = (dx, dy) => ({x: sourceWidth - 1 - dy, y: dx}); + } else { + return false; + } + + const sourceCenterX = rect.x + (sourceWidth - 1) / 2; + const sourceCenterY = rect.y + (sourceHeight - 1) / 2; + const destX = Math.round(sourceCenterX - (destWidth - 1) / 2); + const destY = Math.round(sourceCenterY - (destHeight - 1) / 2); + const transformedSelection = region.selected ? new Uint8Array(this.width * this.height) : null; + + if (region.selected) { + // Match selection moving/pasting semantics: remove only ink that was + // inside the selected shape. Empty pixels in the selection do not + // erase unrelated ink already sitting under the transformed result. + for (let i = 0; i < region.shape.length; i++) { + if (region.shape[i] && targetLayer[i]) targetLayer[i] = 0; + } + } else { + targetLayer.fill(0); + } + + for (let dy = 0; dy < destHeight; dy++) { + const canvasY = destY + dy; + if (canvasY < 0 || canvasY >= this.height) continue; + for (let dx = 0; dx < destWidth; dx++) { + const canvasX = destX + dx; + if (canvasX < 0 || canvasX >= this.width) continue; + const source = mapSource(dx, dy); + if (!source || source.x < 0 || source.y < 0 || source.x >= sourceWidth || source.y >= sourceHeight) continue; + const sourceIndex = (rect.y + source.y) * this.width + (rect.x + source.x); + if (region.selected && !region.shape[sourceIndex]) continue; + const destIndex = canvasY * this.width + canvasX; + if (transformedSelection) transformedSelection[destIndex] = 1; + if (sourceLayer[sourceIndex]) targetLayer[destIndex] = 1; + } + } + + if (transformedSelection) { + this.selectionDrag = null; + this.selectionMove = null; + this.applySelectionMask(transformedSelection); + this.selectionMode = true; + this.updateCursorMode(); + } + + let changed = false; + for (let i = 0; i < sourceLayer.length; i++) { + if (sourceLayer[i] !== targetLayer[i]) { + changed = true; + break; + } + } + this.renderCurrentFrame(); + if (changed) timeline.updateFrame(timeline.currentFrame); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return true; + } + resizeSelectionOrLayer(enlarge = false) { + return this.applySelectionOrLayerTransform(enlarge ? 'grow' : 'shrink'); + } + flipSelectionOrLayer(vertical = false) { + return this.applySelectionOrLayerTransform(vertical ? 'flip-y' : 'flip-x'); + } + rotateSelectionOrLayer(anticlockwise = false) { + return this.applySelectionOrLayerTransform(anticlockwise ? 'rotate-ccw' : 'rotate-cw'); + } + nudgeSelectionOrLayer(dx, dy) { + dx = Math.sign(Number(dx) || 0); + dy = Math.sign(Number(dy) || 0); + if ((!dx && !dy) || (dx && dy)) return false; + + const sourceLayer = cloneMask(this.currentMask()); + const targetLayer = this.currentMask(); + const movingSelection = this.hasSelection(); + const sourceSelection = movingSelection ? cloneMask(this.selectionMask) : null; + const movedSelection = movingSelection ? new Uint8Array(this.width * this.height) : null; + const sourceGeometry = this.selectionTransformGeometry ? {...this.selectionTransformGeometry} : null; + + if (movingSelection) { + // Remove only selected ink from its old position. Like the pointer + // move tool, empty pixels inside the selection do not punch holes in + // unrelated artwork at the destination. + for (let i = 0; i < sourceSelection.length; i++) { + if (sourceSelection[i] && targetLayer[i]) targetLayer[i] = 0; + } + for (let i = 0; i < sourceSelection.length; i++) { + if (!sourceSelection[i]) continue; + const x = i % this.width; + const y = Math.floor(i / this.width); + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= this.width || ny >= this.height) continue; + const ni = ny * this.width + nx; + movedSelection[ni] = 1; + if (sourceLayer[i]) targetLayer[ni] = 1; + } + this.selectionDrag = null; + this.selectionMove = null; + this.applySelectionMask(movedSelection); + if (sourceGeometry) this.selectionTransformGeometry = {...sourceGeometry, cx: sourceGeometry.cx + dx, cy: sourceGeometry.cy + dy}; + this.selectionMode = true; + this.updateCursorMode(); + } else { + targetLayer.fill(0); + for (let i = 0; i < sourceLayer.length; i++) { + if (!sourceLayer[i]) continue; + const x = i % this.width; + const y = Math.floor(i / this.width); + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= this.width || ny >= this.height) continue; + targetLayer[ny * this.width + nx] = 1; + } + } + + let changed = false; + for (let i = 0; i < sourceLayer.length; i++) { + if (sourceLayer[i] !== targetLayer[i]) { + changed = true; + break; + } + } + this.renderCurrentFrame(); + if (changed) timeline.updateFrame(timeline.currentFrame); + if (typeof updateSelectionToolState === 'function') updateSelectionToolState(); + return true; + } + currentFrame() { + return timeline.getFrame(timeline.currentFrame); + } + currentMask() { + return this.currentFrame().layers[this.activeLayer]; + } + otherMask() { + return this.currentFrame().layers[this.activeLayer === 0 ? 1 : 0]; + } + finishStroke() { + if (!this.drawing) return; + const wasRightErase = this.rightEraseActive; + const previousTool = this.rightErasePreviousTool; + this.drawing = false; + this.rightEraseActive = false; + this.rightErasePreviousTool = null; + this.strokeState = null; + + if (wasRightErase && previousTool) { + this.currentToolFamily = previousTool.family; + this.canvasElement.classList.remove('pen1-cursor', 'pen2-cursor', 'eraser-cursor', 'checker-cursor', 'paint-cursor', 'fill-cursor'); + this.canvasElement.classList.add( + this.currentToolFamily === 'eraser' ? 'eraser-cursor' + : (this.currentToolFamily === 'paint' ? 'paint-cursor' + : (this.currentToolFamily === 'fill' ? 'fill-cursor' : 'pen1-cursor')) + ); + if (typeof retainedToolChoice !== 'undefined' && previousTool.retainedChoice) { + retainedToolChoice = {...previousTool.retainedChoice}; + } + if (typeof updateDrawingToolUI === 'function') updateDrawingToolUI(); + } + + this.updateCursorMode(); + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + } + setActiveLayer(index) { + if (this.textTool) this.textTool.finish(); + this.activeLayer = index === 1 ? 1 : 0; + updateLayerPanel(); + if (this.pointerInsideCanvas && !this.drawing) this.renderCurrentFrame(); + } + setLayerColor(layerIndex, color) { + if (!FLIPNOTE_INKS[color]) return; + const frame = this.currentFrame(); + frame.colors[layerIndex] = color; + this.activeLayer = layerIndex; + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + updateLayerPanel(); + } + cycleActiveLayerColor() { + const frame = this.currentFrame(); + const oldColor = frame.colors[this.activeLayer]; + const next = FLIPNOTE_INK_ORDER[(FLIPNOTE_INK_ORDER.indexOf(oldColor) + 1) % FLIPNOTE_INK_ORDER.length]; + this.setLayerColor(this.activeLayer, next); + } + cycleLastToolFamily() { + // Each family already remembers its own slot and Advanced choice. T simply + // walks through those retained tools in the editor's familiar order. + const order = ['pen', 'paint', 'fill', 'eraser']; + const currentIndex = order.indexOf(this.currentToolFamily); + const nextFamily = order[(currentIndex + 1 + order.length) % order.length]; + this.setToolFamily(nextFamily); + } + activeToolAdvanced(family = this.currentToolFamily) { + if (family === 'fill') return false; + return !!(this.expertTools || this.selectedToolAdvanced[family]); + } + toolEngineOptions(family = this.currentToolFamily) { + return { + paintState: this.activeToolAdvanced('paint') && family === 'paint' ? 1 : 0, + motif: this.customPaintMotif, + eraserPatternId: this.lastPaintPatternSlot, + }; + } + logicalToolId(family = this.currentToolFamily) { + // Fill has no separate Shift/Advanced geometry: its eight slots are the + // seven Paint patterns plus the solid Fill 8 bucket. + if (family === 'fill') return this.selectedToolSlots.fill; + return this.engine.logicalId( + this.selectedToolSlots[family], + this.activeToolAdvanced(family) + ); + } + setToolFamily(family) { + if (this.textTool) this.textTool.disable(); + if (this.selectionMode || this.hasSelection()) this.clearSelection(); + this.currentToolFamily = ['pen', 'paint', 'eraser', 'fill'].includes(family) ? family : 'pen'; + this.canvasElement.classList.remove('pen1-cursor', 'pen2-cursor', 'eraser-cursor', 'checker-cursor', 'paint-cursor', 'fill-cursor'); + this.canvasElement.classList.add( + this.currentToolFamily === 'eraser' ? 'eraser-cursor' + : (this.currentToolFamily === 'paint' ? 'paint-cursor' + : (this.currentToolFamily === 'fill' ? 'fill-cursor' : 'pen1-cursor')) + ); + if (typeof retainCurrentToolChoice === 'function') retainCurrentToolChoice(); + if (typeof updateDrawingToolUI === 'function') updateDrawingToolUI(); + this.updateCursorMode(); + if (this.pointerInsideCanvas && !this.drawing) this.renderCurrentFrame(); + } + setToolSlot(slot, advanced = this.expertTools) { + if (this.textTool) this.textTool.disable(); + const safeSlot = Math.max(0, Math.min(7, Math.round(Number(slot) || 0))); + this.selectedToolSlots[this.currentToolFamily] = safeSlot; + if (this.currentToolFamily === 'paint') this.lastPaintPatternSlot = safeSlot; + if (this.currentToolFamily === 'eraser') this.hasRetainedEraserChoice = true; + this.selectedToolAdvanced[this.currentToolFamily] = this.currentToolFamily === 'fill' ? false : !!advanced; + if (typeof retainCurrentToolChoice === 'function') retainCurrentToolChoice(); + if (typeof updateDrawingToolUI === 'function') updateDrawingToolUI(); + this.updateCursorMode(); + if (this.pointerInsideCanvas && !this.drawing) this.renderCurrentFrame(); + } + setExpertTools(enabled) { + const next = !!enabled; + if (this.expertTools === next) return; + this.expertTools = next; + if (typeof updateDrawingToolUI === 'function') updateDrawingToolUI(); + this.updateCursorMode(); + if (this.pointerInsideCanvas && !this.drawing) this.renderCurrentFrame(); + } + // Compatibility wrapper for old buttons/third-party scripts that used the + // restored legacy tool names. + setTool(tool) { + if (this.textTool) this.textTool.disable(); + if (tool === 'pen2') { + this.currentToolFamily = 'pen'; + this.selectedToolSlots.pen = 1; + this.selectedToolAdvanced.pen = false; + } else if (tool === 'checker') { + this.currentToolFamily = 'paint'; + this.selectedToolSlots.paint = 4; + this.selectedToolAdvanced.paint = false; + } else if (tool === 'eraser') { + this.currentToolFamily = 'eraser'; + this.selectedToolAdvanced.eraser = false; + this.hasRetainedEraserChoice = true; + } else { + this.currentToolFamily = 'pen'; + this.selectedToolSlots.pen = 0; + this.selectedToolAdvanced.pen = false; + } + this.setToolFamily(this.currentToolFamily); + } + setMaskPixel(x, y, value) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + this.currentMask()[y * this.width + x] = value ? 1 : 0; + } + drawPixel(x, y) { + this.strokeState = this.engine.createStrokeState(); + this.engine.applyStrokeStart(this.currentMask(), this.currentToolFamily, this.logicalToolId(), x, y, this.strokeState, this.toolEngineOptions()); + } + drawLine(x0, y0, x1, y1) { + if (!this.strokeState) this.strokeState = this.engine.createStrokeState(); + this.engine.applyStrokeSegment(this.currentMask(), this.currentToolFamily, this.logicalToolId(), x0, y0, x1, y1, this.strokeState, this.toolEngineOptions()); + } + renderFrameInk(frame, ghost = false, opacity = 1) { + if (!frame) return; + const pixels = new Uint8ClampedArray(this.width * this.height * 4); + const layer1Color = resolveFrameInk(frame, frame.colors[0]); + const layer2Color = resolveFrameInk(frame, frame.colors[1]); + const ghostColor = [96, 96, 96]; + for (let i = 0; i < this.width * this.height; i++) { + let color = null; + if (editorLayerVisibility[0] && frame.layers[0][i]) color = ghost ? ghostColor : layer1Color; + else if (editorLayerVisibility[1] && frame.layers[1][i]) color = ghost ? ghostColor : layer2Color; + if (!color) continue; + const p = i * 4; + pixels[p] = color[0]; + pixels[p + 1] = color[1]; + pixels[p + 2] = color[2]; + pixels[p + 3] = 255; + } + this.inkBufferCtx.clearRect(0, 0, this.width, this.height); + this.inkBufferCtx.putImageData(new ImageData(pixels, this.width, this.height), 0, 0); + this.ctx.save(); + this.ctx.globalAlpha = opacity; + this.ctx.drawImage(this.inkBuffer, 0, 0); + this.ctx.restore(); + } + buildFillPreviewMask(startX, startY) { + const preview = this.cursorPreviewMask; + preview.fill(0); + const layer = this.currentMask(); + const sx = Math.round(startX); + const sy = Math.round(startY); + if (sx < 0 || sy < 0 || sx >= this.width || sy >= this.height) return preview; + const startIndex = sy * this.width + sx; + if (layer[startIndex]) return preview; + + const queue = new Int32Array(this.width * this.height); + let head = 0; + let tail = 0; + queue[tail++] = startIndex; + preview[startIndex] = 1; + while (head < tail) { + const index = queue[head++]; + const x = index % this.width; + const y = Math.floor(index / this.width); + if (x > 0) { + const next = index - 1; + if (!preview[next] && !layer[next]) { preview[next] = 1; queue[tail++] = next; } + } + if (x + 1 < this.width) { + const next = index + 1; + if (!preview[next] && !layer[next]) { preview[next] = 1; queue[tail++] = next; } + } + if (y > 0) { + const next = index - this.width; + if (!preview[next] && !layer[next]) { preview[next] = 1; queue[tail++] = next; } + } + if (y + 1 < this.height) { + const next = index + this.width; + if (!preview[next] && !layer[next]) { preview[next] = 1; queue[tail++] = next; } + } + } + return preview; + } + buildCursorPreviewMask() { + const family = this.rightEraseActive ? 'eraser' : this.currentToolFamily; + if (family === 'fill') { + return this.buildFillPreviewMask(this.cursorPosition.x, this.cursorPosition.y); + } + const preview = this.cursorPreviewMask; + const erasing = family === 'eraser'; + preview.fill(erasing ? 1 : 0); + const logicalId = this.logicalToolId(family); + // Advanced Erase keeps the same outer geometry as the matching regular + // Eraser, while candidate pixels mirror the most recently used Paint + // texture. Preview the actual filtered mask; renderCursorPreview() + // still gives Advanced Erase its spaced outline treatment. + const previewLogicalId = logicalId; + const randomState = this.engine.getRandomState ? this.engine.getRandomState() : null; + try { + this.engine.applyStrokeStart( + preview, + family, + previewLogicalId, + this.cursorPosition.x, + this.cursorPosition.y, + this.engine.createStrokeState(), + this.toolEngineOptions(family) + ); + } finally { + if (randomState !== null && this.engine.resetRandom) this.engine.resetRandom(randomState); + } + if (erasing) { + for (let i = 0; i < preview.length; i++) preview[i] = preview[i] ? 0 : 1; + } + return preview; + } + cursorUsesCrosshair() { + // Brush/nib tools use the editor's pixel-outline preview instead of the + // browser crosshair. Right-click temporarily forces the Eraser preview, + // even if Select or Fill is otherwise active. + if (this.rightEraseActive) return false; + return this.selectionMode || this.currentToolFamily === 'fill'; + } + updateCursorMode() { + this.canvasElement.classList.toggle('flipnote-brush-preview-crosshair', this.cursorUsesCrosshair()); + this.canvasElement.classList.toggle('flipnote-text-cursor', !!(this.textTool && this.textTool.enabled && !this.rightEraseActive)); + } + renderCursorPreview() { + if (this.textTool && this.textTool.enabled && !this.rightEraseActive) return; + // Keep the +1px brush outline visible while an Eraser stroke is actively + // held down. Other drawing tools retain the original pen-down behavior and + // hide their preview until the stroke ends. + const family = this.rightEraseActive ? 'eraser' : this.currentToolFamily; + if (!this.pointerInsideCanvas || (this.selectionMode && !this.rightEraseActive) || (this.drawing && family !== 'eraser') || !this.allowEditing || timeline.playing) return; + const frame = this.currentFrame(); + if (!frame) return; + const preview = this.buildCursorPreviewMask(); + const ink = resolveFrameInk(frame, frame.colors[this.activeLayer]); + + // The guide should sit one pixel outside the pixels that the selected + // tool would actually affect. Dilate the exact preview mask by one + // canvas pixel in every direction, then draw only that enlarged mask's + // outer boundary. This gives every nib/fill region a true +1px outline + // without changing the underlying drawing operation at all. + const enlarged = new Uint8Array(preview.length); + for (let i = 0; i < preview.length; i++) { + if (!preview[i]) continue; + const x = i % this.width; + const y = Math.floor(i / this.width); + const minY = Math.max(0, y - 1); + const maxY = Math.min(this.height - 1, y + 1); + const minX = Math.max(0, x - 1); + const maxX = Math.min(this.width - 1, x + 1); + for (let yy = minY; yy <= maxY; yy++) { + const row = yy * this.width; + for (let xx = minX; xx <= maxX; xx++) enlarged[row + xx] = 1; + } + } + + this.ctx.save(); + const normalOutline = `rgb(${ink[0]}, ${ink[1]}, ${ink[2]})`; + const layer1Color = resolveFrameInk(frame, frame.colors[0]); + const layer2Color = resolveFrameInk(frame, frame.colors[1]); + const outlineStyleAt = (index) => { + // If the guide crosses existing ink, draw the exact RGB inverse of + // the visible drawn pixel at full opacity. That keeps the cursor + // readable even when its normal guide colour matches the artwork. + let drawn = null; + if (frame.layers[0][index]) drawn = layer1Color; + else if (frame.layers[1][index]) drawn = layer2Color; + if (!drawn) return {style: normalOutline, alpha: 0.5}; + return { + style: `rgb(${255 - drawn[0]}, ${255 - drawn[1]}, ${255 - drawn[2]})`, + alpha: 1, + }; + }; + const advancedEraserOutline = family === 'eraser' && this.logicalToolId(family) >= 8; + let firstBoundary = -1; + let painted = 0; + for (let i = 0; i < enlarged.length; i++) { + if (!enlarged[i]) continue; + const x = i % this.width; + const y = Math.floor(i / this.width); + const boundary = x === 0 || y === 0 || x === this.width - 1 || y === this.height - 1 + || !enlarged[i - 1] || !enlarged[i + 1] + || (y > 0 && !enlarged[i - this.width]) + || (y + 1 < this.height && !enlarged[i + this.width]); + if (!boundary) continue; + if (firstBoundary < 0) firstBoundary = i; + // Regular tools use a continuous enlarged outline. Advanced Erase + // uses the same outer Eraser geometry, but with two-pixel gaps so + // the sparse/filtered erase mode remains visually distinct even + // though the +1px guide dilation would otherwise close its holes. + if (advancedEraserOutline && ((x + y) & 3) >= 2) continue; + const guide = outlineStyleAt(i); + this.ctx.globalAlpha = guide.alpha; + this.ctx.fillStyle = guide.style; + this.ctx.fillRect(x, y, 1, 1); + painted++; + } + if (!painted && firstBoundary >= 0) { + const guide = outlineStyleAt(firstBoundary); + this.ctx.globalAlpha = guide.alpha; + this.ctx.fillStyle = guide.style; + this.ctx.fillRect(firstBoundary % this.width, Math.floor(firstBoundary / this.width), 1, 1); + } + this.ctx.restore(); + } + renderSelectionOverlay() { + const drag = this.selectionDrag; + const phase = Math.floor(performance.now() / 100) % 2; + this.ctx.save(); + this.ctx.fillStyle = FLIPNOTE_SELECTION_YELLOW; + + const drawRect = (rect) => { + if (!rect) return; + const left = rect.x; + const top = rect.y; + const right = rect.x + rect.width - 1; + const bottom = rect.y + rect.height - 1; + for (let x = left; x <= right; x++) { + if (((x + phase) & 1) !== 0) continue; + this.ctx.fillRect(x, top, 1, 1); + if (bottom !== top) this.ctx.fillRect(x, bottom, 1, 1); + } + for (let y = top; y <= bottom; y++) { + if (((y + phase) & 1) !== 0) continue; + this.ctx.fillRect(left, y, 1, 1); + if (right !== left) this.ctx.fillRect(right, y, 1, 1); + } + }; + + if (drag && drag.type === 'lasso' && drag.points && drag.points.length) { + const points = drag.points; + this.ctx.lineWidth = 1; + this.ctx.lineJoin = 'round'; + this.ctx.lineCap = 'butt'; + this.ctx.beginPath(); + this.ctx.moveTo(points[0].x + 0.5, points[0].y + 0.5); + for (let i = 1; i < points.length; i++) this.ctx.lineTo(points[i].x + 0.5, points[i].y + 0.5); + this.ctx.strokeStyle = FLIPNOTE_SELECTION_YELLOW; + this.ctx.setLineDash([1, 1]); + this.ctx.lineDashOffset = -phase; + this.ctx.stroke(); + this.ctx.restore(); + return; + } + + if (drag && drag.type === 'rect') { + drawRect(this.selectionRect); + this.ctx.restore(); + return; + } + + if (!this.hasSelection()) { + this.ctx.restore(); + return; + } + + // Retained selections use the same one-pixel dotted yellow outline. + // Only the "on" ant pixel is painted; the gap is left transparent so + // there is never a black/white companion pixel underneath it. + const mask = this.selectionMask; + for (let i = 0; i < mask.length; i++) { + if (!mask[i]) continue; + const x = i % this.width; + const y = Math.floor(i / this.width); + const boundary = x === 0 || y === 0 || x === this.width - 1 || y === this.height - 1 + || !mask[i - 1] || !mask[i + 1] + || (y > 0 && !mask[i - this.width]) + || (y + 1 < this.height && !mask[i + this.width]); + if (!boundary || ((x + y + phase) & 1) !== 0) continue; + this.ctx.fillRect(x, y, 1, 1); + } + if (this.rectangleSelectionMode) { + const handles = this.selectionTransformHandles(); + const rotate = handles.find((handle) => handle.name === 'rotate'); + const north = handles.find((handle) => handle.name === 'n'); + if (rotate && north) { + this.ctx.beginPath(); + this.ctx.moveTo(north.x + .5, north.y + .5); + this.ctx.lineTo(rotate.x + .5, rotate.y + .5); + this.ctx.strokeStyle = '#7c291d'; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([]); + this.ctx.stroke(); + } + for (const handle of handles) { + if (handle.name === 'rotate') { + this.ctx.beginPath(); + this.ctx.arc(handle.x + .5, handle.y + .5, 3.5, 0, Math.PI * 2); + this.ctx.fillStyle = '#fffbe5'; + this.ctx.fill(); + this.ctx.strokeStyle = '#7c291d'; + this.ctx.lineWidth = 1; + this.ctx.stroke(); + } else { + this.ctx.fillStyle = '#fffbe5'; + this.ctx.fillRect(handle.x - 2, handle.y - 2, 5, 5); + this.ctx.strokeStyle = '#7c291d'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(handle.x - 2.5, handle.y - 2.5, 5, 5); + } + } + } + this.ctx.restore(); + } + renderCurrentFrame() { + const frame = typeof timeline !== 'undefined' ? timeline.getFrame(timeline.currentFrame) : null; + if (!frame) return; + this.ctx.save(); + this.ctx.globalAlpha = 1; + const paper = resolveFramePaper(frame); + this.ctx.fillStyle = `rgb(${paper[0]}, ${paper[1]}, ${paper[2]})`; + this.ctx.fillRect(0, 0, this.width, this.height); + this.ctx.restore(); + + // Authentic DSi-style Light Box: previous pages only, oldest first, + // faint neutral traces, and never part of the actual page data. + if (lightboxEnabled && !this.suppressLightbox && !timeline.playing) { + const depth = pagesToLightbox(); + for (let offset = depth; offset >= 1; offset--) { + const previousIndex = timeline.currentFrame - offset; + if (previousIndex < 0) continue; + const ageFactor = 1 - ((offset - 1) / Math.max(depth, 1)); + const opacity = 0.08 + (0.12 * ageFactor); + this.renderFrameInk(timeline.getFrame(previousIndex), true, opacity); + } + } + this.renderFrameInk(frame, false, 1); + this.renderSelectionOverlay(); + this.renderCursorPreview(); + if (this.textTool) this.textTool.renderCaret(); + } + setCanvasData(data) { + if (data instanceof ImageData) this.ctx.putImageData(data, 0, 0); + else this.renderCurrentFrame(); + } + clearCurrentFrame(bothLayers = false) { + if (!this.allowEditing || timeline.playing) return; + if (this.hasSelection()) { + const layers = bothLayers ? this.currentFrame().layers : [this.currentMask()]; + for (const layer of layers) { + for (let i = 0; i < this.selectionMask.length; i++) if (this.selectionMask[i]) layer[i] = 0; + } + } else { + this.currentMask().fill(0); + } + this.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + } + toggleEditing() {} + getCanvasCoords(e) { + const rect = this.canvasElement.getBoundingClientRect(); + const scaleX = this.canvasElement.width / rect.width; + const scaleY = this.canvasElement.height / rect.height; + return { + x: Math.max(0, Math.min(this.width - 1, Math.floor((e.clientX - rect.left) * scaleX))), + y: Math.max(0, Math.min(this.height - 1, Math.floor((e.clientY - rect.top) * scaleY))), + }; + } +} + +class ExportManager { + constructor() { + } + + async exportAsWebm(onProgress) { + const progress = typeof onProgress === 'function' ? onProgress : () => {}; + if (!timeline || !timeline.frames || !timeline.frames.length) throw new Error('Add at least one page before exporting a WEBM.'); + if (typeof MediaRecorder === 'undefined') throw new Error('This browser does not support WEBM recording.'); + if (!canvas.canvasElement.captureStream) throw new Error('This browser cannot capture the Flipnote canvas.'); + + const originalFrame = timeline.currentFrame; + const totalFrames = timeline.frames.length; + const fps = Math.max(0.01, Number(timeline.framerate) || 12); + const frameDurationMs = 1000 / fps; + const exportDurationMs = frameDurationMs * totalFrames; + let timer = null; + let canvasStream = null; + let exportStream = null; + let audioDestination = null; + let audioContext = null; + const exportAudioSources = new Set(); + let cleanedUp = false; + + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + if (timer) clearTimeout(timer); + timer = null; + exportAudioSources.forEach((source) => { + source.onended = null; + try { source.stop(); } catch (error) {} + try { source.disconnect(); } catch (error) {} + }); + exportAudioSources.clear(); + if (exportStream) exportStream.getTracks().forEach((track) => { + try { track.stop(); } catch (error) {} + }); + if (canvasStream && canvasStream !== exportStream) canvasStream.getTracks().forEach((track) => { + try { track.stop(); } catch (error) {} + }); + canvas.suppressLightbox = false; + timeline.setActiveFrame(Math.min(originalFrame, timeline.frames.length - 1)); + }; + + canvas.suppressLightbox = true; + canvas.renderCurrentFrame(); + progress(2, 'Preparing WEBM renderer...'); + + try { + // Decode any editor audio before recording starts. WEBM export uses a + // private Web Audio destination, so the soundtrack is muxed into the + // video without playing through the user's speakers during export. + const exportBuffers = {}; + const audioKeys = ['music', 'sound1', 'sound2', 'sound3']; + const hasProjectAudio = typeof audioTracks !== 'undefined' && audioKeys.some((key) => !!audioTracks[key]); + if (hasProjectAudio) { + progress(4, 'Preparing audio...'); + audioContext = getFlipnoteAudioContext(); + try { await audioContext.resume(); } catch (error) {} + for (const key of audioKeys) { + if (!audioTracks[key]) continue; + try { + const buffer = await ensureAudioTrackBuffer(key); + if (buffer) exportBuffers[key] = buffer; + } catch (error) { + // A single broken optional track should not prevent the + // animation itself from being exported. + } + } + } + + canvasStream = canvas.canvasElement.captureStream(fps); + const tracks = [...canvasStream.getVideoTracks()]; + if (audioContext && Object.keys(exportBuffers).length) { + audioDestination = audioContext.createMediaStreamDestination(); + tracks.push(...audioDestination.stream.getAudioTracks()); + } + exportStream = new MediaStream(tracks); + + const mimeCandidates = [ + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8,opus', + 'video/webm', + ]; + const mimeType = typeof MediaRecorder.isTypeSupported === 'function' + ? (mimeCandidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || '') + : 'video/webm'; + if (!mimeType) throw new Error('This browser cannot encode WEBM video.'); + const mediaRecorder = new MediaRecorder(exportStream, { mimeType }); + const chunks = []; + + const connectExportSource = (source) => { + if (!audioDestination) return false; + source.connect(audioDestination); + exportAudioSources.add(source); + source.onended = () => { + exportAudioSources.delete(source); + try { source.disconnect(); } catch (error) {} + }; + return true; + }; + + const scheduleExportAudio = (startTime) => { + if (!audioContext || !audioDestination) return; + const animationSeconds = totalFrames / fps; + const musicTrack = audioTracks.music; + const musicBuffer = exportBuffers.music; + if (musicTrack && musicBuffer) { + const source = audioContext.createBufferSource(); + source.buffer = musicBuffer; + const playbackRate = audioTrackPlaybackRate(musicTrack, 'music'); + source.playbackRate.value = playbackRate; + const trackDuration = Math.min(Number(musicTrack.duration) || musicBuffer.duration, musicBuffer.duration); + const rawDuration = Math.max(0, Math.min(trackDuration, animationSeconds * playbackRate)); + if (rawDuration > 0.001 && connectExportSource(source)) { + source.start(startTime, 0, rawDuration); + } + } + + ['sound1', 'sound2', 'sound3'].forEach((key) => { + const track = audioTracks[key]; + const buffer = exportBuffers[key]; + if (!track || !buffer) return; + const clipDuration = Math.max(0, Math.min(Number(track.duration) || buffer.duration, buffer.duration)); + if (clipDuration <= 0.001) return; + timeline.frames.forEach((frame, frameIndex) => { + if (!frameHasSound(frame, key)) return; + const triggerSeconds = frameIndex / fps; + if (triggerSeconds >= animationSeconds) return; + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.playbackRate.value = 1; + if (connectExportSource(source)) { + source.start(startTime + triggerSeconds, 0, Math.min(clipDuration, animationSeconds - triggerSeconds)); + } + }); + }); + }; + + return await new Promise((resolve, reject) => { + let exportFrame = 0; + let stopped = false; + const stopRecorder = () => { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + if (mediaRecorder.state !== 'inactive') mediaRecorder.stop(); + }; + + mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.push(event.data); + }; + mediaRecorder.onerror = (event) => { + cleanup(); + reject(new Error((event && event.error && event.error.message) || 'The browser could not render this WEBM.')); + }; + mediaRecorder.onstop = () => { + try { + const blob = new Blob(chunks, { type: mimeType }); + if (!blob.size) throw new Error('The WEBM renderer returned an empty video.'); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const filenameText = document.getElementById('filename').textContent.trim() || 'untitled'; + link.href = url; + link.download = filenameText + '.webm'; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + cleanup(); + progress(100, 'WEBM saved.'); + resolve(blob); + } catch (error) { + cleanup(); + reject(error); + } + }; + + timeline.setActiveFrame(0); + canvas.renderCurrentFrame(); + progress(8, `Rendering page 1 / ${totalFrames}...`); + mediaRecorder.start(250); + + // Record at the Flipnote's actual speed. The old exporter used a + // hard-coded 100 ms delay, which silently forced every WEBM to + // 10 FPS and made Speed 6/7/8 Flipnotes play noticeably slowly. + const wallStart = performance.now(); + const audioStart = audioContext ? audioContext.currentTime : 0; + scheduleExportAudio(audioStart); + + const tick = () => { + const elapsed = performance.now() - wallStart; + const targetFrame = Math.min(totalFrames - 1, Math.floor(elapsed / frameDurationMs)); + if (targetFrame > exportFrame) { + exportFrame = targetFrame; + timeline.setActiveFrame(exportFrame); + canvas.renderCurrentFrame(); + const pct = 8 + Math.round(((exportFrame + 1) / totalFrames) * 86); + progress(Math.min(94, pct), `Rendering page ${exportFrame + 1} / ${totalFrames}...`); + } + if (elapsed >= exportDurationMs) { + stopRecorder(); + return; + } + const nextBoundary = (exportFrame + 1) * frameDurationMs; + const wait = Math.max(1, Math.min(16, nextBoundary - elapsed)); + timer = setTimeout(tick, wait); + }; + timer = setTimeout(tick, Math.max(1, Math.min(16, frameDurationMs))); + }); + } catch (error) { + cleanup(); + throw error; + } + } +} +// The Flipnote Editor intentionally owns right-click/long-click interactions. +// Suppress native browser context menus anywhere on the editor page, including +// dialogs and controls rendered outside the main workspace node. +document.addEventListener('contextmenu', (event) => { + if (document.querySelector('[data-legacy-flipnote-editor]')) event.preventDefault(); +}, true); + +const canvas = new CanvasEditor(document.getElementById('flipnote-canvas')); +canvas.updateCursorMode(); +const timeline = new Timeline(document.getElementById('timeline-container')); +canvas.textTool = new window.FlipnoteTextTool(canvas, timeline, { + canStoreFrame: (frame) => flipnoteSpaceAllowsProjection(timeline, { + animationFrameBytes: flipnoteSpaceEncodedFrameBytes + - (flipnoteSpaceFrameCache.get(frame) || flipnoteSpaceFrameByteLength(frame)) + + flipnoteSpaceFrameByteLength(frame), + }), + notifyFull: () => notifyFlipnoteSpaceLimit(timeline), + markDirty: markEditorDirty, +}); +const exportManager = new ExportManager(); +updatePlaybackSettingsUI(timeline); +syncEditorPlaybackState(timeline); + +// Never keep editor playback running after the user leaves the page/window or +// touches Alt. Returning to the editor never resumes automatically. Alt is an +// interruption safeguard only, not an editor shortcut, so it is intentionally +// omitted from the shortcut/help UI. +function pauseFlipnoteEditorPlayback() { + if (timeline.playing) timeline.stopPlayback(); +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'hidden') return; + pauseFlipnoteEditorPlayback(); + // Standalone Music/Sound previews are playback too; stop those without + // affecting an in-progress microphone recording. + if (typeof stopAudioPlayback === 'function') stopAudioPlayback(); + if (typeof stopTrimPreview === 'function') stopTrimPreview(); +}); +window.addEventListener('blur', pauseFlipnoteEditorPlayback); +window.addEventListener('keydown', (event) => { + if (event.key === 'Alt' || event.code === 'AltLeft' || event.code === 'AltRight') { + pauseFlipnoteEditorPlayback(); + } +}, true); + +const speedControl = document.getElementById('flipnote-speed-control'); +const loopToggle = document.getElementById('flipnote-loop-toggle'); +let speedPointerActive = false; + +function speedLevelFromPointer(event) { + if (!speedControl) return null; + const rect = speedControl.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const x = ((event.clientX - rect.left) / rect.width) * 94; + const y = ((event.clientY - rect.top) / rect.height) * 20; + const hit = FLIPNOTE_SPEED_HITBOXES.find((box) => + x >= box.x && x < box.x + box.width && y >= box.y && y < box.y + box.height + ); + return hit ? hit.level : null; +} + +function applySpeedPointer(event) { + if (timeline.playing) return; + const level = speedLevelFromPointer(event); + if (level !== null) timeline.setSpeedLevel(level); +} + +if (speedControl) { + speedControl.addEventListener('pointerdown', (event) => { + speedPointerActive = true; + const workspace = document.querySelector('[data-legacy-flipnote-editor]'); + if (workspace) workspace.classList.add('is-changing-speed'); + speedControl.setPointerCapture(event.pointerId); + applySpeedPointer(event); + event.preventDefault(); + }); + speedControl.addEventListener('pointermove', (event) => { + if (!speedPointerActive) return; + applySpeedPointer(event); + event.preventDefault(); + }); + const finishSpeedPointer = (event) => { + if (!speedPointerActive) return; + speedPointerActive = false; + const workspace = document.querySelector('[data-legacy-flipnote-editor]'); + if (workspace) workspace.classList.remove('is-changing-speed'); + if (speedControl.hasPointerCapture(event.pointerId)) speedControl.releasePointerCapture(event.pointerId); + }; + speedControl.addEventListener('pointerup', finishSpeedPointer); + speedControl.addEventListener('pointercancel', finishSpeedPointer); + speedControl.addEventListener('keydown', (event) => { + if (timeline.playing) return; + const action = shortcutAction(event, 'speed'); + if (!action) return; + let next = timeline.speedLevel; + if (action === 'speedDown' || action === 'speedDownAlt') next -= 1; + else if (action === 'speedUp' || action === 'speedUpAlt') next += 1; + else if (action === 'speedFirst') next = 1; + else if (action === 'speedLast') next = 8; + event.preventDefault(); + event.stopPropagation(); + timeline.setSpeedLevel(next); + }); +} +if (loopToggle) { + loopToggle.addEventListener('click', () => { + if (timeline.playing) return; + timeline.setLoopEnabled(!timeline.loopEnabled); + }); +} +// Drawing tools. The four family buttons open an image-based eight-slot picker. +// Holding Shift temporarily exposes the Advanced logical IDs (slot + 8). +const toolFamilyButtons = Array.from(document.querySelectorAll('[data-tool-family-picker]')); +const toolSlotsPanel = document.getElementById('flipnote-tool-slots'); +const toolSlotButtons = Array.from(document.querySelectorAll('[data-tool-slot]')); +const editPatternBtn = document.getElementById('flipnote-edit-pattern'); +const patternModal = document.getElementById('flipnote-pattern-modal'); +const patternGrid = document.getElementById('flipnote-pattern-grid'); +const patternCloseBtn = document.getElementById('flipnote-pattern-close'); +const patternCancelBtn = document.getElementById('flipnote-pattern-cancel'); +const patternSaveBtn = document.getElementById('flipnote-pattern-save'); +const patternClearBtn = document.getElementById('flipnote-pattern-clear'); +const patternFillBtn = document.getElementById('flipnote-pattern-fill'); +const patternInvertBtn = document.getElementById('flipnote-pattern-invert'); +const patternResetBtn = document.getElementById('flipnote-pattern-reset'); +const undoBtn = document.getElementById('undoButton'); +const redoBtn = document.getElementById('redoButton'); +const eraseBigBtn = document.getElementById('eraseBigButton'); + + + + + + + + + + +const ppmImportButton = document.getElementById('flipnote-open-ppm-button'); +const ppmImportInput = document.getElementById('flipnote-import-ppm-input'); + +const ppmImportStatus = document.getElementById('flipnote-import-ppm-status'); + + + + + + + + + + + + + +const webmExportModal = document.getElementById('flipnote-webm-export-modal'); +const webmExportClose = document.getElementById('flipnote-webm-export-close'); +const webmExportCancel = document.getElementById('flipnote-webm-export-cancel'); +const webmExportStart = document.getElementById('flipnote-webm-export-start'); +const webmExportProgress = document.getElementById('flipnote-webm-export-progress'); +const webmExportProgressFill = document.getElementById('flipnote-webm-export-progress-fill'); +const webmExportStatus = document.getElementById('flipnote-webm-export-status'); +const webmExportThumbnailTimeline = document.getElementById('flipnote-webm-export-thumbnail-timeline'); +const openPpmModal = document.getElementById('flipnote-open-ppm-modal'); +const openPpmClose = document.getElementById('flipnote-open-ppm-close'); +const openPpmCancel = document.getElementById('flipnote-open-ppm-cancel'); +const openPpmChoice = document.getElementById('flipnote-open-ppm-choice'); + +const openUploadLabel = document.getElementById('flipnote-open-upload'); + + + + + + + + + + + + + + + + + +let webmExporting = false; +let webmExportLastFocus = null; +let openPpmLastFocus = null; + + + +let exportThumbnailFrameRef = null; +let exportThumbnailFrameIndex = null; + + + +let openToolPickerFamily = null; +let lastToolPickerFamily = null; +let retainedToolChoice = { + family: canvas.currentToolFamily, + slot: canvas.selectedToolSlots[canvas.currentToolFamily], + advanced: canvas.currentToolFamily === 'fill' ? false : !!canvas.selectedToolAdvanced[canvas.currentToolFamily], +}; +let toolPickerAnimationTimer = null; +let toolPickerTransitionToken = 0; +let toolPickerTransitioning = false; +let editPatternAnimationTimer = null; + +const TOOL_PICKER_ANIMATION_MS = 275; + +const TOOL_FAMILY_ASSET_PREFIX = { + pen: 'pen', + paint: 'paint', + eraser: 'erase', + fill: 'fill', +}; + +const TOOL_FAMILY_LABEL = { + pen: 'Draw', + paint: 'Paint', + eraser: 'Erase', + fill: 'Fill', +}; + +// The Fill picker is intentionally ordered with the solid bucket first: +// Fill 8, then Fill 1 through Fill 7. Other families keep their normal 1-8 +// visual order. The returned value is the engine/tool slot (0-7), not the +// visual grid position. +function displaySlotToToolSlot(family, displaySlot) { + const visual = Math.max(0, Math.min(7, Number(displaySlot) || 0)); + if (family === 'fill') return visual === 0 ? 7 : visual - 1; + return visual; +} + +function toolChoiceAsset(family, slot, advanced) { + const prefix = TOOL_FAMILY_ASSET_PREFIX[family] || 'pen'; + // Fill does not have an Advanced/Shift palette: all eight buckets are + // fixed, with Fill 8 reserved for solid fill. + const advancedSuffix = family === 'fill' ? '' : (advanced ? '_Adv' : ''); + return `./assets/flipnote-editor/${prefix}${slot + 1}${advancedSuffix}.png`; +} + +function retainCurrentToolChoice() { + if (typeof canvas === 'undefined' || !canvas.selectedToolSlots) return; + const family = canvas.currentToolFamily; + if (!TOOL_FAMILY_LABEL[family]) return; + retainedToolChoice = { + family, + slot: canvas.selectedToolSlots[family] || 0, + advanced: family === 'fill' ? false : !!canvas.selectedToolAdvanced[family], + }; +} + +// Selected tool icons use the DSi-style selected palette without requiring a +// second copy of every PNG. Only exact source pixels are remapped: +// #FFFFFF -> #FBF318 +// #AE301F -> #000000 +// Everything else, including transparency and anti-aliased edge colours, is +// left untouched. Generated variants are cached for the lifetime of the page. +const selectedToolIconAssetCache = new Map(); + +function selectedToolChoiceAsset(sourceUrl) { + if (selectedToolIconAssetCache.has(sourceUrl)) { + return selectedToolIconAssetCache.get(sourceUrl); + } + + const result = new Promise((resolve) => { + const source = new Image(); + source.onload = () => { + try { + const recolorCanvas = document.createElement('canvas'); + recolorCanvas.width = source.naturalWidth || source.width; + recolorCanvas.height = source.naturalHeight || source.height; + const recolorContext = recolorCanvas.getContext('2d', {willReadFrequently: true}); + if (!recolorContext || !recolorCanvas.width || !recolorCanvas.height) { + resolve(sourceUrl); + return; + } + recolorContext.drawImage(source, 0, 0); + const imageData = recolorContext.getImageData(0, 0, recolorCanvas.width, recolorCanvas.height); + const pixels = imageData.data; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i] === 255 && pixels[i + 1] === 255 && pixels[i + 2] === 255) { + pixels[i] = 251; + pixels[i + 1] = 243; + pixels[i + 2] = 24; + } else if (pixels[i] === 174 && pixels[i + 1] === 48 && pixels[i + 2] === 31) { + pixels[i] = 0; + pixels[i + 1] = 0; + pixels[i + 2] = 0; + } + } + recolorContext.putImageData(imageData, 0, 0); + resolve(recolorCanvas.toDataURL('image/png')); + } catch (error) { + // A normal icon is always preferable to a broken picker if an + // image ever becomes unreadable/cross-origin in deployment. + resolve(sourceUrl); + } + }; + source.onerror = () => resolve(sourceUrl); + source.src = sourceUrl; + }); + + selectedToolIconAssetCache.set(sourceUrl, result); + return result; +} + +function clearToolPickerAnimationTimer() { + if (toolPickerAnimationTimer !== null) { + window.clearTimeout(toolPickerAnimationTimer); + toolPickerAnimationTimer = null; + } +} + +function resetToolPickerAnimationClasses() { + if (!toolSlotsPanel) return; + toolSlotsPanel.classList.remove( + 'is-opening', + 'is-opening-from-selection', + 'is-closing', + 'is-closing-to-selection', + 'is-motion-locked' + ); +} + +function animateToolPicker(className, durationMs, onComplete) { + if (!toolSlotsPanel) { + if (typeof onComplete === 'function') onComplete(); + return; + } + clearToolPickerAnimationTimer(); + resetToolPickerAnimationClasses(); + // A click bounce or a hover scale on the retained icon must never compete + // with the snake's positional transform. Freeze all icon-level transforms + // for the entire picker motion, then restore hover only after it settles. + toolSlotButtons.forEach((button) => button.classList.remove('is-tool-clicking')); + toolSlotsPanel.classList.add('is-motion-locked'); + void toolSlotsPanel.offsetWidth; + toolSlotsPanel.classList.add(className); + const token = ++toolPickerTransitionToken; + toolPickerTransitioning = true; + toolPickerAnimationTimer = window.setTimeout(() => { + if (token !== toolPickerTransitionToken) return; + toolSlotsPanel.classList.remove(className); + toolSlotsPanel.classList.remove('is-motion-locked'); + toolPickerAnimationTimer = null; + toolPickerTransitioning = false; + if (typeof onComplete === 'function') onComplete(); + }, durationMs); +} + +function pickerHasRetainedChoice() { + return !!(retainedToolChoice && TOOL_FAMILY_LABEL[retainedToolChoice.family]); +} + +function showRetainedToolChoice() { + if (!toolSlotsPanel || !pickerHasRetainedChoice()) return false; + openToolPickerFamily = null; + lastToolPickerFamily = retainedToolChoice.family; + toolSlotsPanel.hidden = false; + toolSlotsPanel.classList.add('is-selection-only'); + updateDrawingToolUI(); + return true; +} + +function openToolPicker(family, fromRetainedIcon = false) { + if (typeof canvas !== 'undefined' && canvas && (canvas.selectionMode || canvas.hasSelection())) canvas.clearSelection(); + if (!toolSlotsPanel) { + openToolPickerFamily = family; + lastToolPickerFamily = family; + updateDrawingToolUI(); + return; + } + + const wasSelectionOnly = toolSlotsPanel.classList.contains('is-selection-only'); + const openingFromSameRetainedChoice = wasSelectionOnly && pickerHasRetainedChoice() + && retainedToolChoice.family === family; + + // When swapping full menus, retract the old snake completely before the + // replacement family grows out from the left edge. + if (openToolPickerFamily && openToolPickerFamily !== family) { + const nextFamily = family; + animateToolPicker('is-closing', TOOL_PICKER_ANIMATION_MS, () => { + openToolPickerFamily = nextFamily; + lastToolPickerFamily = nextFamily; + toolSlotsPanel.classList.remove('is-selection-only'); + toolSlotsPanel.hidden = false; + updateDrawingToolUI(); + animateToolPicker('is-opening', TOOL_PICKER_ANIMATION_MS); + }); + return; + } + + openToolPickerFamily = family; + lastToolPickerFamily = family; + toolSlotsPanel.hidden = false; + toolSlotsPanel.classList.remove('is-selection-only'); + updateDrawingToolUI(); + animateToolPicker( + openingFromSameRetainedChoice || fromRetainedIcon ? 'is-opening-from-selection' : 'is-opening', + TOOL_PICKER_ANIMATION_MS + ); +} + +function closeToolPicker(options = {}) { + const retainChoice = options.retainChoice || null; + const selectedNow = !!retainChoice; + if (retainChoice) retainedToolChoice = retainChoice; + + if (!toolSlotsPanel || (!openToolPickerFamily && toolSlotsPanel.hidden)) { + openToolPickerFamily = null; + if (!showRetainedToolChoice()) updateDrawingToolUI(); + return; + } + + if (openToolPickerFamily) lastToolPickerFamily = openToolPickerFamily; + const shouldRetain = pickerHasRetainedChoice(); + // Mark the newly chosen slot before the closing animation begins so that + // exact square can travel to the left edge while its neighbours retract. + if (selectedNow) updateDrawingToolUI(); + const closingClass = selectedNow ? 'is-closing-to-selection' : 'is-closing'; + + animateToolPicker(closingClass, TOOL_PICKER_ANIMATION_MS, () => { + openToolPickerFamily = null; + if (shouldRetain) { + toolSlotsPanel.classList.add('is-selection-only'); + toolSlotsPanel.hidden = false; + } else { + toolSlotsPanel.classList.remove('is-selection-only'); + toolSlotsPanel.hidden = true; + } + updateDrawingToolUI(); + }); + // If the retained choice is Paint 8, updateDrawingToolUI() in the + // completion callback reveals Edit Pattern only after the snake has fully + // retracted. Opening still hides it immediately so its slide-up can run in + // parallel with the grid opening motion. +} + +function customPaintToolSelected() { + return canvas.currentToolFamily === 'paint' && canvas.selectedToolSlots.paint === 7; +} + +function setEditPatternButtonVisible(visible, animate = true) { + if (!editPatternBtn) return; + if (editPatternAnimationTimer !== null) { + window.clearTimeout(editPatternAnimationTimer); + editPatternAnimationTimer = null; + } + const shouldShow = !!visible; + editPatternBtn.setAttribute('aria-hidden', shouldShow ? 'false' : 'true'); + + if (shouldShow) { + if (editPatternBtn.hidden) { + editPatternBtn.hidden = false; + editPatternBtn.classList.remove('is-visible'); + if (animate) void editPatternBtn.offsetWidth; + } + if (animate) editPatternBtn.classList.add('is-visible'); + else editPatternBtn.classList.add('is-visible'); + return; + } + + if (editPatternBtn.hidden) return; + editPatternBtn.classList.remove('is-visible'); + if (!animate) { + editPatternBtn.hidden = true; + return; + } + editPatternAnimationTimer = window.setTimeout(() => { + editPatternBtn.hidden = true; + editPatternAnimationTimer = null; + }, 145); +} + +function updateEditPatternButton() { + if (!editPatternBtn) return; + const selectionOnly = !!(toolSlotsPanel && toolSlotsPanel.classList.contains('is-selection-only')); + const visible = customPaintToolSelected() && selectionOnly && !openToolPickerFamily; + setEditPatternButtonVisible(visible, true); +} + +function updateDrawingToolUI() { + if (!window.FlipnoteDrawingEngine || typeof canvas === 'undefined') return; + const shiftAdvanced = canvas.expertTools; + const selectionOnly = !!(toolSlotsPanel && toolSlotsPanel.classList.contains('is-selection-only')); + + toolFamilyButtons.forEach((button) => { + const family = button.dataset.toolFamilyPicker; + const open = family === openToolPickerFamily; + const image = button.querySelector('img'); + button.classList.toggle('is-open', open); + button.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (image) image.src = open ? image.dataset.selectedSrc : image.dataset.normalSrc; + }); + + if (toolSlotsPanel) { + const pickerVisible = Boolean(openToolPickerFamily) + || selectionOnly + || toolSlotsPanel.classList.contains('is-closing') + || toolSlotsPanel.classList.contains('is-closing-to-selection'); + if (!pickerVisible + && !toolSlotsPanel.classList.contains('is-opening') + && !toolSlotsPanel.classList.contains('is-opening-from-selection')) { + toolSlotsPanel.hidden = true; + } + const labelFamily = openToolPickerFamily + || (pickerHasRetainedChoice() ? retainedToolChoice.family : lastToolPickerFamily); + const labelAdvanced = labelFamily === 'fill' + ? false + : (selectionOnly && pickerHasRetainedChoice() ? !!retainedToolChoice.advanced : shiftAdvanced); + toolSlotsPanel.setAttribute('aria-label', labelFamily + ? `${labelAdvanced ? 'Advanced ' : ''}${TOOL_FAMILY_LABEL[labelFamily]} tools` + : 'Drawing tool choices'); + } + + toolSlotButtons.forEach((button) => { + const displaySlot = Number(button.dataset.toolSlot); + const family = openToolPickerFamily + || (pickerHasRetainedChoice() ? retainedToolChoice.family : null) + || lastToolPickerFamily + || canvas.currentToolFamily; + const slot = displaySlotToToolSlot(family, displaySlot); + const displayedAdvanced = family === 'fill' + ? false + : (selectionOnly && pickerHasRetainedChoice() && retainedToolChoice.family === family + // A Shift-locked choice stays Advanced. A normal retained tool + // previews its live Advanced variant only while Shift is held, + // then immediately returns to the normal icon on key release. + ? !!(retainedToolChoice.advanced || shiftAdvanced) + : shiftAdvanced); + const isRetained = pickerHasRetainedChoice() + && retainedToolChoice.family === family + && retainedToolChoice.slot === slot; + const logicalId = canvas.engine.logicalId(slot, displayedAdvanced); + const label = `${displayedAdvanced ? 'Advanced ' : ''}${TOOL_FAMILY_LABEL[family]} ${slot + 1}`; + const image = button.querySelector('img'); + + button.classList.toggle('is-current-tool', isRetained); + button.setAttribute('aria-label', selectionOnly && isRetained + ? `${label}, selected. Open ${TOOL_FAMILY_LABEL[family]} tools` + : label); + button.title = selectionOnly && isRetained + ? `${label} · Selected · Click to reopen ${TOOL_FAMILY_LABEL[family]} tools` + : `${label} · ${canvas.engine.toolName(family, logicalId)}`; + button.setAttribute('aria-pressed', isRetained ? 'true' : 'false'); + if (image) { + const baseSource = toolChoiceAsset(family, slot, displayedAdvanced); + image.dataset.toolBaseSrc = baseSource; + image.src = baseSource; + image.alt = label; + if (isRetained) { + selectedToolChoiceAsset(baseSource).then((selectedSource) => { + // The selection may have changed while the PNG was being + // recoloured; never let a stale async result repaint it. + if (image.dataset.toolBaseSrc !== baseSource) return; + if (!button.classList.contains('is-current-tool')) return; + image.src = selectedSource; + }); + } + } + }); + updateEditPatternButton(); +} + +toolFamilyButtons.forEach((button) => { + button.addEventListener('click', (event) => { + if (toolPickerTransitioning) return; + const family = button.dataset.toolFamilyPicker; + if (family === 'eraser' && canvas.hasSelection()) { + canvas.clearCurrentFrame(!!event.shiftKey); + return; + } + if (openToolPickerFamily === family) closeToolPicker(); + else openToolPicker(family); + canvas.setToolFamily(family); + }); +}); + +function animateToolSlotClick(button) { + if (!button) return; + button.classList.remove('is-tool-clicking'); + void button.offsetWidth; + button.classList.add('is-tool-clicking'); + window.setTimeout(() => button.classList.remove('is-tool-clicking'), 180); +} + +toolSlotButtons.forEach((button) => { + button.addEventListener('click', () => { + if (toolPickerTransitioning) return; + animateToolSlotClick(button); + + // Once a tool is chosen, its icon remains at the left edge. Clicking + // that retained icon expands the same family again for quick changes. + if (!openToolPickerFamily && toolSlotsPanel + && toolSlotsPanel.classList.contains('is-selection-only') + && button.classList.contains('is-current-tool') + && pickerHasRetainedChoice()) { + openToolPicker(retainedToolChoice.family, true); + return; + } + + if (!openToolPickerFamily) return; + const family = openToolPickerFamily; + const displaySlot = Number(button.dataset.toolSlot); + const slot = displaySlotToToolSlot(family, displaySlot); + const advanced = family === 'fill' ? false : !!canvas.expertTools; + canvas.setToolFamily(family); + canvas.setToolSlot(slot, advanced); + closeToolPicker({retainChoice: {family, slot, advanced}}); + }); +}); + +// On touch, long-pressing a Pen/Paint/Erase tile chooses its Advanced +// (desktop Shift) variant. Fill has no separate Advanced palette. +toolSlotButtons.forEach((button) => { + installFlipnoteTouchLongPress(button, () => { + if (toolPickerTransitioning || !openToolPickerFamily || openToolPickerFamily === 'fill') return; + const family = openToolPickerFamily; + const displaySlot = Number(button.dataset.toolSlot); + const slot = displaySlotToToolSlot(family, displaySlot); + animateToolSlotClick(button); + canvas.setToolFamily(family); + canvas.setToolSlot(slot, true); + closeToolPicker({retainChoice: {family, slot, advanced:true}}); + }); +}); + +// Pressing anywhere outside the open choice grid retracts it immediately on +// pointer-down, rather than waiting for the eventual click/pointer-up. Use the +// capture phase so the close starts before the pressed control/canvas handles +// that same pointer-down. Family buttons are excluded because they already own +// the close/switch animation (including switching between families). +document.addEventListener('pointerdown', (event) => { + if (!openToolPickerFamily || toolPickerTransitioning || !toolSlotsPanel) return; + const target = event.target instanceof Element ? event.target : null; + if (target && target.closest('#flipnote-tool-slots')) return; + if (target && target.closest('[data-tool-family-picker]')) return; + closeToolPicker(); +}, true); + +let patternDraft = null; +let patternPointerPainting = false; +let patternPointerValue = 1; +let patternPointerChanged = false; +let patternLastFocusedElement = null; + +function clonePatternData(motif) { + return { + width: FLIPNOTE_CUSTOM_PATTERN_SIZE, + height: FLIPNOTE_CUSTOM_PATTERN_SIZE, + data: Uint8Array.from(motif && motif.data ? motif.data : defaultCustomPaintPattern().data, (value) => value ? 1 : 0), + }; +} + +function renderPatternGrid() { + if (!patternGrid || !patternDraft) return; + const cells = Array.from(patternGrid.querySelectorAll('[data-pattern-cell]')); + cells.forEach((cell, index) => { + const on = !!patternDraft.data[index]; + cell.classList.toggle('is-on', on); + cell.setAttribute('aria-pressed', on ? 'true' : 'false'); + const x = index % FLIPNOTE_CUSTOM_PATTERN_SIZE; + const y = Math.floor(index / FLIPNOTE_CUSTOM_PATTERN_SIZE); + cell.setAttribute('aria-label', `Pattern pixel ${x + 1}, ${y + 1}: ${on ? 'painted' : 'transparent'}`); + }); +} + +function setPatternCell(index, value) { + if (!patternDraft || index < 0 || index >= patternDraft.data.length) return; + patternDraft.data[index] = value ? 1 : 0; + const cell = patternGrid ? patternGrid.querySelector(`[data-pattern-cell="${index}"]`) : null; + if (cell) { + const on = !!patternDraft.data[index]; + cell.classList.toggle('is-on', on); + cell.setAttribute('aria-pressed', on ? 'true' : 'false'); + const x = index % FLIPNOTE_CUSTOM_PATTERN_SIZE; + const y = Math.floor(index / FLIPNOTE_CUSTOM_PATTERN_SIZE); + cell.setAttribute('aria-label', `Pattern pixel ${x + 1}, ${y + 1}: ${on ? 'painted' : 'transparent'}`); + } +} + +function ensurePatternGridCells() { + if (!patternGrid || patternGrid.childElementCount) return; + const fragment = document.createDocumentFragment(); + for (let index = 0; index < FLIPNOTE_CUSTOM_PATTERN_SIZE * FLIPNOTE_CUSTOM_PATTERN_SIZE; index++) { + const cell = document.createElement('button'); + cell.type = 'button'; + cell.dataset.patternCell = String(index); + cell.setAttribute('role', 'gridcell'); + cell.setAttribute('aria-pressed', 'false'); + fragment.appendChild(cell); + } + patternGrid.appendChild(fragment); +} + +function closePatternModal() { + if (!patternModal || patternModal.hidden) return; + patternModal.hidden = true; + patternDraft = null; + patternPointerPainting = false; + document.body.classList.remove('flipnote-pattern-modal-open'); + if (patternLastFocusedElement && typeof patternLastFocusedElement.focus === 'function') { + patternLastFocusedElement.focus(); + } + patternLastFocusedElement = null; +} + +function openPatternModal() { + if (!patternModal || !patternGrid || !customPaintToolSelected()) return; + ensurePatternGridCells(); + patternDraft = clonePatternData(canvas.customPaintMotif); + renderPatternGrid(); + patternLastFocusedElement = document.activeElement; + patternModal.hidden = false; + document.body.classList.add('flipnote-pattern-modal-open'); + if (patternCloseBtn) patternCloseBtn.focus(); +} + +if (editPatternBtn) editPatternBtn.addEventListener('click', openPatternModal); +if (patternCloseBtn) patternCloseBtn.addEventListener('click', closePatternModal); +if (patternCancelBtn) patternCancelBtn.addEventListener('click', closePatternModal); +if (patternSaveBtn) patternSaveBtn.addEventListener('click', () => { + if (!patternDraft) return; + canvas.customPaintMotif = clonePatternData(patternDraft); + saveCustomPaintPattern(canvas.customPaintMotif); + closePatternModal(); +}); +if (patternClearBtn) patternClearBtn.addEventListener('click', () => { + if (!patternDraft) return; + patternDraft.data.fill(0); + renderPatternGrid(); +}); +if (patternFillBtn) patternFillBtn.addEventListener('click', () => { + if (!patternDraft) return; + patternDraft.data.fill(1); + renderPatternGrid(); +}); +if (patternInvertBtn) patternInvertBtn.addEventListener('click', () => { + if (!patternDraft) return; + for (let i = 0; i < patternDraft.data.length; i++) patternDraft.data[i] = patternDraft.data[i] ? 0 : 1; + renderPatternGrid(); +}); +if (patternResetBtn) patternResetBtn.addEventListener('click', () => { + patternDraft = defaultCustomPaintPattern(); + renderPatternGrid(); +}); +if (patternModal) patternModal.addEventListener('pointerdown', (event) => { + if (event.target === patternModal) closePatternModal(); +}); +if (patternGrid) { + ensurePatternGridCells(); + patternGrid.addEventListener('pointerdown', (event) => { + const cell = event.target instanceof Element ? event.target.closest('[data-pattern-cell]') : null; + if (!cell || !patternGrid.contains(cell) || !patternDraft) return; + event.preventDefault(); + const index = Number(cell.dataset.patternCell); + patternPointerPainting = true; + patternPointerChanged = true; + patternPointerValue = patternDraft.data[index] ? 0 : 1; + setPatternCell(index, patternPointerValue); + if (patternGrid.setPointerCapture) patternGrid.setPointerCapture(event.pointerId); + }); + patternGrid.addEventListener('pointermove', (event) => { + if (!patternPointerPainting || !patternDraft) return; + const hit = document.elementFromPoint(event.clientX, event.clientY); + const cell = hit instanceof Element ? hit.closest('[data-pattern-cell]') : null; + if (!cell || !patternGrid.contains(cell)) return; + setPatternCell(Number(cell.dataset.patternCell), patternPointerValue); + }); + const finishPatternPointer = (event) => { + if (!patternPointerPainting) return; + patternPointerPainting = false; + if (patternGrid.hasPointerCapture && patternGrid.hasPointerCapture(event.pointerId)) patternGrid.releasePointerCapture(event.pointerId); + }; + patternGrid.addEventListener('pointerup', finishPatternPointer); + patternGrid.addEventListener('pointercancel', finishPatternPointer); + patternGrid.addEventListener('click', (event) => { + const cell = event.target instanceof Element ? event.target.closest('[data-pattern-cell]') : null; + if (!cell || !patternGrid.contains(cell) || !patternDraft) return; + if (patternPointerChanged) { + patternPointerChanged = false; + return; + } + const index = Number(cell.dataset.patternCell); + setPatternCell(index, patternDraft.data[index] ? 0 : 1); + }); +} +document.addEventListener('keydown', (event) => { + if (shortcutAction(event, 'dialog') !== 'closeDialog' || !patternModal || patternModal.hidden) return; + event.preventDefault(); + closePatternModal(); +}); + +if (eraseBigBtn) eraseBigBtn.onclick = (event) => canvas.clearCurrentFrame(!!event.shiftKey); +if (undoBtn) undoBtn.onclick = () => { + const frame = timeline.getFrame(timeline.currentFrame); + if (frame && frame.undoPointer > 0) frame.undo(); + else updateHistoryButtons(frame); +}; +if (redoBtn) redoBtn.onclick = () => { + const frame = timeline.getFrame(timeline.currentFrame); + if (frame && frame.undoPointer < frame.undoBuffer.length - 1) frame.redo(); + else updateHistoryButtons(frame); +}; + + + + + + + + + + +function base64ToBytes(value) { + const binary = atob(String(value || '')); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function unpackPpmLayer(value) { + const packed = base64ToBytes(value); + const mask = new Uint8Array(256 * 192); + const count = Math.min(mask.length, packed.length * 8); + for (let index = 0; index < count; index += 1) { + mask[index] = (packed[index >> 3] >> (index & 7)) & 1; + } + return mask; +} + +function importedAudioBlob(item) { + const bytes = base64ToBytes(item && item.data); + return new Blob([bytes], {type: (item && item.mime) || 'audio/wav'}); +} + +function clearEditorAudioTracksForPpmImport() { + stopFlipnoteEditorAudio(); + ['music','sound1','sound2','sound3'].forEach((key) => { + const track = audioTracks[key]; + if (track && track.url) URL.revokeObjectURL(track.url); + audioTracks[key] = null; + }); + if (typeof timeline !== 'undefined' && timeline) renderFlipnoteSpaceMeter(timeline); +} + + + +async function applyImportedPpmProject(payload) { + editorDirtyTrackingSuspended += 1; + try { + const project = payload && payload.project; + if (!project || !Array.isArray(project.frames) || !project.frames.length) throw new Error('That PPM did not contain any editable pages.'); + if (project.frames.length > FLIPNOTE_EDITOR_MAX_PAGES) throw new Error(`Flipnote Editor supports a maximum of ${FLIPNOTE_EDITOR_MAX_PAGES} pages.`); + if (timeline.playing) timeline.stopPlayback(); + clearEditorAudioTracksForPpmImport(); + + editorLayerVisibility = Array.isArray(project.layer_visibility) && project.layer_visibility.length === 2 + ? project.layer_visibility.map((visible) => visible !== false) : [true, true]; + const importedFrames = project.frames.map((row) => { + const frame = new Frame({ + layers: [unpackPpmLayer(row.layer1), unpackPpmLayer(row.layer2)], + colors: Array.isArray(row.colours) ? row.colours.slice(0, 2) : ['black', 'red'], + paperColor: row.paper === 'black' ? 'black' : 'white', + soundFlags: normalizeFrameSoundFlags(row.sounds), + }); + frame.pushState(); + return frame; + }); + + timeline.frames = importedFrames; + rebuildFlipnoteSpaceFrameCache(timeline.frames); + timeline.currentFrame = Math.max(0, Math.min(importedFrames.length - 1, Number(project.thumbnail_frame) || 0)); + timeline.lastFrame = timeline.currentFrame; + exportThumbnailFrameIndex = timeline.currentFrame; + exportThumbnailFrameRef = timeline.frames[timeline.currentFrame] || null; + const speed = speedSetting(project.speed_level); + timeline.speedLevel = speed.level; + timeline.framerate = speed.fps; + timeline.loopEnabled = project.loop !== false; + updatePlaybackSettingsUI(timeline); + + + + + + if (filenameElement) { + filenameElement.textContent = String(project.title || 'Imported Flipnote').slice(0, 32) || 'Imported Flipnote'; + document.title = `${filenameElement.textContent} - Flipnote Editor`; + } + + const audio = project.audio && typeof project.audio === 'object' ? project.audio : {}; + ['music','sound1','sound2','sound3'].forEach((key) => { + const item = audio[key]; + if (!item || !item.data) return; + const blob = importedAudioBlob(item); + setAudioTrack( + key, + blob, + Number(item.duration) || 0, + item.source_name || `Imported ${audioTrackLabel(key)}`, + null, + Number(item.reference_fps) || timeline.framerate, + item.source_kind || 'ppm_import' + ); + }); + + canvas.activeLayer = 0; + timeline.createThumbnails(); + timeline.setActiveFrame(timeline.currentFrame); + updateAudioFillBars(); + renderAudioButtons(); + + const source = payload.source || {}; + const author = source.original_author_name || source.original_author_id || 'the original creator'; + if (ppmImportStatus) { + ppmImportStatus.textContent = `Imported ${importedFrames.length} page${importedFrames.length === 1 ? '' : 's'} from ${author}.`; + } + } finally { + editorDirtyTrackingSuspended = Math.max(0, editorDirtyTrackingSuspended - 1); + } +} + +async function importPpmFile(file) { + if (!file) return false; + if (timeline.playing) { setOpenPpmStatus('Stop playback before opening a PPM.'); return false; } + if (activeAudioRecording) { setOpenPpmStatus('Stop recording before opening a PPM.'); return false; } + if (ppmImportButton) ppmImportButton.disabled = true; + setOpenPpmStatus('Reading PPM...'); + try { + const payload = await FlipnotePpmImport.read(file); + await applyImportedPpmProject(payload); + markEditorDirty(); + return true; + } catch (error) { + setOpenPpmStatus(error && error.message ? error.message : 'That PPM could not be opened.'); + return false; + } finally { + if (ppmImportButton) ppmImportButton.disabled = false; + if (ppmImportInput) ppmImportInput.value = ''; + } +} + + + + + + + + + + + + + + + + + + + +function resolveExportThumbnailFrameIndex() { + if (!timeline || !Array.isArray(timeline.frames) || !timeline.frames.length) return 0; + if (exportThumbnailFrameRef) { + const refIndex = timeline.frames.indexOf(exportThumbnailFrameRef); + if (refIndex >= 0) { + exportThumbnailFrameIndex = refIndex; + return refIndex; + } + } + const fallback = Number.isFinite(Number(exportThumbnailFrameIndex)) + ? Number(exportThumbnailFrameIndex) + : Number(timeline.currentFrame) || 0; + const safe = Math.max(0, Math.min(timeline.frames.length - 1, Math.round(fallback))); + exportThumbnailFrameIndex = safe; + exportThumbnailFrameRef = timeline.frames[safe] || null; + return safe; +} + +function chooseExportThumbnailFrame(index) { + + if (!timeline || !Array.isArray(timeline.frames) || !timeline.frames.length) return; + const safe = Math.max(0, Math.min(timeline.frames.length - 1, Math.round(Number(index) || 0))); + exportThumbnailFrameIndex = safe; + exportThumbnailFrameRef = timeline.frames[safe] || null; + renderExportThumbnailTimelines(); + +} + +function renderExportThumbnailTimeline(container) { + if (!container || !timeline || !Array.isArray(timeline.frames)) return; + container.textContent = ''; + const selected = resolveExportThumbnailFrameIndex(); + timeline.frames.forEach((frame, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'flipnote-editor-export-thumbnail'; + button.dataset.frameIndex = String(index); + button.setAttribute('aria-label', `Use page ${index + 1} as the Flipnote thumbnail`); + button.setAttribute('aria-pressed', index === selected ? 'true' : 'false'); + if (index === selected) button.classList.add('selected'); + + const preview = document.createElement('canvas'); + preview.width = 256; + preview.height = 192; + preview.setAttribute('aria-hidden', 'true'); + const previewCtx = preview.getContext('2d', {alpha:false}); + previewCtx.imageSmoothingEnabled = false; + previewCtx.putImageData(frame.compositeImageData(), 0, 0); + button.appendChild(preview); + button.addEventListener('click', () => chooseExportThumbnailFrame(index)); + container.appendChild(button); + }); + requestAnimationFrame(() => { + const selectedButton = container.querySelector('.flipnote-editor-export-thumbnail.selected'); + if (selectedButton && typeof selectedButton.scrollIntoView === 'function') { + selectedButton.scrollIntoView({block:'nearest', inline:'nearest'}); + } + }); +} + +function renderExportThumbnailTimelines() { renderExportThumbnailTimeline(webmExportThumbnailTimeline); } + + + + + + + + + + + + + + + + + +function setWebmExportProgress(percent, message) { + const safe = Math.max(0, Math.min(100, Math.round(Number(percent) || 0))); + if (webmExportProgress) webmExportProgress.setAttribute('aria-valuenow', String(safe)); + if (webmExportProgressFill) webmExportProgressFill.style.width = `${safe}%`; + if (webmExportStatus && message) webmExportStatus.textContent = message; +} + +function openWebmExportModal() { + if (!webmExportModal || webmExporting) return; + if (activeAudioRecording) { + alert('Stop the current audio recording before exporting a WEBM.'); + return; + } + webmExportLastFocus = document.activeElement; + webmExportModal.hidden = false; + document.body.classList.add('flipnote-editor-info-open'); + if (webmExportStart) { webmExportStart.hidden = false; webmExportStart.disabled = false; webmExportStart.textContent = 'Save .WEBM'; } + if (webmExportCancel) { webmExportCancel.disabled = false; webmExportCancel.textContent = 'Cancel'; } + if (webmExportClose) webmExportClose.disabled = false; + if (!exportThumbnailFrameRef || !timeline.frames.includes(exportThumbnailFrameRef)) { + exportThumbnailFrameIndex = Math.max(0, Math.min(timeline.frames.length - 1, Number(timeline.currentFrame) || 0)); + exportThumbnailFrameRef = timeline.frames[exportThumbnailFrameIndex] || null; + } + renderExportThumbnailTimelines(); + setWebmExportProgress(0, `Ready to render ${timeline.frames.length} page${timeline.frames.length === 1 ? '' : 's'}.`); + webmExportStart?.focus(); +} + +function closeWebmExportModal() { + if (!webmExportModal || webmExporting) return; + webmExportModal.hidden = true; + document.body.classList.remove('flipnote-editor-info-open'); + const target = webmExportLastFocus; + webmExportLastFocus = null; + if (target && typeof target.focus === 'function') target.focus(); +} + +async function exportWebmFromEditor() { + if (webmExporting) return; + webmExporting = true; + if (webmExportStart) { webmExportStart.disabled = true; webmExportStart.textContent = 'Rendering...'; } + if (webmExportCancel) webmExportCancel.disabled = true; + if (webmExportClose) webmExportClose.disabled = true; + try { + await exportManager.exportAsWebm(setWebmExportProgress); + if (webmExportStart) webmExportStart.hidden = true; + if (webmExportCancel) webmExportCancel.textContent = 'Close'; + } catch (error) { + setWebmExportProgress(0, error && error.message ? error.message : 'The browser could not export that WEBM.'); + if (webmExportStart) webmExportStart.textContent = 'Try again'; + } finally { + webmExporting = false; + if (webmExportStart) webmExportStart.disabled = false; + if (webmExportCancel) webmExportCancel.disabled = false; + if (webmExportClose) webmExportClose.disabled = false; + } +} + +function setOpenPpmStatus(message) { + if (ppmImportStatus) ppmImportStatus.textContent = String(message || ''); +} + +function showOpenPpmChoice() { + if (openPpmChoice) openPpmChoice.hidden = false; + + setOpenPpmStatus(''); +} + +function openOpenPpmModal() { + if (!openPpmModal || timeline.playing) return; + openPpmLastFocus = document.activeElement; + openPpmModal.hidden = false; + document.body.classList.add('flipnote-editor-info-open'); + showOpenPpmChoice(); + if (ppmImportInput) ppmImportInput.value = ''; + + +} + +function closeOpenPpmModal() { + if (!openPpmModal) return; + + openPpmModal.hidden = true; + document.body.classList.remove('flipnote-editor-info-open'); + const target = openPpmLastFocus; + openPpmLastFocus = null; + if (target && typeof target.focus === 'function') target.focus(); +} + + + + + + + +if (ppmImportButton) ppmImportButton.addEventListener('click', (event) => { + event.preventDefault(); + openOpenPpmModal(); +}); +if (ppmImportInput) ppmImportInput.addEventListener('change', async () => { + const file = ppmImportInput.files && ppmImportInput.files[0]; + if (!file) return; + const imported = await importPpmFile(file); + if (imported) closeOpenPpmModal(); +}); + + + +if (openUploadLabel) openUploadLabel.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + try { + if (ppmImportInput && typeof ppmImportInput.showPicker === 'function') ppmImportInput.showPicker(); + else ppmImportInput?.click(); + } catch (error) { + ppmImportInput?.click(); + } +}); + + + + + +if (openPpmClose) openPpmClose.addEventListener('click', closeOpenPpmModal); +if (openPpmCancel) openPpmCancel.addEventListener('click', closeOpenPpmModal); +if (openPpmModal) openPpmModal.addEventListener('pointerdown', (event) => { if (event.target === openPpmModal) closeOpenPpmModal(); }); + + + + + + + + + + + + + + + +const exportBtn = document.getElementById('export-btn'); +if (exportBtn) exportBtn.addEventListener('click', (event) => { event.preventDefault(); openWebmExportModal(); }); +if (webmExportStart) webmExportStart.addEventListener('click', () => { void exportWebmFromEditor(); }); +if (webmExportCancel) webmExportCancel.addEventListener('click', closeWebmExportModal); +if (webmExportClose) webmExportClose.addEventListener('click', closeWebmExportModal); +if (webmExportModal) webmExportModal.addEventListener('pointerdown', (event) => { if (event.target === webmExportModal) closeWebmExportModal(); }); + +document.addEventListener('keydown', (event) => { + if (shortcutAction(event, 'dialog') !== 'closeDialog') return; + + + if (webmExportModal && !webmExportModal.hidden) { event.preventDefault(); closeWebmExportModal(); return; } + if (openPpmModal && !openPpmModal.hidden) { event.preventDefault(); closeOpenPpmModal(); } +}); +// The editor starts on Draw 1. Present that default exactly like a tool the +// user has already chosen: its selected-colour icon stays visible at the top +// of the closed picker and can be clicked to reopen the Draw menu. +showRetainedToolChoice(); + +let openColorPickerLayer = null; + +function closeLayerColorPicker() { + if (openColorPickerLayer === null) return; + openColorPickerLayer = null; + updateLayerPanel(); +} + +function openLayerColorPicker(layer) { + openColorPickerLayer = layer === 1 ? 1 : 0; + canvas.setActiveLayer(openColorPickerLayer); + updateLayerPanel(); +} + +function updateLayerPanel() { + const frame = timeline.getFrame(timeline.currentFrame); + if (!frame) return; + document.querySelectorAll('[data-layer-row]').forEach((row) => { + const layer = Number(row.dataset.layerRow); + const active = layer === canvas.activeLayer; + const pickerOpen = layer === openColorPickerLayer; + row.classList.toggle('active', active); + row.classList.toggle('layer-hidden', !editorLayerVisibility[layer]); + row.classList.toggle('color-picker-open', pickerOpen); + + const colors = Array.from(row.querySelectorAll('[data-layer-color]')); + const selectedColor = frame.colors[layer]; + const otherColors = FLIPNOTE_INK_ORDER.filter((color) => color !== selectedColor); + // Keep the selected swatch anchored at the far right. The two alternate + // colours occupy slots to its left and animate out from underneath it. + const visibleOrder = [otherColors[0], otherColors[1], selectedColor]; + colors.forEach((button) => { + const selected = selectedColor === button.dataset.layerColor; + const pickerSlot = visibleOrder.indexOf(button.dataset.layerColor) + 1; + button.classList.toggle('selected', selected); + button.setAttribute('aria-pressed', selected ? 'true' : 'false'); + button.style.order = String(pickerSlot); + button.style.setProperty('--flipnote-picker-slot', String(pickerSlot)); + button.tabIndex = pickerOpen || selected ? 0 : -1; + if (button.dataset.layerColor === 'black') { + const monochromeInk = resolveFrameInk(frame, 'black'); + button.style.backgroundColor = `rgb(${monochromeInk[0]}, ${monochromeInk[1]}, ${monochromeInk[2]})`; + button.title = frame.paperColor === 'black' ? 'White ink' : 'Black ink'; + } else { + button.style.backgroundColor = ''; + button.title = button.dataset.layerColor === 'red' ? 'Red' : 'Blue'; + } + }); + + const colorGroup = row.querySelector('.flipnote-editor-layer-colors'); + if (colorGroup) colorGroup.setAttribute('aria-expanded', pickerOpen ? 'true' : 'false'); + }); + document.querySelectorAll('[data-layer-select]').forEach((button) => { + const layer = Number(button.dataset.layerSelect); + const active = layer === canvas.activeLayer; + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + const status = button.querySelector('[data-layer-status]'); + if (status) status.textContent = !editorLayerVisibility[layer] ? 'Hidden' : (active ? 'Active' : 'Select'); + }); +} + +// Layer visibility is a PPM-wide flag; swapping exchanges this page's artwork and colours. +const layerMenu = document.createElement('div'); +layerMenu.className = 'flipnote-editor-layer-menu'; +layerMenu.hidden = true; +layerMenu.setAttribute('role', 'menu'); +layerMenu.innerHTML = ''; +document.body.appendChild(layerMenu); +let layerMenuTarget = 0; +function closeLayerMenu() { layerMenu.hidden = true; } +function refreshLayerVisibility() { + markEditorDirty(); + + canvas.renderCurrentFrame(); + timeline.createThumbnails(); + updateLayerPanel(); +} +layerMenu.querySelector('[data-layer-visibility]').addEventListener('click', () => { + editorLayerVisibility[layerMenuTarget] = !editorLayerVisibility[layerMenuTarget]; + closeLayerMenu(); + refreshLayerVisibility(); +}); +layerMenu.querySelector('[data-layer-swap]').addEventListener('click', () => { + if (timeline.playing || !canvas.allowEditing) return closeLayerMenu(); + const frame = timeline.getFrame(timeline.currentFrame); + if (frame) { + [frame.layers[0], frame.layers[1]] = [frame.layers[1], frame.layers[0]]; + [frame.colors[0], frame.colors[1]] = [frame.colors[1], frame.colors[0]]; + timeline.updateFrame(timeline.currentFrame); + canvas.renderCurrentFrame(); + updateLayerPanel(); + } + closeLayerMenu(); +}); +document.addEventListener('pointerdown', (event) => { if (!layerMenu.contains(event.target)) closeLayerMenu(); }); +document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeLayerMenu(); }); +document.querySelectorAll('[data-layer-row]').forEach((row) => { + row.addEventListener('contextmenu', (event) => { + event.preventDefault(); + layerMenuTarget = Number(row.dataset.layerRow) === 1 ? 1 : 0; + layerMenu.querySelector('[data-layer-visibility]').textContent = editorLayerVisibility[layerMenuTarget] ? 'Hide Layer' : 'Show Layer'; + layerMenu.hidden = false; + layerMenu.style.left = `${Math.max(0, Math.min(event.clientX, window.innerWidth - layerMenu.offsetWidth))}px`; + layerMenu.style.top = `${Math.max(0, Math.min(event.clientY, window.innerHeight - layerMenu.offsetHeight))}px`; + }); +}); + +// The artwork makes the whole row read as one layer button. Keep that entire +// visible row selectable (except the colour controls) instead of relying on a +// narrower invisible hit target. This also fixes clicks near the right side of +// the layer artwork being ignored. +document.querySelectorAll('[data-layer-row]').forEach((row) => { + row.addEventListener('click', (event) => { + if (event.target instanceof Element && event.target.closest('.flipnote-editor-layer-colors')) return; + canvas.setActiveLayer(Number(row.dataset.layerRow)); + }); +}); + +// Delegate the picker click from the colour group itself. This keeps the +// visible swatch clickable even when the image-backed layer row overlaps it. +document.querySelectorAll('.flipnote-editor-layer-colors').forEach((group) => { + const layerRow = group.closest('[data-layer-row]'); + const setColorInteraction = (active) => { + if (layerRow) layerRow.classList.toggle('color-picker-interaction', !!active); + }; + // Hovering/pressing the colour controls should animate only the swatches, + // not the large layer artwork underneath them. + group.addEventListener('pointerenter', () => setColorInteraction(true)); + group.addEventListener('pointerleave', () => setColorInteraction(false)); + group.addEventListener('focusin', () => setColorInteraction(true)); + group.addEventListener('focusout', (event) => { + if (!group.contains(event.relatedTarget)) setColorInteraction(false); + }); + group.addEventListener('pointerdown', (event) => { + setColorInteraction(true); + event.stopPropagation(); + }, true); + group.addEventListener('click', (event) => { + const button = event.target.closest('[data-layer-color]'); + if (!button) { + const row = group.closest('[data-layer-row]'); + if (row) canvas.setActiveLayer(Number(row.dataset.layerRow)); + return; + } + event.preventDefault(); + event.stopPropagation(); + const layer = Number(button.dataset.layer); + if (openColorPickerLayer !== layer) { + openLayerColorPicker(layer); + return; + } + canvas.setLayerColor(layer, button.dataset.layerColor); + openColorPickerLayer = null; + updateLayerPanel(); + }); +}); +document.addEventListener('click', (event) => { + if (openColorPickerLayer === null) return; + if (event.target instanceof Element && event.target.closest('.flipnote-editor-layer-colors')) return; + closeLayerColorPicker(); +}); +document.addEventListener('keydown', (event) => { + if (shortcutAction(event, 'dialog') === 'closeDialog') closeLayerColorPicker(); +}); +updateLayerPanel(); + +function setToolTab(tabNumber) { + const nextTab = Math.max(1, Math.min(5, Number(tabNumber) || 1)); + // The selection belongs to the current frame, not to the visible toolbar tab. + // Keep it visible until Select or Transform is switched off (or the frame changes). + activeToolTab = nextTab; + document.querySelectorAll('[data-tool-tab]').forEach((button) => { + const active = Number(button.dataset.toolTab) === nextTab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + button.tabIndex = active ? 0 : -1; + const image = button.querySelector('img'); + const normalSrc = button.dataset.tabNormalSrc; + const selectedSrc = button.dataset.tabSelectedSrc; + if (image && normalSrc && selectedSrc) { + image.src = active ? selectedSrc : normalSrc; + } + }); + document.querySelectorAll('[data-tool-tab-pane]').forEach((pane) => { + pane.hidden = Number(pane.dataset.toolTabPane) !== nextTab; + }); + document.querySelectorAll('[data-tool-tab-shared]').forEach((panel) => { + const tabs = String(panel.dataset.toolTabShared || '').split(/\s+/).map(Number); + panel.hidden = !tabs.includes(nextTab); + }); + closeLayerColorPicker(); +} + +document.querySelectorAll('[data-tool-tab]').forEach((button) => { + button.addEventListener('click', () => setToolTab(button.dataset.toolTab)); +}); +setToolTab(1); + +const paperTool = document.getElementById('paper-tool'); +const lightboxTool = document.getElementById('lightbox-tool'); +const cloneTool = document.getElementById('clone-tool'); +const selectTool = document.getElementById('select-tool'); +const copyTool = document.getElementById('copy-tool'); +const pasteTool = document.getElementById('paste-tool'); +const transformTool = document.getElementById('transform-tool'); +const invertTool = document.getElementById('invert-tool'); +const resizeTool = document.getElementById('resize-tool'); +const flipTool = document.getElementById('flip-tool'); +const rotateTool = document.getElementById('rotate-tool'); +const selectionStatus = document.getElementById('flipnote-selection-status'); +const selectionTools = document.querySelector('.flipnote-editor-selection-tools'); +let selectionShiftHeld = false; + +// Selection UI fixes live here in editor.js now; no separate +// selection-ui-fixes.js runtime patch is required. +let selectionBase = null; +if (selectionTools) { + selectionTools.style.position = 'relative'; + + const baseCandidates = Array.from(selectionTools.querySelectorAll( + '[data-selection-base], .flipnote-editor-selection-base, .selbase, .selection-base, img[src*="selbase"]' + )); + selectionBase = baseCandidates.find((node) => node.parentElement === selectionTools) || null; + if (!selectionBase) { + selectionBase = document.createElement('img'); + selectionBase.className = 'flipnote-editor-selection-base'; + selectionBase.dataset.selectionBase = ''; + selectionBase.alt = ''; + selectionBase.draggable = false; + selectionBase.setAttribute('aria-hidden', 'true'); + selectionTools.prepend(selectionBase); + } + selectionBase.src = './assets/flipnote-editor/selbase.png'; + selectionBase.hidden = false; + Object.assign(selectionBase.style, { + position: 'absolute', + zIndex: '0', + inset: '0', + display: 'block', + width: '100%', + height: '100%', + objectFit: 'fill', + pointerEvents: 'none', + userSelect: 'none', + transform: 'none', + filter: 'none', + transition: 'none', + }); + + // selbase is one backing plate for the whole group, never one per button. + baseCandidates.forEach((node) => { + if (node === selectionBase) return; + node.hidden = true; + node.setAttribute('aria-hidden', 'true'); + node.style.display = 'none'; + }); + + [selectTool, copyTool, pasteTool].forEach((button) => { + if (!button) return; + button.style.position = 'relative'; + button.style.zIndex = '1'; + // Filter changes must be instantaneous; only the button growth animates. + button.style.transition = 'transform .1s ease'; + }); +} + +function setSelectionButtonDisabled(button, disabled) { + if (!button) return; + button.classList.toggle('is-disabled', disabled); + button.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + button.tabIndex = disabled ? -1 : 0; + button.style.filter = disabled ? 'grayscale(1) opacity(.48)' : ''; + button.style.transition = 'transform .1s ease'; +} + +function updateSelectionToolState() { + const hasSelection = !!(canvas && typeof canvas.hasSelection === 'function' && canvas.hasSelection()); + const hasClipboard = !!(canvas && canvas.selectionClipboard); + + if (selectTool) { + const image = selectTool.querySelector('img'); + const selecting = !!(canvas && canvas.selectionMode && !canvas.rectangleSelectionMode); + selectTool.classList.toggle('is-enabled', selecting); + selectTool.setAttribute('aria-pressed', selecting ? 'true' : 'false'); + selectTool.title = canvas && canvas.mobileRectangleSelection + ? 'Rectangle Select (touch and drag)' + : (selecting ? 'Select (click again to cancel)' : 'Select'); + // Keep Select at the same grown size as hover for as long as it is on. + selectTool.style.transform = selecting ? 'scale(1.04)' : ''; + selectTool.style.zIndex = selecting ? '2' : '1'; + if (image) { + image.src = selecting + ? './assets/flipnote-editor/select_y.png' + : './assets/flipnote-editor/select.png'; + image.alt = selecting ? 'Select enabled' : 'Select'; + } + } + + if (copyTool) { + const copyImage = copyTool.querySelector('img'); + const copyIsCut = !!selectionShiftHeld; + setSelectionButtonDisabled(copyTool, !hasSelection); + copyTool.title = hasSelection + ? (copyIsCut ? 'Cut selection' : 'Copy selection') + : (copyIsCut ? 'Cut selection (disabled)' : 'Copy selection (disabled)'); + if (copyImage) { + copyImage.src = copyIsCut + ? './assets/flipnote-editor/selectionCut.png' + : './assets/flipnote-editor/selectionCopy.png'; + copyImage.alt = copyIsCut ? 'Cut' : 'Copy'; + } + } + + if (pasteTool) { + setSelectionButtonDisabled(pasteTool, !hasClipboard); + pasteTool.title = hasClipboard ? 'Paste clipboard' : 'Paste clipboard (disabled)'; + } + if (transformTool) { + const active = !!(canvas && canvas.selectionMode && canvas.rectangleSelectionMode); + transformTool.classList.toggle('is-enabled', active); + transformTool.setAttribute('aria-pressed', active ? 'true' : 'false'); + transformTool.title = hasSelection ? 'Select the bounding rectangle' : 'Rectangle selection'; + const image = transformTool.querySelector('img'); + if (image) image.src = active + ? './assets/flipnote-editor/transform_active.png' + : './assets/flipnote-editor/transform.png'; + } + if (invertTool) { + invertTool.disabled = !hasSelection; + invertTool.title = hasSelection + ? (selectionShiftHeld ? 'Swap layer ink colours inside the selection' : 'Invert ink and erased pixels in selection') + : 'Select an area to invert'; + } + + if (resizeTool) { + const image = resizeTool.querySelector('img'); + resizeTool.title = selectionShiftHeld ? 'Enlarge' : 'Shrink'; + if (image) { + image.src = selectionShiftHeld + ? './assets/flipnote-editor/resize_larger.png' + : './assets/flipnote-editor/resize_smaller.png'; + image.alt = selectionShiftHeld ? 'Enlarge' : 'Shrink'; + } + } + if (flipTool) { + const image = flipTool.querySelector('img'); + flipTool.title = selectionShiftHeld ? 'Flip vertically' : 'Flip horizontally'; + if (image) { + image.src = selectionShiftHeld + ? './assets/flipnote-editor/flip_y.png' + : './assets/flipnote-editor/flip_x.png'; + image.alt = selectionShiftHeld ? 'Flip vertically' : 'Flip horizontally'; + } + } + if (rotateTool) { + const image = rotateTool.querySelector('img'); + rotateTool.title = selectionShiftHeld ? 'Rotate anticlockwise' : 'Rotate clockwise'; + if (image) { + image.src = selectionShiftHeld + ? './assets/flipnote-editor/rotate_anticlockwise.png' + : './assets/flipnote-editor/rotate_clockwise.png'; + image.alt = selectionShiftHeld ? 'Rotate anticlockwise' : 'Rotate clockwise'; + } + } + +} + +function updatePaperToolState() { + const frame = typeof timeline !== 'undefined' ? timeline.getFrame(timeline.currentFrame) : null; + if (paperTool && frame) { + const isBlackPaper = frame.paperColor === 'black'; + const image = paperTool.querySelector('img'); + paperTool.setAttribute('aria-pressed', isBlackPaper ? 'true' : 'false'); + paperTool.title = isBlackPaper ? 'Paper: Black (click for white)' : 'Paper: White (click for black)'; + if (image) { + image.src = isBlackPaper ? './assets/flipnote-editor/paperB.png' : './assets/flipnote-editor/paperW.png'; + image.alt = isBlackPaper ? 'Black paper' : 'White paper'; + } + } + if (lightboxTool) { + const image = lightboxTool.querySelector('img'); + lightboxTool.classList.toggle('is-enabled', lightboxEnabled); + lightboxTool.setAttribute('aria-pressed', lightboxEnabled ? 'true' : 'false'); + lightboxTool.title = `Light-box (${pagesToLightbox()} previous page${pagesToLightbox() === 1 ? '' : 's'})`; + if (image) { + image.src = lightboxEnabled ? './assets/flipnote-editor/lightbox_pressed.png' : './assets/flipnote-editor/lightbox.png'; + image.alt = lightboxEnabled ? 'Light-box enabled' : 'Light-box'; + } + } + if (cloneTool) { + const image = cloneTool.querySelector('img'); + cloneTool.classList.toggle('is-enabled', cloneEnabled); + cloneTool.setAttribute('aria-pressed', cloneEnabled ? 'true' : 'false'); + if (image) { + image.src = cloneEnabled ? './assets/flipnote-editor/copy_pressed.png' : './assets/flipnote-editor/copy.png'; + image.alt = cloneEnabled ? 'Clone enabled' : 'Clone'; + } + } +} + +if (paperTool) { + paperTool.addEventListener('click', (event) => { + event.preventDefault(); + const frame = timeline.getFrame(timeline.currentFrame); + if (!frame) return; + frame.paperColor = frame.paperColor === 'black' ? 'white' : 'black'; + canvas.renderCurrentFrame(); + timeline.updateFrame(timeline.currentFrame); + updateLayerPanel(); + updatePaperToolState(); + }); +} +if (lightboxTool) { + lightboxTool.addEventListener('click', () => { + lightboxEnabled = !lightboxEnabled; + updatePaperToolState(); + canvas.renderCurrentFrame(); + }); +} +if (cloneTool) { + cloneTool.addEventListener('click', () => { + cloneEnabled = !cloneEnabled; + updatePaperToolState(); + }); +} +if (selectTool) { + selectTool.addEventListener('click', (event) => { + event.preventDefault(); + if (canvas.selectionMode && !canvas.rectangleSelectionMode) canvas.clearSelection(); + else { + canvas.rectangleSelectionMode = false; + canvas.mobileRectangleSelection = false; + canvas.setSelectionMode(true); + } + }); +} +if (transformTool) { + transformTool.addEventListener('click', (event) => { + event.preventDefault(); + if (canvas.selectionMode && canvas.rectangleSelectionMode) { + canvas.clearSelection(); + } else if (!canvas.selectBoundingRectangle()) { + canvas.rectangleSelectionMode = true; + canvas.mobileRectangleSelection = false; + canvas.setSelectionMode(true); + } + }); +} +if (invertTool) { + invertTool.addEventListener('click', (event) => { + event.preventDefault(); + if (!invertTool.disabled) canvas.invertSelection(!!(selectionShiftHeld || editorAlternateActive(event))); + }); +} +if (copyTool) { + copyTool.addEventListener('click', (event) => { + event.preventDefault(); + if (copyTool.getAttribute('aria-disabled') === 'true') return; + canvas.copySelection(!!(selectionShiftHeld || editorAlternateActive(event))); + }); +} +if (pasteTool) { + pasteTool.addEventListener('click', (event) => { + event.preventDefault(); + if (pasteTool.getAttribute('aria-disabled') === 'true') return; + canvas.pasteSelection(); + }); +} +if (resizeTool) { + resizeTool.addEventListener('click', (event) => { + event.preventDefault(); + canvas.resizeSelectionOrLayer(!!(selectionShiftHeld || editorAlternateActive(event))); + }); +} +if (flipTool) { + flipTool.addEventListener('click', (event) => { + event.preventDefault(); + canvas.flipSelectionOrLayer(!!(selectionShiftHeld || editorAlternateActive(event))); + }); +} +if (rotateTool) { + rotateTool.addEventListener('click', (event) => { + event.preventDefault(); + canvas.rotateSelectionOrLayer(!!(selectionShiftHeld || editorAlternateActive(event))); + }); +} + +// Touch long-press exposes the desktop Shift variants without changing any +// normal tap/click behavior. +installFlipnoteTouchLongPress(selectTool, () => { + if (!canvas) return; + if (canvas.hasSelection()) canvas.clearSelection(); + canvas.mobileRectangleSelection = true; + canvas.setSelectionMode(true); + updateSelectionToolState(); +}); +installFlipnoteTouchLongPress(copyTool, () => { + if (copyTool && copyTool.getAttribute('aria-disabled') !== 'true') canvas.copySelection(true); +}); +installFlipnoteTouchLongPress(resizeTool, () => canvas.resizeSelectionOrLayer(true)); +installFlipnoteTouchLongPress(flipTool, () => canvas.flipSelectionOrLayer(true)); +installFlipnoteTouchLongPress(rotateTool, () => canvas.rotateSelectionOrLayer(true)); +document.addEventListener('keydown', (event) => { + if (!editorAlternateKeyEvent(event) || selectionShiftHeld) return; + selectionShiftHeld = true; + updateSelectionToolState(); +}); +document.addEventListener('keyup', (event) => { + if (!editorAlternateReleaseEvent(event)) return; + selectionShiftHeld = false; + updateSelectionToolState(); +}); +window.addEventListener('blur', () => { + if (!selectionShiftHeld) return; + selectionShiftHeld = false; + updateSelectionToolState(); +}); +updatePaperToolState(); +updateSelectionToolState(); + +// Flipnote Editor Page 4: Music + three Sound Effect tracks. The supplied +// DSi-style sprites are the controls themselves; Shift temporarily swaps the +// record/play controls to their import artwork so MP3/WAV can replace a track. +// PPM stores mono audio at 8,192 Hz as one ADPCM byte per two samples plus a +// four-byte decoder header. Keep imported clips within the actual byte budget +// (rather than just the nominal 2s/60s duration) so export can always fit. +const FLIPNOTE_BGM_ADPCM_MAX_BYTES = 245408; +const FLIPNOTE_SFX_ADPCM_MAX_BYTES = 8192; +const FLIPNOTE_MUSIC_MAX_SECONDS = ((FLIPNOTE_BGM_ADPCM_MAX_BYTES - 4) * 2) / 8192; +const FLIPNOTE_SOUND_EFFECT_LIMIT_SECONDS = ((FLIPNOTE_SFX_ADPCM_MAX_BYTES - 4) * 2) / 8192; +const FLIPNOTE_AUTO_MUSIC_MAX_FRAMES = FLIPNOTE_EDITOR_MAX_PAGES; +const audioControlButtons = Array.from(document.querySelectorAll('[data-audio-control]')); +const audioEraseButtons = Array.from(document.querySelectorAll('[data-audio-erase]')); +const audioFileInput = document.getElementById('flipnote-audio-file-input'); +const audioStatus = document.getElementById('flipnote-audio-status'); +const audioTrimModal = document.getElementById('flipnote-audio-trim-modal'); +const audioTrimClose = document.getElementById('flipnote-audio-trim-close'); +const audioTrimCancel = document.getElementById('flipnote-audio-trim-cancel'); +const audioTrimBack = document.getElementById('flipnote-audio-trim-back'); +const audioTrimSave = document.getElementById('flipnote-audio-trim-save'); +const audioPreviewButton = document.getElementById('flipnote-audio-preview'); +const audioImportSourceStep = document.getElementById('flipnote-audio-import-source-step'); +const audioImportSourceCopy = document.getElementById('flipnote-audio-import-source-copy'); +const audioImportSourceOptions = document.getElementById('flipnote-audio-import-source-options'); +const audioTrimStep = document.getElementById('flipnote-audio-trim-step'); + + + + + + + + + + + +const audioWaveform = document.getElementById('flipnote-audio-waveform'); +const audioStartRange = document.getElementById('flipnote-audio-start'); +const audioEndRange = document.getElementById('flipnote-audio-end'); +const audioStartValue = document.getElementById('flipnote-audio-start-value'); +const audioEndValue = document.getElementById('flipnote-audio-end-value'); +const audioSelectionDuration = document.getElementById('flipnote-audio-selection-duration'); +const audioTrimFilename = document.getElementById('flipnote-audio-trim-filename'); +const audioTrimLimit = document.getElementById('flipnote-audio-trim-limit'); +const audioTrimTitle = document.getElementById('flipnote-audio-trim-title'); +const audioAutoFramesOption = document.getElementById('flipnote-audio-auto-frames-option'); +const audioAutoFramesToggle = document.getElementById('flipnote-audio-auto-frames'); +const audioAutoFramesDetail = document.getElementById('flipnote-audio-auto-frames-detail'); +const audioTrimVolumeMeter = document.getElementById('flipnote-audio-trim-volume-meter'); +const audioTrimVolumeFill = document.getElementById('flipnote-audio-trim-volume-fill'); +const audioTrimVolumeValue = document.getElementById('flipnote-audio-trim-volume-value'); +const audioVolumeMonitor = document.getElementById('flipnote-audio-volume-monitor'); +const audioVolumeHistoryHost = document.getElementById('flipnote-audio-volume-history'); +const audioTracks = {music:null, sound1:null, sound2:null, sound3:null}; +flipnoteSpaceAudioTracks = audioTracks; +renderFlipnoteSpaceMeter(timeline); +let audioShiftHeld = false; +let audioImportTarget = null; +let activeAudioPlayback = null; +let activeAudioRecording = null; +let audioTrimState = null; + + + + + + + + + + +let sharedAudioContext = null; +let timelineMusicPlayback = null; +let timelineMusicGeneration = 0; +const timelineSoundSources = new Set(); +let timelineSoundGeneration = 0; +const AUDIO_VOLUME_HISTORY_COLUMNS = 17; +const AUDIO_VOLUME_HISTORY_INTERVAL_MS = 100; +const AUDIO_VOLUME_SAMPLE_INTERVAL_MS = 16; +const AUDIO_VOLUME_LOGICAL_WIDTH = 92; +const AUDIO_VOLUME_LOGICAL_HEIGHT = 84; +const AUDIO_VOLUME_BAR_START_X = 4; +const AUDIO_VOLUME_BAR_STEP_X = 5; +const AUDIO_VOLUME_BAR_BASELINE_Y = 76; +const AUDIO_VOLUME_BAR_TOP_Y = 2; +const AUDIO_VOLUME_RED_BOTTOM_Y = 18; +const AUDIO_VOLUME_GOOD_SRC = './assets/flipnote-editor/goodVolume.png'; +const AUDIO_VOLUME_HURT_SRC = './assets/flipnote-editor/earsHurt.png'; +let playbackVolumeAnalyser = null; +let playbackVolumeSamples = null; +let audioVolumeTimer = null; +let audioVolumeSampleTimer = null; +let audioVolumePeakHold = 0; +let audioVolumeHistory = Array(AUDIO_VOLUME_HISTORY_COLUMNS).fill(0); +const audioVolumeBars = []; + +function getFlipnoteAudioContext() { + if (!sharedAudioContext) { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (!AudioContextClass) throw new Error('This browser does not support Web Audio.'); + sharedAudioContext = new AudioContextClass(); + } + return sharedAudioContext; +} + +function initializeAudioVolumeMeter() { + if (!audioVolumeHistoryHost || audioVolumeBars.length) return; + for (let index = 0; index < AUDIO_VOLUME_HISTORY_COLUMNS; index += 1) { + const bar = document.createElement('img'); + bar.className = 'flipnote-editor-volume-sample'; + bar.src = AUDIO_VOLUME_GOOD_SRC; + bar.alt = ''; + bar.draggable = false; + bar.dataset.volumeSprite = 'good'; + bar.style.left = `${(((AUDIO_VOLUME_BAR_START_X + (index * AUDIO_VOLUME_BAR_STEP_X)) / AUDIO_VOLUME_LOGICAL_WIDTH) * 100).toFixed(6)}%`; + audioVolumeHistoryHost.appendChild(bar); + audioVolumeBars.push(bar); + } + renderAudioVolumeHistory(); +} + +function getPlaybackVolumeAnalyser() { + const context = getFlipnoteAudioContext(); + if (!playbackVolumeAnalyser) { + playbackVolumeAnalyser = context.createAnalyser(); + playbackVolumeAnalyser.fftSize = 256; + playbackVolumeAnalyser.smoothingTimeConstant = 0; + playbackVolumeSamples = new Float32Array(playbackVolumeAnalyser.fftSize); + // All editor playback is routed through this analyser so the meter sees + // Music and overlapping SE1/SE2/SE3 as one Flipnote output mix. + playbackVolumeAnalyser.connect(context.destination); + } + return playbackVolumeAnalyser; +} + +function connectAudioSourceToEditorOutput(source) { + source.connect(getPlaybackVolumeAnalyser()); + startAudioVolumeMeter(); +} + +function audioVolumeIsActive() { + return !!( + activeAudioRecording || + activeAudioPlayback || + timelineMusicPlayback || + timelineSoundSources.size || + (audioTrimState && audioTrimState.previewSource) + ); +} + +function readAudioVolumeLevel(analyser, samples) { + if (!analyser || !samples) return 0; + try { + analyser.getFloatTimeDomainData(samples); + } catch (error) { + return 0; + } + let peak = 0; + for (let index = 0; index < samples.length; index += 1) { + const value = Math.abs(samples[index] || 0); + if (value > peak) peak = value; + } + if (peak <= 0.00001) return 0; + // A dB mapping keeps quiet material visible while reserving the supplied + // pink/red warning band for genuinely loud peaks. + const decibels = 20 * Math.log10(peak); + return Math.max(0, Math.min(1, (decibels + 60) / 60)); +} + +function currentAudioVolumeLevel() { + const recording = activeAudioRecording; + if (recording && recording.volumeAnalyser && recording.volumeSamples) { + return readAudioVolumeLevel(recording.volumeAnalyser, recording.volumeSamples); + } + return readAudioVolumeLevel(playbackVolumeAnalyser, playbackVolumeSamples); +} + +function renderAudioVolumeHistory() { + if (!audioVolumeBars.length) return; + const travel = AUDIO_VOLUME_BAR_BASELINE_Y - AUDIO_VOLUME_BAR_TOP_Y; + audioVolumeBars.forEach((bar, index) => { + const level = Math.max(0, Math.min(1, Number(audioVolumeHistory[index]) || 0)); + const logicalY = Math.round(AUDIO_VOLUME_BAR_BASELINE_Y - (level * travel)); + const danger = logicalY < AUDIO_VOLUME_RED_BOTTOM_Y; + bar.style.top = `${((logicalY / AUDIO_VOLUME_LOGICAL_HEIGHT) * 100).toFixed(6)}%`; + const sprite = danger ? 'hurt' : 'good'; + if (bar.dataset.volumeSprite !== sprite) { + bar.dataset.volumeSprite = sprite; + bar.src = danger ? AUDIO_VOLUME_HURT_SRC : AUDIO_VOLUME_GOOD_SRC; + } + }); +} + +function renderAudioTrimVolume(level) { + const value = Math.max(0, Math.min(1, Number(level) || 0)); + const percent = Math.round(value * 100); + if (audioTrimVolumeFill) audioTrimVolumeFill.style.width = `${percent}%`; + if (audioTrimVolumeValue) audioTrimVolumeValue.textContent = `${percent}%`; + if (audioTrimVolumeMeter) { + audioTrimVolumeMeter.setAttribute('aria-valuenow', String(percent)); + audioTrimVolumeMeter.classList.toggle('is-danger', value >= ((AUDIO_VOLUME_BAR_BASELINE_Y - AUDIO_VOLUME_RED_BOTTOM_Y) / (AUDIO_VOLUME_BAR_BASELINE_Y - AUDIO_VOLUME_BAR_TOP_Y))); + } +} + +function audioVolumeSampleTick() { + const active = audioVolumeIsActive(); + const level = active ? currentAudioVolumeLevel() : 0; + if (active) audioVolumePeakHold = Math.max(audioVolumePeakHold, level); + // The trim dialog gets a smooth live meter, while the sidebar history moves + // at the deliberately slower history cadence below. + renderAudioTrimVolume((audioTrimState && audioTrimState.previewSource) ? level : 0); +} + +function audioVolumeMeterTick() { + const active = audioVolumeIsActive(); + const instant = active ? currentAudioVolumeLevel() : 0; + const level = active ? Math.max(audioVolumePeakHold, instant) : 0; + audioVolumePeakHold = 0; + audioVolumeHistory.shift(); + audioVolumeHistory.push(level); + renderAudioVolumeHistory(); + if (!active && audioVolumeHistory.every((value) => value <= 0.00001)) { + if (audioVolumeTimer) clearInterval(audioVolumeTimer); + if (audioVolumeSampleTimer) clearInterval(audioVolumeSampleTimer); + audioVolumeTimer = null; + audioVolumeSampleTimer = null; + renderAudioTrimVolume(0); + } +} + +function startAudioVolumeMeter() { + initializeAudioVolumeMeter(); + if (!audioVolumeMonitor) return; + if (!audioVolumeSampleTimer) audioVolumeSampleTimer = setInterval(audioVolumeSampleTick, AUDIO_VOLUME_SAMPLE_INTERVAL_MS); + if (!audioVolumeTimer) audioVolumeTimer = setInterval(audioVolumeMeterTick, AUDIO_VOLUME_HISTORY_INTERVAL_MS); +} + +initializeAudioVolumeMeter(); +renderAudioTrimVolume(0); + +function flipnoteDurationSeconds() { + const frames = Math.max(1, timeline && timeline.frames ? timeline.frames.length : 1); + const fps = Math.max(0.01, Number(timeline && timeline.framerate) || 12); + return frames / fps; +} + +function audioTrackLimitSeconds(key) { + return key === 'music' + ? Math.max(0.05, Math.min(flipnoteDurationSeconds(), FLIPNOTE_MUSIC_MAX_SECONDS)) + : FLIPNOTE_SOUND_EFFECT_LIMIT_SECONDS; +} + +function audioTrackLabel(key) { + if (key === 'music') return 'Music'; + return `Sound ${String(key || '').replace('sound','') || '1'}`; +} + +function formatAudioSeconds(value) { + return `${Math.max(0, Number(value) || 0).toFixed(2)}s`; +} + +function flipnoteTimelineFps() { + return Math.max(0.01, Number(timeline && timeline.framerate) || 12); +} + +function audioTrackReferenceFps(track) { + return Math.max(0.01, Number(track && track.referenceFps) || flipnoteTimelineFps()); +} + +function audioTrackPlaybackRate(track, key) { + // Flipnote BGM is speed-linked: changing animation speed changes both + // Music tempo and pitch while preserving its frame alignment. SE1/SE2/SE3 + // behave like original Flipnote Studio sound effects and always play at + // the recorded/imported file's original speed and pitch. + if (/^sound[123]$/.test(String(key || ''))) return 1; + return Math.max(0.01, Math.min(64, flipnoteTimelineFps() / audioTrackReferenceFps(track))); +} + +function audioTrackEffectiveDuration(track, key) { + if (!track) return 0; + return Math.max(0, Number(track.duration) || 0) / audioTrackPlaybackRate(track, key); +} + +function setAudioStatus(message) { + if (audioStatus) audioStatus.textContent = message || ''; +} + +function renderAudioEraseButtons() { + const timelineLive = !!(timeline && timeline.playing); + const recording = !!activeAudioRecording; + audioEraseButtons.forEach((button) => { + const key = button.dataset.audioErase; + const hasTrack = !!audioTracks[key]; + const blocked = timelineLive || recording; + button.hidden = !hasTrack; + button.disabled = !hasTrack || blocked; + button.inert = !hasTrack || blocked; + const title = blocked + ? `Stop ${timelineLive ? 'Flipnote playback' : 'recording'} before erasing ${audioTrackLabel(key)}.` + : `Erase ${audioTrackLabel(key)}`; + button.title = title; + button.setAttribute('aria-label', title); + }); +} + +function renderAudioButtons() { + audioControlButtons.forEach((button) => { + const key = button.dataset.audioControl; + const image = button.querySelector('img'); + if (!image) return; + const timelineLive = !!(timeline && timeline.playing); + const liveSound = timelineLive && /^sound[123]$/.test(key); + const emptyLiveSound = liveSound && !audioTracks[key]; + const recording = activeAudioRecording && activeAudioRecording.key === key; + const previewPlaying = activeAudioPlayback && activeAudioPlayback.key === key; + const musicPlayingWithFlipnote = timelineLive && key === 'music' && !!audioTracks.music; + const playing = previewPlaying || musicPlayingWithFlipnote; + let src = button.dataset.recordSrc; + let title = `Record ${audioTrackLabel(key)}. Hold Shift to import an MP3 or WAV.`; + button.classList.toggle('is-recording', !!recording); + button.classList.toggle('is-playing', !!playing); + button.inert = timelineLive && key === 'music'; + button.classList.toggle('is-live-sfx', !!liveSound); + button.classList.toggle('is-live-empty', !!emptyLiveSound); + button.classList.toggle('is-import-mode', !timelineLive && audioShiftHeld && !recording && !playing); + + if (liveSound) { + if (audioTracks[key]) { + src = button.dataset.playSrc; + const number = key.replace('sound', ''); + title = `Play ${audioTrackLabel(key)} now and add it to the current page (${number}).`; + } else { + src = button.dataset.recordSrc; + title = `${audioTrackLabel(key)} has no clip yet. Stop playback to record or import one.`; + } + } else if (musicPlayingWithFlipnote) { + src = button.dataset.stopSrc || button.dataset.playSrc; + title = 'Music is playing with the Flipnote.'; + } else if (recording || previewPlaying) { + src = button.dataset.stopSrc; + title = recording ? `Stop recording ${audioTrackLabel(key)}` : `Stop ${audioTrackLabel(key)} playback`; + } else if (audioShiftHeld) { + src = (button.classList.contains('is-audio-hover') && button.dataset.importAltSrc) ? button.dataset.importAltSrc : button.dataset.importSrc; + title = `Import ${audioTrackLabel(key)} from MP3 or WAV`; + } else if (audioTracks[key]) { + src = button.dataset.playSrc; + title = `Play ${audioTrackLabel(key)}. Hold Shift to import a replacement MP3 or WAV.`; + } + if (src && image.getAttribute('src') !== src) image.src = src; + image.alt = audioTrackLabel(key); + button.title = title; + button.setAttribute('aria-label', title); + }); + renderAudioEraseButtons(); +} + +function updateAudioFillBars(recordingElapsed) { + ['music','sound1','sound2','sound3'].forEach((key) => { + const fill = document.querySelector(`[data-audio-fill="${key}"]`); + if (!fill) return; + const limit = Math.max(0.05, audioTrackLimitSeconds(key)); + let duration = audioTracks[key] ? Math.min(audioTrackEffectiveDuration(audioTracks[key], key), limit) : 0; + if (activeAudioRecording && activeAudioRecording.key === key && Number.isFinite(recordingElapsed)) { + duration = Math.min(limit, Math.max(0, recordingElapsed)); + } + const percent = Math.max(0, Math.min(100, (duration / limit) * 100)); + if (key === 'music') { + // musicBarEmpty.png is 1920x300. Its fill window is exactly + // x=24..1896 and y=24..276. The artwork is rotated 90deg in + // the UI, so source-x becomes the vertical progress axis: + // 1872 / 1920 = 97.5% usable height, inset 1.25% at each end. + fill.style.height = `${(percent * 0.975).toFixed(3)}%`; + } else { + // soundBarEmpry.png is 768x300. Its fill window is exactly + // x=24..744 and y=24..276: 720 / 768 = 93.75% usable width, + // inset 3.125% at each horizontal end. + fill.style.width = `${(percent * 0.9375).toFixed(3)}%`; + } + }); +} + +function stopAudioPlayback() { + if (!activeAudioPlayback) return; + const playing = activeAudioPlayback; + activeAudioPlayback = null; + if (playing.timer) clearTimeout(playing.timer); + if (playing.source) { + playing.source.onended = null; + try { playing.source.stop(); } catch (error) {} + try { playing.source.disconnect(); } catch (error) {} + } + if (playing.audio) { + try { playing.audio.pause(); } catch (error) {} + try { playing.audio.currentTime = 0; } catch (error) {} + } + renderAudioButtons(); +} + +function stopTrimPreview() { + if (!audioTrimState || !audioTrimState.previewSource) { + renderAudioTrimVolume(0); + return; + } + const source = audioTrimState.previewSource; + audioTrimState.previewSource = null; + if (audioTrimState.previewTimer) clearTimeout(audioTrimState.previewTimer); + audioTrimState.previewTimer = null; + try { source.stop(); } catch (error) {} + if (audioPreviewButton) audioPreviewButton.textContent = 'Preview clip'; + renderAudioTrimVolume(0); +} + +function stopAudioRecording() { + if (!activeAudioRecording) return; + const state = activeAudioRecording; + if (state.timer) clearTimeout(state.timer); + if (state.raf) cancelAnimationFrame(state.raf); + if (state.recorder && state.recorder.state !== 'inactive') { + try { state.recorder.stop(); } catch (error) {} + } +} + +function stopFlipnoteEditorAudio() { + stopAudioPlayback(); + stopTrimPreview(); + stopAudioRecording(); + stopFlipnoteTimelineAudio(); +} + +function setAudioTrack(key, blob, duration, sourceName, decodedBuffer, referenceFps, sourceKind = 'unknown') { + const candidate = { + blob, + duration: Math.max(0, Number(duration) || 0), + sourceName: sourceName || audioTrackLabel(key), + buffer: decodedBuffer || null, + referenceFps: Math.max(0.01, Number(referenceFps) || flipnoteTimelineFps()), + sourceKind: String(sourceKind || 'unknown'), + + }; + const projectedTracks = {...audioTracks, [key]: candidate}; + if (!editorDirtyTrackingSuspended && !flipnoteSpaceAllowsProjection(timeline, {audioTracks: projectedTracks})) { + notifyFlipnoteSpaceLimit(timeline); + setAudioStatus(`${audioTrackLabel(key)} was not added because the Flipnote would exceed 1,024 KB.`); + return false; + } + markEditorDirty(); + const old = audioTracks[key]; + if (old && old.url) URL.revokeObjectURL(old.url); + candidate.url = URL.createObjectURL(blob); + audioTracks[key] = candidate; + updateAudioFillBars(); + renderAudioButtons(); + renderFlipnoteSpaceMeter(timeline); + setAudioStatus(`${audioTrackLabel(key)} ready, ${formatAudioSeconds(duration)}.`); + return true; +} + +function clearSoundEffectTriggers(key) { + if (!/^sound[123]$/.test(key) || !timeline || !Array.isArray(timeline.frames)) return 0; + let cleared = 0; + timeline.frames.forEach((frame) => { + if (!frame) return; + frame.soundFlags = normalizeFrameSoundFlags(frame.soundFlags); + if (frame.soundFlags[key]) { + frame.soundFlags[key] = false; + cleared += 1; + } + }); + return cleared; +} + +function eraseAudioTrack(key) { + const track = audioTracks[key]; + if (!track) return; + if (timeline && timeline.playing) { + setAudioStatus('Stop Flipnote playback before erasing audio.'); + return; + } + if (activeAudioRecording) { + setAudioStatus('Stop recording before erasing audio.'); + return; + } + if (activeAudioPlayback && activeAudioPlayback.key === key) stopAudioPlayback(); + if (key === 'music') stopTimelineMusicPlayback(); + else stopTimelineSoundEffects(); + + markEditorDirty(); + audioTracks[key] = null; + if (track.url) URL.revokeObjectURL(track.url); + const cleared = clearSoundEffectTriggers(key); + updateAudioFillBars(); + renderAudioButtons(); + renderFlipnoteSpaceMeter(timeline); + setAudioStatus(cleared + ? `${audioTrackLabel(key)} erased, including ${cleared} page trigger${cleared === 1 ? '' : 's'}.` + : `${audioTrackLabel(key)} erased.`); +} + +async function ensureAudioTrackBuffer(key) { + const track = audioTracks[key]; + if (!track) return null; + if (!track.buffer) track.buffer = await decodeAudioBlob(track.blob); + return audioTracks[key] === track ? track.buffer : null; +} + +function stopTimelineMusicPlayback() { + timelineMusicGeneration += 1; + const playback = timelineMusicPlayback; + timelineMusicPlayback = null; + if (!playback) return; + if (playback.source) { + playback.source.onended = null; + try { playback.source.stop(); } catch (error) {} + try { playback.source.disconnect(); } catch (error) {} + } +} + +function stopTimelineSoundEffects() { + timelineSoundGeneration += 1; + timelineSoundSources.forEach((source) => { + source.onended = null; + try { source.stop(); } catch (error) {} + try { source.disconnect(); } catch (error) {} + }); + timelineSoundSources.clear(); +} + +function stopFlipnoteTimelineAudio() { + stopTimelineMusicPlayback(); + stopTimelineSoundEffects(); + renderAudioButtons(); +} + +async function restartFlipnoteTimelineMusic(frameIndex) { + stopTimelineMusicPlayback(); + const generation = timelineMusicGeneration; + const track = audioTracks.music; + if (!track || !timeline || !timeline.playing) { + renderAudioButtons(); + return; + } + try { + const context = getFlipnoteAudioContext(); + try { await context.resume(); } catch (error) {} + const buffer = await ensureAudioTrackBuffer('music'); + if (!buffer || !timeline.playing || audioTracks.music !== track || generation !== timelineMusicGeneration) return; + + const referenceFps = audioTrackReferenceFps(track); + const playbackRate = audioTrackPlaybackRate(track, 'music'); + const frame = Math.max(0, Math.min(timeline.frames.length - 1, Number(frameIndex) || 0)); + // Buffer offsets are measured in the track's original/reference-speed + // seconds, not today's wall-clock seconds. This keeps page N aligned + // to the same point in the music at every Flipnote speed. + const offset = frame / referenceFps; + const animationRemainingInBufferSeconds = Math.max(0.01, (timeline.frames.length - frame) / referenceFps); + const trackDuration = Math.min(track.duration || buffer.duration, buffer.duration); + if (offset >= trackDuration - 0.005) return; + const duration = Math.max(0.01, Math.min(trackDuration - offset, animationRemainingInBufferSeconds)); + const source = context.createBufferSource(); + source.buffer = buffer; + source.playbackRate.value = playbackRate; + connectAudioSourceToEditorOutput(source); + const playback = {source, track, playbackRate}; + timelineMusicPlayback = playback; + source.onended = () => { + if (timelineMusicPlayback === playback) { + timelineMusicPlayback = null; + renderAudioButtons(); + } + }; + source.start(0, offset, duration); + renderAudioButtons(); + } catch (error) { + stopTimelineMusicPlayback(); + setAudioStatus('Music playback failed.'); + renderAudioButtons(); + } +} + +async function playTimelineSoundEffect(key) { + const track = audioTracks[key]; + const generation = timelineSoundGeneration; + if (!track || !/^sound[123]$/.test(key)) return false; + try { + const context = getFlipnoteAudioContext(); + try { await context.resume(); } catch (error) {} + const buffer = await ensureAudioTrackBuffer(key); + if (!buffer || audioTracks[key] !== track || generation !== timelineSoundGeneration || !timeline || !timeline.playing) return false; + const duration = Math.max(0.01, Math.min(track.duration || buffer.duration, buffer.duration, FLIPNOTE_SOUND_EFFECT_LIMIT_SECONDS)); + const source = context.createBufferSource(); + source.buffer = buffer; + source.playbackRate.value = 1; + connectAudioSourceToEditorOutput(source); + timelineSoundSources.add(source); + source.onended = () => { + timelineSoundSources.delete(source); + try { source.disconnect(); } catch (error) {} + }; + source.start(0, 0, duration); + return true; + } catch (error) { + setAudioStatus(`${audioTrackLabel(key)} playback failed.`); + return false; + } +} + +function frameHasSound(frame, key) { + if (!frame) return false; + frame.soundFlags = normalizeFrameSoundFlags(frame.soundFlags); + return !!frame.soundFlags[key]; +} + +function stampSoundEffectOnFrame(frameIndex, key) { + if (!/^sound[123]$/.test(key)) return false; + const frame = timeline && timeline.getFrame(frameIndex); + if (!frame) return false; + frame.soundFlags = normalizeFrameSoundFlags(frame.soundFlags); + if (!frame.soundFlags[key]) { + markEditorDirty(); + frame.soundFlags[key] = true; + } + return true; +} + +function pulseAudioButton(key) { + const button = document.querySelector(`[data-audio-control="${key}"]`); + if (!button) return; + button.classList.remove('is-live-fired'); + void button.offsetWidth; + button.classList.add('is-live-fired'); + setTimeout(() => button.classList.remove('is-live-fired'), 170); +} + +async function triggerLiveSoundEffect(key) { + if (!timeline || !timeline.playing || !/^sound[123]$/.test(key)) return false; + if (!audioTracks[key]) { + setAudioStatus(`${audioTrackLabel(key)} has no clip. Stop playback to record or import it.`); + pulseAudioButton(key); + return false; + } + const frameIndex = timeline.currentFrame; + stampSoundEffectOnFrame(frameIndex, key); + pulseAudioButton(key); + const played = await playTimelineSoundEffect(key); + if (played) setAudioStatus(`${audioTrackLabel(key)} added to page ${frameIndex + 1}.`); + return played; +} + +function playStampedSoundEffectsForFrame(frameIndex) { + if (!timeline || !timeline.playing) return; + const frame = timeline.getFrame(frameIndex); + ['sound1', 'sound2', 'sound3'].forEach((key) => { + if (frameHasSound(frame, key) && audioTracks[key]) void playTimelineSoundEffect(key); + }); +} + +function startFlipnoteTimelineAudio(frameIndex) { + stopFlipnoteTimelineAudio(); + if (!timeline || !timeline.playing) return; + void restartFlipnoteTimelineMusic(frameIndex); + playStampedSoundEffectsForFrame(frameIndex); +} + +async function playAudioTrack(key) { + const track = audioTracks[key]; + if (!track) return; + stopAudioPlayback(); + stopTrimPreview(); + try { + const context = getFlipnoteAudioContext(); + try { await context.resume(); } catch (error) {} + if (!track.buffer) track.buffer = await decodeAudioBlob(track.blob); + if (audioTracks[key] !== track) return; + const allowedDuration = Math.max(0.02, Math.min(track.duration || track.buffer.duration, track.buffer.duration)); + const playbackRate = audioTrackPlaybackRate(track, key); + const source = context.createBufferSource(); + source.buffer = track.buffer; + source.playbackRate.value = playbackRate; + connectAudioSourceToEditorOutput(source); + const playback = { + key, + track, + source, + timer:null, + rawRemaining:allowedDuration, + rateChangedAt:context.currentTime, + playbackRate, + }; + activeAudioPlayback = playback; + source.onended = () => { + if (activeAudioPlayback === playback) stopAudioPlayback(); + }; + renderAudioButtons(); + source.start(0, 0, allowedDuration); + playback.timer = setTimeout(() => { + if (activeAudioPlayback === playback) stopAudioPlayback(); + }, Math.ceil((allowedDuration / playbackRate) * 1000) + 60); + setAudioStatus(`Playing ${audioTrackLabel(key)}.`); + } catch (error) { + stopAudioPlayback(); + setAudioStatus(`${audioTrackLabel(key)} playback failed.`); + alert('Editor could not play this audio clip.'); + } +} + +function syncActiveAudioPreviewPlaybackRate() { + const playback = activeAudioPlayback; + if (!playback || !playback.source || !playback.track) return; + try { + const context = getFlipnoteAudioContext(); + const now = context.currentTime; + const oldRate = Math.max(0.01, Number(playback.playbackRate) || 1); + const elapsed = Math.max(0, now - (Number(playback.rateChangedAt) || now)); + playback.rawRemaining = Math.max(0, (Number(playback.rawRemaining) || 0) - (elapsed * oldRate)); + const nextRate = audioTrackPlaybackRate(playback.track, playback.key); + playback.source.playbackRate.setValueAtTime(nextRate, now); + playback.playbackRate = nextRate; + playback.rateChangedAt = now; + if (playback.timer) clearTimeout(playback.timer); + if (playback.rawRemaining <= 0.001) { + stopAudioPlayback(); + return; + } + playback.timer = setTimeout(() => { + if (activeAudioPlayback === playback) stopAudioPlayback(); + }, Math.ceil((playback.rawRemaining / nextRate) * 1000) + 60); + } catch (error) { + // If a browser refuses a live AudioParam update, the next playback will + // still use the correct rate. Do not interrupt the editor for this. + } +} + +function chooseRecordingMimeType() { + if (!window.MediaRecorder || typeof MediaRecorder.isTypeSupported !== 'function') return ''; + const choices = ['audio/webm;codecs=opus','audio/ogg;codecs=opus','audio/webm','audio/ogg']; + return choices.find((type) => MediaRecorder.isTypeSupported(type)) || ''; +} + +async function decodeAudioBlob(blob) { + const context = getFlipnoteAudioContext(); + const bytes = await blob.arrayBuffer(); + return await context.decodeAudioData(bytes.slice(0)); +} + +function audioBufferSliceToWavBlob(buffer, startSeconds, endSeconds) { + const sampleRate = buffer.sampleRate; + const startFrame = Math.max(0, Math.min(buffer.length - 1, Math.floor(startSeconds * sampleRate))); + const endFrame = Math.max(startFrame + 1, Math.min(buffer.length, Math.ceil(endSeconds * sampleRate))); + const frames = endFrame - startFrame; + const channels = Math.max(1, Math.min(2, buffer.numberOfChannels)); + const bytesPerSample = 2; + const blockAlign = channels * bytesPerSample; + const dataBytes = frames * blockAlign; + const out = new ArrayBuffer(44 + dataBytes); + const view = new DataView(out); + const writeString = (offset, value) => { for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i)); }; + writeString(0, 'RIFF'); + view.setUint32(4, 36 + dataBytes, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, 16, true); + writeString(36, 'data'); + view.setUint32(40, dataBytes, true); + const channelData = []; + for (let channel = 0; channel < channels; channel++) channelData.push(buffer.getChannelData(channel)); + let offset = 44; + for (let frame = 0; frame < frames; frame++) { + for (let channel = 0; channel < channels; channel++) { + let sample = channelData[channel][startFrame + frame] || 0; + sample = Math.max(-1, Math.min(1, sample)); + view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true); + offset += 2; + } + } + return new Blob([out], {type:'audio/wav'}); +} + +async function startAudioRecording(key) { + if (activeAudioRecording) return; + stopAudioPlayback(); + stopTrimPreview(); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) { + alert('Audio recording is not supported by this browser.'); + return; + } + const limit = audioTrackLimitSeconds(key); + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,echoCancellation:false,noiseSuppression:false,autoGainControl:false}}); + } catch (error) { + // Some devices reject optional audio settings despite granting access. + // Ask for the default microphone before reporting a device failure. + if (error && (error.name === 'OverconstrainedError' || error.name === 'NotFoundError' || error.name === 'TypeError')) { + try { stream = await navigator.mediaDevices.getUserMedia({audio:true}); } + catch (fallbackError) { error = fallbackError; } + } + if (!stream) { + if (error && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError' || error.name === 'SecurityError')) { + alert('Microphone access is blocked. Allow it for Editor in your browser and device settings, then try again.'); + } else if (error && (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError')) { + alert('No microphone was found. Connect or enable one, then try again.'); + } else if (error && (error.name === 'NotReadableError' || error.name === 'TrackStartError')) { + alert('The microphone is allowed, but could not be started. Check that it is not in use by another app, then try again.'); + } else { + alert('The microphone could not be started. Check your browser and device audio settings, then try again.'); + } + return; + } + } + let recorder; + try { + const mimeType = chooseRecordingMimeType(); + recorder = mimeType ? new MediaRecorder(stream, {mimeType}) : new MediaRecorder(stream); + } catch (error) { + stream.getTracks().forEach((track) => track.stop()); + alert('Editor could not start the microphone recorder in this browser.'); + return; + } + const state = {key, recorder, stream, chunks:[], startedAt:performance.now(), limit, referenceFps:flipnoteTimelineFps(), timer:null, raf:null, volumeSource:null, volumeAnalyser:null, volumeSamples:null}; + activeAudioRecording = state; + try { + const context = getFlipnoteAudioContext(); + try { await context.resume(); } catch (error) {} + state.volumeAnalyser = context.createAnalyser(); + state.volumeAnalyser.fftSize = 256; + state.volumeAnalyser.smoothingTimeConstant = 0; + state.volumeSamples = new Float32Array(state.volumeAnalyser.fftSize); + state.volumeSource = context.createMediaStreamSource(stream); + // Deliberately do not connect the microphone analyser to destination: it + // drives the meter only, avoiding microphone feedback while recording. + state.volumeSource.connect(state.volumeAnalyser); + startAudioVolumeMeter(); + } catch (error) { + state.volumeSource = null; + state.volumeAnalyser = null; + state.volumeSamples = null; + } + recorder.addEventListener('dataavailable', (event) => { if (event.data && event.data.size) state.chunks.push(event.data); }); + recorder.addEventListener('stop', async () => { + if (state.timer) clearTimeout(state.timer); + if (state.raf) cancelAnimationFrame(state.raf); + if (state.volumeSource) { try { state.volumeSource.disconnect(); } catch (error) {} } + if (state.volumeAnalyser) { try { state.volumeAnalyser.disconnect(); } catch (error) {} } + state.stream.getTracks().forEach((track) => track.stop()); + if (activeAudioRecording === state) activeAudioRecording = null; + renderAudioButtons(); + const blob = new Blob(state.chunks, {type: recorder.mimeType || 'audio/webm'}); + if (!blob.size) { updateAudioFillBars(); return; } + try { + const buffer = await decodeAudioBlob(blob); + const duration = Math.max(0.01, Math.min(buffer.duration, state.limit)); + const wav = audioBufferSliceToWavBlob(buffer, 0, duration); + setAudioTrack(key, wav, duration, 'Microphone recording', buffer, state.referenceFps, 'microphone'); + } catch (error) { + updateAudioFillBars(); + alert('The recording finished, but Editor could not decode the recorded audio.'); + } + }, {once:true}); + recorder.start(100); + renderAudioButtons(); + setAudioStatus(`Recording ${audioTrackLabel(key)}.`); + const tick = () => { + if (activeAudioRecording !== state) return; + const elapsed = Math.min(state.limit, (performance.now() - state.startedAt) / 1000); + updateAudioFillBars(elapsed); + state.raf = requestAnimationFrame(tick); + }; + state.raf = requestAnimationFrame(tick); + state.timer = setTimeout(() => stopAudioRecording(), Math.ceil(limit * 1000)); +} + +function drawAudioWaveform() { + if (!audioTrimState || !audioWaveform) return; + const ctx = audioWaveform.getContext('2d'); + const width = audioWaveform.width; + const height = audioWaveform.height; + const buffer = audioTrimState.buffer; + const data = buffer.getChannelData(0); + const styles = getComputedStyle(document.documentElement); + const isLight = document.documentElement.classList.contains('theme-light'); + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = isLight ? '#f3eef4' : '#0d0c0f'; + ctx.fillRect(0, 0, width, height); + const startX = Math.round((audioTrimState.start / buffer.duration) * width); + const endX = Math.round((audioTrimState.end / buffer.duration) * width); + ctx.fillStyle = isLight ? 'rgba(174,48,31,.13)' : 'rgba(174,48,31,.22)'; + ctx.fillRect(startX, 0, Math.max(1, endX - startX), height); + const step = Math.max(1, Math.floor(data.length / width)); + const amp = height * 0.44; + ctx.strokeStyle = isLight ? '#5b4f5e' : '#c9becd'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < width; x++) { + let min = 1, max = -1; + const offset = x * step; + for (let i = 0; i < step && offset + i < data.length; i++) { + const value = data[offset + i]; + if (value < min) min = value; + if (value > max) max = value; + } + ctx.moveTo(x + .5, (1 + min) * amp); + ctx.lineTo(x + .5, (1 + max) * amp); + } + ctx.stroke(); + ctx.strokeStyle = '#AE301F'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(startX, 0); ctx.lineTo(startX, height); ctx.moveTo(endX, 0); ctx.lineTo(endX, height); ctx.stroke(); +} + +function autoMusicFrameLimitFrames() { + return FLIPNOTE_AUTO_MUSIC_MAX_FRAMES; +} + +function autoMusicFrameLimitSeconds() { + const fps = Math.max(0.01, Number(timeline && timeline.framerate) || 12); + return Math.min(FLIPNOTE_MUSIC_MAX_SECONDS, autoMusicFrameLimitFrames() / fps); +} + +function musicAutoFramesEnabled() { + return !!(audioTrimState && audioTrimState.key === 'music' && audioAutoFramesToggle && audioAutoFramesToggle.checked); +} + +function updateAudioAutoFramesSummary() { + if (!audioAutoFramesOption || !audioAutoFramesDetail) return; + const isMusic = !!audioTrimState && audioTrimState.key === 'music'; + audioAutoFramesOption.hidden = !isMusic; + if (!isMusic) return; + + const duration = Math.max(0, audioTrimState.end - audioTrimState.start); + const fps = Math.max(0.01, Number(timeline && timeline.framerate) || 12); + const currentFrames = Math.max(1, timeline && timeline.frames ? timeline.frames.length : 1); + const requiredFrames = Math.max(1, Math.ceil((duration * fps) - 1e-9)); + const targetFrames = Math.min(autoMusicFrameLimitFrames(), Math.max(currentFrames, requiredFrames)); + const pagesToAdd = Math.max(0, targetFrames - currentFrames); + + if (audioAutoFramesToggle && audioAutoFramesToggle.checked) { + audioAutoFramesDetail.textContent = pagesToAdd + ? `Adds ${pagesToAdd} blank page${pagesToAdd === 1 ? '' : 's'} (${currentFrames} → ${targetFrames}) at Speed ${timeline.speedLevel} · ${speedSetting(timeline.speedLevel).fps} FPS.` + : `No pages needed — ${currentFrames} pages already cover this clip at Speed ${timeline.speedLevel}.`; + } else { + audioAutoFramesDetail.textContent = `Off: music stays limited to the current ${currentFrames}-page Flipnote. Turn this on to use a longer clip and extend up to ${autoMusicFrameLimitFrames()} pages.`; + } +} + +function updateAudioTrimControls(changed) { + if (!audioTrimState) return; + const duration = audioTrimState.buffer.duration; + const minimum = Math.min(0.01, duration); + let start = Math.max(0, Math.min(duration - minimum, Number(audioStartRange.value) || 0)); + let end = Math.max(start + minimum, Math.min(duration, Number(audioEndRange.value) || duration)); + const maxClip = Math.min(audioTrimState.maxClip, duration); + if (end - start > maxClip) { + if (changed === 'start') end = Math.min(duration, start + maxClip); + else start = Math.max(0, end - maxClip); + } + if (end <= start) end = Math.min(duration, start + minimum); + audioTrimState.start = start; + audioTrimState.end = end; + audioStartRange.value = String(start); + audioEndRange.value = String(end); + if (audioStartValue) audioStartValue.textContent = formatAudioSeconds(start); + if (audioEndValue) audioEndValue.textContent = formatAudioSeconds(end); + if (audioSelectionDuration) audioSelectionDuration.textContent = formatAudioSeconds(end - start); + updateAudioAutoFramesSummary(); + stopTrimPreview(); + drawAudioWaveform(); +} + +function toggleAudioAutoFrames() { + if (!audioTrimState || audioTrimState.key !== 'music') return; + const previousMax = audioTrimState.maxClip; + const selectedDuration = Math.max(0, audioTrimState.end - audioTrimState.start); + const wasAtLimit = Math.abs(selectedDuration - previousMax) < 0.025; + const nextMax = musicAutoFramesEnabled() + ? Math.min(audioTrimState.buffer.duration, autoMusicFrameLimitSeconds()) + : Math.min(audioTrimState.buffer.duration, audioTrackLimitSeconds('music')); + + audioTrimState.maxClip = nextMax; + if (wasAtLimit && nextMax > previousMax) { + audioTrimState.end = Math.min(audioTrimState.buffer.duration, audioTrimState.start + nextMax); + audioEndRange.value = String(audioTrimState.end); + } + updateAudioTrimControls(audioAutoFramesToggle && audioAutoFramesToggle.checked ? 'end' : 'start'); +} + +function extendTimelineToAudioDuration(duration) { + if (!timeline || !timeline.frames || !timeline.frames.length) return 0; + const fps = Math.max(0.01, Number(timeline.framerate) || 12); + const requiredFrames = Math.min(autoMusicFrameLimitFrames(), Math.max(1, Math.ceil((Math.max(0, duration) * fps) - 1e-9))); + const pagesToAdd = Math.max(0, requiredFrames - timeline.frames.length); + if (!pagesToAdd) return 0; + + let added = 0; + for (let i = 0; i < pagesToAdd; i++) { + if (timeline.frames.length >= FLIPNOTE_EDITOR_MAX_PAGES) { + notifyFlipnotePageLimit(timeline); + break; + } + const previous = timeline.frames[timeline.frames.length - 1]; + const frame = new Frame({paperColor: previous ? previous.paperColor : 'white'}); + if (!flipnoteSpaceCanAddFrame(timeline, frame)) { + notifyFlipnoteSpaceLimit(timeline); + break; + } + // Seed the blank page's undo baseline without repainting the history UI + // hundreds of times during a long music import. + frame.undoBuffer = [frameSnapshot(frame)]; + frame.undoPointer = 0; + timeline.frames.push(frame); + flipnoteSpaceRegisterFrame(frame); + added += 1; + } + timeline.createThumbnails(); + document.getElementById('frame-counter').textContent = `${timeline.currentFrame + 1} / ${timeline.frames.length}`; + updateAudioFillBars(); + renderFlipnoteSpaceMeter(timeline); + return added; +} + + + + + + +function audioImportTargetLabel() { + return audioTrackLabel(audioImportTarget || 'music'); +} + +function setAudioImportControlVisible(control, visible) { + if (!control) return; + control.hidden = !visible; + // Editor's shared .button rule sets display explicitly, which can beat + // the browser's default [hidden] style. Guard each footer action inline so + // page 1 only shows Cancel, and the trim page shows its own actions. + control.style.display = visible ? '' : 'none'; +} + +function showAudioImportStep(step) { + const source = step === 'source'; + if (audioImportSourceStep) audioImportSourceStep.hidden = !source; + if (audioTrimStep) audioTrimStep.hidden = source; + setAudioImportControlVisible(audioTrimBack, !source); + setAudioImportControlVisible(audioPreviewButton, !source); + setAudioImportControlVisible(audioTrimSave, !source); + if (audioTrimTitle) { + audioTrimTitle.textContent = source + ? `Import ${audioImportTargetLabel()}` + : (audioImportTarget === 'music' ? 'Trim music to the Flipnote' : `Trim ${audioImportTargetLabel()}`); + } +} + +function openAudioImportModal(key) { + if (!audioTrimModal || !key) return; + stopAudioPlayback(); + stopTrimPreview(); + + audioImportTarget = key; + audioTrimState = null; + if (audioFileInput) audioFileInput.value = ''; + if (audioAutoFramesToggle) audioAutoFramesToggle.checked = false; + if (audioImportSourceCopy) audioImportSourceCopy.hidden = false; + if (audioImportSourceOptions) audioImportSourceOptions.hidden = false; + + + + + + + + + + + + showAudioImportStep('source'); + audioTrimModal.hidden = false; + document.body.classList.add('flipnote-audio-trim-open'); + setTimeout(() => audioUploadOption?.focus(), 0); +} + + + + + + + + + + + + + + + + + +function backToAudioImportSource() { + stopTrimPreview(); renderAudioTrimVolume(0); audioTrimState = null; + showAudioImportStep('source'); audioUploadOption?.focus(); +} + +async function openAudioTrimModal(key, file, buffer, sourceKind = 'file_import') { + if (!audioTrimModal || !buffer || !buffer.duration) return; + stopAudioPlayback(); + + audioImportTarget = key; + if (audioAutoFramesToggle) audioAutoFramesToggle.checked = false; + const maxClip = Math.min(buffer.duration, audioTrackLimitSeconds(key)); + audioTrimState = {key, file, buffer, sourceKind:String(sourceKind || 'file_import'), start:0, end:maxClip, maxClip, previewSource:null, previewTimer:null}; + if (audioTrimFilename) audioTrimFilename.textContent = file.name || `${audioTrackLabel(key)} audio`; + if (audioTrimLimit) audioTrimLimit.textContent = key === 'music' ? `Flipnote length ${formatAudioSeconds(audioTrackLimitSeconds(key))}` : `Sound clip limit ${formatAudioSeconds(FLIPNOTE_SOUND_EFFECT_LIMIT_SECONDS)}`; + audioStartRange.min = '0'; audioStartRange.max = String(buffer.duration); audioStartRange.step = '0.01'; audioStartRange.value = '0'; + audioEndRange.min = '0'; audioEndRange.max = String(buffer.duration); audioEndRange.step = '0.01'; audioEndRange.value = String(maxClip); + audioTrimModal.hidden = false; + renderAudioTrimVolume(0); + document.body.classList.add('flipnote-audio-trim-open'); + showAudioImportStep('trim'); + updateAudioTrimControls('end'); + setTimeout(() => audioStartRange && audioStartRange.focus(), 0); +} + +function closeAudioTrimModal() { + stopTrimPreview(); + + if (audioTrimModal) audioTrimModal.hidden = true; + document.body.classList.remove('flipnote-audio-trim-open'); + renderAudioTrimVolume(0); + audioTrimState = null; + audioImportTarget = null; + if (audioFileInput) audioFileInput.value = ''; +} + +async function previewAudioTrim() { + if (!audioTrimState) return; + if (audioTrimState.previewSource) { stopTrimPreview(); return; } + const context = getFlipnoteAudioContext(); + try { await context.resume(); } catch (error) {} + const source = context.createBufferSource(); + source.buffer = audioTrimState.buffer; + connectAudioSourceToEditorOutput(source); + const duration = Math.max(0.01, audioTrimState.end - audioTrimState.start); + audioTrimState.previewSource = source; + if (audioPreviewButton) audioPreviewButton.textContent = 'Stop preview'; + source.onended = () => { if (audioTrimState && audioTrimState.previewSource === source) stopTrimPreview(); }; + source.start(0, audioTrimState.start, duration); + audioTrimState.previewTimer = setTimeout(stopTrimPreview, Math.ceil(duration * 1000) + 60); +} + +async function saveAudioTrim() { + if (!audioTrimState) return; + const {key, file, buffer, sourceKind, start, end} = audioTrimState; + const autoExtendFrames = key === 'music' && musicAutoFramesEnabled(); + const blob = audioBufferSliceToWavBlob(buffer, start, end); + const duration = end - start; + closeAudioTrimModal(); + // Decode the trimmed WAV on first playback so a non-zero trim start uses + // the selected segment rather than the original full source buffer. + const referenceFps = flipnoteTimelineFps(); + + const trackAdded = setAudioTrack(key, blob, duration, file.name || 'Imported audio', null, referenceFps, sourceKind || 'file_import'); + if (!trackAdded) return; + if (autoExtendFrames) { + const added = extendTimelineToAudioDuration(duration); + if (added) setAudioStatus(`Music ready, ${formatAudioSeconds(duration)}. Added ${added} blank page${added === 1 ? '' : 's'} to match Speed ${timeline.speedLevel}.`); + } +} + +async function importAudioFile(key, file) { + if (!file) return; + const lowerName = String(file.name || '').toLowerCase(); + if (!(lowerName.endsWith('.mp3') || lowerName.endsWith('.wav'))) { + alert('Flipnote Editor audio imports must be MP3 or WAV files.'); + return; + } + if (file.size > 50 * 1024 * 1024) { + alert('That audio file is too large. Please use an MP3 or WAV under 50 MB.'); + return; + } + setAudioStatus(`Reading ${file.name}...`); + try { + const buffer = await decodeAudioBlob(file); + await openAudioTrimModal(key, file, buffer, 'file_import'); + } catch (error) { + alert('Editor could not decode that MP3/WAV file.'); + setAudioStatus('Audio import failed.'); + } +} + +if (audioStartRange) audioStartRange.addEventListener('input', () => updateAudioTrimControls('start')); +if (audioEndRange) audioEndRange.addEventListener('input', () => updateAudioTrimControls('end')); +if (audioAutoFramesToggle) audioAutoFramesToggle.addEventListener('change', toggleAudioAutoFrames); +if (audioPreviewButton) audioPreviewButton.addEventListener('click', previewAudioTrim); +if (audioTrimSave) audioTrimSave.addEventListener('click', saveAudioTrim); +if (audioTrimBack) audioTrimBack.addEventListener('click', backToAudioImportSource); +if (audioTrimCancel) audioTrimCancel.addEventListener('click', closeAudioTrimModal); +if (audioTrimClose) audioTrimClose.addEventListener('click', closeAudioTrimModal); +if (audioTrimModal) audioTrimModal.addEventListener('pointerdown', (event) => { if (event.target === audioTrimModal) closeAudioTrimModal(); }); + + + + + + +const audioUploadOption = document.getElementById('flipnote-audio-import-upload'); +if (audioUploadOption) audioUploadOption.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + if (!audioFileInput) return; + try { + if (typeof audioFileInput.showPicker === 'function') audioFileInput.showPicker(); + else audioFileInput.click(); + } catch (error) { + audioFileInput.click(); + } +}); +if (audioFileInput) audioFileInput.addEventListener('change', () => { + const file = audioFileInput.files && audioFileInput.files[0]; + const key = audioImportTarget; + audioFileInput.value = ''; + if (key && file) importAudioFile(key, file); +}); + +audioEraseButtons.forEach((button) => { + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const key = button.dataset.audioErase; + if (key) eraseAudioTrack(key); + }); +}); + +audioControlButtons.forEach((button) => { + button.addEventListener('pointerenter', () => { button.classList.add('is-audio-hover'); renderAudioButtons(); }); + button.addEventListener('pointerleave', () => { button.classList.remove('is-audio-hover'); renderAudioButtons(); }); + button.addEventListener('focus', () => { button.classList.add('is-audio-hover'); renderAudioButtons(); }); + button.addEventListener('blur', () => { button.classList.remove('is-audio-hover'); renderAudioButtons(); }); + button.addEventListener('click', async (event) => { + const key = button.dataset.audioControl; + if (timeline.playing) { + event.preventDefault(); + if (/^sound[123]$/.test(key)) await triggerLiveSoundEffect(key); + return; + } + if (activeAudioRecording && activeAudioRecording.key === key) { stopAudioRecording(); return; } + if (activeAudioPlayback && activeAudioPlayback.key === key) { stopAudioPlayback(); return; } + if (editorAlternateActive(event) || audioShiftHeld) { + stopAudioPlayback(); + openAudioImportModal(key); + return; + } + if (audioTracks[key]) await playAudioTrack(key); + else await startAudioRecording(key); + }); +}); + +// Shift-click is not available on phones, so a long-press opens the same +// audio-import flow. Normal taps still record/play exactly as before. +audioControlButtons.forEach((button) => { + installFlipnoteTouchLongPress(button, () => { + if (timeline.playing) return; + const key = button.dataset.audioControl; + if (!key) return; + stopAudioPlayback(); + openAudioImportModal(key); + }); +}); + +document.addEventListener('keydown', (event) => { + if (editorAlternateKeyEvent(event) && !audioShiftHeld) { audioShiftHeld = true; renderAudioButtons(); } + if (shortcutAction(event, 'dialog') === 'closeDialog' && audioTrimModal && !audioTrimModal.hidden) { event.preventDefault(); closeAudioTrimModal(); } + const liveSound = shortcutAction(event, 'playing'); + if (timeline.playing && !event.repeat && /^sound[123]$/.test(liveSound)) { + const target = event.target instanceof Element ? event.target : null; + const typingText = !!(target && target.matches('input, textarea, select, [contenteditable="true"]')); + if (!typingText && (!audioTrimModal || audioTrimModal.hidden) && (!editorInfoModal || editorInfoModal.hidden) && (!imageImportModal || imageImportModal.hidden)) { + event.preventDefault(); + void triggerLiveSoundEffect(liveSound); + } + } +}); +document.addEventListener('keyup', (event) => { + if (editorAlternateReleaseEvent(event) && audioShiftHeld) { audioShiftHeld = false; renderAudioButtons(); } +}); +window.addEventListener('blur', () => { if (audioShiftHeld) { audioShiftHeld = false; renderAudioButtons(); } }); +window.addEventListener('beforeunload', (event) => { + if (!editorHasUnsavedChanges) return; + event.preventDefault(); + // Modern browsers intentionally ignore custom beforeunload text and show + // their own standard confirmation message. Assigning returnValue is still + // required for compatibility with browsers that implement the legacy API. + event.returnValue = ''; +}); +window.addEventListener('pagehide', () => { + Object.values(audioTracks).forEach((track) => { if (track && track.url) URL.revokeObjectURL(track.url); }); + +}); +renderAudioButtons(); +updateAudioFillBars(); +renderFlipnoteSpaceMeter(timeline); +window.flipnoteEditorAudioReady = true; + +// Flipnote Editor information / shortcut reference. +const editorInfoButton = document.getElementById('flipnote-editor-info-button'); +const editorInfoModal = document.getElementById('flipnote-editor-info-modal'); +const editorInfoClose = document.getElementById('flipnote-editor-info-close'); +const editorInfoDone = document.getElementById('flipnote-editor-info-done'); +let editorInfoLastFocus = null; + +function openEditorInfoModal() { + if (!editorInfoModal) return; + editorInfoLastFocus = document.activeElement; + editorInfoModal.hidden = false; + document.body.classList.add('flipnote-editor-info-open'); + if (editorInfoClose) editorInfoClose.focus(); +} + +function closeEditorInfoModal() { + if (!editorInfoModal || editorInfoModal.hidden) return; + recordingShortcut = ''; + renderEditorShortcuts(); + editorInfoModal.hidden = true; + document.body.classList.remove('flipnote-editor-info-open'); + if (editorInfoLastFocus && typeof editorInfoLastFocus.focus === 'function') editorInfoLastFocus.focus(); + editorInfoLastFocus = null; +} + +if (editorInfoButton) editorInfoButton.addEventListener('click', openEditorInfoModal); +if (editorInfoClose) editorInfoClose.addEventListener('click', closeEditorInfoModal); +if (editorInfoDone) editorInfoDone.addEventListener('click', closeEditorInfoModal); +if (editorInfoModal) editorInfoModal.addEventListener('pointerdown', (event) => { + if (event.target === editorInfoModal) closeEditorInfoModal(); +}); +document.addEventListener('keydown', (event) => { + if (shortcutAction(event, 'dialog') === 'closeDialog' && editorInfoModal && !editorInfoModal.hidden) { + event.preventDefault(); + closeEditorInfoModal(); + } +}); + +const editorSettingsStorageKey = 'flipnote-editor.flipnote-editor.canvas-settings.v1'; +const canvasSettings = {grid: false, zoom: 100, scrollZoom: false}; +try { + const saved = JSON.parse(localStorage.getItem(editorSettingsStorageKey) || '{}'); + if (saved && typeof saved === 'object') { + canvasSettings.grid = saved.grid === true; + canvasSettings.zoom = Math.max(50, Math.min(800, Math.round(Number(saved.zoom)) || 100)); + canvasSettings.scrollZoom = saved.scrollZoom === true; + } +} catch (error) {} +const editorCanvasWrap = document.querySelector('.flipnote-editor-canvas-wrap'); +const editorCanvasSurface = document.querySelector('.flipnote-editor-canvas-surface'); +const editorCanvasGrid = document.getElementById('flipnote-editor-canvas-grid'); +const editorGridToggle = document.getElementById('flipnote-editor-show-grid'); +const editorZoomInput = document.getElementById('flipnote-editor-zoom'); +const editorZoomValue = document.getElementById('flipnote-editor-zoom-value'); +const editorScrollZoomToggle = document.getElementById('flipnote-editor-scroll-zoom'); +if (editorCanvasGrid) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', Array.from({length: 255}, (_, i) => `M${i + 1} 0V192`).join('') + Array.from({length: 191}, (_, i) => `M0 ${i + 1}H256`).join('')); + path.setAttribute('vector-effect', 'non-scaling-stroke'); + editorCanvasGrid.replaceChildren(path); +} +let editorCanvasPanX = 0; +let editorCanvasPanY = 0; +let editorCanvasBaseWidth = 0; +let editorCanvasBaseHeight = 0; +let editorCanvasDrag = null; +let editorCanvasWheelZoom = canvasSettings.zoom; +function saveCanvasSettings() { + try { localStorage.setItem(editorSettingsStorageKey, JSON.stringify(canvasSettings)); } catch (error) {} +} +function renderCanvasSettings() { + if (editorGridToggle) { + editorGridToggle.checked = canvasSettings.grid; + editorGridToggle.disabled = canvasSettings.zoom < 400; + editorGridToggle.title = editorGridToggle.disabled ? 'Zoom to at least 400% to show the pixel grid' : ''; + } + if (editorZoomInput) editorZoomInput.value = String(canvasSettings.zoom); + if (editorZoomValue) editorZoomValue.textContent = `${canvasSettings.zoom}%`; + if (editorScrollZoomToggle) editorScrollZoomToggle.checked = canvasSettings.scrollZoom; + if (editorCanvasGrid) editorCanvasGrid.toggleAttribute('hidden', !canvasSettings.grid || canvasSettings.zoom < 400); + if (editorCanvasWrap && editorCanvasSurface) { + const style = getComputedStyle(editorCanvasWrap); + const available = editorCanvasWrap.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + editorCanvasBaseWidth = Math.max(1, Math.min(616, available)); + editorCanvasBaseHeight = editorCanvasBaseWidth * 192 / 256; + const scale = canvasSettings.zoom / 100; + const maxPanX = Math.max(0, (editorCanvasBaseWidth * scale - available) / 2); + const maxPanY = Math.max(0, (editorCanvasBaseHeight * scale - editorCanvasBaseHeight) / 2); + editorCanvasPanX = Math.max(-maxPanX, Math.min(maxPanX, editorCanvasPanX)); + editorCanvasPanY = Math.max(-maxPanY, Math.min(maxPanY, editorCanvasPanY)); + editorCanvasSurface.style.width = `${editorCanvasBaseWidth}px`; + editorCanvasSurface.style.transform = `translate(${editorCanvasPanX}px, ${editorCanvasPanY}px) scale(${scale})`; + if (editorCanvasGrid) editorCanvasGrid.style.strokeWidth = `${1 / scale}px`; + } +} +if (editorGridToggle) editorGridToggle.addEventListener('change', () => { + canvasSettings.grid = editorGridToggle.checked; saveCanvasSettings(); renderCanvasSettings(); +}); +if (editorZoomInput) editorZoomInput.addEventListener('input', () => { + canvasSettings.zoom = Number(editorZoomInput.value); editorCanvasWheelZoom = canvasSettings.zoom; saveCanvasSettings(); renderCanvasSettings(); +}); +document.getElementById('flipnote-editor-reset-view')?.addEventListener('click', () => { + canvasSettings.zoom = editorCanvasWheelZoom = 100; editorCanvasPanX = editorCanvasPanY = 0; + saveCanvasSettings(); renderCanvasSettings(); +}); +if (editorScrollZoomToggle) editorScrollZoomToggle.addEventListener('change', () => { + canvasSettings.scrollZoom = editorScrollZoomToggle.checked; saveCanvasSettings(); renderCanvasSettings(); +}); +if (editorCanvasWrap) editorCanvasWrap.addEventListener('wheel', (event) => { + if (!event.target.closest('.flipnote-editor-canvas-surface')) return; + if (canvasSettings.scrollZoom && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + const units = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? editorCanvasBaseHeight : 1; + const delta = Math.max(-100, Math.min(100, event.deltaY * units)); + editorCanvasWheelZoom = Math.max(50, Math.min(800, editorCanvasWheelZoom * Math.exp(-delta * .002))); + const next = Math.round(editorCanvasWheelZoom); + if (next === canvasSettings.zoom) return; + const style = getComputedStyle(editorCanvasWrap); + const rect = editorCanvasWrap.getBoundingClientRect(); + const contentWidth = editorCanvasWrap.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + const cursorX = event.clientX - (rect.left + parseFloat(style.paddingLeft) + contentWidth / 2); + const cursorY = event.clientY - (rect.top + parseFloat(style.paddingTop) + editorCanvasBaseHeight / 2); + const ratio = next / canvasSettings.zoom; + editorCanvasPanX = cursorX - ratio * (cursorX - editorCanvasPanX); + editorCanvasPanY = cursorY - ratio * (cursorY - editorCanvasPanY); + canvasSettings.zoom = next; + saveCanvasSettings(); + renderCanvasSettings(); + } else if (!event.ctrlKey && !event.metaKey && canvasSettings.zoom > 100) { + event.preventDefault(); + editorCanvasPanX -= event.deltaX || (event.shiftKey ? event.deltaY : 0); + editorCanvasPanY -= event.shiftKey ? 0 : event.deltaY; + renderCanvasSettings(); + } +}, {passive: false}); +if (editorCanvasWrap) { + editorCanvasWrap.addEventListener('pointerdown', (event) => { + if (event.button !== 1 || canvasSettings.zoom <= 100) return; + event.preventDefault(); + editorCanvasDrag = {pointerId: event.pointerId, x: event.clientX, y: event.clientY, panX: editorCanvasPanX, panY: editorCanvasPanY}; + editorCanvasWrap.setPointerCapture(event.pointerId); + }); + editorCanvasWrap.addEventListener('pointermove', (event) => { + if (!editorCanvasDrag || event.pointerId !== editorCanvasDrag.pointerId) return; + editorCanvasPanX = editorCanvasDrag.panX + event.clientX - editorCanvasDrag.x; + editorCanvasPanY = editorCanvasDrag.panY + event.clientY - editorCanvasDrag.y; + renderCanvasSettings(); + }); + for (const name of ['pointerup', 'pointercancel', 'lostpointercapture']) { + editorCanvasWrap.addEventListener(name, (event) => { + if (editorCanvasDrag && event.pointerId === editorCanvasDrag.pointerId) editorCanvasDrag = null; + }); + } + editorCanvasWrap.addEventListener('auxclick', (event) => { + if (event.button === 1 && canvasSettings.zoom > 100) event.preventDefault(); + }); +} +if (editorCanvasWrap && typeof ResizeObserver !== 'undefined') new ResizeObserver(renderCanvasSettings).observe(editorCanvasWrap); +window.addEventListener('resize', renderCanvasSettings); +renderCanvasSettings(); + +const editorShortcutSettings = document.getElementById('flipnote-editor-shortcut-settings'); +const editorShortcutMessage = document.getElementById('flipnote-editor-shortcut-message'); +const editorResetShortcuts = document.getElementById('flipnote-editor-reset-shortcuts'); +let recordingShortcut = ''; +function shortcutLabel(key) { + return key.replaceAll('ArrowLeft', '←').replaceAll('ArrowRight', '→').replaceAll('ArrowUp', '↑').replaceAll('ArrowDown', '↓'); +} +function renderEditorShortcuts() { + if (!editorShortcutSettings) return; + editorShortcutSettings.replaceChildren(); + for (const [id, label] of EDITOR_SHORTCUTS) { + const row = document.createElement('div'); + row.className = 'flipnote-editor-shortcut-setting'; + const name = document.createElement('span'); + name.textContent = label; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'flipnote-editor-shortcut-capture'; + button.dataset.shortcutId = id; + button.textContent = recordingShortcut === id ? 'Press keys…' : shortcutLabel(editorShortcutBindings[id]); + button.setAttribute('aria-label', `${label}: ${button.textContent}. Click to change`); + button.setAttribute('aria-pressed', recordingShortcut === id ? 'true' : 'false'); + const reset = document.createElement('button'); + reset.type = 'button'; + reset.className = 'flipnote-editor-shortcut-reset'; + reset.textContent = 'Reset'; + reset.setAttribute('aria-label', `Reset ${label} shortcut`); + reset.addEventListener('click', () => { + recordingShortcut = ''; + editorShortcutBindings[id] = editorShortcutDefaults[id]; + saveEditorShortcuts(); renderEditorShortcuts(); + if (editorShortcutMessage) editorShortcutMessage.textContent = `${label} reset.`; + }); + button.addEventListener('click', () => { + recordingShortcut = id; + if (editorShortcutMessage) editorShortcutMessage.textContent = `Press a key or key combination for ${label}. Escape cancels.`; + renderEditorShortcuts(); + editorShortcutSettings.querySelector(`[data-shortcut-id="${id}"]`)?.focus(); + }); + row.append(name, button, reset); + editorShortcutSettings.append(row); + } + for (const [selector, label, id] of [ + ['#undoButton', 'Undo', 'undo'], ['#redoButton', 'Redo', 'redo'], + ['#eraseBigButton', 'Clear active layer', 'clear'], + ['[data-tool-family-picker="pen"]', 'Draw', 'draw'], + ['[data-tool-family-picker="paint"]', 'Paint', 'paint'], + ['[data-tool-family-picker="fill"]', 'Fill', 'fill'], + ['[data-tool-family-picker="eraser"]', 'Erase', 'erase'], + ]) { + const control = document.querySelector(selector); + if (control) control.title = `${label} (${shortcutLabel(editorShortcutBindings[id])})`; + } +} +function shortcutContextsOverlap(first, second) { + if (first === 'global' || second === 'global' || first === 'modifier' || second === 'modifier') return true; + if (first === 'dialog' || second === 'dialog') return first === second; + if (first === 'title' || second === 'title') return first === second; + if (first === 'speed' || second === 'speed') return first === second; + if (first === 'playing' || second === 'playing') return first === second; + return true; // Frame navigation and drawing shortcuts are active together. +} +document.addEventListener('keydown', (event) => { + if (!recordingShortcut || !editorInfoModal || editorInfoModal.hidden) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (event.key === 'Escape') { + recordingShortcut = ''; + renderEditorShortcuts(); + if (editorShortcutMessage) editorShortcutMessage.textContent = 'Shortcut change cancelled.'; + return; + } + const pressed = shortcutFromEvent(event) || (recordingShortcut === 'alternateActions' && ['Shift', 'Control', 'Alt', 'Meta'].includes(event.key) ? event.key : ''); + if (!pressed) return; + const current = EDITOR_SHORTCUTS.find(([id]) => id === recordingShortcut); + const conflict = EDITOR_SHORTCUTS.find(([id, , , context]) => id !== recordingShortcut && editorShortcutBindings[id] === pressed && shortcutContextsOverlap(current[3], context)); + if (conflict) { + if (editorShortcutMessage) editorShortcutMessage.textContent = `${shortcutLabel(pressed)} is already used by ${conflict[1]}. Choose another combination.`; + return; + } + editorShortcutBindings[recordingShortcut] = pressed; + recordingShortcut = ''; + editorAlternateHeld = false; + saveEditorShortcuts(); renderEditorShortcuts(); + if (editorShortcutMessage) editorShortcutMessage.textContent = `${current[1]} is now ${shortcutLabel(pressed)}.`; +}, true); +if (editorResetShortcuts) editorResetShortcuts.addEventListener('click', () => { + recordingShortcut = ''; + editorShortcutBindings = {...editorShortcutDefaults}; + saveEditorShortcuts(); renderEditorShortcuts(); + if (editorShortcutMessage) editorShortcutMessage.textContent = 'All shortcuts reset.'; +}); +renderEditorShortcuts(); + +// Flipnote Editor image importer. This is a three-step flow: choose an image, +// crop it, then preview/convert it into one or both Flipnote layers. Nothing mutates the +// current page until the final Import button is pressed. +const imageImportButton = document.getElementById('flipnote-import-image-button'); +const imageFileInput = document.getElementById('flipnote-image-file-input'); +const imageImportModal = document.getElementById('flipnote-image-import-modal'); +const imageImportClose = document.getElementById('flipnote-image-import-close'); +const imageImportCancel = document.getElementById('flipnote-image-import-cancel'); +const imageImportBack = document.getElementById('flipnote-image-import-back'); +const imageImportNext = document.getElementById('flipnote-image-import-next'); +const imageImportApply = document.getElementById('flipnote-image-import-apply'); +const imageImportResetCrop = document.getElementById('flipnote-image-import-reset-crop'); +const imageImportTitle = document.getElementById('flipnote-image-import-title'); +const imageImportStep1 = document.getElementById('flipnote-image-import-step-1'); +const imageImportStep2 = document.getElementById('flipnote-image-import-step-2'); +const imageImportStep3 = document.getElementById('flipnote-image-import-step-3'); +const imageImportDropzone = document.getElementById('flipnote-image-import-dropzone'); +const imageImportUpload = document.getElementById('flipnote-image-import-upload'); +const imageImportPasteShortcut = document.getElementById('flipnote-image-import-paste-shortcut'); +const imageImportSourceStatus = document.getElementById('flipnote-image-import-source-status'); +const imageImportFilename = document.getElementById('flipnote-image-import-filename'); +const imageImportDimensions = document.getElementById('flipnote-image-import-dimensions'); +const imageImportFinalFilename = document.getElementById('flipnote-image-import-final-filename'); +const imageImportFinalDimensions = document.getElementById('flipnote-image-import-final-dimensions'); +const imageImportCropCanvas = document.getElementById('flipnote-image-import-crop-canvas'); +const imageImportPreview = document.getElementById('flipnote-image-import-preview'); +const imageImportMode = document.getElementById('flipnote-image-import-mode'); +const imageImportColour = document.getElementById('flipnote-image-import-colour'); +const imageImportSecondColour = document.getElementById('flipnote-image-import-second-colour'); +const imageImportSecondColourLabel = document.getElementById('flipnote-image-import-second-colour-label'); +const imageImportLayer = document.getElementById('flipnote-image-import-layer'); +const imageImportLayerLabel = document.getElementById('flipnote-image-import-layer-label'); +const imageImportSize = document.getElementById('flipnote-image-import-size'); +const imageImportAlign = document.getElementById('flipnote-image-import-align'); +const imageImportDither = document.getElementById('flipnote-image-import-dither'); +const imageImportThreshold = document.getElementById('flipnote-image-import-threshold'); +const imageImportThresholdValue = document.getElementById('flipnote-image-import-threshold-value'); +const imageImportSmoothing = document.getElementById('flipnote-image-import-smoothing'); +const imageImportInvert = document.getElementById('flipnote-image-import-invert'); +const imageImportReplace = document.getElementById('flipnote-image-import-replace'); +const imageImportReplaceText = document.getElementById('flipnote-image-import-replace-text'); +let imageImportState = null; +let imageImportLastFocus = null; +let imageImportCropDrag = null; +let imageImportLoadSerial = 0; + +function imageImportAlternateInk(primary, preferred = null) { + if (preferred && FLIPNOTE_INKS[preferred] && preferred !== primary) return preferred; + return FLIPNOTE_INK_ORDER.find((ink) => ink !== primary) || 'red'; +} + +function syncImageImportColourMode() { + const dual = imageImportMode?.value === 'dual'; + const primary = FLIPNOTE_INKS[imageImportColour?.value] ? imageImportColour.value : 'black'; + if (imageImportSecondColour) { + if (!FLIPNOTE_INKS[imageImportSecondColour.value] || imageImportSecondColour.value === primary) { + imageImportSecondColour.value = imageImportAlternateInk(primary, imageImportSecondColour.value); + } + } + if (imageImportSecondColourLabel) imageImportSecondColourLabel.style.display = dual ? '' : 'none'; + if (imageImportLayerLabel) imageImportLayerLabel.textContent = dual ? 'Primary layer' : 'Layer'; + if (imageImportReplaceText) imageImportReplaceText.textContent = dual + ? 'Replace both target layers instead of merging' + : 'Replace the selected layer instead of merging'; +} + +function imageImportOptions() { + const colour = FLIPNOTE_INKS[imageImportColour?.value] ? imageImportColour.value : 'black'; + return { + mode: imageImportMode?.value === 'dual' ? 'dual' : 'single', + colour, + secondColour: imageImportAlternateInk(colour, imageImportSecondColour?.value), + layer: Number(imageImportLayer?.value) === 1 ? 1 : 0, + size: ['fit','fill','stretch','original'].includes(imageImportSize?.value) ? imageImportSize.value : 'fit', + align: ['center','top-left','top-right','bottom-left','bottom-right'].includes(imageImportAlign?.value) ? imageImportAlign.value : 'center', + dither: ['none','bayer2','bayer4','floyd'].includes(imageImportDither?.value) ? imageImportDither.value : 'bayer4', + threshold: Math.max(0, Math.min(255, Math.round(Number(imageImportThreshold?.value) || 0))), + smoothing: !!imageImportSmoothing?.checked, + invert: !!imageImportInvert?.checked, + replace: imageImportReplace ? !!imageImportReplace.checked : true, + }; +} + +function imageImportAlignedOffset(space, align, axis) { + if (axis === 'x') { + if (align.endsWith('left')) return 0; + if (align.endsWith('right')) return space; + } else { + if (align.startsWith('top')) return 0; + if (align.startsWith('bottom')) return space; + } + return space / 2; +} + +function imageImportSourceSize(source = imageImportState?.source) { + return { + width: Math.max(1, Number(source?.width || source?.naturalWidth) || 1), + height: Math.max(1, Number(source?.height || source?.naturalHeight) || 1), + }; +} + +function renderImageToImportCanvas(source, options, cropRect = null) { + const width = 256; + const height = 192; + const work = document.createElement('canvas'); + work.width = width; + work.height = height; + const ctx = work.getContext('2d', {willReadFrequently:true}); + ctx.imageSmoothingEnabled = !!options.smoothing; + ctx.imageSmoothingQuality = 'high'; + const sourceSize = imageImportSourceSize(source); + const crop = cropRect || {x:0, y:0, w:sourceSize.width, h:sourceSize.height}; + const sx = Math.max(0, Math.min(sourceSize.width - 1, Number(crop.x) || 0)); + const sy = Math.max(0, Math.min(sourceSize.height - 1, Number(crop.y) || 0)); + const sw = Math.max(1, Math.min(sourceSize.width - sx, Number(crop.w) || sourceSize.width)); + const sh = Math.max(1, Math.min(sourceSize.height - sy, Number(crop.h) || sourceSize.height)); + let dw = sw; + let dh = sh; + if (options.size === 'stretch') { + dw = width; + dh = height; + } else if (options.size === 'fit' || options.size === 'fill') { + const scale = options.size === 'fill' + ? Math.max(width / sw, height / sh) + : Math.min(width / sw, height / sh); + dw = sw * scale; + dh = sh * scale; + } + const dx = imageImportAlignedOffset(width - dw, options.align, 'x'); + const dy = imageImportAlignedOffset(height - dh, options.align, 'y'); + ctx.drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh); + return ctx.getImageData(0, 0, width, height); +} + +const IMAGE_IMPORT_BAYER_2 = [0, 2, 3, 1]; +const IMAGE_IMPORT_BAYER_4 = [ + 0, 8, 2, 10, + 12, 4, 14, 6, + 3, 11, 1, 9, + 15, 7, 13, 5, +]; + +function imageImportLuminance(imageData, invert) { + const count = imageData.width * imageData.height; + const lum = new Float32Array(count); + const d = imageData.data; + for (let i = 0; i < count; i++) { + const p = i * 4; + const alpha = d[p + 3] / 255; + let value = 0.2126 * d[p] + 0.7152 * d[p + 1] + 0.0722 * d[p + 2]; + value = value * alpha + 255 * (1 - alpha); + lum[i] = invert ? 255 - value : value; + } + return lum; +} + +function buildImageImportMask(source, options, cropRect = null) { + const imageData = renderImageToImportCanvas(source, options, cropRect); + const {width, height} = imageData; + const mask = new Uint8Array(width * height); + const lum = imageImportLuminance(imageData, options.invert); + const threshold = options.threshold; + + if (options.dither === 'floyd') { + const work = new Float32Array(lum); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + const oldValue = work[i]; + const dark = oldValue < threshold; + mask[i] = dark ? 1 : 0; + const quantized = dark ? 0 : 255; + const error = oldValue - quantized; + if (x + 1 < width) work[i + 1] += error * (7 / 16); + if (y + 1 < height) { + if (x > 0) work[i + width - 1] += error * (3 / 16); + work[i + width] += error * (5 / 16); + if (x + 1 < width) work[i + width + 1] += error * (1 / 16); + } + } + } + return mask; + } + + let matrix = null; + let matrixSize = 0; + if (options.dither === 'bayer2') { matrix = IMAGE_IMPORT_BAYER_2; matrixSize = 2; } + if (options.dither === 'bayer4') { matrix = IMAGE_IMPORT_BAYER_4; matrixSize = 4; } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + let localThreshold = threshold; + if (matrix) { + const value = matrix[(y % matrixSize) * matrixSize + (x % matrixSize)]; + const spread = (((value + 0.5) / (matrixSize * matrixSize)) - 0.5) * 96; + localThreshold = Math.max(0, Math.min(255, threshold + spread)); + } + mask[i] = lum[i] < localThreshold ? 1 : 0; + } + } + return mask; +} + +function imageImportColourDistance(r, g, b, colour) { + const dr = r - colour[0]; + const dg = g - colour[1]; + const db = b - colour[2]; + // Weight green most heavily so the palette choice tracks perceived colour + // more closely than a raw RGB cube distance. + return dr * dr * 0.30 + dg * dg * 0.59 + db * db * 0.11; +} + +function imageImportDualPalettePixel(r, g, b, paper, primary, secondary, paperPenalty = 0) { + const paperDistance = Math.max(0, imageImportColourDistance(r, g, b, paper) + paperPenalty); + const primaryDistance = imageImportColourDistance(r, g, b, primary); + const secondaryDistance = imageImportColourDistance(r, g, b, secondary); + if (paperDistance <= primaryDistance && paperDistance <= secondaryDistance) return 0; + return primaryDistance <= secondaryDistance ? 1 : 2; +} + +function buildImageImportDualMasks(source, options, frame, cropRect = null) { + const imageData = renderImageToImportCanvas(source, options, cropRect); + const {width, height, data} = imageData; + const count = width * height; + const primaryMask = new Uint8Array(count); + const secondaryMask = new Uint8Array(count); + const paper = resolveFramePaper(frame); + const primary = resolveFrameInk(frame, options.colour); + const secondary = resolveFrameInk(frame, options.secondColour); + const thresholdPenalty = (options.threshold - 128) * 500; + + const readPixel = (i) => { + const p = i * 4; + const alpha = data[p + 3] / 255; + if (alpha <= 0.001) return {transparent:true, r:paper[0], g:paper[1], b:paper[2]}; + let r = data[p] * alpha + paper[0] * (1 - alpha); + let g = data[p + 1] * alpha + paper[1] * (1 - alpha); + let b = data[p + 2] * alpha + paper[2] * (1 - alpha); + if (options.invert) { r = 255 - r; g = 255 - g; b = 255 - b; } + return {transparent:false, r, g, b}; + }; + + if (options.dither === 'floyd') { + const wr = new Float32Array(count); + const wg = new Float32Array(count); + const wb = new Float32Array(count); + const transparent = new Uint8Array(count); + for (let i = 0; i < count; i++) { + const px = readPixel(i); + wr[i] = px.r; wg[i] = px.g; wb[i] = px.b; + transparent[i] = px.transparent ? 1 : 0; + } + const palette = [paper, primary, secondary]; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + if (transparent[i]) continue; + const r = Math.max(0, Math.min(255, wr[i])); + const g = Math.max(0, Math.min(255, wg[i])); + const b = Math.max(0, Math.min(255, wb[i])); + const choice = imageImportDualPalettePixel(r, g, b, paper, primary, secondary, thresholdPenalty); + if (choice === 1) primaryMask[i] = 1; + else if (choice === 2) secondaryMask[i] = 1; + const quantized = palette[choice]; + const er = r - quantized[0], eg = g - quantized[1], eb = b - quantized[2]; + const spread = (index, weight) => { + if (index < 0 || index >= count || transparent[index]) return; + wr[index] += er * weight; wg[index] += eg * weight; wb[index] += eb * weight; + }; + if (x + 1 < width) spread(i + 1, 7 / 16); + if (y + 1 < height) { + if (x > 0) spread(i + width - 1, 3 / 16); + spread(i + width, 5 / 16); + if (x + 1 < width) spread(i + width + 1, 1 / 16); + } + } + } + return {primary: primaryMask, secondary: secondaryMask}; + } + + let matrix = null; + let matrixSize = 0; + if (options.dither === 'bayer2') { matrix = IMAGE_IMPORT_BAYER_2; matrixSize = 2; } + if (options.dither === 'bayer4') { matrix = IMAGE_IMPORT_BAYER_4; matrixSize = 4; } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x; + const px = readPixel(i); + if (px.transparent) continue; + let paperPenalty = thresholdPenalty; + if (matrix) { + const value = matrix[(y % matrixSize) * matrixSize + (x % matrixSize)]; + const spread = (((value + 0.5) / (matrixSize * matrixSize)) - 0.5) * 96; + paperPenalty += spread * 500; + } + const choice = imageImportDualPalettePixel(px.r, px.g, px.b, paper, primary, secondary, paperPenalty); + if (choice === 1) primaryMask[i] = 1; + else if (choice === 2) secondaryMask[i] = 1; + } + } + return {primary: primaryMask, secondary: secondaryMask}; +} + +function buildImageImportMasks(source, options, frame, cropRect = null) { + if (options.mode === 'dual') return buildImageImportDualMasks(source, options, frame, cropRect); + return {primary: buildImageImportMask(source, options, cropRect), secondary: null}; +} + +function mergedImageImportLayer(frame, layer, importedMask, replace) { + if (replace) return importedMask; + const merged = cloneMask(frame.layers[layer]); + for (let i = 0; i < merged.length; i++) if (importedMask[i]) merged[i] = 1; + return merged; +} + +function updateImageImportPreview() { + if (!imageImportState?.source || !imageImportPreview) return; + const frame = timeline.getFrame(imageImportState.frameIndex); + if (!frame) return; + syncImageImportColourMode(); + const options = imageImportOptions(); + if (imageImportThresholdValue) imageImportThresholdValue.textContent = String(options.threshold); + const importedMasks = buildImageImportMasks(imageImportState.source, options, frame, imageImportState.crop); + imageImportState.previewMasks = importedMasks; + imageImportState.previewOptions = options; + + const layers = [cloneMask(frame.layers[0]), cloneMask(frame.layers[1])]; + const secondaryLayer = options.layer === 0 ? 1 : 0; + layers[options.layer] = mergedImageImportLayer(frame, options.layer, importedMasks.primary, options.replace); + if (options.mode === 'dual' && importedMasks.secondary) { + layers[secondaryLayer] = mergedImageImportLayer(frame, secondaryLayer, importedMasks.secondary, options.replace); + } + const previewFrame = { + layers, + colors: [frame.colors[0], frame.colors[1]], + paperColor: frame.paperColor, + }; + previewFrame.colors[options.layer] = options.colour; + if (options.mode === 'dual') previewFrame.colors[secondaryLayer] = options.secondColour; + const pixels = new Uint8ClampedArray(256 * 192 * 4); + const paper = resolveFramePaper(previewFrame); + const layer1Colour = resolveFrameInk(previewFrame, previewFrame.colors[0]); + const layer2Colour = resolveFrameInk(previewFrame, previewFrame.colors[1]); + for (let i = 0; i < 256 * 192; i++) { + let r = paper[0], g = paper[1], b = paper[2]; + if (layers[1][i]) [r,g,b] = layer2Colour; + if (layers[0][i]) [r,g,b] = layer1Colour; + const p = i * 4; + pixels[p] = r; pixels[p + 1] = g; pixels[p + 2] = b; pixels[p + 3] = 255; + } + const ctx = imageImportPreview.getContext('2d', {alpha:false}); + ctx.imageSmoothingEnabled = false; + ctx.putImageData(new ImageData(pixels, 256, 192), 0, 0); +} + +async function decodeImageImportFile(file) { + if (typeof createImageBitmap === 'function') return await createImageBitmap(file); + const url = URL.createObjectURL(file); + try { + const image = new Image(); + image.decoding = 'async'; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = () => reject(new Error('Could not decode this image.')); + image.src = url; + }); + return image; + } finally { + URL.revokeObjectURL(url); + } +} + +function setImageImportStatus(message = '', error = false) { + if (!imageImportSourceStatus) return; + imageImportSourceStatus.textContent = message; + imageImportSourceStatus.classList.toggle('is-error', !!error); +} + +function imageImportIsMacLike() { + const platform = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || ''; + return /Mac|iPhone|iPad|iPod/i.test(platform); +} + +function setImageImportControlVisible(control, visible) { + if (!control) return; + control.hidden = !visible; + // Editor's shared .button rule sets display explicitly, which can override + // the browser's default [hidden] rule. Keep an inline display guard so the + // image-import footer cannot leak buttons from another step. + control.style.display = visible ? '' : 'none'; +} + +function setImageImportStep(step) { + if (!imageImportState) return; + imageImportState.step = step; + if (imageImportStep1) imageImportStep1.hidden = step !== 1; + if (imageImportStep2) imageImportStep2.hidden = step !== 2; + if (imageImportStep3) imageImportStep3.hidden = step !== 3; + if (imageImportTitle) imageImportTitle.textContent = step === 1 ? 'Import image' : step === 2 ? 'Crop image' : 'Image import options'; + // Step 1: Cancel only. Step 2: Back + Reset crop + Continue + Cancel. + // Step 3: Back + Import + Cancel. Continue/Import never coexist. + setImageImportControlVisible(imageImportBack, step === 2 || step === 3); + setImageImportControlVisible(imageImportResetCrop, step === 2); + setImageImportControlVisible(imageImportNext, step === 2); + setImageImportControlVisible(imageImportApply, step === 3); + if (step === 2) { + requestAnimationFrame(() => { + drawImageImportCrop(); + imageImportCropCanvas?.focus?.(); + }); + } else if (step === 3) { + updateImageImportFinalFileInfo(); + updateImageImportPreview(); + } else { + imageImportDropzone?.focus?.(); + } +} + +function resetImageImportCrop() { + if (!imageImportState?.source) return; + const {width, height} = imageImportSourceSize(); + imageImportState.crop = {x:0, y:0, w:width, h:height}; + imageImportState.previewMasks = null; + imageImportState.previewOptions = null; + drawImageImportCrop(); + updateImageImportFinalFileInfo(); +} + +function imageImportCropView() { + if (!imageImportCropCanvas || !imageImportState?.source) return null; + const {width:sw, height:sh} = imageImportSourceSize(); + const cw = imageImportCropCanvas.width; + const ch = imageImportCropCanvas.height; + const pad = 24; + const scale = Math.min((cw - pad * 2) / sw, (ch - pad * 2) / sh); + const dw = sw * scale; + const dh = sh * scale; + return {sw, sh, scale, ox:(cw - dw) / 2, oy:(ch - dh) / 2, dw, dh}; +} + +function drawImageImportCrop() { + if (!imageImportCropCanvas || !imageImportState?.source || !imageImportState.crop) return; + const ctx = imageImportCropCanvas.getContext('2d'); + const view = imageImportCropView(); + if (!view) return; + const {width:cw, height:ch} = imageImportCropCanvas; + const crop = imageImportState.crop; + ctx.clearRect(0, 0, cw, ch); + ctx.fillStyle = '#0d0c0f'; + ctx.fillRect(0, 0, cw, ch); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(imageImportState.source, view.ox, view.oy, view.dw, view.dh); + + const x = view.ox + crop.x * view.scale; + const y = view.oy + crop.y * view.scale; + const w = crop.w * view.scale; + const h = crop.h * view.scale; + ctx.fillStyle = 'rgba(0,0,0,.58)'; + ctx.fillRect(view.ox, view.oy, view.dw, Math.max(0, y - view.oy)); + ctx.fillRect(view.ox, y + h, view.dw, Math.max(0, view.oy + view.dh - (y + h))); + ctx.fillRect(view.ox, y, Math.max(0, x - view.ox), h); + ctx.fillRect(x + w, y, Math.max(0, view.ox + view.dw - (x + w)), h); + + ctx.save(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.strokeRect(x + 1, y + 1, Math.max(0, w - 2), Math.max(0, h - 2)); + ctx.strokeStyle = 'rgba(174,48,31,.95)'; + ctx.lineWidth = 1; + ctx.strokeRect(x + 3, y + 3, Math.max(0, w - 6), Math.max(0, h - 6)); + ctx.setLineDash([5,5]); + ctx.strokeStyle = 'rgba(255,255,255,.55)'; + ctx.beginPath(); + ctx.moveTo(x + w / 3, y); ctx.lineTo(x + w / 3, y + h); + ctx.moveTo(x + (w * 2) / 3, y); ctx.lineTo(x + (w * 2) / 3, y + h); + ctx.moveTo(x, y + h / 3); ctx.lineTo(x + w, y + h / 3); + ctx.moveTo(x, y + (h * 2) / 3); ctx.lineTo(x + w, y + (h * 2) / 3); + ctx.stroke(); + ctx.restore(); + + const handles = [ + [x,y],[x+w/2,y],[x+w,y], + [x,y+h/2],[x+w,y+h/2], + [x,y+h],[x+w/2,y+h],[x+w,y+h], + ]; + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#AE301F'; + ctx.lineWidth = 2; + for (const [hx,hy] of handles) { + ctx.fillRect(hx - 5, hy - 5, 10, 10); + ctx.strokeRect(hx - 5, hy - 5, 10, 10); + } +} + +function imageImportCanvasPoint(event) { + const rect = imageImportCropCanvas.getBoundingClientRect(); + return { + x:(event.clientX - rect.left) * (imageImportCropCanvas.width / Math.max(1, rect.width)), + y:(event.clientY - rect.top) * (imageImportCropCanvas.height / Math.max(1, rect.height)), + }; +} + +function imageImportSourcePoint(canvasPoint) { + const view = imageImportCropView(); + if (!view) return {x:0,y:0}; + return { + x:Math.max(0, Math.min(view.sw, (canvasPoint.x - view.ox) / view.scale)), + y:Math.max(0, Math.min(view.sh, (canvasPoint.y - view.oy) / view.scale)), + }; +} + +function imageImportCropHitTest(point) { + const view = imageImportCropView(); + const crop = imageImportState?.crop; + if (!view || !crop) return null; + const x = view.ox + crop.x * view.scale; + const y = view.oy + crop.y * view.scale; + const w = crop.w * view.scale; + const h = crop.h * view.scale; + const right = x + w; + const bottom = y + h; + const near = 14; + const nearX1 = Math.abs(point.x - x) <= near; + const nearX2 = Math.abs(point.x - right) <= near; + const nearY1 = Math.abs(point.y - y) <= near; + const nearY2 = Math.abs(point.y - bottom) <= near; + const betweenX = point.x >= x - near && point.x <= right + near; + const betweenY = point.y >= y - near && point.y <= bottom + near; + if (nearX1 && nearY1) return 'nw'; + if (nearX2 && nearY1) return 'ne'; + if (nearX1 && nearY2) return 'sw'; + if (nearX2 && nearY2) return 'se'; + if (nearY1 && betweenX) return 'n'; + if (nearY2 && betweenX) return 's'; + if (nearX1 && betweenY) return 'w'; + if (nearX2 && betweenY) return 'e'; + if (point.x >= x && point.x <= right && point.y >= y && point.y <= bottom) return 'move'; + if (point.x >= view.ox && point.x <= view.ox + view.dw && point.y >= view.oy && point.y <= view.oy + view.dh) return 'new'; + return null; +} + +function imageImportCropCursor(mode) { + if (mode === 'move') return 'move'; + if (mode === 'n' || mode === 's') return 'ns-resize'; + if (mode === 'e' || mode === 'w') return 'ew-resize'; + if (mode === 'nw' || mode === 'se') return 'nwse-resize'; + if (mode === 'ne' || mode === 'sw') return 'nesw-resize'; + return 'crosshair'; +} + +function updateImageImportCropDrag(point) { + if (!imageImportCropDrag || !imageImportState?.crop) return; + const {width:sw, height:sh} = imageImportSourceSize(); + const p = imageImportSourcePoint(point); + const start = imageImportCropDrag.startCrop; + const sp = imageImportCropDrag.startPoint; + const mode = imageImportCropDrag.mode; + const minW = Math.min(8, sw); + const minH = Math.min(8, sh); + let x = start.x, y = start.y, w = start.w, h = start.h; + + if (mode === 'move') { + x = Math.max(0, Math.min(sw - w, start.x + (p.x - sp.x))); + y = Math.max(0, Math.min(sh - h, start.y + (p.y - sp.y))); + } else if (mode === 'new') { + x = Math.min(sp.x, p.x); + y = Math.min(sp.y, p.y); + w = Math.max(minW, Math.abs(p.x - sp.x)); + h = Math.max(minH, Math.abs(p.y - sp.y)); + if (x + w > sw) w = sw - x; + if (y + h > sh) h = sh - y; + } else { + const right = start.x + start.w; + const bottom = start.y + start.h; + if (mode.includes('w')) { + x = Math.max(0, Math.min(right - minW, p.x)); + w = right - x; + } + if (mode.includes('e')) w = Math.max(minW, Math.min(sw - start.x, p.x - start.x)); + if (mode.includes('n')) { + y = Math.max(0, Math.min(bottom - minH, p.y)); + h = bottom - y; + } + if (mode.includes('s')) h = Math.max(minH, Math.min(sh - start.y, p.y - start.y)); + } + imageImportState.crop = {x,y,w,h}; + imageImportState.previewMasks = null; + imageImportState.previewOptions = null; + drawImageImportCrop(); + updateImageImportFinalFileInfo(); +} + +function updateImageImportFileInfo() { + if (!imageImportState?.source) return; + const {width,height} = imageImportSourceSize(); + const name = imageImportState.file?.name || 'Pasted image'; + if (imageImportFilename) imageImportFilename.textContent = name; + if (imageImportDimensions) imageImportDimensions.textContent = `${Math.round(width)} × ${Math.round(height)}px`; + if (imageImportFinalFilename) imageImportFinalFilename.textContent = name; + updateImageImportFinalFileInfo(); +} + +function updateImageImportFinalFileInfo() { + if (!imageImportFinalDimensions || !imageImportState?.crop) return; + const crop = imageImportState.crop; + imageImportFinalDimensions.textContent = `Crop ${Math.max(1, Math.round(crop.w))} × ${Math.max(1, Math.round(crop.h))}px`; +} + +function initializeImageImportOptions() { + const frame = timeline.getFrame(imageImportState?.frameIndex ?? timeline.currentFrame); + if (!frame) return; + const activeLayer = canvas.activeLayer === 1 ? 1 : 0; + const otherLayer = activeLayer === 0 ? 1 : 0; + const primary = frame.colors[activeLayer] || 'black'; + if (imageImportMode) imageImportMode.value = 'single'; + if (imageImportLayer) imageImportLayer.value = String(activeLayer); + if (imageImportColour) imageImportColour.value = primary; + if (imageImportSecondColour) imageImportSecondColour.value = imageImportAlternateInk(primary, frame.colors[otherLayer]); + if (imageImportSize) imageImportSize.value = 'fit'; + if (imageImportAlign) imageImportAlign.value = 'center'; + if (imageImportDither) imageImportDither.value = 'bayer4'; + if (imageImportThreshold) imageImportThreshold.value = '128'; + if (imageImportSmoothing) imageImportSmoothing.checked = true; + if (imageImportInvert) imageImportInvert.checked = false; + if (imageImportReplace) imageImportReplace.checked = true; + syncImageImportColourMode(); +} + +async function loadImageImportFile(file) { + if (!file || !imageImportState) return; + if (file.type && !file.type.startsWith('image/')) { + setImageImportStatus('That file is not an image.', true); + return; + } + const serial = ++imageImportLoadSerial; + setImageImportStatus('Loading image...'); + try { + const source = await decodeImageImportFile(file); + if (!imageImportState || serial !== imageImportLoadSerial) { + if (source && typeof source.close === 'function') source.close(); + return; + } + if (imageImportState.source && typeof imageImportState.source.close === 'function') imageImportState.source.close(); + imageImportState.file = file; + imageImportState.source = source; + imageImportState.previewMasks = null; + imageImportState.previewOptions = null; + initializeImageImportOptions(); + resetImageImportCrop(); + updateImageImportFileInfo(); + setImageImportStatus(''); + setImageImportStep(2); + } catch (error) { + setImageImportStatus(error?.message || 'Editor could not import this image.', true); + if (imageFileInput) imageFileInput.value = ''; + } +} + +function closeImageImportModal() { + if (!imageImportModal || imageImportModal.hidden) return; + ++imageImportLoadSerial; + imageImportModal.hidden = true; + document.body.classList.remove('flipnote-editor-image-import-open'); + if (imageImportState?.source && typeof imageImportState.source.close === 'function') imageImportState.source.close(); + imageImportState = null; + imageImportCropDrag = null; + if (imageFileInput) imageFileInput.value = ''; + setImageImportStatus(''); + imageImportDropzone?.classList.remove('is-dragover'); + if (imageImportLastFocus && typeof imageImportLastFocus.focus === 'function') imageImportLastFocus.focus(); + imageImportLastFocus = null; +} + +function openImageImportModal() { + if (!imageImportModal || timeline.playing) return; + const frame = timeline.getFrame(timeline.currentFrame); + if (!frame) return; + imageImportLastFocus = document.activeElement; + imageImportState = { + step:1, + file:null, + source:null, + crop:null, + frameIndex:timeline.currentFrame, + previewMasks:null, + previewOptions:null, + }; + if (imageFileInput) imageFileInput.value = ''; + if (imageImportPasteShortcut) imageImportPasteShortcut.textContent = imageImportIsMacLike() ? '⌘V' : 'Ctrl+V'; + setImageImportStatus(''); + imageImportModal.hidden = false; + document.body.classList.add('flipnote-editor-image-import-open'); + setImageImportStep(1); +} + +function openImageFilePicker() { + if (!imageFileInput || imageFileInput.disabled) return; + imageFileInput.value = ''; + try { + if (typeof imageFileInput.showPicker === 'function') imageFileInput.showPicker(); + else imageFileInput.click(); + } catch (error) { + imageFileInput.click(); + } +} + +if (imageImportButton) imageImportButton.addEventListener('click', openImageImportModal); +if (imageImportUpload) imageImportUpload.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openImageFilePicker(); +}); +if (imageImportDropzone) { + imageImportDropzone.addEventListener('click', (event) => { + if (event.target.closest('label')) return; + openImageFilePicker(); + }); + imageImportDropzone.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openImageFilePicker(); + }); + ['dragenter','dragover'].forEach((type) => imageImportDropzone.addEventListener(type, (event) => { + event.preventDefault(); + event.stopPropagation(); + imageImportDropzone.classList.add('is-dragover'); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + })); + ['dragleave','dragend'].forEach((type) => imageImportDropzone.addEventListener(type, (event) => { + event.preventDefault(); + imageImportDropzone.classList.remove('is-dragover'); + })); + imageImportDropzone.addEventListener('drop', (event) => { + event.preventDefault(); + event.stopPropagation(); + imageImportDropzone.classList.remove('is-dragover'); + const files = Array.from(event.dataTransfer?.files || []); + const file = files.find((candidate) => !candidate.type || candidate.type.startsWith('image/')); + if (!file) { + setImageImportStatus('Drop a PNG, JPEG, WebP, GIF or BMP image.', true); + return; + } + loadImageImportFile(file); + }); +} +if (imageFileInput) imageFileInput.addEventListener('change', () => { + const file = imageFileInput.files && imageFileInput.files[0]; + if (file) loadImageImportFile(file); +}); + +document.addEventListener('paste', (event) => { + if (!imageImportState || imageImportModal?.hidden || imageImportState.step !== 1) return; + const items = Array.from(event.clipboardData?.items || []); + const item = items.find((candidate) => candidate.kind === 'file' && candidate.type.startsWith('image/')); + if (!item) return; + const blob = item.getAsFile(); + if (!blob) return; + event.preventDefault(); + const ext = (blob.type.split('/')[1] || 'png').replace('jpeg','jpg'); + const file = blob.name ? blob : new File([blob], `Pasted image.${ext}`, {type:blob.type || 'image/png'}); + loadImageImportFile(file); +}); + +if (imageImportCropCanvas) { + imageImportCropCanvas.tabIndex = 0; + imageImportCropCanvas.addEventListener('pointerdown', (event) => { + if (!imageImportState?.source || imageImportState.step !== 2) return; + const point = imageImportCanvasPoint(event); + const mode = imageImportCropHitTest(point); + if (!mode) return; + event.preventDefault(); + imageImportCropCanvas.setPointerCapture?.(event.pointerId); + imageImportCropDrag = { + mode, + startPoint:imageImportSourcePoint(point), + startCrop:{...imageImportState.crop}, + }; + if (mode === 'new') { + imageImportCropDrag.startPoint = imageImportSourcePoint(point); + } + imageImportCropCanvas.style.cursor = imageImportCropCursor(mode); + }); + imageImportCropCanvas.addEventListener('pointermove', (event) => { + const point = imageImportCanvasPoint(event); + if (imageImportCropDrag) { + event.preventDefault(); + updateImageImportCropDrag(point); + return; + } + imageImportCropCanvas.style.cursor = imageImportCropCursor(imageImportCropHitTest(point)); + }); + const finishCropDrag = (event) => { + if (!imageImportCropDrag) return; + imageImportCropCanvas.releasePointerCapture?.(event.pointerId); + imageImportCropDrag = null; + imageImportCropCanvas.style.cursor = 'crosshair'; + drawImageImportCrop(); + }; + imageImportCropCanvas.addEventListener('pointerup', finishCropDrag); + imageImportCropCanvas.addEventListener('pointercancel', finishCropDrag); + imageImportCropCanvas.addEventListener('pointerleave', () => { + if (!imageImportCropDrag) imageImportCropCanvas.style.cursor = 'crosshair'; + }); +} + +[ + imageImportMode, imageImportColour, imageImportSecondColour, imageImportLayer, imageImportSize, imageImportAlign, + imageImportDither, imageImportThreshold, imageImportSmoothing, + imageImportInvert, imageImportReplace, +].forEach((control) => { + if (!control) return; + const refresh = () => { + if (control === imageImportMode || control === imageImportColour || control === imageImportSecondColour) { + syncImageImportColourMode(); + } + updateImageImportPreview(); + }; + control.addEventListener('input', refresh); + control.addEventListener('change', refresh); +}); + +if (imageImportBack) imageImportBack.addEventListener('click', () => { + if (!imageImportState) return; + if (imageImportState.step === 3) setImageImportStep(2); + else if (imageImportState.step === 2) { + setImageImportStatus(imageImportState.file ? `Current image: ${imageImportState.file.name || 'Pasted image'}` : ''); + setImageImportStep(1); + } +}); +if (imageImportResetCrop) imageImportResetCrop.addEventListener('click', resetImageImportCrop); +if (imageImportNext) imageImportNext.addEventListener('click', () => { + if (!imageImportState?.source) return; + setImageImportStep(3); +}); +if (imageImportApply) imageImportApply.addEventListener('click', () => { + if (!imageImportState?.source) return; + const frame = timeline.getFrame(imageImportState.frameIndex); + if (!frame) { closeImageImportModal(); return; } + const options = imageImportState.previewOptions || imageImportOptions(); + const importedMasks = imageImportState.previewMasks || buildImageImportMasks(imageImportState.source, options, frame, imageImportState.crop); + const secondaryLayer = options.layer === 0 ? 1 : 0; + frame.layers[options.layer] = mergedImageImportLayer(frame, options.layer, importedMasks.primary, options.replace); + frame.colors[options.layer] = options.colour; + if (options.mode === 'dual' && importedMasks.secondary) { + frame.layers[secondaryLayer] = mergedImageImportLayer(frame, secondaryLayer, importedMasks.secondary, options.replace); + frame.colors[secondaryLayer] = options.secondColour; + } + timeline.currentFrame = imageImportState.frameIndex; + canvas.activeLayer = options.layer; + timeline.updateFrame(imageImportState.frameIndex); + timeline.setActiveFrame(imageImportState.frameIndex); + closeImageImportModal(); +}); +if (imageImportClose) imageImportClose.addEventListener('click', closeImageImportModal); +if (imageImportCancel) imageImportCancel.addEventListener('click', closeImageImportModal); +if (imageImportModal) imageImportModal.addEventListener('pointerdown', (event) => { + if (event.target === imageImportModal) closeImageImportModal(); +}); +document.addEventListener('keydown', (event) => { + if (shortcutAction(event, 'dialog') === 'closeDialog' && imageImportModal && !imageImportModal.hidden) { + event.preventDefault(); + closeImageImportModal(); + } +}); + + +//Playback +const buttonPlayPause = document.getElementById('play-pause'); +const buttonFirst = document.getElementById('first-frame'); +const buttonNext = document.getElementById('next-frame'); +const buttonPrev = document.getElementById('prev-frame'); +const buttonLast = document.getElementById('last-frame'); +const buttonNew = document.getElementById('new-frame'); +const buttonDelete = document.getElementById('delete-frame'); + +buttonPlayPause.onclick = () => { + timeline.togglePlayPause(); +} +buttonFirst.onclick = () => { + if (timeline.playing) return; + timeline.setActiveFrame(0); +} +buttonNext.onclick = () => { + if (timeline.playing) return; + timeline.setActiveFrame(timeline.currentFrame + 1); +} +buttonPrev.onclick = () => { + if (timeline.playing) return; + timeline.setActiveFrame(timeline.currentFrame - 1); +} +buttonLast.onclick = () => { + if (timeline.playing) return; + timeline.setActiveFrame(Math.max(0, timeline.frames.length - 1)); +} +buttonNew.onclick = () => { + if (timeline.playing) return; + const insertAt = timeline.currentFrame + 1; + const sourceFrame = cloneEnabled ? timeline.frames[timeline.currentFrame] : undefined; + const addedIndex = timeline.addFrame(sourceFrame, insertAt); + if (addedIndex >= 0) timeline.setActiveFrame(addedIndex); + updateAudioFillBars(); +} +buttonDelete.onclick = () => { + if (timeline.playing) return; + if (timeline.frames.length > 1) { + timeline.removeFrame(timeline.currentFrame); + timeline.setActiveFrame(timeline.currentFrame - 1); + updateAudioFillBars(); + } +} + +// Slide-out Page Tools below the filmstrip. Erase/Insert deliberately delegate +// to the existing page controls so their behaviour stays identical. +const pageToolsToggle = document.getElementById('flipnote-page-tools-toggle'); +const pageToolsToggleImage = document.getElementById('flipnote-page-tools-toggle-image'); +const pageToolsTray = document.getElementById('flipnote-page-tools-tray'); +const pageEraseButton = document.getElementById('flipnote-page-erase'); +const pageInsertButton = document.getElementById('flipnote-page-insert'); +const pageCopyButton = document.getElementById('flipnote-page-copy'); +const pagePasteButton = document.getElementById('flipnote-page-paste'); + +function setPageToolsOpen(open) { + const next = !!open; + if (!pageToolsToggle || !pageToolsTray) return; + pageToolsToggle.setAttribute('aria-expanded', next ? 'true' : 'false'); + pageToolsToggle.title = next ? 'Close Page Tools' : 'Open Page Tools'; + pageToolsTray.classList.toggle('is-open', next); + pageToolsTray.setAttribute('aria-hidden', next ? 'false' : 'true'); + if (pageToolsToggleImage) { + pageToolsToggleImage.src = next + ? './assets/flipnote-editor/closepages.png' + : './assets/flipnote-editor/openpages.png'; + pageToolsToggleImage.alt = next ? 'Close Page Tools' : 'Open Page Tools'; + } +} + +if (pageToolsToggle) { + pageToolsToggle.addEventListener('click', () => { + setPageToolsOpen(pageToolsToggle.getAttribute('aria-expanded') !== 'true'); + }); +} +if (pageEraseButton) pageEraseButton.addEventListener('click', () => { + if (!timeline.playing && buttonDelete) buttonDelete.click(); +}); +if (pageInsertButton) pageInsertButton.addEventListener('click', () => { + if (!timeline.playing && buttonNew) buttonNew.click(); +}); +if (pageCopyButton) pageCopyButton.addEventListener('click', () => { + if (!timeline.playing) timeline.copyPage(timeline.currentFrame); +}); +if (pagePasteButton) pagePasteButton.addEventListener('click', () => { + if (!timeline.playing) timeline.pastePage(timeline.currentFrame); +}); + + +// One-click to start editing, make sure opacity is 100% while editing +filenameElement.addEventListener('input', () => { + markEditorDirty(); +}); + +filenameElement.addEventListener('click', () => { + filenameElement.contentEditable = true; + filenameElement.classList.add('editing'); + filenameElement.focus(); + + // Set opacity to 100% when editing + filenameElement.style.opacity = '1'; + + // Select all text + const range = document.createRange(); + range.selectNodeContents(filenameElement); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + + // Stop editing on blur or Enter + filenameElement.addEventListener('blur', () => { + filenameElement.contentEditable = false; + filenameElement.classList.remove('editing'); + + // Revert opacity back to 80% when not editing + filenameElement.style.opacity = '0.8'; + + // Ensure the title is updated based on the filename text + const newTitle = filenameElement.textContent.trim() || 'Untitled flipnote'; + document.title = `${newTitle} - Flipnote Editor`; + }); + + // Stop editing when Enter is pressed and enforce character limit of 32 + filenameElement.addEventListener('keydown', (e) => { + if (shortcutAction(e, 'title') === 'confirmTitle') { + e.preventDefault(); + filenameElement.blur(); + } + else if (e.key === 'Enter') e.preventDefault(); + + // Enforce character limit of 32 + if (filenameElement.textContent.length > 32) { + filenameElement.textContent = filenameElement.textContent.substring(0, 32); + } + }); + + + + +// Initial blank-project setup is complete. From this point onward, real user +// mutations arm the close-tab confirmation. Auto spin-off import temporarily +// suppresses tracking and resets this baseline once its import finishes. +editorDirtyTrackingReady = true; + +/* Editor mobile editor layout: move the existing drawing tool picker directly + * under the canvas on phones/tablets. The same controls are moved back to their + * original DOM position as soon as the viewport returns to desktop. */ +(() => { + const mobileQuery = window.matchMedia('(max-width: 767px), (max-width: 1024px) and (hover: none) and (pointer: coarse)'); + const install = () => { + const stage = document.querySelector('.flipnote-editor-stage'); + const canvasWrap = document.querySelector('.flipnote-editor-canvas-wrap'); + const picker = document.querySelector('.flipnote-editor-tool-picker-inline'); + const toolPane = picker && picker.closest('.flipnote-editor-tool-page'); + const tabOne = document.querySelector('.flipnote-editor-tool-tab[data-tool-tab="1"]'); + if (!stage || !canvasWrap || !picker || !toolPane) return; + + const originalParent = picker.parentNode; + const originalNextSibling = picker.nextSibling; + let dock = null; + + const ensureDock = () => { + if (dock && dock.isConnected) return dock; + dock = document.createElement('div'); + dock.className = 'flipnote-editor-mobile-primary-tools'; + dock.setAttribute('aria-label', 'Drawing tools'); + canvasWrap.insertAdjacentElement('afterend', dock); + return dock; + }; + + const apply = () => { + if (mobileQuery.matches) { + const target = ensureDock(); + if (picker.parentNode !== target) target.appendChild(picker); + toolPane.classList.add('flipnote-editor-mobile-tools-docked'); + document.body.classList.add('flipnote-editor-mobile-layout'); + } else { + if (picker.parentNode !== originalParent) { + if (originalNextSibling && originalNextSibling.parentNode === originalParent) originalParent.insertBefore(picker, originalNextSibling); + else originalParent.appendChild(picker); + } + toolPane.classList.remove('flipnote-editor-mobile-tools-docked'); + document.body.classList.remove('flipnote-editor-mobile-layout'); + if (dock) { + dock.remove(); + dock = null; + } + } + }; + + if (tabOne) { + tabOne.addEventListener('click', () => { + if (!mobileQuery.matches) return; + window.requestAnimationFrame(() => { + const target = document.querySelector('.flipnote-editor-mobile-primary-tools'); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + }); + } + + if (typeof mobileQuery.addEventListener === 'function') mobileQuery.addEventListener('change', apply); + else if (typeof mobileQuery.addListener === 'function') mobileQuery.addListener(apply); + apply(); + }; + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install, { once: true }); + else install(); +})(); diff --git a/assets/flipnote-editor/editpattern.png b/assets/flipnote-editor/editpattern.png new file mode 100644 index 0000000..68e4679 Binary files /dev/null and b/assets/flipnote-editor/editpattern.png differ diff --git a/assets/flipnote-editor/erase1.png b/assets/flipnote-editor/erase1.png new file mode 100644 index 0000000..143bd56 Binary files /dev/null and b/assets/flipnote-editor/erase1.png differ diff --git a/assets/flipnote-editor/erase1_Adv.png b/assets/flipnote-editor/erase1_Adv.png new file mode 100644 index 0000000..143bd56 Binary files /dev/null and b/assets/flipnote-editor/erase1_Adv.png differ diff --git a/assets/flipnote-editor/erase2.png b/assets/flipnote-editor/erase2.png new file mode 100644 index 0000000..9266961 Binary files /dev/null and b/assets/flipnote-editor/erase2.png differ diff --git a/assets/flipnote-editor/erase2_Adv.png b/assets/flipnote-editor/erase2_Adv.png new file mode 100644 index 0000000..21f172e Binary files /dev/null and b/assets/flipnote-editor/erase2_Adv.png differ diff --git a/assets/flipnote-editor/erase3.png b/assets/flipnote-editor/erase3.png new file mode 100644 index 0000000..31f0454 Binary files /dev/null and b/assets/flipnote-editor/erase3.png differ diff --git a/assets/flipnote-editor/erase3_Adv.png b/assets/flipnote-editor/erase3_Adv.png new file mode 100644 index 0000000..22a05d9 Binary files /dev/null and b/assets/flipnote-editor/erase3_Adv.png differ diff --git a/assets/flipnote-editor/erase4.png b/assets/flipnote-editor/erase4.png new file mode 100644 index 0000000..cedd908 Binary files /dev/null and b/assets/flipnote-editor/erase4.png differ diff --git a/assets/flipnote-editor/erase4_Adv.png b/assets/flipnote-editor/erase4_Adv.png new file mode 100644 index 0000000..e852c89 Binary files /dev/null and b/assets/flipnote-editor/erase4_Adv.png differ diff --git a/assets/flipnote-editor/erase5.png b/assets/flipnote-editor/erase5.png new file mode 100644 index 0000000..b84146c Binary files /dev/null and b/assets/flipnote-editor/erase5.png differ diff --git a/assets/flipnote-editor/erase5_Adv.png b/assets/flipnote-editor/erase5_Adv.png new file mode 100644 index 0000000..f130ec6 Binary files /dev/null and b/assets/flipnote-editor/erase5_Adv.png differ diff --git a/assets/flipnote-editor/erase6.png b/assets/flipnote-editor/erase6.png new file mode 100644 index 0000000..f342bf9 Binary files /dev/null and b/assets/flipnote-editor/erase6.png differ diff --git a/assets/flipnote-editor/erase6_Adv.png b/assets/flipnote-editor/erase6_Adv.png new file mode 100644 index 0000000..65089a8 Binary files /dev/null and b/assets/flipnote-editor/erase6_Adv.png differ diff --git a/assets/flipnote-editor/erase7.png b/assets/flipnote-editor/erase7.png new file mode 100644 index 0000000..05b5101 Binary files /dev/null and b/assets/flipnote-editor/erase7.png differ diff --git a/assets/flipnote-editor/erase7_Adv.png b/assets/flipnote-editor/erase7_Adv.png new file mode 100644 index 0000000..52b63cc Binary files /dev/null and b/assets/flipnote-editor/erase7_Adv.png differ diff --git a/assets/flipnote-editor/erase8.png b/assets/flipnote-editor/erase8.png new file mode 100644 index 0000000..7ea8530 Binary files /dev/null and b/assets/flipnote-editor/erase8.png differ diff --git a/assets/flipnote-editor/erase8_Adv.png b/assets/flipnote-editor/erase8_Adv.png new file mode 100644 index 0000000..e18e660 Binary files /dev/null and b/assets/flipnote-editor/erase8_Adv.png differ diff --git a/assets/flipnote-editor/eraseaudio.png b/assets/flipnote-editor/eraseaudio.png new file mode 100644 index 0000000..a922dc7 Binary files /dev/null and b/assets/flipnote-editor/eraseaudio.png differ diff --git a/assets/flipnote-editor/erasebig.png b/assets/flipnote-editor/erasebig.png new file mode 100644 index 0000000..5f6c543 Binary files /dev/null and b/assets/flipnote-editor/erasebig.png differ diff --git a/assets/flipnote-editor/eraser.png b/assets/flipnote-editor/eraser.png new file mode 100644 index 0000000..09f72a3 Binary files /dev/null and b/assets/flipnote-editor/eraser.png differ diff --git a/assets/flipnote-editor/fill.png b/assets/flipnote-editor/fill.png new file mode 100644 index 0000000..6c21db1 Binary files /dev/null and b/assets/flipnote-editor/fill.png differ diff --git a/assets/flipnote-editor/fill1.png b/assets/flipnote-editor/fill1.png new file mode 100644 index 0000000..aaf3682 Binary files /dev/null and b/assets/flipnote-editor/fill1.png differ diff --git a/assets/flipnote-editor/fill2.png b/assets/flipnote-editor/fill2.png new file mode 100644 index 0000000..058e329 Binary files /dev/null and b/assets/flipnote-editor/fill2.png differ diff --git a/assets/flipnote-editor/fill3.png b/assets/flipnote-editor/fill3.png new file mode 100644 index 0000000..e715cfb Binary files /dev/null and b/assets/flipnote-editor/fill3.png differ diff --git a/assets/flipnote-editor/fill4.png b/assets/flipnote-editor/fill4.png new file mode 100644 index 0000000..835d663 Binary files /dev/null and b/assets/flipnote-editor/fill4.png differ diff --git a/assets/flipnote-editor/fill5.png b/assets/flipnote-editor/fill5.png new file mode 100644 index 0000000..37ac5a2 Binary files /dev/null and b/assets/flipnote-editor/fill5.png differ diff --git a/assets/flipnote-editor/fill6.png b/assets/flipnote-editor/fill6.png new file mode 100644 index 0000000..83caaf9 Binary files /dev/null and b/assets/flipnote-editor/fill6.png differ diff --git a/assets/flipnote-editor/fill7.png b/assets/flipnote-editor/fill7.png new file mode 100644 index 0000000..61b16b2 Binary files /dev/null and b/assets/flipnote-editor/fill7.png differ diff --git a/assets/flipnote-editor/fill8.png b/assets/flipnote-editor/fill8.png new file mode 100644 index 0000000..9528a16 Binary files /dev/null and b/assets/flipnote-editor/fill8.png differ diff --git a/assets/flipnote-editor/fill_selected.png b/assets/flipnote-editor/fill_selected.png new file mode 100644 index 0000000..606b66c Binary files /dev/null and b/assets/flipnote-editor/fill_selected.png differ diff --git a/assets/flipnote-editor/filmstrip-dots.png b/assets/flipnote-editor/filmstrip-dots.png new file mode 100644 index 0000000..3f838c7 Binary files /dev/null and b/assets/flipnote-editor/filmstrip-dots.png differ diff --git a/assets/flipnote-editor/first-frame.png b/assets/flipnote-editor/first-frame.png new file mode 100644 index 0000000..a3a75f3 Binary files /dev/null and b/assets/flipnote-editor/first-frame.png differ diff --git a/assets/flipnote-editor/flip_x.png b/assets/flipnote-editor/flip_x.png new file mode 100644 index 0000000..e203ae1 Binary files /dev/null and b/assets/flipnote-editor/flip_x.png differ diff --git a/assets/flipnote-editor/flip_y.png b/assets/flipnote-editor/flip_y.png new file mode 100644 index 0000000..967d90e Binary files /dev/null and b/assets/flipnote-editor/flip_y.png differ diff --git a/assets/flipnote-editor/goodVolume.png b/assets/flipnote-editor/goodVolume.png new file mode 100644 index 0000000..55ce70a Binary files /dev/null and b/assets/flipnote-editor/goodVolume.png differ diff --git a/assets/flipnote-editor/invert.png b/assets/flipnote-editor/invert.png new file mode 100644 index 0000000..5b1db54 Binary files /dev/null and b/assets/flipnote-editor/invert.png differ diff --git a/assets/flipnote-editor/last-frame.png b/assets/flipnote-editor/last-frame.png new file mode 100644 index 0000000..2298eeb Binary files /dev/null and b/assets/flipnote-editor/last-frame.png differ diff --git a/assets/flipnote-editor/layer1.png b/assets/flipnote-editor/layer1.png new file mode 100644 index 0000000..39a60ef Binary files /dev/null and b/assets/flipnote-editor/layer1.png differ diff --git a/assets/flipnote-editor/layer1_inactive.png b/assets/flipnote-editor/layer1_inactive.png new file mode 100644 index 0000000..3a59c3e Binary files /dev/null and b/assets/flipnote-editor/layer1_inactive.png differ diff --git a/assets/flipnote-editor/layer2.png b/assets/flipnote-editor/layer2.png new file mode 100644 index 0000000..7fb583e Binary files /dev/null and b/assets/flipnote-editor/layer2.png differ diff --git a/assets/flipnote-editor/layer2_inactive.png b/assets/flipnote-editor/layer2_inactive.png new file mode 100644 index 0000000..bec7f0f Binary files /dev/null and b/assets/flipnote-editor/layer2_inactive.png differ diff --git a/assets/flipnote-editor/lightbox-placeholder.png b/assets/flipnote-editor/lightbox-placeholder.png new file mode 100644 index 0000000..da7bba7 Binary files /dev/null and b/assets/flipnote-editor/lightbox-placeholder.png differ diff --git a/assets/flipnote-editor/lightbox.png b/assets/flipnote-editor/lightbox.png new file mode 100644 index 0000000..67f7606 Binary files /dev/null and b/assets/flipnote-editor/lightbox.png differ diff --git a/assets/flipnote-editor/lightbox_pressed.png b/assets/flipnote-editor/lightbox_pressed.png new file mode 100644 index 0000000..4c8eb42 Binary files /dev/null and b/assets/flipnote-editor/lightbox_pressed.png differ diff --git a/assets/flipnote-editor/loop_disabled.png b/assets/flipnote-editor/loop_disabled.png new file mode 100644 index 0000000..6401b86 Binary files /dev/null and b/assets/flipnote-editor/loop_disabled.png differ diff --git a/assets/flipnote-editor/loop_enabled.png b/assets/flipnote-editor/loop_enabled.png new file mode 100644 index 0000000..e59c803 Binary files /dev/null and b/assets/flipnote-editor/loop_enabled.png differ diff --git a/assets/flipnote-editor/musicBarEmpty.png b/assets/flipnote-editor/musicBarEmpty.png new file mode 100644 index 0000000..0dc3998 Binary files /dev/null and b/assets/flipnote-editor/musicBarEmpty.png differ diff --git a/assets/flipnote-editor/musicImportalt.png b/assets/flipnote-editor/musicImportalt.png new file mode 100644 index 0000000..b73a6ea Binary files /dev/null and b/assets/flipnote-editor/musicImportalt.png differ diff --git a/assets/flipnote-editor/musicPlay.png b/assets/flipnote-editor/musicPlay.png new file mode 100644 index 0000000..03ea293 Binary files /dev/null and b/assets/flipnote-editor/musicPlay.png differ diff --git a/assets/flipnote-editor/musicRec.png b/assets/flipnote-editor/musicRec.png new file mode 100644 index 0000000..4f71675 Binary files /dev/null and b/assets/flipnote-editor/musicRec.png differ diff --git a/assets/flipnote-editor/musicStop.png b/assets/flipnote-editor/musicStop.png new file mode 100644 index 0000000..8527191 Binary files /dev/null and b/assets/flipnote-editor/musicStop.png differ diff --git a/assets/flipnote-editor/new-frame.png b/assets/flipnote-editor/new-frame.png new file mode 100644 index 0000000..3502d40 Binary files /dev/null and b/assets/flipnote-editor/new-frame.png differ diff --git a/assets/flipnote-editor/new-frame.wav b/assets/flipnote-editor/new-frame.wav new file mode 100644 index 0000000..630cb89 Binary files /dev/null and b/assets/flipnote-editor/new-frame.wav differ diff --git a/assets/flipnote-editor/next-frame.png b/assets/flipnote-editor/next-frame.png new file mode 100644 index 0000000..549052e Binary files /dev/null and b/assets/flipnote-editor/next-frame.png differ diff --git a/assets/flipnote-editor/openpages.png b/assets/flipnote-editor/openpages.png new file mode 100644 index 0000000..ad28ac4 Binary files /dev/null and b/assets/flipnote-editor/openpages.png differ diff --git a/assets/flipnote-editor/openppm.png b/assets/flipnote-editor/openppm.png new file mode 100644 index 0000000..cb29be0 Binary files /dev/null and b/assets/flipnote-editor/openppm.png differ diff --git a/assets/flipnote-editor/pageCopy.png b/assets/flipnote-editor/pageCopy.png new file mode 100644 index 0000000..2e06622 Binary files /dev/null and b/assets/flipnote-editor/pageCopy.png differ diff --git a/assets/flipnote-editor/pageErase.png b/assets/flipnote-editor/pageErase.png new file mode 100644 index 0000000..267f75f Binary files /dev/null and b/assets/flipnote-editor/pageErase.png differ diff --git a/assets/flipnote-editor/pageInsert.png b/assets/flipnote-editor/pageInsert.png new file mode 100644 index 0000000..05db6b2 Binary files /dev/null and b/assets/flipnote-editor/pageInsert.png differ diff --git a/assets/flipnote-editor/pagePaste.png b/assets/flipnote-editor/pagePaste.png new file mode 100644 index 0000000..959dcb3 Binary files /dev/null and b/assets/flipnote-editor/pagePaste.png differ diff --git a/assets/flipnote-editor/paint1.png b/assets/flipnote-editor/paint1.png new file mode 100644 index 0000000..8e71aa9 Binary files /dev/null and b/assets/flipnote-editor/paint1.png differ diff --git a/assets/flipnote-editor/paint1_Adv.png b/assets/flipnote-editor/paint1_Adv.png new file mode 100644 index 0000000..02ab13c Binary files /dev/null and b/assets/flipnote-editor/paint1_Adv.png differ diff --git a/assets/flipnote-editor/paint2.png b/assets/flipnote-editor/paint2.png new file mode 100644 index 0000000..2a12512 Binary files /dev/null and b/assets/flipnote-editor/paint2.png differ diff --git a/assets/flipnote-editor/paint2_Adv.png b/assets/flipnote-editor/paint2_Adv.png new file mode 100644 index 0000000..ce6d37b Binary files /dev/null and b/assets/flipnote-editor/paint2_Adv.png differ diff --git a/assets/flipnote-editor/paint3.png b/assets/flipnote-editor/paint3.png new file mode 100644 index 0000000..2d3b53b Binary files /dev/null and b/assets/flipnote-editor/paint3.png differ diff --git a/assets/flipnote-editor/paint3_Adv.png b/assets/flipnote-editor/paint3_Adv.png new file mode 100644 index 0000000..f1ecaca Binary files /dev/null and b/assets/flipnote-editor/paint3_Adv.png differ diff --git a/assets/flipnote-editor/paint4.png b/assets/flipnote-editor/paint4.png new file mode 100644 index 0000000..6ebb61f Binary files /dev/null and b/assets/flipnote-editor/paint4.png differ diff --git a/assets/flipnote-editor/paint4_Adv.png b/assets/flipnote-editor/paint4_Adv.png new file mode 100644 index 0000000..3d94338 Binary files /dev/null and b/assets/flipnote-editor/paint4_Adv.png differ diff --git a/assets/flipnote-editor/paint5.png b/assets/flipnote-editor/paint5.png new file mode 100644 index 0000000..9447cbb Binary files /dev/null and b/assets/flipnote-editor/paint5.png differ diff --git a/assets/flipnote-editor/paint5_Adv.png b/assets/flipnote-editor/paint5_Adv.png new file mode 100644 index 0000000..4efb694 Binary files /dev/null and b/assets/flipnote-editor/paint5_Adv.png differ