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: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ jobs:
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create ${{ github.ref_name }} dist/* --generate-notes
TAG_NAME: ${{ github.ref_name }}
run: |
if gh release view "$TAG_NAME" > /dev/null 2>&1; then
gh release upload "$TAG_NAME" dist/* --clobber
else
gh release create "$TAG_NAME" dist/* --generate-notes
fi
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ format:
uv run ruff format src/

typecheck:
-uv run ty check
uv run ty check

setup-hooks:
git config core.hooksPath .githooks
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "leafpress"
version = "0.8.0"
version = "0.8.1"
description = "Convert MkDocs sites to PDF, Word, HTML, ODT, EPUB, and Markdown documents with branding"
readme = "README.md"
authors = [{name = "Shane Hutchins", email = "hutchins@users.noreply.github.com"}]
Expand Down
47 changes: 29 additions & 18 deletions src/leafpress/importer/converter_pptx.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

import re
from collections import Counter
from pathlib import Path

from pptx import Presentation
Expand Down Expand Up @@ -71,12 +71,15 @@ def import_pptx(
except Exception as e:
raise PptxImportError(f"Failed to open PPTX: {e}") from e

warnings: list[str] = []
skipped_counts: Counter[str] = Counter()
sections: list[str] = []
for slide_num, slide in enumerate(prs.slides, start=1):
slide_md = _convert_slide(slide, slide_num, image_handler, include_notes, warnings)
slide_md = _convert_slide(
slide, slide_num, image_handler, include_notes, skipped_counts
)
sections.append(slide_md)

warnings = _aggregate_warnings(skipped_counts)
markdown = "\n\n".join(sections)
markdown = postprocess_markdown(markdown)

Expand All @@ -91,12 +94,21 @@ def import_pptx(
)


def _aggregate_warnings(skipped_counts: Counter[str]) -> list[str]:
"""Build aggregated warning messages from per-type skip counts."""
warnings: list[str] = []
for label, count in sorted(skipped_counts.items()):
plural = "s" if count > 1 else ""
warnings.append(f"Presentation has {count} unsupported {label}{plural} — skipped")
return warnings


def _convert_slide(
slide,
slide_num: int,
image_handler: ImageHandler | None,
include_notes: bool,
warnings: list[str],
skipped_counts: Counter[str],
) -> str:
"""Convert a single slide to markdown."""
parts: list[str] = []
Expand All @@ -115,7 +127,7 @@ def _convert_slide(
for shape in slide.shapes:
if shape is title_shape:
continue
shape_md = _convert_shape(shape, image_handler, slide_label, warnings)
shape_md = _convert_shape(shape, image_handler, slide_label, skipped_counts)
if shape_md:
parts.append(shape_md)

Expand All @@ -140,11 +152,11 @@ def _convert_shape(
shape,
image_handler: ImageHandler | None,
slide_label: str,
warnings: list[str],
skipped_counts: Counter[str],
) -> str:
"""Convert a single shape to markdown."""
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
return _convert_group(shape, image_handler, slide_label, warnings)
return _convert_group(shape, image_handler, slide_label, skipped_counts)

if shape.shape_type == MSO_SHAPE_TYPE.TABLE:
return _table_to_markdown(shape.table)
Expand All @@ -156,12 +168,11 @@ def _convert_shape(
return _text_frame_to_markdown(shape.text_frame)

# Shapes with no text, table, image, or group content are skipped.
# Warn for shape types that may contain meaningful content.
# Count per type for aggregated warnings.
shape_type = shape.shape_type
if shape_type in _WARN_SHAPE_TYPES:
label = _WARN_SHAPE_TYPES[shape_type]
name = shape.name or "unnamed"
warnings.append(f"Unsupported {label} '{name}' on slide '{slide_label}' — skipped")
skipped_counts[label] += 1

return ""

Expand All @@ -170,12 +181,12 @@ def _convert_group(
group_shape,
image_handler: ImageHandler | None,
slide_label: str,
warnings: list[str],
skipped_counts: Counter[str],
) -> str:
"""Recursively convert shapes inside a group."""
parts: list[str] = []
for shape in group_shape.shapes:
md = _convert_shape(shape, image_handler, slide_label, warnings)
md = _convert_shape(shape, image_handler, slide_label, skipped_counts)
if md:
parts.append(md)
return "\n\n".join(parts)
Expand All @@ -187,7 +198,8 @@ def _convert_image(shape, image_handler: ImageHandler) -> str:
image_bytes = image.blob
content_type = image.content_type
src = image_handler.save_image(image_bytes, content_type)
return f"![]({src})"
alt = shape.name or ""
return f"![{alt}]({src})"


def _text_frame_to_markdown(text_frame) -> str:
Expand All @@ -214,15 +226,14 @@ def _runs_to_markdown(paragraph) -> str:
if not text:
continue

# Apply formatting
# Apply hyperlink first, then wrap with formatting so
# bold/italic are preserved: **[text](url)** instead of lost.
if run.hyperlink and run.hyperlink.address:
text = f"[{text}]({run.hyperlink.address})"
if run.font.bold:
text = f"**{text}**"
if run.font.italic:
text = f"*{text}*"
if run.hyperlink and run.hyperlink.address:
# Strip formatting wrappers to put inside link text
display = re.sub(r"^\*{1,2}(.*?)\*{1,2}$", r"\1", text)
text = f"[{display}]({run.hyperlink.address})"

parts.append(text)
return "".join(parts)
Expand Down
2 changes: 1 addition & 1 deletion src/leafpress/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def resolve_source(
def _clone_repo(url: str, branch: str | None) -> Path:
"""Clone a git repo to a temporary directory."""
tmp_dir = Path(tempfile.mkdtemp(prefix="leafpress_"))
clone_kwargs: dict[str, object] = {"depth": 50}
clone_kwargs: dict[str, str | int] = {"depth": 50}
if branch:
clone_kwargs["branch"] = branch

Expand Down
4 changes: 2 additions & 2 deletions tests/test_cibutler_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from leafpress.mkdocs_parser import MkDocsConfig, flatten_nav, parse_mkdocs_config
from leafpress.pdf.renderer import PdfRenderer

CIBUTLER_DOCS = Path("/Users/hutchins/projects/osdu/cibutler/docs")
CIBUTLER_REPO = Path("/Users/hutchins/projects/osdu/cibutler")
CIBUTLER_DOCS = Path("/Users/hutchins/projects/cibutler/docs")
CIBUTLER_REPO = Path("/Users/hutchins/projects/cibutler")

pytestmark = pytest.mark.skipif(
not CIBUTLER_DOCS.exists(),
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from pathlib import Path

from docx import Document as DocxDocument
from leafpress.cli import cli
from pptx import Presentation
from typer.testing import CliRunner

from leafpress.cli import cli

runner = CliRunner()


Expand Down
1 change: 1 addition & 0 deletions tests/test_docx_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

import pytest

from leafpress.config import WatermarkConfig, load_config
from leafpress.docx.renderer import DocxRenderer
from leafpress.git_info import extract_git_info
Expand Down
12 changes: 8 additions & 4 deletions tests/test_docx_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
from leafpress.docx.styles import _parse_hex_color, apply_branding_styles


def _make_branding(**kwargs: object) -> BrandingConfig:
defaults = {"company_name": "TestCo", "project_name": "TestProject"}
defaults.update(kwargs)
return BrandingConfig(**defaults)
def _make_branding(
primary_color: str = "#1a73e8",
) -> BrandingConfig:
return BrandingConfig(
company_name="TestCo",
project_name="TestProject",
primary_color=primary_color,
)


def test_apply_branding_styles() -> None:
Expand Down
14 changes: 10 additions & 4 deletions tests/test_html_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
from leafpress.html.styles import generate_html_css


def _make_branding(**kwargs: object) -> BrandingConfig:
defaults = {"company_name": "TestCo", "project_name": "TestProject"}
defaults.update(kwargs)
return BrandingConfig(**defaults)
def _make_branding(
primary_color: str = "#1a73e8",
accent_color: str = "#ffffff",
) -> BrandingConfig:
return BrandingConfig(
company_name="TestCo",
project_name="TestProject",
primary_color=primary_color,
accent_color=accent_color,
)


def test_generate_css_with_branding() -> None:
Expand Down
84 changes: 66 additions & 18 deletions tests/test_import_pptx.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,12 @@ def test_table_extraction(tmp_path: Path) -> None:


def test_image_extraction(tmp_path: Path) -> None:
"""Embedded images are extracted to assets/ and referenced in markdown."""
"""Embedded images are extracted to assets/ with alt text from shape name."""
pptx_path = _make_image_pptx(tmp_path)
result = import_pptx(pptx_path)
md = result.markdown_path.read_text()
assert "![](assets/" in md
assert "![" in md
assert "](assets/" in md
assert len(result.images) == 1
assert result.images[0].exists()

Expand Down Expand Up @@ -329,8 +330,9 @@ def test_runs_to_markdown_plain() -> None:
assert _runs_to_markdown(para) == "hello world"


def test_convert_shape_warns_on_chart() -> None:
"""Chart shapes produce a warning."""
def test_convert_shape_counts_chart() -> None:
"""Chart shapes increment the skipped counter."""
from collections import Counter
from unittest.mock import MagicMock

from leafpress.importer.converter_pptx import _convert_shape
Expand All @@ -339,30 +341,27 @@ def test_convert_shape_warns_on_chart() -> None:
shape.shape_type = MSO_SHAPE_TYPE.CHART
shape.name = "Chart 1"
shape.has_text_frame = False
warnings: list[str] = []
skipped_counts: Counter[str] = Counter()

result = _convert_shape(shape, None, "Revenue Slide", warnings)
result = _convert_shape(shape, None, "Revenue Slide", skipped_counts)
assert result == ""
assert len(warnings) == 1
assert "chart" in warnings[0].lower()
assert "Chart 1" in warnings[0]
assert "Revenue Slide" in warnings[0]
assert "skipped" in warnings[0].lower()
assert skipped_counts["chart"] == 1


def test_convert_shape_no_warn_on_freeform() -> None:
def test_convert_shape_no_count_on_freeform() -> None:
"""Freeform shapes (decorative lines, etc.) are silently skipped."""
from collections import Counter
from unittest.mock import MagicMock

from leafpress.importer.converter_pptx import _convert_shape

shape = MagicMock()
shape.shape_type = MSO_SHAPE_TYPE.FREEFORM
shape.has_text_frame = False
warnings: list[str] = []
skipped_counts: Counter[str] = Counter()

_convert_shape(shape, None, "Slide 1", warnings)
assert len(warnings) == 0
_convert_shape(shape, None, "Slide 1", skipped_counts)
assert len(skipped_counts) == 0


def test_runs_to_markdown_hyperlink() -> None:
Expand All @@ -382,7 +381,7 @@ def test_runs_to_markdown_hyperlink() -> None:


def test_runs_to_markdown_bold_hyperlink() -> None:
"""Bold text with a hyperlink unwraps formatting for the link text."""
"""Bold text with a hyperlink preserves both formatting and link."""
from unittest.mock import MagicMock

para = MagicMock()
Expand All @@ -394,7 +393,56 @@ def test_runs_to_markdown_bold_hyperlink() -> None:
para.runs = [run]

md = _runs_to_markdown(para)
assert "[bold link](https://example.com)" in md
assert "**[bold link](https://example.com)**" in md


def test_runs_to_markdown_italic_hyperlink() -> None:
"""Italic text with a hyperlink preserves both formatting and link."""
from unittest.mock import MagicMock

para = MagicMock()
run = MagicMock()
run.text = "italic link"
run.font.bold = False
run.font.italic = True
run.hyperlink.address = "https://example.com"
para.runs = [run]

md = _runs_to_markdown(para)
assert "*[italic link](https://example.com)*" in md


def test_aggregate_warnings_multiple_charts() -> None:
"""Multiple charts across slides produce a single aggregated warning."""
from collections import Counter
from unittest.mock import MagicMock

from leafpress.importer.converter_pptx import _aggregate_warnings, _convert_shape

skipped_counts: Counter[str] = Counter()
for i in range(3):
shape = MagicMock()
shape.shape_type = MSO_SHAPE_TYPE.CHART
shape.name = f"Chart {i}"
shape.has_text_frame = False
_convert_shape(shape, None, f"Slide {i}", skipped_counts)

warnings = _aggregate_warnings(skipped_counts)
assert len(warnings) == 1
assert "3 unsupported charts" in warnings[0]


def test_aggregate_warnings_mixed_types() -> None:
"""Different unsupported types each get their own aggregated warning."""
from collections import Counter

from leafpress.importer.converter_pptx import _aggregate_warnings

skipped_counts: Counter[str] = Counter({"chart": 2, "media": 1})
warnings = _aggregate_warnings(skipped_counts)
assert len(warnings) == 2
assert any("2 unsupported charts" in w for w in warnings)
assert any("1 unsupported media" in w for w in warnings)


def test_runs_to_markdown_empty_run() -> None:
Expand Down Expand Up @@ -587,7 +635,7 @@ def test_image_extracted(self) -> None:
"""Embedded image extracted to assets/."""
assert len(self.result.images) >= 1
assert self.result.images[0].exists()
assert "![](assets/" in self.content
assert "](assets/" in self.content

def test_slide_count(self) -> None:
"""All 5 slides appear in output."""
Expand Down
2 changes: 1 addition & 1 deletion tests/test_markdown_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def test_extension_load_failure_includes_error_message(sample_mkdocs_dir: Path)
)
failed = [(ext, ok, msg) for ext, ok, msg in renderer.extension_load_results if not ok]
assert len(failed) >= 1
ext, ok, msg = failed[0]
ext, _ok, msg = failed[0]
assert ext == "nonexistent_extension_xyz"
assert msg != ""

Expand Down
Loading
Loading