diff --git a/llm4papers/config.example.py b/llm4papers/config.example.py index f93382d..416f254 100644 --- a/llm4papers/config.example.py +++ b/llm4papers/config.example.py @@ -7,8 +7,12 @@ class OpenAIConfig(BaseSettings): class OverleafConfig(BaseSettings): + # Username and password for logging into your overleaf account username: str = "###" password: str = "###" + # Author name and email that should appear in git history + git_name: str = "AI assistant" + git_email: str = "dummy@agi.ai" class Settings(BaseSettings): diff --git a/llm4papers/paper_remote/OverleafGitPaperRemote.py b/llm4papers/paper_remote/OverleafGitPaperRemote.py index 7dda210..0323572 100644 --- a/llm4papers/paper_remote/OverleafGitPaperRemote.py +++ b/llm4papers/paper_remote/OverleafGitPaperRemote.py @@ -8,15 +8,47 @@ import shutil import datetime from urllib.parse import quote -from git import Repo # type: ignore - -from llm4papers.models import EditTrigger, EditResult, EditType, DocumentID, RevisionID +from git import Repo, GitCommandError # type: ignore +from typing import Iterable +import re + +from llm4papers.models import ( + EditTrigger, + EditResult, + EditType, + DocumentID, + RevisionID, + LineRange, +) from llm4papers.paper_remote.MultiDocumentPaperRemote import MultiDocumentPaperRemote from llm4papers.logger import logger +diff_line_edit_re = re.compile( + r"@{2,}\s*-(?P\d+),(?P\d+)\s*\+(?P\d+),(?P\d+)\s*@{2,}" +) + + +def _diff_to_ranges(diff: str) -> Iterable[LineRange]: + """Given a git diff, return LineRange object(s) indicating which lines in the + original document were changed. + """ + for match in diff_line_edit_re.finditer(diff): + git_line_number = int(match.group("new_line")) + git_line_count = int(match.group("new_count")) + # Git counts from 1 and gives (start, length), inclusive. LineRange counts from + # 0 and gives start:end indices (exclusive). + zero_index_start = git_line_number - 1 + yield zero_index_start, zero_index_start + git_line_count + + +def _ranges_overlap(a: LineRange, b: LineRange) -> bool: + """Given two LineRanges, return True if they overlap, False otherwise.""" + return not (a[1] < b[0] or b[1] < a[0]) + + def _too_close_to_human_edits( - repo: Repo, filename: str, line_number: int, last_n: int = 2 + repo: Repo, filename: str, line_range: LineRange, last_n: int = 2 ) -> bool: """ Determine if the line `line_number` of the file `filename` was changed in @@ -41,22 +73,19 @@ def _too_close_to_human_edits( logger.info(f"Last commit was {sec_since_last_commit}s ago, approving edit.") return False - # Get the diff for HEAD~n: + # Get the diff for HEAD~n. Note that the gitpython DiffIndex and Diff objects + # drop the line number info (!) so we can't use the gitpython object-oriented API + # to do this. Calling repo.git.diff is pretty much a direct pass-through to + # running "git diff HEAD~n -- " on the command line. total_diff = repo.git.diff(f"HEAD~{last_n}", filename, unified=0) - # Get the current repo state of that line: - current_line = repo.git.show(f"HEAD:{filename}").split("\n")[line_number] - - logger.debug("Diff: " + total_diff) - logger.debug("Current line: " + current_line) - - # Match the line in the diff: - if current_line in total_diff: - logger.info( - f"Found current line ({current_line[:10]}...) in diff, rejecting edit." - ) - return True - + for git_line_range in _diff_to_ranges(total_diff): + if _ranges_overlap(git_line_range, line_range): + logger.info( + f"Line range {line_range} overlaps with git-edited {git_line_range}, " + f"rejecting edit." + ) + return True return False @@ -77,6 +106,21 @@ def _add_auth(uri: str): return uri +def _add_git_user_from_config(repo: Repo) -> None: + try: + from llm4papers.config import OverleafConfig + + config = OverleafConfig() + repo.config_writer().set_value("user", "name", config.git_name).release() + repo.config_writer().set_value("user", "email", config.git_email).release() + except ImportError: + logger.debug("No config file found, assuming public repo.") + repo.config_writer().set_value("user", "name", "no-config").release() + repo.config_writer().set_value( + "user", "email", "no-config@placeholder.com" + ).release() + + class OverleafGitPaperRemote(MultiDocumentPaperRemote): """ Overleaf exposes a git remote for each project. This class handles reading @@ -84,8 +128,6 @@ class OverleafGitPaperRemote(MultiDocumentPaperRemote): PaperRemote protocol for use by the AI editor. """ - current_revision_id: RevisionID - def __init__(self, git_cached_repo: str): """ Saves the git repo to a local temporary directory using gitpython. @@ -100,6 +142,10 @@ def __init__(self, git_cached_repo: str): self._cached_repo: Repo | None = None self.refresh() + @property + def current_revision_id(self) -> RevisionID: + return self._get_repo().head.commit.hexsha + def _get_repo(self) -> Repo: if self._cached_repo is None: # TODO - this makes me anxious about race conditions. every time we refresh, @@ -119,7 +165,7 @@ def _doc_id_to_path(self, doc_id: DocumentID) -> pathlib.Path: # so we can cast to a string on this next line: return pathlib.Path(git_root) / str(doc_id) - def refresh(self): + def refresh(self, retry: bool = True): """ This is a fallback method (that likely needs some love) to ensure that the repo is up to date with the latest upstream changes. @@ -134,6 +180,7 @@ def refresh(self): ) self._cached_repo = Repo(f"/tmp/{self._reposlug}") + _add_git_user_from_config(self._cached_repo) logger.info(f"Pulling latest from repo {self._reposlug}.") try: @@ -143,15 +190,14 @@ def refresh(self): f"Latest change at {self._get_repo().head.commit.committed_datetime}" ) logger.info(f"Repo dirty: {self._get_repo().is_dirty()}") - self.current_revision_id = self._get_repo().head.commit.hexsha try: self._get_repo().git.stash("pop") - except Exception as e: + except GitCommandError as e: # TODO: this just means there was nothing to pop, but # we should handle this more gracefully. logger.debug(f"Nothing to pop: {e}") pass - except Exception as e: + except GitCommandError as e: logger.error( f"Error pulling from repo {self._reposlug}: {e}. " "Falling back on DESTRUCTION!!!" @@ -161,7 +207,10 @@ def refresh(self): self._cached_repo = None # recursively delete the repo shutil.rmtree(f"/tmp/{self._reposlug}") - self.refresh() + if retry: + self.refresh(retry=False) + else: + raise e def list_doc_ids(self) -> list[DocumentID]: """ @@ -196,14 +245,15 @@ def is_edit_ok(self, edit: EditTrigger) -> bool: # want to wait for the user to move on to the next line. for doc_range in edit.input_ranges + edit.output_ranges: repo_scoped_file = str(self._doc_id_to_path(doc_range.doc_id)) - for i in range(doc_range.selection[0], doc_range.selection[1]): - if _too_close_to_human_edits(self._get_repo(), repo_scoped_file, i): - logger.info( - f"Temporarily skipping edit request in {doc_range.doc_id}" - " at line {i} because it was still in progress" - " in the last commit." - ) - return False + if _too_close_to_human_edits( + self._get_repo(), repo_scoped_file, doc_range.selection + ): + logger.info( + f"Temporarily skipping edit request in {doc_range.doc_id}" + " at line {i} because it was still in progress" + " in the last commit." + ) + return False return True def to_dict(self): @@ -221,27 +271,30 @@ def perform_edit(self, edit: EditResult) -> bool: Returns: True if the edit was successful, False otherwise """ + if not self._doc_id_to_path(edit.range.doc_id).exists(): + logger.error(f"Document {edit.range.doc_id} does not exist.") + return False + logger.info(f"Performing edit {edit} on remote {self._reposlug}") - if edit.type == EditType.replace: - success = self._perform_replace(edit) - elif edit.type == EditType.comment: - success = self._perform_comment(edit) - else: - raise ValueError(f"Unknown edit type {edit.type}") + try: + with self.rewind(edit.range.revision_id, message="AI edit") as paper: + if edit.type == EditType.replace: + success = paper._perform_replace(edit) + elif edit.type == EditType.comment: + success = paper._perform_comment(edit) + else: + raise ValueError(f"Unknown edit type {edit.type}") + except GitCommandError as e: + logger.error( + f"Git error performing edit {edit} on remote {self._reposlug}: {e}" + ) + success = False if success: - # TODO - apply edit relative to the edit.range.revision_id commit and then - # rebase onto HEAD for poor-man's operational transforms - self._get_repo().index.add([self._doc_id_to_path(str(edit.range.doc_id))]) - self._get_repo().index.commit("AI edit completed.") - # Instead of just pushing, we need to rebase and then push. - # This is because we want to make sure that the AI edits are always - # on top of the stack. - self._get_repo().git.pull() - # TODO: We could do a better job catching WARNs here and then maybe setting - # success = False self._get_repo().git.push() + else: + self.refresh() return success @@ -257,6 +310,19 @@ def _perform_replace(self, edit: EditResult) -> bool: """ doc_range, text = edit.range, edit.content try: + num_lines = len(self.get_lines(doc_range.doc_id)) + if ( + any(i < 0 for i in doc_range.selection) + or doc_range.selection[1] < doc_range.selection[0] + or any( + i > len(self.get_lines(doc_range.doc_id)) + for i in doc_range.selection + ) + ): + raise IndexError( + f"Invalid selection {doc_range.selection} for document " + f"{doc_range.doc_id} with {num_lines} lines." + ) lines = self.get_lines(doc_range.doc_id) lines = ( lines[: doc_range.selection[0]] @@ -284,3 +350,49 @@ def _perform_comment(self, edit: EditResult) -> bool: # TODO - implement this for real logger.info(f"Performing comment edit {edit} on remote {self._reposlug}") return True + + def rewind(self, commit: str, message: str): + return self.RewindContext(self, commit, message) + + # Create an inner class for "with" semantics so that we can do + # `with remote.rewind(commit)` to rewind to a particular commit and play some edits + # onto it, then merge when the 'with' context exits. + class RewindContext: + # TODO - there are tricks in gitpython where an IndexFile can be used to + # handle changes to files in-memory without having to call checkout() and + # (briefly) modify the state of things on disk. This would be an improvement, + # but would require using the gitpython API more directly inside of + # perform_edit, such as calling git.IndexFile.write() instead of python's + # open() and write() + + def __init__(self, remote: "OverleafGitPaperRemote", commit: str, message: str): + self._remote = remote + self._message = message + self._rewind_commit = commit + + def __enter__(self): + repo = self._remote._get_repo() + self._restore_ref = repo.head.ref + self._new_branch = repo.create_head( + "tmp-edit-branch", commit=self._rewind_commit + ) + self._new_branch.checkout() + return self._remote + + def __exit__(self, exc_type, exc_val, exc_tb): + repo = self._remote._get_repo() + assert ( + repo.active_branch == self._new_branch + ), "Branch changed unexpectedly mid-`with`" + # Add files that changed + repo.index.add([_file for (_file, _), _ in repo.index.entries.items()]) + repo.index.commit(self._message) + self._restore_ref.checkout() + try: + repo.git.merge("tmp-edit-branch") + except GitCommandError as e: + # Hard reset on failure + repo.git.reset("--hard", self._restore_ref.commit.hexsha) + raise e + finally: + repo.delete_head(self._new_branch, force=True) diff --git a/llm4papers/paper_remote/test_OverleafGitPaperRemote.py b/llm4papers/paper_remote/test_OverleafGitPaperRemote.py new file mode 100644 index 0000000..58c5eb5 --- /dev/null +++ b/llm4papers/paper_remote/test_OverleafGitPaperRemote.py @@ -0,0 +1,303 @@ +from .OverleafGitPaperRemote import OverleafGitPaperRemote +from ..models import EditTrigger, EditResult, EditType, DocumentRange +import pytest +import git +from pathlib import Path +import shutil +import time + + +def _recursive_delete(path: Path): + """Delete a file or directory, recursively.""" + if path.is_file(): + path.unlink() + elif path.is_dir(): + for child in path.iterdir(): + _recursive_delete(child) + path.rmdir() + + +# Create a local git repo to test with. Upon the start of each test, copy the contents +# of +@pytest.fixture +def temporary_git_paper_repo(): + src = Path("test_data/dummy_overleaf_git_project/") + + # Remove the .git directory from the dummy project to make a fresh start. + git_root = src / ".git" + if git_root.exists(): + _recursive_delete(git_root) + + # Initialize src as a git repo and add user config to prevent git from complaining + # about missing user info. + repo = git.Repo.init(src, mkdir=False) + repo.config_writer().set_value("user", "name", "dummy").release() + repo.config_writer().set_value("user", "email", "dummy@agi.ai").release() + + # Create initial commit with (mostly empty) main.tex file, then add content to it by + # copying main-1.tex, main-2.tex, etc to main.tex. The purpose of this is to ensure + # that the git history has a few commits. + i = 0 + while (file := src / f"main-{i}.tex").exists(): + shutil.copyfile(file, src / "main.tex") + repo.git.add("main.tex") + repo.index.commit(f"Commit {i}") + i += 1 + + # If /tmp/dummy_overleaf_git_project exists, delete it, since this is where + # OverleafGitPaperRemote will clone the repo to. + dst = Path("/tmp/dummy_overleaf_git_project") + if dst.exists(): + _recursive_delete(dst) + + # Set it so that the 'origin' repo (in test_data) does not have any branches + # checked out, allowing 'push' from the /tmp/ clone to work. Do this by checking + # out the current commit directly (detached HEAD state) + repo.git.checkout(repo.head.commit.hexsha) + + # Use the file:// protocol explicitly so that the repo is "cloned" from the local + # filesystem, and adding file://username:password@src works + yield "file://" + str(src.resolve()) + + # Run this after the test is done (after yield) + (src / "main.tex").unlink() + repo.close() + _recursive_delete(src / ".git") + + +def test_merge_resolution_from_revision_id(temporary_git_paper_repo): + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + # Test that we can make *two* edits to different parts of the file by rebasing + # the second edit onto the first one. + + # First, add a line early in the paper (after \section{} before text) + lines = remote.get_lines("main.tex") + i_section = next(i for i, line in enumerate(lines) if r"\section" in line) + i_end_doc = next(i for i, line in enumerate(lines) if r"\end{document}" in line) + + edit1 = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(i_section + 2, i_section + 2), + ), + content="% This is a comment\n% that spans\n% multiple lines\n", + ) + + # Second, add some stuff at the end of the paper *after* the \end{document}, using + # the same revision ID as the first edit. Since the first edit spans multiple lines, + # this fails unless we rebase the second edit onto the first one. + edit2 = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(i_end_doc + 1, i_end_doc + 1), + ), + content="\n% This is a comment that should appear AFTER the end-document\n", + ) + + # Perform the two edits in succession + remote.perform_edit(edit1) + remote.perform_edit(edit2) + + # Confirm that both edits happened + lines = remote.get_lines("main.tex") + assert any("multiple lines" in line for line in lines) + assert any("appear AFTER" in line for line in lines) + + # Confirm that the second edit was rebased onto the first one by checking that the + # comment did indeed appear after the \end{document} + i_end_doc_2 = next(i for i, line in enumerate(lines) if r"\end{document}" in line) + i_second_edit = next(i for i, line in enumerate(lines) if "appear AFTER" in line) + assert i_second_edit > i_end_doc_2 + + +def test_recover_gracefully_from_merge_conflict(temporary_git_paper_repo): + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + + good_edit = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(20, 21), + ), + content="Hey look\nI'm overwriting\nsome stuff\nin the middle of the\ndoc.\n", + ) + + conflict_edit = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(20, 22), + ), + content="Hey so am I!\n", + ) + + i_end_doc = len(remote.get_lines("main.tex")) - 1 + good_edit_2 = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(i_end_doc + 1, i_end_doc + 1), + ), + content="\nThis line should appear somewhere at the end\n", + ) + + # Test that we can do good/conflict/good and get the two good edits to succeed + # while the conflict edit fails silently (logged but not raised) + remote.perform_edit(good_edit) + remote.perform_edit(conflict_edit) + remote.perform_edit(good_edit_2) + + lines = remote.get_lines("main.tex") + # Assert good_edit went through + assert any("I'm overwriting" in line for line in lines) + # Assert conflict_edit did not go through + assert not any("Hey so am I!" in line for line in lines) + # Assert good_edit_2 went through + assert any("This line should appear" in line for line in lines) + + +def test_recover_gracefully_from_bad_edit(temporary_git_paper_repo): + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + + good_edit = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(20, 21), + ), + content="Hey look\nI'm overwriting\nsome stuff\nin the middle of the\ndoc.\n", + ) + + # Edit is bad because file is far fewer than 100 lines long + bad_edit = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(100, 101), + ), + content="Hey so am I!\n", + ) + + # Edit is bad because file "dne.tex" does not exist + bad_edit_2 = EditResult( + type=EditType.replace, + range=DocumentRange( + doc_id="dne.tex", + revision_id=remote.current_revision_id, + selection=(0, 1), + ), + content="Blah blah blah\n", + ) + + # Test that we can do bad/bad/good and get just the good edit to succeed + remote.perform_edit(bad_edit) + remote.perform_edit(bad_edit_2) + remote.perform_edit(good_edit) + + lines = remote.get_lines("main.tex") + # Assert good_edit went through + assert any("I'm overwriting" in line for line in lines) + # Assert conflict_edit did not go through + assert not any("Hey so am I!" in line for line in lines) + + +def test_can_always_make_edits_to_overleaf_git_paper_remote(temporary_git_paper_repo): + """ + Confirm that edits can always be made to an in-memory paper remote. + + """ + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + assert remote.is_edit_ok( + EditTrigger( + input_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(0, 1), + ) + ], + output_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(0, 1), + ) + ], + request_text="Nothin'!", + ) + ) + + +def test_edit_ok_if_different_part_of_doc(temporary_git_paper_repo): + # Add a comment at the end. This doesn't overlap with anything in the document, so + # it should be immediately accepted. + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + num_lines = len(remote.get_lines("main.tex")) + new_last_line = "\n% --- end of document ---\n" + end_of_doc_comment_edit = EditTrigger( + input_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(num_lines, num_lines), + ) + ], + output_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(num_lines, num_lines), + ) + ], + request_text=new_last_line, + ) + assert remote.is_edit_ok(end_of_doc_comment_edit) + remote.perform_edit( + EditResult( + type=EditType.replace, + range=end_of_doc_comment_edit.output_ranges[0], + content=new_last_line, + ) + ) + assert remote.get_lines("main.tex")[-1].strip() == new_last_line.strip() + + +def test_edit_reject_or_accept_given_delay(temporary_git_paper_repo): + """ + Confirm that edits are rejected if "human edits" happened within the last 10s, but + accepted if they happened more than 10s ago. + """ + remote = OverleafGitPaperRemote(temporary_git_paper_repo) + # Find the line where the "\title{}" is, and make an edit to it. + lines = remote.get_lines("main.tex") + i_title = next(i for i, line in enumerate(lines) if r"\title" in line) + + title_edit = EditTrigger( + input_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(i_title, i_title + 1), + ) + ], + output_ranges=[ + DocumentRange( + doc_id="main.tex", + revision_id=remote.current_revision_id, + selection=(i_title, i_title + 1), + ) + ], + request_text=r"\title{A much snazzier title}\n", + ) + assert not remote.is_edit_ok(title_edit) + time.sleep(10) + assert remote.is_edit_ok(title_edit) diff --git a/test_data/dummy_overleaf_git_project/main-0.tex b/test_data/dummy_overleaf_git_project/main-0.tex new file mode 100644 index 0000000..bf36842 --- /dev/null +++ b/test_data/dummy_overleaf_git_project/main-0.tex @@ -0,0 +1,10 @@ +% Preamble +\documentclass[11pt]{article} + +% Packages +\usepackage{amsmath} + +% Document +\begin{document} + +\end{document} \ No newline at end of file diff --git a/test_data/dummy_overleaf_git_project/main-1.tex b/test_data/dummy_overleaf_git_project/main-1.tex new file mode 100644 index 0000000..ddff804 --- /dev/null +++ b/test_data/dummy_overleaf_git_project/main-1.tex @@ -0,0 +1,26 @@ +% Preamble +\documentclass[11pt]{article} + +% Packages +\usepackage{amsmath} + +% Document +\begin{document} + +\section{Quick fox, lazy dog} + +In a world not quite like our own, where animals could speak and had societies akin to those of humans, there lived a +quick brown fox named Felix and a lazy dog named Dudley. The two were known across the vast expanses of the Woodlands, +but for entirely different reasons. Felix, agile and swift, was a famed courier, his russet fur a blur as he darted +through the forest, delivering messages and parcels from the crow's nest in the towering pines to the rabbit warrens in +the cool, dark earth. Dudley, on the other hand, was known for his unmatched languor, a dog who preferred sunbathing on +the riverbank to any form of physical exertion, his only movement being the occasional flick of his tail or turn of his +head. One day, a challenge arose from the animal kingdom — a test of speed and agility. Felix, eager to prove his +prowess, was quick to accept. The challenge was simple: he had to leap over Dudley, who was sprawled in his usual spot +by the river. The whole of the Woodlands gathered, curious critters watching as Felix took a running start. His muscles +coiled, and with a burst of speed, he jumped, his silhouette briefly blocking out the sun. Gasps echoed as he cleared +Dudley with room to spare, landing gracefully on the other side. That day, the quick brown fox didn't just jump over the + lazy dog, he soared above him, further cementing his reputation in the annals of Woodland's history, while Dudley + merely yawned, rolled over, and resumed his nap under the warm sun. + +\end{document} \ No newline at end of file diff --git a/test_data/dummy_overleaf_git_project/main-2.tex b/test_data/dummy_overleaf_git_project/main-2.tex new file mode 100644 index 0000000..81a5e5c --- /dev/null +++ b/test_data/dummy_overleaf_git_project/main-2.tex @@ -0,0 +1,29 @@ +% Preamble +\documentclass[11pt]{article} + +% Packages +\usepackage{amsmath} + +\title{The Quick Brown Fox Jumps Over the Lazy Dog} +\author{GPT-4} + +% Document +\begin{document} + +\section{Quick fox, lazy dog} + +In a world not quite like our own, where animals could speak and had societies akin to those of humans, there lived a +quick brown fox named Felix and a lazy dog named Dudley. The two were known across the vast expanses of the Woodlands, +but for entirely different reasons. Felix, agile and swift, was a famed courier, his russet fur a blur as he darted +through the forest, delivering messages and parcels from the crow's nest in the towering pines to the rabbit warrens in +the cool, dark earth. Dudley, on the other hand, was known for his unmatched languor, a dog who preferred sunbathing on +the riverbank to any form of physical exertion, his only movement being the occasional flick of his tail or turn of his +head. One day, a challenge arose from the animal kingdom — a test of speed and agility. Felix, eager to prove his +prowess, was quick to accept. The challenge was simple: he had to leap over Dudley, who was sprawled in his usual spot +by the river. The whole of the Woodlands gathered, curious critters watching as Felix took a running start. His muscles +coiled, and with a burst of speed, he jumped, his silhouette briefly blocking out the sun. Gasps echoed as he cleared +Dudley with room to spare, landing gracefully on the other side. That day, the quick brown fox didn't just jump over the + lazy dog, he soared above him, further cementing his reputation in the annals of Woodland's history, while Dudley + merely yawned, rolled over, and resumed his nap under the warm sun. + +\end{document} \ No newline at end of file