From fa5eb91c3946ad2c730c109506e42f3c272b77fb Mon Sep 17 00:00:00 2001 From: macbook Date: Tue, 15 Sep 2026 04:00:08 +0100 Subject: [PATCH 1/3] Fix RTL text (Arabic, Hebrew, Persian) in mpv OSD and chat log mpv: wordwrapify_string put {\fscx0} {\fscx100} between every character. The two hidden spaces split RTL words into single letters, so libass showed them in reverse order. Text with RTL letters now skips these markers. libass still wraps it. Chat log and mpv chat: each line and each username gets Unicode isolate marks (U+2068/U+2069), so RTL text keeps its own direction next to LTR text. Text without RTL letters renders the same as before. --- syncplay/constants.py | 1 + syncplay/players/mpv.py | 2 ++ syncplay/resources/syncplayintf.lua | 27 +++++++++++++++++++++++++++ syncplay/ui/gui.py | 6 ++++++ 4 files changed, 36 insertions(+) diff --git a/syncplay/constants.py b/syncplay/constants.py index 57d5c091..2162948b 100755 --- a/syncplay/constants.py +++ b/syncplay/constants.py @@ -27,6 +27,7 @@ def getValueForOS(constantDict): MPC_OSD_POSITION = 1 # Right corner, 1 for left MPLAYER_OSD_LEVEL = 1 UI_TIME_FORMAT = "[%X] " +BIDI_ISOLATE_FORMAT = "\u2068{}\u2069" # Unicode first-strong isolate: RTL text (e.g. Arabic) keeps its own direction CONFIG_NAMES = [".syncplay", "syncplay.ini"] # Syncplay searches first to last DEFAULT_CONFIG_NAME = "syncplay.ini" RECENT_CLIENT_THRESHOLD = "1.7.6" # This and higher considered 'recent' clients (no warnings) diff --git a/syncplay/players/mpv.py b/syncplay/players/mpv.py index 214a68e8..4c32296d 100755 --- a/syncplay/players/mpv.py +++ b/syncplay/players/mpv.py @@ -138,6 +138,8 @@ def displayMessage(self, message, duration=(constants.OSD_DURATION * 1000), OSDT self._listener.sendLine(["script-message-to", "syncplayintf", "{}-osd-{}".format(OSDType, mood), messageString]) def displayChatMessage(self, username, message): + username = constants.BIDI_ISOLATE_FORMAT.format(username) + message = constants.BIDI_ISOLATE_FORMAT.format(message) if not self._client._config["chatOutputEnabled"]: messageString = "<{}> {}".format(username, message) messageString = self._sanitizeText(messageString.replace("\\n", "")).replace("", "\\n") diff --git a/syncplay/resources/syncplayintf.lua b/syncplay/resources/syncplayintf.lua index a6bdc42e..d8197f6e 100644 --- a/syncplay/resources/syncplayintf.lua +++ b/syncplay/resources/syncplayintf.lua @@ -672,6 +672,28 @@ local function is_regional_indicator(codepoint) return codepoint ~= nil and codepoint >= 0x1F1E6 and codepoint <= 0x1F1FF end +local function is_rtl_letter(codepoint) + -- Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic (+ presentation forms), + -- historic RTL scripts, Adlam, Mende Kikakui and Arabic mathematical symbols. + -- Direction control characters (LRM, RLM, isolates) are not letters and do not count. + return (codepoint >= 0x0590 and codepoint <= 0x08FF) + or (codepoint >= 0xFB1D and codepoint <= 0xFDFF) + or (codepoint >= 0xFE70 and codepoint <= 0xFEFC) + or (codepoint >= 0x10800 and codepoint <= 0x10FFF) + or (codepoint >= 0x1E800 and codepoint <= 0x1EFFF) +end + +local function contains_rtl_text(str) + local pos = 1 + while pos <= str:len() do + if is_rtl_letter(utf8_codepoint_at(str, pos)) then + return true + end + pos = next_utf8(str, pos) + end + return false +end + function wordwrapify_string(line, restoreBackslashSubstitute) -- Used to ensure characters wrap on a per-character rather than per-word basis -- to avoid issues with long filenames, etc. @@ -680,6 +702,10 @@ function wordwrapify_string(line, restoreBackslashSubstitute) if str == nil or str == "" then return "" end + -- The wrap markers contain spaces, which split RTL words into single letters and reverse them. + -- Text with RTL letters gets no markers (whole string, so a multi-line OSD block counts as one). + -- libass still wraps it at spaces, and inside a word when the word is wider than the screen. + local has_rtl_text = contains_rtl_text(str) local newstr = "" local currentChar = 1 local nextChar = 0 @@ -701,6 +727,7 @@ function wordwrapify_string(line, restoreBackslashSubstitute) and previousCodepoint ~= 0x200C and previousCodepoint ~= 0x200D and previousCodepoint ~= 0x2060 and previousCodepoint ~= 0xFEFF and not is_wordwrap_continuation(codepoint) + and not has_rtl_text and not (is_regional_indicator(previousCodepoint) and is_regional_indicator(codepoint) and regionalIndicatorCount % 2 == 1) diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index 7bf86f34..aeba6da3 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -658,7 +658,13 @@ def showMessage(self, message, noTimestamp=False, isMotd=False): username = messageWithUsername.group("username") message = messageWithUsername.group("message") message = message.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + # Isolate each line and the username, so RTL text (e.g. Arabic) keeps its own direction in the LTR log. + # Leading spaces stay outside the isolate, so HTML still collapses them like before. + message = "\n".join( + line[:len(line) - len(line.lstrip())] + constants.BIDI_ISOLATE_FORMAT.format(line.lstrip()) if line.strip() else line + for line in message.split("\n")) if username: + username = constants.BIDI_ISOLATE_FORMAT.format(username) message = constants.STYLE_USER_MESSAGE.format(constants.STYLE_USERNAME, username, message) # When showing a MOTD, escape spaces and use a monospace font to preserve the look of ASCII art. if isMotd: From 5268d0d0f8664081817d4cf17f306ceff8784337 Mon Sep 17 00:00:00 2001 From: macbook Date: Tue, 15 Sep 2026 10:55:33 +0100 Subject: [PATCH 2/3] Keep isolate marks out of text copied from the chat log The chat log wraps each line and user name in U+2068/U+2069. Copy and drag took these invisible marks along, so a copied link could end with U+2069 and fail to open. ChatLogBrowser removes the marks from the selection. It gives the same copy formats as Qt (plain text, HTML, Markdown, ODF) with the same content as before. The selection is not laid out, because the log is one long paragraph and its layout takes seconds. --- syncplay/ui/gui.py | 67 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index aeba6da3..d8c8cfe1 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -37,8 +37,10 @@ QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, applyDPIScaling) if IsPySide6: from PySide6.QtCore import QStandardPaths + from PySide6.QtGui import QTextDocumentWriter elif IsPySide2: from PySide2.QtCore import QStandardPaths + from PySide2.QtGui import QTextDocumentWriter if isMacOS() and IsPySide: from Foundation import NSURL from Cocoa import NSString, NSUTF8StringEncoding @@ -249,6 +251,69 @@ def closeDialog(self): self.close() +class ChatLogBrowser(QtWidgets.QTextBrowser): + # Copy and drag text without the invisible isolate marks that showMessage adds for RTL text. + # The copied data is the same as a plain QTextBrowser gives, and the selection is never laid out, + # because the chat log is one very long paragraph and laying it out takes seconds. + copyFormats = None + knownFormats = ("text/plain", "text/html", "text/markdown", "application/vnd.oasis.opendocument.text") + + def createMimeDataFromSelection(self): + marks = constants.BIDI_ISOLATE_FORMAT.format("") + selection = self.textCursor() + if not any(mark in selection.selectedText() for mark in marks): + return super().createMimeDataFromSelection() + document = QtGui.QTextDocument() + document.setDefaultFont(self.document().defaultFont()) + cursor = QtGui.QTextCursor(document) + cursor.beginEditBlock() # otherwise Qt lays out the text again after every deletion + cursor.insertFragment(selection.selection()) + # Qt positions count UTF-16 units, so an emoji (or any character above U+FFFF) takes two + positions = [] + position = 0 + for char in document.toPlainText(): + if char in marks: + positions.append(position) + position += 2 if ord(char) > 0xFFFF else 1 + for position in reversed(positions): + cursor.setPosition(position) + cursor.setPosition(position + 1, QtGui.QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.endEditBlock() + fragment = QtGui.QTextDocumentFragment(document) + + if ChatLogBrowser.copyFormats is None: + probe = QtWidgets.QTextEdit() + probe.setPlainText("x") + probe.selectAll() + probeData = probe.createMimeDataFromSelection() + probeData.setParent(probe) # freed together with the probe + ChatLogBrowser.copyFormats = list(probeData.formats()) + if any(mimeType not in ChatLogBrowser.knownFormats for mimeType in ChatLogBrowser.copyFormats): + # A format added by a newer Qt: let Qt build the data (same result, but slower) + copySource = QtWidgets.QTextEdit() + document.setParent(copySource) # freed together with copySource + copySource.setDocument(document) + copySource.selectAll() + return copySource.createMimeDataFromSelection() + # Same formats, in the same order, as Qt's own copy (QTextEditMimeData) + mimeData = QtWidgets.QTextEdit().createMimeDataFromSelection() + for mimeType in ChatLogBrowser.copyFormats: + if mimeType == "text/plain": + mimeData.setText(fragment.toPlainText()) + elif mimeType == "text/html": + html = fragment.toHtml(b"utf-8") if IsPySide2 else fragment.toHtml() + mimeData.setData(mimeType, html.encode("utf-8")) + elif mimeType == "text/markdown": + mimeData.setData(mimeType, fragment.toMarkdown().encode("utf-8")) + else: + buffer = QtCore.QBuffer() + QTextDocumentWriter(buffer, b"ODF").write(fragment) + buffer.close() + mimeData.setData(mimeType, buffer.data()) + return mimeData + + class MainWindow(QtWidgets.QMainWindow): insertPosition = None playlistState = [] @@ -1651,7 +1716,7 @@ def addTopLayout(self, window): window.topSplit = self.topSplitter(Qt.Horizontal, self) window.outputLayout = QtWidgets.QVBoxLayout() - window.outputbox = QtWidgets.QTextBrowser() + window.outputbox = ChatLogBrowser() if isDarkMode: window.outputbox.document().setDefaultStyleSheet(constants.STYLE_DARK_LINKS_COLOR); window.outputbox.setReadOnly(True) window.outputbox.setTextInteractionFlags(window.outputbox.textInteractionFlags() | Qt.TextSelectableByKeyboard) From 95ad440997b4678a3d88ed950a3e0c6be93fad8a Mon Sep 17 00:00:00 2001 From: macbook Date: Sun, 27 Sep 2026 12:23:05 +0100 Subject: [PATCH 3/3] Fix word order of notifications that start with an RTL name A first-strong isolate on every chat log line gave a notification the direction of its first letter. An English notification that starts with an Arabic user name showed its words in reverse order. English chat that starts with an Arabic word had the same problem. - Notification and error lines are UI text, so they get a left-to-right isolate. - Chat and MOTD lines get the direction of most of their words. If both directions have the same number of words, the first letter decides. - Only lines and user names with RTL characters get isolate marks. Other text is the same as before, byte for byte. - mpv notifications get the same isolates as the chat log. --- syncplay/constants.py | 3 +++ syncplay/players/mpv.py | 13 +++++++++++-- syncplay/ui/gui.py | 13 ++++++------- syncplay/utils.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/syncplay/constants.py b/syncplay/constants.py index 2162948b..abe7a02a 100755 --- a/syncplay/constants.py +++ b/syncplay/constants.py @@ -28,6 +28,9 @@ def getValueForOS(constantDict): MPLAYER_OSD_LEVEL = 1 UI_TIME_FORMAT = "[%X] " BIDI_ISOLATE_FORMAT = "\u2068{}\u2069" # Unicode first-strong isolate: RTL text (e.g. Arabic) keeps its own direction +BIDI_ISOLATE_LTR_FORMAT = "\u2066{}\u2069" # Unicode left-to-right isolate +BIDI_ISOLATE_RTL_FORMAT = "\u2067{}\u2069" # Unicode right-to-left isolate +BIDI_ISOLATE_MARKS = "\u2066\u2067\u2068\u2069" # All isolate marks, removed again when a user copies from the chat log CONFIG_NAMES = [".syncplay", "syncplay.ini"] # Syncplay searches first to last DEFAULT_CONFIG_NAME = "syncplay.ini" RECENT_CLIENT_THRESHOLD = "1.7.6" # This and higher considered 'recent' clients (no warnings) diff --git a/syncplay/players/mpv.py b/syncplay/players/mpv.py index 4c32296d..6e9adba4 100755 --- a/syncplay/players/mpv.py +++ b/syncplay/players/mpv.py @@ -14,6 +14,7 @@ from syncplay.utils import getRuntimeDir, isURL, findResourcePath from syncplay.utils import isMacOS, isWindows, isASCII from syncplay.utils import playerPathExists +from syncplay.utils import isolateBidiText from syncplay.vendor.python_mpv_jsonipc.python_mpv_jsonipc import MPV class MpvPlayer(BasePlayer): @@ -130,6 +131,14 @@ def mpvErrorCheck(self, line): def displayMessage(self, message, duration=(constants.OSD_DURATION * 1000), OSDType=constants.OSD_NOTIFICATION, mood=constants.MESSAGE_NEUTRAL): + # Isolate the text like the chat log does, so RTL text (e.g. Arabic) keeps its own direction. + # Chat messages come here too when the mpv chat is off. + chatMessage = re.match(constants.MESSAGE_WITH_USERNAME_REGEX, message, re.UNICODE) + if chatMessage: + username = isolateBidiText(chatMessage.group("username")) + message = "<{}>{}".format(username, isolateBidiText(message[chatMessage.end(1):])) # all text after "" + else: + message = isolateBidiText(message, constants.BIDI_ISOLATE_LTR_FORMAT) if not self._client._config["chatOutputEnabled"]: messageString = self._sanitizeText(message.replace("\\n", "")).replace("", "\\n") self._listener.mpvpipe.show_text(messageString, duration, constants.MPLAYER_OSD_LEVEL) @@ -138,8 +147,8 @@ def displayMessage(self, message, duration=(constants.OSD_DURATION * 1000), OSDT self._listener.sendLine(["script-message-to", "syncplayintf", "{}-osd-{}".format(OSDType, mood), messageString]) def displayChatMessage(self, username, message): - username = constants.BIDI_ISOLATE_FORMAT.format(username) - message = constants.BIDI_ISOLATE_FORMAT.format(message) + username = isolateBidiText(username) + message = isolateBidiText(message) if not self._client._config["chatOutputEnabled"]: messageString = "<{}> {}".format(username, message) messageString = self._sanitizeText(messageString.replace("\\n", "")).replace("", "\\n") diff --git a/syncplay/ui/gui.py b/syncplay/ui/gui.py index d8c8cfe1..e33caae9 100755 --- a/syncplay/ui/gui.py +++ b/syncplay/ui/gui.py @@ -259,7 +259,7 @@ class ChatLogBrowser(QtWidgets.QTextBrowser): knownFormats = ("text/plain", "text/html", "text/markdown", "application/vnd.oasis.opendocument.text") def createMimeDataFromSelection(self): - marks = constants.BIDI_ISOLATE_FORMAT.format("") + marks = constants.BIDI_ISOLATE_MARKS selection = self.textCursor() if not any(mark in selection.selectedText() for mark in marks): return super().createMimeDataFromSelection() @@ -722,14 +722,12 @@ def showMessage(self, message, noTimestamp=False, isMotd=False): if messageWithUsername: username = messageWithUsername.group("username") message = messageWithUsername.group("message") + # Isolate lines and user names with RTL text (e.g. Arabic), so they keep their own direction in the LTR log. + # Notification lines are UI text, and all UI languages are LTR. Chat and MOTD lines follow their own words. + message = utils.isolateBidiText(message, None if username or isMotd else constants.BIDI_ISOLATE_LTR_FORMAT) message = message.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") - # Isolate each line and the username, so RTL text (e.g. Arabic) keeps its own direction in the LTR log. - # Leading spaces stay outside the isolate, so HTML still collapses them like before. - message = "\n".join( - line[:len(line) - len(line.lstrip())] + constants.BIDI_ISOLATE_FORMAT.format(line.lstrip()) if line.strip() else line - for line in message.split("\n")) if username: - username = constants.BIDI_ISOLATE_FORMAT.format(username) + username = utils.isolateBidiText(username) message = constants.STYLE_USER_MESSAGE.format(constants.STYLE_USERNAME, username, message) # When showing a MOTD, escape spaces and use a monospace font to preserve the look of ASCII art. if isMotd: @@ -1192,6 +1190,7 @@ def showErrorMessage(self, message, criticalerror=False): message = str(message) if criticalerror: QtWidgets.QMessageBox.critical(self, "Syncplay", message) + message = utils.isolateBidiText(message, constants.BIDI_ISOLATE_LTR_FORMAT) # like notification lines in showMessage message = message.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") message = message.replace("<a href="https://syncplay.pl/trouble">", '').replace("</a>", "") message = message.replace("<a href="https://mpv.io/">", '').replace("</a>", "") diff --git a/syncplay/utils.py b/syncplay/utils.py index 6c5d08f7..2972d6b1 100755 --- a/syncplay/utils.py +++ b/syncplay/utils.py @@ -13,6 +13,7 @@ import tempfile import time import traceback +import unicodedata import urllib.error import urllib.parse import urllib.request @@ -406,6 +407,39 @@ def versiontotuple(ver): return versiontotuple(version) >= versiontotuple(minVersion) +def getBidiIsolateFormat(text): + # The Unicode isolate for user text (e.g. a chat message): the direction of most words wins. + # If both directions have the same number of words, the first letter decides (first-strong isolate). + rtlWords = ltrWords = 0 + for word in text.split(): + for char in word: + direction = unicodedata.bidirectional(char) + if direction in ("R", "AL"): + rtlWords += 1 + break + if direction == "L": + ltrWords += 1 + break + if rtlWords > ltrWords: + return constants.BIDI_ISOLATE_RTL_FORMAT + if ltrWords > rtlWords: + return constants.BIDI_ISOLATE_LTR_FORMAT + return constants.BIDI_ISOLATE_FORMAT + + +def isolateBidiText(text, isolateFormat=None): + # Put each line with RTL characters (e.g. Arabic) in Unicode isolate marks, so it keeps its own direction. + # Other lines do not change. Without a format, a line follows its own words. + # Leading spaces stay outside the isolate, so HTML can still collapse them. + lines = [] + for line in text.split("\n"): + content = line.lstrip() + if any(unicodedata.bidirectional(char) in ("R", "AL") for char in content): + line = line[:len(line) - len(content)] + (isolateFormat or getBidiIsolateFormat(content)).format(content) + lines.append(line) + return "\n".join(lines) + + def isURL(path): if path is None: return False