Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 도구를 추가했습니다.
Expand Down
8 changes: 4 additions & 4 deletions tests/test_repository_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
178 changes: 178 additions & 0 deletions tests/test_tools_export_html.py
Original file line number Diff line number Diff line change
@@ -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 <Test>",
"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 "<title>test_doc_1 &amp; &lt; &gt;</title>" in html_out
assert "<h1>Document: test_doc_1 &amp; &lt; &gt;</h1>" in html_out

# Page and Header
assert "<h2>Page 1</h2>" in html_out
assert "<strong>Header:</strong> Top Header &amp; More" in html_out

# Article
assert '<h3 class="article-headline">Main Article &lt;Test&gt;</h3>' in html_out
assert '<p class="body-block">This is paragraph 1.</p>' in html_out
assert '<p class="body-block">This is paragraph 2 &amp; stuff.</p>' in html_out

# Images and Captions
assert "<code>image1.png</code>" in html_out
assert "Caption: Image 1 Caption &amp;" in html_out
assert "Caption: Article Level Caption" in html_out
assert "Footnote: Article Footnote 1" in html_out

# Ads, Footers, Page Numbers
assert '<div class="ad-block">Ad Content 1 &amp; Co.</div>' in html_out
assert "Bottom Footer</div>" in html_out
assert "Page No: I</span>" in html_out

# Empty Page
assert "<h2>Page 2</h2>" in html_out

assert html_out.endswith("</html>\n")


def test_generate_html_empty_data() -> None:
html_out = generate_html({})

assert "<h1>Document: Unknown Document</h1>" 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 "<h2>Page Unknown</h2>" 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 &amp;" in html_out
assert "Caption: article caption &lt;" in html_out
assert "Footnote: plain footnote &gt;" 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 "<h1>Document: test_doc_1 &amp; &lt; &gt;</h1>" 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 "<h1>Document: test_doc_1 &amp; &lt; &gt;</h1>" 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
165 changes: 165 additions & 0 deletions tools/export_html.py
Original file line number Diff line number Diff line change
@@ -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] = [
"<!DOCTYPE html>",
'<html lang="en">',
"<head>",
'<meta charset="UTF-8">',
f"<title>{document_id}</title>",
f"<style>{css}</style>",
"</head>",
"<body>",
f"<h1>Document: {document_id}</h1>",
]

for page in data.get("pages", []):
if not isinstance(page, dict):
continue

page_number = html.escape(str(page.get("page_number", "Unknown")))
lines.append('<div class="page">')
lines.append(f'<div class="page-header"><h2>Page {page_number}</h2></div>')

headers = page.get("headers", [])
if headers:
lines.append('<div class="headers">')
for header in headers:
lines.append(
f"<div><strong>Header:</strong> {html.escape(str(header))}</div>"
)
lines.append("</div><hr>")

for article in page.get("articles", []):
if not isinstance(article, dict):
continue

headline = html.escape(str(article.get("headline", "Untitled Article")))
lines.append('<div class="article">')
lines.append(f'<h3 class="article-headline">{headline}</h3>')

for block in article.get("body_blocks", []):
lines.append(f'<p class="body-block">{html.escape(str(block))}</p>')

for index, image in enumerate(article.get("images", []), 1):
if not isinstance(image, dict):
continue
path = html.escape(str(image.get("path", "")))
lines.append('<div class="image-container">')
lines.append(
f"<div><strong>Image {index}:</strong> <code>{path}</code></div>"
)
for caption in image.get("captions", []):
lines.append(
f'<div class="caption">Caption: {_caption_text(caption)}</div>'
)
lines.append("</div>")

captions = article.get("captions", [])
if captions:
lines.append('<div class="captions">')
for caption in captions:
lines.append(
f'<div class="caption">Caption: {_caption_text(caption)}</div>'
)
lines.append("</div>")

footnotes = article.get("footnotes", [])
if footnotes:
lines.append('<div class="footnotes">')
for footnote in footnotes:
lines.append(
f'<div class="footnote">Footnote: {_caption_text(footnote)}</div>'
)
lines.append("</div>")

lines.append("</div>") # close article

ads = page.get("ads", [])
if ads:
lines.append('<div class="ads">')
for ad in ads:
lines.append(f'<div class="ad-block">{html.escape(str(ad))}</div>')
lines.append("</div>")

footers = page.get("footers", [])
if footers:
lines.append('<div class="footer-block">')
for footer in footers:
lines.append(f"<div>{html.escape(str(footer))}</div>")
lines.append("</div>")

page_numbers = page.get("page_numbers", [])
if page_numbers:
lines.append(
'<div class="page-numbers" style="text-align: right; margin-top: 1rem;">'
)
for pnum in page_numbers:
lines.append(f"<span>Page No: {html.escape(str(pnum))}</span> ")
lines.append("</div>")

lines.append("</div>") # close page

lines.extend(["</body>", "</html>", ""])
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()
Loading