Repository: tatuylonen/wiktextract

Issue:
Word alignment in examples and translations
On Wiktionary, example sentences are aligned with their entries.

Consider the third sense of "example":
> Something that serves as a [pattern](https://en.wiktionary.org/wiki/pattern#English) of [behaviour](https://en.wiktionary.org/wiki/behaviour#English) to be [imitated](https://en.wiktionary.org/wiki/imitate#English) (a [good](https://en.wiktionary.org/wiki/good#English) example) or not to be imitated (a [bad](https://en.wiktionary.org/wiki/bad#English) example).

The example text is as follows:
> Nelson Mandela was an **example** for many to follow.

In it, "**example**" is highlighted in bold.

It is important to know which word is associated with the entry due to inflections and polysemy.

Will there be any support for this in the near future? The best way to do this, in my opinion, would be to include a `raw_text`, `raw_roman` and `raw_english` property for each example, or a `raw_examples` collection altogether, each value with the original WikiText or HTML.

Right now, I have written by own Python script to add the `<b>` and `</b>` tags back with web scraping, but this is inefficient and will eventually lead to API abuse.

Hints from issue comments:
@xxyzz: Maybe we could add the expanded wikitext and template arguments, some templates like "ux" in page "example" only expand to bold wikitext not "b" HTML tag. This feature might be difficult to implement in en code because it processes plain text and the node structure information are discarded.@urroxyz: Thank you for the quick response!

Any quick fix would be fine as long as it grants the user a little bit of freedom, I think. Raw text, regardless of format, would be infinitely appreciated.@xxyzz: BTW Wiktionary has HTML dump file: https://dumps.wikimedia.org/other/enterprise_html/runs/20250301/enwiktionary-NS0-20250301-ENTERPRISE-HTML.json.tar.gz so you don't have to abuse their API.@urroxyz: Thank you so much again!

I couldn't find this, for some reason. Is there a home page with a list of the HTML dumps?@xxyzz: https://dumps.wikimedia.org/other/enterprise_html/
https://dumps.wikimedia.org/other/enterprise_html/runs/@xxyzz: I have created a commit to add bold word offsets for "zh-x" template in Chinese Wiktionary: https://github.com/tatuylonen/wiktextract/commit/dc2c4c0bfc7b9920976b01c2d67844c238e592bb (not merged yet)

For English Wiktionary, these offset data could also be added but will be limited to "zh-x", "ja-x" and "quote-*" templates. Any comments or suggestions?  @urroxyz: This is a great start!

Could a catch-all be added for the multilingual [`ux` template](https://en.wiktionary.org/wiki/Template:ux#Examples)? What about [`*-usex`](https://en.wiktionary.org/w/index.php?search=Template%3A*-usex&title=Special%3ASearch&ns0=1)?

If the parser reads raw WikiText, it could simply detect fields with `'''` (or however the formatting is included) and keep the bold emphasis.

Please correct me if I'm wrong or if there would be any conflcits with this approach.@xxyzz: English Wiktionary extractor processes the plain text of example templates IIRC and only the templates I mentioned above are using wikitext nodes. "ja-usex" is already included in the linked pr. "ux" template unfortunately is processed in plain text, and I'm not confident making too much changes to the en code, maybe we'll add offsets to "ux" later.@urroxyz: Okay, I understand. Thank you.

Would love to see more changes in the future!@xxyzz: "ux" and some other templates are also supported but please wait a few days for kaikki.org to update.@urroxyz: Amazing!@urroxyz: It's been a few weeks since I opened the original issue and I downloaded the English edition for Japanese entries from kaikki, but there doesn't appear to be any alignment between words and their example texts.

Are there any updates?@xxyzz: Please make sure you downloaded the [latest file](https://kaikki.org/dictionary/rawdata.html), the "bold_*_offsets" data were added several weeks ago. For example, page https://kaikki.org/dictionary/Japanese/meaning/日/日本/日本.html has "bold_text_offsets" and some other offsets fields.

Don't download from https://kaikki.org/dictionary/Japanese/index.html, only download from https://kaikki.org/dictionary/rawdata.html.@urroxyz: Thanks again!

Solution patch:
diff --git a/src/wiktextract/extractor/en/example.py b/src/wiktextract/extractor/en/example.py
index 9a400dbb..fe59b49f 100644
--- a/src/wiktextract/extractor/en/example.py
+++ b/src/wiktextract/extractor/en/example.py
@@ -6,6 +6,7 @@ from ...page import clean_node
 from ...tags import valid_tags
 from ...wxr_context import WiktextractContext
 from ..ruby import extract_ruby
+from ..share import calculate_bold_offsets
 from .type_utils import ExampleData, SenseData
 
 
@@ -17,7 +18,7 @@ def extract_example_list_item(
 ) -> list[ExampleData]:
     examples = []
     for template_node in list_item.find_child(NodeKind.TEMPLATE):
-        if template_node.template_name in ["zh-x", "zh-q"]:
+        if template_node.template_name in ["zh-x", "zh-usex", "zh-q"]:
             examples.extend(
                 extract_template_zh_x(
                     wxr,
@@ -26,7 +27,7 @@ def extract_example_list_item(
                     parent_data,
                 )
             )
-        elif template_node.template_name in ["ja-usex", "ja-x"]:
+        elif template_node.template_name in ["ja-usex", "ja-x", "ja-ux"]:
             examples.append(
                 extract_template_ja_usex(
                     wxr,
@@ -54,6 +55,28 @@ def extract_example_list_item(
                     )
             else:
                 examples.append(q_example)
+        elif template_node.template_name in [
+            "ux",
+            "usex",
+            "uxi",
+            "ko-usex",
+            "koex",
+            "ko-x",
+            "th-usex",
+            "th-x",
+            "th-xi",
+            "collocation",
+            "co",
+            "coi",
+        ]:
+            examples.append(
+                extract_ux_template(
+                    wxr,
+                    template_node,
+                    sense_data,
+                    parent_data,
+                )
+            )
 
     return examples
 
@@ -65,26 +88,43 @@ def extract_quote_templates(
         wxr.wtp.node_to_wikitext(node), expand_all=True
     )
     clean_node(wxr, sense_data, expanded_node)
-    ref = ""
-    text = ""
-    translation = ""
-    roman = ""
+    example_data = ExampleData(
+        text="", ref="", english="", roman="", type="quote"
+    )
     for span_tag in expanded_node.find_html_recursively("span"):
         span_class = span_tag.attrs.get("class", "")
         if "cited-source" == span_class:
-            ref = clean_node(wxr, None, span_tag)
+            example_data["ref"] = clean_node(wxr, None, span_tag)
         elif "e-quotation" in span_class:
-            text = clean_node(wxr, None, span_tag)
+            example_data["text"] = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data["text"],
+                example_data,
+                "bold_text_offsets",
+            )
         elif "e-translation" in span_class:
-            translation = clean_node(wxr, None, span_tag)
+            example_data["english"] = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data["english"],
+                example_data,
+                "bold_english_offsets",
+            )
     for i_tag in expanded_node.find_html_recursively(
         "i", attr_name="class", attr_value="e-transliteration"
     ):
-        roman = clean_node(wxr, None, i_tag)
+        example_data["roman"] = clean_node(wxr, None, i_tag)
+        calculate_bold_offsets(
+            wxr,
+            span_tag,
+            example_data["roman"],
+            example_data,
+            "bold_roman_offsets",
+        )
         break
-    example_data = ExampleData(
-        text=text, ref=ref, english=translation, roman=roman, type="quote"
-    )
     clean_example_empty_data(example_data)
     return example_data
 
@@ -105,16 +145,46 @@ def extract_template_ja_usex(
     ):
         ruby_data, node_without_ruby = extract_ruby(wxr, span_tag)
         example_data["text"] = clean_node(wxr, None, node_without_ruby)
+        calculate_bold_offsets(
+            wxr,
+            wxr.wtp.parse(wxr.wtp.node_to_wikitext(node_without_ruby)),
+            example_data["text"],
+            example_data,
+            "bold_text_offsets",
+        )
         example_data["ruby"] = ruby_data
     for span_tag in expanded_node.find_html_recursively(
         "span", attr_name="class", attr_value="tr"
     ):
         example_data["roman"] = clean_node(wxr, None, span_tag)
-    example_data["english"] = clean_node(
-        wxr, None, node.template_parameters.get(3, "")
+        calculate_bold_offsets(
+            wxr,
+            span_tag,
+            example_data["roman"],
+            example_data,
+            "bold_roman_offsets",
+        )
+    tr_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(node.template_parameters.get(3, ""))
+    )
+    example_data["english"] = clean_node(wxr, None, tr_arg)
+    calculate_bold_offsets(
+        wxr,
+        tr_arg,
+        example_data["english"],
+        example_data,
+        "bold_english_offsets",
     )
-    example_data["literal_meaning"] = clean_node(
-        wxr, None, node.template_parameters.get("lit", "")
+    lit_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(node.template_parameters.get("lit", ""))
+    )
+    example_data["literal_meaning"] = clean_node(wxr, None, lit_arg)
+    calculate_bold_offsets(
+        wxr,
+        lit_arg,
+        example_data["literal_meaning"],
+        example_data,
+        "bold_literal_offsets",
     )
     clean_example_empty_data(example_data)
     return example_data
@@ -136,8 +206,18 @@ def extract_template_zh_x(
     for dl_tag in expanded_node.find_html_recursively("dl"):
         has_dl_tag = True
         example_data = deepcopy(parent_example)
-        example_data["english"] = clean_node(
-            wxr, None, template_node.template_parameters.get(2, "")
+        tr_arg = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(
+                template_node.template_parameters.get(2, "")
+            )
+        )
+        example_data["english"] = clean_node(wxr, None, tr_arg)
+        calculate_bold_offsets(
+            wxr,
+            tr_arg,
+            example_data["english"],
+            example_data,
+            "bold_english_offsets",
         )
         for dd_tag in dl_tag.find_html("dd"):
             dd_text = clean_node(wxr, None, dd_tag)
@@ -148,6 +228,13 @@ def extract_template_zh_x(
                     "span", attr_name="lang", attr_value="Latn"
                 ):
                     example_data["roman"] = clean_node(wxr, None, span_tag)
+                    calculate_bold_offsets(
+                        wxr,
+                        span_tag,
+                        example_data["roman"],
+                        example_data,
+                        "bold_roman_offsets",
+                    )
                     for span_tag in dd_tag.find_html_recursively("span"):
                         span_text = clean_node(wxr, None, span_tag)
                         if span_text.startswith("[") and span_text.endswith(
@@ -166,16 +253,43 @@ def extract_template_zh_x(
             "span", attr_name="lang", attr_value="Latn"
         ):
             example_data["roman"] = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data["roman"],
+                example_data,
+                "bold_roman_offsets",
+            )
             break
         for span_tag in expanded_node.find_html("span"):
             span_text = clean_node(wxr, None, span_tag)
             if span_text.startswith("[") and span_text.endswith("]"):
                 example_data["raw_tags"].append(span_text.strip("[]"))
-        example_data["english"] = clean_node(
-            wxr, None, template_node.template_parameters.get(2, "")
+        tr_arg = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(
+                template_node.template_parameters.get(2, "")
+            )
+        )
+        example_data["english"] = clean_node(wxr, None, tr_arg)
+        calculate_bold_offsets(
+            wxr,
+            tr_arg,
+            example_data["english"],
+            example_data,
+            "bold_english_offsets",
+        )
+        lit_arg = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(
+                template_node.template_parameters.get("lit", "")
+            )
         )
-        example_data["literal_meaning"] = clean_node(
-            wxr, None, template_node.template_parameters.get("lit", "")
+        example_data["literal_meaning"] = clean_node(wxr, None, lit_arg)
+        calculate_bold_offsets(
+            wxr,
+            tr_arg,
+            example_data["literal_meaning"],
+            example_data,
+            "bold_literal_offsets",
         )
         for span_tag in expanded_node.find_html("span"):
             span_lang = span_tag.attrs.get("lang", "")
@@ -184,6 +298,13 @@ def extract_template_zh_x(
                 if len(example_text) > 0:
                     new_example = deepcopy(example_data)
                     new_example["text"] = example_text
+                    calculate_bold_offsets(
+                        wxr,
+                        span_tag,
+                        example_text,
+                        example_data,
+                        "bold_text_offsets",
+                    )
                     new_example["tags"].append(
                         "Traditional Chinese"
                         if span_lang == "zh-Hant"
@@ -205,6 +326,13 @@ def extract_zh_x_dl_span_tag(
         if span_lang in ["zh-Hant", "zh-Hans"]:
             new_example = deepcopy(example)
             new_example["text"] = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                new_example["text"],
+                new_example,
+                "bold_text_offsets",
+            )
             results.append(new_example)
         elif "vsHide" in span_tag.attrs.get("class", ""):
             # template has arg "collapsed=y"
@@ -258,3 +386,60 @@ def clean_example_empty_data(data: ExampleData) -> None:
     for key, value in data.copy().items():
         if len(value) == 0:
             del data[key]
+
+
+def extract_ux_template(
+    wxr: WiktextractContext,
+    t_node: TemplateNode,
+    sense_data: SenseData,
+    example_data: ExampleData,
+) -> ExampleData:
+    expanded_node = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(t_node), expand_all=True
+    )
+    clean_node(wxr, sense_data, expanded_node)
+    for html_node in expanded_node.find_child_recursively(NodeKind.HTML):
+        class_names = html_node.attrs.get("class", "")
+        if "e-example" in class_names:
+            example_data["text"] = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data["text"],
+                example_data,
+                "bold_text_offsets",
+            )
+        elif "e-transliteration" in class_names:
+            example_data["roman"] = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data["roman"],
+                example_data,
+                "bold_roman_offsets",
+            )
+        elif "e-translation" in class_names:
+            example_data["english"] = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data["english"],
+                example_data,
+                "bold_english_offsets",
+            )
+        elif "e-literally" in class_names:
+            example_data["literal_meaning"] = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data["literal_meaning"],
+                example_data,
+                "bold_literal_offsets",
+            )
+        elif "qualifier-content" in class_names:
+            raw_tag = clean_node(wxr, None, html_node)
+            if raw_tag != "":
+                example_data["raw_tags"].append(raw_tag)
+
+    clean_example_empty_data(example_data)
+    return example_data
diff --git a/src/wiktextract/extractor/en/type_utils.py b/src/wiktextract/extractor/en/type_utils.py
index cfa0e120..ba5e8d25 100644
--- a/src/wiktextract/extractor/en/type_utils.py
+++ b/src/wiktextract/extractor/en/type_utils.py
@@ -30,13 +30,17 @@ class LinkageData(TypedDict, total=False):
 
 class ExampleData(TypedDict, total=False):
     english: str
+    bold_english_offsets: list[tuple[int, int]]
     note: str
     ref: str
     roman: str
+    bold_roman_offsets: list[tuple[int, int]]
     ruby: Union[list[tuple[str, str]], list[Sequence[str]]]
     text: str
+    bold_text_offsets: list[tuple[int, int]]
     type: str
     literal_meaning: str
+    bold_literal_offsets: list[tuple[int, int]]
     tags: list[str]
     raw_tags: list[str]
 
diff --git a/src/wiktextract/extractor/fr/gloss.py b/src/wiktextract/extractor/fr/gloss.py
index 2a348a18..721d1201 100644
--- a/src/wiktextract/extractor/fr/gloss.py
+++ b/src/wiktextract/extractor/fr/gloss.py
@@ -5,6 +5,7 @@ from wikitextprocessor import NodeKind, TemplateNode, WikiNode
 
 from ...page import clean_node
 from ...wxr_context import WiktextractContext
+from ..share import calculate_bold_offsets
 from .models import AltForm, Example, Sense, WordEntry
 from .tags import translate_raw_tags
 
@@ -149,27 +150,43 @@ def process_exemple_template(
     # https://fr.wiktionary.org/wiki/Modèle:exemple
     # https://fr.wiktionary.org/wiki/Modèle:ja-exemple
     # https://fr.wiktionary.org/wiki/Modèle:zh-exemple
-    text = clean_node(wxr, None, node.template_parameters.get(1, ""))
-    translation = clean_node(
-        wxr,
-        None,
-        node.template_parameters.get(
-            2, node.template_parameters.get("sens", "")
-        ),
+    text_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(node.template_parameters.get(1, ""))
     )
-    transcription = clean_node(
-        wxr,
-        None,
-        node.template_parameters.get(3, node.template_parameters.get("tr", "")),
+    text = clean_node(wxr, None, text_arg)
+    trans_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(
+            node.template_parameters.get(
+                2, node.template_parameters.get("sens", "")
+            )
+        )
+    )
+    translation = clean_node(wxr, None, trans_arg)
+    roman_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(
+            node.template_parameters.get(
+                3, node.template_parameters.get("tr", "")
+            )
+        )
     )
+    transcription = clean_node(wxr, None, roman_arg)
     source = clean_node(wxr, None, node.template_parameters.get("source", ""))
     example_data = Example(
-        text=clean_node(wxr, None, text),
-        translation=clean_node(wxr, None, translation),
-        roman=clean_node(wxr, None, transcription),
-        ref=clean_node(wxr, None, source),
+        text=text,
+        translation=translation,
+        roman=transcription,
+        ref=source,
         time=time,
     )
+    calculate_bold_offsets(
+        wxr, text_arg, text, example_data, "bold_text_offsets"
+    )
+    calculate_bold_offsets(
+        wxr, trans_arg, translation, example_data, "bold_translation_offsets"
+    )
+    calculate_bold_offsets(
+        wxr, roman_arg, transcription, example_data, "bold_roman_offsets"
+    )
     if len(example_data.text) > 0 and isinstance(gloss_data, Sense):
         gloss_data.examples.append(example_data)
     if gloss_data is not None:
diff --git a/src/wiktextract/extractor/fr/models.py b/src/wiktextract/extractor/fr/models.py
index fc840135..d3f5ed31 100644
--- a/src/wiktextract/extractor/fr/models.py
+++ b/src/wiktextract/extractor/fr/models.py
@@ -12,12 +12,15 @@ class FrenchBaseModel(BaseModel):
 
 class Example(FrenchBaseModel):
     text: str = Field(default="", description="Example usage sentence")
+    bold_text_offsets: list[tuple[int, int]] = []
     translation: str = Field(
         default="", description="French translation of the example sentence"
     )
+    bold_translation_offsets: list[tuple[int, int]] = []
     roman: str = Field(
         default="", description="Romanization of the example sentence"
     )
+    bold_roman_offsets: list[tuple[int, int]] = []
     ref: str = Field(
         default="",
         description="Source of the sentence, like book title and page number",
diff --git a/src/wiktextract/extractor/share.py b/src/wiktextract/extractor/share.py
index 2a75a4a0..c79b5918 100644
--- a/src/wiktextract/extractor/share.py
+++ b/src/wiktextract/extractor/share.py
@@ -3,11 +3,11 @@ import re
 from html import unescape
 from typing import Iterable, Optional, Union
 
-from wikitextprocessor import WikiNode
+from wikitextprocessor import NodeKind, WikiNode
 
 
 def strip_nodes(
-    nodes: list[Union[WikiNode, str]]
+    nodes: list[Union[WikiNode, str]],
 ) -> Iterable[Union[WikiNode, str]]:
     # filter nodes that only have newlines, white spaces and non-breaking spaces
     return filter(
@@ -58,7 +58,7 @@ def create_audio_url_dict(filename: str) -> dict[str, str]:
     file_extension = filename[filename.rfind(".") + 1 :].lower()
     if file_extension == "ogv":
         # ".ogv" pages are redirected to ".oga" pages in Wikipedia Commons
-        filename = filename[:filename.rfind(".")] + ".oga"
+        filename = filename[: filename.rfind(".")] + ".oga"
         file_extension = "oga"
     file_url_key = file_extension + "_url"
     filename_without_prefix = filename.removeprefix("File:")
@@ -127,7 +127,32 @@ def split_senseids(senseids_str: str) -> list[str]:
                         )
                     ]
                 )
-            except:
+            except Exception:
                 pass
 
     return senseids
+
+
+def calculate_bold_offsets(
+    wxr,
+    node: WikiNode,
+    node_text: str,
+    example,
+    field: str,
+) -> None:
+    from ..page import clean_node
+
+    offsets = []
+    bold_words = set()
+    for b_tag in node.find_html_recursively("b"):
+        bold_words.add(clean_node(wxr, None, b_tag))
+    for bold_node in node.find_child_recursively(NodeKind.BOLD):
+        bold_words.add(clean_node(wxr, None, bold_node))
+    for bold_word in bold_words:
+        for m in re.finditer(re.escape(bold_word), node_text):
+            offsets.append((m.start(), m.end()))
+    if len(offsets) > 0:
+        if hasattr(example, field):  # pydantic model
+            setattr(example, field, sorted(offsets))
+        elif isinstance(example, dict):
+            example[field] = sorted(offsets)
diff --git a/src/wiktextract/extractor/zh/example.py b/src/wiktextract/extractor/zh/example.py
index df0e7014..b94202e0 100644
--- a/src/wiktextract/extractor/zh/example.py
+++ b/src/wiktextract/extractor/zh/example.py
@@ -1,10 +1,9 @@
-from typing import Optional
-
 from wikitextprocessor.parser import HTMLNode, NodeKind, TemplateNode, WikiNode
 
 from ...page import clean_node
 from ...wxr_context import WiktextractContext
 from ..ruby import extract_ruby
+from ..share import calculate_bold_offsets
 from .linkage import process_linkage_templates_in_gloss
 from .models import Example, Sense, WordEntry
 from .tags import translate_raw_tags
@@ -26,7 +25,7 @@ def extract_example_list_item(
     sense_data: Sense,
     list_item: WikiNode,
     word_entry: WordEntry,
-    parent_example: Optional[Example] = None,
+    parent_example: Example | None = None,
 ) -> None:
     example_data = parent_example or Example()
     if list_item.contain_node(NodeKind.LIST) and not all(
@@ -54,7 +53,21 @@ def extract_example_list_item(
                     extract_template_zh_x(wxr, child, example_data)
                 )
                 clean_node(wxr, sense_data, child)  # add cat link
-            elif template_name in ["ux", "eg", "usex", "uxi", "coi"]:
+            elif template_name in [
+                "ux",
+                "eg",
+                "usex",
+                "uxi",
+                "collocation",
+                "co",
+                "coi",
+                "ko-usex",
+                "ko-x",
+                "koex",
+                "th-usex",
+                "th-x",
+                "th-xi",
+            ]:
                 extract_template_ux(wxr, child, example_data)
                 clean_node(wxr, sense_data, child)  # add cat link
             elif template_name == "Q":
@@ -111,12 +124,33 @@ def extract_quote_templates(
             example_data.ref = clean_node(wxr, None, span_tag)
         elif "e-quotation" in span_class:
             example_data.text = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data.text,
+                example_data,
+                "bold_text_offsets",
+            )
         elif "e-translation" in span_class:
             example_data.translation = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data.translation,
+                example_data,
+                "bold_translation_offsets",
+            )
     for i_tag in expanded_node.find_html_recursively(
         "i", attr_name="class", attr_value="e-transliteration"
     ):
         example_data.roman = clean_node(wxr, None, i_tag)
+        calculate_bold_offsets(
+            wxr,
+            i_tag,
+            example_data.roman,
+            example_data,
+            "bold_roman_offsets",
+        )
         break
 
 
@@ -131,16 +165,46 @@ def extract_template_ja_usex(
     ):
         ruby_data, node_without_ruby = extract_ruby(wxr, span_tag)
         example_data.text = clean_node(wxr, None, node_without_ruby)
+        calculate_bold_offsets(
+            wxr,
+            wxr.wtp.parse(wxr.wtp.node_to_wikitext(node_without_ruby)),
+            example_data.text,
+            example_data,
+            "bold_text_offsets",
+        )
         example_data.ruby = ruby_data
     for span_tag in expanded_node.find_html_recursively(
         "span", attr_name="class", attr_value="tr"
     ):
         example_data.roman = clean_node(wxr, None, span_tag)
-    example_data.translation = clean_node(
-        wxr, None, node.template_parameters.get(3, "")
+        calculate_bold_offsets(
+            wxr,
+            span_tag,
+            example_data.roman,
+            example_data,
+            "bold_roman_offsets",
+        )
+    tr_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(node.template_parameters.get(3, ""))
+    )
+    example_data.translation = clean_node(wxr, None, tr_arg)
+    calculate_bold_offsets(
+        wxr,
+        tr_arg,
+        example_data.translation,
+        example_data,
+        "bold_translation_offsets",
     )
-    example_data.literal_meaning = clean_node(
-        wxr, None, node.template_parameters.get("lit", "")
+    lit_arg = wxr.wtp.parse(
+        wxr.wtp.node_to_wikitext(node.template_parameters.get("lit", ""))
+    )
+    example_data.literal_meaning = clean_node(wxr, None, lit_arg)
+    calculate_bold_offsets(
+        wxr,
+        lit_arg,
+        example_data.literal_meaning,
+        example_data,
+        "bold_literal_offsets",
     )
 
 
@@ -167,6 +231,13 @@ def extract_template_zh_x(
                     "span", attr_name="lang", attr_value="Latn"
                 ):
                     example_data.roman = clean_node(wxr, None, span_tag)
+                    calculate_bold_offsets(
+                        wxr,
+                        span_tag,
+                        example_data.roman,
+                        example_data,
+                        "bold_roman_offsets",
+                    )
                     is_roman = True
                     for span_tag in dd_tag.find_html_recursively("span"):
                         span_text = clean_node(wxr, None, span_tag)
@@ -177,6 +248,13 @@ def extract_template_zh_x(
                     break
                 if not is_roman:
                     example_data.translation = dd_text
+                    calculate_bold_offsets(
+                        wxr,
+                        dd_tag,
+                        dd_text,
+                        example_data,
+                        "bold_translation_offsets",
+                    )
         results.extend(extract_zh_x_dl_span_tag(wxr, dl_tag, example_data))
 
     # no source, single line example
@@ -186,16 +264,43 @@ def extract_template_zh_x(
             "span", attr_name="lang", attr_value="Latn"
         ):
             example_data.roman = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                example_data.roman,
+                example_data,
+                "bold_roman_offsets",
+            )
             break
         for span_tag in expanded_node.find_html("span"):
             span_text = clean_node(wxr, None, span_tag)
             if span_text.startswith("[") and span_text.endswith("]"):
                 example_data.raw_tags.append(span_text.strip("[]"))
-        example_data.translation = clean_node(
-            wxr, None, template_node.template_parameters.get(2, "")
+        tr_arg = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(
+                template_node.template_parameters.get(2, "")
+            )
+        )
+        example_data.translation = clean_node(wxr, None, tr_arg)
+        calculate_bold_offsets(
+            wxr,
+            tr_arg,
+            example_data.translation,
+            example_data,
+            "bold_translation_offsets",
+        )
+        lit_arg = wxr.wtp.parse(
+            wxr.wtp.node_to_wikitext(
+                template_node.template_parameters.get("lit", "")
+            )
         )
-        example_data.literal_meaning = clean_node(
-            wxr, None, template_node.template_parameters.get("lit", "")
+        example_data.literal_meaning = clean_node(wxr, None, lit_arg)
+        calculate_bold_offsets(
+            wxr,
+            lit_arg,
+            example_data.literal_meaning,
+            example_data,
+            "bold_literal_offsets",
         )
         for span_tag in expanded_node.find_html("span"):
             span_lang = span_tag.attrs.get("lang", "")
@@ -204,6 +309,13 @@ def extract_template_zh_x(
                 if len(example_text) > 0:
                     new_example = example_data.model_copy(deep=True)
                     new_example.text = example_text
+                    calculate_bold_offsets(
+                        wxr,
+                        span_tag,
+                        example_text,
+                        new_example,
+                        "bold_text_offsets",
+                    )
                     new_example.tags.append(
                         "Traditional Chinese"
                         if span_lang == "zh-Hant"
@@ -225,6 +337,13 @@ def extract_zh_x_dl_span_tag(
         if span_lang in ["zh-Hant", "zh-Hans"]:
             new_example = example.model_copy(deep=True)
             new_example.text = clean_node(wxr, None, span_tag)
+            calculate_bold_offsets(
+                wxr,
+                span_tag,
+                new_example.text,
+                new_example,
+                "bold_text_offsets",
+            )
             results.append(new_example)
         elif "vsHide" in span_tag.attrs.get("class", ""):
             # template has arg "collapsed=y"
@@ -260,21 +379,47 @@ def extract_template_ux(
     expanded_node = wxr.wtp.parse(
         wxr.wtp.node_to_wikitext(node), expand_all=True
     )
-    for i_tag in expanded_node.find_html_recursively("i"):
-        i_class = i_tag.attrs.get("class", "")
-        if "e-example" in i_class:
-            example_data.text = clean_node(wxr, None, i_tag)
-        elif "e-transliteration" in i_class:
-            example_data.roman = clean_node(wxr, None, i_tag)
-    for span_tag in expanded_node.find_html_recursively("span"):
-        span_class = span_tag.attrs.get("class", "")
-        if "e-translation" in span_class:
-            example_data.translation = clean_node(wxr, None, span_tag)
-        elif "e-literally" in span_class:
-            example_data.literal_meaning = clean_node(wxr, None, span_tag)
-        elif "qualifier-content" in span_class:
+    for html_node in expanded_node.find_child_recursively(NodeKind.HTML):
+        class_names = html_node.attrs.get("class", "")
+        if "e-example" in class_names:
+            example_data.text = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data.text,
+                example_data,
+                "bold_text_offsets",
+            )
+        elif "e-transliteration" in class_names:
+            example_data.roman = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data.roman,
+                example_data,
+                "bold_roman_offsets",
+            )
+        elif "e-translation" in class_names:
+            example_data.translation = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data.translation,
+                example_data,
+                "bold_translation_offsets",
+            )
+        elif "e-literally" in class_names:
+            example_data.literal_meaning = clean_node(wxr, None, html_node)
+            calculate_bold_offsets(
+                wxr,
+                html_node,
+                example_data.literal_meaning,
+                example_data,
+                "bold_literal_offsets",
+            )
+        elif "qualifier-content" in class_names:
             example_data.raw_tags.extend(
-                clean_node(wxr, None, span_tag).split("、")
+                clean_node(wxr, None, html_node).split("、")
             )
     translate_raw_tags(example_data)
 
@@ -296,6 +441,13 @@ def extract_template_Q(
                     "i", attr_name="class", attr_value="e-transliteration"
                 ):
                     example_data.roman = clean_node(wxr, None, i_tag)
+                    calculate_bold_offsets(
+                        wxr,
+                        i_tag,
+                        example_data.roman,
+                        example_data,
+                        "bold_roman_offsets",
+                    )
                 break
             ref_nodes.append(child)
         ref_text = clean_node(wxr, None, ref_nodes)
@@ -307,8 +459,18 @@ def extract_template_Q(
             ("trans", "translation"),
             ("lit", "literal_meaning"),
         ):
-            value = clean_node(
-                wxr, None, node.template_parameters.get(t_arg, "")
+            t_arg_node = wxr.wtp.parse(
+                wxr.wtp.node_to_wikitext(
+                    node.template_parameters.get(t_arg, "")
+                )
             )
+            value = clean_node(wxr, None, t_arg_node)
             if len(value) > 0:
                 setattr(example_data, field, value)
+                calculate_bold_offsets(
+                    wxr,
+                    t_arg_node,
+                    value,
+                    example_data,
+                    "bold_" + field.split("_")[0] + "_offsets",
+                )
diff --git a/src/wiktextract/extractor/zh/models.py b/src/wiktextract/extractor/zh/models.py
index 81e23b4a..7a88b307 100644
--- a/src/wiktextract/extractor/zh/models.py
+++ b/src/wiktextract/extractor/zh/models.py
@@ -16,13 +16,17 @@ class Example(ChineseBaseModel):
         description="Example usage sentences, some might have have both "
         "Simplified and Traditional Chinese forms",
     )
+    bold_text_offsets: list[tuple[int, int]] = []
     translation: str = Field(
         default="", description="Chinese translation of the example sentence"
     )
+    bold_translation_offsets: list[tuple[int, int]] = []
     literal_meaning: str = ""
+    bold_literal_offsets: list[tuple[int, int]] = []
     roman: str = Field(
         default="", description="Romanization of the example sentence"
     )
+    bold_roman_offsets: list[tuple[int, int]] = []
     ref: str = Field(
         default="",
         description="Source of the sentence, like book title and page number",


Test patch:
diff --git a/tests/test_en_category.py b/tests/test_en_category.py
index c298feed..d07b2df0 100644
--- a/tests/test_en_category.py
+++ b/tests/test_en_category.py
@@ -3,6 +3,7 @@ from unittest import TestCase
 from wikitextprocessor import Wtp
 
 from wiktextract.config import WiktionaryConfig
+from wiktextract.thesaurus import close_thesaurus_db
 from wiktextract.wxr_context import WiktextractContext
 
 
@@ -14,6 +15,9 @@ class TestEnCategory(TestCase):
 
     def tearDown(self) -> None:
         self.wxr.wtp.close_db_conn()
+        close_thesaurus_db(
+            self.wxr.thesaurus_db_path, self.wxr.thesaurus_db_conn
+        )
 
     def test_filter_language_categories(self):
         # GH issue #537
@@ -40,11 +44,15 @@ class TestEnCategory(TestCase):
         self.wxr.wtp.add_page(
             "Template:C",
             10,
-            "[[Category:en:Beetles|BEETLE]][[Category:en:Games|BEETLE]]"
+            "[[Category:en:Beetles|BEETLE]][[Category:en:Games|BEETLE]]",
         )
-        page_data = parse_page(self.wxr, "beetle", """==English==
+        page_data = parse_page(
+            self.wxr,
+            "beetle",
+            """==English==
 ===Noun===
 # Any of numerous species of insect...
 
-{{C|en|Beetles|Games}}""")
+{{C|en|Beetles|Games}}""",
+        )
         self.assertEqual(page_data[0]["categories"], ["en:Beetles", "en:Games"])
diff --git a/tests/test_en_pronunciation.py b/tests/test_en_pronunciation.py
index 11bc9bdc..3d6688e2 100644
--- a/tests/test_en_pronunciation.py
+++ b/tests/test_en_pronunciation.py
@@ -5,6 +5,7 @@ from wikitextprocessor import Page, Wtp
 
 from wiktextract.config import WiktionaryConfig
 from wiktextract.extractor.en.pronunciation import parse_pronunciation
+from wiktextract.thesaurus import close_thesaurus_db
 from wiktextract.wxr_context import WiktextractContext
 
 
@@ -18,6 +19,9 @@ class TestPronunciation(TestCase):
 
     def tearDown(self) -> None:
         self.wxr.wtp.close_db_conn()
+        close_thesaurus_db(
+            self.wxr.thesaurus_db_path, self.wxr.thesaurus_db_conn
+        )
 
     test1_templates = [
         ("enPR", "(Received Pronunciation) enPR: föö"),
@@ -245,11 +249,12 @@ class TestPronunciation(TestCase):
                         "ipa": "/foo/",
                         "pos": "noun",
                     },
-                    { "homophone": "feu", "pos": "noun" },
+                    {"homophone": "feu", "pos": "noun"},
                     {"rhymes": "-oo", "pos": "noun"},
                 ]
             },
         )
+
     test1_templates = [
         (
             "IPA",
diff --git a/tests/test_fr_gloss.py b/tests/test_fr_gloss.py
index cb75b578..e5a6c354 100644
--- a/tests/test_fr_gloss.py
+++ b/tests/test_fr_gloss.py
@@ -131,9 +131,11 @@ class TestFrGloss(TestCase):
                         "examples": [
                             {
                                 "text": "这匹马很大。",
+                                "bold_text_offsets": [(2, 3)],
                                 "translation": "Ce cheval est grand.",
                                 "roman": "Zhè pǐ mǎ hěn dà.\n"
                                 "⠌⠢⠆ ⠏⠊⠄ ⠍⠔⠄ ⠓⠴⠄ ⠙⠔⠆⠐⠆",
+                                "bold_roman_offsets": [(7, 9)],
                             }
                         ],
                     }
@@ -687,6 +689,7 @@ class TestFrGloss(TestCase):
                 {
                     "ref": "source",
                     "text": "之後十年，越南人民吃盡千辛萬苦",
+                    "bold_text_offsets": [(11, 15)],
                     "translation": "Au cours de la décennie suivante, le peuple vietnamien a beaucoup souffert",
                 }
             ],
diff --git a/tests/test_fr_linkage.py b/tests/test_fr_linkage.py
index 6a0e64b0..5cbcbdac 100644
--- a/tests/test_fr_linkage.py
+++ b/tests/test_fr_linkage.py
@@ -253,10 +253,7 @@ class TestLinkage(TestCase):
         root = self.wxr.wtp.parse("* (''10<sup>9</sup>'') [[Gb]]")
         extract_linkage(self.wxr, page_data, root, "variantes orthographiques")
         self.assertEqual(
-            [
-                d.model_dump(exclude_defaults=True)
-                for d in page_data[-1].forms
-            ],
+            [d.model_dump(exclude_defaults=True) for d in page_data[-1].forms],
             [
                 {"form": "Gb", "sense": "10⁹"},
             ],
diff --git a/tests/test_zh_etymology.py b/tests/test_zh_etymology.py
index bf2dd6b1..8d92cad4 100644
--- a/tests/test_zh_etymology.py
+++ b/tests/test_zh_etymology.py
@@ -53,7 +53,9 @@ class TestNote(TestCase):
                         "Traditional Chinese",
                     ],
                     "text": "春宵一刻直千金，花有清香月有陰。",
+                    "bold_text_offsets": [(2, 4), (5, 7)],
                     "roman": "Chūnxiāo yīkè zhí qiānjīn, huā yǒu qīngxiāng yuè yǒu yīn.",
+                    "bold_roman_offsets": [(9, 13), (18, 25)],
                     "translation": "春天的夜晚非常寶貴，僅僅一刻卻值得千金價，花朵散發陣陣清香，月光投射出朦朧陰影。",
                 },
                 {
@@ -64,7 +66,9 @@ class TestNote(TestCase):
                         "Simplified Chinese",
                     ],
                     "text": "春宵一刻直千金，花有清香月有阴。",
+                    "bold_text_offsets": [(2, 4), (5, 7)],
                     "roman": "Chūnxiāo yīkè zhí qiānjīn, huā yǒu qīngxiāng yuè yǒu yīn.",
+                    "bold_roman_offsets": [(9, 13), (18, 25)],
                     "translation": "春天的夜晚非常寶貴，僅僅一刻卻值得千金價，花朵散發陣陣清香，月光投射出朦朧陰影。",
                 },
             ],
@@ -100,7 +104,12 @@ class TestNote(TestCase):
                         "Traditional Chinese",
                     ],
                     "text": "焚膏油以繼晷，恆兀兀以窮年。",
+                    "bold_text_offsets": [
+                        (0, 1),
+                        (4, 5),
+                    ],  # broken partial b tag
                     "roman": "Fén gāoyóu yǐ jì guǐ, héng wùwù yǐ qióng nián.",
+                    "bold_roman_offsets": [(0, 7), (14, 20)],
                     "translation": "燃燒燈油夜以繼日，終年孜孜不倦刻苦用功。",
                 },
                 {
@@ -111,7 +120,9 @@ class TestNote(TestCase):
                         "Simplified Chinese",
                     ],
                     "text": "焚膏油以继晷，恒兀兀以穷年。",
+                    "bold_text_offsets": [(0, 1), (4, 5)],
                     "roman": "Fén gāoyóu yǐ jì guǐ, héng wùwù yǐ qióng nián.",
+                    "bold_roman_offsets": [(0, 7), (14, 20)],
                     "translation": "燃燒燈油夜以繼日，終年孜孜不倦刻苦用功。",
                 },
             ],
diff --git a/tests/test_zh_example.py b/tests/test_zh_example.py
index 3e2711ff..3bc08602 100644
--- a/tests/test_zh_example.py
+++ b/tests/test_zh_example.py
@@ -65,8 +65,11 @@ class TestExample(TestCase):
                         "Traditional Chinese",
                     ],
                     "text": "王曰：「封，以厥庶民暨厥臣達大家，以厥臣達王惟邦君。」",
+                    "bold_text_offsets": [(14, 16)],
                     "roman": "Wáng yuē: “Fēng, yǐ jué shùmín jì jué chén dá dàjiā, yǐ jué chén dá wáng wéi bāngjūn.”",
+                    "bold_roman_offsets": [(46, 51)],
                     "translation": "王說：「封啊，從殷的老百姓和他們的官員到卿大夫，從他們的官員到諸侯和國君。」",
+                    "bold_translation_offsets": [(20, 23)],
                 },
                 {
                     "ref": "《尚書·梓材》",
@@ -76,8 +79,11 @@ class TestExample(TestCase):
                         "Simplified Chinese",
                     ],
                     "text": "王曰：「封，以厥庶民暨厥臣达大家，以厥臣达王惟邦君。」",
+                    "bold_text_offsets": [(14, 16)],
                     "roman": "Wáng yuē: “Fēng, yǐ jué shùmín jì jué chén dá dàjiā, yǐ jué chén dá wáng wéi bāngjūn.”",
+                    "bold_roman_offsets": [(46, 51)],
                     "translation": "王說：「封啊，從殷的老百姓和他們的官員到卿大夫，從他們的官員到諸侯和國君。」",
+                    "bold_translation_offsets": [(20, 23)],
                 },
             ],
         )
@@ -99,13 +105,17 @@ class TestExample(TestCase):
             [
                 {
                     "text": "中文授課",
+                    "bold_text_offsets": [(0, 2)],
                     "tags": ["Traditional Chinese"],
                     "roman": "zhōngwén shòukè",
+                    "bold_roman_offsets": [(0, 8)],
                 },
                 {
                     "text": "中文授课",
+                    "bold_text_offsets": [(0, 2)],
                     "tags": ["Simplified Chinese"],
                     "roman": "zhōngwén shòukè",
+                    "bold_roman_offsets": [(0, 8)],
                 },
             ],
         )
@@ -134,7 +144,9 @@ class TestExample(TestCase):
                 {
                     "ref": "2002年3月9日, 堀田 由美",
                     "text": "オレの日本語どう？悪くないだろ 韓国語と英語も話すんだぜ 趣味だな語学は 寝泊りはどこ？近くのホテル？",
+                    "bold_text_offsets": [(20, 22)],
                     "roman": "Ore no Nihongo dō? Waruku naidaro Kankokugo to Eigo mo hanasunda ze Shumi da na gogaku wa Netomari wa doko? Chikaku no hoteru?",
+                    "bold_roman_offsets": [(47, 51)],
                     "ruby": [
                         ("日", "に"),
                         ("本", "ほん"),
@@ -169,7 +181,7 @@ class TestExample(TestCase):
         self.wxr.wtp.add_page(
             "Template:zh-x",
             10,
-            """<dl class="zhusex"><span lang="zh-Hant" class="Hant">-{<!-- -->[[如果#漢語|如果]][[唔係#漢語|唔係]][[今日#漢語|今日]][[拆穿#漢語|拆穿]][[你#漢語|你]][[槓嘢#漢語|槓野]]，[[畀#漢語|俾]][[你#漢語|你]][[混#漢語|混]][[咗#漢語|左]][[入#漢語|入]][[稅局#漢語|稅局]][[重#漢語|重]][[死人#漢語|死人]][[呀#漢語|呀]]！<!-- -->}-</span> <span style="color:darkgreen; font-size:x-small;">&#91;[[w:廣州話|廣州話]]，[[w:繁体中文|繁體]]&#93;</span><br><span lang="zh-Hans" class="Hans">-{<!-- -->[[如果#漢語|如果]][[唔系#漢語|唔系]][[今日#漢語|今日]][[拆穿#漢語|拆穿]][[你#漢語|你]][[杠嘢#漢語|杠野]]，[[畀#漢語|俾]][[你#漢語|你]][[混#漢語|混]][[咗#漢語|左]][[入#漢語|入]][[税局#漢語|税局]][[重#漢語|重]][[死人#漢語|死人]][[呀#漢語|呀]]！<!-- -->}-</span> <span style="color:darkgreen; font-size:x-small;">&#91;[[w:廣州話|廣州話]]，[[w:简体中文|簡體]]&#93;</span><dd><span lang="zh-Latn"><i>roman</i></span> <span>&#91;[[w:廣州話拼音方案|廣州話拼音]]&#93;</span></dd><dd>如果不是今天揭穿你的老底，給你混進稅務局就更'''糟糕'''了！</dd></dl>[[Category:有使用例的粵語詞]]""",
+            """<dl class="zhusex"><span lang="zh-Hant" class="Hant">-{<!-- -->[[如果#漢語|如果]][[唔係#漢語|唔係]][[今日#漢語|今日]][[拆穿#漢語|拆穿]][[你#漢語|你]][[槓嘢#漢語|槓野]]，[[畀#漢語|俾]][[你#漢語|你]][[混#漢語|混]][[咗#漢語|左]][[入#漢語|入]][[稅局#漢語|稅局]][[重#漢語|重]]<b>死人</b>[[呀#漢語|呀]]！<!-- -->}-</span> <span style="color:darkgreen; font-size:x-small;">&#91;[[w:廣州話|廣州話]]，[[w:繁体中文|繁體]]&#93;</span><br><span lang="zh-Hans" class="Hans">-{<!-- -->[[如果#漢語|如果]][[唔系#漢語|唔系]][[今日#漢語|今日]][[拆穿#漢語|拆穿]][[你#漢語|你]][[杠嘢#漢語|杠野]]，[[畀#漢語|俾]][[你#漢語|你]][[混#漢語|混]][[咗#漢語|左]][[入#漢語|入]][[税局#漢語|税局]][[重#漢語|重]]<b>死人</b>[[呀#漢語|呀]]！<!-- -->}-</span> <span style="color:darkgreen; font-size:x-small;">&#91;[[w:廣州話|廣州話]]，[[w:简体中文|簡體]]&#93;</span><dd><span lang="zh-Latn" style="color:#404D52"><i>jyu<sup>4</sup> gwo<sup>2</sup> m<sup>4</sup> hai<sup>6</sup> gam<sup>1</sup> jat<sup>6</sup> caak<sup>3</sup> cyun<sup>1</sup> nei<sup>5</sup> lung<sup>5</sup> je<sup>5</sup>, bei<sup>2</sup> nei<sup>5</sup> wan<sup>6</sup> zo<sup>2</sup> jap<sup>6</sup> seoi<sup>3</sup> guk<sup>6-2</sup> zung<sup>6</sup> <b>sei<sup>2</sup> jan<sup>4</sup></b> aa<sup>3</sup>!</i></span> <span style="color:darkgreen; font-size:x-small;">&#91;[[w:廣州話拼音方案|廣州話拼音]]&#93;</span></dd><dd>如果不是今天揭穿你的老底，給你混進稅務局就更'''糟糕'''了！</dd></dl>[[Category:有使用例的粵語詞]]""",
         )
         sense_data = Sense()
         root = self.wxr.wtp.parse("""#* {{quote-book|zh}}
@@ -183,16 +195,22 @@ class TestExample(TestCase):
                 {
                     "ref": "1957, 王力",
                     "text": "如果唔係今日拆穿你槓野，俾你混左入稅局重死人呀！",
-                    "roman": "roman",
+                    "bold_text_offsets": [(20, 22)],
+                    "roman": "jyu⁴ gwo² m⁴ hai⁶ gam¹ jat⁶ caak³ cyun¹ nei⁵ lung⁵ je⁵, bei² nei⁵ wan⁶ zo² jap⁶ seoi³ guk⁶⁻² zung⁶ sei² jan⁴ aa³!",
+                    "bold_roman_offsets": [(99, 108)],
                     "tags": ["Cantonese", "Pinyin", "Traditional Chinese"],
                     "translation": "如果不是今天揭穿你的老底，給你混進稅務局就更糟糕了！",
+                    "bold_translation_offsets": [(22, 24)],
                 },
                 {
                     "ref": "1957, 王力",
                     "text": "如果唔系今日拆穿你杠野，俾你混左入税局重死人呀！",
-                    "roman": "roman",
+                    "bold_text_offsets": [(20, 22)],
+                    "roman": "jyu⁴ gwo² m⁴ hai⁶ gam¹ jat⁶ caak³ cyun¹ nei⁵ lung⁵ je⁵, bei² nei⁵ wan⁶ zo² jap⁶ seoi³ guk⁶⁻² zung⁶ sei² jan⁴ aa³!",
+                    "bold_roman_offsets": [(99, 108)],
                     "tags": ["Cantonese", "Pinyin", "Simplified Chinese"],
                     "translation": "如果不是今天揭穿你的老底，給你混進稅務局就更糟糕了！",
+                    "bold_translation_offsets": [(22, 24)],
                 },
             ],
         )
@@ -217,7 +235,9 @@ class TestExample(TestCase):
             [
                 {
                     "text": "黑奴籲天錄",
+                    "bold_text_offsets": [(0, 2)],
                     "roman": "Hēinú Yùtiānlù",
+                    "bold_roman_offsets": [(0, 5)],
                     "tags": [
                         "Traditional Chinese",
                         "Classical Chinese",
@@ -225,10 +245,13 @@ class TestExample(TestCase):
                     ],
                     "translation": "湯姆叔叔的小屋",
                     "literal_meaning": "黑人奴隸向上天呼告的記錄",
+                    "bold_literal_offsets": [(0, 4)],
                 },
                 {
                     "text": "黑奴吁天录",
+                    "bold_text_offsets": [(0, 2)],
                     "roman": "Hēinú Yùtiānlù",
+                    "bold_roman_offsets": [(0, 5)],
                     "tags": [
                         "Simplified Chinese",
                         "Classical Chinese",
@@ -236,6 +259,7 @@ class TestExample(TestCase):
                     ],
                     "translation": "湯姆叔叔的小屋",
                     "literal_meaning": "黑人奴隸向上天呼告的記錄",
+                    "bold_literal_offsets": [(0, 4)],
                 },
             ],
         )
@@ -259,7 +283,9 @@ class TestExample(TestCase):
             [
                 {
                     "text": "その認識で正しいと思う。",
+                    "bold_text_offsets": [(2, 4)],
                     "roman": "Sono ninshiki de tadashii to omou.",
+                    "bold_roman_offsets": [(5, 13)],
                     "ruby": [
                         ("認識", "にんしき"),
                         ("正", "ただ"),
@@ -267,6 +293,7 @@ class TestExample(TestCase):
                     ],
                     "translation": "我相信你是對的。",
                     "literal_meaning": "我相信你的理解是對的。",
+                    "bold_literal_offsets": [(5, 7)],
                 },
             ],
         )
@@ -290,8 +317,11 @@ class TestExample(TestCase):
             [
                 {
                     "text": "وَمَا مُحَمَّدٌ إِلَّا رَسُولٌ قَدْ خَلَتْ مِنْ قَبْلِهِ الرُّسُلُ",
+                    "bold_text_offsets": [(6, 15)],
                     "roman": "wa-mā muḥammadun ʔillā rasūlun qad ḵalat min qablihi r-rusulu",
+                    "bold_roman_offsets": [(6, 16)],
                     "translation": "穆罕默德只是一個使者，在他之前，有許多使者，確已逝去了。",
+                    "bold_translation_offsets": [(0, 4)],
                     "ref": "公元 609年–632年, 《古蘭經》, 3:144:",
                 },
             ],
@@ -316,7 +346,9 @@ class TestExample(TestCase):
             [
                 {
                     "text": "В чужо́й монасты́рь со свои́м уста́вом не хо́дят",
+                    "bold_text_offsets": [(30, 38)],
                     "roman": "V čužój monastýrʹ so svoím ustávom ne xódjat",
+                    "bold_roman_offsets": [(27, 34)],
                     "translation": "入鄉隨俗，入境隨俗",
                     "literal_meaning": "你不能用你自己的憲章去另一個寺院",
                     "raw_tags": ["諺語"],
@@ -343,8 +375,11 @@ class TestExample(TestCase):
             [
                 {
                     "text": "Я кончаюсь, Горбунок: Царь велит мне в кипяток!",
+                    'bold_text_offsets': [(39, 46)],
                     "roman": "Ja končajusʹ, Gorbunok: Carʹ velit mne v kipjatok!",
+                    'bold_roman_offsets': [(41, 49)],
                     "translation": "我將要完蛋，駝背。國王命令我跳入沸水中！",
+                    'bold_translation_offsets': [(16, 18)],
                     "ref": "P. Yershov, The Humpback Horse 駝背的馬:",
                 },
             ],