Bladeren bron

Add rich text RTF mode to Text editor

Introduce full .rtf support in the Text web app with a new rich-text mode and dedicated toolbar actions (underline, colors, highlight, font size, alignment). Add `rtf.js` as a self-contained RTF parser/serializer with HTML conversion, Unicode/code-page handling, tables/lists/images, and image inlining for save/export. Update load/save/open/export flows and mode switching so markdown, rich text, and plain text coexist cleanly, and register `.rtf` in the module supported extensions.
Toby Chui 1 dag geleden
bovenliggende
commit
ac72e28fe8
3 gewijzigde bestanden met toevoegingen van 1305 en 41 verwijderingen
  1. 303 40
      src/web/Text/index.html
  2. 1 1
      src/web/Text/init.agi
  3. 1001 0
      src/web/Text/rtf.js

+ 303 - 40
src/web/Text/index.html

@@ -11,6 +11,7 @@
     <script src="lib/turndown-plugin-gfm.min.js"></script>
     <script src="lib/pdf-lib.min.js"></script>
     <script src="highlighter.js"></script>
+    <script src="rtf.js"></script>
     <style>
         *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
@@ -131,6 +132,21 @@
 
         /* hide markdown-only controls when editing plain text */
         body.mode-txt .md-only { display: none !important; }
+        /* rich-text (RTF) mode: its own formatting controls, minus markdown-only ones */
+        body.mode-rtf .no-rtf { display: none !important; }
+        body:not(.mode-rtf) .rtf-only { display: none !important; }
+
+        /* colour swatch dropdowns (RTF text colour / highlight) */
+        .swatch-menu { min-width: 0; width: 176px; }
+        .swatch-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; padding: 4px 2px 2px; }
+        .swatch {
+            width: 22px; height: 22px; border-radius: 5px; cursor: pointer; padding: 0;
+            border: 1px solid rgba(0,0,0,.18);
+        }
+        .swatch:hover { transform: scale(1.12); }
+        .glyph-color { display: inline-flex; flex-direction: column; align-items: center; width: 15px; line-height: 1; }
+        .glyph-color b { font-size: 13px; }
+        .glyph-color i { display: block; width: 14px; height: 3px; border-radius: 1px; margin-top: 1px; }
 
         /* ── Editor surfaces ──────────────────────────────────────────────── */
         #editor-scroll {
@@ -150,6 +166,12 @@
         }
         body.mode-txt #rich  { display: none; }
         body.mode-txt #plain { display: block; }
+        /* RTF keeps spaces and tabs significant */
+        body.mode-rtf #rich p, body.mode-rtf #rich li, body.mode-rtf #rich td, body.mode-rtf #rich th,
+        body.mode-rtf #rich h1, body.mode-rtf #rich h2, body.mode-rtf #rich h3,
+        body.mode-rtf #rich h4, body.mode-rtf #rich h5, body.mode-rtf #rich h6 { white-space: pre-wrap; tab-size: 4; }
+        /* keep highlighted runs readable on the dark theme */
+        body.dark.mode-rtf #rich [style*="background-color"] { color: #1d2230; }
 
         #rich:empty::before, #rich.is-empty::before {
             content: attr(data-ph); color: var(--text2); pointer-events: none;
@@ -514,11 +536,46 @@
         <button class="tb-btn md-only" id="btn-bold"   onclick="actBold()"   title="Bold"><span class="glyph"><strong>B</strong></span></button>
         <button class="tb-btn md-only" id="btn-italic" onclick="actItalic()" title="Italic"><span class="glyph"><em>I</em></span></button>
         <button class="tb-btn md-only" id="btn-strike" onclick="actStrike()" title="Strikethrough"><span class="glyph"><s>S</s></span></button>
-        <button class="tb-btn md-only" id="btn-code"   onclick="actInlineCode()" title="Inline code"><span class="glyph" style="font-family:monospace;">&lt;&gt;</span></button>
+        <button class="tb-btn rtf-only" id="btn-underline" onclick="actUnderline()" title="Underline"><span class="glyph"><u>U</u></span></button>
+        <button class="tb-btn md-only no-rtf" id="btn-code"   onclick="actInlineCode()" title="Inline code"><span class="glyph" style="font-family:monospace;">&lt;&gt;</span></button>
+
+        <div class="menu-wrap rtf-only">
+            <button class="tb-btn" onclick="toggleMenu('fore-menu')" title="Text colour">
+                <span class="glyph-color"><b>A</b><i id="fore-bar" style="background:#c00000;"></i></span>
+            </button>
+            <div class="menu swatch-menu" id="fore-menu"></div>
+        </div>
+        <div class="menu-wrap rtf-only">
+            <button class="tb-btn" onclick="toggleMenu('back-menu')" title="Highlight colour">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l-6 6v3h9l3-3"/><path d="M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg>
+            </button>
+            <div class="menu swatch-menu" id="back-menu"></div>
+        </div>
+        <select id="sel-size" class="tb-select rtf-only" style="width:58px;" onchange="actFontSize()" title="Font size (pt)">
+            <option value="">Size</option>
+            <option>8</option><option>9</option><option>10</option><option>11</option><option>12</option>
+            <option>14</option><option>16</option><option>18</option><option>20</option><option>24</option>
+            <option>28</option><option>36</option><option>48</option><option>72</option>
+        </select>
+
+        <div class="tb-sep rtf-only"></div>
+
+        <button class="tb-btn rtf-only" id="btn-align-left" onclick="actAlign('Left')" title="Align left">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-center" onclick="actAlign('Center')" title="Center">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="5" y1="18" x2="19" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-right" onclick="actAlign('Right')" title="Align right">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-full" onclick="actAlign('Full')" title="Justify">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
+        </button>
 
         <div class="tb-sep md-only"></div>
 
-        <button class="tb-btn md-only" onclick="actQuote()" title="Blockquote">
+        <button class="tb-btn md-only no-rtf" onclick="actQuote()" title="Blockquote">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h4v4H8.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7zm7 0h4v4h-2.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7z"/></svg>
         </button>
         <button class="tb-btn md-only" onclick="actBullet()" title="Bullet list">
@@ -527,7 +584,7 @@
         <button class="tb-btn md-only" onclick="actNumber()" title="Numbered list">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="10" y1="6" x2="20" y2="6"/><line x1="10" y1="12" x2="20" y2="12"/><line x1="10" y1="18" x2="20" y2="18"/><text x="2" y="8" font-size="7" fill="currentColor" stroke="none">1</text><text x="2" y="14" font-size="7" fill="currentColor" stroke="none">2</text><text x="2" y="20" font-size="7" fill="currentColor" stroke="none">3</text></svg>
         </button>
-        <button class="tb-btn md-only" onclick="actCodeBlock()" title="Code block">
+        <button class="tb-btn md-only no-rtf" onclick="actCodeBlock()" title="Code block">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
         </button>
 
@@ -638,13 +695,14 @@
                 <div class="sub">Editor behaviour and appearance.</div>
 
                 <div class="set-row">
-                    <div><div class="set-label">Document mode</div><div class="set-desc">Markdown enables live formatting; Plain text edits raw characters.</div></div>
+                    <div><div class="set-label">Document mode</div><div class="set-desc">Markdown enables live formatting; Rich text saves as RTF (fonts, colours, alignment); Plain text edits raw characters.</div></div>
                     <div class="set-ctl"><div class="seg" id="seg-mode">
                         <button data-mode="md"  onclick="setMode('md')">Markdown</button>
+                        <button data-mode="rtf" onclick="setMode('rtf')">Rich text</button>
                         <button data-mode="txt" onclick="setMode('txt')">Plain text</button>
                     </div></div>
                 </div>
-                <div class="set-row md-only">
+                <div class="set-row md-only no-rtf">
                     <div><div class="set-label">Markdown syntax</div><div class="set-desc">Reveal the markdown marks (#, **, &gt;, …) so you can see and edit the source.</div></div>
                     <div class="set-ctl"><select class="set-select" id="set-syntax" onchange="applySyntaxMode()">
                         <option value="active">Show on selected line</option>
@@ -764,6 +822,8 @@
 var filepath        = "";
 var filename        = "";
 var isTxtMode       = false;      // false = markdown WYSIWYG, true = plain text
+var isRtfMode       = false;      // rich surface saved as RTF instead of markdown (never with isTxtMode)
+var rtfDoc          = { baseSize:24, baseFont:"" };   // defaults of the loaded .rtf, kept on save
 var isDark          = false;
 var dirtyFlag       = false;
 var dlgCallback     = null;
@@ -789,7 +849,7 @@ var FONT_MAP = {
 
 // ── Shortcuts ───────────────────────────────────────────────────────────
 var DEFAULT_KEYS = {
-    save:"ctrl+s", bold:"ctrl+b", italic:"ctrl+i", strike:"ctrl+shift+x", inlineCode:"ctrl+e",
+    save:"ctrl+s", bold:"ctrl+b", italic:"ctrl+i", underline:"ctrl+u", strike:"ctrl+shift+x", inlineCode:"ctrl+e",
     h1:"ctrl+1", h2:"ctrl+2", h3:"ctrl+3", h4:"ctrl+4", h5:"ctrl+5", h6:"ctrl+6", paragraph:"ctrl+0",
     blockquote:"ctrl+shift+q", bulletList:"ctrl+shift+u", orderedList:"ctrl+shift+o",
     codeBlock:"ctrl+shift+k", link:"ctrl+k", image:"ctrl+shift+i", hr:"ctrl+shift+h"
@@ -798,8 +858,9 @@ var ACTIONS = {
     save:      { label:"Save",            fn:saveFile,                  md:false },
     bold:      { label:"Bold",            fn:actBold,                   md:true  },
     italic:    { label:"Italic",          fn:actItalic,                 md:true  },
+    underline: { label:"Underline (rich text)", fn:actUnderline,    md:true  },
     strike:    { label:"Strikethrough",   fn:actStrike,                 md:true  },
-    inlineCode:{ label:"Inline code",     fn:actInlineCode,             md:true  },
+    inlineCode:{ label:"Inline code",     fn:actInlineCode,             md:true, rtf:false },
     h1:        { label:"Heading 1",       fn:function(){setBlock("h1");}, md:true },
     h2:        { label:"Heading 2",       fn:function(){setBlock("h2");}, md:true },
     h3:        { label:"Heading 3",       fn:function(){setBlock("h3");}, md:true },
@@ -807,10 +868,10 @@ var ACTIONS = {
     h5:        { label:"Heading 5",       fn:function(){setBlock("h5");}, md:true },
     h6:        { label:"Heading 6",       fn:function(){setBlock("h6");}, md:true },
     paragraph: { label:"Paragraph",       fn:function(){setBlock("p");},  md:true },
-    blockquote:{ label:"Blockquote",      fn:actQuote,                  md:true  },
+    blockquote:{ label:"Blockquote",      fn:actQuote,                  md:true, rtf:false },
     bulletList:{ label:"Bullet list",     fn:actBullet,                 md:true  },
     orderedList:{label:"Numbered list",   fn:actNumber,                 md:true  },
-    codeBlock: { label:"Code block",      fn:actCodeBlock,              md:true  },
+    codeBlock: { label:"Code block",      fn:actCodeBlock,              md:true, rtf:false },
     link:      { label:"Link",            fn:actLink,                   md:true  },
     image:     { label:"Insert image",    fn:function(){pickDeviceImage();}, md:true },
     hr:        { label:"Horizontal rule", fn:actHr,                     md:true  }
@@ -877,11 +938,11 @@ $(function(){
     if (files && files.length > 0){
         filepath = files[0].filepath;
         filename = files[0].filename;
-        isTxtMode = !isMarkdownExt(filename);
+        modeForFile(filename);
         applyMode();
         loadFile();
     } else {
-        isTxtMode = false;
+        isTxtMode = false; isRtfMode = false;
         applyMode();
         updateTitle();
     }
@@ -922,9 +983,10 @@ $(function(){
     });
     // keep selection alive when clicking toolbar buttons
     document.getElementById("toolbar").addEventListener("mousedown", function(e){
-        if (e.target.closest(".tb-btn") && !e.target.closest("select")) e.preventDefault();
+        if (e.target.closest(".tb-btn, .swatch") && !e.target.closest("select")) e.preventDefault();
     });
 
+    buildSwatches();
     buildKeyList();
     syncSettingsUI();
     applyTypography();
@@ -935,6 +997,16 @@ function isMarkdownExt(name){
     var ext = (name.split(".").pop() || "").toLowerCase();
     return ext === "md" || ext === "markdown" || ext === "mdown" || ext === "mkd";
 }
+function isRtfExt(name){
+    return (name.split(".").pop() || "").toLowerCase() === "rtf";
+}
+// pick the editing surface from the file extension
+function modeForFile(name){
+    isRtfMode = isRtfExt(name);
+    isTxtMode = !isRtfMode && !isMarkdownExt(name);
+}
+function modeName(){ return isTxtMode ? "txt" : (isRtfMode ? "rtf" : "md"); }
+function defaultNewName(){ return "Untitled." + modeName(); }
 
 // ════════════════════════════════════════════════════════════════════════
 // File load / save
@@ -943,6 +1015,18 @@ function loadFile(){
     // strong cache-buster: unique nonce + jQuery cache:false so the browser
     // always fetches the freshly-saved file, never a stale cached copy
     var nonce = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
+    if (isRtfMode){
+        // RTF may carry raw code-page bytes, so read it byte-for-byte rather
+        // than letting the browser decode it as UTF-8
+        fetch(ao_root + "media?file=" + encodeURIComponent(filepath) + "&nocache=" + nonce, { cache:"no-store", credentials:"same-origin" })
+            .then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); })
+            .then(function(buf){
+                setContent(bytesToBinary(new Uint8Array(buf)));
+                dirtyFlag = false;
+                updateTitle(); updateStatBar();
+            }).catch(function(){ setStatus("Failed to load file", "error"); });
+        return;
+    }
     $.ajax({
         url: ao_root + "media?file=" + encodeURIComponent(filepath) + "&nocache=" + nonce,
         method: "GET",
@@ -958,9 +1042,23 @@ function loadFile(){
     });
 }
 
+function bytesToBinary(u8){
+    var parts = [];
+    for (var i = 0; i < u8.length; i += 0x8000) parts.push(String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)));
+    return parts.join("");
+}
+
 function setContent(text){
     if (isTxtMode){
         plain.value = text;
+    } else if (isRtfMode){
+        var res;
+        try { res = TextRTF.toHTML(text || ""); }
+        catch(e){ console.error(e); setStatus("Could not read this RTF file", "error"); res = { html:"<p><br></p>", baseSize:24, baseFont:"" }; }
+        rtfDoc = { baseSize:res.baseSize, baseFont:res.baseFont };
+        rich.innerHTML = res.html;
+        refreshEmptyState();
+        updateActiveBlock();
     } else {
         rich.innerHTML = renderWithBlankRuns(text || "");
         normalizeEmptyParas(rich);
@@ -1014,6 +1112,24 @@ function normalizeEmptyParas(root){
 
 function getContent(){
     if (isTxtMode) return plain.value;
+    if (isRtfMode) return TextRTF.fromHTML(richClone(), rtfDoc);
+    return richMarkdown();
+}
+// like getContent(), but RTF first embeds every image (fetched + inlined as
+// PNG/JPEG) — use this for anything that writes the document out
+function getContentAsync(){
+    if (!isRtfMode) return Promise.resolve(getContent());
+    return TextRTF.inlineImages(richClone()).then(function(root){ return TextRTF.fromHTML(root, rtfDoc); });
+}
+// the rich surface without its editing decorations
+function richClone(){
+    var src = rich.cloneNode(true);
+    var syns = src.querySelectorAll(".md-imgsyn, .md-linksyn, .md-langsel");
+    for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
+    return src;
+}
+// the rich surface as markdown (also feeds PDF export in rich-text mode)
+function richMarkdown(){
     var src = rich.cloneNode(true);
     var syns = src.querySelectorAll(".md-imgsyn, .md-linksyn, .md-langsel");
     for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
@@ -1089,7 +1205,7 @@ function richPlainText(){
 
 function saveFile(){
     if (!filepath){
-        var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
+        var def = defaultNewName();
         ao_module_openFileSelector(handleSaveAs, "user:/Desktop", "new", false, { defaultName: def });
         return;
     }
@@ -1101,26 +1217,27 @@ function handleSaveAs(fd){
     doSave();
 }
 function doSave(callback){
-    var content = getContent();
-    ao_module_agirun("Text/backend/filesaver.agi", { filepath: filepath, content: content }, function(data){
-        if (data && data.error){
-            setStatus("Save failed: " + data.error, "error");
-        } else {
-            dirtyFlag = false; updateTitle(); setStatus("Saved");
-            if (callback) callback();
-        }
-    }, function(){ setStatus("Save failed", "error"); });
+    getContentAsync().then(function(content){
+        ao_module_agirun("Text/backend/filesaver.agi", { filepath: filepath, content: content }, function(data){
+            if (data && data.error){
+                setStatus("Save failed: " + data.error, "error");
+            } else {
+                dirtyFlag = false; updateTitle(); setStatus("Saved");
+                if (callback) callback();
+            }
+        }, function(){ setStatus("Save failed", "error"); });
+    }).catch(function(err){ console.error(err); setStatus("Save failed", "error"); });
 }
 
 function openFile(){
     ao_module_openFileSelector(handleOpenFile, "user:/", "file", false, {
-        filter: ["txt","md","markdown","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
+        filter: ["txt","md","markdown","rtf","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
     });
 }
 function handleOpenFile(fd){
     if (!fd || !fd.length) return;
     filepath = fd[0].filepath; filename = fd[0].filename;
-    isTxtMode = !isMarkdownExt(filename);
+    modeForFile(filename);
     applyMode();
     dirtyFlag = false;
     loadFile();
@@ -1131,23 +1248,46 @@ function handleOpenFile(fd){
 // ════════════════════════════════════════════════════════════════════════
 function applyMode(){
     document.body.classList.toggle("mode-txt", isTxtMode);
-    $("#stat-mode").text(isTxtMode ? "Plain text" : "Markdown");
-    $("#mi-download-lbl").text(isTxtMode ? "Download .txt" : "Download .md");
+    document.body.classList.toggle("mode-rtf", isRtfMode);
+    $("#stat-mode").text(isTxtMode ? "Plain text" : (isRtfMode ? "Rich text" : "Markdown"));
+    $("#mi-download-lbl").text("Download ." + modeName());
+    $("#rich").attr("data-ph", isRtfMode ? "Start writing…" : "Start writing… type # for a heading, ** for bold");
     $("#seg-mode button").removeClass("active");
-    $('#seg-mode button[data-mode="' + (isTxtMode ? "txt" : "md") + '"]').addClass("active");
+    $('#seg-mode button[data-mode="' + modeName() + '"]').addClass("active");
     applyTypography();
     updateActiveBlock();
 }
 
 // invoked from the settings segmented control — converts current content
 function setMode(mode){
-    var wantTxt = (mode === "txt");
-    if (wantTxt === isTxtMode) return;
-    var current = getContent();           // serialise from the current surface
-    isTxtMode = wantTxt;
-    applyMode();
-    setContent(current);                  // re-hydrate into the new surface
-    markDirty(); updateStatBar();
+    var from = modeName();
+    if (mode === from) return;
+    function to(m){ isTxtMode = (m === "txt"); isRtfMode = (m === "rtf"); applyMode(); }
+    if (from !== "txt" && mode !== "txt"){
+        // markdown <-> rich text share the same WYSIWYG surface: keep the DOM
+        if (mode === "rtf"){ clearDecorations(); clearLangSelectors(); tidyRichForRtf(); }
+        to(mode);
+        markDirty(); updateStatBar();
+        return;
+    }
+    getContentAsync().then(function(current){    // serialise from the current surface
+        to(mode);
+        setContent(current);                     // re-hydrate into the new surface
+        markDirty(); updateStatBar();
+    });
+}
+// marked's HTML carries layout newlines (between blocks, soft line breaks);
+// rich-text mode shows whitespace literally, so flatten them first
+function tidyRichForRtf(){
+    var walker = document.createTreeWalker(rich, NodeFilter.SHOW_TEXT, null, false);
+    var n, dead = [];
+    while ((n = walker.nextNode())){
+        if (n.parentNode.closest && n.parentNode.closest("pre")) continue;
+        if (n.nodeValue.indexOf("\n") < 0) continue;
+        if (/^\s*$/.test(n.nodeValue)) dead.push(n);
+        else n.nodeValue = n.nodeValue.replace(/\s*\n\s*/g, " ");
+    }
+    dead.forEach(function(d){ d.parentNode.removeChild(d); });
 }
 
 // ════════════════════════════════════════════════════════════════════════
@@ -1732,6 +1872,7 @@ function exec(cmd, val){
 function actBold(){   if(isTxtMode) return; exec("bold"); }
 function actItalic(){ if(isTxtMode) return; exec("italic"); }
 function actStrike(){ if(isTxtMode) return; exec("strikeThrough"); }
+function actUnderline(){ if(isRtfMode) exec("underline"); }   // markdown has no underline
 function actBullet(){ if(isTxtMode) return; exec("insertUnorderedList"); }
 function actNumber(){ if(isTxtMode) return; exec("insertOrderedList"); }
 function actInlineCode(){ if(isTxtMode) return; wrapSelection("code"); }
@@ -1853,6 +1994,106 @@ function onHeadingSelect(){
     setBlock($("#sel-head").val());
 }
 
+// ── Rich text (RTF) formatting ──────────────────────────────────────────
+var FORE_SWATCHES = ["#000000","#595959","#a6a6a6","#c00000","#e36c09","#bf8f00",
+                     "#00b050","#0f7c80","#0070c0","#002060","#7030a0","#d6337a"];
+var BACK_SWATCHES = ["#ffff00","#00ff00","#00ffff","#ff00ff","#ffc000","#ff9999",
+                     "#b4c7e7","#c5e0b4","#d9d9d9","#a9d18e","#f4b183","#ffe699"];
+var AUTO_SENTINEL = "#010203";         // marks "automatic colour" runs for clean-up
+function buildSwatches(){
+    function fill(id, list, kind, noneLabel){
+        var html = '<button class="menu-item swatch-none" onclick="hideMenus();actColor(\'' + kind + '\', null)">' + noneLabel + '</button><div class="swatch-grid">';
+        list.forEach(function(c){
+            html += '<button class="swatch" title="' + c + '" style="background:' + c + ';" onclick="hideMenus();actColor(\'' + kind + '\', \'' + c + '\')"></button>';
+        });
+        document.getElementById(id).innerHTML = html + "</div>";
+    }
+    fill("fore-menu", FORE_SWATCHES, "fore", "Automatic");
+    fill("back-menu", BACK_SWATCHES, "back", "No highlight");
+}
+// text / highlight colour of the selection; null = automatic / none
+function actColor(kind, color){
+    if (!isRtfMode) return;
+    rich.focus();
+    try { document.execCommand("styleWithCSS", false, true); } catch(e){}
+    if (kind === "fore"){
+        document.execCommand("foreColor", false, color || AUTO_SENTINEL);
+        if (color) $("#fore-bar").css("background", color);
+        else stripSentinelColor();
+    } else {
+        document.execCommand("hiliteColor", false, color || "transparent");
+    }
+    try { document.execCommand("styleWithCSS", false, false); } catch(e){}
+    markDirty(); updateStatBar();
+}
+function stripSentinelColor(){
+    $(rich).find('[style*="rgb(1, 2, 3)"], font[color="' + AUTO_SENTINEL + '"]').each(function(){
+        this.style.color = "";
+        this.removeAttribute("color");
+        if ((this.tagName === "SPAN" || this.tagName === "FONT") && !this.getAttribute("style") && this.attributes.length <= 1) $(this).contents().unwrap();
+    });
+}
+function actAlign(dir){
+    if (!isRtfMode) return;
+    exec("justify" + dir);
+}
+// font size in points; a collapsed caret gets an empty sized span to type into
+function actFontSize(){
+    var pt = $("#sel-size").val();
+    if (!isRtfMode || !pt) return;
+    rich.focus();
+    var sel = getSel();
+    if (!sel.rangeCount || !rich.contains(sel.anchorNode)) return;
+    if (sel.isCollapsed){
+        var span = document.createElement("span");
+        span.style.fontSize = pt + "pt";
+        span.appendChild(document.createTextNode("​"));
+        var r = sel.getRangeAt(0);
+        r.insertNode(span);
+        var nr = document.createRange(); nr.setStart(span.firstChild, 1); nr.collapse(true);
+        sel.removeAllRanges(); sel.addRange(nr);
+    } else {
+        document.execCommand("fontSize", false, "7");
+        $(rich).find('font[size="7"]').each(function(){
+            var sp = document.createElement("span");
+            sp.style.fontSize = pt + "pt";
+            while (this.firstChild) sp.appendChild(this.firstChild);
+            // the new size wins over any size set inside the selection
+            $(sp).find("[style]").each(function(){ this.style.fontSize = ""; });
+            $(sp).find("font[size]").each(function(){ this.removeAttribute("size"); });
+            this.parentNode.replaceChild(sp, this);
+        });
+    }
+    markDirty(); updateStatBar();
+}
+// image picked / pasted / dropped in rich-text mode: embed it in the document
+function insertEmbeddedImage(file){
+    prepareBlob(file, function(blob){
+        var fr = new FileReader();
+        fr.onload = function(){
+            rich.focus();
+            document.execCommand("insertHTML", false, '<img src="' + escapeAttr(fr.result) + '" alt="">');
+            refreshEmptyState(); markDirty(); setStatus("Image inserted");
+        };
+        fr.onerror = function(){ setStatus("Could not read the image", "error"); };
+        fr.readAsDataURL(blob);
+    });
+}
+function updateRtfToolbarState(){
+    try { $("#btn-underline").toggleClass("active", document.queryCommandState("underline")); } catch(e){}
+    var n = getSel().anchorNode;
+    if (n && n.nodeType === 3) n = n.parentNode;
+    if (!n || !rich.contains(n)) return;
+    // queryCommandState("justify…") is unreliable, so read the block's alignment
+    var b = currentBlock();
+    var ta = b ? getComputedStyle(b).textAlign : "left";
+    var cur = ta === "center" ? "center" : (ta === "right" || ta === "end") ? "right" : ta === "justify" ? "full" : "left";
+    ["left","center","right","full"].forEach(function(d){ $("#btn-align-" + d).toggleClass("active", d === cur); });
+    var pt = Math.round(parseFloat(getComputedStyle(n).fontSize) * 0.75 * 2) / 2;
+    var opt = $('#sel-size option').filter(function(){ return this.value === String(pt); });
+    $("#sel-size").val(opt.length ? String(pt) : "");
+}
+
 function wrapSelection(tag){
     rich.focus();
     var sel = getSel();
@@ -1884,6 +2125,7 @@ function updateToolbarState(){
     if (!/^h[1-6]$/.test(tag)) tag = "p";
     $("#sel-head").val(tag);
     $("#btn-code").toggleClass("active", !!(getSel().anchorNode && getSel().anchorNode.parentNode && getSel().anchorNode.parentNode.closest("code")));
+    if (isRtfMode) updateRtfToolbarState();
 }
 
 // ── Markdown source-mark reveal ─────────────────────────────────────────
@@ -1944,7 +2186,7 @@ function updateActiveBlock(){
     if (isTxtMode) return;
     if (activeImgSyn() || activeLinkSyn()) return;   // never tear down a mark being edited
     clearDecorations();
-    if (settings.syntax === "none") return;             // Always hide
+    if (settings.syntax === "none" || isRtfMode) return;   // Always hide / no markdown marks in RTF
     if (settings.syntax === "always"){
         var all = rich.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,blockquote");
         for (var j = 0; j < all.length; j++) decorateBlock(all[j]);
@@ -2078,6 +2320,7 @@ function globalKeydown(e){
             var def = ACTIONS[action];
             if (!def) continue;
             if (def.md && isTxtMode) continue;    // markdown-only action in txt mode
+            if (def.rtf === false && isRtfMode) continue;   // no RTF equivalent
             if (def.md && (activeImgSyn() || activeLinkSyn())) continue; // don't format inside a source mark
             e.preventDefault();
             def.fn();
@@ -2188,13 +2431,14 @@ function ensureImgDir(cb){
 
 function pickDeviceImage(){
     if (isTxtMode) return;
-    if (!filepath){ alert("Please save the document first."); return; }
+    if (!filepath && !isRtfMode){ alert("Please save the document first."); return; }
     document.getElementById("device-file").click();
 }
 function onDeviceFileChosen(e){
     var file = e.target.files[0];
     e.target.value = "";
     if (!file) return;
+    if (isRtfMode){ insertEmbeddedImage(file); return; }
     ensureImgDir(function(rel){
         prepareBlob(file, function(blob, ext){
             var fname = makeImgName(file.name, ext);
@@ -2233,7 +2477,7 @@ function prepareBlob(file, cb){
 
 function pickServerImage(){
     if (isTxtMode) return;
-    if (!filepath){ alert("Please save the document first."); return; }
+    if (!filepath && !isRtfMode){ alert("Please save the document first."); return; }
     ao_module_openFileSelector(handleServerImage, "user:/", "file", false, {
         filter: ["jpg","jpeg","png","gif","webp","bmp","svg"]
     });
@@ -2241,6 +2485,14 @@ function pickServerImage(){
 function handleServerImage(fd){
     if (!fd || !fd.length) return;
     var src = fd[0].filepath, name = fd[0].filename;
+    if (isRtfMode){
+        setStatus("Importing image…");
+        fetch(ao_root + "media?file=" + encodeURIComponent(src), { credentials:"same-origin" })
+            .then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.blob(); })
+            .then(function(b){ insertEmbeddedImage(new File([b], name, { type:b.type })); })
+            .catch(function(){ setStatus("Image import failed", "error"); });
+        return;
+    }
     var rel = relDir();
     var dest = makeImgName(name, settings.compress ? "jpg" : null);
     setStatus("Importing image…");
@@ -2300,6 +2552,7 @@ function onRichDrop(e){
     }
 }
 function handleDroppedImage(file){
+    if (isRtfMode){ insertEmbeddedImage(file); return; }
     if (!filepath){ alert("Please save the document first to store pasted images."); return; }
     ensureImgDir(function(rel){
         prepareBlob(file, function(blob, ext){
@@ -2318,6 +2571,7 @@ function handleDroppedImage(file){
 // ════════════════════════════════════════════════════════════════════════
 function renderedHTML(){
     if (isTxtMode) return "<pre>" + escapeHtml(plain.value) + "</pre>";
+    if (isRtfMode) return richClone().innerHTML.replace(/​/g, "");
     return renderWithBlankRuns(getContent());
 }
 
@@ -2437,6 +2691,15 @@ function collectDocImages(){
 }
 
 function exportDocument(){
+    if (isRtfMode){                       // images are embedded in the RTF itself
+        showBusy("Building document…");
+        getContentAsync().then(function(rtf){
+            hideBusy();
+            downloadBlob(new Blob([rtf], { type:"application/rtf" }), docName() + ".rtf");
+            setStatus("Downloaded " + docName() + ".rtf");
+        }).catch(function(){ hideBusy(); setStatus("Export failed", "error"); });
+        return;
+    }
     var content = getContent();
     var ext  = isTxtMode ? "txt" : "md";
     var name = docName() + "." + ext;
@@ -2864,7 +3127,7 @@ function pdfBuild(){
             });
             return chain.then(function(){ return pdf.save(); });
         }
-        return pdfRenderTokens(P, marked.lexer(getContent())).then(function(){ return pdf.save(); });
+        return pdfRenderTokens(P, marked.lexer(isRtfMode ? richMarkdown() : getContent())).then(function(){ return pdf.save(); });
     });
 }
 
@@ -2896,7 +3159,7 @@ function syncSettingsUI(){
     $("#quality-val").text(settings.quality + "%");
     $("#set-maxw").val(settings.maxWidth);
     $("#seg-mode button").removeClass("active");
-    $('#seg-mode button[data-mode="'+(isTxtMode?"txt":"md")+'"]').addClass("active");
+    $('#seg-mode button[data-mode="'+modeName()+'"]').addClass("active");
     onCompressToggle(true);
 }
 
@@ -3047,7 +3310,7 @@ function dlgDiscard(){ var cb=dlgCallback; hideDialog(); dlgCallback=null; if(cb
 function dlgSave(){
     pendingClose = dlgCallback; dlgCallback = null; hideDialog();
     if (!filepath){
-        var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
+        var def = defaultNewName();
         ao_module_openFileSelector(handleDlgSaveAs, "user:/Desktop", "new", false, { defaultName: def });
     } else {
         doSave(function(){ if (pendingClose){ pendingClose("saved"); pendingClose=null; } });

+ 1 - 1
src/web/Text/init.agi

@@ -15,7 +15,7 @@ var moduleLaunchInfo = {
     LaunchFWDir: "Text/index.html",
     SupportEmb: false,
     InitFWSize: [940, 620],
-    SupportedExt: [".txt", ".md", ".markdown", ".mdown", ".mkd", ".csv", ".log", ".ini", ".conf"]
+    SupportedExt: [".txt", ".md", ".markdown", ".mdown", ".mkd", ".rtf", ".csv", ".log", ".ini", ".conf"]
 }
 
 registerModule(JSON.stringify(moduleLaunchInfo));

+ 1001 - 0
src/web/Text/rtf.js

@@ -0,0 +1,1001 @@
+/*
+    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 <img> 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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
+function escAttr(s){ return esc(s).replace(/"/g, "&quot;"); }
+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 "<p>" + (l ? esc(l) : "<br>") + "</p>"; }).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 += '<a href="' + escAttr(openLink) + '">' + buf + "</a>"; buf = ""; openLink = null; }
+        }
+        runs.forEach(function(r){
+            var c = r.chr, piece;
+            if (r.type === "br") piece = "<br>";
+            else if (r.type === "img"){
+                piece = '<img src="' + r.src + '" alt=""' + (r.w ? ' width="' + r.w + '"' : "") + ">";
+            } 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 = '<span style="' + escAttr(style.join(";")) + '">' + piece + "</span>";
+                if (c.sup) piece = "<sup>" + piece + "</sup>";
+                if (c.sub) piece = "<sub>" + piece + "</sub>";
+                if (c.s)   piece = "<s>" + piece + "</s>";
+                if (c.u && !c.link) piece = "<u>" + piece + "</u>";
+                if (c.i)   piece = "<i>" + piece + "</i>";
+                if (c.b && !heading) piece = "<b>" + piece + "</b>";
+            }
+            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 "<hr>";
+        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 || "<br>") + "</" + tag + ">";
+    }
+    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 = "<table><tbody>";
+        t.rows.forEach(function(r){
+            html += "<tr>";
+            r.forEach(function(c){
+                var cellHTML = c.map(function(p){ return runsHTML(p.runs, false); }).join("<br>");
+                html += "<td>" + (cellHTML || "<br>") + "</td>";
+            });
+            html += "</tr>";
+        });
+        return html + "</tbody></table>";
+    }
+
+    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 <ul>/<ol>
+        var stackL = [];                 // open list tags
+        var j = i;
+        for (; j < bl.length && isList(bl[j]); j++){
+            var p = bl[j], lvl = Math.min(listLevel(p), 8), kind = listKind(p);
+            while (stackL.length > lvl + 1){ out += "</li></" + stackL.pop() + ">"; }
+            if (stackL.length === lvl + 1 && stackL[lvl] !== kind){ out += "</li></" + stackL.pop() + ">"; }
+            if (stackL.length === lvl + 1) out += "</li>";
+            while (stackL.length < lvl + 1){ out += "<" + kind + ">"; stackL.push(kind); }
+            out += "<li" + blockStyle(p.pp, true) + ">" + (runsHTML(p.runs, false) || "<br>");
+        }
+        while (stackL.length) out += "</li></" + stackL.pop() + ">";
+        i = j - 1;
+    }
+    return { html: out || "<p><br></p>", baseSize: baseSize, baseFont: baseFont };
+}
+
+// ════════════════════════════════════════════════════════════════════════
+// Writer
+// ════════════════════════════════════════════════════════════════════════
+var HEAD_SCALE = { H1:2, H2:1.6, H3:1.3, H4:1.1, H5:1, H6:0.9 };
+var FONT_SIZE_ATTR = { 1:8, 2:10, 3:12, 4:14, 5:18, 6:24, 7:36 };
+var BLOCK_TAGS = /^(P|DIV|H[1-6]|UL|OL|LI|BLOCKQUOTE|PRE|TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|HR|SECTION|ARTICLE|FIGURE|HEADER|FOOTER|ASIDE|NAV|ADDRESS|DL|DT|DD)$/;
+var MONO = "Courier New";
+
+function parseColor(v){
+    if (!v) return null;
+    v = String(v).trim().toLowerCase();
+    if (v === "transparent" || v === "inherit" || v === "initial" || v === "currentcolor") return null;
+    var m = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
+    if (m){
+        var h = m[1];
+        if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
+        return { r:parseInt(h.substr(0,2),16), g:parseInt(h.substr(2,2),16), b:parseInt(h.substr(4,2),16) };
+    }
+    m = v.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)(?:[\s,/]+([\d.]+%?))?\s*\)$/);
+    if (m){
+        if (m[4] !== undefined && parseFloat(m[4]) === 0) return null;
+        return { r:+m[1], g:+m[2], b:+m[3] };
+    }
+    // named colours: let the browser resolve them
+    try {
+        var cv = document.createElement("canvas").getContext("2d");
+        cv.fillStyle = "#000001"; cv.fillStyle = v;
+        if (cv.fillStyle !== "#000001") return parseColor(cv.fillStyle);
+    } catch(e){}
+    return null;
+}
+function firstFamily(v){
+    if (!v) return "";
+    var f = String(v).split(",")[0].trim().replace(/^['"]|['"]$/g, "");
+    var g = f.toLowerCase();
+    if (g === "monospace") return MONO;
+    if (g === "serif") return "Times New Roman";
+    if (g === "sans-serif" || g === "system-ui" || g === "-apple-system" || g === "inherit") return "";
+    return f;
+}
+// CSS font-size → half-points (relative sizes resolve against the current size)
+function sizeHalfPts(v, cur){
+    if (!v) return 0;
+    var m = String(v).trim().match(/^([\d.]+)(pt|px|em|rem|%)?$/i);
+    if (!m) return 0;
+    var n = parseFloat(m[1]), unit = (m[2] || "px").toLowerCase();
+    var pt = unit === "pt" ? n : unit === "px" ? n * 0.75 : unit === "%" ? cur / 2 * n / 100 : cur / 2 * n;
+    return Math.max(2, Math.round(pt * 2));
+}
+
+function fromHTML(root, opts){
+    opts = opts || {};
+    var base = opts.baseSize || 24;
+    var fonts = [opts.baseFont || "Calibri"], fontIdx = {};
+    fontIdx[fonts[0].toLowerCase()] = 0;
+    var colors = [], colorIdx = {};
+    var out = [];
+
+    function fontNo(name){
+        if (!name) return 0;
+        var k = name.toLowerCase();
+        if (fontIdx[k] === undefined){ fontIdx[k] = fonts.length; fonts.push(name); }
+        return fontIdx[k];
+    }
+    function colorNo(c){
+        if (!c) return 0;
+        var k = c.r + "," + c.g + "," + c.b;
+        if (colorIdx[k] === undefined){ colors.push(c); colorIdx[k] = colors.length; }
+        return colorIdx[k];
+    }
+    function escText(s){
+        var r = "";
+        for (var i = 0; i < s.length; i++){
+            var c = s.charCodeAt(i), ch = s.charAt(i);
+            if (ch === "\\" || ch === "{" || ch === "}") r += "\\" + ch;
+            else if (ch === "\t") r += "\\tab ";
+            else if (ch === "\n") r += "\\line ";
+            else if (c === 0xA0) r += "\\~";
+            else if (c === 0x200B || c === 0xFEFF || c === 0x0D) continue;
+            else if (c < 0x20) continue;
+            else if (c < 0x80) r += ch;
+            else r += "\\u" + (c > 32767 ? c - 65536 : c) + "?";
+        }
+        return r;
+    }
+
+    // ── inline content → list of items ──
+    function fmtOf(el, f){
+        var n = copy(f), tag = el.tagName;
+        if (tag === "B" || tag === "STRONG") n.b = true;
+        else if (tag === "I" || tag === "EM" || tag === "CITE") n.i = true;
+        else if (tag === "U" || tag === "INS") n.u = true;
+        else if (tag === "S" || tag === "STRIKE" || tag === "DEL") n.s = true;
+        else if (tag === "SUP"){ n.sup = true; n.sub = false; }
+        else if (tag === "SUB"){ n.sub = true; n.sup = false; }
+        else if (tag === "CODE" || tag === "KBD" || tag === "TT" || tag === "SAMP") n.font = MONO;
+        else if (tag === "MARK") n.bg = n.bg || { r:255, g:255, b:0 };
+        if (tag === "FONT"){
+            var fc = parseColor(el.getAttribute("color")); if (fc) n.color = fc;
+            var ff = firstFamily(el.getAttribute("face")); if (ff) n.font = ff;
+            var fz = FONT_SIZE_ATTR[el.getAttribute("size")]; if (fz) n.size = fz * 2;
+        }
+        var s = el.style;
+        if (s){
+            if (s.fontWeight){ n.b = s.fontWeight === "bold" || s.fontWeight === "bolder" || parseInt(s.fontWeight, 10) >= 600; }
+            if (s.fontStyle){ n.i = s.fontStyle === "italic" || s.fontStyle === "oblique"; }
+            var td = s.textDecorationLine || s.textDecoration || "";
+            if (td.indexOf("underline") >= 0) n.u = true;
+            if (td.indexOf("line-through") >= 0) n.s = true;
+            if (td === "none"){ n.u = false; n.s = false; }
+            if (s.verticalAlign === "super"){ n.sup = true; n.sub = false; }
+            if (s.verticalAlign === "sub"){ n.sub = true; n.sup = false; }
+            if (s.fontSize){ var hp = sizeHalfPts(s.fontSize, n.size || base); if (hp) n.size = hp; }
+            if (s.fontFamily){ var fam = firstFamily(s.fontFamily); if (fam) n.font = fam; }
+            if (s.color){ var col = parseColor(s.color); if (col) n.color = col; }
+            if (s.backgroundColor){
+                var bg = parseColor(s.backgroundColor);
+                n.bg = bg;                                  // transparent clears it
+            }
+        }
+        return n;
+    }
+    function collect(nodes, f, items){
+        for (var i = 0; i < nodes.length; i++){
+            var nd = nodes[i];
+            if (nd.nodeType === 3){
+                var t = nd.nodeValue.replace(/\u200B/g, "");
+                if (t) items.push({ t:"text", text:t, f:f });
+                continue;
+            }
+            if (nd.nodeType !== 1) continue;
+            var tag = nd.tagName;
+            if (tag === "BR"){ items.push({ t:"br" }); continue; }
+            if (tag === "IMG"){ items.push({ t:"img", el:nd, f:f }); continue; }
+            if (tag === "SCRIPT" || tag === "STYLE" || tag === "SELECT") continue;
+            if (tag === "A" && nd.getAttribute("href")){
+                var sub = [];
+                collect(nd.childNodes, fmtOf(nd, f), sub);
+                items.push({ t:"link", url:nd.getAttribute("href"), items:sub });
+                continue;
+            }
+            if (BLOCK_TAGS.test(tag)){                      // stray block inside inline flow
+                collect(nd.childNodes, f, items);
+                items.push({ t:"br" });
+                continue;
+            }
+            collect(nd.childNodes, fmtOf(nd, f), items);
+        }
+    }
+    function fmtCodes(f, extra){
+        var c = "";
+        if (f.b) c += "\\b";
+        if (f.i) c += "\\i";
+        if (f.u) c += "\\ul";
+        if (f.s) c += "\\strike";
+        if (f.sup) c += "\\super";
+        if (f.sub) c += "\\sub";
+        if (f.font) c += "\\f" + fontNo(f.font);
+        if (f.size && f.size !== base) c += "\\fs" + f.size;
+        if (f.color) c += "\\cf" + colorNo(f.color);
+        if (f.bg) c += "\\highlight" + colorNo(f.bg);
+        return c + (extra || "");
+    }
+    function itemsRTF(items){
+        // drop the trailing placeholder <br> contenteditable leaves in a line
+        while (items.length && items[items.length - 1].t === "br") items.pop();
+        var r = "", i = 0;
+        while (i < items.length){
+            var it = items[i];
+            if (it.t === "br"){ r += "\\line "; i++; continue; }
+            if (it.t === "img"){ r += pictRTF(it.el); i++; continue; }
+            if (it.t === "link"){
+                r += "{\\field{\\*\\fldinst{HYPERLINK \"" + escText(it.url).replace(/"/g, "%22") + "\"}}{\\fldrslt{\\ul\\cf" +
+                     colorNo({ r:5, g:99, b:193 }) + " " + itemsRTF(it.items.slice()) + "}}}";
+                i++; continue;
+            }
+            // merge consecutive text items with the same formatting
+            var codes = fmtCodes(it.f), text = it.text;
+            i++;
+            while (i < items.length && items[i].t === "text" && fmtCodes(items[i].f) === codes){ text += items[i].text; i++; }
+            r += codes ? "{" + codes + " " + escText(text) + "}" : escText(text);
+        }
+        return r;
+    }
+    function pictRTF(img){
+        var src = img.getAttribute("src") || "";
+        var m = src.match(/^data:image\/(png|jpe?g);base64,(.*)$/i);
+        if (!m) return "";
+        var bin;
+        try { bin = atob(m[2]); } catch(e){ return ""; }
+        var hex = [];
+        for (var i = 0; i < bin.length; i++){
+            hex.push(("0" + bin.charCodeAt(i).toString(16)).slice(-2));
+            if (i % 64 === 63) hex.push("\r\n");
+        }
+        var natW = +img.getAttribute("data-natw") || +img.getAttribute("width") || 300;
+        var natH = +img.getAttribute("data-nath") || +img.getAttribute("height") || 150;
+        var dispW = +img.getAttribute("width") || parseFloat(img.style.width) || natW;
+        var dispH = +img.getAttribute("height") || parseFloat(img.style.height) || Math.round(natH * dispW / natW);
+        if (dispW > 624){ dispH = Math.round(dispH * 624 / dispW); dispW = 624; }
+        var type = /png/i.test(m[1]) ? "\\pngblip" : "\\jpegblip";
+        return "{\\pict" + type + "\\picw" + natW + "\\pich" + natH + "\\picwgoal" + Math.round(dispW * 15) +
+               "\\pichgoal" + Math.round(dispH * 15) + "\r\n" + hex.join("") + "}";
+    }
+
+    // ── blocks ──
+    function alignOf(el, inherited){
+        var a = (el.style && el.style.textAlign) || el.getAttribute("align") || "";
+        a = a.toLowerCase();
+        if (a === "center") return "\\qc";
+        if (a === "right" || a === "end") return "\\qr";
+        if (a === "justify") return "\\qj";
+        if (a === "left" || a === "start") return "";
+        return inherited || "";
+    }
+    function indentOf(el){
+        var ml = el.style ? el.style.marginLeft : "";
+        var m = ml && ml.match(/^([\d.]+)(px|pt)?$/);
+        return m ? Math.round(parseFloat(m[1]) * (m[2] === "pt" ? 20 : 15)) : 0;
+    }
+    function textIndentOf(el){
+        var ti = el.style ? el.style.textIndent : "";
+        var m = ti && ti.match(/^(-?[\d.]+)(px|pt)?$/);
+        return m ? Math.round(parseFloat(m[1]) * (m[2] === "pt" ? 20 : 15)) : 0;
+    }
+    function para(nodes, ctx, opt){
+        opt = opt || {};
+        var f = { b:false, i:false, u:false, s:false, sup:false, sub:false };
+        if (opt.font) f.font = opt.font;
+        if (ctx.bold) f.b = true;
+        if (opt.heading){ f.b = true; f.size = Math.round(base * HEAD_SCALE[opt.heading] / 2) * 2; }
+        var items = [];
+        collect(nodes, f, items);
+        var head = "\\pard" + (ctx.intbl ? "\\intbl" : "") + (opt.align || "");
+        var li = (ctx.indent || 0) + (opt.li || 0);
+        if (li) head += "\\li" + li;
+        if (opt.fi) head += "\\fi" + opt.fi;
+        if (opt.heading) head += "\\outlinelevel" + (parseInt(opt.heading.charAt(1), 10) - 1) + "\\sb240";
+        head += "\\sa" + (opt.sa === undefined ? 120 : opt.sa) + "\\plain\\f0\\fs" + base + " ";
+        out.push(head + (opt.prefix || "") + itemsRTF(items) + (opt.end || "\\par") + "\r\n");
+    }
+    function hasContent(nodes){
+        for (var i = 0; i < nodes.length; i++){
+            var n = nodes[i];
+            if (n.nodeType === 3 && n.nodeValue.replace(/[\u200B\s]/g, "") !== "") return true;
+            if (n.nodeType === 1) return true;
+        }
+        return false;
+    }
+    function container(el, ctx){
+        var buf = [];
+        function flushInline(){
+            if (buf.length && hasContent(buf)) para(buf, ctx, { align:ctx.align });
+            buf = [];
+        }
+        for (var i = 0; i < el.childNodes.length; i++){
+            var c = el.childNodes[i];
+            if (c.nodeType === 1 && BLOCK_TAGS.test(c.tagName)){ flushInline(); block(c, ctx); }
+            else buf.push(c);
+        }
+        flushInline();
+    }
+    function hasBlockChild(el){
+        for (var i = 0; i < el.children.length; i++) if (BLOCK_TAGS.test(el.children[i].tagName)) return true;
+        return false;
+    }
+    function block(el, ctx){
+        var tag = el.tagName, align = alignOf(el, ctx.align);
+        if (/^H[1-6]$/.test(tag)){ para(el.childNodes, ctx, { align:align, heading:tag }); return; }
+        if (tag === "HR"){ out.push("\\pard" + (ctx.intbl ? "\\intbl" : "") + "\\brdrb\\brdrs\\brdrw10\\brsp20\\sa120\\plain\\f0\\fs" + base + " \\par\r\n"); return; }
+        if (tag === "PRE"){
+            var lines = (el.textContent || "").replace(/\u200B/g, "").replace(/\n$/, "").split("\n");
+            lines.forEach(function(ln, k){
+                var tn = document.createTextNode(ln);
+                para([tn], ctx, { font:MONO, sa:(k === lines.length - 1 ? 120 : 0) });
+            });
+            return;
+        }
+        if (tag === "UL" || tag === "OL"){ list(el, ctx); return; }
+        if (tag === "BLOCKQUOTE"){
+            container(el, { indent:(ctx.indent || 0) + 720, align:align, intbl:ctx.intbl, level:ctx.level });
+            return;
+        }
+        if (tag === "TABLE"){ table(el, ctx); return; }
+        if (tag === "P" || tag === "DT" || tag === "DD" || !hasBlockChild(el)){
+            para(el.childNodes, ctx, { align:align, li:indentOf(el), fi:textIndentOf(el) });
+            return;
+        }
+        container(el, { indent:(ctx.indent || 0) + indentOf(el), align:align, intbl:ctx.intbl, level:ctx.level });
+    }
+    function list(el, ctx){
+        var ordered = el.tagName === "OL";
+        var level = (ctx.level === undefined ? -1 : ctx.level) + 1;
+        var num = parseInt(el.getAttribute("start"), 10) || 1;
+        var li = 720 * (level + 1);
+        for (var i = 0; i < el.children.length; i++){
+            var item = el.children[i];
+            if (item.tagName !== "LI"){ block(item, ctx); continue; }
+            var marker = ordered ? (num++) + "." : "\\u8226?";
+            var pn = ordered
+                ? "{\\*\\pn\\pnlvlbody\\pndec\\pnstart1\\pnindent360{\\pntxta.}}"
+                : "{\\*\\pn\\pnlvlblt\\pnf" + fontNo("Symbol") + "\\pnindent360{\\pntxtb\\'B7}}";
+            var prefix = "{\\listtext\\pard\\plain\\f0\\fs" + base + " " + marker + "\\tab}" + pn;
+            var sub = { indent:ctx.indent, align:ctx.align, intbl:ctx.intbl, level:level };
+            // the item's own text runs up to its first block child
+            var inline = [], rest = [];
+            for (var k = 0; k < item.childNodes.length; k++){
+                var c = item.childNodes[k];
+                if (rest.length || (c.nodeType === 1 && BLOCK_TAGS.test(c.tagName))) rest.push(c);
+                else inline.push(c);
+            }
+            if (!hasContent(inline) && rest.length && /^(P|DIV)$/.test(rest[0].tagName)){
+                inline = Array.prototype.slice.call(rest.shift().childNodes);
+            }
+            para(inline, ctx, { align:alignOf(item, ctx.align), li:li, fi:-360, sa:40, prefix:prefix });
+            rest.forEach(function(c){
+                if (c.tagName === "UL" || c.tagName === "OL") list(c, sub);
+                else block(c, { indent:(ctx.indent || 0) + li, align:ctx.align, intbl:ctx.intbl, level:level });
+            });
+        }
+    }
+    function table(el, ctx){
+        var rows = el.querySelectorAll("tr");
+        for (var r = 0; r < rows.length; r++){
+            var cells = [];
+            for (var c = 0; c < rows[r].children.length; c++){
+                if (/^T[DH]$/.test(rows[r].children[c].tagName)) cells.push(rows[r].children[c]);
+            }
+            if (!cells.length) continue;
+            var w = Math.floor(9000 / cells.length), def = "\\trowd\\trgaph108\\trleft0";
+            for (var k = 0; k < cells.length; k++){
+                def += "\\clbrdrt\\brdrs\\brdrw10\\clbrdrl\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10\\cellx" + (w * (k + 1));
+            }
+            out.push(def + "\r\n");
+            for (var k2 = 0; k2 < cells.length; k2++){
+                var cell = cells[k2], isHead = cell.tagName === "TH";
+                var start = out.length;
+                var cctx = { intbl:true, align:alignOf(cell, ""), bold:isHead };
+                if (hasBlockChild(cell)) container(cell, cctx);
+                else para(cell.childNodes, cctx, { align:cctx.align, sa:0 });
+                if (out.length === start) para([], cctx, { sa:0 });
+                // the cell's last paragraph ends with \cell instead of \par
+                out[out.length - 1] = out[out.length - 1].replace(/\\par\r\n$/, "\\cell\r\n");
+            }
+            out.push(def + "\\row\r\n");
+        }
+    }
+
+    container(root, { indent:0, align:"" });
+
+    var ft = "{\\fonttbl";
+    fonts.forEach(function(name, i){
+        var fam = name === MONO ? "\\fmodern" : name === "Symbol" ? "\\ftech" : "\\fnil";
+        var cs = name === "Symbol" ? 2 : (/[^\x00-\x7f]/.test(name) ? 1 : 0);
+        ft += "{\\f" + i + fam + "\\fcharset" + cs + " " + escText(name) + ";}";
+    });
+    ft += "}";
+    var ct = "{\\colortbl ;";
+    colors.forEach(function(c){ ct += "\\red" + c.r + "\\green" + c.g + "\\blue" + c.b + ";"; });
+    ct += "}";
+    return "{\\rtf1\\ansi\\ansicpg1252\\deff0\\nouicompat" + ft + "\r\n" + ct + "\r\n" +
+           "{\\*\\generator ArozOS Text;}\\viewkind4\\uc1\r\n" + out.join("") + "}\r\n";
+}
+
+// ════════════════════════════════════════════════════════════════════════
+// Image preparation (async)
+// ════════════════════════════════════════════════════════════════════════
+function blobToDataURL(blob){
+    return new Promise(function(res, rej){
+        var fr = new FileReader();
+        fr.onload = function(){ res(fr.result); };
+        fr.onerror = rej;
+        fr.readAsDataURL(blob);
+    });
+}
+function loadImage(src){
+    return new Promise(function(res, rej){
+        var im = new Image();
+        im.onload = function(){ res(im); };
+        im.onerror = function(){ rej(new Error("image load failed")); };
+        im.src = src;
+    });
+}
+function inlineOne(img){
+    var src = img.getAttribute("src") || "";
+    var p = /^data:image\/(png|jpe?g);/i.test(src)
+        ? Promise.resolve(src)
+        : fetch(src, { credentials:"same-origin" }).then(function(r){
+              if (!r.ok) throw new Error("HTTP " + r.status);
+              return r.blob();
+          }).then(function(b){
+              if (/^image\/(png|jpe?g)$/i.test(b.type)) return blobToDataURL(b);
+              // other formats (gif/webp/svg/bmp) are re-encoded as PNG
+              var url = URL.createObjectURL(b);
+              return loadImage(url).then(function(im){
+                  URL.revokeObjectURL(url);
+                  var cv = document.createElement("canvas");
+                  cv.width = im.naturalWidth || 300; cv.height = im.naturalHeight || 150;
+                  cv.getContext("2d").drawImage(im, 0, 0);
+                  return cv.toDataURL("image/png");
+              });
+          }).then(function(d){
+              // sniff the real format: a mislabelled blob still needs png/jpeg bytes
+              return d.replace(/^data:[^;,]*;/, function(){
+                  var head = atob(d.split(",")[1].substr(0, 8));
+                  return head.charCodeAt(0) === 0xFF ? "data:image/jpeg;" : "data:image/png;";
+              });
+          });
+    return p.then(function(d){
+        img.setAttribute("src", d);
+        return loadImage(d).then(function(im){
+            img.setAttribute("data-natw", im.naturalWidth);
+            img.setAttribute("data-nath", im.naturalHeight);
+        });
+    }).catch(function(){
+        img.parentNode && img.parentNode.removeChild(img);   // unreachable image: leave it out
+    });
+}
+function inlineImages(root){
+    var imgs = Array.prototype.slice.call(root.querySelectorAll("img"));
+    return Promise.all(imgs.map(inlineOne)).then(function(){ return root; });
+}
+
+window.TextRTF = { toHTML:toHTML, fromHTML:fromHTML, inlineImages:inlineImages, parse:parse };
+})();