Repository: WeblateOrg/weblate

Issue:
Add html support
Transifex has html support, but weblate does not.

<bountysource-plugin>

---
Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/70778026-add-html-support?utm_campaign=plugin&utm_content=tracker%2F253393&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F253393&utm_medium=issues&utm_source=github).
</bountysource-plugin>


Discussion:
We currently do not support translating any documents, only formats designed for localization.
Should be doable with some html2po hooks.

Resolving patch:
diff --git a/weblate/formats/convert.py b/weblate/formats/convert.py
new file mode 100644
--- /dev/null
+++ b/weblate/formats/convert.py
@@ -0,0 +1,353 @@
+#
+# Copyright © 2012 - 2020 Michal Čihař <michal@cihar.com>
+#
+# This file is part of Weblate <https://weblate.org/>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+"""Translate Toolkit convertor based file format wrappers."""
+
+import codecs
+import os
+import shutil
+from io import BytesIO
+from zipfile import ZipFile
+
+from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
+from translate.convert.html2po import html2po
+from translate.convert.po2html import po2html
+from translate.convert.po2idml import translate_idml, write_idml
+from translate.convert.xliff2odf import translate_odf, write_odf
+from translate.storage.idml import INLINE_ELEMENTS, NO_TRANSLATE_ELEMENTS, open_idml
+from translate.storage.odf_io import open_odf
+from translate.storage.odf_shared import inline_elements, no_translate_content_elements
+from translate.storage.po import pofile
+from translate.storage.test_factory import BaseTestFactory
+from translate.storage.xliff import xlifffile
+from translate.storage.xml_extract.extract import (
+    IdMaker,
+    ParseState,
+    build_idml_store,
+    build_store,
+    make_postore_adder,
+)
+
+from weblate.formats.base import TranslationFormat
+from weblate.formats.ttkit import TTKitUnit, XliffUnit
+from weblate.utils.errors import report_error
+
+
+class ConvertUnit(TTKitUnit):
+    def is_translated(self):
+        """Check whether unit is translated."""
+        return self.unit is not None
+
+    def is_fuzzy(self, fallback=False):
+        """Check whether unit needs editing."""
+        return fallback
+
+    @cached_property
+    def locations(self):
+        return ""
+
+    @cached_property
+    def context(self):
+        """Return context of message."""
+        return "".join(self.mainunit.getlocations())
+
+
+class FileNameUnit(ConvertUnit):
+    @cached_property
+    def context(self):
+        """Return context of message."""
+        return "".join(
+            location.rsplit("/", 1)[1] for location in self.mainunit.getlocations()
+        )
+
+
+class ConvertFormat(TranslationFormat):
+    """
+    Base class for convert based formats.
+
+    This always uses intermediate representation.
+    """
+
+    monolingual = True
+    can_add_unit = False
+    unit_class = ConvertUnit
+
+    def save_content(self, handle):
+        """Store content to file."""
+        raise NotImplementedError()
+
+    def save(self):
+        """Save underlaying store to disk."""
+        self.save_atomic(self.storefile, self.save_content)
+
+    @staticmethod
+    def convertfile(storefile):
+        raise NotImplementedError()
+
+    @classmethod
+    def load(cls, storefile):
+        # Did we get file or filename?
+        if not hasattr(storefile, "read"):
+            storefile = open(storefile, "rb")
+        # Adjust store to have translations
+        store = cls.convertfile(storefile)
+        for unit in store.units:
+            if unit.isheader():
+                continue
+            unit.target = unit.source
+            unit.rich_target = unit.rich_source
+        return store
+
+    @classmethod
+    def create_new_file(cls, filename, language, base):
+        """Handle creation of new translation file."""
+        if not base:
+            raise ValueError("Not supported")
+        # Copy file
+        shutil.copy(base, filename)
+
+    @classmethod
+    def is_valid_base_for_new(cls, base, monolingual):
+        """Check whether base is valid."""
+        if not base:
+            return False
+        try:
+            cls.load(base)
+            return True
+        except Exception:
+            report_error(cause="File parse error")
+            return False
+
+    @classmethod
+    def get_class(cls):
+        # This needs translate-toolkit 3.0.0, check can be
+        # removed once dependency is raised
+        if not hasattr(BaseTestFactory, "test_getobject_store"):
+            raise ImportError("Needs newer translate-toolkit")
+
+    def add_unit(self, ttkit_unit):
+        raise ValueError("Not supported")
+
+    def create_unit(self, key, source):
+        raise ValueError("Not supported")
+
+
+class HTMLFormat(ConvertFormat):
+    name = _("HTML file")
+    autoload = ("*.htm", "*.html")
+    format_id = "html"
+    check_flags = ("safe-html", "strict-same")
+    unit_class = FileNameUnit
+
+    @staticmethod
+    def convertfile(storefile):
+        return html2po().convertfile(storefile, os.path.basename(storefile.name))
+
+    def save_content(self, handle):
+        """Store content to file."""
+        convertor = po2html()
+        templatename = self.template_store.storefile
+        if hasattr(templatename, "name"):
+            templatename = templatename.name
+        with open(templatename, "rb") as templatefile:
+            outputstring = convertor.mergestore(
+                self.store, templatefile, includefuzzy=False
+            )
+        handle.write(outputstring.encode("utf-8"))
+
+    @staticmethod
+    def mimetype():
+        """Return most common mime type for format."""
+        return "text/html"
+
+    @staticmethod
+    def extension():
+        """Return most common file extension for format."""
+        return "html"
+
+
+class OpenDocumentFormat(ConvertFormat):
+    name = _("OpenDocument file")
+    autoload = (
+        "*.sxw",
+        "*.odt",
+        "*.ods",
+        "*.odp",
+        "*.odg",
+        "*.odc",
+        "*.odf",
+        "*.odi",
+        "*.odm",
+        "*.ott",
+        "*.ots",
+        "*.otp",
+        "*.otg",
+        "*.otc",
+        "*.otf",
+        "*.oti",
+        "*.oth",
+    )
+    format_id = "odf"
+    check_flags = ("strict-same",)
+    unit_class = XliffUnit
+
+    @staticmethod
+    def convertfile(storefile):
+        store = xlifffile()
+        store.setfilename(store.getfilenode("NoName"), os.path.basename(storefile.name))
+        contents = open_odf(storefile)
+        for data in contents.values():
+            parse_state = ParseState(no_translate_content_elements, inline_elements)
+            build_store(BytesIO(data), store, parse_state)
+        return store
+
+    def save_content(self, handle):
+        """Store content to file."""
+        templatename = self.template_store.storefile
+        if hasattr(templatename, "name"):
+            templatename = templatename.name
+        with open(templatename, "rb") as templatefile:
+            dom_trees = translate_odf(templatefile, self.store)
+            write_odf(templatefile, handle, dom_trees)
+
+    @staticmethod
+    def mimetype():
+        """Return most common mime type for format."""
+        return "application/vnd.oasis.opendocument.text"
+
+    @staticmethod
+    def extension():
+        """Return most common file extension for format."""
+        return "odt"
+
+
+class IDMLFormat(ConvertFormat):
+    name = _("IDML file")
+    autoload = ("*.idml", "*.idms")
+    format_id = "idml"
+    check_flags = ("strict-same",)
+
+    @staticmethod
+    def convertfile(storefile):
+        store = pofile()
+
+        contents = open_idml(storefile)
+
+        # Create it here to avoid having repeated ids.
+        id_maker = IdMaker()
+
+        for filename, translatable_file in contents.items():
+            parse_state = ParseState(NO_TRANSLATE_ELEMENTS, INLINE_ELEMENTS)
+            po_store_adder = make_postore_adder(store, id_maker, filename)
+            build_idml_store(
+                BytesIO(translatable_file),
+                store,
+                parse_state,
+                store_adder=po_store_adder,
+            )
+
+        return store
+
+    def save_content(self, handle):
+        """Store content to file."""
+        templatename = self.template_store.storefile
+        if hasattr(templatename, "name"):
+            templatename = templatename.name
+        with ZipFile(templatename, "r") as template_zip:
+            translatable_files = [
+                filename
+                for filename in template_zip.namelist()
+                if filename.startswith("Stories/")
+            ]
+
+            dom_trees = translate_idml(templatename, self.store, translatable_files)
+
+            write_idml(template_zip, handle, dom_trees)
+
+    @staticmethod
+    def mimetype():
+        """Return most common mime type for format."""
+        return "application/octet-stream"
+
+    @staticmethod
+    def extension():
+        """Return most common file extension for format."""
+        return "idml"
+
+
+class WindowsRCFormat(ConvertFormat):
+    name = _("RC file")
+    format_id = "rc"
+    autoload = ("*.rc",)
+    language_format = "bcp"
+
+    @staticmethod
+    def mimetype():
+        """Return most common media type for format."""
+        return "text/plain"
+
+    @staticmethod
+    def extension():
+        """Return most common file extension for format."""
+        return "rc"
+
+    @classmethod
+    def get_class(cls):
+        # This needs translate-toolkit 3.0.0 and pyparsing optional dep
+        from translate.storage.rc import rc_statement
+
+        return rc_statement
+
+    @staticmethod
+    def convertfile(storefile):
+        from translate.storage.rc import rcfile
+        from translate.convert.rc2po import rc2po
+
+        input_store = rcfile(storefile)
+        convertor = rc2po()
+        store = convertor.convert_store(input_store)
+        store.rcfile = input_store
+        return store
+
+    def save_content(self, handle):
+        """Store content to file."""
+        from translate.convert.po2rc import rerc
+
+        # Fallback language
+        lang = "LANG_ENGLISH"
+        sublang = "SUBLANG_DEFAULT"
+
+        # Keep existing language tags
+        rcfile = self.store.rcfile
+        if rcfile.lang:
+            lang = rcfile.lang
+            if rcfile.sublang:
+                sublang = rcfile.sublang
+
+        templatename = self.template_store.storefile
+        if hasattr(templatename, "name"):
+            templatename = templatename.name
+        with open(templatename, "rb") as templatefile:
+            convertor = rerc(templatefile, lang=lang, sublang=sublang)
+            outputrclines = convertor.convertstore(self.store)
+            try:
+                handle.write(outputrclines.encode("cp1252"))
+            except UnicodeEncodeError:
+                handle.write(codecs.BOM_UTF16_LE)
+                handle.write(outputrclines.encode("utf-16-le"))
diff --git a/weblate/formats/models.py b/weblate/formats/models.py
--- a/weblate/formats/models.py
+++ b/weblate/formats/models.py
@@ -48,8 +48,7 @@ def load_data(self):
                 fileformat.get_class()
             except (AttributeError, ImportError) as error:
                 result.pop(fileformat.format_id)
-                if fileformat.format_id != "rc":
-                    self.errors[fileformat.format_id] = str(error)
+                self.errors[fileformat.format_id] = str(error)
 
         return result
 
@@ -88,10 +87,13 @@ class FormatsConf(AppConf):
         "weblate.formats.ttkit.AdvSubStationAlphaFormat",
         "weblate.formats.ttkit.SubStationAlphaFormat",
         "weblate.formats.ttkit.DTDFormat",
-        "weblate.formats.ttkit.WindowsRCFormat",
         "weblate.formats.ttkit.FlatXMLFormat",
         "weblate.formats.external.XlsxFormat",
         "weblate.formats.txt.AppStoreFormat",
+        "weblate.formats.convert.HTMLFormat",
+        "weblate.formats.convert.IDMLFormat",
+        "weblate.formats.convert.OpenDocumentFormat",
+        "weblate.formats.convert.WindowsRCFormat",
     )
 
     class Meta:
diff --git a/weblate/formats/ttkit.py b/weblate/formats/ttkit.py
--- a/weblate/formats/ttkit.py
+++ b/weblate/formats/ttkit.py
@@ -1221,31 +1221,6 @@ def all_store_units(self):
         return (unit for unit in self.store.units if not unit.isnull())
 
 
-class WindowsRCFormat(TTKitFormat):
-    name = _("RC file")
-    format_id = "rc"
-    loader = ("rc", "rcfile")
-    autoload = ("*.rc",)
-    unit_class = MonolingualSimpleUnit
-    can_add_unit = False
-    language_format = "bcp"
-
-    @staticmethod
-    def mimetype():
-        """Return most common media type for format."""
-        return "text/plain"
-
-    @staticmethod
-    def extension():
-        """Return most common file extension for format."""
-        return "rc"
-
-    @classmethod
-    def get_class(cls):
-        """Return class for handling this module."""
-        raise ImportError("Windows RC file format unsupported on Python 3")
-
-
 class SubtitleUnit(MonolingualIDUnit):
     @cached_property
     def source(self):


Test patch:
diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py
new file mode 100644
--- /dev/null
+++ b/weblate/formats/tests/test_convert.py
@@ -0,0 +1,133 @@
+#
+# Copyright © 2012 - 2020 Michal Čihař <michal@cihar.com>
+#
+# This file is part of Weblate <https://weblate.org/>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+#
+"""File format specific behavior."""
+
+from weblate.formats.convert import (
+    HTMLFormat,
+    IDMLFormat,
+    OpenDocumentFormat,
+    WindowsRCFormat,
+)
+from weblate.formats.helpers import BytesIOMode
+from weblate.formats.tests.test_formats import AutoFormatTest
+from weblate.trans.tests.utils import get_test_file
+
+IDML_FILE = get_test_file("en.idml")
+HTML_FILE = get_test_file("cs.html")
+OPENDOCUMENT_FILE = get_test_file("cs.odt")
+TEST_RC = get_test_file("cs-CZ.rc")
+
+
+class ConvertFormatTest(AutoFormatTest):
+    NEW_UNIT_MATCH = None
+    EXPECTED_FLAGS = ""
+
+    def parse_file(self, filename):
+        return self.FORMAT(filename, template_store=self.FORMAT(filename))
+
+
+class HTMLFormatTest(ConvertFormatTest):
+    FORMAT = HTMLFormat
+    FILE = HTML_FILE
+    MIME = "text/html"
+    EXT = "html"
+    COUNT = 5
+    MASK = "*/translations.html"
+    EXPECTED_PATH = "cs_CZ/translations.html"
+    FIND_CONTEXT = "cs.html+html.body.p:5-1"
+    FIND_MATCH = "Orangutan has five bananas."
+    MATCH = b"<body>"
+    NEW_UNIT_MATCH = None
+    BASE = HTML_FILE
+    EXPECTED_FLAGS = ""
+    EDIT_OFFSET = 1
+
+
+class OpenDocumentFormatTest(ConvertFormatTest):
+    FORMAT = OpenDocumentFormat
+    FILE = OPENDOCUMENT_FILE
+    MIME = "application/vnd.oasis.opendocument.text"
+    EXT = "odt"
+    COUNT = 4
+    MASK = "*/translations.odt"
+    EXPECTED_PATH = "cs_CZ/translations.odt"
+    FIND_CONTEXT = (
+        "cs.odt///office:document-content[0]/office:body[0]/office:text[0]/text:p[1]"
+    )
+    FIND_MATCH = "Orangutan has five bananas."
+    MATCH = b"PK"
+    NEW_UNIT_MATCH = None
+    BASE = OPENDOCUMENT_FILE
+    EXPECTED_FLAGS = ""
+    EDIT_OFFSET = 1
+
+    @staticmethod
+    def extract_document(content):
+        return bytes(
+            OpenDocumentFormat.convertfile(BytesIOMode("test.odt", content))
+        ).decode()
+
+    def assert_same(self, newdata, testdata):
+        self.assertEqual(
+            self.extract_document(newdata), self.extract_document(testdata),
+        )
+
+
+class IDMLFormatTest(ConvertFormatTest):
+    FORMAT = IDMLFormat
+    FILE = IDML_FILE
+    MIME = "application/octet-stream"
+    EXT = "idml"
+    COUNT = 6
+    MASK = "*/translations.idml"
+    EXPECTED_PATH = "cs_CZ/translations.idml"
+    FIND_CONTEXT = (
+        "idPkg:Story[0]/{}Story[0]/{}XMLElement[0]/{}ParagraphStyleRange[0]"
+        "Stories/Story_mainmainmainmainmainmainmainmainmainmainmainu188.xml"
+    )
+    FIND_MATCH = """<g id="0"><g id="1">THE HEADLINE HERE</g></g>"""
+    MATCH = b"PK"
+    NEW_UNIT_MATCH = None
+    BASE = IDML_FILE
+    EXPECTED_FLAGS = ""
+    EDIT_OFFSET = 1
+
+    @staticmethod
+    def extract_document(content):
+        return bytes(IDMLFormat.convertfile(BytesIOMode("test.idml", content))).decode()
+
+    def assert_same(self, newdata, testdata):
+        self.assertEqual(
+            self.extract_document(newdata), self.extract_document(testdata),
+        )
+
+
+class WindowsRCFormatTest(ConvertFormatTest):
+    FORMAT = WindowsRCFormat
+    FILE = TEST_RC
+    BASE = TEST_RC
+    MIME = "text/plain"
+    EXT = "rc"
+    COUNT = 5
+    MASK = "rc/*.rc"
+    EXPECTED_PATH = "rc/cs-CZ.rc"
+    MATCH = "STRINGTABLE"
+    FIND_CONTEXT = "STRINGTABLE.IDS_MSG1"
+    FIND_MATCH = "Hello, world!\n"
+    EDIT_OFFSET = 1
diff --git a/weblate/formats/tests/test_formats.py b/weblate/formats/tests/test_formats.py
--- a/weblate/formats/tests/test_formats.py
+++ b/weblate/formats/tests/test_formats.py
@@ -48,7 +48,6 @@
     RubyYAMLFormat,
     TSFormat,
     WebExtensionJSONFormat,
-    WindowsRCFormat,
     XliffFormat,
     YAMLFormat,
 )
@@ -77,7 +76,6 @@
 TEST_YAML = get_test_file("cs.pyml")
 TEST_RUBY_YAML = get_test_file("cs.ryml")
 TEST_DTD = get_test_file("cs.dtd")
-TEST_RC = get_test_file("cs-CZ.rc")
 TEST_HE_CLDR = get_test_file("he-cldr.po")
 TEST_HE_CUSTOM = get_test_file("he-custom.po")
 TEST_HE_SIMPLE = get_test_file("he-simple.po")
@@ -162,6 +160,7 @@ class AutoFormatTest(FixtureTestCase, TempDirMixin):
     NEW_UNIT_MATCH = b'\nmsgid "key"\nmsgstr "Source string"\n'
     SUPPORTS_FLAG = True
     EXPECTED_FLAGS = "c-format, max-length:100"
+    EDIT_OFFSET = 0
 
     def setUp(self):
         super().setUp()
@@ -199,7 +198,7 @@ def test_save(self, edit=False):
 
         if edit:
             units = storage.all_units
-            units[0].set_target("Nazdar, svete!\n")
+            units[self.EDIT_OFFSET].set_target("Nazdar, svete!\n")
 
         # Save test file
         storage.save()
@@ -302,6 +301,7 @@ def assert_same(self, newdata, testdata):
 
 class PoFormatTest(AutoFormatTest):
     FORMAT = PoFormat
+    EDIT_OFFSET = 1
 
     def test_add_encoding(self):
         out = os.path.join(self.tempdir, "test.po")
@@ -714,25 +714,6 @@ class DTDFormatTest(AutoFormatTest):
     EXPECTED_FLAGS = ""
 
 
-class WindowsRCFormatTest(AutoFormatTest):
-    FORMAT = WindowsRCFormat
-    FILE = TEST_RC
-    BASE = TEST_RC
-    MIME = "text/plain"
-    EXT = "rc"
-    COUNT = 4
-    MASK = "rc/*.rc"
-    EXPECTED_PATH = "rc/cs-CZ.rc"
-    MATCH = "STRINGTABLE"
-    FIND = "Hello, world!\n"
-    FIND_MATCH = "Hello, world!\n"
-    NEW_UNIT_MATCH = None
-    EXPECTED_FLAGS = ""
-
-    def test_edit(self):
-        raise SkipTest("Known to be broken")
-
-
 class CSVFormatTest(AutoFormatTest):
     FORMAT = CSVFormat
     FILE = TEST_CSV
diff --git a/weblate/trans/tests/data/cs-CZ.rc b/weblate/trans/tests/data/cs-CZ.rc
--- a/weblate/trans/tests/data/cs-CZ.rc
+++ b/weblate/trans/tests/data/cs-CZ.rc
@@ -1,10 +1,9 @@
-LANGUAGE LANG_CZECH, SUBLANG_DEFAULT
-
-STRINGTABLE DISCARDABLE
-BEGIN
-
-IDS_MSG1 "Hello, world!\n"
-IDS_MSG2 "Orangutan has %d banana.\n"
-IDS_MSG3 "Try Weblate at http://demo.weblate.org/!\n"
-IDS_MSG4 "Thank you for using Weblate."
-END
+LANGUAGE LANG_CZECH, SUBLANG_DEFAULT
+
+STRINGTABLE
+BEGIN
+    IDS_MSG1                "Hello, world!\n"
+    IDS_MSG2                "Orangutan has %d banana.\n"
+    IDS_MSG3                "Try Weblate at http://demo.weblate.org/!\n"
+    IDS_MSG4                "Thank you for using Weblate."
+END
diff --git a/weblate/trans/tests/data/cs.html b/weblate/trans/tests/data/cs.html
new file mode 100644
--- /dev/null
+++ b/weblate/trans/tests/data/cs.html
@@ -0,0 +1,10 @@
+<html>
+<body>
+<p>Ahoj světe!</p>
+
+<p>Orangutan has five bananas.</p>
+
+<p>Try Weblate at <a href="https://demo.weblate.org/">weblate.org</a>!</p>
+
+<p>Thank you for using Weblate.</p>
+</body>
diff --git a/weblate/trans/tests/data/cs.odt b/weblate/trans/tests/data/cs.odt
new file mode 100644
Binary files /dev/null and b/weblate/trans/tests/data/cs.odt differ
diff --git a/weblate/trans/tests/data/en.idml b/weblate/trans/tests/data/en.idml
new file mode 100644
Binary files /dev/null and b/weblate/trans/tests/data/en.idml differ
diff --git a/weblate/trans/tests/data/test-base-repo.git.tar b/weblate/trans/tests/data/test-base-repo.git.tar
Binary files a/weblate/trans/tests/data/test-base-repo.git.tar and b/weblate/trans/tests/data/test-base-repo.git.tar differ
diff --git a/weblate/trans/tests/data/test-base-repo.hg.tar b/weblate/trans/tests/data/test-base-repo.hg.tar
Binary files a/weblate/trans/tests/data/test-base-repo.hg.tar and b/weblate/trans/tests/data/test-base-repo.hg.tar differ
diff --git a/weblate/trans/tests/data/test-base-repo.svn.tar b/weblate/trans/tests/data/test-base-repo.svn.tar
Binary files a/weblate/trans/tests/data/test-base-repo.svn.tar and b/weblate/trans/tests/data/test-base-repo.svn.tar differ
diff --git a/weblate/trans/tests/test_component.py b/weblate/trans/tests/test_component.py
--- a/weblate/trans/tests/test_component.py
+++ b/weblate/trans/tests/test_component.py
@@ -341,6 +341,24 @@ def test_create_dtd(self):
         component = self.create_dtd()
         self.verify_component(component, 2, "cs", 4)
 
+    def test_create_html(self):
+        component = self.create_html()
+        self.verify_component(component, 2, "cs", 4, unit="Hello, world!")
+
+    def test_create_idml(self):
+        component = self.create_idml()
+        self.verify_component(
+            component,
+            1,
+            "en",
+            5,
+            unit="""<g id="0"><g id="1">THE HEADLINE HERE</g></g>""",
+        )
+
+    def test_create_odt(self):
+        component = self.create_odt()
+        self.verify_component(component, 2, "cs", 4, unit="Hello, world!")
+
     def test_link(self):
         component = self.create_link()
         self.verify_component(component, 4, "cs", 4)
diff --git a/weblate/trans/tests/utils.py b/weblate/trans/tests/utils.py
--- a/weblate/trans/tests/utils.py
+++ b/weblate/trans/tests/utils.py
@@ -367,6 +367,15 @@ def create_dtd(self):
     def create_appstore(self):
         return self._create_component("appstore", "metadata/*", "metadata/en-US")
 
+    def create_html(self):
+        return self._create_component("html", "html/*.html", "html/en.html")
+
+    def create_idml(self):
+        return self._create_component("idml", "idml/*.idml", "idml/en.idml")
+
+    def create_odt(self):
+        return self._create_component("odt", "odt/*.odt", "odt/en.odt")
+
     def create_link(self, **kwargs):
         parent = self.create_iphone(*kwargs)
         return Component.objects.create(