ネストの深いオブジェクトの差分表示

## 目的
ネストの深いオブジェクトの差分をわかりやすく比較表示してhtml出力するpythonのサンプルコードを作成してみました。

## 全体の流れ

1. **HTML生成**: `generate_html` 関数を使用して、Pythonのデータ構造（辞書やリスト）をHTML形式の文字列に変換します。
2. **差分の検出とハイライト**: `diff_objects` 関数が2つのオブジェクトを比較し、差分を検出しながらHTML文字列を生成します。差分は追加、削除、変更としてハイライトされます。
3. **非表示部分の処理**: 深くネストされた部分など、差分がない部分を "..." で簡略表示するために、`remove_hidden_content` 関数が使用されます。
4. **行番号の追加**: `add_line_numbers` 関数を使用して、表示されたHTMLに行番号を追加します。
5. **差分の集計表示**: 追加、削除、変更の数をカウントし、HTMLのトップに集計表として表示します。
6. **HTMLファイルの出力**: 最終的なHTML内容をファイルに保存します。

---

## 各関数の詳細解説

### 1. `generate_html`

```python
def generate_html(value: Any, indent: int = 0) -> str:
    """
    オブジェクトをHTML形式の文字列に変換します。

    Args:
        value (Any): 対象の値（辞書、リスト、またはシンプルな値）。
        indent (int, optional): インデントレベル。デフォルトは0。

    Returns:
        str: HTML形式の文字列。
    """
    indent_str = '  ' * indent
    if value is None:
        return ''
    elif isinstance(value, dict):
        items = []
        for key in sorted(value.keys()):
            key_html = '"' + html.escape(str(key)) + '"'
            val_html = generate_html(value[key], indent + 1)
            line = f'{indent_str}  {key_html}: {val_html}'
            items.append(line)
        result = '{\n' + build_lines_with_commas(items) + f'\n{indent_str}}}'
    elif isinstance(value, list):
        items = []
        for item in value:
            item_html = generate_html(item, indent + 1)
            line = f'{indent_str}  {item_html}'
            items.append(line)
        result = '[\n' + build_lines_with_commas(items) + f'\n{indent_str}]'
    else:
        if isinstance(value, str):
            result = '"' + html.escape(value) + '"'
        else:
            result = html.escape(str(value))
    return result
```

**目的**:  
Pythonのデータ構造（辞書やリスト、基本データ型）を再帰的にHTML形式の文字列に変換します。インデントを考慮し、見やすいフォーマットで出力します。

**詳細**:
- **引数**:
    - `value`: 変換対象の値。辞書、リスト、文字列、数値などが考えられます。
    - `indent`: 現在のインデントレベル。ネストの深さに応じて増加します。

- **処理**:
    - **辞書の場合** (`dict`):
        - キーをソートして一貫性を保ちます。
        - 各キーと値に対して再帰的に`generate_html`を呼び出します。
        - 各項目をリストに追加し、最後にカンマを適切に配置します。
        - `{}`で囲みます。
    - **リストの場合** (`list`):
        - 各アイテムに対して再帰的に`generate_html`を呼び出します。
        - 各アイテムをリストに追加し、最後にカンマを適切に配置します。
        - `[]`で囲みます。
    - **基本データ型の場合**:
        - 文字列はダブルクオートで囲み、HTMLエスケープします。
        - その他の型はそのまま文字列に変換し、HTMLエスケープします。

- **返り値**:  
    HTML形式の文字列。

### 2. `build_lines_with_commas`

```python
def build_lines_with_commas(items: List[str]) -> str:
    """
    複数行の文字列リストを結合し、適切にカンマを配置します。

    Args:
        items (List[str]): 各アイテムは複数行の文字列。

    Returns:
        str: 結合された文字列。
    """
    all_lines: List[str] = []
    for idx, item in enumerate(items):
        item_lines = item.split('\n')
        # 最終アイテムでなければ、最後の非空行にカンマを追加
        if idx != len(items) - 1:
            for i in range(len(item_lines)-1, -1, -1):
                if item_lines[i].strip() and not item_lines[i].strip().startswith('__HIDE'):
                    item_lines[i] += ','
                    break
        all_lines.extend(item_lines)
    return '\n'.join(all_lines)
```

**目的**:  
複数行にわたる文字列リストを結合し、適切な位置にカンマを追加します。特に、JSONの各項目の後にカンマを配置するために使用されます。

**詳細**:
- **引数**:
    - `items`: 各項目が複数行にわたる文字列のリスト。

- **処理**:
    - 各アイテムをループで処理。
    - 最後のアイテムでなければ、最後の非空行にカンマを追加します。ただし、`__HIDE`で始まるマーカー行は除外します。
    - 全ての行を一つのリストにまとめ、最終的に改行で結合します。

- **返り値**:  
    カンマが適切に配置された結合済みの文字列。

### 3. `diff_objects`

```python
def diff_objects(source: Any, target: Any, indent: int = 0, parent_changed: bool = False, diff_counts: Dict[str, int] = None) -> Tuple[Union[str, None], Union[str, None]]:
    """
    2つのオブジェクトを比較し、差分をハイライトしたHTML文字列を返します。
    未変更の部分は表示しません。

    Args:
        source (Any): 比較元オブジェクト。
        target (Any): 比較先オブジェクト。
        indent (int, optional): インデントレベル。デフォルトは0。
        parent_changed (bool, optional): 親が変更されているかどうか。デフォルトはFalse。
        diff_counts (Dict[str, int], optional): 差分のカウントを格納する辞書。デフォルトはNone。

    Returns:
        Tuple[Union[str, None], Union[str, None]]: 比較元と比較先のHTML文字列。
    """
    if diff_counts is None:
        diff_counts = {'added': 0, 'deleted': 0, 'changed': 0}

    indent_str = '  ' * indent

    # オブジェクトが同じ場合は表示しない
    if source == target and not parent_changed:
        return None, None

    if type(source) != type(target):
        # 型が異なる場合は変更とみなす
        val_source = generate_html(source, indent) if source is not None else ''
        val_target = generate_html(target, indent) if target is not None else ''
        if source is not None or target is not None:
            val_source = apply_span(val_source, 'background-color: yellow;', indent * 2)  # 変更
            val_target = apply_span(val_target, 'background-color: yellow;', indent * 2)  # 変更
            diff_counts['changed'] += 1
        return val_source, val_target
    elif isinstance(source, dict):
        items_source = []
        items_target = []
        all_keys = set(source.keys()) | set(target.keys())
        for key in sorted(all_keys):
            s_val = source.get(key)
            t_val = target.get(key)
            if s_val is None:
                # 比較元にない場合（追加）
                t_line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(t_val, indent + 1)}'
                t_line = apply_span_multi_line(t_line, 'background-color: lightgreen;', indent_level=indent + 1)
                items_target.append(t_line)
                items_source.append(f'{indent_str}  ')  # 空行
                diff_counts['added'] += 1
            elif t_val is None:
                # 比較先にない場合（削除）
                s_line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(s_val, indent + 1)}'
                s_line = apply_span_multi_line(s_line, 'background-color: lightcoral;', indent_level=indent + 1)
                items_source.append(s_line)
                items_target.append(f'{indent_str}  ')  # 空行
                diff_counts['deleted'] += 1
            else:
                s_str, t_str = diff_objects(s_val, t_val, indent + 1, parent_changed or s_val != t_val, diff_counts)
                if s_str is not None or t_str is not None:
                    key_html = '"' + html.escape(str(key)) + '"'
                    if s_str is not None:
                        line_source = f'{indent_str}  {key_html}: {s_str}'
                        items_source.append(line_source)
                    else:
                        items_source.append(f'{indent_str}  ')  # 空行
                    if t_str is not None:
                        line_target = f'{indent_str}  {key_html}: {t_str}'
                        items_target.append(line_target)
                    else:
                        items_target.append(f'{indent_str}  ')  # 空行
                else:
                    # 差分がない場合も表示する
                    line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(s_val, indent + 1)}'
                    items_source.append(line)
                    items_target.append(line)
        if items_source or parent_changed:
            content_source = build_lines_with_commas(items_source)
            html_source = '{\n' + content_source + f'\n{indent_str}}}'
        else:
            html_source = None
        if items_target or parent_changed:
            content_target = build_lines_with_commas(items_target)
            html_target = '{\n' + content_target + f'\n{indent_str}}}'
        else:
            html_target = None
        return html_source, html_target
    elif isinstance(source, list):
        items_source = []
        items_target = []
        max_len = max(len(source), len(target))
        for i in range(max_len):
            s_item = source[i] if i < len(source) else None
            t_item = target[i] if i < len(target) else None

            # リスト内の辞書が同一であれば表示しない
            if isinstance(s_item, dict) and isinstance(t_item, dict) and s_item == t_item:
                # 非表示にするためのマーカーを挿入
                item_line = generate_html(s_item, indent + 1)
                item_line_with_marker = f'__HIDE_START__\n{item_line}\n__HIDE_END__'
                items_source.append(item_line_with_marker)
                items_target.append(item_line_with_marker)
                continue

            s_str, t_str = diff_objects(s_item, t_item, indent + 1, parent_changed or s_item != t_item, diff_counts)

            if s_str is not None or t_str is not None:
                if s_str is not None:
                    items_source.append(s_str)
                else:
                    items_source.append('')  # 空行
                if t_str is not None:
                    items_target.append(t_str)
                else:
                    items_target.append('')  # 空行
            else:
                # 差分がない場合も表示する
                item_line = generate_html(s_item, indent + 1)
                items_source.append(item_line)
                items_target.append(item_line)
        if items_source or parent_changed:
            # リストのアイテムをインデント調整
            adjusted_items_source = [f'{indent_str}  {item}' if item else f'{indent_str}  ' for item in items_source]
            content_source = build_lines_with_commas(adjusted_items_source)
            html_source = '[\n' + content_source + f'\n{indent_str}]'
        else:
            html_source = None
        if items_target or parent_changed:
            adjusted_items_target = [f'{indent_str}  {item}' if item else f'{indent_str}  ' for item in items_target]
            content_target = build_lines_with_commas(adjusted_items_target)
            html_target = '[\n' + content_target + f'\n{indent_str}]'
        else:
            html_target = None
        return html_source, html_target
    else:
        if source == target and not parent_changed:
            return None, None
        else:
            val_source = generate_html(source, indent) if source is not None else ''
            val_target = generate_html(target, indent) if target is not None else ''
            if source != target:
                if source is None:
                    val_target = apply_span(val_target, 'background-color: lightgreen;', indent * 2)  # 追加
                    diff_counts['added'] += 1
                elif target is None:
                    val_source = apply_span(val_source, 'background-color: lightcoral;', indent * 2)  # 削除
                    diff_counts['deleted'] += 1
                else:
                    val_source = apply_span(val_source, 'background-color: yellow;', indent * 2)
                    val_target = apply_span(val_target, 'background-color: yellow;', indent * 2)
                    diff_counts['changed'] += 1
            return val_source, val_target
```

**目的**:  
2つのオブジェクト（`source` と `target`）を比較し、差分をハイライトしたHTML文字列を生成します。また、追加、削除、変更された項目のカウントを行います。

**詳細**:
- **引数**:
    - `source`: 比較元のオブジェクト。
    - `target`: 比較先のオブジェクト。
    - `indent`: 現在のインデントレベル。
    - `parent_changed`: 親要素が変更されたかどうか。
    - `diff_counts`: 差分のカウントを保持する辞書。キーは `'added'`, `'deleted'`, `'changed'`。

- **処理の流れ**:
    1. **初期チェック**:
        - `source` と `target` が同一であり、かつ親要素が変更されていない場合、`None` を返します（変更なし）。
    2. **型の違いのチェック**:
        - `source` と `target` の型が異なる場合、それらを「変更」として扱い、ハイライトします。
        - `diff_counts['changed']` をインクリメントします。
    3. **辞書の場合**:
        - 両方が辞書の場合、全てのキーを集めて比較します。
        - **キーの追加**: `source` に存在せず `target` に存在するキーは「追加」として扱います。
        - **キーの削除**: `target` に存在せず `source` に存在するキーは「削除」として扱います。
        - **キーの共通**: 同じキーが存在する場合、再帰的に`diff_objects`を呼び出して差分を検出します。
        - **ハイライト**: 追加は `lightgreen`、削除は `lightcoral`、変更は `yellow` でハイライトします。
    4. **リストの場合**:
        - 両方がリストの場合、最長の長さに合わせてループを回します。
        - 各インデックスでのアイテムを比較します。
        - **辞書の一致**: リスト内の辞書が完全に一致する場合、その部分を非表示マーカー (`__HIDE_START__`, `__HIDE_END__`) で囲みます。
        - **差分の検出**: 一致しない場合は再帰的に`diff_objects`を呼び出して差分を検出します。
    5. **基本データ型の場合**:
        - 値が異なる場合、「追加」、「削除」、「変更」としてハイライトし、カウントします。
    6. **結果の返却**:
        - 比較元と比較先のHTML文字列をタプルで返します。差分がなければ `None` を返します。

- **返り値**:  
    比較元と比較先のHTML形式の文字列のタプル。差分がなければ `None`。

### 4. `remove_hidden_content`

```python
def remove_hidden_content(html_content: str) -> str:
    """
    __HIDE_START__と__HIDE_END__の間のコンテンツを削除し、'...'を挿入します。
    連続する '...' はひとつにまとめます。

    Args:
        html_content (str): 対象のHTML文字列。

    Returns:
        str: 修正されたHTML文字列。
    """
    lines = html_content.split('\n')
    result_lines = []
    hide = False
    ellipsis_added = False
    for line in lines:
        if '__HIDE_START__' in line:
            hide = True
            if not ellipsis_added:
                result_lines.append('  ...')  # '...' を挿入
                ellipsis_added = True
            continue
        elif '__HIDE_END__' in line:
            hide = False
            continue
        if not hide:
            if line.strip() == '...':
                # 既に '...' が追加されている場合はスキップ
                continue
            result_lines.append(line)
            ellipsis_added = False
    return '\n'.join(result_lines)
```

**目的**:  
非表示マーカー (`__HIDE_START__` と `__HIDE_END__`) の間のコンテンツを削除し、代わりに "..." を挿入します。連続して "..." が挿入されるのを防ぎ、見やすさを向上させます。

**詳細**:
- **引数**:
    - `html_content`: 処理対象のHTML文字列。

- **処理**:
    - 文字列を行単位で分割します。
    - 各行をループで処理：
        - `__HIDE_START__` が見つかると、`hide` フラグを立て、"..." を追加（まだ追加されていなければ）。
        - `__HIDE_END__` が見つかると、`hide` フラグを解除します。
        - `hide` が立っていない行はそのまま結果に追加します。ただし、既に "..." が追加されている場合はスキップします。

- **返り値**:  
    非表示部分が "..." に置き換えられ、連続する "..." がひとつにまとめられたHTML文字列。

### 5. `apply_span`

```python
def apply_span(content: str, style: str, base_indent: int) -> str:
    """
    コンテンツの指定したインデント以降にスタイルを適用します。

    Args:
        content (str): 対象のコンテンツ文字列。
        style (str): 適用するスタイル。
        base_indent (int): ハイライトから除外するスペースの数。

    Returns:
        str: スタイルが適用された文字列。
    """
    lines = content.split('\n')
    highlighted_lines = []
    for line in lines:
        # 行が空の場合はそのまま
        if not line.strip():
            highlighted_lines.append(line)
            continue
        # 実際の行頭スペース数を計算
        leading_spaces = len(line) - len(line.lstrip(' '))
        # ハイライトから除外するスペース数を調整
        exclude_spaces = min(base_indent, leading_spaces)
        leading = line[:exclude_spaces]
        rest = line[exclude_spaces:]
        highlighted_line = f'{leading}<span style="{style}">{rest}</span>'
        highlighted_lines.append(highlighted_line)
    return '\n'.join(highlighted_lines)
```

**目的**:  
指定したインデント以降の文字列部分にスタイルを適用します。主に差分をハイライトするために使用されます。

**詳細**:
- **引数**:
    - `content`: スタイルを適用する文字列。
    - `style`: CSSスタイル。例: `'background-color: yellow;'`
    - `base_indent`: スタイルを適用する際に除外するスペースの数。

- **処理**:
    - 文字列を行単位で分割します。
    - 各行をループで処理：
        - 空行はそのまま追加。
        - 行頭のスペース数を計算し、`base_indent` に基づいて除外するスペースを決定。
        - 除外されたスペースはそのまま、残りの部分に `<span>` タグを適用してスタイルを挿入。
    - スタイルが適用された行を結合して返します。

- **返り値**:  
    スタイルが適用されたHTML文字列。

### 6. `apply_span_multi_line`

```python
def apply_span_multi_line(content: str, style: str, indent_level: int) -> str:
    """
    複数行のコンテンツ全体にスタイルを適用します。
    指定したインデントレベル分のスペースはハイライトから除外します。

    Args:
        content (str): 対象のコンテンツ文字列（複数行）。
        style (str): 適用するスタイル。
        indent_level (int): ハイライトから除外するインデントレベル。

    Returns:
        str: スタイルが適用されたコンテンツ。
    """
    base_indent = indent_level * 2  # スペース数
    return apply_span(content, style, base_indent)
```

**目的**:  
複数行にわたる文字列全体にスタイルを適用します。特に、ネストされた構造の差分部分をハイライトする際に使用されます。

**詳細**:
- **引数**:
    - `content`: スタイルを適用する複数行の文字列。
    - `style`: CSSスタイル。
    - `indent_level`: インデントの深さ（スペース数の基準）。

- **処理**:
    - `indent_level` に基づいて `base_indent` を計算（スペース数）。
    - `apply_span` 関数を呼び出して、指定したスペース以降にスタイルを適用。

- **返り値**:  
    スタイルが適用されたHTML文字列。

### 7. `add_line_numbers`

```python
def add_line_numbers(html_content: str) -> str:
    """
    行番号を追加します。空白行には行番号を付けません。

    Args:
        html_content (str): 対象のHTML文字列。

    Returns:
        str: 行番号が追加されたHTML文字列。
    """
    lines = html_content.split('\n')
    line_number = 1
    new_lines = []
    for line in lines:
        if line.strip() and not line.strip().startswith('...'):
            line_num_str = f'{line_number:4}: '
            new_line = line_num_str + line
            new_lines.append(new_line)
            line_number += 1
        else:
            new_lines.append(line)
    return '\n'.join(new_lines)
```

**目的**:  
HTML文字列に行番号を追加します。視覚的にどの行がどの番号かを把握しやすくするために使用されます。ただし、空白行や "..." の行には行番号を付けません。

**詳細**:
- **引数**:
    - `html_content`: 行番号を追加する対象のHTML文字列。

- **処理**:
    - 文字列を行単位で分割。
    - 各行をループで処理：
        - 行が空白でなく、かつ "..." で始まらない場合、行番号を付加。
        - それ以外の行はそのまま追加。
    - 行番号が付加された行を結合して返します。

- **返り値**:  
    行番号が追加されたHTML文字列。

---

## 使用例と実行フロー

以下に、提供された `source` と `target` のオブジェクトを使用したコードの実行フローを説明します。

### 1. データの定義

```python
source = {
    'h': {'i': 12, 'j': [1, 3, 5], 'k': {'l': ['m', 'n']}},
    'list': [
        {'p1': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u1', 'v']}}},
        {'p2': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u2', 'v']}}},
        {'p3': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u3', 'v']}}},
        {'p4': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u4', 'v']}}}
    ],
    'deleted_obj': {'key': 'value'}  # 削除されたオブジェクト
}

target = {
    'h': {'i': 12, 'j': [1, 3, "5"], 'k': {'l': ['o', 'n']}},
    'list': [
        {'p1': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u1', 'v']}}},
        {'p2': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u2', 'v']}}},
        {'p3': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['w3', 'v']}}},
        {'p4': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u4', 'v']}}}
    ],
    'added_obj': {'new_key': 'new_value'}  # 追加されたオブジェクト
}
```

**説明**:
- `source` と `target` は2つのネストされた辞書です。
- `source` には `deleted_obj` という削除されたオブジェクトが含まれています。
- `target` には `added_obj` という追加されたオブジェクトが含まれています。
- `h.j` のリスト内で、`source` では数値 `5` が、`target` では文字列 `"5"` に変更されています（これは型の変更です）。
- `h.k.l` のリスト内で、`source` では `'m'` が、`target` では `'o'` に変更されています。

### 2. 差分のカウント用辞書の初期化

```python
diff_counts = {'added': 0, 'deleted': 0, 'changed': 0}
```

**説明**:  
差分のカウントを保持する辞書を初期化します。キーは `'added'`, `'deleted'`, `'changed'` で、それぞれ追加、削除、変更された項目の数をカウントします。

### 3. 差分の検出とHTML生成

```python
html_source, html_target = diff_objects(source, target, diff_counts=diff_counts)
```

**説明**:  
`diff_objects` 関数を呼び出して、`source` と `target` の差分を検出し、ハイライトされたHTML文字列を生成します。また、差分のカウントも更新されます。

### 4. 行番号の追加

```python
html_source = add_line_numbers(html_source) if html_source else ''
html_target = add_line_numbers(html_target) if html_target else ''
```

**説明**:  
生成されたHTML文字列に行番号を追加します。ただし、差分がない場合は空文字列を設定します。

### 5. 非表示部分の処理

```python
html_source = remove_hidden_content(html_source)
html_target = remove_hidden_content(html_target)
```

**説明**:  
非表示マーカーで囲まれた部分を "..." に置き換え、連続する "..." がひとつにまとめられます。

### 6. 差分の集計表の作成

```python
summary_table = f'''
<h2>差分の集計</h2>
<table border="1">
    <tr>
        <th>追加されたオブジェクト</th>
        <th>削除されたオブジェクト</th>
        <th>変更された値</th>
    </tr>
    <tr>
        <td>{diff_counts['added']}</td>
        <td>{diff_counts['deleted']}</td>
        <td>{diff_counts['changed']}</td>
    </tr>
</table>
<br>
'''
```

**説明**:  
差分のカウント結果をHTMLの表形式で作成します。追加されたオブジェクト、削除されたオブジェクト、変更された値の数が表示されます。

### 7. HTMLファイルへの出力

```python
with open('diff_output.html', 'w', encoding='utf-8') as f:
    f.write('<html><head><meta charset="UTF-8"><style>')
    f.write('table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }')
    f.write('td, th { border: 1px solid #000; padding: 8px; vertical-align: top; }')
    f.write('pre { margin: 0; font-family: monospace; white-space: pre-wrap; word-wrap: break-word; }')
    f.write('.hidden_content { display: none; }')  # 非表示にするスタイル
    f.write('</style></head><body>')
    # 集計表を追加
    f.write(summary_table)
    # 差分表を追加
    f.write('<table border="1"><tr><th>比較元</th><th>比較先</th></tr>')
    f.write('<tr>')
    f.write(f'<td><pre>{html_source}</pre></td>')
    f.write(f'<td><pre>{html_target}</pre></td>')
    f.write('</tr></table>')
    f.write('</body></html>')
```

**説明**:  
以下の手順でHTMLファイルを生成します：
1. HTMLの基本構造（`<html>`, `<head>`, `<body>`）を作成。
2. CSSスタイルを定義：
    - テーブルの幅やボーダー、パディングを設定。
    - `pre` タグのフォントや折り返し設定。
    - `.hidden_content` クラスを定義（今回は未使用ですが、非表示スタイル用）。
3. 差分の集計表 (`summary_table`) をHTMLに追加。
4. 比較元と比較先の差分を表形式で追加。`<pre>` タグを使用してフォーマットを維持。
5. HTMLを閉じる。

**出力**:  
`diff_output.html` というファイルに、差分の集計表と比較元・比較先の差分表示が含まれたHTMLが保存されます。このファイルをブラウザで開くことで、差分を視覚的に確認できます。

---

## コード

```python:diff_to_html.py
import html
from typing import Any, List, Tuple, Union, Dict

def generate_html(value: Any, indent: int = 0) -> str:
    """
    オブジェクトをHTML形式の文字列に変換します。

    Args:
        value (Any): 対象の値（辞書、リスト、またはシンプルな値）。
        indent (int, optional): インデントレベル。デフォルトは0。

    Returns:
        str: HTML形式の文字列。
    """
    indent_str = '  ' * indent
    if value is None:
        return ''
    elif isinstance(value, dict):
        items = []
        for key in sorted(value.keys()):
            key_html = '"' + html.escape(str(key)) + '"'
            val_html = generate_html(value[key], indent + 1)
            line = f'{indent_str}  {key_html}: {val_html}'
            items.append(line)
        result = '{\n' + build_lines_with_commas(items) + f'\n{indent_str}}}'
    elif isinstance(value, list):
        items = []
        for item in value:
            item_html = generate_html(item, indent + 1)
            line = f'{indent_str}  {item_html}'
            items.append(line)
        result = '[\n' + build_lines_with_commas(items) + f'\n{indent_str}]'
    else:
        if isinstance(value, str):
            result = '"' + html.escape(value) + '"'
        else:
            result = html.escape(str(value))
    return result

def build_lines_with_commas(items: List[str]) -> str:
    """
    複数行の文字列リストを結合し、適切にカンマを配置します。

    Args:
        items (List[str]): 各アイテムは複数行の文字列。

    Returns:
        str: 結合された文字列。
    """
    all_lines: List[str] = []
    for idx, item in enumerate(items):
        item_lines = item.split('\n')
        # 最終アイテムでなければ、最後の非空行にカンマを追加
        if idx != len(items) - 1:
            for i in range(len(item_lines)-1, -1, -1):
                if item_lines[i].strip() and not item_lines[i].strip().startswith('__HIDE'):
                    item_lines[i] += ','
                    break
        all_lines.extend(item_lines)
    return '\n'.join(all_lines)

def diff_objects(source: Any, target: Any, indent: int = 0, parent_changed: bool = False, diff_counts: Dict[str, int] = None) -> Tuple[Union[str, None], Union[str, None]]:
    """
    2つのオブジェクトを比較し、差分をハイライトしたHTML文字列を返します。
    未変更の部分は表示しません。

    Args:
        source (Any): 比較元オブジェクト。
        target (Any): 比較先オブジェクト。
        indent (int, optional): インデントレベル。デフォルトは0。
        parent_changed (bool, optional): 親が変更されているかどうか。デフォルトはFalse。
        diff_counts (Dict[str, int], optional): 差分のカウントを格納する辞書。デフォルトはNone。

    Returns:
        Tuple[Union[str, None], Union[str, None]]: 比較元と比較先のHTML文字列。
    """
    if diff_counts is None:
        diff_counts = {'added': 0, 'deleted': 0, 'changed': 0}

    indent_str = '  ' * indent

    # オブジェクトが同じ場合は表示しない
    if source == target and not parent_changed:
        return None, None

    if type(source) != type(target):
        # 型が異なる場合は変更とみなす
        val_source = generate_html(source, indent) if source is not None else ''
        val_target = generate_html(target, indent) if target is not None else ''
        if source is not None or target is not None:
            val_source = apply_span(val_source, 'background-color: yellow;', indent * 2)  # 変更
            val_target = apply_span(val_target, 'background-color: yellow;', indent * 2)  # 変更
            diff_counts['changed'] += 1
        return val_source, val_target
    elif isinstance(source, dict):
        items_source = []
        items_target = []
        all_keys = set(source.keys()) | set(target.keys())
        for key in sorted(all_keys):
            s_val = source.get(key)
            t_val = target.get(key)
            if s_val is None:
                # 比較元にない場合（追加）
                t_line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(t_val, indent + 1)}'
                t_line = apply_span_multi_line(t_line, 'background-color: lightgreen;', indent_level=indent + 1)
                items_target.append(t_line)
                items_source.append(f'{indent_str}  ')  # 空行
                diff_counts['added'] += 1
            elif t_val is None:
                # 比較先にない場合（削除）
                s_line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(s_val, indent + 1)}'
                s_line = apply_span_multi_line(s_line, 'background-color: lightcoral;', indent_level=indent + 1)
                items_source.append(s_line)
                items_target.append(f'{indent_str}  ')  # 空行
                diff_counts['deleted'] += 1
            else:
                s_str, t_str = diff_objects(s_val, t_val, indent + 1, parent_changed or s_val != t_val, diff_counts)
                if s_str is not None or t_str is not None:
                    key_html = '"' + html.escape(str(key)) + '"'
                    if s_str is not None:
                        line_source = f'{indent_str}  {key_html}: {s_str}'
                        items_source.append(line_source)
                    else:
                        items_source.append(f'{indent_str}  ')  # 空行
                    if t_str is not None:
                        line_target = f'{indent_str}  {key_html}: {t_str}'
                        items_target.append(line_target)
                    else:
                        items_target.append(f'{indent_str}  ')  # 空行
                else:
                    # 差分がない場合も表示する
                    line = f'{indent_str}  "{html.escape(str(key))}": {generate_html(s_val, indent + 1)}'
                    items_source.append(line)
                    items_target.append(line)
        if items_source or parent_changed:
            content_source = build_lines_with_commas(items_source)
            html_source = '{\n' + content_source + f'\n{indent_str}}}'
        else:
            html_source = None
        if items_target or parent_changed:
            content_target = build_lines_with_commas(items_target)
            html_target = '{\n' + content_target + f'\n{indent_str}}}'
        else:
            html_target = None
        return html_source, html_target
    elif isinstance(source, list):
        items_source = []
        items_target = []
        max_len = max(len(source), len(target))
        for i in range(max_len):
            s_item = source[i] if i < len(source) else None
            t_item = target[i] if i < len(target) else None

            # リスト内の辞書が同一であれば表示しない
            if isinstance(s_item, dict) and isinstance(t_item, dict) and s_item == t_item:
                # 非表示にするためのマーカーを挿入
                item_line = generate_html(s_item, indent + 1)
                item_line_with_marker = f'__HIDE_START__\n{item_line}\n__HIDE_END__'
                items_source.append(item_line_with_marker)
                items_target.append(item_line_with_marker)
                continue

            s_str, t_str = diff_objects(s_item, t_item, indent + 1, parent_changed or s_item != t_item, diff_counts)

            if s_str is not None or t_str is not None:
                if s_str is not None:
                    items_source.append(s_str)
                else:
                    items_source.append('')  # 空行
                if t_str is not None:
                    items_target.append(t_str)
                else:
                    items_target.append('')  # 空行
            else:
                # 差分がない場合も表示する
                item_line = generate_html(s_item, indent + 1)
                items_source.append(item_line)
                items_target.append(item_line)
        if items_source or parent_changed:
            # リストのアイテムをインデント調整
            adjusted_items_source = [f'{indent_str}  {item}' if item else f'{indent_str}  ' for item in items_source]
            content_source = build_lines_with_commas(adjusted_items_source)
            html_source = '[\n' + content_source + f'\n{indent_str}]'
        else:
            html_source = None
        if items_target or parent_changed:
            adjusted_items_target = [f'{indent_str}  {item}' if item else f'{indent_str}  ' for item in items_target]
            content_target = build_lines_with_commas(adjusted_items_target)
            html_target = '[\n' + content_target + f'\n{indent_str}]'
        else:
            html_target = None
        return html_source, html_target
    else:
        if source == target and not parent_changed:
            return None, None
        else:
            val_source = generate_html(source, indent) if source is not None else ''
            val_target = generate_html(target, indent) if target is not None else ''
            if source != target:
                if source is None:
                    val_target = apply_span(val_target, 'background-color: lightgreen;', indent * 2)  # 追加
                    diff_counts['added'] += 1
                elif target is None:
                    val_source = apply_span(val_source, 'background-color: lightcoral;', indent * 2)  # 削除
                    diff_counts['deleted'] += 1
                else:
                    val_source = apply_span(val_source, 'background-color: yellow;', indent * 2)
                    val_target = apply_span(val_target, 'background-color: yellow;', indent * 2)
                    diff_counts['changed'] += 1
            return val_source, val_target

def remove_hidden_content(html_content: str) -> str:
    """
    __HIDE_START__と__HIDE_END__の間のコンテンツを削除し、'...'を挿入します。
    連続する '...' はひとつにまとめます。

    Args:
        html_content (str): 対象のHTML文字列。

    Returns:
        str: 修正されたHTML文字列。
    """
    lines = html_content.split('\n')
    result_lines = []
    hide = False
    ellipsis_added = False
    for line in lines:
        if '__HIDE_START__' in line:
            hide = True
            if not ellipsis_added:
                result_lines.append('  ...')  # '...' を挿入
                ellipsis_added = True
            continue
        elif '__HIDE_END__' in line:
            hide = False
            continue
        if not hide:
            if line.strip() == '...':
                # 既に '...' が追加されている場合はスキップ
                continue
            result_lines.append(line)
            ellipsis_added = False
    return '\n'.join(result_lines)

def apply_span(content: str, style: str, base_indent: int) -> str:
    """
    コンテンツの指定したインデント以降にスタイルを適用します。

    Args:
        content (str): 対象のコンテンツ文字列。
        style (str): 適用するスタイル。
        base_indent (int): ハイライトから除外するスペースの数。

    Returns:
        str: スタイルが適用された文字列。
    """
    lines = content.split('\n')
    highlighted_lines = []
    for line in lines:
        # 行が空の場合はそのまま
        if not line.strip():
            highlighted_lines.append(line)
            continue
        # 実際の行頭スペース数を計算
        leading_spaces = len(line) - len(line.lstrip(' '))
        # ハイライトから除外するスペース数を調整
        exclude_spaces = min(base_indent, leading_spaces)
        leading = line[:exclude_spaces]
        rest = line[exclude_spaces:]
        highlighted_line = f'{leading}<span style="{style}">{rest}</span>'
        highlighted_lines.append(highlighted_line)
    return '\n'.join(highlighted_lines)

def apply_span_multi_line(content: str, style: str, indent_level: int) -> str:
    """
    複数行のコンテンツ全体にスタイルを適用します。
    指定したインデントレベル分のスペースはハイライトから除外します。

    Args:
        content (str): 対象のコンテンツ文字列（複数行）。
        style (str): 適用するスタイル。
        indent_level (int): ハイライトから除外するインデントレベル。

    Returns:
        str: スタイルが適用されたコンテンツ。
    """
    base_indent = indent_level * 2  # スペース数
    return apply_span(content, style, base_indent)

def add_line_numbers(html_content: str) -> str:
    """
    行番号を追加します。空白行には行番号を付けません。

    Args:
        html_content (str): 対象のHTML文字列。

    Returns:
        str: 行番号が追加されたHTML文字列。
    """
    lines = html_content.split('\n')
    line_number = 1
    new_lines = []
    for line in lines:
        if line.strip() and not line.strip().startswith('...'):
            line_num_str = f'{line_number:4}: '
            new_line = line_num_str + line
            new_lines.append(new_line)
            line_number += 1
        else:
            new_lines.append(line)
    return '\n'.join(new_lines)

# 使用例
source = {
    'h': {'i': 12, 'j': [1, 3, 5], 'k': {'l': ['m', 'n']}},
    'list': [
        {'p1': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u1', 'v']}}},
        {'p2': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u2', 'v']}}},
        {'p3': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u3', 'v']}}},
        {'p4': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u4', 'v']}}}
    ],
    'deleted_obj': {'key': 'value'}  # 削除されたオブジェクト
}

target = {
    'h': {'i': 12, 'j': [1, 3, "5"], 'k': {'l': ['o', 'n']}},
    'list': [
        {'p1': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u1', 'v']}}},
        {'p2': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u2', 'v']}}},
        {'p3': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['w3', 'v']}}},
        {'p4': {'q': 12, 'r': [1, 3, 5], 's': {'t': ['u4', 'v']}}}
    ],
    'added_obj': {'new_key': 'new_value'}  # 追加されたオブジェクト
}

# 差分のカウント用辞書を初期化
diff_counts = {'added': 0, 'deleted': 0, 'changed': 0}

html_source, html_target = diff_objects(source, target, diff_counts=diff_counts)

# 行番号を追加
html_source = add_line_numbers(html_source) if html_source else ''
html_target = add_line_numbers(html_target) if html_target else ''

# 非表示にする内容を削除し、'...'を挿入
html_source = remove_hidden_content(html_source)
html_target = remove_hidden_content(html_target)

# 集計表のHTMLを作成
summary_table = f'''
<h2>差分の集計</h2>
<table border="1">
    <tr>
        <th>追加されたオブジェクト</th>
        <th>削除されたオブジェクト</th>
        <th>変更された値</th>
    </tr>
    <tr>
        <td>{diff_counts['added']}</td>
        <td>{diff_counts['deleted']}</td>
        <td>{diff_counts['changed']}</td>
    </tr>
</table>
<br>
'''

# 結果をHTMLファイルに保存
with open('diff_output.html', 'w', encoding='utf-8') as f:
    f.write('<html><head><meta charset="UTF-8"><style>')
    f.write('table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }')
    f.write('td, th { border: 1px solid #000; padding: 8px; vertical-align: top; }')
    f.write('pre { margin: 0; font-family: monospace; white-space: pre-wrap; word-wrap: break-word; }')
    f.write('.hidden_content { display: none; }')  # 非表示にするスタイル
    f.write('</style></head><body>')
    # 集計表を追加
    f.write(summary_table)
    # 差分表を追加
    f.write('<table border="1"><tr><th>比較元</th><th>比較先</th></tr>')
    f.write('<tr>')
    f.write(f'<td><pre>{html_source}</pre></td>')
    f.write(f'<td><pre>{html_target}</pre></td>')
    f.write('</tr></table>')
    f.write('</body></html>')
'''
```

## 別アプローチの検討

中間オブジェクトを生成し、ソートによる比較ミスを防ぐ。（TBD）

```python
def generate_html(value: Any, indent: int = 0) -> str:
    """
    オブジェクトをHTML形式の文字列に変換します。

    Args:
        value (Any): 対象の値（辞書、リスト、またはシンプルな値）。
        indent (int, optional): インデントレベル。デフォルトは0。

    Returns:
        str: HTML形式の文字列。
    """
    indent_str = '  ' * indent
    if value is None:
        return ''
    elif isinstance(value, dict):
        items = []
        for key in sorted(value.keys()):
            key_html = '"' + html.escape(str(key)) + '"'
            val_html = generate_html(value[key], indent + 1)
            line = f'{indent_str}  {key_html}: {val_html}'
            items.append(line)
        result = '{\n' + build_lines_with_commas(items) + f'\n{indent_str}}}'
    elif isinstance(value, list):
        items = []
        for item in value:
            item_html = generate_html(item, indent + 1)
            line = f'{indent_str}  {item_html}'
            items.append(line)
        result = '[\n' + build_lines_with_commas(items) + f'\n{indent_str}]'
    else:
        if isinstance(value, str):
            result = '"' + html.escape(value) + '"'
        else:
            result = html.escape(str(value))
    return result

```