@richscripts/richtexteditor 2.7.0 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -519,6 +519,29 @@ function RTE_Plugin_AccessibilityChecker() {
519
519
  }
520
520
  }
521
521
 
522
+ // 2026-09-04 A link with no accessible name is announced as just
523
+ // "link", giving a keyboard or screen-reader user nothing to decide
524
+ // on (WCAG 2.4.4 / 4.1.2). Reached the editor easily: an image-only
525
+ // link whose <img> has empty alt, or a link left empty after its
526
+ // text was deleted.
527
+ //
528
+ // Only elements with href are links. A bare <a name="x"> or an <a>
529
+ // with no href is an anchor TARGET and has no name to announce, so
530
+ // flagging it would be an error with no valid fix.
531
+ var links = tag === "a" ? [node] : (node.querySelectorAll ? node.querySelectorAll("a") : []);
532
+ for (var linkIndex = 0; linkIndex < links.length; linkIndex++) {
533
+ var link = links[linkIndex];
534
+ if (!link.getAttribute || link.getAttribute("href") === null) continue;
535
+ if (accessibleNameOf(link)) continue;
536
+ issues.push({
537
+ code: "link-empty",
538
+ severity: "error",
539
+ message: "Link has no text, so it is announced only as \"link\". Add link text, an aria-label, or alt text on the image inside it.",
540
+ path: path + ".link[" + linkIndex + "]",
541
+ _target: link
542
+ });
543
+ }
544
+
522
545
  var tables = tag === "table" ? [node] : (node.querySelectorAll ? node.querySelectorAll("table") : []);
523
546
  for (var tableIndex = 0; tableIndex < tables.length; tableIndex++) {
524
547
  var table = tables[tableIndex];
@@ -553,11 +576,168 @@ function RTE_Plugin_AccessibilityChecker() {
553
576
  _target: table
554
577
  });
555
578
  }
579
+ else {
580
+ // 2026-09-04 A table can HAVE headers and still be unusable if
581
+ // one is blank: the screen reader announces the column's name as
582
+ // an empty string, which is worse than no header at all because
583
+ // the relationship exists and says nothing (WCAG 1.3.1).
584
+ // `table-missing-header` above only fires when there is not a
585
+ // single <th>, so this case reported valid. Found by testing
586
+ // CKEditor issue #19204 against this checker.
587
+ //
588
+ // THE CORNER CELL IS EXEMPT. In a cross-tab - column headers
589
+ // across the top, row headers down the side - the top-left cell
590
+ // is conventionally and correctly empty; it names neither axis.
591
+ // Flagging it would be an error the author can only clear by
592
+ // inventing a word, which is how a checker teaches people to
593
+ // ignore it. Same reasoning that exempts layout tables above.
594
+ var headerCells = table.querySelectorAll("th");
595
+ var hasRowHeaders = false;
596
+ for (var probeIndex = 0; probeIndex < headerCells.length; probeIndex++) {
597
+ var probeRow = headerCells[probeIndex].parentNode;
598
+ if (probeRow && probeRow.rowIndex > 0) { hasRowHeaders = true; break; }
599
+ }
600
+ for (var headerIndex = 0; headerIndex < headerCells.length; headerIndex++) {
601
+ var headerCell = headerCells[headerIndex];
602
+ if (accessibleNameOf(headerCell)) continue;
603
+ var ownerRow = headerCell.parentNode;
604
+ if (hasRowHeaders && ownerRow && ownerRow.rowIndex === 0 && headerCell.cellIndex === 0) continue;
605
+ issues.push({
606
+ code: "table-header-empty",
607
+ severity: "error",
608
+ message: "Header cell is empty, so the column or row it labels is announced with no name. Give it text, or make it a normal cell if it labels nothing.",
609
+ path: path + ".table[" + tableIndex + "].th[" + headerIndex + "]",
610
+ _target: headerCell
611
+ });
612
+ }
613
+ }
614
+ }
615
+
616
+
617
+ // 2026-09-07 COLOUR CONTRAST (WCAG 1.4.3). The most-cited criterion in
618
+ // any accessibility audit and the checker had no rule for it -- and
619
+ // our own comparison page once claimed one, which had to be retracted.
620
+ //
621
+ // Only INLINE colour is judged. Contrast against a stylesheet the
622
+ // editor cannot see would be a guess, and a checker that guesses
623
+ // teaches people to ignore it. Where no background is declared on the
624
+ // element or an ancestor, white is assumed and stated in the message,
625
+ // because that is the editing surface's own ground.
626
+ var coloured = node.querySelectorAll ? node.querySelectorAll("[style*='color']") : [];
627
+ var colouredList = (node.getAttribute && /(^|;)\s*color\s*:/i.test(node.getAttribute("style") || ""))
628
+ ? [node].concat(Array.prototype.slice.call(coloured))
629
+ : Array.prototype.slice.call(coloured);
630
+ for (var cIndex = 0; cIndex < colouredList.length; cIndex++) {
631
+ var cEl = colouredList[cIndex];
632
+ var fg = parseCssColour(cEl.style && cEl.style.color);
633
+ if (!fg) continue;
634
+ var bg = nearestBackground(cEl);
635
+ var ratio = contrastRatio(fg, bg.rgb);
636
+ // 4.5:1 is the AA threshold for body text; 3:1 for large text
637
+ // (>=18pt, or >=14pt bold). Using the body number on a heading
638
+ // would report a failure that is not one.
639
+ var large = isLargeText(cEl);
640
+ var need = large ? 3 : 4.5;
641
+ // Epsilon absorbs float noise ONLY. 0.05 was 1% of the threshold and
642
+ // swallowed real near-misses: #777777 on white is 4.48:1, a genuine
643
+ // failure, and went unreported. It must also not fire on a colour that
644
+ // passes at exactly 4.5, so the comparison errs toward silence.
645
+ if (ratio < need - 0.001) {
646
+ issues.push({
647
+ code: "contrast-insufficient",
648
+ severity: "error",
649
+ message: "Text contrast is " + ratio.toFixed(2) + ":1 against " +
650
+ (bg.assumed ? "an assumed white background" : "its background") +
651
+ ", below the " + need + ":1 needed for " +
652
+ (large ? "large text" : "body text") + " (WCAG 1.4.3).",
653
+ path: path + ".contrast[" + cIndex + "]",
654
+ _target: cEl
655
+ });
656
+ }
657
+ }
658
+
659
+ // 2026-09-07 BLOCKQUOTE USED AS INDENTATION (WCAG 1.3.1). A quotation
660
+ // element used for visual offset is announced as a quotation by screen
661
+ // readers and exported to Word as the Quote style.
662
+ //
663
+ // This is OUR OWN historical output: until 2026-09-04 the indent button
664
+ // fell through to the browser's execCommand, which wraps the block in
665
+ // <blockquote style="margin:0 0 0 40px;border:none;padding:0px">. Every
666
+ // document indented in an older build carries these, so the rule
667
+ // matters most for content we produced ourselves.
668
+ //
669
+ // Recognise only that SHAPE -- an offset with the quote decoration
670
+ // explicitly switched off. A blockquote that keeps its border is a real
671
+ // quotation and must not be flagged.
672
+ var quotes = tag === "blockquote" ? [node] : (node.querySelectorAll ? node.querySelectorAll("blockquote") : []);
673
+ for (var qIndex = 0; qIndex < quotes.length; qIndex++) {
674
+ var q = quotes[qIndex];
675
+ var qs = q.style;
676
+ if (!qs) continue;
677
+ var hasOffset = /^\s*[\d.]+\s*[a-z%]+\s*$/i.test(qs.marginLeft || "") && parseFloat(qs.marginLeft) > 0;
678
+ var borderOff = /^(none|0|0px)$/i.test(String(qs.borderStyle || qs.border || "").trim());
679
+ if (hasOffset && borderOff) {
680
+ issues.push({
681
+ code: "blockquote-as-indent",
682
+ severity: "warning",
683
+ message: "This looks like indentation, not a quotation: a <blockquote> with a left margin and no quote styling. Screen readers announce it as a quotation. Use the indent button instead.",
684
+ path: path + ".blockquote[" + qIndex + "]",
685
+ _target: q
686
+ });
687
+ }
688
+ }
689
+
690
+ // 2026-09-07 AMBIGUOUS LINK TEXT (WCAG 2.4.4). "Click here" read out of
691
+ // context -- which is how a screen-reader user listing links hears it --
692
+ // conveys nothing about the destination.
693
+ var ambiguous = /^(click here|here|read more|more|link|this|this link|learn more|details|go)$/i;
694
+ var namedLinks = tag === "a" ? [node] : (node.querySelectorAll ? node.querySelectorAll("a") : []);
695
+ for (var aIndex = 0; aIndex < namedLinks.length; aIndex++) {
696
+ var aEl = namedLinks[aIndex];
697
+ if (!aEl.getAttribute || aEl.getAttribute("href") === null) continue;
698
+ var aName = accessibleNameOf(aEl);
699
+ if (aName && ambiguous.test(aName.replace(/[\s.,!:;]+$/g, "").replace(/^\s+/, ""))) {
700
+ issues.push({
701
+ code: "link-ambiguous-text",
702
+ severity: "warning",
703
+ message: "Link text \"" + aName + "\" does not say where it goes. Out of context a screen reader announces only this text.",
704
+ path: path + ".link[" + aIndex + "]",
705
+ _target: aEl
706
+ });
707
+ }
556
708
  }
557
709
 
558
710
  collectUnmarkedLanguageRuns(node, editable, path, issues);
559
711
  }
560
712
 
713
+ // 2026-09-07 DUPLICATE id (WCAG 4.1.1). Two elements sharing an id break
714
+ // every aria-labelledby / aria-describedby / label that points at it --
715
+ // the reference silently resolves to the first one. Reached easily by
716
+ // copying and pasting a block that carries an id.
717
+ //
718
+ // Runs ONCE over the whole editable, deliberately NOT inside the per-node
719
+ // walk: querySelectorAll returns DESCENDANTS only, so a walk visiting each
720
+ // sibling separately sees one id at a time and can never detect a pair.
721
+ // The same trap already cost this file its top-level image check.
722
+ if (editable && editable.querySelectorAll) {
723
+ var allWithIds = editable.querySelectorAll("[id]");
724
+ var idSeen = {};
725
+ for (var dupIndex = 0; dupIndex < allWithIds.length; dupIndex++) {
726
+ var dupId = allWithIds[dupIndex].getAttribute("id");
727
+ if (!dupId) continue;
728
+ if (idSeen[dupId]) {
729
+ issues.push({
730
+ code: "duplicate-id",
731
+ severity: "error",
732
+ message: "id \"" + dupId + "\" is used more than once. Any aria-labelledby, aria-describedby or label pointing at it resolves to only the first element.",
733
+ path: "document.id[" + dupIndex + "]",
734
+ _target: allWithIds[dupIndex]
735
+ });
736
+ }
737
+ idSeen[dupId] = true;
738
+ }
739
+ }
740
+
561
741
  return { document: null, issues: issues, valid: !issues.length, source: "dom" };
562
742
  }
563
743
 
@@ -587,6 +767,29 @@ function RTE_Plugin_AccessibilityChecker() {
587
767
  else if (issue.code === "layout-table-semantics") {
588
768
  markTableAsLayout(target);
589
769
  }
770
+ else if (issue.code === "table-header-empty") {
771
+ // Two valid outcomes and only the author knows which: the cell labels
772
+ // something (give it text) or it labels nothing (it is not a header).
773
+ var headerText = String((options && options.headerText) || "").replace(/^\s+|\s+$/g, "");
774
+ if (headerText) target.textContent = headerText;
775
+ else if (options && options.demoteToCell) {
776
+ var cell = target.ownerDocument.createElement("td");
777
+ while (target.firstChild) cell.appendChild(target.firstChild);
778
+ copyAttributes(target, cell);
779
+ cell.removeAttribute("scope");
780
+ target.parentNode.replaceChild(cell, target);
781
+ }
782
+ }
783
+ else if (issue.code === "link-empty") {
784
+ // Prefer real text: an aria-label is invisible, so a sighted editor
785
+ // cannot see what the link says and it drifts out of date.
786
+ var linkText = String((options && options.linkText) || "").replace(/^\s+|\s+$/g, "");
787
+ if (linkText) target.textContent = linkText;
788
+ else {
789
+ var linkLabel = String((options && options.linkLabel) || "").replace(/^\s+|\s+$/g, "");
790
+ if (linkLabel) target.setAttribute("aria-label", linkLabel);
791
+ }
792
+ }
590
793
  else if (issue.code === "language-of-parts") {
591
794
  markLanguageRuns(target, options && options.lang ? options.lang : issue._lang, issue._script);
592
795
  }
@@ -673,6 +876,86 @@ function RTE_Plugin_AccessibilityChecker() {
673
876
  // Delegates to layouttable.js when it is loaded so there is ONE definition of
674
877
  // what "layout table" means; the fallback keeps the repair working in builds
675
878
  // where that plugin was left out of the bundle.
879
+ // The accessible name of an element, as a screen reader would compute it:
880
+ // aria-label wins, then visible text, then title, then the alt text of an
881
+ // image standing in for the text. Empty string means "announced as nothing".
882
+ // Deliberately NOT aria-labelledby - resolving it needs the whole document
883
+ // and a wrong answer here would produce an error the author cannot clear.
884
+
885
+ // --- contrast helpers (WCAG 1.4.3) ---------------------------------------
886
+ function parseCssColour(v) {
887
+ if (!v) return null;
888
+ var str = String(v).trim();
889
+ var m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(str);
890
+ if (m) {
891
+ var hex = m[1];
892
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
893
+ return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
894
+ }
895
+ var rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(str);
896
+ if (rgb) return [Math.round(+rgb[1]), Math.round(+rgb[2]), Math.round(+rgb[3])];
897
+ return null; // named colours are not resolved: a guess is worse than silence
898
+ }
899
+ // Nearest ancestor that DECLARES a background. Reports whether it had to
900
+ // fall back, so the message can say "assumed white" rather than asserting a
901
+ // background the document never set.
902
+ function nearestBackground(el) {
903
+ var n = el;
904
+ while (n && n.style) {
905
+ var c = parseCssColour(n.style.backgroundColor);
906
+ if (c) return { rgb: c, assumed: false };
907
+ n = n.parentElement;
908
+ }
909
+ return { rgb: [255, 255, 255], assumed: true };
910
+ }
911
+ function relativeLuminance(rgb) {
912
+ var a = [rgb[0], rgb[1], rgb[2]].map(function (v) {
913
+ v = v / 255;
914
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
915
+ });
916
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
917
+ }
918
+ function contrastRatio(fg, bg) {
919
+ var l1 = relativeLuminance(fg), l2 = relativeLuminance(bg);
920
+ var hi = Math.max(l1, l2), lo = Math.min(l1, l2);
921
+ return (hi + 0.05) / (lo + 0.05);
922
+ }
923
+ // WCAG "large text": >=18pt, or >=14pt bold. A heading judged against the
924
+ // body threshold reports a failure that is not one.
925
+ function isLargeText(el) {
926
+ var st = el.style || {};
927
+ var size = String(st.fontSize || "");
928
+ var pt = null;
929
+ var m = /^([\d.]+)(px|pt)$/i.exec(size.trim());
930
+ if (m) pt = m[2].toLowerCase() === "pt" ? parseFloat(m[1]) : parseFloat(m[1]) * 0.75;
931
+ if (pt === null) {
932
+ var tagName = (el.tagName || "").toLowerCase();
933
+ if (/^h[1-3]$/.test(tagName)) return true;
934
+ return false;
935
+ }
936
+ var bold = /bold|^[6-9]00$/.test(String(st.fontWeight || ""));
937
+ return pt >= 18 || (pt >= 14 && bold);
938
+ }
939
+
940
+ function accessibleNameOf(element) {
941
+ if (!element) return "";
942
+ var trim = function (v) { return String(v || "").replace(/^\s+|\s+$/g, ""); };
943
+ var label = trim(element.getAttribute && element.getAttribute("aria-label"));
944
+ if (label) return label;
945
+ var text = trim(element.textContent);
946
+ if (text) return text;
947
+ var title = trim(element.getAttribute && element.getAttribute("title"));
948
+ if (title) return title;
949
+ if (element.querySelectorAll) {
950
+ var imgs = element.querySelectorAll("img");
951
+ for (var i = 0; i < imgs.length; i++) {
952
+ var alt = trim(imgs[i].getAttribute("alt"));
953
+ if (alt) return alt;
954
+ }
955
+ }
956
+ return "";
957
+ }
958
+
676
959
  function markTableAsLayout(table) {
677
960
  if (!table) return;
678
961
  if (editor && typeof editor.setTableLayoutMode === "function") {
@@ -23020,9 +23303,24 @@ function RTE_Plugin_DialogA11y() {
23020
23303
  }
23021
23304
  }
23022
23305
 
23306
+ // 2026-09-05 Widened. The original selector only matched
23307
+ // <rte-dropdown-panel>/.rte-panel-general, but Find&Replace, Insert Link and
23308
+ // their siblings render as <rte-dialog-float class="rte-panel-find"> wrapping
23309
+ // an <rte-dialog-inner role="dialog">. Those carry EXACTLY the markup this
23310
+ // plugin consumes - an <rte-dialog-header> and <rte-dialog-line-X> field
23311
+ // wrappers - and were simply never visited, so the plugin reported nothing
23312
+ // and covered nothing. Measured before the change: calling
23313
+ // editor.applyDialogAccessibility() with the find dialog open labelled 0 of
23314
+ // its 4 inputs, leaving "Find" and "Replace" announced as bare "edit"
23315
+ // (WCAG 1.3.1 / 3.3.2 / 4.1.2) while the two checkboxes were fine because
23316
+ // the core happens to wrap those in <label>.
23317
+ //
23318
+ // Widening is safe because enhancePanel() still requires an
23319
+ // <rte-dialog-header>, which is what keeps colour/font dropdowns out.
23023
23320
  function isPanel(el) {
23024
23321
  return el && el.nodeType === 1 &&
23025
- (el.tagName === "RTE-DROPDOWN-PANEL" || /rte-panel-general/.test(el.className || ""));
23322
+ (el.tagName === "RTE-DROPDOWN-PANEL" || el.tagName === "RTE-DIALOG-FLOAT" ||
23323
+ /rte-panel-general/.test(el.className || ""));
23026
23324
  }
23027
23325
 
23028
23326
  function schedule(panel) {
@@ -23042,7 +23340,7 @@ function RTE_Plugin_DialogA11y() {
23042
23340
 
23043
23341
  function enhanceAll(host) {
23044
23342
  if (!host || !host.querySelectorAll) return;
23045
- var ps = host.querySelectorAll("rte-dropdown-panel,[class*='rte-panel-general']");
23343
+ var ps = host.querySelectorAll("rte-dropdown-panel,rte-dialog-float,[class*='rte-panel-general']");
23046
23344
  for (var i = 0; i < ps.length; i++) enhancePanel(ps[i]);
23047
23345
  }
23048
23346
 
@@ -23054,12 +23352,22 @@ function RTE_Plugin_DialogA11y() {
23054
23352
  var name = (headerEl.textContent || "").trim()
23055
23353
  .replace(/\s*\((?:Ctrl|Cmd|Alt|Shift|⌘|⇧|⌥)[^)]*\)\s*$/i, "").trim();
23056
23354
 
23355
+ // Name the element that IS the dialog. The float-style panels already
23356
+ // carry role="dialog" on an inner <rte-dialog-inner>; adding a second
23357
+ // role="dialog" to the wrapper would announce two nested dialogs, so
23358
+ // when an inner one exists it is the target and the wrapper is left
23359
+ // alone. When there is none (the original dropdown-panel shape) this
23360
+ // resolves to the panel itself and behaves exactly as before.
23361
+ var dialogEl = panel.getAttribute("role") === "dialog"
23362
+ ? panel
23363
+ : (panel.querySelector('[role="dialog"]') || panel);
23364
+
23057
23365
  // A titled, form-bearing popup is a dialog, not a menu — override the
23058
23366
  // core's default role="menu" (a menu must not contain text fields).
23059
- var role = panel.getAttribute("role");
23060
- if (!role || role === "menu") panel.setAttribute("role", "dialog");
23061
- if (name && !panel.getAttribute("aria-label") && !panel.getAttribute("aria-labelledby")) {
23062
- panel.setAttribute("aria-label", name);
23367
+ var role = dialogEl.getAttribute("role");
23368
+ if (!role || role === "menu") dialogEl.setAttribute("role", "dialog");
23369
+ if (name && !dialogEl.getAttribute("aria-label") && !dialogEl.getAttribute("aria-labelledby")) {
23370
+ dialogEl.setAttribute("aria-label", name);
23063
23371
  }
23064
23372
 
23065
23373
  var fields = panel.querySelectorAll("input,select,textarea");
@@ -23074,6 +23382,25 @@ function RTE_Plugin_DialogA11y() {
23074
23382
  }
23075
23383
 
23076
23384
  function fieldLabel(f, dialogName) {
23385
+ // 0) THE VISIBLE LABEL WINS. 2026-09-05: deriving the name from the
23386
+ // <rte-dialog-line-X> suffix alone produced "Keyword" for a field whose
23387
+ // visible label reads "Find". That fails WCAG 2.5.3 Label in Name, and
23388
+ // it breaks speech input concretely: a user says "Find" and nothing
23389
+ // matches, because the accessible name does not contain the words they
23390
+ // can see. The markup already carries the right text in a sibling
23391
+ // <rte-dialog-input-label>, so prefer it and keep the suffix as the
23392
+ // fallback for fields that have no visible label.
23393
+ var lineEl = f.parentElement;
23394
+ for (var d = 0; d < 8 && lineEl; d++) {
23395
+ if (/^RTE-DIALOG-LINE-/.test(lineEl.tagName || "")) break;
23396
+ lineEl = lineEl.parentElement;
23397
+ }
23398
+ if (lineEl && lineEl.querySelector) {
23399
+ var visEl = lineEl.querySelector("rte-dialog-input-label");
23400
+ var vis = visEl ? (visEl.textContent || "").replace(/^\s+|\s+$/g, "").replace(/[::]\s*$/, "") : "";
23401
+ if (vis) return vis;
23402
+ }
23403
+
23077
23404
  // 1) nearest <rte-dialog-line-X> ancestor — the suffix is the field meaning
23078
23405
  var n = f.parentElement;
23079
23406
  for (var i = 0; i < 8 && n; i++) {
@@ -24333,6 +24660,25 @@ function RTE_Plugin_DocumentImport() {
24333
24660
  var hv = hl.getAttributeNS(W, "val") || hl.getAttribute("w:val") || "";
24334
24661
  if (hv && hv !== "none") css += "background-color:" + hv + ";";
24335
24662
  }
24663
+ // w:shd is the OTHER way Word carries a background, and it is the
24664
+ // one our own exporter writes (w:highlight only accepts a fixed
24665
+ // set of colour NAMES, shd takes any hex). Reading only
24666
+ // w:highlight meant every highlight we exported came back
24667
+ // unhighlighted - the two sides used different vocabulary for the
24668
+ // same thing.
24669
+ var shd = firstChildEl(rpr, "shd");
24670
+ if (shd) {
24671
+ var fill = shd.getAttributeNS(W, "fill") || shd.getAttribute("w:fill") || "";
24672
+ if (fill && /^[0-9A-Fa-f]{6}$/.test(fill)) css += "background-color:#" + fill + ";";
24673
+ }
24674
+ // Monospace run -> inline code. Our exporter marks <code>/<kbd>/
24675
+ // <samp>/<pre> with w:rFonts Consolas; nothing read it back.
24676
+ var rf = firstChildEl(rpr, "rFonts");
24677
+ if (rf) {
24678
+ var asc = rf.getAttributeNS(W, "ascii") || rf.getAttribute("w:ascii") || "";
24679
+ if (/consolas|courier|monospace|mono$/i.test(asc)) txt = "<code>" + txt + "</code>";
24680
+ else if (asc) css += "font-family:" + asc.replace(/[<>"]/g, "") + ";";
24681
+ }
24336
24682
  var sz = firstChildEl(rpr, "sz");
24337
24683
  if (sz) {
24338
24684
  var sv = parseInt(sz.getAttributeNS(W, "val") || sz.getAttribute("w:val") || "", 10);
@@ -24505,7 +24851,16 @@ function RTE_Plugin_DocumentImport() {
24505
24851
  // without this they came back as plain <p> and the round-trip lost
24506
24852
  // every blockquote. Match Quote / IntenseQuote / BlockQuote.
24507
24853
  else if (/^(intense)?quote$|blockquote/i.test(v.replace(/\s+/g, ""))) info.quote = true;
24854
+ // docxexport.js writes <w:pStyle w:val="Code"/> for <pre>; without
24855
+ // this a code block came back as an ordinary paragraph and lost
24856
+ // both its monospace and its whitespace.
24857
+ else if (/^(code|sourcecode|htmlpre|preformatted)$/i.test(v.replace(/\s+/g, ""))) info.pre = true;
24508
24858
  }
24859
+ // A horizontal rule is exported as an EMPTY paragraph carrying a
24860
+ // bottom border - Word has no <hr>. Nothing read it back, so every
24861
+ // rule became a stray blank paragraph on import.
24862
+ var pbdr = firstChildEl(ppr, "pBdr");
24863
+ if (pbdr && firstChildEl(pbdr, "bottom")) info.rule = true;
24509
24864
  var numPr = firstChildEl(ppr, "numPr");
24510
24865
  if (numPr) {
24511
24866
  info.list = true;
@@ -24599,6 +24954,12 @@ function RTE_Plugin_DocumentImport() {
24599
24954
  continue;
24600
24955
  }
24601
24956
  closeListsTo(0);
24957
+ // Only a rule when the paragraph carries no text of its own -
24958
+ // a bordered paragraph WITH content is a real bordered paragraph.
24959
+ if (st.rule && !(inner || "").replace(/<[^>]*>/g, "").trim()) {
24960
+ closeListsTo(0); out.push("<hr>"); continue;
24961
+ }
24962
+ if (st.pre) { out.push("<pre>" + (inner || "<br>") + "</pre>"); continue; }
24602
24963
  if (st.heading) {
24603
24964
  // Headings carry w:ind too; without this an indented
24604
24965
  // heading imported flush left.
@@ -25217,7 +25578,7 @@ function RTE_Plugin_DocxExport() {
25217
25578
  bold: inherited.bold, italic: inherited.italic, underline: inherited.underline,
25218
25579
  strike: inherited.strike, sizeHalfPoints: inherited.sizeHalfPoints,
25219
25580
  color: inherited.color, highlight: inherited.highlight, vertAlign: inherited.vertAlign,
25220
- mono: inherited.mono
25581
+ mono: inherited.mono, font: inherited.font
25221
25582
  };
25222
25583
  var tag = node.tagName ? node.tagName.toLowerCase() : "";
25223
25584
  if (tag === "b" || tag === "strong") s.bold = true;
@@ -25227,6 +25588,10 @@ function RTE_Plugin_DocxExport() {
25227
25588
  if (tag === "sup") s.vertAlign = "superscript";
25228
25589
  if (tag === "sub") s.vertAlign = "subscript";
25229
25590
  if (tag === "code" || tag === "kbd" || tag === "samp" || tag === "pre") s.mono = true;
25591
+ // <mark> carries no inline style, so the CSS branch below never sees it
25592
+ // and highlighted text was dropped entirely on export. Word's own
25593
+ // default highlight is yellow.
25594
+ if (tag === "mark") s.highlight = s.highlight || "FFFF00";
25230
25595
 
25231
25596
  var css = node.style;
25232
25597
  if (css) {
@@ -25237,6 +25602,12 @@ function RTE_Plugin_DocxExport() {
25237
25602
  if (deco.indexOf("line-through") >= 0) s.strike = true;
25238
25603
  if (css.color) { var c = hexColor(css.color); if (c) s.color = c; }
25239
25604
  if (css.backgroundColor) { var h = hexColor(css.backgroundColor); if (h) s.highlight = h; }
25605
+ // Font family was read by nothing, so an exported document lost every
25606
+ // typeface choice. Word wants ONE family name, not a CSS stack.
25607
+ if (css.fontFamily) {
25608
+ var fam = String(css.fontFamily).split(",")[0].replace(/^\s*['"]?|['"]?\s*$/g, "");
25609
+ if (fam) s.font = fam;
25610
+ }
25240
25611
  if (css.fontSize) {
25241
25612
  var pt = cssToPoints(css.fontSize);
25242
25613
  // w:sz is in HALF-POINTS. Writing points here makes every
@@ -25273,7 +25644,10 @@ function RTE_Plugin_DocxExport() {
25273
25644
 
25274
25645
  function runProps(s) {
25275
25646
  var p = "";
25276
- if (s.mono) p += '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>';
25647
+ // One rFonts element only: mono wins, because <code> inside a styled span
25648
+ // should still read as code.
25649
+ var fontName = s.mono ? "Consolas" : (s.font || "");
25650
+ if (fontName) p += '<w:rFonts w:ascii="' + esc(fontName) + '" w:hAnsi="' + esc(fontName) + '"/>';
25277
25651
  if (s.bold) p += "<w:b/>";
25278
25652
  if (s.italic) p += "<w:i/>";
25279
25653
  if (s.underline) p += '<w:u w:val="single"/>';
@@ -25691,7 +26065,17 @@ function RTE_Plugin_DocxExport() {
25691
26065
  for (var i = 0; i < node.childNodes.length; i++) {
25692
26066
  var li = node.childNodes[i];
25693
26067
  if (li.nodeType !== 1 || li.tagName.toLowerCase() !== "li") continue;
25694
- xml += para(li, styleFrom(li, style), { numId: numId, level: level, pageBreakBefore: pendingBreak });
26068
+ // Serialise the item's OWN content only. para() walks every
26069
+ // descendant, so passing the whole <li> emitted the nested list's
26070
+ // text here AND again as its own items below - a round trip turned
26071
+ // "xx" with a child "yy" into "xxyy". Content duplication, not loss.
26072
+ var own = li.ownerDocument.createElement("li");
26073
+ for (var k = 0; k < li.childNodes.length; k++) {
26074
+ var ch = li.childNodes[k];
26075
+ if (ch.nodeType === 1 && /^(ul|ol)$/.test(ch.tagName.toLowerCase())) continue;
26076
+ own.appendChild(ch.cloneNode(true));
26077
+ }
26078
+ xml += para(own, styleFrom(li, style), { numId: numId, level: level, pageBreakBefore: pendingBreak });
25695
26079
  pendingBreak = false;
25696
26080
  for (var j = 0; j < li.childNodes.length; j++) {
25697
26081
  var sub = li.childNodes[j];
@@ -31379,6 +31763,21 @@ function RTE_Plugin_KeyboardA11y() {
31379
31763
  if (config.keyboardA11y === false) return;
31380
31764
 
31381
31765
  editor.focusToolbar = function () { return focusFirstToolbarButton(); };
31766
+ // Public so plugins can report their own outcomes -- find/replace match
31767
+ // counts, what the paste filter removed, export completion.
31768
+ editor.announce = function (text, opts) { return announce(text, opts); };
31769
+ // setReadOnly() lives in the core and cannot call into a plugin, so wrap
31770
+ // it here rather than leaving the two states to drift apart.
31771
+ if (typeof editor.setReadOnly === "function" && !editor.setReadOnly.__a11yWrapped) {
31772
+ var origSetReadOnly = editor.setReadOnly;
31773
+ var wrapped = function (v) {
31774
+ var r = origSetReadOnly.apply(editor, arguments);
31775
+ try { syncReadOnlyState(); } catch (e) { }
31776
+ return r;
31777
+ };
31778
+ wrapped.__a11yWrapped = true;
31779
+ editor.setReadOnly = wrapped;
31780
+ }
31382
31781
 
31383
31782
  setup();
31384
31783
  try { editor.attachEvent("ready", setup); } catch (e) {}
@@ -31402,6 +31801,8 @@ function RTE_Plugin_KeyboardA11y() {
31402
31801
  function setup() {
31403
31802
  var root = shell();
31404
31803
  if (!root) return;
31804
+ ensureLiveRegions();
31805
+ syncReadOnlyState();
31405
31806
  bindEscapeHatch();
31406
31807
  applyRovingTabindex(root);
31407
31808
  syncToggleStates(root);
@@ -31409,6 +31810,97 @@ function RTE_Plugin_KeyboardA11y() {
31409
31810
  watch(root);
31410
31811
  }
31411
31812
 
31813
+
31814
+ // ------------------------------------------------- 4. status messages
31815
+ //
31816
+ // WCAG 4.1.3 Status Messages (Level AA): a change of state that is NOT
31817
+ // given focus still has to reach assistive technology. Before this the
31818
+ // editor had no live region at all -- not one aria-live node, in the page
31819
+ // or in the iframe -- so a screen reader user got silence for every
31820
+ // outcome the sighted user reads off the chrome: how many matches Find
31821
+ // found, what the paste filter stripped, that the length limit was hit,
31822
+ // that the document went read-only.
31823
+ //
31824
+ // Two regions, because politeness is not a detail: 'polite' waits for a
31825
+ // pause in speech (counts, confirmations), 'assertive' interrupts (errors,
31826
+ // refusals). aria-atomic="true" on both so the whole message is re-read
31827
+ // rather than only the words that changed -- reading a diff aloud produces
31828
+ // sentences that were never written.
31829
+ //
31830
+ // The region lives in the HOST document, not the editing iframe: a message
31831
+ // announced from inside the document the user is editing would also become
31832
+ // part of what they are editing.
31833
+ var liveNodes = null;
31834
+ function ensureLiveRegions() {
31835
+ if (liveNodes && liveNodes.polite && liveNodes.polite.isConnected) return liveNodes;
31836
+ var root = shell();
31837
+ if (!root) return null;
31838
+ function mk(politeness) {
31839
+ var el = root.querySelector('[data-rte-live="' + politeness + '"]');
31840
+ if (el) return el;
31841
+ el = document.createElement("div");
31842
+ el.setAttribute("data-rte-live", politeness);
31843
+ el.setAttribute("aria-live", politeness);
31844
+ el.setAttribute("aria-atomic", "true");
31845
+ el.setAttribute("role", politeness === "assertive" ? "alert" : "status");
31846
+ // Visually hidden but still rendered: display:none and
31847
+ // visibility:hidden remove the node from the accessibility tree,
31848
+ // which silences it. Clip is the technique that does not.
31849
+ el.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;" +
31850
+ "padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;";
31851
+ root.appendChild(el);
31852
+ return el;
31853
+ }
31854
+ liveNodes = { polite: mk("polite"), assertive: mk("assertive") };
31855
+ return liveNodes;
31856
+ }
31857
+
31858
+ // editor.announce(text, {assertive}) -- also used by plugins.
31859
+ function announce(text, opts) {
31860
+ var msg = String(text == null ? "" : text).trim();
31861
+ if (!msg) return false;
31862
+ var nodes = ensureLiveRegions();
31863
+ if (!nodes) return false;
31864
+ var el = (opts && opts.assertive) ? nodes.assertive : nodes.polite;
31865
+ // Re-announcing the SAME string is a no-op for most screen readers
31866
+ // unless the node is cleared first, so "3 results" after "3 results"
31867
+ // would be silent. Clear, then set on the next frame.
31868
+ el.textContent = "";
31869
+ setTimeout(function () { el.textContent = msg; }, 30);
31870
+ return true;
31871
+ }
31872
+
31873
+ // ------------------------------------------------- 5. read-only state
31874
+ //
31875
+ // setReadOnly() flips designMode, which genuinely blocks editing -- but
31876
+ // designMode is invisible to assistive technology. The toolbar already
31877
+ // marks its buttons aria-disabled; the editing region itself said nothing,
31878
+ // so a screen reader user entered a field described as a rich text editor,
31879
+ // typed, and got no response and no explanation. aria-readonly is the
31880
+ // attribute that carries this, and the accessible name says it too because
31881
+ // a name is what gets announced on entry.
31882
+ var lastReadOnly = null;
31883
+ function syncReadOnlyState() {
31884
+ var ed;
31885
+ try { ed = editor.getEditable(); } catch (e) { return; }
31886
+ if (!ed) return;
31887
+ var ro = false;
31888
+ try { ro = !!(editor.getReadOnly ? editor.getReadOnly() : editor.isReadOnly && editor.isReadOnly()); } catch (e) { }
31889
+ ed.setAttribute("aria-readonly", ro ? "true" : "false");
31890
+ var suffix = config.a11yReadOnlySuffix || "Read only.";
31891
+ // Plain string trimming rather than a built regex: the suffix is
31892
+ // config-supplied and would otherwise need escaping to be safe.
31893
+ var label = (ed.getAttribute("aria-label") || "");
31894
+ if (label.length >= suffix.length && label.slice(-suffix.length) === suffix) {
31895
+ label = label.slice(0, -suffix.length).replace(/\s+$/, "");
31896
+ }
31897
+ ed.setAttribute("aria-label", ro ? (label + " " + suffix) : label);
31898
+ if (lastReadOnly !== null && lastReadOnly !== ro) {
31899
+ announce(ro ? suffix : (config.a11yEditableAgain || "Editing enabled."));
31900
+ }
31901
+ lastReadOnly = ro;
31902
+ }
31903
+
31412
31904
  // ---------------------------------------------------- 1. keyboard trap
31413
31905
  //
31414
31906
  // Tab keeps its editing meaning (indent, next table cell) because that is
@@ -35797,7 +36289,7 @@ function RTE_Plugin_PdfExport() {
35797
36289
  return px * 0.75;
35798
36290
  }
35799
36291
 
35800
- function block(node, style, indent, listMarker, listId, listOrdered) {
36292
+ function block(node, style, indent, listMarker, listId, listOrdered, roleOverride) {
35801
36293
  var tag = node.tagName.toLowerCase();
35802
36294
  var s = styleOf(node, style);
35803
36295
  var align = "left";
@@ -35823,12 +36315,18 @@ function RTE_Plugin_PdfExport() {
35823
36315
  // untagged still fails an accessibility audit: assistive
35824
36316
  // technology gets a flat stream with no headings, no list
35825
36317
  // structure and no reliable reading order.
35826
- role: HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P"),
36318
+ // roleOverride carries the quotation down to the paragraphs INSIDE a
36319
+ // <blockquote>. The role was only ever emitted for a blockquote
36320
+ // holding inline text directly - and the editor writes
36321
+ // <blockquote><p>...</p></blockquote>, so in practice every
36322
+ // blockquote we produce was tagged /P and the quotation was absent
36323
+ // from the accessibility tree we publish claims about.
36324
+ role: roleOverride || (HEADING_SCALE[tag] ? "H" + tag.charAt(1) : (tag === "blockquote" ? "BlockQuote" : "P")),
35827
36325
  listId: listId || null, listOrdered: !!listOrdered
35828
36326
  });
35829
36327
  }
35830
36328
 
35831
- function walk(node, style, indent) {
36329
+ function walk(node, style, indent, roleOverride) {
35832
36330
  for (var i = 0; i < node.childNodes.length; i++) {
35833
36331
  var c = node.childNodes[i];
35834
36332
  if (c.nodeType === 3) {
@@ -35870,8 +36368,9 @@ function RTE_Plugin_PdfExport() {
35870
36368
  }
35871
36369
  if (isBlockTag(tag)) {
35872
36370
  var own = (tag === "blockquote" ? self.baseSize * 1.5 : 0) + cssIndentPt(c);
35873
- if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own); continue; }
35874
- block(c, style, indent + own, null);
36371
+ var childRole = (tag === "blockquote") ? "BlockQuote" : roleOverride;
36372
+ if (hasBlockChildren(c)) { walk(c, styleOf(c, style), indent + own, childRole); continue; }
36373
+ block(c, style, indent + own, null, null, false, childRole);
35875
36374
  continue;
35876
36375
  }
35877
36376
  // Inline content sitting directly under a container.