202 lines · 7.3 KB
Raw Download
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("&ldquo;")
86
                i += 1
87
            elif text[i] == '”':  # Right double quote
88
                result.append("&rdquo;")
89
                i += 1
90
            elif text[i] == '‘':  # Left single quote
91
                result.append('&lsquo;')
92
                i += 1
93
            elif text[i] == '’':  # Right single quote
94
                result.append("&rsquo;")
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"&rsquo;\1", text)
107
108
        # Contractions (apostrophes in middle of words)
109
        text = re.sub(r"(\w)'(\w)", r"\1&rsquo;\2", text)
110
111
        # Possessives (apostrophes at end of words)
112
        text = re.sub(r"(\w)'(s\b|\s|$|[^\w])", r"\1&rsquo;\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("&rdquo;")
140
                        in_quote = False
141
                    else:
142
                        result.append("&ldquo;")
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&lsquo;", 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 + "&rsquo;"
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