From 6dfbf77ed2a7e62ab4cdde9f8430ab4f7efb0784 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:08:41 +0000 Subject: [PATCH] feat: add --rich flag to convert clipboard Markdown to rich text (HTML) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copied Markdown pastes as raw syntax into rich editors because the clipboard only carries a plain-text flavor. The new --rich/-r mode renders clipboard Markdown to a clean semantic HTML fragment and writes it back to the macOS pasteboard with both HTML and plain-text flavors, so pasting into Confluence, Google Docs, Gmail, or Slack produces formatted content while plain-text targets still get the original markdown. - New richtext module: markdown-it-py (CommonMark + GFM tables and strikethrough) plus a BeautifulSoup scrub that unwraps spans, strips style/class attributes, and maps code fence languages to Confluence's recognized set (js -> javascript, yml -> yaml, ...) - New clipboard.copy_rich_text_to_clipboard: AppleScript record with hex-encoded «class HTML» data (inverse of the existing HTML read path), script passed over stdin to avoid argv size limits - CLI: --rich branches before audio auto-detection; composes with --preview, secret scanning, and an optional filename to also save the HTML Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XHkRru9CypU7LFbyT9vdVX --- README.md | 17 ++++- pyproject.toml | 1 + src/clipdrop/clipboard.py | 95 +++++++++++++++++++++++ src/clipdrop/main.py | 112 ++++++++++++++++++++++++++- src/clipdrop/richtext.py | 136 +++++++++++++++++++++++++++++++++ tests/test_cli_rich.py | 130 +++++++++++++++++++++++++++++++ tests/test_clipboard.py | 78 ++++++++++++++++++- tests/test_richtext.py | 157 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 src/clipdrop/richtext.py create mode 100644 tests/test_cli_rich.py create mode 100644 tests/test_richtext.py diff --git a/README.md b/README.md index 78ef881..a753f9f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,21 @@ clipdrop --ocr --auto-name # Name a screenshot from its recognized text - The positional filename is optional; a provided extension is kept, otherwise format detection picks it - Falls back to a content-based slug when Apple Intelligence isn't available -### 8. ✍️ **Transform** (macOS 26.0+) - Writing Tools for the Terminal +### 8. 📋 **Rich Text Copy** - Paste Markdown into Confluence +Copied Markdown pastes as raw `# syntax` in rich editors. `--rich` converts it +to HTML and puts it back on the clipboard with both rich-text and plain-text +flavors, so it pastes *formatted* into Confluence, Google Docs, Gmail, or Slack: +```bash +# Copy some markdown, then: +clipdrop --rich # Convert → paste formatted anywhere +clipdrop -r -p # Preview the HTML first +clipdrop -r page # Also save it → page.html +``` +- Headings, lists, tables, links, and code blocks survive the paste +- Plain-text targets (and "Paste and Match Style") still get your original markdown +- Code fence languages are mapped to what Confluence recognizes (`js` → `javascript`) + +### 9. ✍️ **Transform** (macOS 26.0+) - Writing Tools for the Terminal Reshape copied text on the way to a file, entirely on-device: ```bash clipdrop email.txt --rewrite formal # restyle (concise/friendly/…) @@ -227,6 +241,7 @@ clipdrop -f # Force overwrite ```bash clipdrop -yt # YouTube transcript mode clipdrop --audio # Force audio transcription +clipdrop --rich # Markdown → rich text, back to the clipboard ``` ### Filters & Options diff --git a/pyproject.toml b/pyproject.toml index c74ca7b..8b38ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "requests>=2.34.2", "lxml>=6.1.1", "html2text>=2025.4.15", + "markdown-it-py>=3.0.0", ] [project.urls] diff --git a/src/clipdrop/clipboard.py b/src/clipdrop/clipboard.py index dd42fea..9a512b7 100644 --- a/src/clipdrop/clipboard.py +++ b/src/clipdrop/clipboard.py @@ -1,5 +1,7 @@ """Clipboard operations module for ClipDrop.""" +import subprocess +import sys import time from typing import Optional, Dict, Any import pyperclip @@ -27,6 +29,10 @@ # Maximum content size (100MB) MAX_CONTENT_SIZE = 100 * 1024 * 1024 +# Maximum HTML size for rich text clipboard writes (10MB); hex encoding +# doubles the osascript payload, so keep this well under MAX_CONTENT_SIZE +MAX_RICH_CONTENT_SIZE = 10 * 1024 * 1024 + def get_text() -> Optional[str]: """ @@ -275,6 +281,95 @@ def copy_to_clipboard(content: str) -> None: raise ClipboardAccessError("Cannot copy to clipboard", original_error=e) +def _escape_applescript_string(text: str) -> str: + """Escape text for embedding in a double-quoted AppleScript string.""" + text = text.replace('\r\n', '\n') + text = text.replace('\\', '\\\\') + text = text.replace('"', '\\"') + text = text.replace('\n', '\\n') + text = text.replace('\r', '\\n') + text = text.replace('\t', '\\t') + return text + + +def build_rich_clipboard_script(html: str, plain_text: str) -> str: + """ + Build an AppleScript that sets both HTML and plain-text clipboard flavors. + + The HTML is hex-encoded into a «data HTML…» literal (the inverse of the + read path in html_parser.get_html_from_clipboard), so it needs no + escaping. The text attribute supplies the plain-text flavor — without it + paste breaks entirely in apps like Slack and Firefox. + + Args: + html: HTML fragment for the rich text flavor + plain_text: Plain-text fallback (typically the original markdown) + + Returns: + AppleScript source string + """ + hex_html = html.encode('utf-8').hex().upper() + escaped_text = _escape_applescript_string(plain_text) + return ( + f'set the clipboard to ' + f'{{text:"{escaped_text}", «class HTML»:«data HTML{hex_html}»}}' + ) + + +def copy_rich_text_to_clipboard(html: str, plain_text: str) -> None: + """ + Copy rich text (HTML) to the macOS clipboard with a plain-text fallback. + + Sets both the public.html and plain-text pasteboard flavors so rich + editors (Confluence, Google Docs, Gmail, Slack) paste formatted content + while plain-text targets get the original source. + + Args: + html: HTML fragment for the rich text flavor + plain_text: Plain-text fallback (typically the original markdown) + + Raises: + ContentTooLargeError: If HTML exceeds MAX_RICH_CONTENT_SIZE + ClipboardAccessError: If not on macOS or osascript fails + """ + html_size = len(html.encode('utf-8')) + if html_size > MAX_RICH_CONTENT_SIZE: + raise ContentTooLargeError(html_size, MAX_RICH_CONTENT_SIZE) + + if sys.platform != 'darwin': + raise ClipboardAccessError( + "Rich text clipboard copy requires macOS" + ) + + script = build_rich_clipboard_script(html, plain_text) + try: + # Script over stdin: hex-encoded HTML can exceed argv size limits + result = subprocess.run( + ['osascript', '-'], + input=script.encode('utf-8'), + capture_output=True, + timeout=10 + ) + except subprocess.TimeoutExpired as e: + raise ClipboardAccessError( + "Timed out writing rich text to clipboard", original_error=e + ) + except OSError as e: + raise ClipboardAccessError( + "Cannot run osascript to write rich text", original_error=e + ) + + if result.returncode != 0: + stderr = result.stderr.decode('utf-8', errors='ignore').strip() + raise ClipboardAccessError( + f"Failed to write rich text to clipboard: {stderr or 'osascript error'}" + ) + + # Update cache with the plain-text flavor + _clipboard_cache['content'] = plain_text + _clipboard_cache['timestamp'] = time.time() + + # Image clipboard operations _image_cache: Dict[str, Any] = { 'image': None, diff --git a/src/clipdrop/main.py b/src/clipdrop/main.py index 4ad6371..e1c7b60 100644 --- a/src/clipdrop/main.py +++ b/src/clipdrop/main.py @@ -17,7 +17,7 @@ from rich.prompt import Confirm from clipdrop import __version__ -from clipdrop import clipboard, detect, files, images, pdf +from clipdrop import clipboard, detect, files, images, pdf, richtext from clipdrop.macos_ai import summarize_content, summarize_content_with_chunking from clipdrop.error_helpers import display_error, show_success_message from clipdrop.paranoid import ( @@ -26,6 +26,8 @@ print_binary_skip_notice, ) from clipdrop.exceptions import ( + ClipboardAccessError, + ContentTooLargeError, YTDLPNotFoundError, NoCaptionsError, YouTubeError @@ -1039,6 +1041,87 @@ def handle_youtube_transcript( raise typer.Exit(1) +def handle_rich_copy( + filename: Optional[str], + scan: bool, + paranoid_mode: Optional[ParanoidMode], + preview: bool, + force: bool, + yes: bool, +) -> None: + """Convert clipboard Markdown to rich text (HTML) and copy it back. + + Sets both HTML and plain-text clipboard flavors so pasting into rich + editors (Confluence, Google Docs, Gmail, Slack) produces formatted + content while plain-text targets still get the original markdown. + Optionally saves the HTML to a file when a filename is given. + """ + content = clipboard.get_text() + if not content or not content.strip(): + display_error('empty_clipboard') + raise typer.Exit(1) + + if not detect.is_markdown(content): + console.print( + "[yellow]⚠️ Clipboard content doesn't look like Markdown — " + "converting anyway[/yellow]" + ) + + # Scan the markdown source before it goes anywhere + active_paranoid = paranoid_mode or (ParanoidMode.PROMPT if scan else None) + if active_paranoid: + content, _ = paranoid_gate( + content, + active_paranoid, + is_tty=sys.stdin.isatty(), + auto_yes=yes + ) + if content is None: + console.print("[yellow]⚠️ Content not copied (paranoid mode)[/yellow]") + raise typer.Exit(0) + + try: + html = richtext.markdown_to_rich_html(content) + except ValueError as e: + console.print(f"[red]❌ {str(e)}[/red]") + raise typer.Exit(1) + + if preview: + console.print(Panel( + Syntax(html, "html", word_wrap=True), + title="Rich text (HTML) preview", + border_style="cyan" + )) + if not yes and not Confirm.ask( + "\n[yellow]Copy rich text to clipboard?[/yellow]" + ): + console.print("[yellow]Cancelled[/yellow]") + raise typer.Exit(0) + + try: + clipboard.copy_rich_text_to_clipboard(html, plain_text=content) + except (ContentTooLargeError, ClipboardAccessError) as e: + console.print(f"[red]❌ {str(e)}[/red]") + raise typer.Exit(1) + + if filename: + output_filename = filename if Path(filename).suffix else f"{filename}.html" + files.write_text(output_filename, html, force=force) + console.print(f"[green]💾 HTML saved to '{output_filename}'[/green]") + + html_size = len(html.encode('utf-8')) + size_str = ( + f"{html_size:,} bytes" if html_size < 1024 else f"{html_size/1024:.1f} KB" + ) + console.print(Panel( + "[green]✅ Rich text copied to clipboard[/green]\n" + "Paste into Confluence, Google Docs, Gmail, or Slack for " + "formatted content.\n" + f"[dim]HTML: {size_str} | Plain-text fallback: original markdown[/dim]", + border_style="green" + )) + + def main( filename: Optional[str] = typer.Argument( None, @@ -1170,6 +1253,15 @@ def main( "Outputs: .srt (subtitles), .txt (plain text), or .md (markdown). " "Requires macOS 26.0+ with Apple Intelligence" ), + rich: bool = typer.Option( + False, + "--rich", + "-r", + help="Convert clipboard Markdown to rich text (HTML) and copy it " + "back to the clipboard for pasting into Confluence, Google " + "Docs, Gmail, or Slack. Saves the HTML too when a filename " + "is given" + ), version: Optional[bool] = typer.Option( None, "--version", @@ -1208,6 +1300,11 @@ def main( clipdrop -yt --lang es # Spanish transcript clipdrop -yt --chapters # Include chapter markers + [green]Rich Text:[/green] + clipdrop --rich # Markdown → rich text, back to clipboard + clipdrop -r -p # Preview HTML before copying + clipdrop -r page # Also save the HTML → page.html + [green]Mixed Content:[/green] clipdrop document # Mixed text+image → document.pdf clipdrop content --text-only # Forces text mode @@ -1263,6 +1360,18 @@ def main( summarize=summarize, ) + # Check if this is rich text mode (before audio auto-detect so stale + # clipboard audio can't hijack the command) + if rich: + return handle_rich_copy( + filename=filename, + scan=paranoid_flag, + paranoid_mode=paranoid_mode, + preview=preview, + force=force, + yes=yes, + ) + # Check for audio in clipboard (with or without filename) try: from clipdrop.macos_ai import check_audio_in_clipboard @@ -1288,6 +1397,7 @@ def main( console.print(" clipdrop data.json # Save JSON") console.print(" clipdrop --youtube # Download YouTube transcript") console.print(" clipdrop --audio # Transcribe audio from clipboard") + console.print(" clipdrop --rich # Markdown → rich text clipboard") console.print(" clipdrop -yt output.srt # YouTube with custom name") console.print("\n[dim]Try 'clipdrop --help' for more options[/dim]") raise typer.Exit(1) diff --git a/src/clipdrop/richtext.py b/src/clipdrop/richtext.py new file mode 100644 index 0000000..e43d306 --- /dev/null +++ b/src/clipdrop/richtext.py @@ -0,0 +1,136 @@ +"""Markdown to rich text (HTML) conversion for clipboard pasting. + +Renders clipboard Markdown into a clean semantic HTML fragment that paste +targets like Confluence, Google Docs, Gmail, and Slack map cleanly into +their internal document models. +""" + +from bs4 import BeautifulSoup +from markdown_it import MarkdownIt + +# Languages Confluence's code macro recognizes; unknown languages fall back +# to plain text on paste, so unrecognized classes are stripped entirely. +CONFLUENCE_LANGUAGES = frozenset({ + 'actionscript3', 'applescript', 'bash', 'c', 'clojure', 'coldfusion', + 'cpp', 'csharp', 'css', 'delphi', 'diff', 'elixir', 'erlang', 'go', + 'graphql', 'groovy', 'haskell', 'html', 'java', 'javascript', 'json', + 'kotlin', 'livescript', 'lua', 'mathematica', 'matlab', 'objectivec', + 'perl', 'php', 'plaintext', 'powershell', 'python', 'qml', 'r', 'ruby', + 'rust', 'sass', 'scala', 'scheme', 'shell', 'sql', 'swift', 'text', + 'typescript', 'vb', 'xml', 'yaml', +}) + +LANGUAGE_ALIASES = { + 'js': 'javascript', + 'jsx': 'javascript', + 'ts': 'typescript', + 'tsx': 'typescript', + 'sh': 'bash', + 'zsh': 'bash', + 'shell-session': 'shell', + 'console': 'shell', + 'yml': 'yaml', + 'py': 'python', + 'python3': 'python', + 'rb': 'ruby', + 'golang': 'go', + 'c++': 'cpp', + 'c#': 'csharp', + 'cs': 'csharp', + 'objective-c': 'objectivec', + 'objc': 'objectivec', + 'md': 'text', + 'markdown': 'text', + 'txt': 'text', + 'plain': 'text', + 'htm': 'html', + 'xhtml': 'html', + 'svg': 'xml', + 'postgres': 'sql', + 'postgresql': 'sql', + 'mysql': 'sql', + 'sqlite': 'sql', + 'ps1': 'powershell', + 'dockerfile': 'bash', + 'makefile': 'bash', +} + +_md = MarkdownIt("commonmark").enable(["table", "strikethrough"]) + + +def render_markdown(md_text: str) -> str: + """ + Render Markdown to an HTML fragment. + + Args: + md_text: Markdown source text + + Returns: + HTML fragment string (no document wrapper) + """ + return _md.render(md_text) + + +def _normalize_code_language(class_list: list) -> list: + """Map a code element's classes to a single recognized language class.""" + for cls in class_list: + if not cls.startswith('language-'): + continue + lang = cls[len('language-'):].lower() + lang = LANGUAGE_ALIASES.get(lang, lang) + if lang in CONFLUENCE_LANGUAGES: + return [f'language-{lang}'] + return [] + + +def postprocess_html(html: str) -> str: + """ + Scrub rendered HTML for safe pasting into rich-text editors. + + Unwraps tags, strips style/class attributes (keeping only + recognized code-language classes on
), so editors like
+    Confluence's don't mangle the paste.
+
+    Args:
+        html: HTML fragment to clean
+
+    Returns:
+        Cleaned HTML fragment
+    """
+    # html.parser keeps the fragment as-is (lxml would add )
+    soup = BeautifulSoup(html, 'html.parser')
+
+    for span in soup.find_all('span'):
+        span.unwrap()
+
+    for tag in soup.find_all(True):
+        tag.attrs.pop('style', None)
+        classes = tag.attrs.pop('class', None)
+        if classes and tag.name == 'code' and tag.parent and tag.parent.name == 'pre':
+            normalized = _normalize_code_language(list(classes))
+            if normalized:
+                tag.attrs['class'] = normalized
+
+    return str(soup)
+
+
+def markdown_to_rich_html(md_text: str) -> str:
+    """
+    Convert Markdown to a Confluence-safe HTML fragment.
+
+    Args:
+        md_text: Markdown source text
+
+    Returns:
+        Cleaned HTML fragment
+
+    Raises:
+        ValueError: If input or rendered output is empty
+    """
+    if not md_text or not md_text.strip():
+        raise ValueError("No text content to convert")
+
+    html = postprocess_html(render_markdown(md_text))
+    if not html.strip():
+        raise ValueError("Markdown rendered to empty HTML")
+    return html
diff --git a/tests/test_cli_rich.py b/tests/test_cli_rich.py
new file mode 100644
index 0000000..94f2324
--- /dev/null
+++ b/tests/test_cli_rich.py
@@ -0,0 +1,130 @@
+"""Tests for the --rich CLI flag (Markdown → rich text clipboard)."""
+
+import os
+from unittest.mock import patch
+from typer.testing import CliRunner
+
+from clipdrop.main import app
+
+
+runner = CliRunner()
+
+SAMPLE_MD = "# Title\n\nSome **bold** text.\n"
+
+
+class TestRichFlag:
+    """Test --rich flag routing and behavior."""
+
+    @patch('clipdrop.clipboard.copy_rich_text_to_clipboard')
+    @patch('clipdrop.clipboard.get_text')
+    def test_rich_flag_copies_html(self, mock_get, mock_copy):
+        mock_get.return_value = SAMPLE_MD
+
+        result = runner.invoke(app, ["--rich"])
+
+        assert result.exit_code == 0
+        assert "Rich text copied" in result.stdout
+        html, = mock_copy.call_args.args
+        assert "

Title

" in html + assert "bold" in html + assert mock_copy.call_args.kwargs['plain_text'] == SAMPLE_MD + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_short_flag_works(self, mock_get, mock_copy): + mock_get.return_value = SAMPLE_MD + + result = runner.invoke(app, ["-r"]) + + assert result.exit_code == 0 + assert mock_copy.called + + @patch('clipdrop.clipboard.get_text') + def test_empty_clipboard_errors(self, mock_get): + mock_get.return_value = None + + result = runner.invoke(app, ["--rich"]) + + assert result.exit_code == 1 + assert "clipboard is empty" in result.stdout + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_non_markdown_warns_but_converts(self, mock_get, mock_copy): + mock_get.return_value = "just a plain sentence with no markdown at all" + + result = runner.invoke(app, ["--rich"]) + + assert result.exit_code == 0 + assert "doesn't look like Markdown" in result.stdout + assert mock_copy.called + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_filename_saves_html_file(self, mock_get, mock_copy, temp_directory): + mock_get.return_value = SAMPLE_MD + + cwd = os.getcwd() + os.chdir(temp_directory) + try: + result = runner.invoke(app, ["--rich", "page"]) + finally: + os.chdir(cwd) + + assert result.exit_code == 0 + output = temp_directory / "page.html" + assert output.exists() + assert "

Title

" in output.read_text() + assert mock_copy.called + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.macos_ai.check_audio_in_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_rich_branches_before_audio_detection( + self, mock_get, mock_audio, mock_copy + ): + """--rich must win even when the clipboard holds audio.""" + mock_get.return_value = SAMPLE_MD + mock_audio.return_value = True + + result = runner.invoke(app, ["--rich"]) + + assert result.exit_code == 0 + assert mock_copy.called + assert not mock_audio.called + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_clipboard_write_failure_errors(self, mock_get, mock_copy): + from clipdrop.exceptions import ClipboardAccessError + + mock_get.return_value = SAMPLE_MD + mock_copy.side_effect = ClipboardAccessError("osascript failed") + + result = runner.invoke(app, ["--rich"]) + + assert result.exit_code == 1 + assert "osascript failed" in result.stdout + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_preview_with_yes_skips_confirm(self, mock_get, mock_copy): + mock_get.return_value = SAMPLE_MD + + result = runner.invoke(app, ["--rich", "--preview", "--yes"]) + + assert result.exit_code == 0 + assert "preview" in result.stdout.lower() + assert mock_copy.called + + @patch('clipdrop.clipboard.copy_rich_text_to_clipboard') + @patch('clipdrop.clipboard.get_text') + def test_preview_decline_cancels(self, mock_get, mock_copy): + mock_get.return_value = SAMPLE_MD + + with patch('clipdrop.main.Confirm.ask', return_value=False): + result = runner.invoke(app, ["--rich", "--preview"]) + + assert result.exit_code == 0 + assert "Cancelled" in result.stdout + assert not mock_copy.called diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py index 26b2ce9..8e5d0ca 100644 --- a/tests/test_clipboard.py +++ b/tests/test_clipboard.py @@ -239,4 +239,80 @@ def test_large_content_performance(self, mock_clipboard, performance_timer): assert result == large_content # Should handle 10MB in reasonable time (< 0.5 seconds) - assert performance_timer.elapsed < 0.5 \ No newline at end of file + assert performance_timer.elapsed < 0.5 + +class TestRichTextClipboard: + """Tests for rich text (HTML) clipboard writing.""" + + def test_build_script_hex_round_trip(self): + """HTML in the script hex-decodes back to the original.""" + import re + html = "

Héllo 世界

" + script = clipboard.build_rich_clipboard_script(html, "fallback") + + match = re.search(r'«data HTML([0-9A-Fa-f]+)»', script) + assert match is not None + assert bytes.fromhex(match.group(1)).decode('utf-8') == html + + def test_build_script_escapes_plain_text(self): + """Plain-text part escapes quotes, backslashes, and newlines.""" + script = clipboard.build_rich_clipboard_script( + "

x

", 'say "hi"\nback\\slash' + ) + assert '\\"hi\\"' in script + assert '\\n' in script + assert '\\\\slash' in script + # No raw newlines may survive inside the single-line script + assert '\n' not in script + + def test_build_script_sets_both_flavors(self): + script = clipboard.build_rich_clipboard_script("

x

", "md source") + assert script.startswith('set the clipboard to {text:"') + assert '«class HTML»' in script + + @patch('clipdrop.clipboard.sys.platform', 'darwin') + @patch('clipdrop.clipboard.subprocess.run') + def test_copy_rich_text_invokes_osascript_via_stdin(self, mock_run): + mock_run.return_value.returncode = 0 + clipboard.copy_rich_text_to_clipboard("

Hi

", "# Hi") + + args, kwargs = mock_run.call_args + assert args[0] == ['osascript', '-'] + script = kwargs['input'].decode('utf-8') + assert '«class HTML»' in script + assert 'set the clipboard to' in script + + @patch('clipdrop.clipboard.sys.platform', 'darwin') + @patch('clipdrop.clipboard.subprocess.run') + def test_copy_rich_text_updates_cache(self, mock_run): + mock_run.return_value.returncode = 0 + clipboard.copy_rich_text_to_clipboard("

Hi

", "# Hi") + assert clipboard._clipboard_cache['content'] == "# Hi" + + @patch('clipdrop.clipboard.sys.platform', 'darwin') + @patch('clipdrop.clipboard.subprocess.run') + def test_copy_rich_text_osascript_failure(self, mock_run): + from clipdrop.exceptions import ClipboardAccessError + import pytest + + mock_run.return_value.returncode = 1 + mock_run.return_value.stderr = b"execution error" + + with pytest.raises(ClipboardAccessError): + clipboard.copy_rich_text_to_clipboard("

Hi

", "# Hi") + + @patch('clipdrop.clipboard.sys.platform', 'linux') + def test_copy_rich_text_requires_macos(self): + from clipdrop.exceptions import ClipboardAccessError + import pytest + + with pytest.raises(ClipboardAccessError, match="macOS"): + clipboard.copy_rich_text_to_clipboard("

Hi

", "# Hi") + + def test_copy_rich_text_content_too_large(self): + from clipdrop.exceptions import ContentTooLargeError + import pytest + + huge_html = "x" * (clipboard.MAX_RICH_CONTENT_SIZE + 1) + with pytest.raises(ContentTooLargeError): + clipboard.copy_rich_text_to_clipboard(huge_html, "fallback") diff --git a/tests/test_richtext.py b/tests/test_richtext.py new file mode 100644 index 0000000..100899a --- /dev/null +++ b/tests/test_richtext.py @@ -0,0 +1,157 @@ +"""Tests for Markdown to rich text (HTML) conversion.""" + +import pytest + +from clipdrop.richtext import ( + markdown_to_rich_html, + postprocess_html, + render_markdown, +) + + +class TestRenderMarkdown: + """Test Markdown rendering to HTML fragments.""" + + def test_headings(self): + html = render_markdown("# H1\n\n## H2\n\n### H3") + assert "

H1

" in html + assert "

H2

" in html + assert "

H3

" in html + + def test_emphasis(self): + html = render_markdown("**bold** and *italic*") + assert "bold" in html + assert "italic" in html + + def test_strikethrough(self): + html = render_markdown("~~gone~~") + assert "gone" in html + + def test_links(self): + html = render_markdown("[Example](https://example.com)") + assert 'Example' in html + + def test_unordered_list(self): + html = render_markdown("- one\n- two") + assert "
    " in html + assert "
  • one
  • " in html + + def test_ordered_list(self): + html = render_markdown("1. first\n2. second") + assert "
      " in html + assert "
    1. first
    2. " in html + + def test_nested_list(self): + html = render_markdown("- outer\n - inner") + assert html.count("
        ") == 2 + assert "inner" in html + + def test_blockquote(self): + html = render_markdown("> quoted") + assert "
        " in html + + def test_horizontal_rule(self): + html = render_markdown("---") + assert "
        " in html or "
        " in html + + def test_inline_code(self): + html = render_markdown("use `foo()` here") + assert "foo()" in html + + def test_fenced_code_with_language(self): + html = render_markdown("```python\nprint('hi')\n```") + assert '
        ' in html
        +
        +    def test_gfm_table(self):
        +        html = render_markdown("| A | B |\n|---|---|\n| 1 | 2 |")
        +        assert "" in html
        +        assert "" in html
        +        assert "" in html
        +        assert "" in html
        +
        +    def test_task_list_renders_as_plain_list(self):
        +        html = render_markdown("- [ ] todo\n- [x] done")
        +        assert "" in html
        +
        +
        +class TestPostprocessHtml:
        +    """Test the Confluence-safety HTML scrub."""
        +
        +    def test_unwraps_spans(self):
        +        html = postprocess_html('

        text

        ') + assert "text

        ') + assert "style=" not in html + + def test_strips_classes_outside_code(self): + html = postprocess_html('

        text

        ') + assert "class=" not in html + + def test_keeps_recognized_code_language(self): + html = postprocess_html( + '
        x
        ' + ) + assert 'class="language-python"' in html + + def test_maps_language_alias(self): + html = postprocess_html('
        x
        ') + assert 'class="language-javascript"' in html + + def test_strips_unknown_language(self): + html = postprocess_html( + '
        x
        ' + ) + assert "class=" not in html + + def test_inline_code_class_stripped(self): + # language classes only make sense on pre>code + html = postprocess_html('

        x

        ') + assert "class=" not in html + + +class TestMarkdownToRichHtml: + """Test the end-to-end conversion pipeline.""" + + def test_full_document(self, sample_markdown): + html = markdown_to_rich_html(sample_markdown) + assert "

        Test Document

        " in html + assert "Bold text" in html + assert 'Links' in html + assert '
        ' in html
        +        assert "
        " in html + assert "
        A1
        " in html + + def test_fragment_purity(self, sample_markdown): + html = markdown_to_rich_html(sample_markdown) + assert "just plain text

        " in html + + def test_empty_input_raises(self): + with pytest.raises(ValueError): + markdown_to_rich_html("") + + def test_whitespace_input_raises(self): + with pytest.raises(ValueError): + markdown_to_rich_html(" \n ") + + def test_unicode_content(self): + html = markdown_to_rich_html("# Héllo 世界 🌍") + assert "Héllo 世界 🌍" in html + + def test_raw_html_span_in_markdown_is_unwrapped(self): + html = markdown_to_rich_html( + 'text with styled html' + ) + assert "