From 8837555540c4c29bd9e7c6c39bccc03ea913d7ba Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:27:33 -0500 Subject: [PATCH] fix(serializer): keep meta.language out of text exports and emit lang attributes in HTML `BaseMeta.language` is metadata about the text, but the Markdown meta serializer had no branch for `LanguageMetaField` and fell through to `str(field_val)`, so a document with language metadata gained paragraphs such as `confidence=0.99 created_by='langdetect' code=` in Markdown and plain text. `EntitiesMetaField` leaked its repr the same way, and HTML had no way to express the language at all: `HTMLParams.html_lang` was never emitted and elements carried no `lang` attribute. Markdown / plain text: `LanguageMetaField` and `EntitiesMetaField` are skipped unless `mark_meta=True`, where they render as `[Language] de` and `[Entities] IBM, Zurich`. Any other structured field without a renderer is skipped instead of printing its pydantic repr; custom scalar fields keep rendering as before. `CodeMetaField` (`PictureMeta.code`) gets a renderer so the skip does not drop it: a fenced block through `_code_block`, which now takes the language as the info string and sizes the fence past any backtick run in the code, or the bare text when `format_code_blocks` is off (plain text). A marked export puts `[Code]` on the line above the fence, because a fence must start its own line. HTML: the root element is ``. `html_lang` now defaults to None, which resolves to the document language (`doc.body.meta.language`), else the character-weighted majority of the text items' `meta.language`, else "en". Paragraphs, headings and list items whose `meta.language` differs from the page language (compared by primary subtag) get a `lang` attribute (WCAG 3.1.1 and 3.1.2). The language row in the meta details block is unchanged. Ground-truth HTML files are regenerated for the root element only. Closes #745 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Jeff Witt <1848307+wittjeff@users.noreply.github.com> --- docling_core/transforms/serializer/html.py | 68 +++++++++++++-- .../transforms/serializer/markdown.py | 42 ++++++++- docling_core/types/doc/document.py | 4 +- tests/data/doc/2206.01062.yaml.html | 2 +- tests/data/doc/2408.09869_p1_split.gt.html | 2 +- ...iched_p1_include_annotations_false.gt.html | 2 +- ...riched_p1_include_annotations_true.gt.html | 2 +- .../2408.09869v3_enriched_p2_p3_p5.gt.html | 2 +- .../doc/2408.09869v3_enriched_split.gt.html | 2 +- .../2408.09869v3_enriched_split_p2.gt.html | 2 +- tests/data/doc/activities.gt.html | 2 +- tests/data/doc/activities_p1.gt.html | 2 +- tests/data/doc/activities_p2.gt.html | 2 +- tests/data/doc/barchart.gt.html | 2 +- tests/data/doc/concatenated.html | 2 +- .../data/doc/constructed_doc.embedded.html.gt | 2 +- .../doc/constructed_doc.placeholder.html.gt | 2 +- .../doc/constructed_doc.referenced.html.gt | 2 +- tests/data/doc/constructed_document.yaml.html | 2 +- tests/data/doc/constructed_orig_false.gt.html | 2 +- tests/data/doc/constructed_orig_true.gt.html | 2 +- tests/data/doc/dummy_doc.yaml.html | 2 +- tests/data/doc/inline_and_formatting.gt.html | 2 +- tests/data/doc/polymers.gt.html | 2 +- tests/data/doc/rich_table.gt.html | 2 +- tests/data/doc/table_footnotes_496.gt.html | 2 +- .../export/formula_mathml.html | 2 +- tests/test_metadata.py | 85 +++++++++++++++++++ 28 files changed, 208 insertions(+), 39 deletions(-) diff --git a/docling_core/transforms/serializer/html.py b/docling_core/transforms/serializer/html.py index c052af47f..2657e2d02 100644 --- a/docling_core/transforms/serializer/html.py +++ b/docling_core/transforms/serializer/html.py @@ -117,7 +117,10 @@ class HTMLParams(CommonParams): image_mode: ImageRefMode = ImageRefMode.PLACEHOLDER # HTML document properties - html_lang: str = "en" + # Language of the page, emitted as ````. None resolves to the document + # language: ``doc.body.meta.language``, else the character-weighted majority of the + # text items' ``meta.language``, else "en". + html_lang: str | None = None html_head: str | None = None css_styles: str | None = None @@ -143,6 +146,42 @@ class HTMLParams(CommonParams): show_original_list_item_marker: bool = True +_DEFAULT_HTML_LANG = "en" + + +def resolve_document_language(doc: DoclingDocument) -> str: + """Return the language to declare on ```` for the document. + + The document-level language (``doc.body.meta.language``) wins. Without it, the language + that covers most characters across the text items that carry ``meta.language`` is used. + Without any language metadata the result is ``"en"``. + """ + if doc.body.meta is not None and doc.body.meta.language is not None: + return doc.body.meta.language.code.value + weights: dict[str, int] = {} + for item in doc.texts: + if item.meta is not None and item.meta.language is not None: + code = item.meta.language.code.value + weights[code] = weights.get(code, 0) + len(item.text) + if not weights: + return _DEFAULT_HTML_LANG + return max(weights, key=lambda code: (weights[code], code)) + + +def _primary_subtag(lang: str) -> str: + return lang.split("-", 1)[0].lower() + + +def _lang_attrs(item: NodeItem, page_lang: str | None) -> dict[str, str]: + """``lang`` attribute for an item whose language differs from the page language.""" + if item.meta is None or item.meta.language is None: + return {} + code = item.meta.language.code.value + if page_lang is not None and _primary_subtag(page_lang) == _primary_subtag(code): + return {} + return {"lang": code} + + class HTMLTextSerializer(BaseModel, BaseTextSerializer): """HTML-specific text item serializer.""" @@ -162,6 +201,7 @@ def serialize( my_visited: set[str] = visited if visited is not None else set() res_parts: list[SerializationResult] = [] post_processed = False + lang_attrs = _lang_attrs(item, params.html_lang) has_inline_repr = ( item.text == "" @@ -184,7 +224,7 @@ def serialize( # Prepare the HTML based on item type if isinstance(item, TitleItem | SectionHeaderItem): section_level = min(item.level + 1, 6) if isinstance(item, SectionHeaderItem) else 1 - text = get_html_tag_with_text_direction(html_tag=f"h{section_level}", text=text) + text = get_html_tag_with_text_direction(html_tag=f"h{section_level}", text=text, attrs=lang_attrs) elif isinstance(item, FormulaItem): text = self._process_formula( @@ -232,11 +272,14 @@ def serialize( get_html_tag_with_text_direction( html_tag="li", text=text, - attrs=( - {"style": f"list-style-type: '{_escape_css_string(item.marker)} ';"} - if params.show_original_list_item_marker and item.marker - else {} - ), + attrs={ + **lang_attrs, + **( + {"style": f"list-style-type: '{_escape_css_string(item.marker)} ';"} + if params.show_original_list_item_marker and item.marker + else {} + ), + }, ) if text else "" @@ -244,7 +287,7 @@ def serialize( elif not is_inline_scope: # Regular text item - text = get_html_tag_with_text_direction(html_tag="p", text=text) + text = get_html_tag_with_text_direction(html_tag="p", text=text, attrs=lang_attrs) # Apply formatting and hyperlinks to the parent's own text+tag. if not post_processed: @@ -1131,6 +1174,12 @@ class HTMLDocSerializer(DocSerializer): hoist_decoration_whitespace: ClassVar[bool] = True + def model_post_init(self, __context: Any) -> None: + """Resolve the page language once so every element serializer sees the same value.""" + super().model_post_init(__context) + if self.params.html_lang is None: + self.params = self.params.model_copy(update={"html_lang": resolve_document_language(self.doc)}) + @override def _item_wraps_meta(self, item: NodeItem) -> bool: """PictureItem meta is wrapped inside the figure element.""" @@ -1197,9 +1246,10 @@ def _serialize_page_img(page_img: Image): return f"
{img_text}
" # Create HTML structure + html_lang = self.params.html_lang or resolve_document_language(self.doc) html_parts = [ "", - "", + f'', self._generate_head(), "", ] diff --git a/docling_core/transforms/serializer/markdown.py b/docling_core/transforms/serializer/markdown.py index 27ce0dc35..bf4eebf16 100644 --- a/docling_core/transforms/serializer/markdown.py +++ b/docling_core/transforms/serializer/markdown.py @@ -41,6 +41,7 @@ BaseMeta, CaptionPlacement, CodeItem, + CodeMetaField, ContentLayer, CoordOrigin, DescriptionAnnotation, @@ -48,6 +49,7 @@ DocItem, DocItemLabel, DoclingDocument, + EntitiesMetaField, FieldItem, FieldRegionItem, FloatingItem, @@ -60,6 +62,7 @@ InlineGroup, KeyValueItem, KeywordsMetaField, + LanguageMetaField, ListGroup, ListItem, MoleculeMetaField, @@ -230,10 +233,13 @@ def _code_span(text: str) -> str: return f"{ticks}{pad}{text}{pad}{ticks}" -def _code_block(text: str) -> str: - """Wrap text in a fenced code block whose fence is longer than any backtick run inside.""" +def _code_block(text: str, lang: str = "") -> str: + """Wrap text in a fenced code block whose fence is longer than any backtick run inside. + + ``lang`` becomes the info string of the opening fence. + """ fence = "`" * max(3, _longest_backtick_run(text) + 1) - return f"{fence}\n{text}\n{fence}" + return f"{fence}{lang}\n{text}\n{fence}" class OrigListItemMarkerMode(str, Enum): @@ -549,6 +555,7 @@ def serialize( key, params.mark_meta, include_picture_classification=params.include_picture_classification, + format_code_blocks=params.format_code_blocks, ) ) ) @@ -567,21 +574,40 @@ def _serialize_meta_field( mark_meta: bool, *, include_picture_classification: bool = True, + format_code_blocks: bool = True, **kwargs: Any, ) -> str | None: if (field_val := getattr(meta, name)) is not None: + fenced = False if isinstance(field_val, SummaryMetaField): txt = field_val.text elif isinstance(field_val, KeywordsMetaField | TopicsMetaField): txt = ", ".join(field_val.values) elif isinstance(field_val, DescriptionMetaField): txt = field_val.text + elif isinstance(field_val, LanguageMetaField): + # A language code describes the text; it is not content. Only a marked + # export lists it. + if not mark_meta: + return None + txt = field_val.code.value + elif isinstance(field_val, EntitiesMetaField): + if not mark_meta: + return None + txt = ", ".join(mention.text for mention in field_val.mentions) elif isinstance(field_val, PictureClassificationMetaField): if not include_picture_classification: return None txt = self._humanize_text(field_val.get_main_prediction().class_name) elif isinstance(field_val, MoleculeMetaField): txt = field_val.smi + elif isinstance(field_val, CodeMetaField): + if format_code_blocks: + lang = field_val.language.value.lower() if field_val.language else "" + txt = _code_block(field_val.text, lang=lang) + fenced = True + else: + txt = field_val.text elif isinstance(field_val, TabularChartMetaField): temp_doc = DoclingDocument(name="temp") temp_table = temp_doc.add_table(data=field_val.chart_data) @@ -590,11 +616,19 @@ def _serialize_meta_field( txt = table_content else: return None + elif isinstance(field_val, BaseModel): + # A structured field with no renderer above: skip it rather than print its + # pydantic repr as a paragraph. + return None elif tmp := str(field_val or ""): txt = tmp else: return None - return f"[{self._humanize_text(name, title=True)}] {txt}" if mark_meta else txt + if not mark_meta: + return txt + label = f"[{self._humanize_text(name, title=True)}]" + # A code fence must start its own line, so the label goes on the line above it. + return f"{label}\n{txt}" if fenced else f"{label} {txt}" else: return None diff --git a/docling_core/types/doc/document.py b/docling_core/types/doc/document.py index af5d3fa36..c22833a4d 100644 --- a/docling_core/types/doc/document.py +++ b/docling_core/types/doc/document.py @@ -4298,7 +4298,7 @@ def save_as_html( image_mode: ImageRefMode = ImageRefMode.PLACEHOLDER, formula_to_mathml: bool = True, page_no: int | None = None, - html_lang: str = "en", + html_lang: str | None = None, html_head: str = "null", # should be deprecated included_content_layers: set[ContentLayer] | None = None, split_page_view: bool = False, @@ -4396,7 +4396,7 @@ def export_to_html( image_mode: ImageRefMode = ImageRefMode.PLACEHOLDER, formula_to_mathml: bool = True, page_no: int | None = None, - html_lang: str = "en", + html_lang: str | None = None, html_head: str = "null", # should be deprecated ... included_content_layers: set[ContentLayer] | None = None, split_page_view: bool = False, diff --git a/tests/data/doc/2206.01062.yaml.html b/tests/data/doc/2206.01062.yaml.html index dafb11276..87d4c13e6 100644 --- a/tests/data/doc/2206.01062.yaml.html +++ b/tests/data/doc/2206.01062.yaml.html @@ -1,5 +1,5 @@ - + 2206.01062v1 diff --git a/tests/data/doc/2408.09869_p1_split.gt.html b/tests/data/doc/2408.09869_p1_split.gt.html index 1ee955fe7..47d5724ec 100644 --- a/tests/data/doc/2408.09869_p1_split.gt.html +++ b/tests/data/doc/2408.09869_p1_split.gt.html @@ -1,5 +1,5 @@ - + 2408.09869_p1 diff --git a/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_false.gt.html b/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_false.gt.html index aecafbfd1..dc5540615 100644 --- a/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_false.gt.html +++ b/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_false.gt.html @@ -1,5 +1,5 @@ - +
diff --git a/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_true.gt.html b/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_true.gt.html index aecafbfd1..dc5540615 100644 --- a/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_true.gt.html +++ b/tests/data/doc/2408.09869v3_enriched_p1_include_annotations_true.gt.html @@ -1,5 +1,5 @@ - +
diff --git a/tests/data/doc/2408.09869v3_enriched_p2_p3_p5.gt.html b/tests/data/doc/2408.09869v3_enriched_p2_p3_p5.gt.html index 160f63017..39d2dfbd2 100644 --- a/tests/data/doc/2408.09869v3_enriched_p2_p3_p5.gt.html +++ b/tests/data/doc/2408.09869v3_enriched_p2_p3_p5.gt.html @@ -1,5 +1,5 @@ - + 2408.09869v3 diff --git a/tests/data/doc/2408.09869v3_enriched_split.gt.html b/tests/data/doc/2408.09869v3_enriched_split.gt.html index 9f58bd6e9..44151dc37 100644 --- a/tests/data/doc/2408.09869v3_enriched_split.gt.html +++ b/tests/data/doc/2408.09869v3_enriched_split.gt.html @@ -1,5 +1,5 @@ - + 2408.09869v3 diff --git a/tests/data/doc/2408.09869v3_enriched_split_p2.gt.html b/tests/data/doc/2408.09869v3_enriched_split_p2.gt.html index 8ae65bc51..30e6e0007 100644 --- a/tests/data/doc/2408.09869v3_enriched_split_p2.gt.html +++ b/tests/data/doc/2408.09869v3_enriched_split_p2.gt.html @@ -1,5 +1,5 @@ - + 2408.09869v3 diff --git a/tests/data/doc/activities.gt.html b/tests/data/doc/activities.gt.html index 7609052e6..d077dd563 100644 --- a/tests/data/doc/activities.gt.html +++ b/tests/data/doc/activities.gt.html @@ -1,5 +1,5 @@ - + activities diff --git a/tests/data/doc/activities_p1.gt.html b/tests/data/doc/activities_p1.gt.html index 0879bd003..cb7547445 100644 --- a/tests/data/doc/activities_p1.gt.html +++ b/tests/data/doc/activities_p1.gt.html @@ -1,5 +1,5 @@ - + activities diff --git a/tests/data/doc/activities_p2.gt.html b/tests/data/doc/activities_p2.gt.html index 0c89987ac..e72b3015a 100644 --- a/tests/data/doc/activities_p2.gt.html +++ b/tests/data/doc/activities_p2.gt.html @@ -1,5 +1,5 @@ - + activities diff --git a/tests/data/doc/barchart.gt.html b/tests/data/doc/barchart.gt.html index ee8c345bb..c9dbea7df 100644 --- a/tests/data/doc/barchart.gt.html +++ b/tests/data/doc/barchart.gt.html @@ -1,5 +1,5 @@ - + Document diff --git a/tests/data/doc/concatenated.html b/tests/data/doc/concatenated.html index 825ba4cce..ca35aab38 100644 --- a/tests/data/doc/concatenated.html +++ b/tests/data/doc/concatenated.html @@ -1,5 +1,5 @@ - + 2501.17887v1 + Untitled 1 + 2311.18481v1 diff --git a/tests/data/doc/constructed_doc.embedded.html.gt b/tests/data/doc/constructed_doc.embedded.html.gt index 6f057eb93..784d0020a 100644 --- a/tests/data/doc/constructed_doc.embedded.html.gt +++ b/tests/data/doc/constructed_doc.embedded.html.gt @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/constructed_doc.placeholder.html.gt b/tests/data/doc/constructed_doc.placeholder.html.gt index 9fcbc6053..b96d648a5 100644 --- a/tests/data/doc/constructed_doc.placeholder.html.gt +++ b/tests/data/doc/constructed_doc.placeholder.html.gt @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/constructed_doc.referenced.html.gt b/tests/data/doc/constructed_doc.referenced.html.gt index e83c472db..63c5acc29 100644 --- a/tests/data/doc/constructed_doc.referenced.html.gt +++ b/tests/data/doc/constructed_doc.referenced.html.gt @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/constructed_document.yaml.html b/tests/data/doc/constructed_document.yaml.html index eb6bd11a1..f31261210 100644 --- a/tests/data/doc/constructed_document.yaml.html +++ b/tests/data/doc/constructed_document.yaml.html @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/constructed_orig_false.gt.html b/tests/data/doc/constructed_orig_false.gt.html index 3a5fab39d..dffb4f23e 100644 --- a/tests/data/doc/constructed_orig_false.gt.html +++ b/tests/data/doc/constructed_orig_false.gt.html @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/constructed_orig_true.gt.html b/tests/data/doc/constructed_orig_true.gt.html index eb6bd11a1..f31261210 100644 --- a/tests/data/doc/constructed_orig_true.gt.html +++ b/tests/data/doc/constructed_orig_true.gt.html @@ -1,5 +1,5 @@ - + Untitled 1 diff --git a/tests/data/doc/dummy_doc.yaml.html b/tests/data/doc/dummy_doc.yaml.html index 34c76a33b..9415d6034 100644 --- a/tests/data/doc/dummy_doc.yaml.html +++ b/tests/data/doc/dummy_doc.yaml.html @@ -1,5 +1,5 @@ - + dummy_doc diff --git a/tests/data/doc/inline_and_formatting.gt.html b/tests/data/doc/inline_and_formatting.gt.html index de12782eb..055ffe2ea 100644 --- a/tests/data/doc/inline_and_formatting.gt.html +++ b/tests/data/doc/inline_and_formatting.gt.html @@ -1,5 +1,5 @@ - + inline_and_formatting diff --git a/tests/data/doc/polymers.gt.html b/tests/data/doc/polymers.gt.html index 527065a78..969d0aabd 100644 --- a/tests/data/doc/polymers.gt.html +++ b/tests/data/doc/polymers.gt.html @@ -1,5 +1,5 @@ - + report on task: `Write me a document on polymers in food-packaging.` diff --git a/tests/data/doc/rich_table.gt.html b/tests/data/doc/rich_table.gt.html index 9bb8343e7..ee67f7b3d 100644 --- a/tests/data/doc/rich_table.gt.html +++ b/tests/data/doc/rich_table.gt.html @@ -1,5 +1,5 @@ - + Docling Document diff --git a/tests/data/doc/table_footnotes_496.gt.html b/tests/data/doc/table_footnotes_496.gt.html index 78fd9ee0b..d6f23ac3b 100644 --- a/tests/data/doc/table_footnotes_496.gt.html +++ b/tests/data/doc/table_footnotes_496.gt.html @@ -1,5 +1,5 @@ - + table_parsing_test diff --git a/tests/data/docling_document/export/formula_mathml.html b/tests/data/docling_document/export/formula_mathml.html index d298fe954..7aeca38bc 100644 --- a/tests/data/docling_document/export/formula_mathml.html +++ b/tests/data/docling_document/export/formula_mathml.html @@ -1,5 +1,5 @@ - +
diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 42eb5b96c..3d1efc51e 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -13,8 +13,11 @@ MarkdownMetaSerializer, MarkdownParams, ) +from docling_core.transforms.serializer.plain_text import PlainTextDocSerializer from docling_core.types.doc import ( BaseMeta, + CodeLanguageLabel, + CodeMetaField, DocItem, DocItemLabel, DoclingDocument, @@ -423,3 +426,85 @@ def test_keywords_topics_required_values() -> None: TopicsMetaField(values=34) with pytest.raises(ValidationError, match="valid string"): TopicsMetaField(values=[34]) + + +def _bilingual_doc() -> DoclingDocument: + doc = DoclingDocument(name="bilingual") + doc.add_title(text="Titel") + german = doc.add_text(label=DocItemLabel.PARAGRAPH, text="Dieser Absatz ist auf Deutsch.") + german.meta = BaseMeta( + language=LanguageMetaField(code=HumanLanguageLabel.DE, confidence=0.99, created_by="langdetect"), + entities=EntitiesMetaField(mentions=[EntityMention(text="Deutsch", label="LANG")]), + ) + english = doc.add_text(label=DocItemLabel.PARAGRAPH, text="Short.") + english.meta = BaseMeta(language=LanguageMetaField(code=HumanLanguageLabel.EN)) + return doc + + +def test_md_language_and_entities_are_metadata_not_content() -> None: + doc = _bilingual_doc() + + assert doc.export_to_markdown() == "# Titel\n\nDieser Absatz ist auf Deutsch.\n\nShort." + assert doc.export_to_text() == "Titel\n\nDieser Absatz ist auf Deutsch.\n\nShort." + assert doc.export_to_markdown(mark_meta=True) == ( + "# Titel\n\nDieser Absatz ist auf Deutsch.\n\n[Language] de\n\n[Entities] Deutsch\n\nShort.\n\n[Language] en" + ) + + +def test_md_skips_structured_meta_without_renderer() -> None: + class _Opaque(BaseModel): + value: int = 1 + + doc = DoclingDocument(name="opaque") + item = doc.add_text(label=DocItemLabel.PARAGRAPH, text="Body.") + item.meta = BaseMeta(acme__opaque=_Opaque(), acme__note="kept") + + assert doc.export_to_markdown() == "Body.\n\nkept" + + +def test_md_renders_picture_code_meta() -> None: + code = "import matplotlib.pyplot as plt\nplt.plot([1, 2])" + doc = DoclingDocument(name="chart_code") + pic = doc.add_picture() + pic.meta = PictureMeta(code=CodeMetaField(text=code, language=CodeLanguageLabel.PYTHON)) + + assert doc.export_to_markdown() == f"\n\n```python\n{code}\n```" + assert doc.export_to_markdown(mark_meta=True) == f"\n\n[Code]\n```python\n{code}\n```" + assert PlainTextDocSerializer(doc=doc).serialize().text == code + + # A backtick run inside the code must not close the fence early. + code = 'doc = """\n```\nnot a fence\n```\n"""' + pic.meta = PictureMeta(code=CodeMetaField(text=code)) + assert doc.export_to_markdown() == f"\n\n````\n{code}\n````" + + +def test_html_lang_attributes_mark_language_of_parts() -> None: + doc = _bilingual_doc() + + # Default page language: character-weighted majority of the items' languages. + html = doc.export_to_html() + assert '' in html + assert "

Dieser Absatz ist auf Deutsch.

" in html + assert '

Short.

' in html + + # An explicit page language is honoured, and compared by primary subtag. + html = HTMLDocSerializer(doc=doc, params=HTMLParams(html_lang="en-US")).serialize().text + assert '' in html + assert '

Dieser Absatz ist auf Deutsch.

' in html + assert "

Short.

" in html + + # The document-level language on the body meta sets the page language. + doc.body.meta = BaseMeta(language=LanguageMetaField(code=HumanLanguageLabel.FR)) + html = doc.export_to_html() + assert '' in html + assert '

Dieser Absatz ist auf Deutsch.

' in html + assert '

Short.

' in html + + +def test_html_lang_defaults_to_en_without_language_meta() -> None: + doc = DoclingDocument(name="plain") + doc.add_text(label=DocItemLabel.PARAGRAPH, text="No language metadata.") + + html = doc.export_to_html() + assert '' in html + assert "

No language metadata.

" in html