diff --git a/artifacts/go-benchmark/issue/xlsx/report/benchmark_coverage.json b/artifacts/go-benchmark/issue/xlsx/report/benchmark_coverage.json index 35f4301a..c5db50bc 100644 --- a/artifacts/go-benchmark/issue/xlsx/report/benchmark_coverage.json +++ b/artifacts/go-benchmark/issue/xlsx/report/benchmark_coverage.json @@ -16,7 +16,7 @@ "missing_auxiliary_references": 0, "comparison_completed": true, "comparison_results": 24, - "average_score": 0.49293750000000003, + "average_score": 0.5162083333333333, "cases": [ { "name": "Academic Achievement Summary Table", @@ -25,7 +25,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Academic Achievement Summary Table.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -37,7 +37,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/AcademicAchievement_temp.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -49,7 +49,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Business expense budget1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -61,7 +61,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Business expenses budget2.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -73,7 +73,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Business plan checklist with SWOT analysis1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -85,7 +85,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Event budget1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -97,7 +97,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Expense report basic1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -109,7 +109,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Grocery list1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -121,7 +121,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Issue202609031340.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -133,7 +133,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/payroll-calculator_f.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -145,7 +145,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/PO_anonymized.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -157,7 +157,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Simple invoice1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -169,7 +169,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Small business cash flow forecast1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -181,7 +181,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Wedding_timeline_planner1_copy.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -193,7 +193,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Weekly schedule planner1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -205,7 +205,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue152_ClusteredNonZeroBarChart.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -217,7 +217,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue75.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -229,7 +229,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue77_MergedCellAlignment.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -241,7 +241,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue77_Template1.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -253,7 +253,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue77_Template2_Workaround.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -265,7 +265,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue81_LayoutOptions.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -277,7 +277,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue82_5mb.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -289,7 +289,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue82_SampleTestData5mb.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true @@ -301,7 +301,7 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/XlsxIssue82_WideTable.xlsx", "conversion_status": "passed", - "conversion_exit_code": null, + "conversion_exit_code": 0, "candidate_exists": true, "reference_exists": true, "auxiliary_reference_exists": true diff --git a/artifacts/go-benchmark/issue/xlsx/report/comparison_report.json b/artifacts/go-benchmark/issue/xlsx/report/comparison_report.json index c5bd219d..66ec16ad 100644 --- a/artifacts/go-benchmark/issue/xlsx/report/comparison_report.json +++ b/artifacts/go-benchmark/issue/xlsx/report/comparison_report.json @@ -182,22 +182,22 @@ "format": "xlsx", "source_path": "tests/Issue_Files/xlsx/Business expense budget1.xlsx", "suite": "issue", - "minipdf_size": 4633, + "minipdf_size": 85664, "reference_size": 159864, "pdf_valid": true, - "minipdf_pages": 2, + "minipdf_pages": 4, "reference_pages": 4, "compared_pages": 3, - "text_similarity": 0.4638, - "flat_text_similarity": 0.0771, - "word_text_similarity": 0.4638, - "text_diff": "--- minipdf/Business expense budget1.pdf\n+++ reference/Business expense budget1.pdf\n@@ -1,42 +1,73 @@\n BUSINESS EXPENSE BUDGET\n\n Fiscal Year 2026 - Quarterly Report\n\n-CATEGORY Q1 BUDGET Q1 ACTUAL Q2 BUDGET Q2 ACTUAL VARIANCE\n\n+CATEGORY Q1 BUDGET Q1 ACTUAL Q2 BUDGET\n\n PERSONNEL\n\n-Salaries & Wages 45000 44200 46000 45800 1000\n\n-Benefits & Insurance 12000 11850 12500 12400 250\n\n-Training & Development 3000 2800 3200 3500 -100\n\n-Recruitment 2000 1500 2500 2200 800\n\n-Personnel Subtotal\n\n+Salaries & Wages 45,000.00 44,200.00 46,000.00\n\n+Benefits & Insurance 12,000.00 11,850.00 12,500.00\n\n+Training & Development 3,000.00 2,800.00 3,200.00\n\n+Recruitment 2,000.00 1,500.00 2,500.00\n\n+Personnel Subtotal 62,000.00 60,350.00 64,200.00\n\n OPERATIONS\n\n-Rent & Utilities 8000 8000 8200 8150 50\n\n-Office Supplies 1500 1650 1500 1400 -50\n\n-Equipment & Maintenance 3000 2700 3500 3800 0\n\n-IT & Software 4000 4200 4500 4300 0\n\n-Telecommunications 2000 1900 2100 2050 150\n\n-Operations Subtotal\n\n+Rent & Utilities 8,000.00 8,000.00 8,200.00\n\n+Office Supplies 1,500.00 1,650.00 1,500.00\n\n+Equipment & Maintenance 3,000.00 2,700.00 3,500.00\n\n+IT & Software 4,000.00 4,200.00 4,500.00\n\n+Telecommunications 2,000.00 1,900.00 2,100.00\n\n+Operations Subtotal 18,500.00 18,450.00 19,800.00\n\n MARKETING\n\n-Advertising 5000 4800 6000 5500 700\n\n-Digital Marketing 3000 3200 3500 3600 -300\n\n-Events & Sponsorships 2000 1800 2500 2800 -100\n\n-Printing & Design 1000 950 1200 1100 150\n\n-Marketing Subtotal\n\n+Advertising 5,000.00 4,800.00 6,000.00\n\n+Digital Marketing 3,000.00 3,200.00 3,500.00\n\n+Events & Sponsorships 2,000.00 1,800.00 2,500.00\n\n+Printing & Design 1,000.00 950.00 1,200.00\n\n+Marketing Subtotal 11,000.00 10,750.00 13,200.00\n\n TRAVEL & ENTERTAINMENT\n\n-Domestic Travel 3000 2500 3500 3200 800\n\n-International Travel 5000 4000 6000 5500 1500\n\n-Meals & Entertainment 2000 1800 2200 2100 300\n\n-Vehicle Expenses 1500 1400 1500 1600 0\n\n-Travel & Entertainment Subtotal\n\n+Domestic Travel 3,000.00 2,500.00 3,500.00\n\n+International Travel 5,000.00 4,000.00 6,000.00\n\n+Meals & Entertainment 2,000.00 1,800.00 2,200.00\n\n+Vehicle Expenses 1,500.00 1,400.00 1,500.00\n\n+Travel & Entertainment Subtotal 11,500.00 9,700.00 13,200.00\n\n PROFESSIONAL SERVICES\n\n-Legal Fees 3000 2800 3500 3200 500\n\n-Accounting & Audit 2500 2500 2500 2500 0\n\n-Consulting 4000 3500 5000 4800 700\n\n-Insurance 2000 2000 2100 2100 0\n\n-Professional Services Subtotal\n\n-GRAND TOTAL\n\n+Legal Fees 3,000.00 2,800.00 3,500.00\n\n+Accounting & Audit 2,500.00 2,500.00 2,500.00\n\n+Consulting 4,000.00 3,500.00 5,000.00\n\n+Insurance 2,000.00 2,000.00 2,100.00\n\n+Professional Services Subtotal 11,500.00 10,800.00 13,100.00\n\n+---PAGE---\n\n+GRAND TOTAL 114,500.00 110,050.00 123,500.00\n\n Category Total Budget Total Actual\n\n Personnel 126200 124250\n\n Operations 38300 38150\n\n Marketing 24200 23750\n\n Travel & Entertainment 24700 22100\n\n+Professional Services 24600 23400\n\n+Budget vs Actual by Category\n\n+Amount (\\$)\n\n+Category\n\n ---PAGE---\n\n-Professional Services 24600 23400\n+Q2 ACTUAL VARIANCE\n\n+45,800.00 1,000.00\n\n+12,400.00 250.00\n\n+3,500.00 -100.00\n\n+2,200.00 800.00\n\n+63,900.00 1,950.00\n\n+8,150.00 50.00\n\n+1,400.00 -50.00\n\n+3,800.00 0.00\n\n+4,300.00 0.00\n\n+2,050.00 150.00\n\n+19,700.00 150.00\n\n+5,500.00 700.00\n\n+3,600.00 -300.00\n\n+2,800.00 -100.00\n\n+1,100.00 150.00\n\n+13,000.00 450.00\n\n+3,200.00 800.00\n\n+5,500.00 1,500.00\n\n+2,100.00 300.00\n\n+1,600.00 0.00\n\n+12,400.00 2,600.00\n\n+3,200.00 500.00\n\n+2,500.00 0.00\n\n+4,800.00 700.00\n\n+2,100.00 0.00\n\n+12,600.00 1,200.00", + "text_similarity": 1.0, + "flat_text_similarity": 1.0, + "word_text_similarity": 1.0, + "text_diff": "(identical)", "visual_scores": [ - 0.5166, - 0.4059, - 0.0 + 0.8898, + 0.9049, + 0.9575 ], - "visual_avg": 0.3075, + "visual_avg": 0.9174, "diff_images": [ { "page": 1, @@ -211,15 +211,15 @@ 1754 ], "difference_bbox_px": [ - 113, - 127, + 146, + 176, 996, - 1614 + 1579 ], - "changed_pixels": 713208, - "changed_fraction": 0.327654, - "mean_abs_rgb": 40.6046, - "rmse_rgb": 86.4236, + "changed_pixels": 305806, + "changed_fraction": 0.14049, + "mean_abs_rgb": 21.3092, + "rmse_rgb": 63.9244, "threshold": 12, "gain": 5.0 }, @@ -237,15 +237,15 @@ 1754 ], "difference_bbox_px": [ - 114, - 96, + 112, + 178, 996, - 1225 + 1226 ], - "changed_pixels": 212045, - "changed_fraction": 0.097415, - "mean_abs_rgb": 14.3262, - "rmse_rgb": 50.0036, + "changed_pixels": 207301, + "changed_fraction": 0.095236, + "mean_abs_rgb": 11.1807, + "rmse_rgb": 40.9815, "threshold": 12, "gain": 5.0 }, @@ -253,13 +253,32 @@ }, { "page": 3, - "minipdf_img": null, + "minipdf_img": "Business expense budget1_p3_minipdf.png", "reference_img": "Business expense budget1_p3_reference.png", "auxiliary_img": "Business expense budget1_p3_libreoffice.png", + "heatmap_img": "Business expense budget1_p3_heatmap.png", + "heatmap_metrics": { + "size_px": [ + 1241, + 1754 + ], + "difference_bbox_px": [ + 111, + 296, + 444, + 1579 + ], + "changed_pixels": 109374, + "changed_fraction": 0.050247, + "mean_abs_rgb": 7.5647, + "rmse_rgb": 37.8974, + "threshold": 12, + "gain": 5.0 + }, "composite_img": "Business expense budget1_p3_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png" } ], - "overall_score": 0.4085 + "overall_score": 0.967 }, { "name": "Business expenses budget2", diff --git a/artifacts/go-benchmark/issue/xlsx/report/comparison_report.md b/artifacts/go-benchmark/issue/xlsx/report/comparison_report.md index 22705a13..ccd41446 100644 --- a/artifacts/go-benchmark/issue/xlsx/report/comparison_report.md +++ b/artifacts/go-benchmark/issue/xlsx/report/comparison_report.md @@ -1,6 +1,6 @@ # go MiniPdf vs Microsoft 365 Excel Reference PDF Comparison Report -Generated: 2026-09-14T20:24:21.049172 +Generated: 2026-09-14T22:49:48.552791 ## Summary @@ -8,7 +8,7 @@ Generated: 2026-09-14T20:24:21.049172 |---|-----------|-------|----------|------------|-------------|--------| | 1 | 🔴 Academic Achievement Summary Table | ✅ | 0.3235 | 0.4218 | 9/2 | **0.3981** | | 2 | 🔴 AcademicAchievement_temp | ✅ | 0.3235 | 0.4218 | 9/2 | **0.3981** | -| 3 | 🔴 Business expense budget1 | ✅ | 0.4638 | 0.3075 | 2/4 | **0.4085** | +| 3 | 🟢 Business expense budget1 | ✅ | 1.0 | 0.9174 | 4/4 | **0.967** | | 4 | 🔴 Business expenses budget2 | ✅ | 0.119 | 0.0808 | 14/4 | **0.1799** | | 5 | 🔴 Business plan checklist with SWOT analysis1 | ✅ | 0.8729 | 0.2639 | 1/1 | **0.6547** | | 6 | 🔴 Event budget1 | ✅ | 0.712 | 0.5681 | 4/5 | **0.612** | @@ -31,7 +31,7 @@ Generated: 2026-09-14T20:24:21.049172 | 23 | 🔴 XlsxIssue82_WideTable | ✅ | 0.8139 | 0.5165 | 15/13 | **0.6322** | | 24 | 🟡 XlsxIssue152_ClusteredNonZeroBarChart | ✅ | 0.6923 | 0.5806 | 1/1 | **0.7092** | -**Average Overall Score: 0.4929** +**Average Overall Score: 0.5162** ## Labeled Side-by-Side Comparison @@ -296,12 +296,17 @@ Blue areas are below the configured difference threshold; red areas have stronge Business expense budget1
Page 1 Business expense budget1 page 1 difference heatmap - changed: 713208 px (32.77%)
bbox: [113, 127, 996, 1614]
mean abs RGB: 40.6046
RMSE RGB: 86.4236
threshold: 12, gain: 5.0 + changed: 305806 px (14.05%)
bbox: [146, 176, 996, 1579]
mean abs RGB: 21.3092
RMSE RGB: 63.9244
threshold: 12, gain: 5.0 Business expense budget1
Page 2 Business expense budget1 page 2 difference heatmap - changed: 212045 px (9.74%)
bbox: [114, 96, 996, 1225]
mean abs RGB: 14.3262
RMSE RGB: 50.0036
threshold: 12, gain: 5.0 + changed: 207301 px (9.52%)
bbox: [112, 178, 996, 1226]
mean abs RGB: 11.1807
RMSE RGB: 40.9815
threshold: 12, gain: 5.0 + + + Business expense budget1
Page 3 + Business expense budget1 page 3 difference heatmap + changed: 109374 px (5.02%)
bbox: [111, 296, 444, 1579]
mean abs RGB: 7.5647
RMSE RGB: 37.8974
threshold: 12, gain: 5.0 Business expenses budget2
Page 1 @@ -601,7 +606,7 @@ Scores compare go MiniPdf against Microsoft 365 Excel Reference. LibreOffice is Business expense budget1
format: xlsx | case: Business expense budget1 | scope: go-issue-xlsx
- Business expense budget1 ⬤ 40.8% + Business expense budget1 ⬤ 96.7% go MiniPdf @@ -614,7 +619,7 @@ Scores compare go MiniPdf against Microsoft 365 Excel Reference. LibreOffice is LibreOffice - missing + go MiniPdf Microsoft 365 Excel Reference LibreOffice @@ -1265,171 +1270,13 @@ Scores compare go MiniPdf against Microsoft 365 Excel Reference. LibreOffice is - **Case Metadata:** format: xlsx | case: Business expense budget1 | scope: go-issue-xlsx - **Source:** tests/Issue_Files/xlsx/Business expense budget1.xlsx -- **Text Similarity:** 0.4638 -- **Visual Average:** 0.3075 -- **Overall Score:** 0.4085 -- **Pages:** MiniPdf=2, Reference=4 -- **File Size:** MiniPdf=4633 bytes, Reference=159864 bytes - -
Text Diff - -```diff ---- minipdf/Business expense budget1.pdf -+++ reference/Business expense budget1.pdf -@@ -1,42 +1,73 @@ - BUSINESS EXPENSE BUDGET - - Fiscal Year 2026 - Quarterly Report - --CATEGORY Q1 BUDGET Q1 ACTUAL Q2 BUDGET Q2 ACTUAL VARIANCE - -+CATEGORY Q1 BUDGET Q1 ACTUAL Q2 BUDGET - - PERSONNEL - --Salaries & Wages 45000 44200 46000 45800 1000 - --Benefits & Insurance 12000 11850 12500 12400 250 - --Training & Development 3000 2800 3200 3500 -100 - --Recruitment 2000 1500 2500 2200 800 - --Personnel Subtotal - -+Salaries & Wages 45,000.00 44,200.00 46,000.00 - -+Benefits & Insurance 12,000.00 11,850.00 12,500.00 - -+Training & Development 3,000.00 2,800.00 3,200.00 - -+Recruitment 2,000.00 1,500.00 2,500.00 - -+Personnel Subtotal 62,000.00 60,350.00 64,200.00 - - OPERATIONS - --Rent & Utilities 8000 8000 8200 8150 50 - --Office Supplies 1500 1650 1500 1400 -50 - --Equipment & Maintenance 3000 2700 3500 3800 0 - --IT & Software 4000 4200 4500 4300 0 - --Telecommunications 2000 1900 2100 2050 150 - --Operations Subtotal - -+Rent & Utilities 8,000.00 8,000.00 8,200.00 - -+Office Supplies 1,500.00 1,650.00 1,500.00 - -+Equipment & Maintenance 3,000.00 2,700.00 3,500.00 - -+IT & Software 4,000.00 4,200.00 4,500.00 - -+Telecommunications 2,000.00 1,900.00 2,100.00 - -+Operations Subtotal 18,500.00 18,450.00 19,800.00 - - MARKETING - --Advertising 5000 4800 6000 5500 700 - --Digital Marketing 3000 3200 3500 3600 -300 - --Events & Sponsorships 2000 1800 2500 2800 -100 - --Printing & Design 1000 950 1200 1100 150 - --Marketing Subtotal - -+Advertising 5,000.00 4,800.00 6,000.00 - -+Digital Marketing 3,000.00 3,200.00 3,500.00 - -+Events & Sponsorships 2,000.00 1,800.00 2,500.00 +- **Text Similarity:** 1.0 +- **Visual Average:** 0.9174 +- **Overall Score:** 0.967 +- **Pages:** MiniPdf=4, Reference=4 +- **File Size:** MiniPdf=85664 bytes, Reference=159864 bytes -+Printing & Design 1,000.00 950.00 1,200.00 - -+Marketing Subtotal 11,000.00 10,750.00 13,200.00 - - TRAVEL & ENTERTAINMENT - --Domestic Travel 3000 2500 3500 3200 800 - --International Travel 5000 4000 6000 5500 1500 - --Meals & Entertainment 2000 1800 2200 2100 300 - --Vehicle Expenses 1500 1400 1500 1600 0 - --Travel & Entertainment Subtotal - -+Domestic Travel 3,000.00 2,500.00 3,500.00 - -+International Travel 5,000.00 4,000.00 6,000.00 - -+Meals & Entertainment 2,000.00 1,800.00 2,200.00 - -+Vehicle Expenses 1,500.00 1,400.00 1,500.00 - -+Travel & Entertainment Subtotal 11,500.00 9,700.00 13,200.00 - - PROFESSIONAL SERVICES - --Legal Fees 3000 2800 3500 3200 500 - --Accounting & Audit 2500 2500 2500 2500 0 - --Consulting 4000 3500 5000 4800 700 - --Insurance 2000 2000 2100 2100 0 - --Professional Services Subtotal - --GRAND TOTAL - -+Legal Fees 3,000.00 2,800.00 3,500.00 - -+Accounting & Audit 2,500.00 2,500.00 2,500.00 - -+Consulting 4,000.00 3,500.00 5,000.00 - -+Insurance 2,000.00 2,000.00 2,100.00 - -+Professional Services Subtotal 11,500.00 10,800.00 13,100.00 - -+---PAGE--- - -+GRAND TOTAL 114,500.00 110,050.00 123,500.00 - - Category Total Budget Total Actual - - Personnel 126200 124250 - - Operations 38300 38150 - - Marketing 24200 23750 - - Travel & Entertainment 24700 22100 - -+Professional Services 24600 23400 - -+Budget vs Actual by Category - -+Amount (\$) - -+Category - - ---PAGE--- - --Professional Services 24600 -... (503 more characters) - -``` -
+Text content: ✅ Identical ### Business expenses budget2 @@ -4096,7 +3943,6 @@ Scores compare go MiniPdf against Microsoft 365 Excel Reference. LibreOffice is 1. **PO_anonymized** (score: 0.3912) 1. **Academic Achievement Summary Table** (score: 0.3981) 1. **AcademicAchievement_temp** (score: 0.3981) -1. **Business expense budget1** (score: 0.4085) 1. **Grocery list1** (score: 0.4451) 1. **XlsxIssue82_5mb** (score: 0.4474) 1. **Issue202609031340** (score: 0.4541) diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_heatmap.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_heatmap.png index 01c4bca8..0b0d6fce 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_heatmap.png and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_heatmap.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_minipdf.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_minipdf.png index d794e085..838908e6 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_minipdf.png and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p1_minipdf.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_heatmap.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_heatmap.png index 2fc53901..7322744c 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_heatmap.png and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_heatmap.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_minipdf.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_minipdf.png index 2d0a8c1d..d5a5a19b 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_minipdf.png and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p2_minipdf.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_heatmap.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_heatmap.png new file mode 100644 index 00000000..85efcbf8 Binary files /dev/null and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_heatmap.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_minipdf.png b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_minipdf.png new file mode 100644 index 00000000..3a562bfc Binary files /dev/null and b/artifacts/go-benchmark/issue/xlsx/report/images/Business expense budget1_p3_minipdf.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p1_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p1_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png index 281d3aeb..ff2b7bd9 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p1_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png and b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p1_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p2_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p2_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png index fd139bef..ca2df8b7 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p2_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png and b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p2_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png differ diff --git a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p3_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p3_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png index 68dbcaf5..3fb55732 100644 Binary files a/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p3_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png and b/artifacts/go-benchmark/issue/xlsx/report/side-by-side/Business expense budget1_p3_go_minipdf_vs_microsoft_365_excel_reference_vs_libreoffice.png differ diff --git a/minipdf-go/office_test.go b/minipdf-go/office_test.go index 4c1cb7a0..8390a4a9 100644 --- a/minipdf-go/office_test.go +++ b/minipdf-go/office_test.go @@ -335,6 +335,129 @@ func TestExtractWorksheetFormatsBooleanCells(t *testing.T) { } } +func TestConvertXLSXRendersCellStylesAndDimensions(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/styles.xml": `` + + `` + + `` + + `` + + `` + + ``, + "xl/worksheets/sheet1.xml": `` + + `` + + `HEADER` + + `BudgetSUM(B3:B4)` + + `2000025000` + + `` + + ``, + }) + files, err := openOfficePackage(input) + if err != nil { + t.Fatal(err) + } + worksheet, err := files.read("xl/worksheets/sheet1.xml") + if err != nil { + t.Fatal(err) + } + grid, err := readXLSXGrid(worksheet, nil) + if err != nil { + t.Fatal(err) + } + if grid.maxColumn != 2 || grid.maxRow != 4 { + t.Fatalf("printable grid = %dx%d, want 2x4", grid.maxColumn, grid.maxRow) + } + + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "HEADER", "45,000.00", "0.12156862745098039 0.3058823529411765 0.4745098039215686 rg", " re f", " RG ") +} + +func TestRenderXLSXCellMapsCalibriMetricsAndVerticalAlignment(t *testing.T) { + style := xlsxStyle{font: xlsxFont{name: "Calibri", size: 11, color: PDFColorBlack}} + page := &PDFPage{} + renderXLSXCell(page, xlsxCell{value: "Cell"}, 10, 20, 100, 15, style, true) + + bottom := page.operations[0].(textOperation) + if bottom.fontSize != 11*calibriToHelveticaFontScale || bottom.horizontalScale != calibriRegularWidthScale { + t.Fatalf("Calibri fallback metrics = size %v, scale %v", bottom.fontSize, bottom.horizontalScale) + } + if bottom.y != 20+bottom.fontSize*helveticaBaselineRatio { + t.Fatalf("bottom-aligned baseline = %v", bottom.y) + } + + style.vertical = "center" + style.align = "center" + page.operations = nil + renderXLSXCell(page, xlsxCell{value: "Cell"}, 10, 20, 100, 28, style, true) + centered := page.operations[0].(textOperation) + if centered.y != 20+(28-centered.fontSize)/2 { + t.Fatalf("centered baseline = %v", centered.y) + } + expectedX := 10 + (100-measureHelveticaText("Cell", centered.fontSize, centered.horizontalScale, false))/2 + if centered.x != expectedX { + t.Fatalf("centered x = %v, want %v", centered.x, expectedX) + } + + style.font.bold = true + page.operations = nil + renderXLSXCell(page, xlsxCell{value: "Cell"}, 10, 20, 100, 28, style, true) + bold := page.operations[0].(textOperation) + if bold.horizontalScale != calibriBoldWidthScale { + t.Fatalf("bold Calibri fallback scale = %v", bold.horizontalScale) + } +} + +func TestXLSXDefaultRowHeightUsesExcelPDFScale(t *testing.T) { + grid := xlsxGrid{defaultRowHeight: 15, rowHeights: map[int]float64{2: 22}} + if height := grid.rowHeight(1); height < 14.599 || height > 14.601 { + t.Fatalf("default row height = %v, want 14.6", height) + } + if height := grid.rowHeight(2); height != 22 { + t.Fatalf("explicit row height = %v, want 22", height) + } +} + +func TestConvertXLSXRendersClusteredColumnChart(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/styles.xml": ``, + "xl/worksheets/sheet1.xml": `` + + `CategoryBudgetActual` + + `One108` + + `Two2018` + + `SUM(C2:C3)`, + "xl/worksheets/_rels/sheet1.xml.rels": ``, + "xl/drawings/drawing1.xml": `0040`, + "xl/drawings/_rels/drawing1.xml.rels": ``, + "xl/charts/chart1.xml": `<tx><rich><p><r><t>Budget vs Actual</t></r></p></rich></tx>` + + `Sheet1!B1Sheet1!A2:A3Sheet1!B2:B3` + + `Sheet1!C1Sheet1!A2:A3Sheet1!C2:C3` + + `<tx><rich><p><r><t>Category</t></r></p></rich></tx>` + + `<tx><rich><p><r><t>Amount</t></r></p></rich></tx>`, + }) + + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "Budget vs Actual", "Budget", "Actual", "Category", "Amount", "0.31 0.506 0.741 rg", "0.753 0.314 0.302 rg") +} + +func TestNeedsCalculatedXLSXGridOnlyForFullyUncachedFormulas(t *testing.T) { + for name, test := range map[string]struct { + cells string + want bool + }{ + "uncached": {cells: `SUM(A2:A3)`, want: true}, + "cached": {cells: `SUM(A2:A3)3`}, + "mixed": {cells: `A2B22`}, + "plain": {cells: `1`}, + } { + t.Run(name, func(t *testing.T) { + worksheet := []byte(`` + test.cells + ``) + if got := needsCalculatedXLSXGrid(worksheet); got != test.want { + t.Fatalf("needsCalculatedXLSXGrid() = %v, want %v", got, test.want) + } + }) + } +} + func TestXLSXRowColumnLimitsAndOrientation(t *testing.T) { input := officePackageBytes(t, map[string]string{ "xl/workbook.xml": ``, diff --git a/minipdf-go/pdf.go b/minipdf-go/pdf.go index 5d37b60d..fe5ec9d1 100644 --- a/minipdf-go/pdf.go +++ b/minipdf-go/pdf.go @@ -53,7 +53,13 @@ func (document *PDFDocument) AddPage(width, height float64) *PDFPage { func (page *PDFPage) AddText(text string, x, y, fontSize float64, color PDFColor, bold bool) { page.operations = append(page.operations, textOperation{ - text: text, x: x, y: y, fontSize: fontSize, color: color, bold: bold, + text: text, x: x, y: y, fontSize: fontSize, horizontalScale: 100, color: color, bold: bold, + }) +} + +func (page *PDFPage) addScaledText(text string, x, y, fontSize, horizontalScale float64, color PDFColor, bold bool) { + page.operations = append(page.operations, textOperation{ + text: text, x: x, y: y, fontSize: fontSize, horizontalScale: horizontalScale, color: color, bold: bold, }) } @@ -70,17 +76,23 @@ func (page *PDFPage) AddLine(x1, y1, x2, y2 float64, color PDFColor, width float } type textOperation struct { - text string - x, y, fontSize float64 - color PDFColor - bold bool + text string + x, y, fontSize float64 + horizontalScale float64 + color PDFColor + bold bool } func (operation textOperation) appendPDF(buffer *bytes.Buffer, embedded *embeddedFont) { + horizontalScale := "" + if operation.horizontalScale != 0 && operation.horizontalScale != 100 { + horizontalScale = pdfNumber(operation.horizontalScale) + " Tz " + } if embedded != nil { if encoded, ok := embedded.encode(operation.text); ok { - fmt.Fprintf(buffer, "BT /FU1 %s Tf %s %s %s rg %s %s Td <%s> Tj ET\n", + fmt.Fprintf(buffer, "BT /FU1 %s Tf %s%s %s %s rg %s %s Td <%s> Tj ET\n", pdfNumber(operation.fontSize), + horizontalScale, pdfNumber(operation.color.Red), pdfNumber(operation.color.Green), pdfNumber(operation.color.Blue), @@ -95,9 +107,10 @@ func (operation textOperation) appendPDF(buffer *bytes.Buffer, embedded *embedde if operation.bold { font = "F2" } - fmt.Fprintf(buffer, "BT /%s %s Tf %s %s %s rg %s %s Td (%s) Tj ET\n", + fmt.Fprintf(buffer, "BT /%s %s Tf %s%s %s %s rg %s %s Td (%s) Tj ET\n", font, pdfNumber(operation.fontSize), + horizontalScale, pdfNumber(operation.color.Red), pdfNumber(operation.color.Green), pdfNumber(operation.color.Blue), diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go index 21944730..7800a98d 100644 --- a/minipdf-go/xlsx.go +++ b/minipdf-go/xlsx.go @@ -21,6 +21,26 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { if len(worksheets) == 0 { return nil, errorsNewMissingWorksheets() } + styles, err := readXLSXStyles(files) + if err != nil { + return nil, err + } + if len(styles) > 0 { + needsGridRendering := false + for _, name := range worksheets { + worksheetXML, readErr := files.read(name) + if readErr != nil { + return nil, readErr + } + if needsCalculatedXLSXGrid(worksheetXML) { + needsGridRendering = true + break + } + } + if needsGridRendering { + return renderXLSXWorksheets(files, worksheets, sharedStrings, styles, options) + } + } pages := make([]textPage, 0, len(worksheets)) fallbackPageSize := PageSizeA4 for _, name := range worksheets { @@ -255,24 +275,34 @@ func worksheetColumnIndex(reference string) int { } func decodeWorksheetCell(decoder *xml.Decoder, start xml.StartElement, sharedStrings []string) (string, error) { + value, _, err := decodeWorksheetCellContent(decoder, start, sharedStrings) + return value, err +} + +func decodeWorksheetCellContent(decoder *xml.Decoder, start xml.StartElement, sharedStrings []string) (string, string, error) { cellType := attrValue(start, "t") var value strings.Builder + var formula string depth := 1 for depth > 0 { token, err := decoder.Token() if err != nil { - return "", err + return "", "", err } switch element := token.(type) { case xml.StartElement: depth++ - if element.Name.Local == "v" || element.Name.Local == "t" { + if element.Name.Local == "v" || element.Name.Local == "t" || element.Name.Local == "f" { var text string if err := decoder.DecodeElement(&text, &element); err != nil { - return "", err + return "", "", err } depth-- - value.WriteString(text) + if element.Name.Local == "f" { + formula = text + } else { + value.WriteString(text) + } } case xml.EndElement: depth-- @@ -282,16 +312,16 @@ func decodeWorksheetCell(decoder *xml.Decoder, start xml.StartElement, sharedStr if cellType == "s" { index, err := strconv.Atoi(strings.TrimSpace(rawValue)) if err == nil && index >= 0 && index < len(sharedStrings) { - return sharedStrings[index], nil + return sharedStrings[index], formula, nil } } if cellType == "b" { switch strings.TrimSpace(rawValue) { case "1": - return "TRUE", nil + return "TRUE", formula, nil case "0": - return "FALSE", nil + return "FALSE", formula, nil } } - return rawValue, nil + return rawValue, formula, nil } diff --git a/minipdf-go/xlsx_chart.go b/minipdf-go/xlsx_chart.go new file mode 100644 index 00000000..934b7338 --- /dev/null +++ b/minipdf-go/xlsx_chart.go @@ -0,0 +1,457 @@ +package minipdf + +import ( + "encoding/xml" + "fmt" + "math" + "path" + "strconv" + "strings" +) + +const xlsxEmuPerPoint = 12700 + +type xlsxChartSeries struct { + name string + categories []string + values []float64 +} + +type xlsxChart struct { + anchorRow, anchorColumn int + rowOffset, columnOffset float64 + width, height float64 + title, categoryTitle string + valueTitle, legendPosition string + gapWidth float64 + series []xlsxChartSeries +} + +type xlsxRelationshipsDocument struct { + Items []struct { + ID string `xml:"Id,attr"` + Type string `xml:"Type,attr"` + Target string `xml:"Target,attr"` + } `xml:"Relationship"` +} + +type xlsxDrawingMarker struct { + Column int `xml:"col"` + ColumnOffset int64 `xml:"colOff"` + Row int `xml:"row"` + RowOffset int64 `xml:"rowOff"` +} + +type xlsxChartAnchor struct { + From xlsxDrawingMarker `xml:"from"` + To xlsxDrawingMarker `xml:"to"` + Extension struct { + Width int64 `xml:"cx,attr"` + Height int64 `xml:"cy,attr"` + } `xml:"ext"` + Frame struct { + Reference struct { + ID string `xml:"id,attr"` + } `xml:"graphic>graphicData>chart"` + } `xml:"graphicFrame"` +} + +type xlsxDrawingDocument struct { + OneCell []xlsxChartAnchor `xml:"oneCellAnchor"` + TwoCell []xlsxChartAnchor `xml:"twoCellAnchor"` +} + +type xlsxChartTitleXML struct { + Runs []string `xml:"tx>rich>p>r>t"` +} + +type xlsxChartSeriesXML struct { + Name string `xml:"tx>v"` + NameFormula string `xml:"tx>strRef>f"` + CategoryNum string `xml:"cat>numRef>f"` + CategoryStr string `xml:"cat>strRef>f"` + Value string `xml:"val>numRef>f"` +} + +type xlsxChartDocument struct { + Chart struct { + Title xlsxChartTitleXML `xml:"title"` + Plot struct { + Bar struct { + Series []xlsxChartSeriesXML `xml:"ser"` + GapWidth struct { + Value float64 `xml:"val,attr"` + } `xml:"gapWidth"` + } `xml:"barChart"` + CategoryAxis struct { + Title xlsxChartTitleXML `xml:"title"` + } `xml:"catAx"` + ValueAxis struct { + Title xlsxChartTitleXML `xml:"title"` + } `xml:"valAx"` + } `xml:"plotArea"` + Legend struct { + Position struct { + Value string `xml:"val,attr"` + } `xml:"legendPos"` + } `xml:"legend"` + } `xml:"chart"` +} + +func readXLSXCharts(files officePackage, worksheetName string, grid *xlsxGrid) ([]xlsxChart, error) { + relationshipsName := xlsxRelationshipPartName(worksheetName) + _, ok := files[relationshipsName] + if !ok { + return nil, nil + } + relationshipsData, err := files.read(relationshipsName) + if err != nil { + return nil, err + } + var relationships xlsxRelationshipsDocument + if err := xml.Unmarshal(relationshipsData, &relationships); err != nil { + return nil, fmt.Errorf("parse %s: %w", relationshipsName, err) + } + drawingName := "" + for _, relationship := range relationships.Items { + if strings.HasSuffix(relationship.Type, "/drawing") { + drawingName = xlsxResolveRelationshipTarget(worksheetName, relationship.Target) + break + } + } + if drawingName == "" { + return nil, nil + } + + drawingData, err := files.read(drawingName) + if err != nil { + return nil, err + } + drawingRelationshipsName := xlsxRelationshipPartName(drawingName) + drawingRelationshipsData, err := files.read(drawingRelationshipsName) + if err != nil { + return nil, err + } + var drawingRelationships xlsxRelationshipsDocument + if err := xml.Unmarshal(drawingRelationshipsData, &drawingRelationships); err != nil { + return nil, fmt.Errorf("parse %s: %w", drawingRelationshipsName, err) + } + chartParts := make(map[string]string) + for _, relationship := range drawingRelationships.Items { + if strings.HasSuffix(relationship.Type, "/chart") { + chartParts[relationship.ID] = xlsxResolveRelationshipTarget(drawingName, relationship.Target) + } + } + + var drawing xlsxDrawingDocument + if err := xml.Unmarshal(drawingData, &drawing); err != nil { + return nil, fmt.Errorf("parse %s: %w", drawingName, err) + } + anchors := append(drawing.OneCell, drawing.TwoCell...) + charts := make([]xlsxChart, 0, len(anchors)) + for _, anchor := range anchors { + chartName := chartParts[anchor.Frame.Reference.ID] + if chartName == "" { + continue + } + width := float64(anchor.Extension.Width) / xlsxEmuPerPoint + height := float64(anchor.Extension.Height) / xlsxEmuPerPoint + if width <= 0 { + width = grid.columnOffset(anchor.To.Column) - grid.columnOffset(anchor.From.Column) + } + if height <= 0 { + height = grid.rowOffset(anchor.To.Row) - grid.rowOffset(anchor.From.Row) + } + if width <= 0 || height <= 0 { + continue + } + chartData, err := files.read(chartName) + if err != nil { + return nil, err + } + chart, ok := parseXLSXChart(chartData, *grid) + if !ok { + continue + } + chart.anchorRow = anchor.From.Row + chart.anchorColumn = anchor.From.Column + chart.rowOffset = float64(anchor.From.RowOffset) / xlsxEmuPerPoint + chart.columnOffset = float64(anchor.From.ColumnOffset) / xlsxEmuPerPoint + chart.width, chart.height = width, height + charts = append(charts, chart) + grid.extendForChart(chart) + } + return charts, nil +} + +func xlsxRelationshipPartName(partName string) string { + return path.Join(path.Dir(partName), "_rels", path.Base(partName)+".rels") +} + +func xlsxResolveRelationshipTarget(partName, target string) string { + if strings.HasPrefix(target, "/") { + return strings.TrimPrefix(path.Clean(target), "/") + } + return path.Clean(path.Join(path.Dir(partName), target)) +} + +func parseXLSXChart(data []byte, grid xlsxGrid) (xlsxChart, bool) { + var document xlsxChartDocument + if xml.Unmarshal(data, &document) != nil || len(document.Chart.Plot.Bar.Series) == 0 { + return xlsxChart{}, false + } + chart := xlsxChart{ + title: xlsxChartTitle(document.Chart.Title), + categoryTitle: xlsxChartTitle(document.Chart.Plot.CategoryAxis.Title), + valueTitle: xlsxChartTitle(document.Chart.Plot.ValueAxis.Title), + legendPosition: document.Chart.Legend.Position.Value, + gapWidth: document.Chart.Plot.Bar.GapWidth.Value, + } + if chart.gapWidth <= 0 { + chart.gapWidth = 150 + } + for _, source := range document.Chart.Plot.Bar.Series { + name := source.Name + if name == "" { + values := xlsxReferenceValues(grid, source.NameFormula) + if len(values) > 0 { + name = values[0] + } + } + categoryFormula := source.CategoryStr + if categoryFormula == "" { + categoryFormula = source.CategoryNum + } + valueStrings := xlsxReferenceValues(grid, source.Value) + values := make([]float64, len(valueStrings)) + for index, value := range valueStrings { + values[index], _ = strconv.ParseFloat(strings.TrimSpace(value), 64) + } + chart.series = append(chart.series, xlsxChartSeries{ + name: name, categories: xlsxReferenceValues(grid, categoryFormula), values: values, + }) + } + return chart, len(chart.series) > 0 +} + +func xlsxChartTitle(title xlsxChartTitleXML) string { + return strings.Join(title.Runs, "") +} + +func xlsxReferenceValues(grid xlsxGrid, formula string) []string { + if separator := strings.LastIndex(formula, "!"); separator >= 0 { + formula = formula[separator+1:] + } + formula = strings.ReplaceAll(formula, "$", "") + parts := strings.Split(formula, ":") + if len(parts) == 0 || len(parts) > 2 { + return nil + } + startRow, startColumn := xlsxCellPosition(parts[0]) + endRow, endColumn := startRow, startColumn + if len(parts) == 2 { + endRow, endColumn = xlsxCellPosition(parts[1]) + } + if startRow == 0 || startColumn == 0 || endRow < startRow || endColumn < startColumn { + return nil + } + values := make([]string, 0, (endRow-startRow+1)*(endColumn-startColumn+1)) + for row := startRow; row <= endRow; row++ { + for column := startColumn; column <= endColumn; column++ { + values = append(values, grid.cells[[2]int{row, column}].value) + } + } + return values +} + +func (grid xlsxGrid) columnOffset(columnCount int) float64 { + offset := 0.0 + for column := 1; column <= columnCount; column++ { + offset += grid.columnWidth(column) + } + return offset +} + +func (grid xlsxGrid) rowOffset(rowCount int) float64 { + offset := 0.0 + for row := 1; row <= rowCount; row++ { + offset += grid.rowHeight(row) + } + return offset +} + +func (grid *xlsxGrid) extendForChart(chart xlsxChart) { + chartRight := grid.columnOffset(chart.anchorColumn) + chart.columnOffset + chart.width + for column := chart.anchorColumn + 1; grid.columnOffset(column) < chartRight; column++ { + grid.maxColumn = max(grid.maxColumn, column+1) + } + chartBottom := grid.rowOffset(chart.anchorRow) + chart.rowOffset + chart.height + for row := chart.anchorRow + 1; grid.rowOffset(row) < chartBottom; row++ { + grid.maxRow = max(grid.maxRow, row+1) + } +} + +type xlsxChartClip struct { + page *PDFPage + left, bottom, right, top float64 +} + +func (clip xlsxChartClip) line(x1, y1, x2, y2 float64, color PDFColor, width float64) { + if y1 == y2 { + if y1 < clip.bottom || y1 > clip.top { + return + } + x1, x2 = max(x1, clip.left), min(x2, clip.right) + } else if x1 == x2 { + if x1 < clip.left || x1 > clip.right { + return + } + y1, y2 = max(y1, clip.bottom), min(y2, clip.top) + } + if x2 >= x1 && y2 >= y1 { + clip.page.AddLine(x1, y1, x2, y2, color, width) + } +} + +func (clip xlsxChartClip) rect(x, y, width, height float64, color PDFColor) { + left, right := max(x, clip.left), min(x+width, clip.right) + bottom, top := max(y, clip.bottom), min(y+height, clip.top) + if right > left && top > bottom { + clip.page.AddRect(left, bottom, right-left, top-bottom, color) + } +} + +func (clip xlsxChartClip) text(text string, x, y, fontSize float64, color PDFColor, bold bool) { + if text == "" || x >= clip.right || y < clip.bottom || y > clip.top { + return + } + width := float64(len([]rune(text))) * fontSize * 0.52 + if x+width <= clip.left { + return + } + clip.page.AddText(text, x, y, fontSize, color, bold) +} + +func renderXLSXCharts(page *PDFPage, grid xlsxGrid, rows, columns xlsxRange) { + bandLeft := grid.columnOffset(columns.start - 1) + bandTop := grid.rowOffset(rows.start - 1) + bandWidth := grid.columnOffset(columns.end) - bandLeft + bandHeight := grid.rowOffset(rows.end) - bandTop + clip := xlsxChartClip{ + page: page, left: grid.margins.Left, right: grid.margins.Left + bandWidth, + top: grid.pageSize.Height - grid.margins.Top, + bottom: grid.pageSize.Height - grid.margins.Top - bandHeight, + } + for _, chart := range grid.charts { + chartLeft := grid.columnOffset(chart.anchorColumn) + chart.columnOffset + chartTop := grid.rowOffset(chart.anchorRow) + chart.rowOffset + if chartLeft >= bandLeft+bandWidth || chartLeft+chart.width <= bandLeft || + chartTop >= bandTop+bandHeight || chartTop+chart.height <= bandTop { + continue + } + x := grid.margins.Left + chartLeft - bandLeft + top := grid.pageSize.Height - grid.margins.Top - (chartTop - bandTop) + renderXLSXClusteredChart(clip, chart, x, top) + } +} + +func renderXLSXClusteredChart(clip xlsxChartClip, chart xlsxChart, x, top float64) { + border := PDFColor{Red: 0.65, Green: 0.65, Blue: 0.65} + clip.line(x, top, x+chart.width, top, border, 0.5) + clip.line(x+chart.width, top-chart.height, x+chart.width, top, border, 0.5) + clip.line(x, top-chart.height, x+chart.width, top-chart.height, border, 0.5) + clip.line(x, top-chart.height, x, top, border, 0.5) + + const fontSize = 8.0 + titleWidth := float64(len([]rune(chart.title))) * fontSize * 0.52 + clip.text(chart.title, x+(chart.width-titleWidth)/2, top-18, fontSize, PDFColorBlack, true) + legendWidth := 0.0 + if chart.legendPosition == "r" || chart.legendPosition == "tr" { + legendWidth = 52 + } + plotLeft := x + 48 + plotRight := x + chart.width - 16 - legendWidth + plotTop := top - 34 + plotBottom := top - chart.height + 36 + plotWidth, plotHeight := plotRight-plotLeft, plotTop-plotBottom + if plotWidth <= 20 || plotHeight <= 20 { + return + } + + maximum := 0.0 + for _, series := range chart.series { + for _, value := range series.values { + maximum = max(maximum, value) + } + } + axisMaximum, step := xlsxChartAxis(maximum) + gridColor := PDFColor{Red: 0.82, Green: 0.82, Blue: 0.82} + for value := 0.0; value <= axisMaximum+step*0.01; value += step { + y := plotBottom + value/axisMaximum*plotHeight + clip.line(plotLeft, y, plotRight, y, gridColor, 0.5) + } + + categories := 0 + for _, series := range chart.series { + categories = max(categories, len(series.values)) + } + if categories > 0 { + groupWidth := plotWidth / float64(categories) + barWidth := groupWidth / (float64(len(chart.series)) + chart.gapWidth/100) + groupPadding := (groupWidth - barWidth*float64(len(chart.series))) / 2 + colors := []PDFColor{ + {Red: 0.31, Green: 0.506, Blue: 0.741}, + {Red: 0.753, Green: 0.314, Blue: 0.302}, + } + for category := 0; category < categories; category++ { + for seriesIndex, series := range chart.series { + if category >= len(series.values) { + continue + } + height := max(0.5, series.values[category]/axisMaximum*plotHeight) + barX := plotLeft + float64(category)*groupWidth + groupPadding + float64(seriesIndex)*barWidth + clip.rect(barX, plotBottom, barWidth, height, colors[seriesIndex%len(colors)]) + } + } + } + clip.line(plotLeft, plotBottom, plotLeft, plotTop, PDFColorBlack, 0.8) + clip.line(plotLeft, plotBottom, plotRight, plotBottom, PDFColorBlack, 0.8) + clip.text(chart.valueTitle, x+3, plotBottom+plotHeight*0.45, 7, PDFColorBlack, true) + categoryWidth := float64(len([]rune(chart.categoryTitle))) * 7 * 0.52 + clip.text(chart.categoryTitle, plotLeft+(plotWidth-categoryWidth)/2, plotBottom-18, 7, PDFColorBlack, true) + + if legendWidth > 0 { + legendX := plotRight + 12 + legendY := plotBottom + plotHeight*0.5 + colors := []PDFColor{ + {Red: 0.31, Green: 0.506, Blue: 0.741}, + {Red: 0.753, Green: 0.314, Blue: 0.302}, + } + for index, series := range chart.series { + y := legendY - float64(index)*16 + clip.rect(legendX, y, 7, 7, colors[index%len(colors)]) + clip.text(series.name, legendX+11, y, 7, PDFColorBlack, false) + } + } +} + +func xlsxChartAxis(maximum float64) (float64, float64) { + if maximum <= 0 { + return 1, 1 + } + roughStep := maximum / 7 + magnitude := math.Pow(10, math.Floor(math.Log10(roughStep))) + fraction := roughStep / magnitude + niceFraction := 1.0 + if fraction > 1.5 { + niceFraction = 2 + } + if fraction > 3 { + niceFraction = 5 + } + if fraction > 7 { + niceFraction = 10 + } + step := niceFraction * magnitude + return math.Ceil(maximum/step) * step, step +} diff --git a/minipdf-go/xlsx_render.go b/minipdf-go/xlsx_render.go new file mode 100644 index 00000000..c963ad11 --- /dev/null +++ b/minipdf-go/xlsx_render.go @@ -0,0 +1,728 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +const ( + xlsxColumnWidthPoints = 5.62 + xlsxDefaultRowHeightScale = 14.6 / 15 + calibriToHelveticaFontScale = 0.73 + calibriRegularWidthScale = 90 / calibriToHelveticaFontScale + calibriBoldWidthScale = 80 / calibriToHelveticaFontScale + helveticaBaselineRatio = 0.45 +) + +type xlsxStyle struct { + font xlsxFont + fill *PDFColor + border xlsxBorder + numberFmt int + align string + vertical string +} + +type xlsxFont struct { + name string + size float64 + color PDFColor + bold bool +} + +type xlsxBorder struct { + left, right, top, bottom float64 +} + +type xlsxCell struct { + row, column int + value string + formula string + style int + numeric bool +} + +type xlsxMerge struct { + startRow, startColumn int + endRow, endColumn int +} + +type xlsxGrid struct { + cells map[[2]int]xlsxCell + columnWidths map[int]float64 + rowHeights map[int]float64 + merges []xlsxMerge + charts []xlsxChart + maxRow, maxColumn int + dimensionRow int + dimensionColumn int + defaultRowHeight float64 + pageSize PageSize + margins Margins +} + +type xlsxStylesDocument struct { + Fonts struct { + Items []struct { + Bold *struct{} `xml:"b"` + Name struct { + Value string `xml:"val,attr"` + } `xml:"name"` + Size struct { + Value float64 `xml:"val,attr"` + } `xml:"sz"` + Color struct { + RGB string `xml:"rgb,attr"` + } `xml:"color"` + } `xml:"font"` + } `xml:"fonts"` + Fills struct { + Items []struct { + Pattern struct { + Type string `xml:"patternType,attr"` + Color struct { + RGB string `xml:"rgb,attr"` + } `xml:"fgColor"` + } `xml:"patternFill"` + } `xml:"fill"` + } `xml:"fills"` + Borders struct { + Items []struct { + Left struct { + Style string `xml:"style,attr"` + } `xml:"left"` + Right struct { + Style string `xml:"style,attr"` + } `xml:"right"` + Top struct { + Style string `xml:"style,attr"` + } `xml:"top"` + Bottom struct { + Style string `xml:"style,attr"` + } `xml:"bottom"` + } `xml:"border"` + } `xml:"borders"` + CellFormats struct { + Items []struct { + FontID int `xml:"fontId,attr"` + FillID int `xml:"fillId,attr"` + BorderID int `xml:"borderId,attr"` + NumberFmt int `xml:"numFmtId,attr"` + Alignment struct { + Horizontal string `xml:"horizontal,attr"` + Vertical string `xml:"vertical,attr"` + } `xml:"alignment"` + } `xml:"xf"` + } `xml:"cellXfs"` +} + +func needsCalculatedXLSXGrid(data []byte) bool { + var worksheet struct { + Cells []struct { + Formula *string `xml:"f"` + Value *string `xml:"v"` + } `xml:"sheetData>row>c"` + } + if xml.Unmarshal(data, &worksheet) != nil { + return false + } + formulaCount := 0 + for _, cell := range worksheet.Cells { + if cell.Formula == nil { + continue + } + formulaCount++ + if cell.Value != nil && strings.TrimSpace(*cell.Value) != "" { + return false + } + } + return formulaCount > 0 +} + +func readXLSXStyles(files officePackage) ([]xlsxStyle, error) { + if _, ok := files["xl/styles.xml"]; !ok { + return nil, nil + } + data, err := files.read("xl/styles.xml") + if err != nil { + return nil, err + } + var document xlsxStylesDocument + if err := xml.Unmarshal(data, &document); err != nil { + return nil, fmt.Errorf("parse xl/styles.xml: %w", err) + } + styles := make([]xlsxStyle, len(document.CellFormats.Items)) + for index, format := range document.CellFormats.Items { + style := xlsxStyle{ + font: xlsxFont{size: 11, color: PDFColorBlack}, numberFmt: format.NumberFmt, + align: format.Alignment.Horizontal, vertical: format.Alignment.Vertical, + } + if format.FontID >= 0 && format.FontID < len(document.Fonts.Items) { + font := document.Fonts.Items[format.FontID] + style.font.name = font.Name.Value + if font.Size.Value > 0 { + style.font.size = font.Size.Value + } + style.font.bold = font.Bold != nil + if color, ok := parseXLSXColor(font.Color.RGB); ok { + style.font.color = color + } + } + if format.FillID >= 0 && format.FillID < len(document.Fills.Items) { + fill := document.Fills.Items[format.FillID].Pattern + if fill.Type == "solid" { + if color, ok := parseXLSXColor(fill.Color.RGB); ok { + style.fill = &color + } + } + } + if format.BorderID >= 0 && format.BorderID < len(document.Borders.Items) { + border := document.Borders.Items[format.BorderID] + style.border = xlsxBorder{ + left: borderWidth(border.Left.Style), right: borderWidth(border.Right.Style), + top: borderWidth(border.Top.Style), bottom: borderWidth(border.Bottom.Style), + } + } + styles[index] = style + } + return styles, nil +} + +func parseXLSXColor(value string) (PDFColor, bool) { + if len(value) < 6 { + return PDFColor{}, false + } + parsed, err := strconv.ParseUint(value[len(value)-6:], 16, 32) + if err != nil { + return PDFColor{}, false + } + return PDFColor{Red: float64(parsed>>16) / 255, Green: float64(parsed>>8&0xff) / 255, Blue: float64(parsed&0xff) / 255}, true +} + +func borderWidth(style string) float64 { + switch style { + case "hair": + return 0.25 + case "medium", "mediumDashed", "mediumDashDot", "mediumDashDotDot": + return 1.5 + case "thick", "double": + return 2 + case "thin", "dashed", "dotted", "dashDot", "dashDotDot": + return 0.75 + default: + return 0 + } +} + +func renderXLSXWorksheets(files officePackage, worksheetNames []string, sharedStrings []string, styles []xlsxStyle, options ConversionOptions) ([]byte, error) { + document := NewPDFDocument() + for _, name := range worksheetNames { + data, err := files.read(name) + if err != nil { + return nil, err + } + grid, err := readXLSXGrid(data, sharedStrings) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + grid.charts, err = readXLSXCharts(files, name, &grid) + if err != nil { + return nil, err + } + grid.applyOptions(options) + renderXLSXGrid(document, grid, styles) + } + return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}), nil +} + +func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { + grid := xlsxGrid{ + cells: make(map[[2]int]xlsxCell), columnWidths: make(map[int]float64), rowHeights: make(map[int]float64), + defaultRowHeight: 15, pageSize: PageSizeA4, margins: Margins{Left: 54, Right: 54, Top: 72, Bottom: 72}, + } + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return xlsxGrid{}, err + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + switch start.Name.Local { + case "dimension": + reference := attrValue(start, "ref") + parts := strings.Split(reference, ":") + grid.dimensionRow, grid.dimensionColumn = xlsxCellPosition(parts[len(parts)-1]) + case "sheetFormatPr": + if height, err := strconv.ParseFloat(attrValue(start, "defaultRowHeight"), 64); err == nil && height > 0 { + grid.defaultRowHeight = height + } + case "col": + minimum, _ := strconv.Atoi(attrValue(start, "min")) + maximum, _ := strconv.Atoi(attrValue(start, "max")) + width, _ := strconv.ParseFloat(attrValue(start, "width"), 64) + if grid.dimensionColumn > 0 { + maximum = min(maximum, grid.dimensionColumn) + } + for column := minimum; column <= maximum && column > 0; column++ { + grid.columnWidths[column] = width * xlsxColumnWidthPoints + } + case "row": + row, _ := strconv.Atoi(attrValue(start, "r")) + if height, err := strconv.ParseFloat(attrValue(start, "ht"), 64); err == nil && height > 0 { + grid.rowHeights[row] = height + } + grid.maxRow = max(grid.maxRow, row) + case "c": + reference := attrValue(start, "r") + row, column := xlsxCellPosition(reference) + value, formula, err := decodeWorksheetCellContent(decoder, start, sharedStrings) + if err != nil { + return xlsxGrid{}, err + } + style, _ := strconv.Atoi(attrValue(start, "s")) + cellType := attrValue(start, "t") + grid.cells[[2]int{row, column}] = xlsxCell{row: row, column: column, value: value, formula: formula, style: style, numeric: cellType == "" || cellType == "n"} + grid.maxRow = max(grid.maxRow, row) + grid.maxColumn = max(grid.maxColumn, column) + case "mergeCell": + if merge, ok := parseXLSXMerge(attrValue(start, "ref")); ok { + grid.merges = append(grid.merges, merge) + } + case "pageMargins": + grid.margins = Margins{ + Left: inchesToPoints(attrValue(start, "left"), 54), Right: inchesToPoints(attrValue(start, "right"), 54), + Top: inchesToPoints(attrValue(start, "top"), 72), Bottom: inchesToPoints(attrValue(start, "bottom"), 72), + } + case "pageSetup": + if attrValue(start, "paperSize") == "1" { + grid.pageSize = PageSizeLetter + } + if attrValue(start, "orientation") == "landscape" { + grid.pageSize.Width, grid.pageSize.Height = grid.pageSize.Height, grid.pageSize.Width + } + } + } + if grid.dimensionRow > 0 && grid.dimensionColumn > 0 { + grid.maxRow = grid.dimensionRow + grid.maxColumn = grid.dimensionColumn + } + grid.evaluateFormulas() + return grid, nil +} + +func (grid *xlsxGrid) evaluateFormulas() { + visiting := make(map[[2]int]bool) + for key, cell := range grid.cells { + if cell.value == "" && cell.formula != "" { + if value, ok := grid.evaluateCell(key, visiting); ok { + cell.value = strconv.FormatFloat(value, 'f', -1, 64) + grid.cells[key] = cell + } + } + } +} + +func (grid *xlsxGrid) evaluateCell(key [2]int, visiting map[[2]int]bool) (float64, bool) { + cell, exists := grid.cells[key] + if !exists { + return 0, true + } + if cell.value != "" { + value, err := strconv.ParseFloat(strings.TrimSpace(cell.value), 64) + return value, err == nil + } + if cell.formula == "" || visiting[key] { + return 0, false + } + visiting[key] = true + defer delete(visiting, key) + formula := strings.TrimSpace(strings.TrimPrefix(cell.formula, "=")) + if strings.HasPrefix(strings.ToUpper(formula), "SUM(") && strings.HasSuffix(formula, ")") { + merge, ok := parseXLSXMerge(formula[4 : len(formula)-1]) + if !ok { + return 0, false + } + total := 0.0 + for row := merge.startRow; row <= merge.endRow; row++ { + for column := merge.startColumn; column <= merge.endColumn; column++ { + value, ok := grid.evaluateCell([2]int{row, column}, visiting) + if !ok { + return 0, false + } + total += value + } + } + return total, true + } + total := 0.0 + for _, reference := range strings.Split(formula, "+") { + row, column := xlsxCellPosition(strings.TrimSpace(reference)) + if row == 0 || column == 0 { + return 0, false + } + value, ok := grid.evaluateCell([2]int{row, column}, visiting) + if !ok { + return 0, false + } + total += value + } + return total, true +} + +func xlsxCellPosition(reference string) (int, int) { + column := worksheetColumnIndex(reference) + index := 0 + for index < len(reference) && ((reference[index] >= 'A' && reference[index] <= 'Z') || (reference[index] >= 'a' && reference[index] <= 'z')) { + index++ + } + row, _ := strconv.Atoi(reference[index:]) + return row, column +} + +func parseXLSXMerge(reference string) (xlsxMerge, bool) { + parts := strings.Split(reference, ":") + if len(parts) != 2 { + return xlsxMerge{}, false + } + startRow, startColumn := xlsxCellPosition(parts[0]) + endRow, endColumn := xlsxCellPosition(parts[1]) + return xlsxMerge{startRow: startRow, startColumn: startColumn, endRow: endRow, endColumn: endColumn}, startRow > 0 && startColumn > 0 && endRow >= startRow && endColumn >= startColumn +} + +func inchesToPoints(value string, fallback float64) float64 { + inches, err := strconv.ParseFloat(value, 64) + if err != nil || inches < 0 { + return fallback + } + return inches * 72 +} + +func (grid *xlsxGrid) applyOptions(options ConversionOptions) { + if options.MaxRows > 0 { + grid.maxRow = min(grid.maxRow, options.MaxRows) + } + if options.MaxColumns > 0 { + grid.maxColumn = min(grid.maxColumn, options.MaxColumns) + } + if options.PageSize != nil { + grid.pageSize = *options.PageSize + } + if options.Margins != nil { + grid.margins = *options.Margins + } + if options.Landscape != nil && (*options.Landscape != (grid.pageSize.Width > grid.pageSize.Height)) { + grid.pageSize.Width, grid.pageSize.Height = grid.pageSize.Height, grid.pageSize.Width + } +} + +type xlsxRange struct{ start, end int } + +func renderXLSXGrid(document *PDFDocument, grid xlsxGrid, styles []xlsxStyle) { + if grid.maxRow == 0 || grid.maxColumn == 0 { + document.AddPage(grid.pageSize.Width, grid.pageSize.Height) + return + } + columnBands := xlsxBands(grid.maxColumn, grid.pageSize.Width-grid.margins.Left-grid.margins.Right, func(index int) float64 { return grid.columnWidth(index) }) + rowBands := xlsxBands(grid.maxRow, grid.pageSize.Height-grid.margins.Top-grid.margins.Bottom, func(index int) float64 { return grid.rowHeight(index) }) + for _, columnBand := range columnBands { + for _, rowBand := range rowBands { + page := document.AddPage(grid.pageSize.Width, grid.pageSize.Height) + renderXLSXBand(page, grid, styles, rowBand, columnBand) + } + } +} + +func xlsxBands(count int, available float64, size func(int) float64) []xlsxRange { + var bands []xlsxRange + for start := 1; start <= count; { + end := start + used := 0.0 + for end <= count && (used+size(end) <= available || end == start) { + used += size(end) + end++ + } + bands = append(bands, xlsxRange{start: start, end: end - 1}) + start = end + } + return bands +} + +func (grid xlsxGrid) columnWidth(column int) float64 { + if width := grid.columnWidths[column]; width > 0 { + return width + } + return 8.43 * xlsxColumnWidthPoints +} + +func (grid xlsxGrid) rowHeight(row int) float64 { + if height := grid.rowHeights[row]; height > 0 { + return height + } + return grid.defaultRowHeight * xlsxDefaultRowHeightScale +} + +func renderXLSXBand(page *PDFPage, grid xlsxGrid, styles []xlsxStyle, rows, columns xlsxRange) { + xPositions := map[int]float64{columns.start: grid.margins.Left} + for column := columns.start; column <= columns.end; column++ { + xPositions[column+1] = xPositions[column] + grid.columnWidth(column) + } + yPositions := map[int]float64{rows.start: grid.pageSize.Height - grid.margins.Top} + for row := rows.start; row <= rows.end; row++ { + yPositions[row+1] = yPositions[row] - grid.rowHeight(row) + } + merged := make(map[[2]int]bool) + for _, merge := range grid.merges { + for row := merge.startRow; row <= merge.endRow; row++ { + for column := merge.startColumn; column <= merge.endColumn; column++ { + merged[[2]int{row, column}] = true + } + } + if merge.endRow < rows.start || merge.startRow > rows.end || merge.endColumn < columns.start || merge.startColumn > columns.end { + continue + } + cell := grid.cells[[2]int{merge.startRow, merge.startColumn}] + startColumn := max(merge.startColumn, columns.start) + endColumn := min(merge.endColumn, columns.end) + startRow := max(merge.startRow, rows.start) + endRow := min(merge.endRow, rows.end) + renderXLSXCell(page, cell, xPositions[startColumn], yPositions[endRow+1], xPositions[endColumn+1]-xPositions[startColumn], yPositions[startRow]-yPositions[endRow+1], styleAt(styles, cell.style), merge.startColumn >= columns.start && merge.startColumn <= columns.end) + } + for row := rows.start; row <= rows.end; row++ { + for column := columns.start; column <= columns.end; column++ { + if merged[[2]int{row, column}] { + continue + } + cell, exists := grid.cells[[2]int{row, column}] + if !exists { + continue + } + renderXLSXCell(page, cell, xPositions[column], yPositions[row+1], grid.columnWidth(column), grid.rowHeight(row), styleAt(styles, cell.style), true) + } + } + renderXLSXCharts(page, grid, rows, columns) +} + +func styleAt(styles []xlsxStyle, index int) xlsxStyle { + if index >= 0 && index < len(styles) { + return styles[index] + } + return xlsxStyle{font: xlsxFont{size: 11, color: PDFColorBlack}} +} + +func renderXLSXCell(page *PDFPage, cell xlsxCell, x, y, width, height float64, style xlsxStyle, drawText bool) { + if style.fill != nil { + page.AddRect(x, y, width, height, *style.fill) + } + if style.border.left > 0 { + page.AddLine(x, y, x, y+height, PDFColorBlack, style.border.left) + } + if style.border.right > 0 { + page.AddLine(x+width, y, x+width, y+height, PDFColorBlack, style.border.right) + } + if style.border.top > 0 { + page.AddLine(x, y+height, x+width, y+height, PDFColorBlack, style.border.top) + } + if style.border.bottom > 0 { + page.AddLine(x, y, x+width, y, PDFColorBlack, style.border.bottom) + } + if !drawText || cell.value == "" { + return + } + text := formatXLSXValue(cell.value, cell.numeric, style.numberFmt) + fontSize := style.font.size + horizontalScale := 100.0 + if strings.EqualFold(style.font.name, "Calibri") { + fontSize *= calibriToHelveticaFontScale + horizontalScale = calibriRegularWidthScale + if style.font.bold { + horizontalScale = calibriBoldWidthScale + } + } + textWidth := measureHelveticaText(text, fontSize, horizontalScale, style.font.bold) + textX := x + 2 + switch style.align { + case "center": + textX = x + (width-textWidth)/2 + case "right": + textX = x + width - textWidth - 2 + default: + if cell.numeric { + textX = x + width - textWidth - 2 + } + } + textY := y + fontSize*helveticaBaselineRatio + if style.vertical == "center" { + textY = y + (height-fontSize)/2 + } else if style.vertical == "top" { + textY = y + height - fontSize + } + page.addScaledText(text, textX, textY, fontSize, horizontalScale, style.font.color, style.font.bold) +} + +func measureHelveticaText(text string, fontSize, horizontalScale float64, bold bool) float64 { + total := 0 + for _, character := range text { + total += helveticaCharacterWidth(character, bold) + } + return float64(total) * fontSize / 1000 * horizontalScale / 100 +} + +func helveticaCharacterWidth(character rune, bold bool) int { + if bold { + switch character { + case ' ', ',': + return 278 + case '!', ':', ';', '(', ')', '-', '.', '/': + return 333 + case '"': + return 474 + case '#', '$', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + return 556 + case '%': + return 889 + case '&', 'A', 'B', 'C', 'D', 'G', 'H', 'K', 'N', 'O', 'P', 'Q', 'R', 'U', 'V', 'X', 'Y': + return 722 + case '\'', 'I': + return 238 + case '*': + return 389 + case '+', '<', '=', '>', '^', '~': + return 584 + case '?', 'E', 'S': + return 611 + case '@': + return 975 + case 'F', 'L', 'T', 'Z': + return 611 + case 'J': + return 556 + case 'M': + return 833 + case 'W': + return 944 + case '[', ']': + return 333 + case '\\': + return 278 + case '_': + return 556 + case '`': + return 333 + case 'a', 'c', 'e', 's', 'x', 'v': + return 556 + case 'b', 'd', 'g', 'h', 'n', 'o', 'p', 'q', 'u': + return 611 + case 'f', 't': + return 333 + case 'i', 'j', 'l': + return 278 + case 'k': + return 556 + case 'm': + return 889 + case 'r': + return 389 + case 'w': + return 778 + case 'y': + return 556 + case 'z': + return 500 + case '{', '}': + return 389 + case '|': + return 280 + } + } else { + switch character { + case ' ', '!', ',', '.', '/', ':', ';', 'I', '[', '\\', ']': + return 278 + case '"': + return 355 + case '#', '$', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '?', 'L', '_': + return 556 + case '%': + return 889 + case '&', 'A', 'B', 'E', 'K', 'P', 'S', 'V', 'X', 'Y': + return 667 + case '\'': + return 191 + case '(', ')', '-', '`': + return 333 + case '*': + return 389 + case '+', '<', '=', '>', '~': + return 584 + case '@': + return 1015 + case 'C', 'D', 'H', 'N', 'R', 'U': + return 722 + case 'F', 'T', 'Z': + return 611 + case 'G', 'O', 'Q': + return 778 + case 'J': + return 500 + case 'M': + return 833 + case 'W': + return 944 + case '^': + return 469 + case 'a', 'b', 'd', 'e', 'g', 'h', 'n', 'o', 'p', 'q', 'u': + return 556 + case 'c', 'k', 's', 'v', 'x', 'y', 'z': + return 500 + case 'f': + return 278 + case 'i', 'j', 'l': + return 222 + case 'm': + return 833 + case 'r': + return 333 + case 't': + return 278 + case 'w': + return 722 + case '{', '}': + return 334 + case '|': + return 260 + } + } + if character >= 0x1100 && (character <= 0x115f || character >= 0x2e80 && character <= 0x9fff || character >= 0xac00 && character <= 0xd7af || character >= 0xf900 && character <= 0xfaff || character >= 0xfe30 && character <= 0xfe4f || character >= 0xff01 && character <= 0xff60 || character >= 0xffe0 && character <= 0xffe6) { + return 1000 + } + return 556 +} + +func formatXLSXValue(value string, numeric bool, numberFormat int) string { + if !numeric || numberFormat != 4 { + return value + } + number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return value + } + formatted := strconv.FormatFloat(number, 'f', 2, 64) + parts := strings.SplitN(formatted, ".", 2) + digits := parts[0] + sign := "" + if strings.HasPrefix(digits, "-") { + sign, digits = "-", digits[1:] + } + for index := len(digits) - 3; index > 0; index -= 3 { + digits = digits[:index] + "," + digits[index:] + } + return sign + digits + "." + parts[1] +}