Skip to content

Document Styles

craftable.styles.DocxStyle

Bases: TableStyle

Source code in src/craftable/styles/docx_style.py
class DocxStyle(TableStyle):
    def __init__(self, table_style: str | None = "Light List"):
        super().__init__()
        self.terminal_style = False
        self.string_output = False  # Binary format
        self.table_style_name = table_style

    ###############################################################################
    # write_table
    ###############################################################################

    def write_table(
        self,
        value_rows: list[list[Any]],
        header_row: list[str] | None,
        col_defs: "ColDefList",
        header_defs: "ColDefList | None",
        file: str | Path | IO[bytes],
    ) -> None:
        """Write table to DOCX file format."""
        try:
            from docx import Document  # type: ignore
            from docx.enum.text import WD_ALIGN_PARAGRAPH  # type: ignore
            from docx.shared import Inches  # type: ignore
        except Exception as e:  # pragma: no cover - only hit without optional dep
            raise ImportError(
                "python-docx is required for DocxStyle; install group 'docx'"
            ) from e

        doc = Document()

        # Create table: rows = header + data
        n_rows = len(value_rows) + (1 if header_row else 0)
        n_cols = len(col_defs)
        table = doc.add_table(
            rows=n_rows if n_rows else 1, cols=n_cols if n_cols else 1
        )
        if self.table_style_name:
            try:
                table.style = self.table_style_name
            except Exception:
                # Silently ignore style setting if not available
                pass

        # Set column widths based on ColDef widths
        # Approximate: 1 character ~ 0.12 inches at 11pt
        for c, col_def in enumerate(col_defs):
            if c < len(table.columns):
                char_width = max(col_def.width, 1)
                table.columns[c].width = Inches(char_width * 0.12)

        # Write header row
        row_idx = 0
        if header_row:
            hdr_cells = table.rows[row_idx].cells
            for j, text in enumerate(header_row):
                if j >= len(hdr_cells):
                    break
                p = hdr_cells[j].paragraphs[0]
                # Clear any existing content and add a bold run
                p.clear()
                run = p.add_run(str(text))
                run.bold = True
                # Use alignment from header_defs if available, else center
                if header_defs and j < len(header_defs):
                    hdr_def = header_defs[j]
                    if ">" in hdr_def.align:
                        p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
                    elif "^" in hdr_def.align:
                        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
                    else:
                        p.alignment = WD_ALIGN_PARAGRAPH.LEFT
                else:
                    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
            row_idx += 1

        # Helper for formatting with preprocess/postprocess
        def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
            v2 = cd.preprocess(val, row, col_idx)
            try:
                if v2 is None:
                    text = cd.prefix + (cd.none_text or "") + cd.suffix
                elif cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    text = cd.prefix + fmt.format(v2) + cd.suffix
                else:
                    text = cd.prefix + str(v2) + cd.suffix
            except Exception:
                text = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
            return cd.postprocess(val, text, row, col_idx)

        # Write data rows
        for r, row in enumerate(value_rows):
            cells = table.rows[row_idx].cells
            for c, (val, col_def) in enumerate(zip(row, col_defs)):
                if c >= len(cells):
                    break
                p = cells[c].paragraphs[0]
                p.clear()
                run = p.add_run(_format_for_export(col_def, val, row, c))
                # Align mapping
                if ">" in col_def.align:
                    p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
                elif "^" in col_def.align:
                    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
                else:
                    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
            row_idx += 1

        # Save (coerce Path to str for python-docx type expectations)
        save_target = str(file) if isinstance(file, Path) else file
        doc.save(save_target)

write_table

Write table to DOCX file format.

Source code in src/craftable/styles/docx_style.py
def write_table(
    self,
    value_rows: list[list[Any]],
    header_row: list[str] | None,
    col_defs: "ColDefList",
    header_defs: "ColDefList | None",
    file: str | Path | IO[bytes],
) -> None:
    """Write table to DOCX file format."""
    try:
        from docx import Document  # type: ignore
        from docx.enum.text import WD_ALIGN_PARAGRAPH  # type: ignore
        from docx.shared import Inches  # type: ignore
    except Exception as e:  # pragma: no cover - only hit without optional dep
        raise ImportError(
            "python-docx is required for DocxStyle; install group 'docx'"
        ) from e

    doc = Document()

    # Create table: rows = header + data
    n_rows = len(value_rows) + (1 if header_row else 0)
    n_cols = len(col_defs)
    table = doc.add_table(
        rows=n_rows if n_rows else 1, cols=n_cols if n_cols else 1
    )
    if self.table_style_name:
        try:
            table.style = self.table_style_name
        except Exception:
            # Silently ignore style setting if not available
            pass

    # Set column widths based on ColDef widths
    # Approximate: 1 character ~ 0.12 inches at 11pt
    for c, col_def in enumerate(col_defs):
        if c < len(table.columns):
            char_width = max(col_def.width, 1)
            table.columns[c].width = Inches(char_width * 0.12)

    # Write header row
    row_idx = 0
    if header_row:
        hdr_cells = table.rows[row_idx].cells
        for j, text in enumerate(header_row):
            if j >= len(hdr_cells):
                break
            p = hdr_cells[j].paragraphs[0]
            # Clear any existing content and add a bold run
            p.clear()
            run = p.add_run(str(text))
            run.bold = True
            # Use alignment from header_defs if available, else center
            if header_defs and j < len(header_defs):
                hdr_def = header_defs[j]
                if ">" in hdr_def.align:
                    p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
                elif "^" in hdr_def.align:
                    p.alignment = WD_ALIGN_PARAGRAPH.CENTER
                else:
                    p.alignment = WD_ALIGN_PARAGRAPH.LEFT
            else:
                p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        row_idx += 1

    # Helper for formatting with preprocess/postprocess
    def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
        v2 = cd.preprocess(val, row, col_idx)
        try:
            if v2 is None:
                text = cd.prefix + (cd.none_text or "") + cd.suffix
            elif cd.format_spec:
                fmt = f"{{:{cd.format_spec}}}"
                text = cd.prefix + fmt.format(v2) + cd.suffix
            else:
                text = cd.prefix + str(v2) + cd.suffix
        except Exception:
            text = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
        return cd.postprocess(val, text, row, col_idx)

    # Write data rows
    for r, row in enumerate(value_rows):
        cells = table.rows[row_idx].cells
        for c, (val, col_def) in enumerate(zip(row, col_defs)):
            if c >= len(cells):
                break
            p = cells[c].paragraphs[0]
            p.clear()
            run = p.add_run(_format_for_export(col_def, val, row, c))
            # Align mapping
            if ">" in col_def.align:
                p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
            elif "^" in col_def.align:
                p.alignment = WD_ALIGN_PARAGRAPH.CENTER
            else:
                p.alignment = WD_ALIGN_PARAGRAPH.LEFT
        row_idx += 1

    # Save (coerce Path to str for python-docx type expectations)
    save_target = str(file) if isinstance(file, Path) else file
    doc.save(save_target)

craftable.styles.XlsxStyle

Bases: TableStyle

Source code in src/craftable/styles/xlsx_style.py
class XlsxStyle(TableStyle):
    def __init__(self, sheet_name: str = "Sheet1"):
        super().__init__()
        self.terminal_style = False
        self.string_output = False  # Binary format
        self.sheet_name = sheet_name

    ###############################################################################
    # write_table
    ###############################################################################

    def write_table(
        self,
        value_rows: list[list[Any]],
        header_row: list[str] | None,
        col_defs: "ColDefList",
        header_defs: "ColDefList | None",
        file: str | Path | IO[bytes],
    ) -> None:
        """Write table to XLSX file format."""
        try:
            from openpyxl import Workbook  # type: ignore
            from openpyxl.utils import get_column_letter  # type: ignore
            from openpyxl.styles import Alignment, Font  # type: ignore
        except Exception as e:  # pragma: no cover - only hit without optional dep
            raise ImportError(
                "openpyxl is required for XlsxStyle; install group 'xlsx'"
            ) from e

        wb = Workbook()
        ws: Any = wb.active  # type: ignore[assignment]
        ws.title = self.sheet_name

        # Write header row
        row_idx = 1
        if header_row:
            for col_idx, text in enumerate(header_row, start=1):
                cell = ws.cell(row=row_idx, column=col_idx, value=str(text))
                cell.font = Font(bold=True)
                # Use alignment from header_defs if available, else center
                if header_defs and (col_idx - 1) < len(header_defs):
                    hdr_def = header_defs[col_idx - 1]
                    if ">" in hdr_def.align:
                        cell.alignment = Alignment(horizontal="right")
                    elif "^" in hdr_def.align:
                        cell.alignment = Alignment(horizontal="center")
                    else:
                        cell.alignment = Alignment(horizontal="left")
                else:
                    cell.alignment = Alignment(horizontal="center")
            row_idx += 1

        # Helper for converting format specs to Excel number formats
        def _to_excel_number_format(fmt_spec_obj) -> str | None:
            """Map FormatSpec or string to Excel number format.

            Supports:
            - Float with precision and grouping: .2f, ,.2f -> 0.00, #,##0.00
            - Integer with grouping: d, ,d -> 0, #,##0
            - Percentage with precision: .2%, % -> 0.00%, 0%

            Returns None if unsupported or if fmt_spec_obj is None.
            """
            if fmt_spec_obj is None:
                return None

            # Handle FormatSpec objects by extracting relevant fields
            if hasattr(fmt_spec_obj, "type"):
                type_char = fmt_spec_obj.type
                has_group = fmt_spec_obj.grouping == ","
                precision_str = fmt_spec_obj.precision

                # Parse precision (e.g., ".2" -> 2)
                decimals = 0
                if precision_str and precision_str.startswith("."):
                    try:
                        decimals = int(precision_str[1:])
                    except (ValueError, IndexError):
                        decimals = 0

                if type_char == "f":
                    base = "#,##0" if has_group else "0"
                    if decimals:
                        base += "." + ("0" * decimals)
                    return base
                elif type_char == "d":
                    return "#,##0" if has_group else "0"
                elif type_char == "%":
                    base = "0" if decimals == 0 else "0." + ("0" * decimals)
                    return base + "%"
                return None

            # Fallback: parse string representation
            if not isinstance(fmt_spec_obj, str):
                fmt_spec_obj = str(fmt_spec_obj)

            spec = fmt_spec_obj.strip().lstrip("<>^")
            if spec.endswith("f"):
                parts = spec[:-1].split(".")
                decimals = 0
                if len(parts) == 2 and parts[1].isdigit():
                    decimals = int(parts[1])
                has_group = "," in parts[0]
                base = "#,##0" if has_group else "0"
                if decimals:
                    base += "." + ("0" * decimals)
                return base
            if spec.endswith("d"):
                has_group = "," in spec[:-1]
                return "#,##0" if has_group else "0"
            if spec.endswith("%"):
                # Extract precision before %
                decimals = 0
                if ".%" in spec:
                    # Pattern like ".2%"
                    parts = spec.split(".")
                    if len(parts) >= 2:
                        num_part = parts[1].rstrip("%")
                        if num_part.isdigit():
                            decimals = int(num_part)
                base = "0" if decimals == 0 else "0." + ("0" * decimals)
                return base + "%"
            return None

        # Helper for formatting cell values with preprocess/postprocess
        def _coerce_cell_value(
            cd, original: Any, row: list[Any], col_idx: int
        ) -> tuple[Any, str | None]:
            """Return (value_for_cell, excel_number_format).
            Falls back to formatted text when numeric coercion not safe.
            """
            v2 = cd.preprocess(original, row, col_idx)
            if v2 is None:
                return "", None
            # If prefix/suffix/postprocessor present we treat as text
            if cd.prefix or cd.suffix or cd.postprocessor is not None:
                try:
                    if cd.format_spec:
                        fmt = f"{{:{cd.format_spec}}}"
                        text = fmt.format(v2)
                    else:
                        text = str(v2)
                except Exception:
                    text = str(v2)
                text = cd.prefix + text + cd.suffix
                return cd.postprocess(original, text, row, col_idx), None
            # Direct acceptable python types
            if isinstance(v2, (bool, int, float, date, datetime)):
                nf = _to_excel_number_format(cd.format_spec) if cd.format_spec else None
                return v2, nf
            # Try to parse strings into numbers
            if isinstance(v2, str):
                txt = v2.strip().replace(",", "")
                try:
                    if "." in txt:
                        num = float(txt)
                        nf = (
                            _to_excel_number_format(cd.format_spec)
                            if cd.format_spec
                            else None
                        )
                        return num, nf
                    num = int(txt)
                    nf = (
                        _to_excel_number_format(cd.format_spec)
                        if cd.format_spec
                        else None
                    )
                    return num, nf
                except Exception:
                    pass
            # Fallback to formatted text
            try:
                if cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    text = fmt.format(v2)
                else:
                    text = str(v2)
            except Exception:
                text = str(v2)
            text = cd.prefix + text + cd.suffix
            text = cd.postprocess(original, text, row, col_idx)
            return text, None

        # Write data rows
        for r, row in enumerate(value_rows):
            for c, (val, col_def) in enumerate(zip(row, col_defs), start=1):
                cell_value, number_format = _coerce_cell_value(col_def, val, row, c - 1)
                cell = ws.cell(row=row_idx, column=c, value=cell_value)
                if number_format:
                    try:
                        cell.number_format = number_format
                        if isinstance(cell.value, int) and "." in number_format:
                            cell.value = float(cell.value)
                    except Exception:
                        pass
                if ">" in col_def.align:
                    cell.alignment = Alignment(horizontal="right")
                elif "^" in col_def.align:
                    cell.alignment = Alignment(horizontal="center")
                else:
                    cell.alignment = Alignment(horizontal="left")
            row_idx += 1

        # Set column widths based on ColDef widths
        # Excel width units are approximately character widths at default font
        # Add small padding for better visual appearance
        for c, col_def in enumerate(col_defs, start=1):
            letter = get_column_letter(c)
            char_width = max(col_def.width, 1)
            # Excel width formula: slightly wider than character count for padding
            ws.column_dimensions[letter].width = char_width + 2

        wb.save(file)

write_table

Write table to XLSX file format.

Source code in src/craftable/styles/xlsx_style.py
def write_table(
    self,
    value_rows: list[list[Any]],
    header_row: list[str] | None,
    col_defs: "ColDefList",
    header_defs: "ColDefList | None",
    file: str | Path | IO[bytes],
) -> None:
    """Write table to XLSX file format."""
    try:
        from openpyxl import Workbook  # type: ignore
        from openpyxl.utils import get_column_letter  # type: ignore
        from openpyxl.styles import Alignment, Font  # type: ignore
    except Exception as e:  # pragma: no cover - only hit without optional dep
        raise ImportError(
            "openpyxl is required for XlsxStyle; install group 'xlsx'"
        ) from e

    wb = Workbook()
    ws: Any = wb.active  # type: ignore[assignment]
    ws.title = self.sheet_name

    # Write header row
    row_idx = 1
    if header_row:
        for col_idx, text in enumerate(header_row, start=1):
            cell = ws.cell(row=row_idx, column=col_idx, value=str(text))
            cell.font = Font(bold=True)
            # Use alignment from header_defs if available, else center
            if header_defs and (col_idx - 1) < len(header_defs):
                hdr_def = header_defs[col_idx - 1]
                if ">" in hdr_def.align:
                    cell.alignment = Alignment(horizontal="right")
                elif "^" in hdr_def.align:
                    cell.alignment = Alignment(horizontal="center")
                else:
                    cell.alignment = Alignment(horizontal="left")
            else:
                cell.alignment = Alignment(horizontal="center")
        row_idx += 1

    # Helper for converting format specs to Excel number formats
    def _to_excel_number_format(fmt_spec_obj) -> str | None:
        """Map FormatSpec or string to Excel number format.

        Supports:
        - Float with precision and grouping: .2f, ,.2f -> 0.00, #,##0.00
        - Integer with grouping: d, ,d -> 0, #,##0
        - Percentage with precision: .2%, % -> 0.00%, 0%

        Returns None if unsupported or if fmt_spec_obj is None.
        """
        if fmt_spec_obj is None:
            return None

        # Handle FormatSpec objects by extracting relevant fields
        if hasattr(fmt_spec_obj, "type"):
            type_char = fmt_spec_obj.type
            has_group = fmt_spec_obj.grouping == ","
            precision_str = fmt_spec_obj.precision

            # Parse precision (e.g., ".2" -> 2)
            decimals = 0
            if precision_str and precision_str.startswith("."):
                try:
                    decimals = int(precision_str[1:])
                except (ValueError, IndexError):
                    decimals = 0

            if type_char == "f":
                base = "#,##0" if has_group else "0"
                if decimals:
                    base += "." + ("0" * decimals)
                return base
            elif type_char == "d":
                return "#,##0" if has_group else "0"
            elif type_char == "%":
                base = "0" if decimals == 0 else "0." + ("0" * decimals)
                return base + "%"
            return None

        # Fallback: parse string representation
        if not isinstance(fmt_spec_obj, str):
            fmt_spec_obj = str(fmt_spec_obj)

        spec = fmt_spec_obj.strip().lstrip("<>^")
        if spec.endswith("f"):
            parts = spec[:-1].split(".")
            decimals = 0
            if len(parts) == 2 and parts[1].isdigit():
                decimals = int(parts[1])
            has_group = "," in parts[0]
            base = "#,##0" if has_group else "0"
            if decimals:
                base += "." + ("0" * decimals)
            return base
        if spec.endswith("d"):
            has_group = "," in spec[:-1]
            return "#,##0" if has_group else "0"
        if spec.endswith("%"):
            # Extract precision before %
            decimals = 0
            if ".%" in spec:
                # Pattern like ".2%"
                parts = spec.split(".")
                if len(parts) >= 2:
                    num_part = parts[1].rstrip("%")
                    if num_part.isdigit():
                        decimals = int(num_part)
            base = "0" if decimals == 0 else "0." + ("0" * decimals)
            return base + "%"
        return None

    # Helper for formatting cell values with preprocess/postprocess
    def _coerce_cell_value(
        cd, original: Any, row: list[Any], col_idx: int
    ) -> tuple[Any, str | None]:
        """Return (value_for_cell, excel_number_format).
        Falls back to formatted text when numeric coercion not safe.
        """
        v2 = cd.preprocess(original, row, col_idx)
        if v2 is None:
            return "", None
        # If prefix/suffix/postprocessor present we treat as text
        if cd.prefix or cd.suffix or cd.postprocessor is not None:
            try:
                if cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    text = fmt.format(v2)
                else:
                    text = str(v2)
            except Exception:
                text = str(v2)
            text = cd.prefix + text + cd.suffix
            return cd.postprocess(original, text, row, col_idx), None
        # Direct acceptable python types
        if isinstance(v2, (bool, int, float, date, datetime)):
            nf = _to_excel_number_format(cd.format_spec) if cd.format_spec else None
            return v2, nf
        # Try to parse strings into numbers
        if isinstance(v2, str):
            txt = v2.strip().replace(",", "")
            try:
                if "." in txt:
                    num = float(txt)
                    nf = (
                        _to_excel_number_format(cd.format_spec)
                        if cd.format_spec
                        else None
                    )
                    return num, nf
                num = int(txt)
                nf = (
                    _to_excel_number_format(cd.format_spec)
                    if cd.format_spec
                    else None
                )
                return num, nf
            except Exception:
                pass
        # Fallback to formatted text
        try:
            if cd.format_spec:
                fmt = f"{{:{cd.format_spec}}}"
                text = fmt.format(v2)
            else:
                text = str(v2)
        except Exception:
            text = str(v2)
        text = cd.prefix + text + cd.suffix
        text = cd.postprocess(original, text, row, col_idx)
        return text, None

    # Write data rows
    for r, row in enumerate(value_rows):
        for c, (val, col_def) in enumerate(zip(row, col_defs), start=1):
            cell_value, number_format = _coerce_cell_value(col_def, val, row, c - 1)
            cell = ws.cell(row=row_idx, column=c, value=cell_value)
            if number_format:
                try:
                    cell.number_format = number_format
                    if isinstance(cell.value, int) and "." in number_format:
                        cell.value = float(cell.value)
                except Exception:
                    pass
            if ">" in col_def.align:
                cell.alignment = Alignment(horizontal="right")
            elif "^" in col_def.align:
                cell.alignment = Alignment(horizontal="center")
            else:
                cell.alignment = Alignment(horizontal="left")
        row_idx += 1

    # Set column widths based on ColDef widths
    # Excel width units are approximately character widths at default font
    # Add small padding for better visual appearance
    for c, col_def in enumerate(col_defs, start=1):
        letter = get_column_letter(c)
        char_width = max(col_def.width, 1)
        # Excel width formula: slightly wider than character count for padding
        ws.column_dimensions[letter].width = char_width + 2

    wb.save(file)

craftable.styles.OdtStyle

Bases: TableStyle

Source code in src/craftable/styles/odt_style.py
class OdtStyle(TableStyle):
    def __init__(self):
        super().__init__()
        self.terminal_style = False
        self.string_output = False  # Binary format

    ###############################################################################
    # write_table
    ###############################################################################

    def write_table(
        self,
        value_rows: list[list[Any]],
        header_row: list[str] | None,
        col_defs: "ColDefList",
        header_defs: "ColDefList | None",
        file: str | Path | IO[bytes],
    ) -> None:
        """Write table to ODT file format."""
        try:
            from odf.opendocument import OpenDocumentText  # type: ignore
            from odf import table, text  # type: ignore
        except Exception as e:  # pragma: no cover - only hit without optional dep
            raise ImportError(
                "odfpy is required for OdtStyle; install group 'odt'"
            ) from e

        doc = OpenDocumentText()

        # Define paragraph styles
        from odf.style import Style, TextProperties, ParagraphProperties  # type: ignore

        odf_table = table.Table(name="Table1")

        # Define alignment styles
        left_style = Style(name="LeftAlign", family="paragraph")
        left_style.addElement(ParagraphProperties(textalign="left"))
        doc.automaticstyles.addElement(left_style)

        center_style = Style(name="CenterAlign", family="paragraph")
        center_style.addElement(ParagraphProperties(textalign="center"))
        doc.automaticstyles.addElement(center_style)

        right_style = Style(name="RightAlign", family="paragraph")
        right_style.addElement(ParagraphProperties(textalign="right"))
        doc.automaticstyles.addElement(right_style)

        # Bold styles for headers
        header_bold_left = Style(name="HeaderBoldLeft", family="paragraph")
        header_bold_left.addElement(TextProperties(fontweight="bold"))
        header_bold_left.addElement(ParagraphProperties(textalign="left"))
        doc.automaticstyles.addElement(header_bold_left)

        header_bold_center = Style(name="HeaderBoldCenter", family="paragraph")
        header_bold_center.addElement(TextProperties(fontweight="bold"))
        header_bold_center.addElement(ParagraphProperties(textalign="center"))
        doc.automaticstyles.addElement(header_bold_center)

        header_bold_right = Style(name="HeaderBoldRight", family="paragraph")
        header_bold_right.addElement(TextProperties(fontweight="bold"))
        header_bold_right.addElement(ParagraphProperties(textalign="right"))
        doc.automaticstyles.addElement(header_bold_right)

        # Write header row with proper alignment
        if header_row:
            hdr_row = table.TableRow()
            for i, header in enumerate(header_row):
                cell = table.TableCell()
                # Use header_defs for alignment if provided
                if header_defs and i < len(header_defs):
                    hdr_def = header_defs[i]
                    if hdr_def.align == ">":
                        p = text.P(stylename=header_bold_right, text=str(header))
                    elif hdr_def.align == "^":
                        p = text.P(stylename=header_bold_center, text=str(header))
                    else:  # Left or default
                        p = text.P(stylename=header_bold_left, text=str(header))
                else:
                    # Default to centered bold
                    p = text.P(stylename=header_bold_center, text=str(header))
                cell.addElement(p)
                hdr_row.addElement(cell)
                odf_table.addElement(hdr_row)

        # Helper for formatting with preprocess/postprocess
        def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
            v2 = cd.preprocess(val, row, col_idx)
            try:
                if v2 is None:
                    s = cd.prefix + (cd.none_text or "") + cd.suffix
                elif cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    s = cd.prefix + fmt.format(v2) + cd.suffix
                else:
                    s = cd.prefix + str(v2) + cd.suffix
            except Exception:
                s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
            return cd.postprocess(val, s, row, col_idx)

        # Write data rows
        for row in value_rows:
            tr = table.TableRow()
            for i, (val, col_def) in enumerate(zip(row, col_defs)):
                tc = table.TableCell()
                # Map ColDef alignment to paragraph style
                if ">" in col_def.align:
                    style = right_style
                elif "^" in col_def.align:
                    style = center_style
                else:
                    style = left_style
                p = text.P(
                    stylename=style, text=_format_for_export(col_def, val, row, i)
                )
                tc.addElement(p)
                tr.addElement(tc)
            odf_table.addElement(tr)

        doc.text.addElement(odf_table)

        # Save (OpenDocumentText.save accepts filename or file-like)
        doc.save(file)

write_table

Write table to ODT file format.

Source code in src/craftable/styles/odt_style.py
def write_table(
    self,
    value_rows: list[list[Any]],
    header_row: list[str] | None,
    col_defs: "ColDefList",
    header_defs: "ColDefList | None",
    file: str | Path | IO[bytes],
) -> None:
    """Write table to ODT file format."""
    try:
        from odf.opendocument import OpenDocumentText  # type: ignore
        from odf import table, text  # type: ignore
    except Exception as e:  # pragma: no cover - only hit without optional dep
        raise ImportError(
            "odfpy is required for OdtStyle; install group 'odt'"
        ) from e

    doc = OpenDocumentText()

    # Define paragraph styles
    from odf.style import Style, TextProperties, ParagraphProperties  # type: ignore

    odf_table = table.Table(name="Table1")

    # Define alignment styles
    left_style = Style(name="LeftAlign", family="paragraph")
    left_style.addElement(ParagraphProperties(textalign="left"))
    doc.automaticstyles.addElement(left_style)

    center_style = Style(name="CenterAlign", family="paragraph")
    center_style.addElement(ParagraphProperties(textalign="center"))
    doc.automaticstyles.addElement(center_style)

    right_style = Style(name="RightAlign", family="paragraph")
    right_style.addElement(ParagraphProperties(textalign="right"))
    doc.automaticstyles.addElement(right_style)

    # Bold styles for headers
    header_bold_left = Style(name="HeaderBoldLeft", family="paragraph")
    header_bold_left.addElement(TextProperties(fontweight="bold"))
    header_bold_left.addElement(ParagraphProperties(textalign="left"))
    doc.automaticstyles.addElement(header_bold_left)

    header_bold_center = Style(name="HeaderBoldCenter", family="paragraph")
    header_bold_center.addElement(TextProperties(fontweight="bold"))
    header_bold_center.addElement(ParagraphProperties(textalign="center"))
    doc.automaticstyles.addElement(header_bold_center)

    header_bold_right = Style(name="HeaderBoldRight", family="paragraph")
    header_bold_right.addElement(TextProperties(fontweight="bold"))
    header_bold_right.addElement(ParagraphProperties(textalign="right"))
    doc.automaticstyles.addElement(header_bold_right)

    # Write header row with proper alignment
    if header_row:
        hdr_row = table.TableRow()
        for i, header in enumerate(header_row):
            cell = table.TableCell()
            # Use header_defs for alignment if provided
            if header_defs and i < len(header_defs):
                hdr_def = header_defs[i]
                if hdr_def.align == ">":
                    p = text.P(stylename=header_bold_right, text=str(header))
                elif hdr_def.align == "^":
                    p = text.P(stylename=header_bold_center, text=str(header))
                else:  # Left or default
                    p = text.P(stylename=header_bold_left, text=str(header))
            else:
                # Default to centered bold
                p = text.P(stylename=header_bold_center, text=str(header))
            cell.addElement(p)
            hdr_row.addElement(cell)
            odf_table.addElement(hdr_row)

    # Helper for formatting with preprocess/postprocess
    def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
        v2 = cd.preprocess(val, row, col_idx)
        try:
            if v2 is None:
                s = cd.prefix + (cd.none_text or "") + cd.suffix
            elif cd.format_spec:
                fmt = f"{{:{cd.format_spec}}}"
                s = cd.prefix + fmt.format(v2) + cd.suffix
            else:
                s = cd.prefix + str(v2) + cd.suffix
        except Exception:
            s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
        return cd.postprocess(val, s, row, col_idx)

    # Write data rows
    for row in value_rows:
        tr = table.TableRow()
        for i, (val, col_def) in enumerate(zip(row, col_defs)):
            tc = table.TableCell()
            # Map ColDef alignment to paragraph style
            if ">" in col_def.align:
                style = right_style
            elif "^" in col_def.align:
                style = center_style
            else:
                style = left_style
            p = text.P(
                stylename=style, text=_format_for_export(col_def, val, row, i)
            )
            tc.addElement(p)
            tr.addElement(tc)
        odf_table.addElement(tr)

    doc.text.addElement(odf_table)

    # Save (OpenDocumentText.save accepts filename or file-like)
    doc.save(file)

craftable.styles.OdsStyle

Bases: TableStyle

Source code in src/craftable/styles/ods_style.py
class OdsStyle(TableStyle):
    def __init__(self, sheet_name: str = "Sheet1"):
        super().__init__()
        self.terminal_style = False
        self.string_output = False  # Binary format
        self.sheet_name = sheet_name

    ###############################################################################
    # write_table
    ###############################################################################

    def write_table(
        self,
        value_rows: list[list[Any]],
        header_row: list[str] | None,
        col_defs: "ColDefList",
        header_defs: "ColDefList | None",
        file: str | Path | IO[bytes],
    ) -> None:
        """Write table to ODS file format."""
        try:
            from odf.opendocument import OpenDocumentSpreadsheet  # type: ignore
            from odf import table, text  # type: ignore
        except Exception as e:  # pragma: no cover - only hit without optional dep
            raise ImportError(
                "odfpy is required for OdsStyle; install group 'ods'"
            ) from e

        doc = OpenDocumentSpreadsheet()

        # Define paragraph styles
        from odf.style import Style, TextProperties, ParagraphProperties  # type: ignore

        left_style = Style(name="LeftAlign", family="paragraph")
        left_style.addElement(ParagraphProperties(textalign="left"))
        doc.automaticstyles.addElement(left_style)

        center_style = Style(name="CenterAlign", family="paragraph")
        center_style.addElement(ParagraphProperties(textalign="center"))
        doc.automaticstyles.addElement(center_style)

        right_style = Style(name="RightAlign", family="paragraph")
        right_style.addElement(ParagraphProperties(textalign="right"))
        doc.automaticstyles.addElement(right_style)

        # Bold styles for headers
        header_bold_left = Style(name="HeaderBoldLeft", family="paragraph")
        header_bold_left.addElement(TextProperties(fontweight="bold"))
        header_bold_left.addElement(ParagraphProperties(textalign="left"))
        doc.automaticstyles.addElement(header_bold_left)

        header_bold_center = Style(name="HeaderBoldCenter", family="paragraph")
        header_bold_center.addElement(TextProperties(fontweight="bold"))
        header_bold_center.addElement(ParagraphProperties(textalign="center"))
        doc.automaticstyles.addElement(header_bold_center)

        header_bold_right = Style(name="HeaderBoldRight", family="paragraph")
        header_bold_right.addElement(TextProperties(fontweight="bold"))
        header_bold_right.addElement(ParagraphProperties(textalign="right"))
        doc.automaticstyles.addElement(header_bold_right)

        sheet = table.Table(name=self.sheet_name)

        # Write header row
        if header_row:
            tr = table.TableRow()
            for i, h in enumerate(header_row):
                tc = table.TableCell()
                # Determine style based on header_defs alignment
                style_to_use = header_bold_center
                if header_defs and i < len(header_defs):
                    hdr_def = header_defs[i]
                    if ">" in hdr_def.align:
                        style_to_use = header_bold_right
                    elif "<" in hdr_def.align:
                        style_to_use = header_bold_left
                    # Center alignment uses header_bold_center (already bold)
                p = text.P(stylename=style_to_use, text=str(h))
                tc.addElement(p)
                tr.addElement(tc)
            sheet.addElement(tr)

        # Helper for formatting with preprocess/postprocess
        def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
            v2 = cd.preprocess(val, row, col_idx)
            try:
                if v2 is None:
                    s = cd.prefix + (cd.none_text or "") + cd.suffix
                elif cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    s = cd.prefix + fmt.format(v2) + cd.suffix
                else:
                    s = cd.prefix + str(v2) + cd.suffix
            except Exception:
                s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
            return cd.postprocess(val, s, row, col_idx)

        # Write data rows
        for row in value_rows:
            tr = table.TableRow()
            for i, (val, col_def) in enumerate(zip(row, col_defs)):
                # Prepare value (respect preprocess / postprocess similar to XLSX)
                v2 = col_def.preprocess(val, row, i)
                is_text_mode = (
                    col_def.prefix
                    or col_def.suffix
                    or col_def.postprocessor is not None
                )
                tc_kwargs: dict[str, str] = {}
                cell_text: str
                if v2 is None:
                    cell_text = (
                        col_def.prefix + (col_def.none_text or "") + col_def.suffix
                    )
                # Check boolean FIRST since bool is subclass of int in Python
                elif (not is_text_mode) and isinstance(v2, bool):
                    tc_kwargs = {
                        "valuetype": "boolean",
                        "value": "true" if v2 else "false",
                    }
                    cell_text = "true" if v2 else "false"
                elif (not is_text_mode) and isinstance(v2, (date, datetime)):
                    # ODF date value
                    iso_val = v2.isoformat()
                    tc_kwargs = {"valuetype": "date", "datevalue": iso_val}
                    cell_text = iso_val
                elif (not is_text_mode) and isinstance(v2, (int, float)):
                    # Check if format spec indicates percentage
                    is_percent = (
                        col_def.format_spec
                        and hasattr(col_def.format_spec, "type")
                        and col_def.format_spec.type == "%"
                    )
                    if is_percent:
                        tc_kwargs = {"valuetype": "percentage", "value": str(v2)}
                        cell_text = str(v2)
                    else:
                        tc_kwargs = {"valuetype": "float", "value": str(v2)}
                        cell_text = str(v2)
                else:
                    try:
                        if col_def.format_spec:
                            fmt = f"{{:{col_def.format_spec}}}"
                            cell_text = fmt.format(v2)
                        else:
                            cell_text = str(v2)
                    except Exception:
                        cell_text = str(v2)
                    cell_text = col_def.prefix + cell_text + col_def.suffix
                # Apply postprocessor if any
                if col_def.postprocessor is not None:
                    cell_text = col_def.postprocessor(val, cell_text, row, i)
                tc = table.TableCell(**tc_kwargs)
                # Map ColDef alignment to paragraph style
                if ">" in col_def.align:
                    style = right_style
                elif "^" in col_def.align:
                    style = center_style
                else:
                    style = left_style
                p = text.P(stylename=style, text=cell_text)
                tc.addElement(p)
                tr.addElement(tc)
            sheet.addElement(tr)

        doc.spreadsheet.addElement(sheet)

        doc.save(file)

write_table

Write table to ODS file format.

Source code in src/craftable/styles/ods_style.py
def write_table(
    self,
    value_rows: list[list[Any]],
    header_row: list[str] | None,
    col_defs: "ColDefList",
    header_defs: "ColDefList | None",
    file: str | Path | IO[bytes],
) -> None:
    """Write table to ODS file format."""
    try:
        from odf.opendocument import OpenDocumentSpreadsheet  # type: ignore
        from odf import table, text  # type: ignore
    except Exception as e:  # pragma: no cover - only hit without optional dep
        raise ImportError(
            "odfpy is required for OdsStyle; install group 'ods'"
        ) from e

    doc = OpenDocumentSpreadsheet()

    # Define paragraph styles
    from odf.style import Style, TextProperties, ParagraphProperties  # type: ignore

    left_style = Style(name="LeftAlign", family="paragraph")
    left_style.addElement(ParagraphProperties(textalign="left"))
    doc.automaticstyles.addElement(left_style)

    center_style = Style(name="CenterAlign", family="paragraph")
    center_style.addElement(ParagraphProperties(textalign="center"))
    doc.automaticstyles.addElement(center_style)

    right_style = Style(name="RightAlign", family="paragraph")
    right_style.addElement(ParagraphProperties(textalign="right"))
    doc.automaticstyles.addElement(right_style)

    # Bold styles for headers
    header_bold_left = Style(name="HeaderBoldLeft", family="paragraph")
    header_bold_left.addElement(TextProperties(fontweight="bold"))
    header_bold_left.addElement(ParagraphProperties(textalign="left"))
    doc.automaticstyles.addElement(header_bold_left)

    header_bold_center = Style(name="HeaderBoldCenter", family="paragraph")
    header_bold_center.addElement(TextProperties(fontweight="bold"))
    header_bold_center.addElement(ParagraphProperties(textalign="center"))
    doc.automaticstyles.addElement(header_bold_center)

    header_bold_right = Style(name="HeaderBoldRight", family="paragraph")
    header_bold_right.addElement(TextProperties(fontweight="bold"))
    header_bold_right.addElement(ParagraphProperties(textalign="right"))
    doc.automaticstyles.addElement(header_bold_right)

    sheet = table.Table(name=self.sheet_name)

    # Write header row
    if header_row:
        tr = table.TableRow()
        for i, h in enumerate(header_row):
            tc = table.TableCell()
            # Determine style based on header_defs alignment
            style_to_use = header_bold_center
            if header_defs and i < len(header_defs):
                hdr_def = header_defs[i]
                if ">" in hdr_def.align:
                    style_to_use = header_bold_right
                elif "<" in hdr_def.align:
                    style_to_use = header_bold_left
                # Center alignment uses header_bold_center (already bold)
            p = text.P(stylename=style_to_use, text=str(h))
            tc.addElement(p)
            tr.addElement(tc)
        sheet.addElement(tr)

    # Helper for formatting with preprocess/postprocess
    def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
        v2 = cd.preprocess(val, row, col_idx)
        try:
            if v2 is None:
                s = cd.prefix + (cd.none_text or "") + cd.suffix
            elif cd.format_spec:
                fmt = f"{{:{cd.format_spec}}}"
                s = cd.prefix + fmt.format(v2) + cd.suffix
            else:
                s = cd.prefix + str(v2) + cd.suffix
        except Exception:
            s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
        return cd.postprocess(val, s, row, col_idx)

    # Write data rows
    for row in value_rows:
        tr = table.TableRow()
        for i, (val, col_def) in enumerate(zip(row, col_defs)):
            # Prepare value (respect preprocess / postprocess similar to XLSX)
            v2 = col_def.preprocess(val, row, i)
            is_text_mode = (
                col_def.prefix
                or col_def.suffix
                or col_def.postprocessor is not None
            )
            tc_kwargs: dict[str, str] = {}
            cell_text: str
            if v2 is None:
                cell_text = (
                    col_def.prefix + (col_def.none_text or "") + col_def.suffix
                )
            # Check boolean FIRST since bool is subclass of int in Python
            elif (not is_text_mode) and isinstance(v2, bool):
                tc_kwargs = {
                    "valuetype": "boolean",
                    "value": "true" if v2 else "false",
                }
                cell_text = "true" if v2 else "false"
            elif (not is_text_mode) and isinstance(v2, (date, datetime)):
                # ODF date value
                iso_val = v2.isoformat()
                tc_kwargs = {"valuetype": "date", "datevalue": iso_val}
                cell_text = iso_val
            elif (not is_text_mode) and isinstance(v2, (int, float)):
                # Check if format spec indicates percentage
                is_percent = (
                    col_def.format_spec
                    and hasattr(col_def.format_spec, "type")
                    and col_def.format_spec.type == "%"
                )
                if is_percent:
                    tc_kwargs = {"valuetype": "percentage", "value": str(v2)}
                    cell_text = str(v2)
                else:
                    tc_kwargs = {"valuetype": "float", "value": str(v2)}
                    cell_text = str(v2)
            else:
                try:
                    if col_def.format_spec:
                        fmt = f"{{:{col_def.format_spec}}}"
                        cell_text = fmt.format(v2)
                    else:
                        cell_text = str(v2)
                except Exception:
                    cell_text = str(v2)
                cell_text = col_def.prefix + cell_text + col_def.suffix
            # Apply postprocessor if any
            if col_def.postprocessor is not None:
                cell_text = col_def.postprocessor(val, cell_text, row, i)
            tc = table.TableCell(**tc_kwargs)
            # Map ColDef alignment to paragraph style
            if ">" in col_def.align:
                style = right_style
            elif "^" in col_def.align:
                style = center_style
            else:
                style = left_style
            p = text.P(stylename=style, text=cell_text)
            tc.addElement(p)
            tr.addElement(tc)
        sheet.addElement(tr)

    doc.spreadsheet.addElement(sheet)

    doc.save(file)

craftable.styles.RtfStyle

Bases: TableStyle

Source code in src/craftable/styles/rtf_style.py
class RtfStyle(TableStyle):
    def __init__(self):
        super().__init__()
        self.terminal_style = False
        self.string_output = False  # RTF is not human-readable string output

    ###############################################################################
    # write_table
    ###############################################################################

    def write_table(
        self,
        value_rows: list[list[Any]],
        header_row: list[str] | None,
        col_defs: "ColDefList",
        header_defs: "ColDefList | None",
        file: str | Path | IO[bytes],
    ) -> None:
        """Write table to RTF file format."""
        # Render an actual RTF table using \trowd/\cellx/\cell/\row.
        # Widths are approximated from column character widths.

        def char_width_to_twips(chars: int) -> int:
            # Approximate: ~240 twips per character (roughly 12pt mono width)
            return max(1, int(chars) * 240)

        # Compute cell boundaries (\cellx) as cumulative widths
        cellx_positions: list[int] = []
        x = 0
        for cd in col_defs:
            # Add minimal padding around text visually
            col_twips = char_width_to_twips(max(cd.width, 1) + 2)
            x += col_twips
            cellx_positions.append(x)

        parts: list[str] = []
        parts.append("{\\rtf1\\ansi")

        def row_to_rtf(
            cells: list[str], bold: bool = False, use_header_defs: bool = False
        ) -> None:
            parts.append("\\trowd\\trgaph108")
            # Define cell boundaries
            for cx in cellx_positions:
                parts.append(f"\\cellx{cx}")
            # Cell contents
            for idx, text in enumerate(cells):
                # Alignment mapping using paragraph alignment
                # For headers, use header_defs alignment if available
                if use_header_defs and header_defs and idx < len(header_defs):
                    hdr_def = header_defs[idx]
                    if ">" in hdr_def.align:
                        align = "\\qr"
                    elif "^" in hdr_def.align:
                        align = "\\qc"
                    else:
                        align = "\\ql"
                else:
                    cd = col_defs[idx]
                    if ">" in cd.align:
                        align = "\\qr"
                    elif "^" in cd.align:
                        align = "\\qc"
                    else:
                        align = "\\ql"
                content = self._escape(text)
                # Wrap in \b ... \b0 for header bold
                if bold:
                    parts.append(f"{{\\intbl {align} \\b {content} \\b0 \\cell}}")
                else:
                    parts.append(f"{{\\intbl {align} {content} \\cell}}")
            parts.append("\\row")

        # Write header row
        if header_row:
            row_to_rtf([str(h) for h in header_row], bold=True, use_header_defs=True)

        # Helper for formatting with preprocess/postprocess
        def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
            v2 = cd.preprocess(val, row, col_idx)
            try:
                if v2 is None:
                    s = cd.prefix + (cd.none_text or "") + cd.suffix
                elif cd.format_spec:
                    fmt = f"{{:{cd.format_spec}}}"
                    s = cd.prefix + fmt.format(v2) + cd.suffix
                else:
                    s = cd.prefix + str(v2) + cd.suffix
            except Exception:
                s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
            return cd.postprocess(val, s, row, col_idx)

        # Write data rows
        for row in value_rows:
            formatted = [
                _format_for_export(cd, v, row, i)
                for i, (v, cd) in enumerate(zip(row, col_defs))
            ]
            row_to_rtf(formatted, bold=False)

        parts.append("}")
        rtf_content = "".join(parts)

        # Write to file
        if isinstance(file, (str, Path)):
            with open(file, "w", encoding="utf-8") as f:
                f.write(rtf_content)
        else:
            # Assume file-like object
            file.write(rtf_content)  # type: ignore[arg-type]

    def _escape(self, text: str) -> str:
        # Escape backslashes and braces for RTF
        return text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")

write_table

Write table to RTF file format.

Source code in src/craftable/styles/rtf_style.py
def write_table(
    self,
    value_rows: list[list[Any]],
    header_row: list[str] | None,
    col_defs: "ColDefList",
    header_defs: "ColDefList | None",
    file: str | Path | IO[bytes],
) -> None:
    """Write table to RTF file format."""
    # Render an actual RTF table using \trowd/\cellx/\cell/\row.
    # Widths are approximated from column character widths.

    def char_width_to_twips(chars: int) -> int:
        # Approximate: ~240 twips per character (roughly 12pt mono width)
        return max(1, int(chars) * 240)

    # Compute cell boundaries (\cellx) as cumulative widths
    cellx_positions: list[int] = []
    x = 0
    for cd in col_defs:
        # Add minimal padding around text visually
        col_twips = char_width_to_twips(max(cd.width, 1) + 2)
        x += col_twips
        cellx_positions.append(x)

    parts: list[str] = []
    parts.append("{\\rtf1\\ansi")

    def row_to_rtf(
        cells: list[str], bold: bool = False, use_header_defs: bool = False
    ) -> None:
        parts.append("\\trowd\\trgaph108")
        # Define cell boundaries
        for cx in cellx_positions:
            parts.append(f"\\cellx{cx}")
        # Cell contents
        for idx, text in enumerate(cells):
            # Alignment mapping using paragraph alignment
            # For headers, use header_defs alignment if available
            if use_header_defs and header_defs and idx < len(header_defs):
                hdr_def = header_defs[idx]
                if ">" in hdr_def.align:
                    align = "\\qr"
                elif "^" in hdr_def.align:
                    align = "\\qc"
                else:
                    align = "\\ql"
            else:
                cd = col_defs[idx]
                if ">" in cd.align:
                    align = "\\qr"
                elif "^" in cd.align:
                    align = "\\qc"
                else:
                    align = "\\ql"
            content = self._escape(text)
            # Wrap in \b ... \b0 for header bold
            if bold:
                parts.append(f"{{\\intbl {align} \\b {content} \\b0 \\cell}}")
            else:
                parts.append(f"{{\\intbl {align} {content} \\cell}}")
        parts.append("\\row")

    # Write header row
    if header_row:
        row_to_rtf([str(h) for h in header_row], bold=True, use_header_defs=True)

    # Helper for formatting with preprocess/postprocess
    def _format_for_export(cd, val, row: list[Any], col_idx: int) -> str:
        v2 = cd.preprocess(val, row, col_idx)
        try:
            if v2 is None:
                s = cd.prefix + (cd.none_text or "") + cd.suffix
            elif cd.format_spec:
                fmt = f"{{:{cd.format_spec}}}"
                s = cd.prefix + fmt.format(v2) + cd.suffix
            else:
                s = cd.prefix + str(v2) + cd.suffix
        except Exception:
            s = cd.prefix + (cd.none_text if v2 is None else str(v2)) + cd.suffix
        return cd.postprocess(val, s, row, col_idx)

    # Write data rows
    for row in value_rows:
        formatted = [
            _format_for_export(cd, v, row, i)
            for i, (v, cd) in enumerate(zip(row, col_defs))
        ]
        row_to_rtf(formatted, bold=False)

    parts.append("}")
    rtf_content = "".join(parts)

    # Write to file
    if isinstance(file, (str, Path)):
        with open(file, "w", encoding="utf-8") as f:
            f.write(rtf_content)
    else:
        # Assume file-like object
        file.write(rtf_content)  # type: ignore[arg-type]