订单数据是可读PDF,嗨,这还不简单,直接读就完了。
此处应有截图。
读出来是这样的
Supplier SKU Description Color SKU Cost Ordered Amount Retail
85.00 9 765.00 160.00
5 5.5 6 6.5 7 7.5 8 8.5 9 9.5 10 10.5 11 12
1 1 1 1 1 1 1 1 1
85.00 8 680.00 160.00
5 5.5 6 6.5 7 7.5 8 8.5 9 9.5 10 10.5 11 12
1 1 1 1 1 1 1 1
5 5.5开始这一行是尺码,下一行是下单数量,这时候发现不对劲,未下单的尺码并没有空格隔开,直接读每次都是从最小码开始下单,这明显不符合实际情况。怎么办才可以反映出尺码和数量的匹配关系呢?
python里有一个库pdfplumber,号称是强大的pdf操作库,看看它能干什么
python
import pdfplumber
import re
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
def extract_pdf_with_layout(pdf_path, output_xlsx='订单详情.xlsx'):
"""
使用pdfplumber提取PDF内容,输出到Excel文件
每个商品一个sheet,第一行首列为SKU,第二行为尺码,第三行为数量
"""
all_products = []
with pdfplumber.open(pdf_path) as pdf:
# 遍历所有页面
for page_num, page in enumerate(pdf.pages, start=1):
print(f'正在处理第 {page_num} 页...')
# 提取文本(保留布局)
text = page.extract_text(layout=True)
if text:
# 查找"Supplier SKU"的位置
supplier_sku_index = text.find('Supplier SKU')
if supplier_sku_index != -1:
# 只保留Supplier SKU之后的内容
relevant_text = text[supplier_sku_index:]
# 解析商品信息
products = parse_products(relevant_text)
all_products.extend(products)
print(f' 找到 {len(products)} 个商品')
else:
print(f'第 {page_num} 页未找到"Supplier SKU"')
# 保存到Excel
if all_products:
save_to_excel(all_products, output_xlsx)
print(f'\n数据已保存到: {output_xlsx},共 {len(all_products)} 个商品')
else:
print('未提取到任何数据')
def parse_products(text):
"""
解析商品信息,返回商品列表
"""
products = []
lines = text.split('\n')
# 找到表头行(Supplier SKU所在行)
header_line_idx = None
for i, line in enumerate(lines):
if 'Supplier SKU' in line:
header_line_idx = i
break
if header_line_idx is None:
return products
i = header_line_idx + 1
processed_skus = set()
while i < len(lines):
line = lines[i].strip()
# 跳过空行
if not line:
i += 1
continue
# 跳过明显的表头行或汇总行
if 'PO Totals' in line or 'Page:' in line:
i += 1
continue
# 检查是否是商品信息行
# 修复:支持带逗号的Amount和Retail列
# 格式:SKU ... Cost Ordered Amount Retail
# Cost: \d+\.\d{2}
# Ordered: \d+
# Amount: [\d,]+\.[\d,]+ 或 \d+\.\d{2}
# Retail: [\d,]+\.[\d,]+ 或 \d+\.\d{2}
sku_match = re.match(r'^([A-Z0-9-]{5,})', line)
if sku_match:
# 匹配商品信息行的完整格式
# 支持:Cost Ordered Amount Retail
# 其中Amount和Retail可能带逗号
price_patterns = [
# 格式1: Cost(带逗号) Ordered Amount(带逗号) Retail(带逗号)
r'[\d,]+\.[\d,]+?\s+\d+\s+[\d,]+\.[\d,]+?\s+[\d,]+\.[\d,]+',
# 格式2: Cost(不带逗号) Ordered Amount(带逗号) Retail(带逗号)
r'\d+\.\d{2}\s+\d+\s+[\d,]+\.[\d,]+?\s+[\d,]+\.[\d,]+',
# 格式3: Cost(不带逗号) Ordered Amount(带逗号) Retail(不带逗号)
r'\d+\.\d{2}\s+\d+\s+[\d,]+\.[\d,]+?\s+\d+\.\d{2}',
# 格式4: Cost(不带逗号) Ordered Amount(不带逗号) Retail(带逗号)
r'\d+\.\d{2}\s+\d+\s+\d+\.\d{2}\s+[\d,]+\.[\d,]+',
# 格式5: Cost(不带逗号) Ordered Amount(不带逗号) Retail(不带逗号)
r'\d+\.\d{2}\s+\d+\s+\d+\.\d{2}\s+\d+\.\d{2}',
# 格式6: 至少包含一个价格格式(更宽松的匹配)
r'[\d,]+\.[\d,]+',
]
has_price_pattern = False
for pattern in price_patterns:
if re.search(pattern, line):
has_price_pattern = True
break
if has_price_pattern:
sku = sku_match.group(1)
# 避免重复处理
if sku in processed_skus:
i += 1
continue
processed_skus.add(sku)
print(f'\n 处理商品: {sku}')
print(f' 商品行: {line[:100]}')
# 查找尺码行和数量行
size_line = None
qty_line = None
# 扩大搜索范围到15行,确保能找到
for j in range(i + 1, min(i + 15, len(lines))):
next_line = lines[j]
next_line_stripped = next_line.strip()
# 跳过空行
if not next_line_stripped:
continue
# 如果遇到下一个商品行,停止搜索
if re.match(r'^[A-Z0-9-]{5,}', next_line_stripped):
# 检查是否真的是商品行(包含价格)
is_next_product = False
for pattern in price_patterns:
if re.search(pattern, next_line_stripped):
is_next_product = True
break
if is_next_product:
break
# 查找尺码行(放宽条件)
if size_line is None:
# 检查是否包含小数尺码(如5.5, 6.5等)
if re.search(r'\b\d+\.5\b', next_line):
size_line = next_line
print(f' 找到尺码行: {next_line[:80]}')
# 或者检查是否包含多个连续的数字(至少8个数字,可能是尺码)
elif re.findall(r'\b\d+(?:\.5)?\b', next_line) and len(re.findall(r'\b\d+(?:\.5)?\b', next_line)) >= 8:
# 确保不是价格行(不包含XX.XX格式的价格,但允许整数)
if not re.search(r'\d+\.\d{2}', next_line):
size_line = next_line
print(f' 找到尺码行: {next_line[:80]}')
# 查找数量行(放宽匹配条件)
if qty_line is None:
# 检查是否包含前缀格式(B/M, D/M, D/W, 2E/W, 2E/XW等)
if re.search(r'[A-Z0-9]+/[A-Z]+', next_line):
# 确保这一行主要是数字和空格(数量行特征)
numbers = re.findall(r'\b\d+\b', next_line)
if len(numbers) >= 3: # 至少3个数字
qty_line = next_line
print(f' 找到数量行: {next_line[:80]}')
# 解析尺码和数量(使用文本位置匹配)
if size_line and qty_line:
sizes, quantities = extract_sizes_and_quantities_with_text_position(sku, size_line, qty_line)
if sizes:
products.append({
'SKU': sku,
'尺码': sizes,
'数量': quantities
})
print(f' 尺码数量: {len(sizes)}, 订单数量: {sum(quantities)}')
else:
print(f' 警告: 解析失败,未提取到尺码')
else:
print(f' 警告: 未找到尺码行或数量行')
if not size_line:
print(f' 未找到尺码行')
if not qty_line:
print(f' 未找到数量行')
# 输出接下来几行用于调试
print(f' 接下来5行内容:')
for k in range(i + 1, min(i + 6, len(lines))):
print(f' {k}: {repr(lines[k][:80])}')
i += 1
return products
def extract_sizes_and_quantities_with_text_position(sku, size_line, quantity_line):
"""
使用文本位置(字符索引)进行距离匹配
修复:从数量出发找最近尺码,但不累加,每个尺码最多只有一个数量
如果数量被替换,尝试匹配其他尺码,确保所有数量都被匹配
"""
print(f'\n === 开始处理 {sku} ===')
print(f' 尺码行: {repr(size_line[:100])}')
print(f' 数量行: {repr(quantity_line[:100])}')
# 步骤1: 提取尺码及其位置
size_pattern = r'\d+\.?\d*'
sizes = []
size_positions = []
for match in re.finditer(size_pattern, size_line):
size_str = match.group()
# 验证是否是有效的尺码(排除价格等)
if '.' in size_str:
# 包含小数点,如果小数点后只有一位数字,可能是尺码
if len(size_str.split('.')[1]) == 1:
sizes.append(size_str)
center_pos = (match.start() + match.end()) // 2
size_positions.append(center_pos)
else:
# 整数,可能是尺码
sizes.append(size_str)
center_pos = (match.start() + match.end()) // 2
size_positions.append(center_pos)
print(f' 提取到尺码: {sizes}')
print(f' 尺码位置: {size_positions}')
# 步骤2: 找到B/M, D/M, D/W, 2E/W, 2E/XW等标记的位置(放宽匹配)
prefix_match = re.search(r'[A-Z0-9]+/[A-Z]+', quantity_line)
if prefix_match:
prefix_end = prefix_match.end()
search_text = quantity_line[prefix_end:]
offset = prefix_end
print(f' 找到前缀: {prefix_match.group()}, 结束位置: {prefix_end}')
else:
search_text = quantity_line
offset = 0
print(f' 未找到前缀,使用整行')
# 步骤3: 提取订单数量及其位置
quantity_pattern = r'\d+'
quantities = []
quantity_positions = []
for match in re.finditer(quantity_pattern, search_text):
qty = int(match.group())
quantities.append(qty)
center_pos = offset + (match.start() + match.end()) // 2
quantity_positions.append(center_pos)
print(f' 提取到数量: {quantities}')
print(f' 数量位置: {quantity_positions}')
print(f' 总数量: {sum(quantities)}')
# 步骤4: 位置匹配算法(修复:确保所有数量都被匹配)
# 初始化所有尺码的数量为0
size_quantity_map = {size: 0 for size in sizes}
# 记录每个尺码的匹配距离和匹配的数量索引
size_distance_map = {size: float('inf') for size in sizes}
size_qty_idx_map = {size: None for size in sizes} # 记录匹配的数量索引
# 从数量出发,每个数量找最近的尺码
for qty_idx, (qty, qty_pos) in enumerate(zip(quantities, quantity_positions)):
min_distance = float('inf')
closest_size = None
# 找到最近的尺码
for size, size_pos in zip(sizes, size_positions):
distance = abs(qty_pos - size_pos)
if distance < min_distance:
min_distance = distance
closest_size = size
# 如果找到匹配的尺码
if closest_size:
# 如果这个尺码还没有匹配,直接匹配
if size_qty_idx_map[closest_size] is None:
size_quantity_map[closest_size] = qty
size_distance_map[closest_size] = min_distance
size_qty_idx_map[closest_size] = qty_idx
print(f' 匹配: 数量{qty_idx+1} {qty} (位置{qty_pos}) -> 尺码 {closest_size} (位置{size_positions[sizes.index(closest_size)]}, 距离{min_distance:.1f})')
# 如果这个尺码已经有匹配,但当前距离更近,则替换
elif min_distance < size_distance_map[closest_size]:
# 被替换的数量需要重新匹配
old_qty_idx = size_qty_idx_map[closest_size]
old_qty = quantities[old_qty_idx]
print(f' 替换: 数量{qty_idx+1} {qty} (距离{min_distance:.1f}) 替换 数量{old_qty_idx+1} {old_qty} (距离{size_distance_map[closest_size]:.1f}) -> 尺码 {closest_size}')
# 更新匹配
size_quantity_map[closest_size] = qty
size_distance_map[closest_size] = min_distance
size_qty_idx_map[closest_size] = qty_idx
# 被替换的数量尝试匹配其他尺码(排除已匹配的尺码)
matched_sizes = set(size for size, idx in size_qty_idx_map.items() if idx is not None)
best_alt_size = None
best_alt_distance = float('inf')
for size, size_pos in zip(sizes, size_positions):
if size in matched_sizes: # 跳过已匹配的尺码
continue
distance = abs(quantity_positions[old_qty_idx] - size_pos)
if distance < best_alt_distance:
best_alt_distance = distance
best_alt_size = size
# 如果找到替代尺码,匹配它
if best_alt_size and best_alt_distance <= 50: # 距离阈值
size_quantity_map[best_alt_size] = old_qty
size_distance_map[best_alt_size] = best_alt_distance
size_qty_idx_map[best_alt_size] = old_qty_idx
print(f' 重新匹配: 数量{old_qty_idx+1} {old_qty} -> 尺码 {best_alt_size} (距离{best_alt_distance:.1f})')
else:
print(f' 警告: 数量{old_qty_idx+1} {old_qty} 无法找到替代尺码')
else:
# 当前距离更远,尝试匹配其他尺码
matched_sizes = set(size for size, idx in size_qty_idx_map.items() if idx is not None)
best_alt_size = None
best_alt_distance = float('inf')
for size, size_pos in zip(sizes, size_positions):
if size in matched_sizes: # 跳过已匹配的尺码
continue
distance = abs(qty_pos - size_pos)
if distance < best_alt_distance:
best_alt_distance = distance
best_alt_size = size
# 如果找到替代尺码,匹配它
if best_alt_size and best_alt_distance <= 50: # 距离阈值
size_quantity_map[best_alt_size] = qty
size_distance_map[best_alt_size] = best_alt_distance
size_qty_idx_map[best_alt_size] = qty_idx
print(f' 替代匹配: 数量{qty_idx+1} {qty} -> 尺码 {best_alt_size} (距离{best_alt_distance:.1f}, 原目标{closest_size}已被占用)')
else:
print(f' 警告: 数量{qty_idx+1} {qty} 无法找到匹配尺码')
# 步骤5: 转换为列表格式,保持尺码顺序
quantities_list = [size_quantity_map[size] for size in sizes]
print(f' 最终结果: 尺码={sizes}')
print(f' 最终结果: 数量={quantities_list}')
print(f' 匹配到的数量总和: {sum(quantities_list)}')
print(f' 原始数量总和: {sum(quantities)}')
if sum(quantities_list) != sum(quantities):
print(f' ⚠️ 警告: 数量不匹配!丢失了 {sum(quantities) - sum(quantities_list)} 个数量')
print(f' === 处理完成 ===\n')
return sizes, quantities_list
def save_to_excel(products, output_xlsx):
"""
保存商品数据到Excel,每个商品一个sheet
第一行首列为SKU,第二行为尺码,第三行为数量
"""
wb = Workbook()
wb.remove(wb.active) # 删除默认sheet
# 定义样式
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True)
data_fill = PatternFill(start_color="FFFFFF", end_color="FFFFFF", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
center_alignment = Alignment(horizontal='center', vertical='center')
for idx, product in enumerate(products, start=1):
sku = product['SKU']
sizes = product['尺码']
quantities = product['数量']
# 创建sheet,命名为sheet1, sheet2, sheet3...
sheet_name = f"sheet{idx}"
ws = wb.create_sheet(title=sheet_name)
# 第一行:首列为SKU
ws.cell(row=1, column=1, value='SKU')
ws.cell(row=1, column=1).fill = header_fill
ws.cell(row=1, column=1).font = header_font
ws.cell(row=1, column=1).border = border
ws.cell(row=1, column=1).alignment = center_alignment
ws.cell(row=1, column=2, value=sku)
ws.cell(row=1, column=2).fill = data_fill
ws.cell(row=1, column=2).border = border
ws.cell(row=1, column=2).alignment = center_alignment
# 第二行:首列为"尺码",后面是尺码列表
ws.cell(row=2, column=1, value='尺码')
ws.cell(row=2, column=1).fill = header_fill
ws.cell(row=2, column=1).font = header_font
ws.cell(row=2, column=1).border = border
ws.cell(row=2, column=1).alignment = center_alignment
col = 2
for size in sizes:
cell = ws.cell(row=2, column=col, value=size)
cell.fill = data_fill
cell.border = border
cell.alignment = center_alignment
col += 1
# 第三行:首列为"数量",后面是对应的数量(确保对齐)
ws.cell(row=3, column=1, value='数量')
ws.cell(row=3, column=1).fill = header_fill
ws.cell(row=3, column=1).font = header_font
ws.cell(row=3, column=1).border = border
ws.cell(row=3, column=1).alignment = center_alignment
col = 2
# 确保数量和尺码对齐
min_len = min(len(sizes), len(quantities))
for i in range(min_len):
cell = ws.cell(row=3, column=col, value=quantities[i])
cell.fill = data_fill
cell.border = border
cell.alignment = center_alignment
col += 1
# 如果数量少于尺码,剩余位置填0
if len(quantities) < len(sizes):
for i in range(len(quantities), len(sizes)):
cell = ws.cell(row=3, column=col, value=0)
cell.fill = data_fill
cell.border = border
cell.alignment = center_alignment
col += 1
# 调整列宽
ws.column_dimensions['A'].width = 15
for col_idx in range(2, len(sizes) + 2):
if col_idx <= 26:
col_letter = chr(64 + col_idx)
else:
col_letter = chr(64 + (col_idx - 1) // 26) + chr(64 + (col_idx - 1) % 26 + 1)
ws.column_dimensions[col_letter].width = 10
wb.save(output_xlsx)
# 使用示例
if __name__ == "__main__":
pdf_path = r"XXXXXX.pdf"
output_xlsx = "订单详情.xlsx"
extract_pdf_with_layout(pdf_path, output_xlsx)
'''
核心功能模块
1. 主入口函数 extract_pdf_with_layout()
打开PDF并遍历所有页面
定位"Supplier SKU"表头,提取后续内容
调用解析函数提取商品数据
将结果保存到Excel
2. 商品解析函数 parse_products()
识别商品信息行:以SKU开头,包含价格格式(支持带逗号的金额)
查找尺码行:包含小数尺码(如5.5)或至少8个数字
查找数量行:包含前缀(B/M、D/M、D/W、2E/W等)且至少3个数字
去重:使用processed_skus避免重复处理
3. 尺码与数量匹配算法 extract_sizes_and_quantities_with_text_position()
3.1 数据提取阶段
尺码提取:使用正则\d+\.?\d*,过滤价格(小数点后两位),保留尺码(小数点后一位或整数)
数量提取:定位前缀(如D/M)后提取数字,记录每个数字的文本位置(字符索引)
3.2 位置匹配算法
核心思路:基于文本位置(字符索引)的距离匹配
算法流程:
初始化:所有尺码数量为0,记录匹配距离和数量索引
遍历数量:每个数量找最近的尺码
匹配规则:
尺码未匹配:直接匹配
尺码已匹配:比较距离,保留更近的匹配
被替换的数量:尝试匹配其他未使用的尺码
距离过远:尝试匹配其他尺码
距离阈值:50像素,超过则认为不匹配
3.3 算法特点
一对一匹配:每个尺码最多一个数量,每个数量只匹配一次
避免丢失:被替换的数量会重新匹配,确保所有数量都被使用
位置精确:使用文本位置中心点计算距离,而非简单顺序匹配
4. Excel导出函数 save_to_excel()
为每个商品创建独立工作表(sheet1, sheet2...)
格式:
第1行:SKU列 + SKU值
第2行:尺码列 + 所有尺码
第3行:数量列 + 对应数量(与尺码对齐)
样式:表头蓝色背景、白色加粗字体,数据行白色背景,带边框
技术要点
1. 正则表达式匹配
商品行识别:支持多种价格格式组合(带/不带逗号)
尺码提取:区分尺码(5.5)和价格(85.00)
数量行识别:支持多种前缀格式(B/M、D/M、2E/XW等)
2. 文本位置计算
使用字符索引而非像素坐标
计算匹配中心点:(start + end) / 2
距离计算:abs(数量位置 - 尺码位置)
3. 容错处理
搜索范围:扩大到15行,避免遗漏
多种匹配模式:支持不同格式组合
调试输出:输出关键步骤和中间变量,便于排查
数据流程
PDF文件 ↓提取文本(保留布局) ↓定位"Supplier SKU"表头 ↓遍历商品信息行 ├─ 提取SKU ├─ 查找尺码行(下一行或下几行) └─ 查找数量行(下一行或下几行) ↓文本位置匹配算法 ├─ 提取尺码及其位置 ├─ 提取数量及其位置 └─ 距离匹配(确保对齐) ↓生成商品数据列表 ↓保存到Excel(每个商品一个sheet)
算法复杂度
时间复杂度:O(n × m),n为商品数量,m为尺码/数量数量
空间复杂度:O(n × m),存储尺码、数量及其位置信息
适用场景
半结构化PDF订单文件
包含商品SKU、尺码、数量的订单数据
需要将PDF数据转换为结构化Excel格式
代码优势
鲁棒性:支持多种格式变化(带逗号金额、不同前缀等)
精确性:基于位置的距离匹配,而非简单顺序
完整性:确保所有数量都被匹配,避免丢失
可维护性:模块化设计,调试输出完善
该代码实现了从半结构化PDF到结构化Excel的自动化转换,适用于批量处理订单文件。
'''
可以从pdf读出结构化的尺码和订单数据信息,直接打印出来是这样
找到尺码行: 5 5.5 6 6.5 7 7.5 8 8.5 9 9.5 10 10.5 11 12
找到数量行: 1 1 1 1 1 1 1 1 1
尺码行: ' 5 5.5 6 6.5 7 7.5 8 8.5 9 9.5 10 10.5 11 12 '
数量行: ' 1 1 1 1 1 1 1 1 1
前面的空格回来了。
再加入距离匹配算法,让程序不会因为5和5.5占用的字符不同导致匹配出错。
一对一匹配:每个尺码最多一个数量,每个数量只匹配一次
避免丢失:被替换的数量会重新匹配,确保所有数量都被使用
位置精确:使用文本位置中心点计算距离,而非简单顺序匹配
最后生成excel表格,把每个商品放在单独的sheet中,至此问题解决。