diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a9e4ea16..a57bce9b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ -* @Seongho-Bae +* @seonghobae # Security and workflow ownership -.github/ @Seongho-Bae -docs/ @Seongho-Bae -manual/ @Seongho-Bae +.github/ @seonghobae +docs/ @seonghobae +manual/ @seonghobae diff --git a/CHANGELOG.md b/CHANGELOG.md index 201db017..76fbf914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `newsdom_api.dom_builder._html_safe_text` 함수에 early return과 타입 체크를 도입하여 불필요한 `str()` 캐스팅을 제거함으로써 처리 속도를 개선했습니다. ### Added +- [CLI] 파싱된 NewsDOM JSON을 HTML 포맷으로 변환하여 웹 브라우저에서 보기 쉽게 만들어주는 `tools/export_html.py` 도구를 추가했습니다. - [CLI] 파싱된 NewsDOM JSON이 Pydantic 스키마(`ParseResponse`)와 일치하는지 엄격하게 검증하는 `tools/validate_dom.py` 도구 추가 - [CLI] 파싱된 NewsDOM JSON의 기사 제목(headline)과 본문(body_blocks)에서 텍스트를 검색하여 위치를 반환하는 `tools/search_dom.py` 도구 추가 - [CLI] 파싱된 NewsDOM JSON을 Markdown 포맷으로 변환하는 `tools/export_markdown.py` 도구를 추가했습니다. diff --git a/tests/test_repository_governance.py b/tests/test_repository_governance.py index 3d54d6f2..bd7f6896 100644 --- a/tests/test_repository_governance.py +++ b/tests/test_repository_governance.py @@ -15,10 +15,10 @@ def test_codeowners_exists_and_covers_repository() -> None: pattern, *owners = line.split() rules[pattern] = set(owners) - assert "@Seongho-Bae" in rules["*"] - assert "@Seongho-Bae" in rules[".github/"] - assert "@Seongho-Bae" in rules["docs/"] - assert "@Seongho-Bae" in rules["manual/"] + assert "@seonghobae" in rules["*"] + assert "@seonghobae" in rules[".github/"] + assert "@seonghobae" in rules["docs/"] + assert "@seonghobae" in rules["manual/"] def test_codeql_scans_python_and_actions_with_required_check_name() -> None: diff --git a/tests/test_tools_export_html.py b/tests/test_tools_export_html.py new file mode 100644 index 00000000..37445cdb --- /dev/null +++ b/tests/test_tools_export_html.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from tools.export_html import generate_html, main + + +@pytest.fixture +def sample_json_data() -> dict[str, object]: + return { + "document_id": "test_doc_1 & < >", + "pages": [ + { + "page_number": 1, + "headers": ["Top Header & More"], + "articles": [ + { + "headline": "Main Article ", + "body_blocks": [ + "This is paragraph 1.", + "This is paragraph 2 & stuff.", + ], + "images": [ + { + "path": "image1.png", + "captions": [{"text": "Image 1 Caption &"}], + } + ], + "captions": [{"text": "Article Level Caption"}], + "footnotes": [{"text": "Article Footnote 1"}], + } + ], + "ads": ["Ad Content 1 & Co."], + "footers": ["Bottom Footer"], + "page_numbers": ["1", "I"], + }, + {"page_number": 2, "articles": []}, + ], + } + + +def test_generate_html(sample_json_data: dict[str, object]) -> None: + html_out = generate_html(sample_json_data) + + # Document Title and Headings (escaped) + assert "test_doc_1 & < >" in html_out + assert "

Document: test_doc_1 & < >

" in html_out + + # Page and Header + assert "

Page 1

" in html_out + assert "Header: Top Header & More" in html_out + + # Article + assert '

Main Article <Test>

' in html_out + assert '

This is paragraph 1.

' in html_out + assert '

This is paragraph 2 & stuff.

' in html_out + + # Images and Captions + assert "image1.png" in html_out + assert "Caption: Image 1 Caption &" in html_out + assert "Caption: Article Level Caption" in html_out + assert "Footnote: Article Footnote 1" in html_out + + # Ads, Footers, Page Numbers + assert '
Ad Content 1 & Co.
' in html_out + assert "Bottom Footer" in html_out + assert "Page No: I" in html_out + + # Empty Page + assert "

Page 2

" in html_out + + assert html_out.endswith("\n") + + +def test_generate_html_empty_data() -> None: + html_out = generate_html({}) + + assert "

Document: Unknown Document

" in html_out + + +def test_generate_html_skips_non_dict_nodes() -> None: + html_out = generate_html({"pages": ["bad", {"articles": ["bad article"]}]}) + + assert "bad article" not in html_out + assert "

Page Unknown

" in html_out + + +def test_generate_html_handles_loose_caption_and_image_values() -> None: + html_out = generate_html( + { + "pages": [ + { + "articles": [ + { + "headline": "Loose Values", + "images": [ + "bad image", + { + "path": "photo.png", + "captions": ["plain caption &"], + }, + ], + "captions": ["article caption <"], + }, + { + "headline": "Only Footnotes", + "footnotes": ["plain footnote >"], + }, + ] + } + ] + } + ) + + assert "bad image" not in html_out + assert "Caption: plain caption &" in html_out + assert "Caption: article caption <" in html_out + assert "Footnote: plain footnote >" in html_out + + +def test_main_stdout( + tmp_path: Path, + sample_json_data: dict[str, object], + capsys: pytest.CaptureFixture[str], +) -> None: + input_file = tmp_path / "input.json" + input_file.write_text(json.dumps(sample_json_data), encoding="utf-8") + + main([str(input_file)]) + + captured = capsys.readouterr() + assert "

Document: test_doc_1 & < >

" in captured.out + + +def test_main_file_output( + tmp_path: Path, + sample_json_data: dict[str, object], + capsys: pytest.CaptureFixture[str], +) -> None: + input_file = tmp_path / "input.json" + input_file.write_text(json.dumps(sample_json_data), encoding="utf-8") + output_file = tmp_path / "output.html" + + main([str(input_file), "-o", str(output_file)]) + + assert "

Document: test_doc_1 & < >

" in output_file.read_text( + encoding="utf-8" + ) + assert f"HTML written to {output_file}" in capsys.readouterr().out + + +def test_main_invalid_input(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + input_file = tmp_path / "input.json" + input_file.write_text("invalid json", encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + main([str(input_file)]) + + assert excinfo.value.code == 1 + assert "Error exporting HTML" in capsys.readouterr().err + + +def test_main_file_output_error( + tmp_path: Path, + sample_json_data: dict[str, object], + capsys: pytest.CaptureFixture[str], +) -> None: + input_file = tmp_path / "input.json" + input_file.write_text(json.dumps(sample_json_data), encoding="utf-8") + output_file = tmp_path / "nonexistent" / "output.html" + + with pytest.raises(SystemExit) as excinfo: + main([str(input_file), "-o", str(output_file)]) + + assert excinfo.value.code == 1 + assert "Error exporting HTML" in capsys.readouterr().err diff --git a/tools/export_html.py b/tools/export_html.py new file mode 100644 index 00000000..2d55b9a0 --- /dev/null +++ b/tools/export_html.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import argparse +import html +import json +import sys +from pathlib import Path +from typing import Any + + +def _caption_text(caption: Any) -> str: + if isinstance(caption, dict): + return html.escape(str(caption.get("text", ""))) + return html.escape(str(caption)) + + +def generate_html(data: dict[str, Any]) -> str: + """Convert a NewsDOM JSON dictionary into an HTML string.""" + document_id = html.escape(data.get("document_id", "Unknown Document")) + + css = """ + body { font-family: sans-serif; margin: 2rem; background: #f9f9f9; color: #333; } + .page { background: #fff; border: 1px solid #ccc; padding: 2rem; margin-bottom: 2rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } + .page-header { border-bottom: 2px solid #eee; margin-bottom: 1rem; padding-bottom: 0.5rem; } + .article { margin-bottom: 2rem; } + .article-headline { color: #2c3e50; } + .body-block { line-height: 1.6; margin-bottom: 1rem; } + .image-container { margin: 1rem 0; padding: 1rem; background: #f0f0f0; border-radius: 4px; } + .caption { font-size: 0.9em; color: #666; font-style: italic; } + .footnote { font-size: 0.85em; color: #777; border-top: 1px solid #eee; padding-top: 0.5rem; margin-top: 1rem; } + .ad-block { background: #ffeaa7; padding: 1rem; margin-bottom: 1rem; border-left: 4px solid #fdcb6e; } + .footer-block { font-size: 0.8em; text-align: center; color: #999; border-top: 1px solid #eee; padding-top: 1rem; margin-top: 2rem; } + """ + + lines: list[str] = [ + "", + '', + "", + '', + f"{document_id}", + f"", + "", + "", + f"

Document: {document_id}

", + ] + + for page in data.get("pages", []): + if not isinstance(page, dict): + continue + + page_number = html.escape(str(page.get("page_number", "Unknown"))) + lines.append('
') + lines.append(f'') + + headers = page.get("headers", []) + if headers: + lines.append('
') + for header in headers: + lines.append( + f"
Header: {html.escape(str(header))}
" + ) + lines.append("

") + + for article in page.get("articles", []): + if not isinstance(article, dict): + continue + + headline = html.escape(str(article.get("headline", "Untitled Article"))) + lines.append('
') + lines.append(f'

{headline}

') + + for block in article.get("body_blocks", []): + lines.append(f'

{html.escape(str(block))}

') + + for index, image in enumerate(article.get("images", []), 1): + if not isinstance(image, dict): + continue + path = html.escape(str(image.get("path", ""))) + lines.append('
') + lines.append( + f"
Image {index}: {path}
" + ) + for caption in image.get("captions", []): + lines.append( + f'
Caption: {_caption_text(caption)}
' + ) + lines.append("
") + + captions = article.get("captions", []) + if captions: + lines.append('
') + for caption in captions: + lines.append( + f'
Caption: {_caption_text(caption)}
' + ) + lines.append("
") + + footnotes = article.get("footnotes", []) + if footnotes: + lines.append('
') + for footnote in footnotes: + lines.append( + f'
Footnote: {_caption_text(footnote)}
' + ) + lines.append("
") + + lines.append("
") # close article + + ads = page.get("ads", []) + if ads: + lines.append('
') + for ad in ads: + lines.append(f'
{html.escape(str(ad))}
') + lines.append("
") + + footers = page.get("footers", []) + if footers: + lines.append('") + + page_numbers = page.get("page_numbers", []) + if page_numbers: + lines.append( + '
' + ) + for pnum in page_numbers: + lines.append(f"Page No: {html.escape(str(pnum))} ") + lines.append("
") + + lines.append("
") # close page + + lines.extend(["", "", ""]) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> None: + """Run the JSON-to-HTML export CLI.""" + parser = argparse.ArgumentParser(description="Export a NewsDOM JSON file to HTML.") + parser.add_argument("input", type=Path, help="Path to the input JSON file.") + parser.add_argument( + "-o", + "--output", + type=Path, + help="Path to write HTML output. Defaults to stdout.", + ) + + args = parser.parse_args(argv) + + try: + input_data = json.loads(args.input.read_text(encoding="utf-8")) + html_content = generate_html(input_data) + if args.output is None: + print(html_content, end="") + else: + args.output.write_text(html_content, encoding="utf-8") + print(f"HTML written to {args.output}") + except Exception as exc: + print(f"Error exporting HTML: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main()