214 lines
11 KiB
JavaScript
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);
|
