/* 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) : "
") + "

"; }).join(""), baseSize:24, baseFont:"" }; } var doc = parse(src); // dominant size / font become the document base (no inline style) var sizeW = {}, fontW = {}; doc.blocks.forEach(function scan(b){ if (b.type === "table"){ b.rows.forEach(function(r){ r.forEach(function(c){ c.forEach(scan); }); }); return; } if (b.pp && b.pp.outline >= 0 && b.pp.outline <= 5) return; b.runs.forEach(function(r){ if (r.type !== "text") return; var len = r.text.length, f = r.chr.f < 0 ? doc.deff : r.chr.f; sizeW[r.chr.fs] = (sizeW[r.chr.fs] || 0) + len; fontW[f] = (fontW[f] || 0) + len; }); }); function top(w, d){ var best = d, bw = -1; for (var k in w) if (w[k] > bw){ bw = w[k]; best = +k; } return best; } var baseSize = top(sizeW, 24), baseFontIdx = top(fontW, doc.deff); var baseFont = doc.fonts[baseFontIdx] ? doc.fonts[baseFontIdx].name : ""; function fontCss(idx){ var f = doc.fonts[idx]; if (!f || !f.name) return ""; var gen = GENERIC[f.family]; return "'" + f.name.replace(/'/g, "") + "'" + (gen ? ", " + gen : ""); } function runsHTML(runs, heading){ var html = "", openLink = null, buf = ""; function closeLink(){ if (openLink !== null){ html += '' + buf + ""; buf = ""; openLink = null; } } runs.forEach(function(r){ var c = r.chr, piece; if (r.type === "br") piece = "
"; else if (r.type === "img"){ piece = '"; } else { piece = esc(r.text); var style = []; var fi = c.f < 0 ? doc.deff : c.f; if (!heading && c.fs !== baseSize) style.push("font-size:" + (c.fs / 2) + "pt"); if (fi !== baseFontIdx){ var fc = fontCss(fi); if (fc) style.push("font-family:" + fc); } if (c.cf > 0 && doc.colors[c.cf]) style.push("color:" + colorCss(doc.colors[c.cf])); if (c.bg > 0 && doc.colors[c.bg]) style.push("background-color:" + colorCss(doc.colors[c.bg])); if (style.length) piece = '' + piece + ""; if (c.sup) piece = "" + piece + ""; if (c.sub) piece = "" + piece + ""; if (c.s) piece = "" + piece + ""; if (c.u && !c.link) piece = "" + piece + ""; if (c.i) piece = "" + piece + ""; if (c.b && !heading) piece = "" + piece + ""; } if (c.link !== openLink){ closeLink(); if (c.link) openLink = c.link; } if (openLink !== null) buf += piece; else html += piece; }); closeLink(); return html; } function blockStyle(pp, list){ var s = []; if (pp.align) s.push("text-align:" + pp.align); if (!list){ if (pp.li > 0) s.push("margin-left:" + Math.round(pp.li / 15) + "px"); if (pp.fi) s.push("text-indent:" + Math.round(pp.fi / 15) + "px"); } return s.length ? ' style="' + s.join(";") + '"' : ""; } function isBlank(p){ return !p.runs.some(function(r){ return r.type !== "text" || /\S/.test(r.text.replace(/\u00A0/g, "x")); }); } function paraHTML(p){ var pp = p.pp || defaultPara(); if (pp.brdrb && isBlank(p)) return "
"; var tag = (pp.outline >= 0 && pp.outline <= 5) ? "h" + (pp.outline + 1) : "p"; var inner = runsHTML(p.runs, tag !== "p"); return "<" + tag + blockStyle(pp) + ">" + (inner || "
") + ""; } function isList(p){ return p.type !== "table" && (p.marker || p.listType || (p.pp && p.pp.ls > 0)); } function listLevel(p){ if (p.pp.ilvl >= 0) return p.pp.ilvl; return Math.max(0, Math.round(p.pp.li / 720) - 1); } function listKind(p){ if (p.listType) return p.listType; return /^\s*([0-9]+|[a-zA-Z]|[ivxlcIVXLC]+)[.)]/.test(p.marker) ? "ol" : "ul"; } function tableHTML(t){ var html = ""; t.rows.forEach(function(r){ html += ""; r.forEach(function(c){ var cellHTML = c.map(function(p){ return runsHTML(p.runs, false); }).join("
"); html += ""; }); html += ""; }); return html + "
" + (cellHTML || "
") + "
"; } var out = "", bl = doc.blocks; for (var i = 0; i < bl.length; i++){ var b = bl[i]; if (b.type === "table"){ out += tableHTML(b); continue; } if (!isList(b)){ out += paraHTML(b); continue; } // a run of list paragraphs → nested