diff --git a/src/native/QmlNet/QmlNet.pro b/src/native/QmlNet/QmlNet.pro index e944e2e5..29b8884b 100644 --- a/src/native/QmlNet/QmlNet.pro +++ b/src/native/QmlNet/QmlNet.pro @@ -17,3 +17,6 @@ include(QmlNet.pri) target.path = $$(PREFIX)/ INSTALLS += target + +# Needed for CoTaskMemAlloc +win32:LIBS += ole32.lib diff --git a/src/native/QmlNet/QmlNet/qml/NetTranslator.cpp b/src/native/QmlNet/QmlNet/qml/NetTranslator.cpp new file mode 100644 index 00000000..a426d6aa --- /dev/null +++ b/src/native/QmlNet/QmlNet/qml/NetTranslator.cpp @@ -0,0 +1,33 @@ + +#include +#include +#include + +NetTranslator::NetTranslator(NetGCHandle *callbackDelegate, TranslateCallback callback, QObject *parent) : _callbackDelegate(callbackDelegate), _callback(callback), QTranslator(parent) { +} + +bool NetTranslator::isEmpty() const { + // We only install this translator if C# has callbacks, so + // it is never considered to be empty + return false; +} + +QString NetTranslator::translate(const char *context, const char *sourceText, const char *disambiguation, int n) const { + auto contextLength = context ? static_cast(strlen(context)) : -1; + auto sourceTextLength = sourceText ? static_cast(strlen(sourceText)) : -1; + auto disambiguationLength = disambiguation ? static_cast(strlen(disambiguation)) : -1; + + auto str = _callback(context, contextLength, sourceText, sourceTextLength, disambiguation, disambiguationLength, n); + return takeStringFromDotNet(str); +} + +bool NetDataTranslator::load(QByteArray data, const QString &directory) { + _data = std::move(data); + + if (!QTranslator::load(reinterpret_cast(_data.constData()), _data.size(), directory)) { + _data = QByteArray(); // Do not keep the data copy alive unnecessarily + return false; + } + + return true; +} diff --git a/src/native/QmlNet/QmlNet/qml/NetTranslator.h b/src/native/QmlNet/QmlNet/qml/NetTranslator.h new file mode 100644 index 00000000..ffe14f37 --- /dev/null +++ b/src/native/QmlNet/QmlNet/qml/NetTranslator.h @@ -0,0 +1,50 @@ +#ifndef NETTRANSLATOR_H +#define NETTRANSLATOR_H + +#include +#include +#include +#include + +/** + * Signature of the managed delegate when converted to an unmanaged function pointer. + * Please note that the returned pointer must be allocated with CoTaskMemAlloc or the + * cross-platform equivalent (usually malloc). I.e. by using Marshal.StringToCoTaskMemUni. + */ +using TranslateCallback = QChar* (__cdecl *)(const char *context, int contextLength, const char *sourceText, int sourceTextLength, const char *disambiguation, int disambiguationLength, int n); + +class NetTranslator : public QTranslator +{ +Q_OBJECT +public: + NetTranslator(NetGCHandle *callbackDelegate, TranslateCallback callback, QObject *parent = nullptr); + + bool isEmpty() const override; + QString translate(const char *context, const char *sourceText, const char *disambiguation, int n) const override; + +private: + NetDelegate const _callbackDelegate; + TranslateCallback const _callback; +}; + +/** + * Holds a reference to managed memory and frees it, once the translator is destroyed. + */ +class NetDataTranslator : public QTranslator +{ +Q_OBJECT +public: + NetDataTranslator(QObject *parent = nullptr) : QTranslator(parent) { + } + + /** + * Loads the translation data from the given data and keeps + * a copy around to keep the data alive. + */ + bool load(QByteArray data, const QString &directory = QString()); + +private: + QByteArray _data; +}; + +#endif // NETTRANSLATOR_H diff --git a/src/native/QmlNet/QmlNet/qml/QCoreApplication.cpp b/src/native/QmlNet/QmlNet/qml/QCoreApplication.cpp index 25a5bc9d..b5db4fb1 100644 --- a/src/native/QmlNet/QmlNet/qml/QCoreApplication.cpp +++ b/src/native/QmlNet/QmlNet/qml/QCoreApplication.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -182,6 +183,92 @@ Q_DECL_EXPORT void qapp_exit(int returnCode) QGuiApplication::exit(returnCode); } +Q_DECL_EXPORT NetTranslator* qapp_installTranslator(NetGCHandle *callbackHandle, TranslateCallback callback) +{ + auto app = QCoreApplication::instance(); + if (!app) { + qWarning() << "Cannot install a translator without a QCoreApplication"; + return nullptr; + } + + auto translator = new NetTranslator(callbackHandle, callback, app); + if (!QCoreApplication::installTranslator(translator)) { + qWarning() << "Failed to install translator"; + delete translator; + return nullptr; + } + + return translator; +} + +Q_DECL_EXPORT bool qapp_removeTranslator(NetTranslator *translator) +{ + auto result = QCoreApplication::removeTranslator(translator); + delete translator; + return result; +} + +Q_DECL_EXPORT bool qapp_loadTranslationData(const char *data, int dataLength, const QChar *directory) +{ + auto app = QCoreApplication::instance(); + if (!app) { + qWarning() << "Cannot install a translator without a QCoreApplication"; + return false; + } + + QByteArray dataBuffer(data, dataLength); + auto translator = new NetDataTranslator(app); + if (!translator->load(std::move(dataBuffer), QString(directory))) { + qWarning() << "Failed to load translation data"; + delete translator; + return false; + } + + if (!QCoreApplication::installTranslator(translator)) { + qWarning() << "Failed to install translator"; + delete translator; + return false; + } + + return true; +} + +Q_DECL_EXPORT bool qapp_loadTranslationFile(const QChar *locale, const QChar *filename, const QChar *prefix, const QChar *directory, const QChar *suffix) +{ + auto app = QCoreApplication::instance(); + if (!app) { + qWarning() << "Cannot install a translator without a QCoreApplication"; + return false; + } + + auto translator = new QTranslator(app); + bool success; + if (locale) { + success = translator->load(QLocale(QString(locale)), QString(filename), QString(prefix), QString(directory), QString(suffix)); + } else { + success = translator->load(QString(filename), QString(prefix), QString(directory), QString(suffix)); + } + + if (!success) { + qWarning() << "Failed to load translation data"; + delete translator; + return false; + } + + if (!QCoreApplication::installTranslator(translator)) { + qWarning() << "Failed to install translator"; + delete translator; + return false; + } + + return true; +} + +Q_DECL_EXPORT QChar* qapp_translate(const char *context, const char *sourceText, const char *disambiguation, int n) +{ + return returnStringToDotNet(QCoreApplication::translate(context, sourceText, disambiguation, n)); +} + Q_DECL_EXPORT QCoreApplication* qapp_internalPointer(QGuiApplicationContainer* container) { return container->app; diff --git a/src/native/QmlNet/QmlNet/qml/qml.pri b/src/native/QmlNet/QmlNet/qml/qml.pri index 9db8d9b8..b8820281 100644 --- a/src/native/QmlNet/QmlNet/qml/qml.pri +++ b/src/native/QmlNet/QmlNet/qml/qml.pri @@ -19,7 +19,8 @@ HEADERS += \ $$PWD/NetQObject.h \ $$PWD/NetQObjectSignalConnection.h \ $$PWD/NetQObjectArg.h \ - $$PWD/QLocaleInterop.h + $$PWD/QLocaleInterop.h \ + $$PWD/NetTranslator.h SOURCES += \ $$PWD/QQmlApplicationEngine.cpp \ @@ -42,4 +43,5 @@ SOURCES += \ $$PWD/NetQObject.cpp \ $$PWD/NetQObjectSignalConnection.cpp \ $$PWD/NetQObjectArg.cpp \ - $$PWD/QLocaleInterop.cpp + $$PWD/QLocaleInterop.cpp \ + $$PWD/NetTranslator.cpp diff --git a/src/native/QmlNet/QmlNetUtilities.cpp b/src/native/QmlNet/QmlNetUtilities.cpp index ce1b3056..dd5b91a4 100644 --- a/src/native/QmlNet/QmlNetUtilities.cpp +++ b/src/native/QmlNet/QmlNetUtilities.cpp @@ -27,3 +27,44 @@ void freeString(QmlNetStringContainer* container) } +// According to the Microsoft documentation, the string returned from unmanaged +// must be allocated using CoTaskMemAlloc, which doesn't exist on Linux ofcourse. +// This issue says that malloc should be used instead: https://github.com/dotnet/runtime/issues/10748 +#ifdef Q_OS_WIN +#include +#define interop_malloc CoTaskMemAlloc +#define interop_free CoTaskMemFree +#else +#define interop_malloc malloc +#define interop_free free +#endif + +QChar *returnStringToDotNet(const QString &str) +{ + static_assert(sizeof(QChar) == 2, "QChar must be 2-byte UTF-16"); + + if(str.isNull()) { + return nullptr; + } + + auto len = str.length(); + auto result = reinterpret_cast(interop_malloc((len + 1) * sizeof(QChar))); + + memcpy(result, str.utf16(), len * sizeof(QChar)); + result[len] = 0; + + return result; +} + +QString takeStringFromDotNet(QChar *str) +{ + if (!str) { + return QString::null; + } + + QString result(reinterpret_cast(str)); + + interop_free(str); + + return result; +} diff --git a/src/native/QmlNet/QmlNetUtilities.h b/src/native/QmlNet/QmlNetUtilities.h index dc775106..263dd3cc 100644 --- a/src/native/QmlNet/QmlNetUtilities.h +++ b/src/native/QmlNet/QmlNetUtilities.h @@ -16,4 +16,16 @@ Q_DECL_EXPORT void freeString(QmlNetStringContainer* container); } +/** + * Creates a copy of the string suitable for returning to managed code. + * Requires a [return:MarshalAs(LPWSTR)] on the managed side, and is + * only usable for *return values*. + */ +QChar *returnStringToDotNet(const QString &str); + +/** + * Takes ownership of a string returned by .NET via Marshal.StringToCoTaskMemUni. + */ +QString takeStringFromDotNet(QChar *str); + #endif // QMLNETUTILITIES_H diff --git a/src/net/Qml.Net.Tests/QCoreApplicationTests.cs b/src/net/Qml.Net.Tests/QCoreApplicationTests.cs new file mode 100644 index 00000000..81fdf387 --- /dev/null +++ b/src/net/Qml.Net.Tests/QCoreApplicationTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using Xunit; +using FluentAssertions; + +namespace Qml.Net.Tests +{ + public class QCoreApplicationTests + { +#if NETCOREAPP3_1 + [Fact] + public void CanInstallTranslator() + { + string Translator(ReadOnlySpan context, ReadOnlySpan sourceText, ReadOnlySpan disambiguation, int n) + { + return $"translated:{new string(context)}:{new string(sourceText)}:{new string(disambiguation)}:{n}"; + } + + using (var app = new QCoreApplication()) + { + app.InstallTranslator(Translator); + + var result = app.Translate("ctx", "src", "disambig", 2); + result.Should().Be("translated:ctx:src:disambig:2"); + } + } + + [Fact] + public void CanRemoveTranslator() + { + string Translator(ReadOnlySpan context, ReadOnlySpan sourceText, ReadOnlySpan disambiguation, int n) + { + return "should not be called"; + } + + using (var app = new QCoreApplication()) + { + Translator translatorDel = Translator; + app.InstallTranslator(translatorDel); + app.RemoveTranslator(translatorDel); + + var result = app.Translate("ctx", "src"); + result.Should().Be("src"); + } + } + + [Fact] + public void CanLoadTranslationFromMemory() + { + var resourcesFolder = Path.Join(Path.GetDirectoryName(typeof(QCoreApplicationTests).Assembly.Location), + "Resources"); + var qmFile = Path.Join(resourcesFolder, "example.de.qm"); + var qmData = File.ReadAllBytes(qmFile); + + using (var app = new QCoreApplication()) + { + // With no translations installed, the result should be the source text + var untranslated = app.Translate("QPushButton", "Hello world!"); + untranslated.Should().Be("Hello world!"); + + app.LoadTranslationData(qmData); + + // After installing our translations, it should be the text from example.de.ts + var translated = app.Translate("QPushButton", "Hello world!"); + translated.Should().Be("Hallo from Deutschland!"); + } + } +#endif + } +} diff --git a/src/net/Qml.Net.Tests/Qml.Net.Tests.csproj b/src/net/Qml.Net.Tests/Qml.Net.Tests.csproj index 87d919f6..dec3d451 100644 --- a/src/net/Qml.Net.Tests/Qml.Net.Tests.csproj +++ b/src/net/Qml.Net.Tests/Qml.Net.Tests.csproj @@ -2,6 +2,7 @@ netcoreapp2.1;netcoreapp3.1 false + false @@ -16,4 +17,9 @@ + + + PreserveNewest + + \ No newline at end of file diff --git a/src/net/Qml.Net.Tests/Resources/example.de.qm b/src/net/Qml.Net.Tests/Resources/example.de.qm new file mode 100644 index 00000000..2a27c5d0 Binary files /dev/null and b/src/net/Qml.Net.Tests/Resources/example.de.qm differ diff --git a/src/net/Qml.Net.Tests/Resources/example.de.ts b/src/net/Qml.Net.Tests/Resources/example.de.ts new file mode 100644 index 00000000..8ee9fa2a --- /dev/null +++ b/src/net/Qml.Net.Tests/Resources/example.de.ts @@ -0,0 +1,11 @@ + + + + + QPushButton + + Hello world! + Hallo from Deutschland! + + + diff --git a/src/net/Qml.Net.Tests/Resources/example.de_CH.qm b/src/net/Qml.Net.Tests/Resources/example.de_CH.qm new file mode 100644 index 00000000..f0beac80 Binary files /dev/null and b/src/net/Qml.Net.Tests/Resources/example.de_CH.qm differ diff --git a/src/net/Qml.Net.Tests/Resources/example.de_CH.ts b/src/net/Qml.Net.Tests/Resources/example.de_CH.ts new file mode 100644 index 00000000..ba7cb5c1 --- /dev/null +++ b/src/net/Qml.Net.Tests/Resources/example.de_CH.ts @@ -0,0 +1,11 @@ + + + + + QPushButton + + Hello world! + Grüezi aus der Schweiz! + + + diff --git a/src/net/Qml.Net.Tests/Resources/example.ts b/src/net/Qml.Net.Tests/Resources/example.ts new file mode 100644 index 00000000..d682874d --- /dev/null +++ b/src/net/Qml.Net.Tests/Resources/example.ts @@ -0,0 +1,11 @@ + + + + + QPushButton + + Hello world! + + + + diff --git a/src/net/Qml.Net/QCoreApplication.cs b/src/net/Qml.Net/QCoreApplication.cs index f3675ff9..2988099a 100644 --- a/src/net/Qml.Net/QCoreApplication.cs +++ b/src/net/Qml.Net/QCoreApplication.cs @@ -3,9 +3,9 @@ using System.Linq; using System.Runtime.InteropServices; using System.Security; +using System.Text; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json.Bson; using Qml.Net.Internal; using Qml.Net.Internal.Qml; @@ -18,6 +18,10 @@ public class QCoreApplication : BaseDisposable private GCHandle _triggerHandle; private GCHandle _aboutToQuitHandle; private readonly List _aboutToQuitEventHandlers = new List(); +#if NETSTANDARD2_1 + private IntPtr _translatorHandle; + private readonly List _translators = new List(); +#endif private static int? _threadId; protected QCoreApplication(IntPtr handle, bool ownsHandle) @@ -294,6 +298,142 @@ public static void SendPostedEvents(INetQObject receiver = null, int eventType = } } +#if NETSTANDARD2_1 + public void InstallTranslator(Translator translator) + { + if (_translators.Contains(translator)) + { + return; + } + + _translators.Add(translator); + SyncNativeTranslator(); + } + + public void RemoveTranslator(Translator translator) + { + if (!_translators.Remove(translator)) + { + return; + } + + SyncNativeTranslator(); + } + + /// + /// Loads a QM translation file from memory and makes it available to the + /// application. Once loaded, the translations cannot be removed again + /// until the application exits. + /// See https://doc.qt.io/qt-5/qtranslator.html#load-2 + /// + public void LoadTranslationData(byte[] translationData, string directory = null) + { + if (!Interop.QCoreApplication.LoadTranslationData( + translationData, + translationData.Length, + directory)) + { + throw new InvalidOperationException("Failed to load translation data."); + } + } + + /// + /// Loads a QM translation file. + /// + /// See https://doc.qt.io/qt-5/qtranslator.html#load-1 + public void LoadTranslationFile(string locale, string filename, string prefix = null, string directory = null, string suffix = null) + { + if (!Interop.QCoreApplication.LoadTranslationFile( + locale, + filename, + prefix, + directory, + suffix)) + { + throw new InvalidOperationException("Failed to load translation file " + filename); + } + } + + // See: https://doc.qt.io/qt-5/qtranslator.html#translate + public string Translate(string context, string sourceText, string disambiguation = null, int n = -1) + { + return Interop.QCoreApplication.Translate(context, sourceText, disambiguation, n); + } + + // Installs or removes the native->managed translation bridge as needed + private void SyncNativeTranslator() + { + if (_translators.Count > 0) + { + if (_translatorHandle == IntPtr.Zero) + { + QCoreApplicationInterop.TranslateCallbackDel del = TranslateCallback; + var delHandle = GCHandle.ToIntPtr(GCHandle.Alloc(del)); + _translatorHandle = Interop.QCoreApplication.InstallTranslator(delHandle, del); + } + } + else + { + if (_translatorHandle != IntPtr.Zero) + { + Interop.QCoreApplication.RemoveTranslator(_translatorHandle); + _translatorHandle = IntPtr.Zero; + } + } + } + + private IntPtr TranslateCallback( + ref byte context, + int contextLength, + ref byte sourceText, + int sourceTextLength, + ref byte disambiguation, + int disambiguationLength, + int n = -1) + { + // Passed in values are UTF-8 encoded and need to be converted + // We try our best here to avoid heap allocations. + var contextEncoded = contextLength > 0 + ? MemoryMarshal.CreateReadOnlySpan(ref context, contextLength) + : ReadOnlySpan.Empty; + Span contextDecoded = stackalloc char[Encoding.UTF8.GetCharCount(contextEncoded)]; + if (!contextEncoded.IsEmpty) + { + Encoding.UTF8.GetChars(contextEncoded, contextDecoded); + } + + var sourceTextEncoded = sourceTextLength > 0 + ? MemoryMarshal.CreateReadOnlySpan(ref sourceText, sourceTextLength) + : ReadOnlySpan.Empty; + Span sourceTextDecoded = stackalloc char[Encoding.UTF8.GetCharCount(sourceTextEncoded)]; + if (!sourceTextEncoded.IsEmpty) + { + Encoding.UTF8.GetChars(sourceTextEncoded, sourceTextDecoded); + } + + var disambiguationEncoded = disambiguationLength > 0 + ? MemoryMarshal.CreateReadOnlySpan(ref disambiguation, disambiguationLength) + : ReadOnlySpan.Empty; + Span disambiguationDecoded = stackalloc char[Encoding.UTF8.GetCharCount(disambiguationEncoded)]; + if (!disambiguationEncoded.IsEmpty) + { + Encoding.UTF8.GetChars(disambiguationEncoded, disambiguationDecoded); + } + + foreach (var translator in _translators) + { + var translated = translator(contextDecoded, sourceTextDecoded, disambiguationDecoded, n); + if (translated != null) + { + return Marshal.StringToCoTaskMemUni(translated); + } + } + + return IntPtr.Zero; + } + +#endif + protected override void DisposeUnmanaged(IntPtr ptr) { SynchronizationContext.SetSynchronizationContext(_oldSynchronizationContext); @@ -369,11 +509,20 @@ public QueuedAction(Action action, ExecutionContext ec) } } +#if NETSTANDARD2_1 + /// + /// Interface to provide a custom translator to Qt. + /// + /// Implement this interface to support dynamic translation using C# code. + public delegate string Translator(ReadOnlySpan context, ReadOnlySpan sourceText, ReadOnlySpan disambiguation, int n); +#endif + [StructLayout(LayoutKind.Sequential)] internal struct QCoreAppCallbacks { public IntPtr GuiThreadTrigger; public IntPtr AboutToQuitCb; + public IntPtr TranslateCb; } internal class QCoreApplicationInterop @@ -503,5 +652,71 @@ internal class QCoreApplicationInterop [SuppressUnmanagedCodeSecurity] [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void SendPostedEventsDel(IntPtr netQObject, int eventType); + +#if NETSTANDARD2_1 + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr TranslateCallbackDel( + ref byte context, + int contextLength, + ref byte sourceText, + int sourceTextLength, + ref byte disambiguation, + int disambiguationLength, + int n = -1); + + [NativeSymbol(Entrypoint = "qapp_installTranslator")] + public InstallTranslatorDel InstallTranslator { get; set; } + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate IntPtr InstallTranslatorDel( + IntPtr callbackHandle, + TranslateCallbackDel callback); + + [NativeSymbol(Entrypoint = "qapp_removeTranslator")] + public RemoveTranslatorDel RemoveTranslator { get; set; } + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public delegate bool RemoveTranslatorDel(IntPtr translator); + + [NativeSymbol(Entrypoint = "qapp_translate")] + public TranslateDel Translate { get; set; } + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.LPWStr)] + public delegate string TranslateDel( + [MarshalAs(UnmanagedType.LPUTF8Str)] string context, + [MarshalAs(UnmanagedType.LPUTF8Str)] string sourceText, + [MarshalAs(UnmanagedType.LPUTF8Str)] string disambiguation, + int n); + + [NativeSymbol(Entrypoint = "qapp_loadTranslationData")] + public LoadTranslationDataDel LoadTranslationData { get; set; } + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return:MarshalAs(UnmanagedType.I1)] + public delegate bool LoadTranslationDataDel( + [In] byte[] data, + int dataLength, + [MarshalAs(UnmanagedType.LPWStr)] string directory); + + [NativeSymbol(Entrypoint = "qapp_loadTranslationFile")] + public LoadTranslationFileDel LoadTranslationFile { get; set; } + + [SuppressUnmanagedCodeSecurity] + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + public delegate bool LoadTranslationFileDel( + [MarshalAs(UnmanagedType.LPWStr)] string locale, + [MarshalAs(UnmanagedType.LPWStr)] string filename, + [MarshalAs(UnmanagedType.LPWStr)] string prefix, + [MarshalAs(UnmanagedType.LPWStr)] string directory, + [MarshalAs(UnmanagedType.LPWStr)] string suffix); +#endif + } } \ No newline at end of file