Files
kitchen/scripts/test_pdf_export_verify.dart
Developer 3c90407bb5 3d
2026-04-25 01:18:50 +08:00

395 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 2026-04-25 | test_pdf_export_verify.dart | 验证PDF导出功能
// 验证: 中文显示、字体嵌入、图片嵌入、文件结构完整性
import 'dart:io';
import 'dart:typed_data';
void main() async {
print('========================================');
print(' PDF 导出功能验证脚本');
print('========================================\n');
await _test1_PdfGeneratorCreation();
await _test2_ChineseTextRendering();
await _test3_EmbeddedFontSupport();
await _test4_ImageEmbedding();
await _test5_FullRecipeExport();
await _test6_OutputFileStructure();
print('\n========================================');
print(' 全部测试完成');
print('========================================');
}
Future<void> _test1_PdfGeneratorCreation() async {
print('[测试1] PdfGenerator 基础创建...');
try {
final pdfGen = gee.PdfGenerator(fontSize: 12);
final doc = gee.Document(title: 'Test', author: 'Tester');
doc.addParagraph(gee.Paragraph.text('Hello World'));
final bytes = pdfGen.generate(doc);
if (bytes.isNotEmpty && bytes.length > 100) {
print(' ✅ 基础PDF生成成功 (${bytes.length} bytes)');
} else {
print(' ❌ PDF字节过短或为空');
}
} catch (e) {
print(' ❌ 创建失败: $e');
}
}
Future<void> _test2_ChineseTextRendering() async {
print('\n[测试2] 中文文本渲染(无嵌入字体)...');
try {
final pdfGen = gee.PdfGenerator(fontSize: 13);
final doc = gee.Document(title: '中文测试', author: '小妈厨房');
doc.addParagraph(gee.Paragraph.heading('菇笋萝卜豆腐汤', level: 1));
doc.addParagraph(gee.Paragraph.text('这是一道美容养颜的汤品'));
doc.addParagraph(gee.Paragraph.bulletItem('胡萝卜 100g'));
doc.addParagraph(gee.Paragraph.bulletItem('虾仁 50g'));
doc.addParagraph(gee.Paragraph.numberedItem('将所有食材洗净切块'));
doc.addParagraph(gee.Paragraph.numberedItem('锅中加水烧开'));
final bytes = pdfGen.generate(doc);
if (_isValidPdf(bytes)) {
print(' ✅ PDF结构有效 (${bytes.length} bytes)');
print(' ⚠️ 注意: 无嵌入字体时中文可能显示为 ???');
} else {
print(' ❌ PDF结构无效');
}
_saveTestFile(bytes, 'test2_chinese_no_font.pdf');
} catch (e) {
print(' ❌ 失败: $e');
}
}
Future<void> _test3_EmbeddedFontSupport() async {
print('\n[测试3] 嵌入中文字体支持...');
try {
final fontPath = 'assets/fonts/NotoSansSC-Regular.otf';
final boldFontPath = 'assets/fonts/NotoSansSC-Bold.otf';
final fontFile = File(fontPath);
final boldFontFile = File(boldFontPath);
Uint8List? fontBytes;
Uint8List? boldFontBytes;
if (await fontFile.exists()) {
fontBytes = await fontFile.readAsBytes();
print(' 📝 加载常规字体: ${fontBytes!.length} bytes');
} else {
print(' ⚠️ 字体文件不存在: $fontPath');
}
if (await boldFontFile.exists()) {
boldFontBytes = await boldFontFile.readAsBytes();
print(' 📝 加载粗体字体: ${boldFontBytes!.length} bytes');
} else {
print(' ⚠️ 粗体字体不存在: $boldFontPath');
}
final pdfGen = gee.PdfGenerator(
fontSize: 13,
embeddedFontBytes: fontBytes,
embeddedBoldFontBytes: boldFontBytes,
);
final doc = gee.Document(title: '中文菜谱', author: '小妈厨房');
doc.addParagraph(gee.Paragraph.heading('🍲 菇笋萝卜豆腐汤', level: 1));
doc.addParagraph(gee.Paragraph.text('分类: 美容养颜食谱'));
doc.addParagraph(gee.Paragraph.quote('清淡营养,适合四季食用'));
doc.addParagraph(gee.Paragraph.heading('食材', level: 2));
doc.addTable(gee.Table(rows: [
gee.TableRow(cells: [gee.TableCell.text('名称'), gee.TableCell.text('用量')]),
gee.TableRow(cells: [gee.TableCell.text('胡萝卜'), gee.TableCell.text('100g')]),
gee.TableRow(cells: [gee.TableCell.text('虾仁'), gee.TableCell.text('50g')]),
gee.TableRow(cells: [gee.TableCell.text('青豆'), gee.TableCell.text('30g')]),
]));
doc.addParagraph(gee.Paragraph.heading('制作步骤', level: 2));
doc.addParagraph(gee.Paragraph.numberedItem('将所有食材洗净切块'));
doc.addParagraph(gee.Paragraph.numberedItem('锅中加水放入胡萝卜煮3分钟'));
doc.addParagraph(gee.Paragraph.numberedItem('加入虾仁和青豆煮熟即可'));
doc.addParagraph(gee.Paragraph.heading('标签', level: 2));
doc.addParagraph(gee.Paragraph.bulletItem('汤类 · 养生 · 家常菜'));
final bytes = pdfGen.generate(doc);
if (_isValidPdf(bytes)) {
print(' ✅ 带嵌入字体的PDF生成成功 (${bytes.length} bytes)');
final hasFontDesc = _containsBytes(bytes, '/FontDescriptor');
final hasCIDFont = _containsBytes(bytes, '/CIDFontType2');
final hasIdentityH = _containsBytes(bytes, '/Identity-H');
print(' FontDescriptor: ${hasFontDesc ? "" : ""}');
print(' CIDFontType2: ${hasCIDFont ? "" : ""}');
print(' Identity-H: ${hasIdentityH ? "" : ""}');
if (hasFontDesc && hasCIDFont && hasIdentityH) {
print(' ✅ CJK字体嵌入完整');
}
} else {
print(' ❌ PDF结构无效');
}
_saveTestFile(bytes, 'test3_cjk_font.pdf');
} catch (e) {
print(' ❌ 失败: $e');
}
}
Future<void> _test4_ImageEmbedding() async {
print('\n[测试4] 图片嵌入测试...');
Uint8List? fakeJpegBytes;
try {
fakeJpegBytes = _createFakeJpeg(200, 150);
print(' 📷 生成模拟JPEG图片: ${fakeJpegBytes!.length} bytes');
} catch (e) {
print(' ⚠️ 无法创建模拟图片: $e');
}
try {
final pdfGen = gee.PdfGenerator(
fontSize: 13,
coverImageBytes: fakeJpegBytes,
coverImageWidth: 300,
coverImageHeight: 225,
);
final doc = gee.Document(title: '带图片的文档', author: 'Tester');
doc.addParagraph(gee.Paragraph.heading('菜品展示', level: 1));
doc.addParagraph(gee.Paragraph.text('上面是这道菜的精美图片'));
doc.addParagraph(gee.Paragraph.quote('色香味俱全'));
final bytes = pdfGen.generate(doc);
if (_isValidPdf(bytes)) {
print(' ✅ 带图片的PDF生成成功 (${bytes.length} bytes)');
final hasXObject = _containsBytes(bytes, '/XObject');
final hasImage = _containsBytes(bytes, '/Subtype /Image');
final hasIm0 = _containsBytes(bytes, '/Im0');
print(' XObject资源: ${hasXObject ? "" : ""}');
print(' Image对象: ${hasImage ? "" : ""}');
print(' Im0引用: ${hasIm0 ? "" : ""}');
}
_saveTestFile(bytes, 'test4_with_image.pdf');
} catch (e) {
print(' ❌ 失败: $e');
}
}
Future<void> _test5_FullRecipeExport() async {
print('\n[测试5] 完整菜谱导出(字体+图片)...');
try {
final fontFile = File('assets/fonts/NotoSansSC-Regular.otf');
final boldFontFile = File('assets/fonts/NotoSansSC-Bold.otf');
final fontBytes = await fontFile.exists() ? await fontFile.readAsBytes() : null;
final boldFontBytes = await boldFontFile.exists() ? await boldFontFile.readAsBytes() : null;
final imgBytes = _createFakeJpeg(400, 300);
final pdfGen = gee.PdfGenerator(
fontSize: 13,
embeddedFontBytes: fontBytes,
embeddedBoldFontBytes: boldFontBytes,
coverImageBytes: imgBytes,
coverImageWidth: 360,
coverImageHeight: 270,
);
final doc = gee.Document(title: '菇笋萝卜豆腐汤', author: '小妈厨房');
doc.addParagraph(gee.Paragraph.heading('🍲 菇笋萝卜豆腐汤', level: 1));
doc.addParagraph(gee.Paragraph.text('📂 分类: 美容养颜食谱'));
doc.addParagraph(gee.Paragraph.text('✍️ 作者: 小妈厨房'));
doc.addParagraph(gee.Paragraph.quote('清淡营养,适合四季食用。这道汤品富含多种维生素和蛋白质。'));
doc.addParagraph(gee.Paragraph.heading('🥘 食材清单', level: 2));
doc.addTable(gee.Table(rows: [
gee.TableRow(cells: [gee.TableCell.text('食材'), gee.TableCell.text('用量')]),
gee.TableRow(cells: [gee.TableCell.text('新鲜香菇'), gee.TableCell.text('100g')]),
gee.TableRow(cells: [gee.TableCell.text('冬笋'), gee.TableCell.text('80g')]),
gee.TableRow(cells: [gee.TableCell.text('白萝卜'), gee.TableCell.text('150g')]),
gee.TableRow(cells: [gee.TableCell.text('嫩豆腐'), gee.TableCell.text('1块')]),
gee.TableRow(cells: [gee.TableCell.text('枸杞'), gee.TableCell.text('10粒')]),
]));
doc.addParagraph(gee.Paragraph.heading('👨‍🍳 制作步骤', level: 2));
doc.addParagraph(gee.Paragraph.numberedItem('香菇用温水泡发后切片,冬笋去壳切片焯水去涩'));
doc.addParagraph(gee.Paragraph.numberedItem('白萝卜去皮切滚刀块豆腐切成2cm见方的小块'));
doc.addParagraph(gee.Paragraph.numberedItem('锅中加清水1200ml放入萝卜块大火煮开转中火10分钟'));
doc.addParagraph(gee.Paragraph.numberedItem('加入香菇片、冬笋片继续煮5分钟'));
doc.addParagraph(gee.Paragraph.numberedItem('轻轻放入豆腐块加盐调味小火炖3分钟'));
doc.addParagraph(gee.Paragraph.numberedItem('撒入枸杞关火焖1分钟即可上桌'));
doc.addParagraph(gee.Paragraph.heading('💡 小贴士', level: 2));
doc.addParagraph(gee.Paragraph.bulletItem('豆腐要最后放,避免煮散'));
doc.addParagraph(gee.Paragraph.bulletItem('萝卜先煮更入味'));
doc.addParagraph(gee.Paragraph.bulletItem('可根据个人口味加入少许胡椒粉'));
doc.addParagraph(gee.Paragraph.heading('🏷️ 标签', level: 2));
doc.addParagraph(gee.Paragraph.bulletItem('汤类 · 养生 · 家常菜 · 四季适用'));
final bytes = pdfGen.generate(doc);
if (_isValidPdf(bytes)) {
print(' ✅ 完整菜谱PDF生成成功 (${bytes.length} bytes)');
final checks = <String, bool>{
'PDF头': _startsWithPdfHeader(bytes),
'FontDescriptor': _containsBytes(bytes, '/FontDescriptor'),
'CIDFont': _containsBytes(bytes, '/CIDFontType2'),
'Identity-H编码': _containsBytes(bytes, '/Identity-H'),
'XObject图片': _containsBytes(bytes, '/XObject'),
'Image对象': _containsBytes(bytes, '/Subtype /Image'),
'表格数据': _containsBytes(bytes, '香菇'),
'步骤内容': _containsBytes(bytes, '豆腐块'),
'EOF标记': _endsWithEof(bytes),
};
for (final entry in checks.entries) {
print(' ${entry.value ? "" : ""} ${entry.key}');
}
final passCount = checks.values.where((b) => b).length;
print('\n 通过率: $passCount/${checks.length}');
}
_saveTestFile(bytes, 'test5_full_recipe.pdf');
} catch (e) {
print(' ❌ 失败: $e');
print(' ${StackTrace.current}');
}
}
Future<void> _test6_OutputFileStructure() async {
print('\n[测试6] 输出文件结构分析...');
final dir = Directory('.');
final files = await dir.list().where((f) => f.path.endsWith('.pdf')).toList();
if (files.isEmpty) {
print(' ⚠️ 未找到生成的PDF文件');
return;
}
for (final file in files) {
if (!file.path.contains('test')) continue;
final name = file.uri.pathSegments.last;
final stat = await file.stat();
final bytes = await (file as File).readAsBytes();
print('\n 📄 $name');
print(' 大小: ${stat.size} bytes (${(stat.size / 1024).toStringAsFixed(1)} KB)');
print(' 有效PDF: ${_isValidPdf(bytes)}');
if (_containsBytes(bytes, '%PDF-')) {
final version = String.fromCharCodes(bytes.sublist(0, 8)).trim();
print(' 版本: $version');
}
if (_containsBytes(bytes, '/FontDescriptor')) {
print(' ✅ 包含嵌入字体');
}
if (_containsBytes(bytes, '/XObject')) {
print(' ✅ 包含图片资源');
}
}
}
bool _isValidPdf(Uint8List bytes) {
return _startsWithPdfHeader(bytes) && _endsWithEof(bytes);
}
bool _startsWithPdfHeader(Uint8List bytes) {
if (bytes.length < 5) return false;
return String.fromCharCodes(bytes.sublist(0, 5)).startsWith('%PDF-');
}
bool _endsWithEof(Uint8List bytes) {
if (bytes.length < 5) return false;
final end = String.fromCharCodes(bytes.sublist(bytes.length - 5));
return end.trim().endsWith('%%EOF');
}
bool _containsBytes(Uint8List bytes, String pattern) {
final patternBytes = utf8.encode(pattern);
for (int i = 0; i <= bytes.length - patternBytes.length; i++) {
bool match = true;
for (int j = 0; j < patternBytes.length; j++) {
if (bytes[i + j] != patternBytes[j]) { match = false; break; }
}
if (match) return true;
}
return false;
}
Uint8List _createFakeJpeg(int width, int height) {
final jpegHeader = [
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01,
0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF,
0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04,
0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00,
0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32,
0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1,
0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A,
0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35,
0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55,
0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65,
0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94,
0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2,
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6,
0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA,
0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00,
];
final pixelData = List.filled(width * height * 3, 0x80);
final jpegTail = [0xFF, 0xD9];
return Uint8List.fromList([...jpegHeader, ...pixelData, ...jpegTail]);
}
Future<void> _saveTestFile(Uint8List bytes, String fileName) async {
try {
final file = File(fileName);
await file.writeAsBytes(bytes);
print(' 💾 已保存: ${file.absolute.path}');
} catch (e) {
print(' ⚠️ 无法保存文件: $e');
}
}