Files
flipnote-editor/assets/flipnote-editor/arabic-text.js
T
2026-10-29 11:59:16 +00:00

214 lines
11 KiB
JavaScript

(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);