Handlers convert BeautifulSoup nodes into LATEX fragments. They run inside the
core renderer and are grouped by RenderPhase so transformations can hook into
the DOM at the right point.
Handlers are regular Python callables decorated with @renders(...). The
decorator declares the HTML selectors, phase, and metadata such as priority,
nestable, and whether TeXSmith should auto-mark the node as processed.
Drop the module anywhere on PYTHONPATH and import it before calling
texsmith.render. For MkDocs sites, add the import inside a mkdocs plugin or
docs/hooks/mkdocs_hooks.py. For programmatic runs, import the module ahead of
convert_documents so the decorator executes at import time.
Tip
Handlers should call context.mark_processed(element) when they fully
consume a node. Leave the node untouched to let lower-priority handlers run.
Normalise a BeautifulSoup attribute value to a string when possible.
Source code in src/texsmith/adapters/handlers/_helpers.py
2223242526272829303132333435
defcoerce_attribute(value:Any)->str|None:"""Normalise a BeautifulSoup attribute value to a string when possible."""ifisinstance(value,str):returnvalueifisinstance(value,bytes):try:returnvalue.decode("utf-8")exceptUnicodeDecodeError:returnNoneifisinstance(value,Iterable):foriteminvalue:ifisinstance(item,str):returnitemreturnNone
Return a list of classes extracted from a BeautifulSoup attribute.
Source code in src/texsmith/adapters/handlers/_helpers.py
38394041424344
defgather_classes(value:Any)->list[str]:"""Return a list of classes extracted from a BeautifulSoup attribute."""ifisinstance(value,str):return[value]ifisinstance(value,Iterable)andnotisinstance(value,(bytes,bytearray)):return[cast(str,item)foriteminvalueifisinstance(item,str)]return[]
Check whether a URL string has a valid scheme/netloc combination.
Source code in src/texsmith/adapters/handlers/_helpers.py
56575859606162
defis_valid_url(url:str)->bool:"""Check whether a URL string has a valid scheme/netloc combination."""try:result=urlparse(url)exceptValueError:returnFalsereturnbool(result.schemeandresult.netloc)
Mark a BeautifulSoup node as processed and return it for chaining.
Source code in src/texsmith/adapters/handlers/_helpers.py
16171819
defmark_processed(node:NodeT)->NodeT:"""Mark a BeautifulSoup node as processed and return it for chaining."""cast(Any,node).processed=True# type: ignore[attr-defined]returnnode
Resolve an asset path relative to a Markdown source file.
Source code in src/texsmith/adapters/handlers/_helpers.py
47484950515253
defresolve_asset_path(file_path:Path,path:str|Path)->Path|None:"""Resolve an asset path relative to a Markdown source file."""origin=Path(file_path)iforigin.name=="index.md":origin=origin.parenttarget=(origin/path).resolve()returntargetiftarget.exists()elseNone
Handlers for generic admonition and callout markup.
@renders("div",phase=RenderPhase.POST,priority=50,name="admonitions",nestable=True,auto_mark=False,)defrender_div_admonitions(element:Tag,context:RenderContext)->None:"""Handle MkDocs Material admonition blocks."""classes=gather_classes(element.get("class"))if"admonition"notinclasses:returnif"exercise"inclasses:returntitle=_extract_title(element.find("p",class_="admonition-title"))_promote_callout(element,context,classes=classes,title=title)
Convert promoted callout nodes once their children have rendered.
Source code in src/texsmith/adapters/handlers/admonitions.py
251252253254255256257258259260261262263264
@renders("texsmith-callout",phase=RenderPhase.POST,priority=140,name="finalize_callouts",nestable=False,auto_mark=False,after_children=True,)defrender_texsmith_callouts(element:Tag,context:RenderContext)->None:"""Convert promoted callout nodes once their children have rendered."""classes=gather_classes(element.get("class"))title=element.attrs.pop("data-callout-title","")_render_admonition(element,context,classes=classes,title=title)
Discard or unwrap nodes that must not reach later phases.
Source code in src/texsmith/adapters/handlers/basic.py
5960616263646566676869707172737475
@renders(phase=RenderPhase.PRE,auto_mark=False,name="discard_unwanted")defdiscard_unwanted(root:Tag,context:RenderContext)->None:"""Discard or unwrap nodes that must not reach later phases."""fortag_name,classes,modein_merge_strip_rules(context):class_filter=list(classes)candidates=(root.find_all(tag_name,class_=class_filter)ifclass_filterelseroot.find_all(tag_name))fornodeincandidates:ifmode=="unwrap":node.unwrap()elifmode=="extract":node.extract()elifmode=="decompose":node.decompose()
@renders("h1","h2","h3","h4","h5","h6",phase=RenderPhase.POST,name="render_headings",)defrender_headings(element:Tag,context:RenderContext)->None:"""Convert HTML headings to LaTeX sectioning commands."""# Drop anchor tags within headingsforanchorinelement.find_all("a"):anchor.unwrap()drop_title=context.runtime.get("drop_title")ifdrop_title:context.runtime["drop_title"]=Falselatex=context.formatter.pagestyle(text="plain")element.replace_with(mark_processed(NavigableString(latex)))returnraw_text=element.get_text(strip=False)text=render_moving_text(raw_text,context,legacy_accents=getattr(context.config,"legacy_latex_accents",False),escape="\\"notinraw_text,wrap_scripts=True,)plain_text=element.get_text(strip=True)level=int(element.name[1:])base_level=context.runtime.get("base_level",0)rendered_level=level+base_level-1ref=coerce_attribute(element.get("id"))ifnotref:slug=slugify(plain_text,separator="-")ref=slugorNonenumbered=context.runtime.get("numbered",True)latex=context.formatter.heading(text=text,level=rendered_level,ref=ref,numbered=numbered,)element.replace_with(mark_processed(NavigableString(latex)))context.state.add_heading(level=rendered_level,text=plain_text,ref=ref)
Source code in src/texsmith/adapters/handlers/basic.py
162163164165166167
@renders("q",phase=RenderPhase.INLINE,name="inline_quote",after_children=True)defrender_inline_quote(element:Tag,context:RenderContext)->None:"""Render inline quotations using \\enquote{}."""text=element.get_text(strip=False)latex=context.formatter.enquote(text=text)element.replace_with(NavigableString(latex))
Render <span class="texsmith-smallcaps"> nodes as small capitals.
Source code in src/texsmith/adapters/handlers/basic.py
99100101102103104105106107108
@renders("span",phase=RenderPhase.INLINE,name="inline_smallcaps",auto_mark=False)defrender_inline_smallcaps(element:Tag,context:RenderContext)->None:"""Render ``<span class=\"texsmith-smallcaps\">`` nodes as small capitals."""classes=gather_classes(element.get("class"))if"texsmith-smallcaps"notinclasses:returntext=element.get_text(strip=False)latex=context.formatter.smallcaps(text=text)element.replace_with(NavigableString(latex))context.mark_processed(element)
Source code in src/texsmith/adapters/handlers/basic.py
919293949596
@renders("ins",phase=RenderPhase.INLINE,name="inline_underline",after_children=True)defrender_inline_underline(element:Tag,context:RenderContext)->None:"""Render ``<ins>`` tags using the formatter."""text=element.get_text(strip=False)latex=context.formatter.underline(text=text)element.replace_with(NavigableString(latex))
Convert <br> tags into explicit LATEX line breaks.
Source code in src/texsmith/adapters/handlers/basic.py
85868788
@renders("br",phase=RenderPhase.INLINE,name="line_breaks")defreplace_line_breaks(element:Tag,_context:RenderContext)->None:"""Convert ``<br>`` tags into explicit LaTeX line breaks."""element.replace_with(mark_processed(NavigableString("\\")))
@renders("div",phase=RenderPhase.PRE,priority=130,name="tabbed_cleanup",auto_mark=False,after_children=True,)defcleanup_tabbed_content(element:Tag,_context:RenderContext)->None:"""Remove tabbed container wrappers after children are processed."""classes=gather_classes(element.get("class"))if"tabbed-set"notinclasses:returncontainers=element.find_all("div",class_="tabbed-content",recursive=False)ifnotcontainers:containers=element.find_all("div",class_="tabbed-content")ifnotcontainers:element.unwrap()returnforcontainerincontainers:inner_blocks=container.find_all("div",class_="tabbed-block",recursive=False)ifinner_blocks:forblockininner_blocks:block.unwrap()container.unwrap()element.unwrap()
@renders("blockquote",phase=RenderPhase.POST,priority=200,name="blockquotes",nestable=False,after_children=True,)defrender_blockquotes(element:Tag,context:RenderContext)->None:"""Convert blockquote elements into LaTeX blockquote environments."""classes=element.get("class")or[]if"epigraph"inclasses:returntext=element.get_text(strip=False)latex=context.formatter.blockquote(text)element.replace_with(mark_processed(NavigableString(latex)))
@renders(phase=RenderPhase.POST,priority=-10,name="footnotes",auto_mark=False)defrender_footnotes(root:Tag,context:RenderContext)->None:"""Extract and render footnote references."""footnotes:dict[str,str]={}bibliography=context.state.bibliographydef_normalise_footnote_id(value:str|None)->str:ifnotvalue:return""text=str(value).strip()if":"intext:prefix,suffix=text.split(":",1)ifprefix.startswith("fnref")orprefix.startswith("fn"):returnsuffixreturntextor""def_replace_with_latex(node:Tag,latex:str)->None:replacement=mark_processed(NavigableString(latex))node.replace_with(replacement)_citation_payload_pattern=re.compile(rf"^\s*({_CITATION_KEY_PATTERN}(?:\s*,\s*{_CITATION_KEY_PATTERN})*)\s*$")def_citation_keys_from_payload(text:str|None)->list[str]:ifnottext:return[]match=_citation_payload_pattern.match(text)ifnotmatch:return[]keys=[part.strip()forpartinmatch.group(1).split(",")]return[keyforkeyinkeysifkey]def_render_citation(node:Tag,keys:list[str])->bool:ifnotkeys:returnFalse_ensure_doi_entries(keys,context)missing=[keyforkeyinkeysifkeynotinbibliography]ifmissing:returnFalseforkeyinkeys:context.state.record_citation(key)latex=context.formatter.citation(key=",".join(keys))_replace_with_latex(node,latex)returnTruecitation_footnotes:dict[str,list[str]]={}invalid_footnotes:set[str]=set()forcontainerinroot.find_all("div",class_="footnote"):forliincontainer.find_all("li"):footnote_id=_normalise_footnote_id(coerce_attribute(li.get("id")))ifnotfootnote_id:raiseInvalidNodeError("Footnote item missing identifier")text=li.get_text(strip=False)if_is_multiline_footnote(text):warnings.warn(f"Footnote '{footnote_id}' spans multiple lines and cannot be rendered; dropping it.",stacklevel=2,)invalid_footnotes.add(footnote_id)continuetext=text.strip()footnotes[footnote_id]=textrecovered=_citation_keys_from_payload(text)ifrecovered:citation_footnotes[footnote_id]=recoveredcontainer.decompose()iffootnotes:context.state.footnotes.update(footnotes)forsupinroot.find_all("sup",id=True):footnote_id=_normalise_footnote_id(coerce_attribute(sup.get("id")))iffootnote_idininvalid_footnotes:sup.decompose()continuecitation_keys=citation_footnotes.get(footnote_id)ifcitation_keysand_render_citation(sup,citation_keys):continuepayload=footnotes.get(footnote_id)ifpayloadisNone:payload=context.state.footnotes.get(footnote_id)ifpayloadisNone:citation_keys=_split_citation_keys(footnote_id)ifcitation_keysand_render_citation(sup,citation_keys):continue# Fall back to default handling/warnings for unresolved citations.iffootnote_idandfootnote_idinbibliography:placeholder_note=bool(payload)and_is_bibliography_placeholder(payload)ifpayloadandnotplaceholder_note:warnings.warn(f"Conflicting bibliography definition for '{footnote_id}'.",stacklevel=2,)context.state.record_citation(footnote_id)latex=context.formatter.citation(key=footnote_id)_replace_with_latex(sup,latex)continueifpayloadisNone:iffootnote_idandfootnote_idnotinbibliography:warnings.warn(f"Reference to '{footnote_id}' is not in your bibliography...",stacklevel=2,)continuelatex=context.formatter.footnote(payload)_replace_with_latex(sup,latex)forplaceholderinroot.find_all("texsmith-missing-footnote"):identifier_attr=coerce_attribute(placeholder.get("data-footnote-id"))identifier=identifier_attrorplaceholder.get_text(strip=True)footnote_id=identifier.strip()ifidentifierelse""ifnotfootnote_id:placeholder.decompose()continueiffootnote_idininvalid_footnotes:placeholder.decompose()continuecitation_keys=citation_footnotes.get(footnote_id)ifcitation_keysand_render_citation(placeholder,citation_keys):continuecitation_keys=_split_citation_keys(footnote_id)ifcitation_keysand_render_citation(placeholder,citation_keys):continue# Fall back to default handling for unresolved citations.iffootnote_idinbibliography:context.state.record_citation(footnote_id)latex=context.formatter.citation(key=footnote_id)_replace_with_latex(placeholder,latex)else:payload=context.state.footnotes.get(footnote_id)ifpayload:latex=context.formatter.footnote(payload)_replace_with_latex(placeholder,latex)continuewarnings.warn(f"Reference to '{footnote_id}' is not in your bibliography...",stacklevel=2,)replacement=mark_processed(NavigableString(footnote_id))placeholder.replace_with(replacement)
@renders("p","span",phase=RenderPhase.POST,priority=100,name="latex_raw",nestable=False,)defrender_latex_raw(element:Tag,_context:RenderContext)->None:"""Preserve raw LaTeX payloads embedded in hidden paragraphs."""classes=gather_classes(element.get("class"))if"latex-raw"notinclasses:returntext=element.get_text(strip=False)replacement=mark_processed(NavigableString(text))element.replace_with(replacement)
Convert any remaining MkDocs highlight blocks that escaped earlier passes.
Source code in src/texsmith/adapters/handlers/blocks.py
503504505506507508509
@renders(phase=RenderPhase.POST,priority=5,name="fallback_highlight_blocks",auto_mark=False)defrender_remaining_code_blocks(root:Tag,context:RenderContext)->None:"""Convert any remaining MkDocs highlight blocks that escaped earlier passes."""forhighlightin_iter_reversed(root.find_all("div",class_="highlight")):ifcontext.is_processed(highlight):continue_render_code_block(highlight,context)
@renders("table",phase=RenderPhase.POST,priority=40,name="tables",nestable=False)defrender_tables(element:Tag,context:RenderContext)->None:"""Render HTML tables to LaTeX."""caption=Noneifcaption_node:=element.find("caption"):_strip_caption_prefix(caption_node)caption=caption_node.get_text(strip=False).strip()caption_node.decompose()label=coerce_attribute(element.get("id"))table_rows:list[list[str]]=[]styles:list[list[str]]=[]is_large=Falseforrowinelement.find_all("tr"):row_values:list[str]=[]row_styles:list[str]=[]forcellinrow.find_all(["th","td"]):content=cell.get_text(strip=False).strip()row_values.append(content)row_styles.append(_cell_alignment(cell))table_rows.append(row_values)styles.append(row_styles)stripped="".join(re.sub(r"\\href\{[^\}]+?\}|\\\w{3,}|[\{\}|]","",col)forcolinrow_values)iflen(stripped)>50:is_large=Truecolumns=styles[0]ifstyleselse[]latex=context.formatter.table(columns=columns,rows=table_rows,caption=caption,label=label,is_large=is_large,)element.replace_with(mark_processed(NavigableString(latex)))
@renders("pre",phase=RenderPhase.PRE,priority=45,name="preformatted_code",nestable=True)defrender_preformatted_code(element:Tag,context:RenderContext)->None:"""Render plain <pre> blocks that wrap a <code> element."""classes=gather_classes(element.get("class"))if"mermaid"inclasses:returnparent=element.parentparent_classes=gather_classes(getattr(parent,"get",lambda*_:None)("class"))ifany(clsin{"highlight","codehilite"}forclsinparent_classes):returncode_element=element.find("code",recursive=False)code_classes=gather_classes(code_element.get("class"))ifcode_elementelse[]ifany(clsin{"language-mermaid","mermaid"}forclsincode_classes):returnifcode_elementisnotNoneand_looks_like_mermaid(code_element.get_text(strip=False)):returnlanguage=_extract_language(code_element)ifcode_elementelse"text"engine=_resolve_code_engine(context)code_text=(code_element.get_text(strip=False)ifcode_elementelseelement.get_text(strip=False))ifnotcode_text.strip():returnbaselinestretch=0.5if_is_ascii_art(code_text)elseNoneifnotcode_text.endswith("\n"):code_text+="\n"context.state.requires_shell_escape=context.state.requires_shell_escapeorengine=="minted"latex=context.formatter.codeblock(code=code_text,language=language,lineno=False,filename=None,highlight=[],baselinestretch=baselinestretch,engine=engine,state=context.state,)context.suppress_children(element)element.replace_with(mark_processed(NavigableString(latex)))
@renders("code",phase=RenderPhase.PRE,priority=40,name="standalone_code_blocks",auto_mark=False,)defrender_standalone_code_blocks(element:Tag,context:RenderContext)->None:"""Render <code> elements that include multiline content as block code."""ifelement.find_parent("pre"):returnclasses=element.get("class")or[]ifany(clsin{"language-mermaid","mermaid"}forclsinclasses):returncode_text=element.get_text(strip=False)if"\n"notincode_text:returnif_looks_like_mermaid(code_text):returnlanguage=_extract_language(element)iflanguage=="text":hint,adjusted=_extract_language_hint(code_text)ifhint:language=hintcode_text=adjustedengine=_resolve_code_engine(context)ifengine=="minted":code_text=code_text.replace("{",r"\{").replace("}",r"\}")ifnotcode_text.strip():returnifnotcode_text.endswith("\n"):code_text+="\n"baselinestretch=0.5if_is_ascii_art(code_text)elseNonecontext.state.requires_shell_escape=context.state.requires_shell_escapeorengine=="minted"latex=context.formatter.codeblock(code=code_text,language=language,lineno=False,filename=None,highlight=[],baselinestretch=baselinestretch,engine=engine,state=context.state,)node=mark_processed(NavigableString(latex))ifelement.parentandelement.parent.name=="p"and_is_only_meaningful_child(element):element.parent.replace_with(node)context.mark_processed(element.parent)else:element.replace_with(node)context.mark_processed(element)context.suppress_children(element)
Advanced inline handlers ported from the legacy renderer.
@renders(phase=RenderPhase.PRE,name="escape_plain_text",auto_mark=False)defescape_plain_text(root:Tag,context:RenderContext)->None:"""Escape LaTeX characters on plain text nodes outside code blocks."""legacy_latex_accents=getattr(context.config,"legacy_latex_accents",False)fornodeinlist(root.find_all(string=True)):ifgetattr(node,"processed",False):continueif_has_ancestor(node,"code","script"):continueancestor=getattr(node,"parent",None)skip_plain_text=FalsewhileancestorisnotNone:classes=gather_classes(getattr(ancestor,"get",lambda*_:None)("class"))if"latex-raw"inclassesor"arithmatex"inclasses:skip_plain_text=Truebreakancestor=getattr(ancestor,"parent",None)ifskip_plain_text:continuetext=str(node)ifnottext:continueif"\\keystroke{"intextor"\\keystrokes{"intext:node.replace_with(mark_processed(NavigableString(text)))continuematches=list(_MATH_PAYLOAD_PATTERN.finditer(text))ifnotmatches:escaped=_escape_text_segment(text,context,legacy_latex_accents=legacy_latex_accents)ifescaped!=text:node.replace_with(mark_processed(NavigableString(escaped)))continueparts:list[str]=[]cursor=0formatchinmatches:ifmatch.start()>cursor:segment=text[cursor:match.start()]ifsegment:escaped=_escape_text_segment(segment,context,legacy_latex_accents=legacy_latex_accents,)parts.append(escaped)parts.append(match.group(0))cursor=match.end()ifcursor<len(text):tail=text[cursor:]iftail:escaped=_escape_text_segment(tail,context,legacy_latex_accents=legacy_latex_accents,)parts.append(escaped)replacement=mark_processed(NavigableString("".join(parts)))node.replace_with(replacement)
@renders("abbr",phase=RenderPhase.INLINE,priority=30,name="abbreviation",nestable=False)defrender_abbreviation(element:Tag,context:RenderContext)->None:"""Register and render abbreviations."""title_attr=element.get("title")description=title_attr.strip()ifisinstance(title_attr,str)else""term=element.get_text(strip=True)ifnotterm:returnifnotdescription:legacy_latex_accents=getattr(context.config,"legacy_latex_accents",False)latex_text=escape_latex_chars(term,legacy_accents=legacy_latex_accents)element.replace_with(mark_processed(NavigableString(latex_text)))returnkey=context.state.remember_abbreviation(term,description)ifnotkey:key=termlatex=f"\\acrshort{{{key}}}"element.replace_with(mark_processed(NavigableString(latex)))
@renders("mark",phase=RenderPhase.INLINE,priority=35,name="critic_highlight",auto_mark=False,)defrender_critic_highlight(element:Tag,context:RenderContext)->None:"""Render critic highlights using the formatter highlighting helper."""classes=gather_classes(element.get("class"))if"critic"notinclasses:returntext=element.get_text(strip=False)latex=context.formatter.highlight(text=text)node=mark_processed(NavigableString(latex))context.mark_processed(element)context.suppress_children(element)element.replace_with(node)
@renders("span",phase=RenderPhase.INLINE,priority=-10,name="critic_substitution",auto_mark=False,)defrender_critic_substitution(element:Tag,context:RenderContext)->None:"""Render critic substitutions as paired deletion/addition markup."""classes=gather_classes(element.get("class"))if"critic"notinclassesor"subst"notinclasses:returndeleted=element.find("del")inserted=element.find("ins")ifdeletedisNoneorinsertedisNone:raiseInvalidNodeError("Critic substitution requires both <del> and <ins> children")original=deleted.get_text(strip=False)replacement=inserted.get_text(strip=False)latex=context.formatter.substitution(original=original,replacement=replacement)node=mark_processed(NavigableString(latex))context.mark_processed(element)context.suppress_children(element)element.replace_with(node)
@renders("span","a",phase=RenderPhase.INLINE,priority=45,name="index_entries",nestable=False,auto_mark=False,)defrender_index_entry(element:Tag,context:RenderContext)->None:"""Render inline index term annotations."""tag_name=coerce_attribute(element.get("data-tag-name"))ifnottag_name:returnraw_entry=str(tag_name)parts=[segment.strip()forsegmentinraw_entry.split(",")ifsegment.strip()]ifnotparts:returnlegacy_latex_accents=getattr(context.config,"legacy_latex_accents",False)escaped_fragments=[render_moving_text(part,context,legacy_accents=legacy_latex_accents,wrap_scripts=True)or""forpartinparts]escaped_entry="!".join(fragmentforfragmentinescaped_fragmentsiffragment)style_value=coerce_attribute(element.get("data-tag-style"))style_key=style_value.strip().lower()ifstyle_valueelse""ifstyle_keynotin{"b","i","bi"}:style_key=""display_text=element.get_text(strip=False)or""escaped_text=(render_moving_text(display_text,context,legacy_accents=legacy_latex_accents,wrap_scripts=True)or"")latex=context.formatter.index(escaped_text,entry=escaped_entry,style=style_key)node=mark_processed(NavigableString(latex))context.state.has_index_entries=Truecontext.mark_processed(element)context.suppress_children(element)element.replace_with(node)
@renders("code",phase=RenderPhase.PRE,priority=50,name="inline_code",nestable=False)defrender_inline_code(element:Tag,context:RenderContext)->None:"""Render inline code elements using the formatter."""ifelement.find_parent("pre"):returnclasses=gather_classes(element.get("class"))code=_extract_code_text(element)if"\n"incode:returnengine=_resolve_code_engine(context)language_hint=Noneifcode.startswith("#!"):shebang_parts=code[2:].strip().split(None,1)ifshebang_parts:language_hint=shebang_parts[0]code=shebang_parts[1]iflen(shebang_parts)>1else""has_language=any(cls.startswith("language-")forclsinclasses)language=Noneifhas_languageor"highlight"inclasses:language=next((cls[len("language-"):]or"text"forclsinclassesifcls.startswith("language-")),"text",)iflanguage_hintandnotlanguage:language=language_hintiflanguage:delimiter=_pick_mintinline_delimiter(code)ifdelimiterandengine=="minted":context.state.requires_shell_escape=(context.state.requires_shell_escapeorengine=="minted")latex=context.formatter.codeinline(language=languageor"text",text=code,engine=engine,)element.replace_with(mark_processed(NavigableString(latex)))returnlatex=context.formatter.codeinline(language=languageor"text",text=code,engine=engine,state=context.state,)element.replace_with(mark_processed(NavigableString(latex)))returnlatex=context.formatter.codeinlinett(code)element.replace_with(mark_processed(NavigableString(latex)))
Convert lingering inline code nodes that escaped the PRE phase.
Source code in src/texsmith/adapters/handlers/inline.py
929930931932933934935936937
@renders(phase=RenderPhase.POST,priority=5,name="inline_code_fallback",auto_mark=False)defrender_inline_code_fallback(root:Tag,context:RenderContext)->None:"""Convert lingering inline code nodes that escaped the PRE phase."""forcodeinlist(root.find_all("code")):ifcode.find_parent("pre"):continueifcontext.is_processed(code):continuerender_inline_code(code,context)
@renders("span",phase=RenderPhase.INLINE,priority=30,name="latex_text",nestable=False,auto_mark=False,)defrender_latex_text_span(element:Tag,context:RenderContext)->None:"""Render the custom ``latex-text`` span into canonical LaTeX."""classes=gather_classes(element.get("class"))if"latex-text"notinclasses:returnlatex=mark_processed(NavigableString(r"\LaTeX{}"))context.mark_processed(element)context.suppress_children(element)element.replace_with(latex)
@renders("div",phase=RenderPhase.PRE,priority=30,name="math_block",auto_mark=False,)defrender_math_block(element:Tag,_context:RenderContext)->None:"""Preserve block math payloads."""classes=gather_classes(element.get("class"))if"arithmatex"notinclasses:returntext=element.get_text(strip=False)stripped=text.strip()match=_DISPLAY_MATH_PATTERN.match(stripped)ifmatch:inner=match.group(1)if_payload_is_block_environment(inner):# align/equation environments already provide display math.latex=f"\n{inner.strip()}\n"element.replace_with(mark_processed(NavigableString(latex)))returnelement.replace_with(mark_processed(NavigableString(f"\n{text}\n")))
Source code in src/texsmith/adapters/handlers/inline.py
493494495496497498499500501502503504505506
@renders("span",phase=RenderPhase.PRE,priority=60,name="inline_math",auto_mark=False,)defrender_math_inline(element:Tag,_context:RenderContext)->None:"""Preserve inline math payloads untouched."""classes=gather_classes(element.get("class"))if"arithmatex"notinclasses:returntext=element.get_text(strip=False)element.replace_with(mark_processed(NavigableString(text)))
@renders("script",phase=RenderPhase.PRE,priority=65,name="math_script",nestable=False,auto_mark=False,)defrender_math_script(element:Tag,_context:RenderContext)->None:"""Preserve math payloads generated via script tags (e.g. mdx_math)."""type_attr=coerce_attribute(element.get("type"))iftype_attrisNone:returnifnottype_attr.startswith("math/tex"):returnpayload=element.get_text(strip=False)ifpayloadisNone:payload=""payload=payload.strip()is_display="mode=display"intype_attrifnotpayload:node=NavigableString("")elifis_display:if_payload_is_block_environment(payload):node=NavigableString(f"\n{payload}\n")else:node=NavigableString(f"\n$$\n{payload}\n$$\n")else:node=NavigableString(f"${payload}$")element.replace_with(mark_processed(node))
@renders("span",phase=RenderPhase.INLINE,priority=30,name="script_spans",nestable=False,auto_mark=False,)defrender_script_spans(element:Tag,context:RenderContext)->None:"""Render spans tagged with data-script into explicit text commands."""slug=coerce_attribute(element.get("data-script"))ifnotslug:returnraw_text=element.get_text(strip=False)ifnotraw_text:element.decompose()returnrecord_script_usage_for_slug(slug,raw_text,context)legacy_accents=getattr(context.config,"legacy_latex_accents",False)payload=escape_latex_chars(raw_text,legacy_accents=legacy_accents)latex=f"\\text{slug}{{{payload}}}"parent=element.parentifparentisnotNoneandgetattr(parent,"attrs",None)isnotNone:parent.attrs["data-texsmith-latex"]="true"context.mark_processed(element)element.replace_with(mark_processed(NavigableString(latex)))
@renders("a",phase=RenderPhase.INLINE,priority=60,name="links",nestable=False)defrender_links(element:Tag,context:RenderContext)->None:"""Render hyperlinks and internal references."""href=coerce_attribute(element.get("href"))or""element_id=coerce_attribute(element.get("id"))text=element.get_text(strip=False)# Already handled in preprocessing modulesifelement.name!="a":returnparsed_href=urlparse(href)scheme=(parsed_href.schemeor"").lower()fragment=parsed_href.fragment.strip()ifparsed_href.fragmentelse""ifschemein{"http","https"}:latex=context.formatter.href(text=text,url=requote_url(href))elifscheme:raiseInvalidNodeError(f"Unsupported link scheme '{scheme}' for '{href}'.")elifhref.startswith("#"):latex=context.formatter.ref(text,ref=href[1:])elifhref==""andelement_id:latex=context.formatter.label(element_id)elifhref:resolved=_resolve_local_target(context,href)ifresolvedisNone:raiseAssetMissingError(f"Unable to resolve link target '{href}'")target_ref=fragmentor_infer_heading_reference(resolved)iftarget_ref:latex=context.formatter.ref(textor"",ref=target_ref)else:content=resolved.read_bytes()digest=sha256(content).hexdigest()reference=f"snippet:{digest}"context.state.register_snippet(reference,{"path":resolved,"content":content,"format":resolved.suffix[1:]ifresolved.suffixelse"",},)latex=context.formatter.ref(textor"extrait",ref=reference)else:legacy_latex_accents=getattr(context.config,"legacy_latex_accents",False)latex=escape_latex_chars(text,legacy_accents=legacy_latex_accents)element.replace_with(mark_processed(NavigableString(latex)))
Handlers responsible for assets such as images and diagrams.
@renders("img",phase=RenderPhase.BLOCK,name="render_images",nestable=False)defrender_images(element:Tag,context:RenderContext)->None:"""Convert <img> nodes into LaTeX figures and manage assets."""ifnotcontext.runtime.get("copy_assets",True):alt_text=(coerce_attribute(element.get("alt"))orcoerce_attribute(element.get("title"))or"")placeholder=alt_text.strip()or"[image]"element.replace_with(mark_processed(NavigableString(placeholder)))returnsrc=coerce_attribute(element.get("src"))ifnotsrc:raiseInvalidNodeError("Image tag without 'src' attribute")src=_strip_mkdocs_theme_variant(src)classes=gather_classes(element.get("class"))if{"twemoji","emojione"}.intersection(classes):returnifelement.find_parent("figure"):returnraw_alt=coerce_attribute(element.get("alt"))alt_text=raw_alt.strip()ifraw_altelseNoneraw_title=coerce_attribute(element.get("title"))caption_text=raw_title.strip()ifraw_titleelseNonewidth=coerce_attribute(element.get("width"))orNonetemplate=_figure_template_for(element)link_wrapper=Nonelink_target=Noneparent=element.parentifisinstance(parent,Tag)andparent.name=="a":candidates=[childforchildinparent.contentsifnot(isinstance(child,NavigableString)andnotchild.strip())]iflen(candidates)==1andcandidates[0]iselement:link_wrapper=parentlink_target=coerce_attribute(parent.get("href"))mermaid_payload=_load_mermaid_diagram(context,src)ifmermaid_payloadisnotNone:diagram,_=mermaid_payloadfigure_node=_render_mermaid_diagram(context,diagram,template=template,caption=caption_text,)iffigure_nodeisNone:raiseInvalidNodeError(f"Mermaid source '{src}' does not contain a valid diagram")element.replace_with(figure_node)returnifnotcaption_text:caption_text=alt_textifis_valid_url(src):stored_path=store_remote_image_asset(context,src)else:resolved=_resolve_source_path(context,src)ifresolvedisNone:raiseAssetMissingError(f"Unable to resolve image asset '{src}'")stored_path=store_local_image_asset(context,resolved)figure_node=_apply_figure_template(context,path=stored_path,caption=caption_text,alt=alt_text,label=None,width=width,template=template,adjustbox=False,link=link_target,)iflink_wrapper:link_wrapper.replace_with(figure_node)else:element.replace_with(figure_node)