Source code for docx_parser_converter.docx_to_html.converters.style_converter

from typing import List, Optional

from docx_parser_converter.docx_parsers.models.styles_models import FontProperties, SpacingProperties, IndentationProperties
from docx_parser_converter.docx_parsers.models.document_models import DocMargins

HEAVY_UNDERLINE_THICKNESS = "0.15em"

UNDERLINE_CSS_MAP = {
    "single": {"line": "underline", "style": "solid"},
    "words": {"line": "underline", "style": "solid"},
    "double": {"line": "underline", "style": "double"},
    "thick": {"line": "underline", "style": "solid", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "dotted": {"line": "underline", "style": "dotted"},
    "dottedheavy": {"line": "underline", "style": "dotted", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "dash": {"line": "underline", "style": "dashed"},
    "dashedheavy": {"line": "underline", "style": "dashed", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "dashlong": {"line": "underline", "style": "dashed"},
    "dashlongheavy": {"line": "underline", "style": "dashed", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "dotdash": {"line": "underline", "style": "dashed"},
    "dashdotheavy": {"line": "underline", "style": "dashed", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "dashdotdotheavy": {"line": "underline", "style": "dashed", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "wave": {"line": "underline", "style": "wavy"},
    # "wavy": {"line": "underline", "style": "wavy"},
    "wavyheavy": {"line": "underline", "style": "wavy", "thickness": HEAVY_UNDERLINE_THICKNESS},
    "none": {"line": "none"},
}

# Aliases & fallbacks specified by the ST_Underline mapping rules
for alias, target in {
    "dotdotdash": "dotdash",
    "dashdot": "dotdash",
    "dashdotdot": "dotdotdash",
    "dotdashheavy": "dashdotheavy",
    "dashheavy": "dashedheavy",
    "thickdash": "dashedheavy",
    "wavy": "wave",
    "wavydouble": "wave",
}.items():
    UNDERLINE_CSS_MAP[alias] = UNDERLINE_CSS_MAP[target]

[docs] class StyleConverter: """ A converter class for converting DOCX style properties to CSS style attributes. """
[docs] @staticmethod def convert_bold(bold: bool) -> str: """ Converts bold property to CSS style. Args: bold (bool): The bold property. Returns: str: The CSS style string for bold. Example: The output style might look like: .. code-block:: css font-weight:bold; """ return "font-weight:bold;" if bold else ""
[docs] @staticmethod def convert_italic(italic: bool) -> str: """ Converts italic property to CSS style. Args: italic (bool): The italic property. Returns: str: The CSS style string for italic. Example: The output style might look like: .. code-block:: css font-style:italic; """ return "font-style:italic;" if italic else ""
[docs] @staticmethod def convert_underline( underline: Optional[str], strikethrough: bool = False, double_strikethrough: bool = False, ) -> str: """ Converts underline and strikethrough properties to CSS style, mapping ST_Underline values to CSS equivalents and optionally combining strikethrough settings. Args: underline (Optional[str]): The underline property. strikethrough (bool): Whether strikethrough should be applied. double_strikethrough (bool): Whether double strikethrough should be applied. Returns: str: The CSS style string for text decoration. """ normalized = ( StyleConverter._normalize_underline_value(underline) if underline else "" ) mapping = UNDERLINE_CSS_MAP.get(normalized) if normalized else None lines: List[str] = [] decoration_style: Optional[str] = None thickness: Optional[str] = None if mapping: line_type = mapping.get("line") if line_type == "none" and not (strikethrough or double_strikethrough): return "text-decoration-line:none;" if line_type and line_type != "none": lines.append(line_type) decoration_style = mapping.get("style") thickness = mapping.get("thickness") elif normalized and not (strikethrough or double_strikethrough): # Unknown underline without strikethrough return "" if strikethrough or double_strikethrough: lines.append("line-through") if double_strikethrough: decoration_style = "double" thickness = None if not lines: return "" unique_lines = [] for value in lines: if value not in unique_lines: unique_lines.append(value) styles = [f"text-decoration-line:{' '.join(unique_lines)};"] if decoration_style: styles.append(f"text-decoration-style:{decoration_style};") if thickness: styles.append(f"text-decoration-thickness:{thickness};") return "".join(styles)
@staticmethod def _normalize_underline_value(value: str) -> str: cleaned = value.strip() if not cleaned: return "" return "".join(ch for ch in cleaned.lower() if ch.isalpha())
[docs] @staticmethod def convert_color(color: str) -> str: """ Converts color property to CSS style, ensuring hex codes include a '#' and mirroring underline color. Args: color (str): The color property. Returns: str: The CSS style string for color. Example: The output style might look like: .. code-block:: css color:#FF0000; """ formatted_color = StyleConverter._format_css_color(color) if not formatted_color: return "" return ( f"color:{formatted_color};" f"text-decoration-color:{formatted_color};" )
[docs] @staticmethod def convert_underline_color(color: str) -> str: """Converts an explicit underline color to CSS style.""" formatted_color = StyleConverter._format_css_color(color) if not formatted_color: return "" return f"text-decoration-color:{formatted_color};"
[docs] @staticmethod def convert_highlight(color: str) -> str: """ Converts highlight color to CSS background color. Args: color (str): The DOCX highlight value (named color or hex string). Returns: str: The CSS background-color style. """ formatted_color = StyleConverter._format_css_color(color) if not formatted_color: return "" return f"background-color:{formatted_color};"
[docs] @staticmethod def convert_all_caps(all_caps: bool) -> str: """ Converts the all caps property to CSS style. """ return "text-transform:uppercase;" if all_caps else ""
[docs] @staticmethod def convert_small_caps(small_caps: bool) -> str: """ Converts the small caps property to CSS style. """ return "font-variant:small-caps;" if small_caps else ""
[docs] @staticmethod def convert_vertical_align( vertical_align: Optional[str], text_position_pt: Optional[float] = None ) -> str: """ Converts DOCX vertical alignment values (superscript/subscript) to CSS. Falls back to the legacy text-position baseline shift when no vertical-align CSS is produced. """ vertical_align_style = "" if vertical_align: normalized = vertical_align.lower() mapping = { "superscript": "vertical-align:super;font-size:smaller;", "subscript": "vertical-align:sub;font-size:smaller;", } vertical_align_style = mapping.get(normalized, "") if vertical_align_style: return vertical_align_style if text_position_pt is not None: return StyleConverter.convert_text_position(text_position_pt) return ""
[docs] @staticmethod def convert_text_position(offset_pt: float) -> str: """ Converts DOCX text position (baseline shift) to CSS relative positioning. """ if not offset_pt: return "" shift = -offset_pt # Positive DOCX values raise the text, so move up. formatted_shift = StyleConverter._format_point_value(shift) if formatted_shift == "0": return "" return f"position:relative;top:{formatted_shift}pt;"
@staticmethod def _format_css_color(color: str) -> str: """Normalizes DOCX color values to valid CSS color tokens.""" if not color: return "" color = color.strip() if not color: return "" lowered = color.lower() if lowered == "auto": return "" if color.startswith("#") or lowered.startswith("rgb"): return color hex_lengths = {3, 4, 6, 8} hex_digits = set("0123456789abcdefABCDEF") if len(color) in hex_lengths and all(ch in hex_digits for ch in color): return f"#{color}" return color @staticmethod def _format_point_value(value: float) -> str: """ Formats a float value into a compact point string suitable for CSS. """ if value is None: return "" if abs(value) < 1e-9: return "0" formatted = f"{value:.4f}".rstrip("0").rstrip(".") return formatted or "0"
[docs] @staticmethod def convert_font(font: FontProperties) -> str: """ Converts font properties to CSS style. Args: font (FontProperties): The font properties. Returns: str: The CSS style string for font. Example: The output style might look like: .. code-block:: css font-family:Arial; """ style = "" if font.ascii: style += f"font-family:{font.ascii};" return style
[docs] @staticmethod def convert_size(size_pt: float) -> str: """ Converts font size property to CSS style. Args: size_pt (float): The font size in points. Returns: str: The CSS style string for font size. Example: The output style might look like: .. code-block:: css font-size:12pt; """ return f"font-size:{size_pt}pt;" if size_pt else ""
[docs] @staticmethod def convert_spacing(spacing: SpacingProperties) -> str: """ Converts spacing properties to CSS style. Args: spacing (SpacingProperties): The spacing properties. Returns: str: The CSS style string for spacing. Example: The output style might look like: .. code-block:: css margin-top:12pt;margin-bottom:12pt;line-height:18pt; """ style = "" if spacing.before_pt: style += f"margin-top:{spacing.before_pt}pt;" if spacing.after_pt: style += f"margin-bottom:{spacing.after_pt}pt;" if spacing.line_pt: style += f"line-height:{spacing.line_pt}pt;" return style
[docs] @staticmethod def convert_indent(indent: IndentationProperties) -> str: """ Converts indentation properties to CSS style. Args: indent (IndentationProperties): The indentation properties. Returns: str: The CSS style string for indentation. Example: The output style might look like: .. code-block:: css margin-left:36pt;margin-right:36pt;text-indent:36pt; """ style = "" if indent.left_pt is not None: style += f"margin-left:{indent.left_pt}pt;" if indent.right_pt is not None: style += f"margin-right:{indent.right_pt}pt;" if indent.firstline_pt: style += f"text-indent:{indent.firstline_pt}pt;" return style
[docs] @staticmethod def convert_justification(justification: str) -> str: """ Converts justification property to CSS style. Args: justification (str): The justification property. Returns: str: The CSS style string for justification. Example: The output style might look like: .. code-block:: css text-align:left; """ if not justification: return "" normalized_value = justification.lower() justification_map = { "left": "left", "start": "left", "right": "right", "end": "right", "center": "center", "both": "justify", "justify": "justify", "distribute": "justify" } css_value = justification_map.get(normalized_value, "left") return f"text-align:{css_value};"
[docs] @staticmethod def convert_doc_margins(margins: DocMargins) -> str: """ Converts document margins to CSS style. Args: margins: The document margins. Returns: str: The CSS style string for document margins. Example: The output style might look like: .. code-block:: css padding-top:36pt;padding-right:36pt;padding-bottom:36pt;padding-left:36pt;padding-top:18pt;padding-bottom:18pt;margin-left:18pt; """ style = f"padding-top:{margins.top_pt}pt; padding-right:{margins.right_pt}pt; padding-bottom:{margins.bottom_pt}pt; padding-left:{margins.left_pt}pt;" if margins.header_pt: style += f" padding-top:{margins.header_pt}pt;" if margins.footer_pt: style += f" padding-bottom:{margins.footer_pt}pt;" if margins.gutter_pt: style += f" margin-left:{margins.gutter_pt}pt;" return style