| 1 |
import sublime |
| 2 |
import sublime_plugin |
| 3 |
import re |
| 4 |
|
| 5 |
|
| 6 |
class SmartQuoteReplacerCommand(sublime_plugin.TextCommand): |
| 7 |
def run(self, edit): |
| 8 |
# Get the entire content or selected text |
| 9 |
# Check if there's a selection by looking at the selection regions |
| 10 |
sel = self.view.sel() |
| 11 |
if len(sel) == 1 and sel[0].empty(): |
| 12 |
# No selection, process entire document |
| 13 |
regions = [sublime.Region(0, self.view.size())] |
| 14 |
else: |
| 15 |
# There is a selection, process only selected regions |
| 16 |
regions = list(sel) |
| 17 |
|
| 18 |
for region in regions: |
| 19 |
text = self.view.substr(region) |
| 20 |
new_text = self.replace_quotes_preserve_html_and_css(text) |
| 21 |
if new_text != text: |
| 22 |
self.view.replace(edit, region, new_text) |
| 23 |
|
| 24 |
def replace_quotes_preserve_html_and_css(self, text): |
| 25 |
# First, protect style and script blocks by temporarily replacing them |
| 26 |
style_blocks = [] |
| 27 |
script_blocks = [] |
| 28 |
|
| 29 |
# Extract and store style blocks |
| 30 |
def store_style(match): |
| 31 |
style_blocks.append(match.group(0)) |
| 32 |
return f"__STYLE_BLOCK_{len(style_blocks)-1}__" |
| 33 |
|
| 34 |
def store_script(match): |
| 35 |
script_blocks.append(match.group(0)) |
| 36 |
return f"__SCRIPT_BLOCK_{len(script_blocks)-1}__" |
| 37 |
|
| 38 |
# Temporarily replace style and script blocks |
| 39 |
text = re.sub( |
| 40 |
r"<style[^>]*>.*?</style>", |
| 41 |
store_style, |
| 42 |
text, |
| 43 |
flags=re.DOTALL | re.IGNORECASE, |
| 44 |
) |
| 45 |
text = re.sub( |
| 46 |
r"<script[^>]*>.*?</script>", |
| 47 |
store_script, |
| 48 |
text, |
| 49 |
flags=re.DOTALL | re.IGNORECASE, |
| 50 |
) |
| 51 |
|
| 52 |
# First, convert any existing smart quotes (curly quotes) to HTML entities |
| 53 |
text = self.convert_smart_quotes_to_entities(text) |
| 54 |
|
| 55 |
# Now process straight quotes while preserving HTML tags |
| 56 |
text = self.replace_quotes_smart(text) |
| 57 |
|
| 58 |
# Restore style blocks |
| 59 |
for i, style_block in enumerate(style_blocks): |
| 60 |
text = text.replace(f"__STYLE_BLOCK_{i}__", style_block) |
| 61 |
|
| 62 |
# Restore script blocks |
| 63 |
for i, script_block in enumerate(script_blocks): |
| 64 |
text = text.replace(f"__SCRIPT_BLOCK_{i}__", script_block) |
| 65 |
|
| 66 |
return text |
| 67 |
|
| 68 |
def convert_smart_quotes_to_entities(self, text): |
| 69 |
"""Convert existing curly/smart quotes to HTML entities outside of HTML tags""" |
| 70 |
result = [] |
| 71 |
i = 0 |
| 72 |
|
| 73 |
while i < len(text): |
| 74 |
if text[i] == "<": |
| 75 |
# Start of HTML tag, find the end |
| 76 |
tag_end = text.find(">", i) |
| 77 |
if tag_end != -1: |
| 78 |
# Include the entire tag without modification |
| 79 |
result.append(text[i : tag_end + 1]) |
| 80 |
i = tag_end + 1 |
| 81 |
else: |
| 82 |
result.append(text[i]) |
| 83 |
i += 1 |
| 84 |
elif text[i] == '“': # Left double quote |
| 85 |
result.append("“") |
| 86 |
i += 1 |
| 87 |
elif text[i] == '”': # Right double quote |
| 88 |
result.append("”") |
| 89 |
i += 1 |
| 90 |
elif text[i] == '‘': # Left single quote |
| 91 |
result.append('‘') |
| 92 |
i += 1 |
| 93 |
elif text[i] == '’': # Right single quote |
| 94 |
result.append("’") |
| 95 |
i += 1 |
| 96 |
else: |
| 97 |
result.append(text[i]) |
| 98 |
i += 1 |
| 99 |
|
| 100 |
return "".join(result) |
| 101 |
|
| 102 |
def replace_quotes_smart(self, text): |
| 103 |
# Handle apostrophes first (before quote processing) |
| 104 |
|
| 105 |
# Graduation years (apostrophe before 2 digits) |
| 106 |
text = re.sub(r"'(\d{2})(?=\W|$)", r"’\1", text) |
| 107 |
|
| 108 |
# Contractions (apostrophes in middle of words) |
| 109 |
text = re.sub(r"(\w)'(\w)", r"\1’\2", text) |
| 110 |
|
| 111 |
# Possessives (apostrophes at end of words) |
| 112 |
text = re.sub(r"(\w)'(s\b|\s|$|[^\w])", r"\1’\2", text) |
| 113 |
|
| 114 |
# Now handle quotes with HTML awareness |
| 115 |
# This approach tracks quote state while skipping over HTML tags |
| 116 |
|
| 117 |
# Handle double quotes |
| 118 |
text = self.replace_double_quotes_html_aware(text) |
| 119 |
|
| 120 |
# Handle single quotes (that aren't apostrophes) |
| 121 |
text = self.replace_single_quotes_html_aware(text) |
| 122 |
|
| 123 |
return text |
| 124 |
|
| 125 |
def replace_double_quotes_html_aware(self, text): |
| 126 |
result = [] |
| 127 |
in_quote = False |
| 128 |
i = 0 |
| 129 |
|
| 130 |
while i < len(text): |
| 131 |
if text[i] == '"': |
| 132 |
# Check if we're inside an HTML tag by looking backwards and forwards |
| 133 |
if self.is_inside_html_tag(text, i): |
| 134 |
# We're inside an HTML tag, don't replace |
| 135 |
result.append(text[i]) |
| 136 |
else: |
| 137 |
# We're in text content, replace the quote |
| 138 |
if in_quote: |
| 139 |
result.append("”") |
| 140 |
in_quote = False |
| 141 |
else: |
| 142 |
result.append("“") |
| 143 |
in_quote = True |
| 144 |
i += 1 |
| 145 |
elif text[i] == "<": |
| 146 |
# Start of HTML tag, find the end |
| 147 |
tag_end = text.find(">", i) |
| 148 |
if tag_end != -1: |
| 149 |
# Include the entire tag |
| 150 |
result.append(text[i : tag_end + 1]) |
| 151 |
i = tag_end + 1 |
| 152 |
else: |
| 153 |
result.append(text[i]) |
| 154 |
i += 1 |
| 155 |
else: |
| 156 |
result.append(text[i]) |
| 157 |
i += 1 |
| 158 |
|
| 159 |
return "".join(result) |
| 160 |
|
| 161 |
def replace_single_quotes_html_aware(self, text): |
| 162 |
# For single quotes, we need to be more careful since they're often apostrophes |
| 163 |
# Only replace quotes that look like actual quotation marks |
| 164 |
|
| 165 |
# Opening single quote: after whitespace, punctuation, or start of string/line |
| 166 |
text = re.sub(r"(^|\s|[(\[{—-])'(?=\w)", r"\1‘", text) |
| 167 |
|
| 168 |
# Closing single quote: after word characters, before whitespace/punctuation/end |
| 169 |
# But avoid replacing if we're inside an HTML tag |
| 170 |
def replace_closing_quote(match): |
| 171 |
full_match = match.group(0) |
| 172 |
before = match.group(1) |
| 173 |
|
| 174 |
# Simple check: if the quote is followed by common HTML attribute patterns, skip it |
| 175 |
remaining_text = text[match.end() :] |
| 176 |
if re.match(r"\s*[=>]", remaining_text): |
| 177 |
return full_match # Likely inside HTML, don't replace |
| 178 |
|
| 179 |
return before + "’" |
| 180 |
|
| 181 |
text = re.sub(r"(\w)'(?=\s|[.,;:!?)}\]—-]|$)", replace_closing_quote, text) |
| 182 |
|
| 183 |
return text |
| 184 |
|
| 185 |
def is_inside_html_tag(self, text, pos): |
| 186 |
# Look backwards for the most recent < or > |
| 187 |
last_open = text.rfind("<", 0, pos) |
| 188 |
last_close = text.rfind(">", 0, pos) |
| 189 |
|
| 190 |
# If we found a < more recently than >, we're inside a tag |
| 191 |
return last_open > last_close |
| 192 |
|
| 193 |
|
| 194 |
class SmartQuoteReplacerListener(sublime_plugin.EventListener): |
| 195 |
def on_modified_async(self, view): |
| 196 |
# Optional: Auto-replace as you type |
| 197 |
# Uncomment the following lines if you want real-time replacement |
| 198 |
pass |
| 199 |
# settings = view.settings() |
| 200 |
# if settings.get('smart_quote_auto_replace', False): |
| 201 |
# view.run_command('smart_quote_replacer') |
| 202 |
|