This commit is contained in:
wdjwxh 2025-12-11 17:45:03 +08:00
parent d519612ded
commit 200a90c859
1 changed files with 399 additions and 233 deletions

624
sync.py
View File

@ -296,7 +296,7 @@ def search_chinese_page(title):
return None return None
def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None): def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None):
"""创建双语对比的HTML页面 - 使用精确的行号映射""" """创建双语对比的HTML页面 - Word批注风格英文变更直接显示在对应中文行右侧"""
# 准备中文内容行 # 准备中文内容行
cn_lines = [] cn_lines = []
if cn_content: if cn_content:
@ -305,6 +305,126 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
# 解析diff并获取行号信息 # 解析diff并获取行号信息
parsed_diff = parse_diff_with_line_numbers(en_diff) if en_diff else [] parsed_diff = parse_diff_with_line_numbers(en_diff) if en_diff else []
# 构建行号到diff内容的映射 - 科学处理连续diff块
en_changes_by_line = {}
blank_lines_to_insert = {} # 记录需要在某行前插入的空白行及其对应的新增内容
if parsed_diff:
i = 0
while i < len(parsed_diff):
# 收集连续的diff块
diff_block = []
start_index = i
# 收集连续的添加/删除操作跳过hunk和header
while i < len(parsed_diff):
item = parsed_diff[i]
if item['type'] in ['added', 'removed']:
diff_block.append(item)
elif item['type'] in ['hunk', 'header'] or item['type'] == 'context':
if diff_block: # 如果已经有diff块就停止
break
i += 1
# 处理连续的diff块
if diff_block:
# 计算行数平衡
line_balance = 0
for item in diff_block:
if item['type'] == 'added':
line_balance += 1
elif item['type'] == 'removed':
line_balance -= 1
# 如果平衡为正数,需要在中文侧添加空白行
if line_balance > 0:
# 找到基准行号(第一个操作的行号)
base_line = None
for item in diff_block:
if item['old_line']: # 优先使用删除行的行号
base_line = item['old_line']
break
elif item['new_line'] and base_line is None:
base_line = item['new_line']
if base_line:
# 收集需要分配到空白行的新增内容
additions_for_blank_lines = []
remaining_additions = []
for item in diff_block:
if item['type'] == 'added':
additions_for_blank_lines.append(item['content'])
# 记录需要插入的空白行和对应的内容
blank_lines_to_insert[base_line] = additions_for_blank_lines
# 处理具体的diff项
j = 0
while j < len(diff_block):
item = diff_block[j]
# 检查是否是替换操作(删除后紧跟新增)
if (item['type'] == 'removed' and j + 1 < len(diff_block) and
diff_block[j + 1]['type'] == 'added'):
next_item = diff_block[j + 1]
# 这是同一行的替换操作
target_line = item['old_line'] # 使用删除行的行号作为目标行号
if target_line not in en_changes_by_line:
en_changes_by_line[target_line] = []
en_changes_by_line[target_line].append({
'type': 'replaced',
'old_content': item['content'],
'new_content': next_item['content']
})
j += 2 # 跳过下一个项目,因为已经处理了
# 处理普通的添加操作(不包括需要分配到空白行的)
elif item['type'] == 'added' and item['new_line']:
# 如果这个新增内容已经被分配到空白行,就跳过
if line_balance > 0 and item['content'] in blank_lines_to_insert.get(base_line, []):
j += 1
continue
if item['new_line'] not in en_changes_by_line:
en_changes_by_line[item['new_line']] = []
en_changes_by_line[item['new_line']].append({
'type': 'added',
'content': item['content']
})
j += 1
# 处理普通的删除操作(没有对应的新增)
elif item['type'] == 'removed' and item['old_line']:
if item['old_line'] not in en_changes_by_line:
en_changes_by_line[item['old_line']] = []
en_changes_by_line[item['old_line']].append({
'type': 'removed',
'content': item['content']
})
j += 1
else:
j += 1
# 继续处理剩余项
else:
i += 1
# HTML转义函数
def html_escape(text):
if not text:
return ""
return (str(text)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;"))
# 生成HTML # 生成HTML
html = f'''<!DOCTYPE html> html = f'''<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@ -323,6 +443,7 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5; background-color: #f5f5f5;
line-height: 1.6; line-height: 1.6;
padding: 20px;
}} }}
.header {{ .header {{
@ -330,6 +451,7 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
padding: 20px; padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px; margin-bottom: 20px;
border-radius: 8px;
}} }}
.header h1 {{ .header h1 {{
@ -343,165 +465,149 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
font-size: 14px; font-size: 14px;
}} }}
.container {{ .content-container {{
display: flex; max-width: 1200px;
max-width: 100%;
margin: 0 auto; margin: 0 auto;
background-color: #fff; background-color: #fff;
min-height: calc(100vh - 100px); border-radius: 8px;
}} box-shadow: 0 2px 8px rgba(0,0,0,0.1);
.column {{
flex: 1;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
}} }}
.column-header {{ .content-header {{
background-color: #e9ecef; background-color: #e9ecef;
padding: 12px 20px; padding: 15px 20px;
font-weight: bold; font-weight: bold;
color: #495057; color: #495057;
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
}} }}
.diff-content {{ .diff-content {{
flex: 1; padding: 0;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.4;
}} }}
.line {{ .line-wrapper {{
display: flex; display: flex;
min-height: 20px; border-bottom: 1px solid #f0f0f0;
position: relative; position: relative;
}} }}
.line-wrapper:hover {{
background-color: rgba(0, 123, 255, 0.02);
}}
.line-wrapper.has-changes {{
background-color: rgba(255, 193, 7, 0.05);
}}
.main-line {{
display: flex;
flex: 1;
min-height: 24px;
align-items: center;
}}
.line-number {{ .line-number {{
width: 60px; width: 60px;
text-align: right; text-align: right;
padding: 0 10px; padding: 8px 12px;
background-color: #f8f9fa; background-color: #f8f9fa;
color: #6c757d; color: #6c757d;
border-right: 1px solid #dee2e6; font-size: 12px;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
}} border-right: 1px solid #e9ecef;
.line.highlight {{
background-color: rgba(255, 235, 59, 0.3) !important;
animation: highlight 2s ease-in-out;
}}
@keyframes highlight {{
0% {{ background-color: rgba(255, 235, 59, 0.8); }}
100% {{ background-color: rgba(255, 235, 59, 0.3); }}
}} }}
.line-content {{ .line-content {{
flex: 1; flex: 1;
padding: 0 10px; padding: 8px 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
color: #333;
}} }}
/* Diff specific styles */ /* 批注样式 */
.line.diff-added {{ .annotation {{
width: 400px;
background-color: #f8f9fa;
border-left: 1px solid #dee2e6;
padding: 8px 12px;
font-size: 12px;
display: none;
}}
.line-wrapper.has-changes .annotation {{
display: block;
}}
.annotation-item {{
margin-bottom: 6px;
padding: 6px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
line-height: 1.4;
}}
.annotation-item:last-child {{
margin-bottom: 0;
}}
.annotation-item.added {{
background-color: #e6ffec; background-color: #e6ffec;
}}
.line.diff-added .line-content {{
background-color: #cdffd8;
border-left: 3px solid #28a745; border-left: 3px solid #28a745;
color: #155724;
}} }}
.line.diff-removed {{ .annotation-item.removed {{
background-color: #ffeef0; background-color: #ffeef0;
}}
.line.diff-removed .line-content {{
background-color: #fdb8c0;
border-left: 3px solid #dc3545; border-left: 3px solid #dc3545;
color: #721c24;
text-decoration: line-through; text-decoration: line-through;
}} }}
.line.diff-context {{ .annotation-item.replaced {{
background-color: #ffffff; margin-bottom: 8px;
}} }}
.line.diff-context .line-content {{ .annotation-item.replaced .old-content {{
background-color: #ffffff; background-color: #ffeef0;
border-left: 3px solid #dc3545;
color: #721c24;
text-decoration: line-through;
padding: 4px 6px;
border-radius: 3px;
margin-bottom: 4px;
}} }}
.line.diff-hunk {{ .annotation-item.replaced .new-content {{
background-color: #f8f9fa; background-color: #e6ffec;
border-left: 3px solid #28a745;
color: #155724;
padding: 4px 6px;
border-radius: 3px;
}}
.annotation-header {{
font-size: 10px;
color: #6c757d; color: #6c757d;
font-style: italic; margin-bottom: 4px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}} }}
.line.diff-hunk .line-content {{ /* 新页面提示 */
background-color: #f1f3f4;
}}
.line.diff-header {{
background-color: #e9ecef;
color: #495057;
font-style: italic;
}}
.line.diff-header .line-content {{
background-color: #e9ecef;
}}
/* Separator between columns */
.separator {{
width: 1px;
background-color: #dee2e6;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
position: relative;
z-index: 10;
}}
/* Scrollbar styling */
.diff-content::-webkit-scrollbar {{
width: 8px;
height: 8px;
}}
.diff-content::-webkit-scrollbar-track {{
background: #f1f1f1;
}}
.diff-content::-webkit-scrollbar-thumb {{
background: #888;
border-radius: 4px;
}}
.diff-content::-webkit-scrollbar-thumb:hover {{
background: #555;
}}
/* Responsive design */
@media (max-width: 768px) {{
.container {{
flex-direction: column;
}}
.separator {{
width: 100%;
height: 1px;
}}
}}
/* Special styling for new page */
.new-page-notice {{ .new-page-notice {{
background-color: #d4edda; background-color: #d4edda;
color: #155724; color: #155724;
padding: 15px 20px; padding: 15px 20px;
margin-bottom: 20px; margin: 15px;
border-radius: 4px;
border-left: 4px solid #28a745; border-left: 4px solid #28a745;
}} }}
@ -509,17 +615,126 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
background-color: #fff3cd; background-color: #fff3cd;
color: #856404; color: #856404;
padding: 15px 20px; padding: 15px 20px;
margin-bottom: 20px; margin: 15px;
border-radius: 4px;
border-left: 4px solid #ffc107; border-left: 4px solid #ffc107;
}} }}
/* Line linking styles */ /* 响应式设计 */
.line[data-cn-line] {{ @media (max-width: 1024px) {{
cursor: pointer; .annotation {{
width: 300px;
}}
}} }}
.line:hover {{ @media (max-width: 768px) {{
background-color: rgba(0, 123, 255, 0.05); body {{
padding: 10px;
}}
.annotation {{
width: 100%;
display: block !important;
border-left: none;
border-top: 1px solid #dee2e6;
}}
.line-wrapper {{
flex-direction: column;
}}
.main-line {{
border-bottom: none;
}}
}}
/* 高亮效果 */
.line-wrapper.highlight {{
background-color: rgba(255, 235, 59, 0.3) !important;
animation: highlight 2s ease-in-out;
}}
@keyframes highlight {{
0% {{ background-color: rgba(255, 235, 59, 0.6); }}
100% {{ background-color: rgba(255, 235, 59, 0.3); }}
}}
/* 空行样式 */
.line-wrapper.empty-line .line-content {{
min-height: 24px;
color: #999;
font-style: italic;
}}
/* 空白占位行样式 */
.line-wrapper.blank-placeholder {{
background-color: #fafafa;
border-bottom: 1px solid #e9ecef;
display: flex;
}}
.line-wrapper.blank-placeholder .main-line {{
min-height: 24px;
flex: 1;
display: flex;
}}
.line-wrapper.blank-placeholder .line-number {{
color: #dee2e6;
}}
.line-wrapper.blank-placeholder .line-content {{
color: #dee2e6;
font-style: italic;
min-height: 24px;
display: flex;
align-items: center;
}}
/* 空白占位行的批注样式 */
.line-wrapper.blank-placeholder .annotation {{
width: 400px;
background-color: #f8f9fa;
border-left: 1px solid #dee2e6;
padding: 8px 12px;
font-size: 12px;
display: block;
}}
.line-wrapper.blank-placeholder .annotation .annotation-item {{
margin-bottom: 6px;
padding: 6px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
line-height: 1.4;
}}
.line-wrapper.blank-placeholder .annotation .annotation-item:last-child {{
margin-bottom: 0;
}}
.line-wrapper.blank-placeholder .annotation .annotation-item.added {{
background-color: #e6ffec;
border-left: 3px solid #28a745;
color: #155724;
}}
.line-wrapper.blank-placeholder .annotation .annotation-header {{
font-size: 10px;
color: #6c757d;
margin-bottom: 4px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.line-wrapper.blank-placeholder:hover {{
background-color: #f8f9fa;
}}
.line-wrapper.blank-placeholder:hover .main-line {{
background-color: rgba(0, 123, 255, 0.02);
}} }}
</style> </style>
</head> </head>
@ -532,58 +747,71 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
</div> </div>
</div> </div>
<div class="container"> <div class="content-container">
<div class="column"> <div class="content-header">中文翻译含英文变更批注</div>
<div class="column-header">English Diff</div> <div class="diff-content">
<div class="diff-content" id="en-diff">
''' '''
# 生成英文diff内容 # 添加中文内容和英文变更批注
if parsed_diff:
for item in parsed_diff:
if item['type'] == 'hunk':
html += f'<div class="line diff-hunk"><span class="line-content">{item["content"]}</span></div>'
elif item['type'] == 'header':
html += f'<div class="line diff-header"><span class="line-content">{item["content"]}</span></div>'
elif item['type'] == 'added':
cn_line_attr = f'data-cn-line="{item["new_line"]}"' if item["new_line"] and cn_lines and item["new_line"] <= len(cn_lines) else ''
cn_title = f'中文第{item["new_line"]}' if item["new_line"] and cn_lines and item["new_line"] <= len(cn_lines) else ''
html += f'<div class="line diff-added" {cn_line_attr} title="{cn_title}"><span class="line-number">{item["new_line"] or ""}</span><span class="line-content">{item["content"]}</span></div>'
elif item['type'] == 'removed':
html += f'<div class="line diff-removed" title="已删除"><span class="line-number">{item["old_line"] or ""}</span><span class="line-content">{item["content"]}</span></div>'
elif item['type'] == 'context':
cn_line_attr = f'data-cn-line="{item["new_line"]}"' if item["new_line"] and cn_lines and item["new_line"] <= len(cn_lines) else ''
cn_title = f'中文第{item["new_line"]}' if item["new_line"] and cn_lines and item["new_line"] <= len(cn_lines) else ''
html += f'<div class="line diff-context" {cn_line_attr} title="{cn_title}"><span class="line-number">{item["new_line"]}</span><span class="line-content">{item["content"]}</span></div>'
else:
html += f'<div class="line"><span class="line-content">{item["content"]}</span></div>'
else:
# 新页面或无diff
if en_diff and en_diff.startswith("新创建页面"):
html += '<div class="new-page-notice">新创建页面</div>'
# 显示完整内容新页面或无diff时
for i, line in enumerate(en_new_lines or [], 1):
cn_line_attr = f'data-cn-line="{i}"' if cn_lines and i <= len(cn_lines) else ''
cn_title = f'中文第{i}' if cn_lines and i <= len(cn_lines) else ''
html += f'<div class="line diff-context" {cn_line_attr} title="{cn_title}"><span class="line-number">{i}</span><span class="line-content">{line}</span></div>'
html += '''
</div>
</div>
<div class="separator"></div>
<div class="column">
<div class="column-header">中文翻译</div>
<div class="diff-content" id="cn-content">
'''
# 添加中文内容
if cn_content: if cn_content:
html += '<div id="cn-lines">'
for i, line in enumerate(cn_lines, 1): for i, line in enumerate(cn_lines, 1):
html += f'<div class="line diff-context" id="cn-line-{i}"><span class="line-number">{i}</span><span class="line-content">{line}</span></div>' # 检查是否需要在此行之前插入空白行
if i in blank_lines_to_insert:
additions_list = blank_lines_to_insert[i]
for addition_content in additions_list:
html += f'<div class="line-wrapper blank-placeholder">'
html += '<div class="main-line">'
html += '<span class="line-number">&nbsp;</span>' # 不显示行号
html += f'<span class="line-content">(新增英文内容占位)</span>'
html += '</div>'
# 为空白行添加对应的新增批注
escaped_addition = html_escape(addition_content)
html += '<div class="annotation">'
html += f'<div class="annotation-item added">'
html += f'<div class="annotation-header">新增</div>'
html += f'<div>{escaped_addition}</div>'
html += '</div>'
html += '</div>'
html += '</div>'
escaped_line = html_escape(line)
has_changes = i in en_changes_by_line
changes = en_changes_by_line.get(i, [])
# 判断是否为空行
is_empty = not line.strip()
html += f'<div class="line-wrapper {"has-changes" if has_changes else ""} {"empty-line" if is_empty else ""}">'
html += f'<div class="main-line">'
html += f'<span class="line-number">{i}</span>'
html += f'<span class="line-content">{escaped_line if not is_empty else "(空行)"}</span>'
html += '</div>'
# 添加英文变更批注(只显示替换和删除操作,新增操作已经在空白行中显示)
if has_changes:
html += '<div class="annotation">'
for change in changes:
if change['type'] == 'added':
# 新增内容已经在空白行中显示,这里跳过
continue
elif change['type'] == 'removed':
escaped_change = html_escape(change['content'])
html += f'<div class="annotation-item removed">'
html += f'<div class="annotation-header">删除</div>'
html += f'<div>{escaped_change}</div>'
html += '</div>'
elif change['type'] == 'replaced':
escaped_old = html_escape(change['old_content'])
escaped_new = html_escape(change['new_content'])
html += f'<div class="annotation-item replaced">'
html += f'<div class="annotation-header">替换</div>'
html += f'<div class="old-content">{escaped_old}</div>'
html += f'<div class="new-content">{escaped_new}</div>'
html += '</div>'
html += '</div>'
html += '</div>' html += '</div>'
else: else:
html += '<div class="no-translation">未找到对应的中文翻译页面</div>' html += '<div class="no-translation">未找到对应的中文翻译页面</div>'
@ -591,80 +819,18 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
html += ''' html += '''
</div> </div>
</div> </div>
</div>
<script> <script>
// 同步滚动功能 // 点击有变更的行时高亮
const enDiff = document.querySelector('#en-diff'); document.querySelectorAll('.line-wrapper.has-changes').forEach(lineWrapper => {{
const cnContent = document.querySelector('#cn-content'); lineWrapper.addEventListener('click', () => {{
const cnLines = {};
// 构建中文行的位置映射
if (document.getElementById('cn-lines')) {{
document.querySelectorAll('#cn-lines .line').forEach(line => {{
const lineNum = line.querySelector('.line-number').textContent;
if (lineNum) {{
cnLines[lineNum] = line.offsetTop;
}}
}});
}}
// 同步滚动
if (enDiff && cnContent) {{
enDiff.addEventListener('scroll', () => {{
cnContent.scrollTop = enDiff.scrollTop;
}});
cnContent.addEventListener('scroll', () => {{
enDiff.scrollTop = cnContent.scrollTop;
}});
}}
// 点击英文行时高亮对应的中文行
document.querySelectorAll('[data-cn-line]').forEach(enLine => {{
enLine.addEventListener('click', () => {{
const cnLineNum = enLine.getAttribute('data-cn-line');
if (cnLineNum) {{
const cnLine = document.getElementById(`cn-line-${cnLineNum}`);
if (cnLine) {{
// 移除所有高亮 // 移除所有高亮
document.querySelectorAll('.line.highlight').forEach(line => {{ document.querySelectorAll('.line-wrapper.highlight').forEach(line => {{
line.classList.remove('highlight'); line.classList.remove('highlight');
}}); }});
// 高亮英文行和中文行 // 高亮当前行
enLine.classList.add('highlight'); lineWrapper.classList.add('highlight');
cnLine.classList.add('highlight');
// 滚动到中文行的位置
cnLine.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
}}
}}
}});
// 鼠标悬停时显示预览
enLine.addEventListener('mouseenter', () => {{
const cnLineNum = enLine.getAttribute('data-cn-line');
if (cnLineNum) {{
const cnLine = document.getElementById(`cn-line-${cnLineNum}`);
if (cnLine) {{
enLine.style.backgroundColor = 'rgba(0, 123, 255, 0.1)';
cnLine.style.backgroundColor = 'rgba(0, 123, 255, 0.1)';
}}
}}
}});
enLine.addEventListener('mouseleave', () => {{
if (!enLine.classList.contains('highlight')) {{
enLine.style.backgroundColor = '';
}}
const cnLineNum = enLine.getAttribute('data-cn-line');
if (cnLineNum) {{
const cnLine = document.getElementById(`cn-line-${cnLineNum}`);
if (cnLine && !cnLine.classList.contains('highlight')) {{
cnLine.style.backgroundColor = '';
}}
}}
}}); }});
}}); }});
</script> </script>