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
def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None):
"""创建双语对比的HTML页面 - 使用精确的行号映射"""
"""创建双语对比的HTML页面 - Word批注风格英文变更直接显示在对应中文行右侧"""
# 准备中文内容行
cn_lines = []
if cn_content:
@ -305,6 +305,126 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
# 解析diff并获取行号信息
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 = f'''<!DOCTYPE html>
<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;
background-color: #f5f5f5;
line-height: 1.6;
padding: 20px;
}}
.header {{
@ -330,6 +451,7 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
border-radius: 8px;
}}
.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;
}}
.container {{
display: flex;
max-width: 100%;
.content-container {{
max-width: 1200px;
margin: 0 auto;
background-color: #fff;
min-height: calc(100vh - 100px);
}}
.column {{
flex: 1;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
display: flex;
flex-direction: column;
}}
.column-header {{
.content-header {{
background-color: #e9ecef;
padding: 12px 20px;
padding: 15px 20px;
font-weight: bold;
color: #495057;
border-bottom: 1px solid #dee2e6;
}}
.diff-content {{
flex: 1;
overflow-y: auto;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.4;
padding: 0;
}}
.line {{
.line-wrapper {{
display: flex;
min-height: 20px;
border-bottom: 1px solid #f0f0f0;
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 {{
width: 60px;
text-align: right;
padding: 0 10px;
padding: 8px 12px;
background-color: #f8f9fa;
color: #6c757d;
border-right: 1px solid #dee2e6;
font-size: 12px;
user-select: none;
flex-shrink: 0;
}}
.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); }}
border-right: 1px solid #e9ecef;
}}
.line-content {{
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;
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;
}}
.line.diff-added .line-content {{
background-color: #cdffd8;
border-left: 3px solid #28a745;
color: #155724;
}}
.line.diff-removed {{
.annotation-item.removed {{
background-color: #ffeef0;
}}
.line.diff-removed .line-content {{
background-color: #fdb8c0;
border-left: 3px solid #dc3545;
color: #721c24;
text-decoration: line-through;
}}
.line.diff-context {{
background-color: #ffffff;
.annotation-item.replaced {{
margin-bottom: 8px;
}}
.line.diff-context .line-content {{
background-color: #ffffff;
.annotation-item.replaced .old-content {{
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 {{
background-color: #f8f9fa;
.annotation-item.replaced .new-content {{
background-color: #e6ffec;
border-left: 3px solid #28a745;
color: #155724;
padding: 4px 6px;
border-radius: 3px;
}}
.annotation-header {{
font-size: 10px;
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 {{
background-color: #d4edda;
color: #155724;
padding: 15px 20px;
margin-bottom: 20px;
margin: 15px;
border-radius: 4px;
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;
color: #856404;
padding: 15px 20px;
margin-bottom: 20px;
margin: 15px;
border-radius: 4px;
border-left: 4px solid #ffc107;
}}
/* Line linking styles */
.line[data-cn-line] {{
cursor: pointer;
/* 响应式设计 */
@media (max-width: 1024px) {{
.annotation {{
width: 300px;
}}
}}
.line:hover {{
background-color: rgba(0, 123, 255, 0.05);
@media (max-width: 768px) {{
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>
</head>
@ -532,58 +747,71 @@ def create_diff_html(title, en_diff, en_old_lines, en_new_lines, cn_content=None
</div>
</div>
<div class="container">
<div class="column">
<div class="column-header">English Diff</div>
<div class="diff-content" id="en-diff">
<div class="content-container">
<div class="content-header">中文翻译含英文变更批注</div>
<div class="diff-content">
'''
# 生成英文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:
html += '<div id="cn-lines">'
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>'
else:
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 += '''
</div>
</div>
</div>
<script>
// 同步滚动功能
const enDiff = document.querySelector('#en-diff');
const cnContent = document.querySelector('#cn-content');
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-wrapper.has-changes').forEach(lineWrapper => {{
lineWrapper.addEventListener('click', () => {{
// 移除所有高亮
document.querySelectorAll('.line.highlight').forEach(line => {{
document.querySelectorAll('.line-wrapper.highlight').forEach(line => {{
line.classList.remove('highlight');
}});
// 高亮英文行和中文行
enLine.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 = '';
}}
}}
// 高亮当前行
lineWrapper.classList.add('highlight');
}});
}});
</script>