在Python中用正则表达式替换时排除特定字符串区间

0 投票
2 回答
56 浏览
提问于 2025-04-14 16:53

我遇到了一个问题。

假设有一个比较长且复杂的文本文件,其中包含一些用特定符号(可以是空的)包围的特殊块:

some text
----
text inside special block
----
some text
----
----
some text
----
text inside special block
----

我的任务是对整个文本进行一些替换,但要排除这些边界(----)内的文本。

举个例子,我们需要把所有的 text 替换成 TEXT,但不包括特殊块内的内容。最终的结果应该是:

some TEXT
----
text inside special block
----
some TEXT
----
----
some TEXT
----
text inside special block
----

在这里我们不能使用前瞻或后顾,因为在特定位置我们不知道自己是否在特殊块内(边界没有方向)。

所以我解决这个问题的方法是,首先解析整个文本,找到特殊块的边界,然后获取“坏”行的索引,接着逐行应用我的正则替换,检查这一行是否不是“坏”行。但是如果我的正则需要应用于多行,那就变得更复杂了。我相信有一些聪明又简单的方法可以处理这个问题。

基本上,我需要的是能够在对整个文本应用 re.sub 时,排除一些文本片段(通过它们的范围)。即使正则表达式只与范围相交(不一定包含它)。这样我就可以先应用第一个正则,获取特殊块的起始和结束索引,然后将这些范围从第二个正则中排除。这个怎么实现呢?

现在我有这个解决方案(上面的例子是简化过的,抱歉):

def find_code_lines(data):
    # Search for blocks by regex They can be empty!
    r = re.compile(r'(\n----(?=\n)(?P<group1>[\s\S]*?\n)----\n)')
    
    # Delete all '\n' which are not line breaks (there are some of them in formulas etc.)
    data_edited = data.replace('\\n', '')

    # Save spans by symbol indexes
    char_spans = []
    for m in r.finditer(data_edited):
        #print(m.span(1))
        #print(m.span[1])
        char_spans.append(m.span(1))
    
    # Calculate spans by line indexes
    line_spans = []
    for span in char_spans:
        begin = data_edited[:span[0]].count("\n") + 2
        end = data_edited[:span[1]].count("\n") - 1
        line_spans.append((begin, end))
        
    return line_spans

# Check if index is inside one of spans
def in_spans(spans, line_index):
    res = False
    for span in spans:
        if line_index >= span[0] and line_index < span[1]:
            res = True
    return res

# Parse text by blocks
code_lines = find_code_lines(data)

lines_edited = []
data_lines = data.splitlines()
replace_count = 0
for i in range(len(data_lines)):
    if in_spans(code_lines, i):
        lines_edited.append(data_lines[i])
        #print('line in spans:', i)
    else:
        data_tuple = re.subn(r'(?P<group1>\s|^|\s\()\$(?P<group2>[^\$`\r\n]{1,1000}?)\$',
                            r'\1stem:[\2]', 
                            data_lines[i])
        if data_tuple[1] == 0:
            lines_edited.append(data_lines[i])
        else:
            lines_edited.append(data_tuple[0])
            replace_count += data_tuple[1]
lines_edited.append('')
data = '\n'.join(lines_edited)
log_it('Replaced Math blocks', replace_count)

更新

我在输入示例中添加了更多文本,因为下面的一些解决方案只能处理特定版本的输入(这些输入比较简单)。到目前为止,最复杂的情况是这样的:

some text
----
text inside special block
----
some text
----
----
some text
----
text inside special block
----
some text

期望的输出:

some TEXT
----
text inside special block
----
some TEXT
----
----
some TEXT
----
text inside special block
----
some TEXT

2 个回答

1

一种可能的方法是使用分隔符的正则表达式创建一个位置数组,然后在这些位置之间进行不同的替换,像这样:

import re


def replace(data, separator_re, replace_outside_special_func):
  poss = [0]
  for match in re.finditer(separator_re, data):
    poss.extend((match.start(), match.end()))
  poss.append(len(data))
  return ''.join(
      data[poss[i] : poss[i + 1]] if i & 3
      else replace_outside_special_func(data[poss[i] : poss[i + 1]])
      for i in range(0, len(poss) - 1))


data = '''some text
----
text inside special block
----
some text
'''
print(replace(
    data, r'(?m)^----$',
    lambda data: data.replace('text', 'TEXT')))

它会输出:

some TEXT
----
text inside special block
----
some TEXT

一些解释:

  • 我们使用 re.finditermatch.start() 以及 match.end() 来找到每个分隔符的开始和结束位置(在字符串中的索引)。我们把这些位置按顺序保存到数组 poss 中。
  • 我们还在 poss 的开头加上 0,在结尾加上 len(data),这样可以确保不会漏掉输入字符串开头和结尾附近的任何块。
  • 我们遍历 poss 中相邻位置标记的每个子字符串,比如 data[poss[0] : poss[1]]data[poss[1] : poss[2]]data[poss[2] : poss[3]] 等等。
  • data[poss[i] : poss[i + 1]] 的内容是:
    • 如果 i % 4 == 0:普通块,比如 'some text\n'
    • 如果 i % 4 == 1:普通块后的分隔符,比如 '----'
    • 如果 i % 4 == 2:特殊块,比如 '\ntext inside special block\n'
    • 如果 i % 4 == 3:特殊块后的分隔符,比如 '----'
  • 我们只在普通块中进行替换。

这里有一个更简短的解决方案:

import re

data = '''some text
----
text inside special block
----
some text
'''
print(re.sub(
    r'(?s)(.*?)(\Z|\n(?:\Z|----(?:\Z|\n.*?(?:\Z|\n(?:\Z|----(?:\Z|\n))))))',
    lambda match: match.group(1).replace('text', 'TEXT') + match.group(2),
    data))

我觉得这个更简短的解决方案可能会慢一些。你可能想在长输入上运行一些基准测试。

一些解释:

  • (?s) 设置了 s 标志,这样 . 就可以匹配任何字符,包括 \n
  • (.*?) 匹配普通块,并将其保存到 match.group(1)
  • (\Z|\n(?:\Z|----(?:\Z|\n.*?(?:\Z|\n(?:\Z|----(?:\Z|\n)))))) 匹配分隔符 + 特殊块 + 分隔符,并将其保存到 match.group(2)
  • .*? 类似于 .*,但会优先尝试较短的匹配。如果没有 ?,这个正则表达式的部分会匹配整个输入。
  • TEXT 替换只应用于普通块(match.group(1))。
  • 这个正则表达式包含了很多 \Z|\n... 的实例,以确保即使输入字符串提前结束(例如在特殊块内),也能正确匹配。

在使用正则表达式进行字符串处理时,这些想法很有用:

  1. 用一个形式为 (...)|(...)|... 的正则表达式匹配所有内容,然后在替换函数中用 if match.group(1) is not None: 等进行分类。

  2. 用多个短的正则表达式进行匹配(例如 ^----$text),并编写代码将匹配和替换连接在一起。

  3. 用一个长的正则表达式进行匹配,在字符串末尾用 \Z|... 进行部分匹配。

上面的长解决方案做了第2种,而短的解决方案做了第3种。对于标记化(比如从C源代码中去除注释),第1种方法很有用。

1

你可以使用 re.sub 这个函数,配合一个替换函数来实现你的需求。这里有个链接可以帮助你理解这个正则表达式的用法:regex101

import re

text = """\
some text
----
text inside special block
----
some text
----
----
some text
----
text inside special block
----
some text"""


pat = r"(.*?)(-+\n.*?-+(?:\n|\Z))(.*?)(?=-+|\Z)"


def repl_fn(g):
    return (
        g.group(1).replace("text", "TEXT")
        + g.group(2)
        + g.group(3).replace("text", "TEXT")
    )


text = re.sub(pat, repl_fn, text, flags=re.S)
print(text)

运行后会输出:

some TEXT
----
text inside special block
----
some TEXT
----
----
some TEXT
----
text inside special block
----
some TEXT

撰写回答