/*
rtf.js — self-contained RTF reader / writer for the Text editor.
TextRTF.toHTML(src)
src is the RTF source as a "binary string" (one char per byte, as read
from the file); characters above U+00FF are accepted as already-decoded
text. Returns { html, baseSize, baseFont } where baseSize is the
document's dominant font size in half-points and baseFont its dominant
font name. Runs at the base size / font carry no inline style, so they
follow the editor's own typography.
TextRTF.fromHTML(root, opts)
Serialises the editor DOM (a detached clone) back to RTF. opts may give
{ baseSize, baseFont } so a document keeps the defaults it was loaded
with. Images must be data: URLs (run inlineImages() first).
TextRTF.inlineImages(root)
Promise. Turns every under root into a PNG/JPEG data: URL and
records its natural size, so fromHTML() can embed it as \pict.
Supported: paragraphs, alignment, indents, headings (\outlinelevel), bold,
italic, underline, strikethrough, super/subscript, fonts, sizes, colours,
highlight, hyperlinks, bullet / numbered lists, tables, horizontal rules,
PNG/JPEG pictures, \u unicode and code-page (\'hh) text incl. DBCS (Big5,
GBK, Shift_JIS, EUC-KR).
*/
(function(){
"use strict";
// ── Code pages ───────────────────────────────────────────────────────────
var CHARSET_CP = {
0:1252, 77:10000, 128:932, 129:949, 130:949, 134:936, 136:950, 161:1253,
162:1254, 163:1258, 177:1255, 178:1256, 186:1257, 204:1251, 222:874, 238:1250
};
var CP_LABEL = {
874:"windows-874", 932:"shift_jis", 936:"gbk", 949:"euc-kr", 950:"big5",
1250:"windows-1250", 1251:"windows-1251", 1252:"windows-1252", 1253:"windows-1253",
1254:"windows-1254", 1255:"windows-1255", 1256:"windows-1256", 1257:"windows-1257",
1258:"windows-1258", 10000:"macintosh", 65001:"utf-8"
};
var decoders = {};
function decoderFor(cp){
if (decoders[cp]) return decoders[cp];
var d = null;
try { d = new TextDecoder(CP_LABEL[cp] || "windows-1252"); } catch(e){}
if (!d) d = new TextDecoder("windows-1252");
decoders[cp] = d;
return d;
}
// destinations whose whole group is dropped
var SKIP_DEST = {
stylesheet:1, info:1, header:1, headerl:1, headerr:1, headerf:1, footer:1, footerl:1,
footerr:1, footerf:1, footnote:1, nonshppict:1, listtable:1, listoverridetable:1,
revtbl:1, rsidtbl:1, xe:1, tc:1, txe:1, themedata:1, colorschememapping:1,
latentstyles:1, datastore:1, xmlnstbl:1, mmathPr:1, pnseclvl:1, filetbl:1,
pgdsctbl:1, author:1, operator:1, title:1, comment:1, company:1, doccomm:1,
keywords:1, subject:1, upr:1, "private":1, annotation:1, atnid:1, atnauthor:1,
objdata:1, objclass:1, bkmkstart:1, bkmkend:1, template:1, docvar:1, userprops:1
};
// "\*"-marked destinations we understand (every other one is skipped)
var KNOWN_STAR = { fldinst:1, shppict:1, pn:1, listtext:1 };
var SYMBOLS = {
emdash:"\u2014", endash:"\u2013", bullet:"\u2022", lquote:"\u2018", rquote:"\u2019",
ldblquote:"\u201C", rdblquote:"\u201D", emspace:"\u2003", enspace:"\u2002",
qmspace:"\u2005", zwj:"\u200D", zwnj:"\u200C", ltrmark:"", rtlmark:""
};
function defaultChar(){
return { b:false, i:false, u:false, s:false, sup:false, sub:false, v:false,
fs:24, f:-1, cf:0, bg:0, link:null };
}
function defaultPara(){
return { align:"", li:0, fi:0, intbl:false, outline:-1, ilvl:-1, ls:0, brdrb:false };
}
function copy(o){ var r = {}; for (var k in o) r[k] = o[k]; return r; }
// ════════════════════════════════════════════════════════════════════════
// Reader
// ════════════════════════════════════════════════════════════════════════
function parse(src){
var fonts = {}, colors = [], deff = 0, ansicp = 1252;
var blocks = []; // finished paragraphs / tables
var table = null, row = null, cell = null;
var para = newPara();
var st = { dest:"main", chr:defaultChar(), par:defaultPara(), uc:1, field:null,
font:null, pict:null };
var stack = [];
var bytes = [], bytesCP = 1252; // pending code-page bytes
var skipChars = 0; // \u fallback characters still to skip
var star = false; // saw "\*" for the next control word
var color = null; // colortbl entry being built
function newPara(){ return { runs:[], marker:"", listType:"" }; }
function fontCP(fi){ return cpOf(fonts[fi]); }
function cpOf(f){
if (!f) return ansicp;
if (f.cpg) return f.cpg;
if (f.charset === 1 || f.charset === undefined) return ansicp;
if (f.charset === 2) return 1252;
return CHARSET_CP[f.charset] || ansicp;
}
function curCP(){ return fontCP(st.chr.f < 0 ? deff : st.chr.f); }
function flush(){
if (!bytes.length) return;
var txt = decoderFor(bytesCP).decode(new Uint8Array(bytes));
bytes = [];
emitText(txt);
}
function emitByte(b){
if (skipChars > 0){ skipChars--; return; }
var cp = st.dest === "fonttbl" ? cpOf(st.font) : curCP();
if (bytes.length && cp !== bytesCP) flush();
bytesCP = cp;
bytes.push(b);
}
function emitText(txt){
if (!txt) return;
switch (st.dest){
case "main":
if (st.chr.v) return; // hidden text
var runs = para.runs, last = runs[runs.length - 1];
if (last && last.type === "text" && sameChr(last.chr, st.chr)) last.text += txt;
else runs.push({ type:"text", text:txt, chr:copy(st.chr) });
return;
case "fonttbl":
if (st.font) st.font.raw += txt;
return;
case "fldinst":
if (st.field) st.field.inst += txt;
return;
case "listtext":
para.marker += txt;
return;
}
}
function sameChr(a, b){
for (var k in a) if (a[k] !== b[k]) return false;
return true;
}
function emitObj(o){
if (st.dest !== "main" || st.chr.v) return;
o.chr = copy(st.chr);
para.runs.push(o);
}
function endPara(){
flush();
var p = para;
p.pp = copy(st.par);
para = newPara();
if (p.pp.intbl){
if (!cell) cell = [];
cell.push(p);
} else {
closeTable();
blocks.push(p);
}
}
function endCell(){
flush();
if (para.runs.length || !cell || !cell.length){
para.pp = copy(st.par);
if (!cell) cell = [];
cell.push(para);
}
para = newPara();
if (!row) row = [];
row.push(cell);
cell = null;
}
function endRow(){
flush();
if (cell) endCell();
if (!table) table = { type:"table", rows:[] };
if (row) table.rows.push(row);
row = null;
}
function closeTable(){
if (cell || row) endRow();
if (table){ blocks.push(table); table = null; }
}
function endGroup(){
flush();
var child = st;
st = stack.pop() || st;
if (child.dest === "pict" && child.pict && st.dest !== "skip") finishPict(child);
if (child.dest === "fonttbl" && child.font && child.font !== st.font) finishFont(child.font);
skipChars = 0;
}
function finishFont(f){
if (f.done) return;
f.done = true;
var name = f.raw.replace(/;.*$/, "").trim();
fonts[f.idx] = { name:name, charset:f.charset, cpg:f.cpg, family:f.family };
}
function finishPict(s){
var p = s.pict, mime = null;
if (p.type === "png") mime = "image/png";
else if (p.type === "jpeg") mime = "image/jpeg";
if (!mime) return; // metafiles / bitmaps can't be shown
var bin = p.bin;
if (!bin){
var hex = p.hex.replace(/[^0-9a-fA-F]/g, "");
var out = [];
for (var i = 0; i + 1 < hex.length; i += 2) out.push(String.fromCharCode(parseInt(hex.substr(i, 2), 16)));
bin = out.join("");
}
if (!bin) return;
var w = p.wgoal ? p.wgoal * (p.scalex || 100) / 100 / 15 : 0;
var h = p.hgoal ? p.hgoal * (p.scaley || 100) / 100 / 15 : 0;
if (st.dest === "main"){ // st is the enclosing group again
para.runs.push({ type:"img", src:"data:" + mime + ";base64," + btoa(bin),
w:Math.round(w), h:Math.round(h), chr:copy(st.chr) });
}
}
function control(word, param, hasParam){
// "\*" handling: unknown ignorable destinations are skipped wholesale
if (star){
star = false;
if (!KNOWN_STAR[word]){ st.dest = "skip"; return; }
}
if (st.dest === "skip") return;
if (skipChars > 0 && word !== "u"){ skipChars--; return; }
if (SKIP_DEST[word]){ st.dest = "skip"; return; }
// unicode text is valid in every text-bearing destination (incl. font names)
if (word === "u"){
flush();
emitText(String.fromCharCode(param < 0 ? param + 65536 : param));
skipChars = st.uc;
return;
}
if (word === "uc"){ st.uc = param; return; }
// ── destinations ──
switch (word){
case "fonttbl": st.dest = "fonttbl"; st.font = null; return;
case "colortbl": st.dest = "colortbl"; color = null; return;
case "pict": st.dest = "pict"; st.pict = { type:"", hex:"", bin:"", wgoal:0, hgoal:0, scalex:100, scaley:100 }; return;
case "field": st.field = { inst:"" }; return;
case "fldinst": st.dest = "fldinst"; return;
case "fldrslt":
st.dest = "main";
if (st.field){
var m = st.field.inst.match(/HYPERLINK\s+(?:\\l\s+)?"([^"]*)"/i) ||
st.field.inst.match(/HYPERLINK\s+(\S+)/i);
if (m){
var url = m[1];
if (/\\l\s/.test(st.field.inst) && url.charAt(0) !== "#") url = "#" + url;
st.chr.link = url;
}
}
return;
case "listtext": case "pntext":
st.dest = "listtext"; return;
case "pn": st.dest = "pn"; return;
case "shppict": return; // its \pict is the real picture
}
if (st.dest === "fonttbl"){
if (word === "f"){
if (st.font && !st.font.done && stack.length && stack[stack.length-1].font !== st.font) finishFont(st.font);
st.font = { idx:param, raw:"", charset:undefined, cpg:0, family:"" };
} else if (st.font){
if (word === "fcharset") st.font.charset = param;
else if (word === "cpg") st.font.cpg = param;
else if (/^f(roman|swiss|modern|script|decor|tech|bidi|nil)$/.test(word)) st.font.family = word.substr(1);
}
return;
}
if (st.dest === "colortbl"){
if (!color) color = { r:0, g:0, b:0 };
if (word === "red") color.r = param;
else if (word === "green") color.g = param;
else if (word === "blue") color.b = param;
return;
}
if (st.dest === "pict"){
var p = st.pict;
if (word === "pngblip") p.type = "png";
else if (word === "jpegblip") p.type = "jpeg";
else if (word === "emfblip" || word === "wmetafile" || word === "macpict" || word === "dibitmap" || word === "wbitmap") p.type = p.type || word;
else if (word === "picwgoal") p.wgoal = param;
else if (word === "pichgoal") p.hgoal = param;
else if (word === "picscalex") p.scalex = param;
else if (word === "picscaley") p.scaley = param;
return;
}
if (st.dest === "pn"){
if (word === "pnlvlblt") para.listType = "ul";
else if (/^pn(lvlbody|dec|ucltr|lcltr|ucrm|lcrm)$/.test(word)) para.listType = para.listType || "ol";
return;
}
var c = st.chr, pp = st.par;
switch (word){
// header
case "ansicpg": ansicp = param || 1252; return;
case "deff": deff = param; return;
// breaks
case "par": case "sect": case "page":
endPara(); return;
case "line":
flush(); emitObj({ type:"br" }); return;
case "tab":
flush(); emitText("\t"); return;
case "cell": case "nestcell":
endCell(); return;
case "row": case "nestrow":
endRow(); return;
// paragraph formatting
case "pard": st.par = defaultPara(); return;
case "ql": pp.align = ""; return;
case "qc": pp.align = "center"; return;
case "qr": pp.align = "right"; return;
case "qj": pp.align = "justify"; return;
case "li": pp.li = param; return;
case "fi": pp.fi = param; return;
case "intbl": pp.intbl = true; return;
case "outlinelevel": pp.outline = param; return;
case "ilvl": pp.ilvl = param; return;
case "ls": pp.ls = param; return;
case "brdrb": pp.brdrb = true; return;
// character formatting
case "plain":
var link = c.link;
st.chr = defaultChar(); st.chr.link = link; return;
case "b": c.b = !hasParam || param !== 0; return;
case "i": c.i = !hasParam || param !== 0; return;
case "strike": case "striked":
c.s = !hasParam || param !== 0; return;
case "ulnone": c.u = false; return;
case "super": c.sup = true; c.sub = false; return;
case "sub": c.sub = true; c.sup = false; return;
case "nosupersub": c.sup = c.sub = false; return;
case "v": c.v = !hasParam || param !== 0; return;
case "fs": c.fs = hasParam ? param : 24; return;
case "f": c.f = param; return;
case "cf": c.cf = param; return;
case "cb": case "highlight": case "chcbpat":
c.bg = param; return;
}
if (/^ul(d|dash|dashd|dashdd|db|hwave|ldash|th|thd|thdash|thdashd|thdashdd|thldash|w|wave)?$/.test(word)){
c.u = !hasParam || param !== 0;
return;
}
if (SYMBOLS.hasOwnProperty(word)){ flush(); emitText(SYMBOLS[word]); }
}
// ── tokenizer ──
var i = 0, n = src.length;
while (i < n){
var ch = src.charAt(i), code = src.charCodeAt(i);
if (ch === "{"){
flush();
stack.push(st);
st = { dest:st.dest, chr:copy(st.chr), par:copy(st.par), uc:st.uc, field:st.field,
font:st.font, pict:st.pict };
i++;
continue;
}
if (ch === "}"){
endGroup(); star = false; i++;
continue;
}
if (ch === "\\"){
var nx = src.charAt(i + 1);
if (/[a-zA-Z]/.test(nx)){
var j = i + 1;
while (j < n && /[a-zA-Z]/.test(src.charAt(j))) j++;
var word = src.substring(i + 1, j);
var k = j;
if (src.charAt(k) === "-" && /[0-9]/.test(src.charAt(k + 1))) k++;
while (k < n && /[0-9]/.test(src.charAt(k))) k++;
var hasParam = k > j;
var param = hasParam ? parseInt(src.substring(j, k), 10) : 0;
if (src.charAt(k) === " ") k++;
i = k;
if (word !== "u") flush();
if (word === "bin"){ // raw binary data follows
var data = src.substr(i, param);
if (st.dest === "pict" && st.pict) st.pict.bin = data;
i += param;
continue;
}
control(word, param, hasParam);
continue;
}
i += 2;
switch (nx){
case "'":
var hb = parseInt(src.substr(i, 2), 16);
i += 2;
if (st.dest === "skip" || st.dest === "pn" || st.dest === "pict" || isNaN(hb)) break;
if (st.dest === "colortbl") break;
emitByte(hb);
break;
case "*": star = true; break;
case "~": flush(); if (skipChars > 0) skipChars--; else emitText("\u00A0"); break;
case "_": flush(); if (skipChars > 0) skipChars--; else emitText("\u2011"); break;
case "-": break; // optional hyphen
case "\n": case "\r": flush(); if (st.dest === "main") endPara(); break;
case "\\": case "{": case "}":
if (st.dest === "skip" || st.dest === "pn") break;
emitByte(nx.charCodeAt(0)); break;
}
continue;
}
i++;
if (ch === "\r" || ch === "\n") continue;
if (st.dest === "skip" || st.dest === "pn") continue;
if (st.dest === "pict"){ st.pict.hex += ch; continue; }
if (st.dest === "colortbl"){
if (ch === ";"){ colors.push(color); color = null; }
continue;
}
if (st.dest === "fonttbl" && ch === ";"){
flush();
if (st.font){ st.font.raw += ";"; finishFont(st.font); }
continue;
}
if (code > 0xFF){ flush(); if (skipChars > 0) skipChars--; else emitText(ch); continue; }
emitByte(code);
}
flush();
if (para.runs.length) endPara();
closeTable();
return { blocks:blocks, fonts:fonts, colors:colors, deff:deff };
}
// ── model → HTML ─────────────────────────────────────────────────────────
function esc(s){ return String(s).replace(/&/g,"&").replace(//g,">"); }
function escAttr(s){ return esc(s).replace(/"/g, """); }
function colorCss(c){
if (!c) return "";
function h(v){ return ("0" + (v & 255).toString(16)).slice(-2); }
return "#" + h(c.r) + h(c.g) + h(c.b);
}
var GENERIC = { roman:"serif", swiss:"sans-serif", modern:"monospace", script:"cursive", decor:"fantasy" };
function toHTML(src){
src = src || "";
if (!/^\s*\{\\rtf/.test(src)){
// not RTF (e.g. a brand-new empty file) — show it as plain paragraphs
var lines = src.replace(/\r\n?/g, "\n").split("\n");
return { html: lines.map(function(l){ return "
" + (l ? esc(l) : "
") + "
| " + (cellHTML || " ") + " | ";
});
html += "