From 7f882ab2c0b8027f21590a1cd81edcf87900f9d2 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 4 Oct 2026 10:43:22 +0900 Subject: [PATCH 1/2] Optimize rendered color extraction and prepare v0.9.0 --- CHANGELOG.md | 6 + .../dense_cf.xlsx | Bin 0 -> 5672 bytes .../overlapping_formula.xlsx | Bin 0 -> 5835 bytes .../sparse_cf.xlsx | Bin 0 -> 5663 bytes .../issue159-colors-cf-probe-windows.json | 1685 ++++++++++++ .../dense_cf.xlsx | Bin 0 -> 6908 bytes .../overlapping_formula.xlsx | Bin 0 -> 7116 bytes .../sparse_cf.xlsx | Bin 0 -> 6893 bytes .../static.xlsx | Bin 0 -> 6925 bytes .../issue159-colors-final-smoke-windows.json | 2229 +++++++++++++++ .../sparse_cf.xlsx | Bin 0 -> 22931 bytes .../static.xlsx | Bin 0 -> 24116 bytes .../issue159-colors-final-windows.json | 2285 +++++++++++++++ .../dense_cf.xlsx | Bin 0 -> 22944 bytes .../merged.xlsx | Bin 0 -> 22905 bytes .../multiple_sheets.xlsx | Bin 0 -> 23943 bytes .../overlapping_formula.xlsx | Bin 0 -> 23827 bytes .../sparse_cf.xlsx | Bin 0 -> 22929 bytes .../static.xlsx | Bin 0 -> 24110 bytes .../theme_indexed.xlsx | Bin 0 -> 24998 bytes .../baselines/issue159-colors-windows.json | 2439 +++++++++++++++++ benchmark/issue159-results.md | 150 + benchmark/issue159_colors.py | 747 +++++ ...R-0015-hybrid-rendered-color-extraction.md | 129 + dev-docs/adr/README.md | 1 + dev-docs/adr/decision-map.md | 5 + dev-docs/adr/index.yaml | 17 + dev-docs/specs/excel-extraction.md | 13 +- dev-docs/specs/hybrid-color-extraction.md | 180 ++ pyproject.toml | 2 +- src/exstruct/core/backends/com_backend.py | 9 + src/exstruct/core/cells.py | 21 +- src/exstruct/core/color_hybrid.py | 288 ++ src/exstruct/core/pipeline.py | 8 +- tests/core/test_color_hybrid.py | 850 ++++++ uv.lock | 2 +- 36 files changed, 11051 insertions(+), 15 deletions(-) create mode 100644 benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/dense_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/overlapping_formula.xlsx create mode 100644 benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/sparse_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-cf-probe-windows.json create mode 100644 benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/dense_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/overlapping_formula.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/sparse_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/static.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-smoke-windows.json create mode 100644 benchmark/baselines/issue159-colors-final-windows-fixtures/sparse_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-windows-fixtures/static.xlsx create mode 100644 benchmark/baselines/issue159-colors-final-windows.json create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/dense_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/merged.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/multiple_sheets.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/overlapping_formula.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/sparse_cf.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/static.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows-fixtures/theme_indexed.xlsx create mode 100644 benchmark/baselines/issue159-colors-windows.json create mode 100644 benchmark/issue159-results.md create mode 100644 benchmark/issue159_colors.py create mode 100644 dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md create mode 100644 dev-docs/specs/hybrid-color-extraction.md create mode 100644 src/exstruct/core/color_hybrid.py create mode 100644 tests/core/test_color_hybrid.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e3b96..bf1a760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project are documented in this file. This changelog ## [Unreleased] +## [0.9.0] - 2026-10-04 + ### Fixed - Ensure newly created Excel instances are cleaned up when opening a workbook @@ -11,6 +13,10 @@ All notable changes to this project are documented in this file. This changelog ### Changed +- Hybridized rendered color extraction for saved `.xlsx` / `.xlsm` workbooks: + ordinary RGB fills use saved styles, while conditional formatting and + ambiguous fills retain Excel DisplayFormat evaluation. Unsupported sheets + and unsaved workbooks retain the complete COM scan. - Switched `.xlsx` / `.xlsm` `light` extraction to the direct OOXML core and rich pipeline using one shared ZIP. Unsupported OOXML and `colors_map` opt-in restart the complete openpyxl compatibility pipeline with an diff --git a/benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/dense_cf.xlsx b/benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/dense_cf.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d0ccd634acd67888c6382b5dde3ac981d31d081e GIT binary patch literal 5672 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 s0@Pabe^>o=JJ(C;FFR3W0KmT#mYxL1WZLVLr z=j`j-PLsnO$;4XzN`9Mr&Pjm3jZhlNd?eM`ksXyi=U>7%5fbweNR-Dwj|IYW$>0LkB7umrnV|M1~SLZdyLmmu6U{~)7XX_Q`d zrBhj*hCpv*q4~2k;>NPQ;*}0yh>G+lN8SElYeiJBzrD^kWz_S9#6q+!G@{kW^6{nnE;He!M1^t z%Zog_9lz2S_i=2HuxiH|t%X3%Q61vgekVT`&&S^{;Pp#H3U}g)Aip6Emxr`Gcl$O| z5dA1{BWm9o<&DwJq(#%hY!xIvNSVv#0gs`Cc`@+PXFA3KZ)v8k^ zu5d0N_sHL0ToU!&-Qc!Z3V)ik<74)fXAD_W!819n&(A_4!%O`Jy48S}ullge$PNG( zg?u3+g^!0uUsLfR??gVBHU{!ABUfN!6MA8Gj@dWvTbV%~a@tDw>S!8)O>e^H83Ya} zI(1^Lb1}&DWmv18!gdZFTTVtNrK}Olcj@>;oOx*sZU*&$-^_uGo_^D5YHqN%xu@sQ z*4M8X9Qp;4gk7zK${nD%#!o1IH{GDeD*)BJo*C0I>LKU^oSxnG5iRfRk9MKGGObyY z(UiXDSf3|@3(K|ZwHiTL73?)4$8+g6W& zmqfM}_Ij+^v9g_(IOHu;+R7@-uKJNul+8;I+yof7cp{7P3dA@g9% zU_&gDuA!7^I7xv; zKC_*xdX7Z2Oo@=6?B2EX@K^R4w3vCgd@r-Z4`8iDB-3P&in?p zM#Bz>6@W8o?hEdHj>lK?75Y%Rwd1+T1{4^{H>L#N3vsp}eO@$d7pFnQzSd%$R9mR8<5^g;$b;bPIzWl%KXNIwR=eb*r))d@rtB zqfMZp!?d9(RbULWtJ$K5vc#nnH<572N?A|~1Kr9fL&{y>g_#+Vw)Xje`e7s|hDAcp zrOevbx)VHmkO|jG5+917b$g=YN$TmtMU_+5b6gFsoMt?PD>W{Kc0M|-3=AI9zJ=SVGhi(eMVcW?~aCocM^!(NF>sg zduP!J9ivRV=9u$&wUgy4fwFa2PSxq-cSSQ^B~;-JXvKr!hs!NKz!|ZxSE?Yd^qm$* z`udI+8#O=VZc4nQYkRtr!?=+h{t5w?i}3X~eP#2aRYQ>6$+3uqQ=!7Fa(J7QeA>#4 zE9>fGs3zu)maZ9}aVr)_d`^IBjQo$KdXXqVoY9}Twu_n7$WHyBz zlhz*_b?Feg#ycPC>?L6oAjFwraeF2K35XJ(RM9etsz5nsms-1Skwd9jSF~PUpqw=M zM(ylEn=44WlGLl52$R36M(mxRHJ?qv&J-5`E~oKRL(&;Fll7s+)U*;mCa-dYZm8 z!adcmvPr(^+yF^`$Ui0KGIn)`?!r(9lOBP1#+n5596t{SR-cq>gD=D!_sGxY>H zmWP^{CI}cTG|IQ)Ai1Ywb09p4@v}|B4^7Q+-`1n+F)nT(j&mc! zjaLAG7+L_}#;*;;8|>!bZfkAr;m-4O`nijwe>Db=Dgwg4)rSUrRL;|dD2j$*YRQ$4 zt-WMTg`-Asu?&P>m8mJ7osNPmP6EjW;kpC}Ug2DQk`!vk=X=KGysThJ?pa-74@%#< zjT&dKdfrZ6|Ky_X_wj~_-0)hT*F6)C=_xKo5L@b+kVVgN>l?*seOZ2+{B(qgYV`Lx z++UY}>UN_pP=CV;Dwk{XemGSbwGdtF2d5)Mej9&~dg5ggkma+C+*QrZH?-FLWS{>? z*hA{zlxC_>N{jGNgnz8ab%MRV+axi7!!%7jB2Hf_DY6^#u!n>)h`wv2*;Pz;`}X@lJiYw!gGKr_%jK~sYCjDeIC!^+6rLaXC1wE^!?8zhJ(30lH_ zt1;zHusCo$nQCaF5Nt}oqrYK3=YQ4!x$O)&=U>?KRSo9DXp0eplkCU%sh&O_EeOo> zdK;uwGpIgDzn|tOdy2o}KUn|nlSSscPkLnUi-xGs16vollF06`PlTSM*d098UX)db z;MA14MNh6Qh2E{+u@|ncOQ%i4Idydrb+Kt-D@3g>rBq?-IqP)CzOT|05_!L%Ff{%) zg+|x?vC&02*Z#jsOLgOME8}rM__LyOXC1=W_t~04g73o>hI-!ufx2?J_O==v7v(=n zI1^N(u5_cWmIogxe(C=C@0Wo=yniwHQ%UCg6J&{;gGhKq@qbskfX>phy&(U79UVf# zY91Jf&dBDwO7Q@L_vA@F(oXg3N4LK@_1p8oj>}B^R+9NXk1Tdy6%O|I54Q!U3RMcZO#=%L9|o=YgU^gO zDvDQqA&AL#ii&c#mg4RQt`6e*iXaFo*AJD&cYO7{!uM<S!9nRaWRw^8m^etcET zsg7JMP4%#>1VBv#m(FKIz#_=~V{hERM~ofX)k?2=a&?hW-9>Nv{D-+VlBF{JBU>+mGJ4X9WR>9Wh)idvWL0fd!ZVfD<7Afd2hBH@kcI zI$FE`I5AJ?H^!y%5}#5uUnM845Y%CeV3L17sK>ER@8=oH_%cQ<)-WLuA}LJM&Mj9F zJ1Q4Ry5<|W`$)o4qr6xQ-z&(X>k5R+D)kAQWeSnzAia7~!8*+luU&%Hatcs; zr7s6tiz~;ImQ7Qwioo0W>(KEitWndm-^!d8r_b4#T`WZ}TV$jR_NbAW!tj-LBT>q6 z3v6L#ZQa2}j6K`K%u$eF$;>|J0K3o}FZ+6(J@*SQWP4|&u>+kuBe!NP;b-#-r?-5n zouAWv5=-lv!?)p8A2oGSGW1uyzpYXilhy+I*u9sRR^p%d`?sLn0fIi;z%QAHP6xUC4C1Nr9=n_A{A-}n z#P80N$(8`9OZ{2R#uvhkTyr?9brpL@3$Q_^rwO_hOiacQ5r+?-=M~@1G25|rDmu@p z=Z;Q4Ki(x58=}briyBoZAFnNx*4@PK-*Q+rTWYMsE5UPmhs|Nf)&{Y`AB5j&YmMx!3+F7x=QrVkH^DsY-glbiMCd z$YcAE(ant$Su9RiH;*4y@s7n<)*T+$?UbFS9;~o6MwfnaJAe+psI6viT0mPCJzQdBVo@WxCe1=1t8wl68fI8HMo1Ph~a)?qm6mt-Q?-zUm1T13o$2 zG0SkxF<=)`k><)4jPKnaA?94zz17YIcScsIx}PZR;U%nAyTh!#@yPcv4ocuD%*t{` zUtNO*RL;A{5qN_s>CmFJlQ%+J1zo%QH{)~e%e!#yCFH3_dnG}oGA8QscJnPQU_Mhz z=exac$t4f#(|PkF`-kOmS-~F;u`fC*NjC|XK{o`H(2f%Q%G@6Z77H-g;YaSqCQN|P zErBPd`5joj*Be<1JVjI=E1g7UPrP|oW27>}t|T%tFq_ArIul;l+_sVIK7N=mUdJ?Q zlc`{Q&pqE(Zt0QlhAzIs5ywh{8ikNNeD^_9Wp%U zlX8Y^6JBInV0E^-7|rrAnsMMSwtq#|-#PyoWn~GH*B_&ahmy_%ZoD1L#w_42qM&*j zdPoxT*T=%9b(%(iB3(7FA>w8IcPkqn!6<)m&0%rUU0c=;>$Wv|K%O`)g*@^bIcw~G z?#xXHK4@8{?3x|zR3iiRr}j8iO|I+bjH3n1LQ=(n&qTM0cms+U-pX0sVV^gid(h;F z5^gj~yd9r^d-cK5Cs1B+Cn*UJj@`4;<>x_u`S&7B#@F?$gb?>Diy5c)R%h@pI++?S z8%Be6a2V@X6UA5#?CfFf>|v_o>tgM0@*}K@pCf*RRjXu8$U*j5vCS^06m!`Yqq7reQ%&IM zM81@Tic?XJ!Od&M2~Oj>-`7GRxE*hPGTH5H7GI?}H|T^%^0n+r&|&{O7IegipUoLhqsP z6BwA}fdAciL`TifPcWL{f3_hnqc8XTeqjNCU<~-*(Z4%@m*JQDVSnHY=;i;VJ9gQ? z<@?ZY1LYxX*M1uK*W1x$E0>QKzpVsg(4$Wq7Y-Yjp_dE!ALu5!-2JVrUj|+-jDLV5 z=>9Ky&A+GYW$@)Z{{vPa{R#dbS%2Bi|1#N literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/sparse_cf.xlsx b/benchmark/baselines/issue159-colors-cf-probe-windows-fixtures/sparse_cf.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7793e7c6bb2b74ecf218e4b3cfceb20bb3ca6559 GIT binary patch literal 5663 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 literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-cf-probe-windows.json b/benchmark/baselines/issue159-colors-cf-probe-windows.json new file mode 100644 index 0000000..fe84c0c --- /dev/null +++ b/benchmark/baselines/issue159-colors-cf-probe-windows.json @@ -0,0 +1,1685 @@ +{ + "schema_version": 1, + "recorded_at": "2026-10-04T00:54:35.714282+00:00", + "platform": "Windows-10-10.0.22631-SP0", + "python": "3.11.13 (main, Jul 23 2025, 00:29:09) [MSC v.1944 64 bit (AMD64)]", + "xlwings": "0.37.4", + "excel_version": "16.0", + "configuration": { + "rows": 10, + "columns": 20, + "cases": [ + "sparse_cf", + "dense_cf", + "overlapping_formula" + ], + "repeats": 1, + "execution": "sequential; one owned Excel application; one workbook open at a time", + "include_default_background_values": [ + false, + true + ], + "ignore_color_cases": [ + null, + [ + "FF0000" + ] + ] + }, + "fixtures": [ + { + "name": "sparse_cf", + "description": "conditional formatting with sparse matches", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-cf-probe-windows-fixtures\\sparse_cf.xlsx", + "sha256": "ba48f4cb0aa8a0bccfc3ca16b037ab819ab402bc74f1ca0abc99b68ffd799bd0", + "rows": 10, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "one formula rule limited to D2:D10; every other row matches" + ], + "expected_conditional_colors_min_cells": { + "FF0000": 1 + } + }, + { + "name": "dense_cf", + "description": "conditional formatting with dense alternating matches", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-cf-probe-windows-fixtures\\dense_cf.xlsx", + "sha256": "9009067df89176f498ac199a951cc227d9442f176dabfd8155e31e3156a6c8b2", + "rows": 10, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "one formula rule across the grid; alternating cells match" + ], + "expected_conditional_colors_min_cells": { + "0070C0": 1 + } + }, + { + "name": "overlapping_formula", + "description": "overlapping CF rules, formulas, and mixed-range probe", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-cf-probe-windows-fixtures\\overlapping_formula.xlsx", + "sha256": "e7d2fd98bc1437c8a3e8f571d63f65cb83968450e3b3cebeb4b4630f975ff796", + "rows": 10, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "three overlapping formula rules across the grid", + "two stop-if-true rules on A1:B1 for the mixed-range probe" + ], + "expected_conditional_colors_min_cells": { + "FF0000": 2, + "0070C0": 2 + } + } + ], + "mixed_range_probe": { + "sheet": "Data", + "range": "A1:B1", + "workbook_saved_flag_before_calculate": true, + "workbook_saved_flag_after_calculate": false, + "range_value": { + "value": 0, + "repr": "0.0", + "type": "float" + }, + "per_cell_values": [ + { + "address": "A1", + "value": 255, + "repr": "255.0", + "type": "float" + }, + { + "address": "B1", + "value": 12611584, + "repr": "12611584.0", + "type": "float" + } + ], + "per_cell_colors_differ": true, + "range_matches_each_cell": false + }, + "measurements": [ + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 906.2811001203954, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "b59b701ff3b25de35a5335c94c5ba0f7717803583d99c1420c62dc165fbd2ec9", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 5 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 38.20779989473522, + "wrapper_display_format_calls": 9, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=9 rendered_candidates=9 display_format_calls=9 fallback=False static_duration_ms=8.041 com_duration_ms=15.623 duration_ms=27.381", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "9", + "display_format_calls": "9" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "b59b701ff3b25de35a5335c94c5ba0f7717803583d99c1420c62dc165fbd2ec9", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 5 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 344.2443001549691, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 33.16750004887581, + "wrapper_display_format_calls": 9, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=9 rendered_candidates=9 display_format_calls=9 fallback=False static_duration_ms=5.631 com_duration_ms=15.758 duration_ms=24.774", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "9", + "display_format_calls": "9" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 380.78799983486533, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "b57f87b573ef3d4107affcbb391c9dd1958a15675adf01a893779dd224374879", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 37.6456999219954, + "wrapper_display_format_calls": 9, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=9 rendered_candidates=9 display_format_calls=9 fallback=False static_duration_ms=5.489 com_duration_ms=19.989 duration_ms=28.711", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "9", + "display_format_calls": "9" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "b57f87b573ef3d4107affcbb391c9dd1958a15675adf01a893779dd224374879", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 373.52390005253255, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "2e22b9532de62984ffa90bfb5aabcac9f8339dc7675ffd29ccdf81a1f890fb3a", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 195 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 34.49550014920533, + "wrapper_display_format_calls": 9, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=9 rendered_candidates=9 display_format_calls=9 fallback=False static_duration_ms=5.535 com_duration_ms=15.917 duration_ms=25.541", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "9", + "display_format_calls": "9" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "2e22b9532de62984ffa90bfb5aabcac9f8339dc7675ffd29ccdf81a1f890fb3a", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 195 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 928.2537999097258, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "22f06aba5615b0f544328054b4f7178912bcf242e86f71e717e73f2a8bf124b5", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 100 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 423.05919993668795, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=200 rendered_candidates=200 display_format_calls=200 fallback=False static_duration_ms=4.344 com_duration_ms=406.816 duration_ms=414.889", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "200", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "22f06aba5615b0f544328054b4f7178912bcf242e86f71e717e73f2a8bf124b5", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 100 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 472.36410016193986, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "22f06aba5615b0f544328054b4f7178912bcf242e86f71e717e73f2a8bf124b5", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 100 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 500.1632000785321, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=200 rendered_candidates=200 display_format_calls=200 fallback=False static_duration_ms=4.371 com_duration_ms=478.506 duration_ms=488.537", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "200", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "22f06aba5615b0f544328054b4f7178912bcf242e86f71e717e73f2a8bf124b5", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 100 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 549.7897998429835, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "e25e6cf59991b52286d61923a87953386dd84a538fdd2c717c523fb0f2d4dc27", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 463.77129992470145, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=200 rendered_candidates=200 display_format_calls=200 fallback=False static_duration_ms=4.824 com_duration_ms=433.866 duration_ms=453.732", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "200", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "e25e6cf59991b52286d61923a87953386dd84a538fdd2c717c523fb0f2d4dc27", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 361.9605000130832, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "e25e6cf59991b52286d61923a87953386dd84a538fdd2c717c523fb0f2d4dc27", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 625.6393999792635, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=200 rendered_candidates=200 display_format_calls=200 fallback=False static_duration_ms=4.172 com_duration_ms=610.329 duration_ms=618.272", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "200", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "e25e6cf59991b52286d61923a87953386dd84a538fdd2c717c523fb0f2d4dc27", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 625.709599815309, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "b83bf927118f378a085a8059e52ed1347be5de213f2bd318c0e0ba9017c54f33", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 59 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 491.01049988530576, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=0 rendered_candidates=0 display_format_calls=200 fallback=True static_duration_ms=0.004 com_duration_ms=469.021 duration_ms=476.412", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "0", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "b83bf927118f378a085a8059e52ed1347be5de213f2bd318c0e0ba9017c54f33", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 59 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 469.5373000577092, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "c13ab214ecd078a7f9df65263926b627e3f23d1495d5dcf44df08c3889c50ab5", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 38 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 457.4407001491636, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=0 rendered_candidates=0 display_format_calls=200 fallback=True static_duration_ms=0.003 com_duration_ms=441.910 duration_ms=447.508", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "0", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "c13ab214ecd078a7f9df65263926b627e3f23d1495d5dcf44df08c3889c50ab5", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 38 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 433.3123001269996, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "44f701c06555da168c6fb357fb670f94834e92adb654ff4c8112ded9bbe8a246", + "map_summary": { + "sheets": 1, + "color_keys": 4, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 407.1120999287814, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=0 rendered_candidates=0 display_format_calls=200 fallback=True static_duration_ms=0.003 com_duration_ms=392.907 duration_ms=397.784", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "0", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "44f701c06555da168c6fb357fb670f94834e92adb654ff4c8112ded9bbe8a246", + "map_summary": { + "sheets": 1, + "color_keys": 4, + "mapped_cells": 200 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 561.4062999375165, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 200, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "8c75e2c0f40fdce01ee8cd91e6ff88bb62ece3759071db5acbcc9a07990d183f", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 179 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 894.7964999824762, + "wrapper_display_format_calls": 200, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=200 conditional_candidates=0 rendered_candidates=0 display_format_calls=200 fallback=True static_duration_ms=0.003 com_duration_ms=878.695 duration_ms=883.827", + "metric_fields": { + "used_cells": "200", + "conditional_candidates": "0", + "display_format_calls": "200" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "8c75e2c0f40fdce01ee8cd91e6ff88bb62ece3759071db5acbcc9a07990d183f", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 179 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "exact_workbook_colors_map_equal": true + } + ], + "comparisons": [ + { + "fixture": "sparse_cf", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 5 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 100 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 21, + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 37 + }, + "missing_minimums": {}, + "status": "passed" + } + } + } + ] + } + ] +} diff --git a/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/dense_cf.xlsx b/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/dense_cf.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d48befa3cdabff3cdd966953fa8525cbdb70a0a0 GIT binary patch literal 6908 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 literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/overlapping_formula.xlsx b/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/overlapping_formula.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1d6ea395fd5977b4781ff816923dda7c9398c8c8 GIT binary patch literal 7116 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 WjDlp9>M(P-A!dVnvr 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 Q;lE1wDLwi7F%{r{03$;uS^xk5 literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/static.xlsx b/benchmark/baselines/issue159-colors-final-smoke-windows-fixtures/static.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..051017dc2b44c3c00ee214472019a8392b8f5243 GIT binary patch literal 6925 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 AlK=n! literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-final-smoke-windows.json b/benchmark/baselines/issue159-colors-final-smoke-windows.json new file mode 100644 index 0000000..7b7b488 --- /dev/null +++ b/benchmark/baselines/issue159-colors-final-smoke-windows.json @@ -0,0 +1,2229 @@ +{ + "schema_version": 1, + "recorded_at": "2026-10-04T00:53:49.650865+00:00", + "platform": "Windows-10-10.0.22631-SP0", + "python": "3.11.13 (main, Jul 23 2025, 00:29:09) [MSC v.1944 64 bit (AMD64)]", + "xlwings": "0.37.4", + "excel_version": "16.0", + "configuration": { + "rows": 30, + "columns": 20, + "cases": [ + "static", + "sparse_cf", + "dense_cf", + "overlapping_formula" + ], + "repeats": 1, + "execution": "sequential; one owned Excel application; one workbook open at a time", + "include_default_background_values": [ + false, + true + ], + "ignore_color_cases": [ + null, + [ + "FF0000" + ] + ] + }, + "fixtures": [ + { + "name": "static", + "description": "rows x 20 values with deterministic direct fills", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-final-smoke-windows-fixtures\\static.xlsx", + "sha256": "261020811952fd6a463d802f1b907608e61c81399317f07decec285b99945203", + "rows": 30, + "columns": 20, + "sheets": 1, + "conditional_formatting": [], + "expected_conditional_colors_min_cells": null + }, + { + "name": "sparse_cf", + "description": "conditional formatting with sparse matches", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-final-smoke-windows-fixtures\\sparse_cf.xlsx", + "sha256": "058767f52160f83a6a120569e7c2f299ec83861b55ce9c3c90efc2a5a7434c55", + "rows": 30, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "one formula rule limited to D2:D30; every other row matches" + ], + "expected_conditional_colors_min_cells": { + "FF0000": 1 + } + }, + { + "name": "dense_cf", + "description": "conditional formatting with dense alternating matches", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-final-smoke-windows-fixtures\\dense_cf.xlsx", + "sha256": "74cef73be138c7f45ded261877b00877c9d47032d8166736a82d40685787f343", + "rows": 30, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "one formula rule across the grid; alternating cells match" + ], + "expected_conditional_colors_min_cells": { + "0070C0": 1 + } + }, + { + "name": "overlapping_formula", + "description": "overlapping CF rules, formulas, and mixed-range probe", + "path": "C:\\Users\\HARUMI\\orca\\workspaces\\exstruct\\waspfish\\benchmark\\baselines\\issue159-colors-final-smoke-windows-fixtures\\overlapping_formula.xlsx", + "sha256": "1a49a078ec5d29c5a905a18281367259c1c14c128fd5d3a40041d052a6399247", + "rows": 30, + "columns": 20, + "sheets": 1, + "conditional_formatting": [ + "three overlapping formula rules across the grid", + "two stop-if-true rules on A1:B1 for the mixed-range probe" + ], + "expected_conditional_colors_min_cells": { + "FF0000": 2, + "0070C0": 2 + } + } + ], + "mixed_range_probe": { + "sheet": "Data", + "range": "A1:B1", + "workbook_saved_flag_before_calculate": true, + "workbook_saved_flag_after_calculate": false, + "range_value": { + "value": 0, + "repr": "0.0", + "type": "float" + }, + "per_cell_values": [ + { + "address": "A1", + "value": 255, + "repr": "255.0", + "type": "float" + }, + { + "address": "B1", + "value": 12611584, + "repr": "12611584.0", + "type": "float" + } + ], + "per_cell_colors_differ": true, + "range_matches_each_cell": false + }, + "measurements": [ + { + "fixture": "static", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1284.909199923277, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "74dffe3b51df3f5fff78dfcbf98fc4348312b2aca31db30a18dada154ff8253d", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 33 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 22.31089980341494, + "wrapper_display_format_calls": 0, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=0 fallback=False static_duration_ms=8.941 com_duration_ms=0.018 duration_ms=12.597", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "0" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "74dffe3b51df3f5fff78dfcbf98fc4348312b2aca31db30a18dada154ff8253d", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 33 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1073.130700038746, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "d8ea6397c7a8c8e01d7cc8ceaef51631ea0860dad148507467ef3459d6e25ccc", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 7 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 19.337200094014406, + "wrapper_display_format_calls": 0, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=0 fallback=False static_duration_ms=7.770 com_duration_ms=0.006 duration_ms=11.500", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "0" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "d8ea6397c7a8c8e01d7cc8ceaef51631ea0860dad148507467ef3459d6e25ccc", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 7 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 1290.4598999302834, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "9b311d4f0bf063b3de44f4baa02edef33c6f5eee4579939ba24e946e7c0a821f", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 600 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 18.726699985563755, + "wrapper_display_format_calls": 0, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=0 fallback=False static_duration_ms=7.531 com_duration_ms=0.006 duration_ms=11.511", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "0" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "9b311d4f0bf063b3de44f4baa02edef33c6f5eee4579939ba24e946e7c0a821f", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 600 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1348.883100086823, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "4dbf9f61fad0cea0249add0b64c4434e7d8f0928a9391691517457e3a05d4ff7", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 574 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "static", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 26.709500001743436, + "wrapper_display_format_calls": 0, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=0 fallback=False static_duration_ms=9.144 com_duration_ms=0.008 duration_ms=15.893", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "0" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "4dbf9f61fad0cea0249add0b64c4434e7d8f0928a9391691517457e3a05d4ff7", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 574 + }, + "conditional_render_validation": null, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1438.3194998372346, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 69.5809000171721, + "wrapper_display_format_calls": 29, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=29 rendered_candidates=29 display_format_calls=29 fallback=False static_duration_ms=8.975 com_duration_ms=50.000 duration_ms=62.215", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "29", + "display_format_calls": "29" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1326.652699848637, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 69.70580015331507, + "wrapper_display_format_calls": 29, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=29 rendered_candidates=29 display_format_calls=29 fallback=False static_duration_ms=7.498 com_duration_ms=50.403 duration_ms=61.983", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "29", + "display_format_calls": "29" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 1287.4936999287456, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 65.16680005006492, + "wrapper_display_format_calls": 29, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=29 rendered_candidates=29 display_format_calls=29 fallback=False static_duration_ms=7.275 com_duration_ms=47.091 duration_ms=57.841", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "29", + "display_format_calls": "29" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1871.4595998171717, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "sparse_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 88.11360015533864, + "wrapper_display_format_calls": 29, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=29 rendered_candidates=29 display_format_calls=29 fallback=False static_duration_ms=7.237 com_duration_ms=65.542 duration_ms=79.425", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "29", + "display_format_calls": "29" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1827.473800163716, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1358.5032001137733, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=600 rendered_candidates=600 display_format_calls=600 fallback=False static_duration_ms=7.234 com_duration_ms=1340.564 duration_ms=1350.808", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "600", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 2264.9328999686986, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 3162.7462999895215, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=600 rendered_candidates=600 display_format_calls=600 fallback=False static_duration_ms=9.016 com_duration_ms=3130.858 duration_ms=3146.012", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "600", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "f96b1a6e800226ad604be38bb06d34b5e89abb58e386bcb7c84128b55c759dd8", + "map_summary": { + "sheets": 1, + "color_keys": 0, + "mapped_cells": 0 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 2589.855199912563, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 1241.4532999973744, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=600 rendered_candidates=600 display_format_calls=600 fallback=False static_duration_ms=6.688 com_duration_ms=1223.062 duration_ms=1234.224", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "600", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1219.7495999280363, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "dense_cf", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1441.270099952817, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=600 rendered_candidates=600 display_format_calls=600 fallback=False static_duration_ms=8.166 com_duration_ms=1401.564 duration_ms=1415.502", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "600", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "127551e83a9758c290973c67913badad18a76a28a81e662717878e04e708783e", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1211.2863000947982, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "0541cb4127cd9a4a44b7574b1984728be7b64950ce2c92b3cdb7f815a4bedb6e", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 2 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": null, + "elapsed_ms": 1218.3375998865813, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=600 fallback=True static_duration_ms=0.004 com_duration_ms=1207.847 duration_ms=1210.964", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "0541cb4127cd9a4a44b7574b1984728be7b64950ce2c92b3cdb7f815a4bedb6e", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 2 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1164.665900170803, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "36969d75f0dedc5c5a04d4fbd5ab4433824448c7fcf233705173130421c104cd", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 1 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1186.2331999000162, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=600 fallback=True static_duration_ms=0.003 com_duration_ms=1174.277 duration_ms=1177.988", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "36969d75f0dedc5c5a04d4fbd5ab4433824448c7fcf233705173130421c104cd", + "map_summary": { + "sheets": 1, + "color_keys": 1, + "mapped_cells": 1 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 1639.61479999125, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "151b3a5e168747d1468415ce7c56f8a129f205e77b65e2e8a65f5460d569c7d7", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": null, + "elapsed_ms": 1273.8779000937939, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=600 fallback=True static_duration_ms=0.003 com_duration_ms=1262.654 duration_ms=1266.462", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "151b3a5e168747d1468415ce7c56f8a129f205e77b65e2e8a65f5460d569c7d7", + "map_summary": { + "sheets": 1, + "color_keys": 3, + "mapped_cells": 600 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "reference", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1317.1645998954773, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 600, + "hybrid_debug_records": [], + "error": null, + "map_sha256": "44fa909984f4a645a78a5e0048d968eef3e012f940e349b580e5d11d3ce77b0d", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 599 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + }, + { + "fixture": "overlapping_formula", + "repeat": 1, + "strategy": "hybrid", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "elapsed_ms": 1271.5755000244826, + "wrapper_display_format_calls": 600, + "cells_context_display_format_calls": 0, + "hybrid_debug_records": [ + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction legacy fallback sheet=Data reason=workbook has unsaved changes", + "metric_fields": {}, + "extra": {} + }, + { + "logger": "exstruct.core.color_hybrid", + "level": "DEBUG", + "message": "Color extraction sheet=Data used_cells=600 conditional_candidates=0 rendered_candidates=0 display_format_calls=600 fallback=True static_duration_ms=0.003 com_duration_ms=1250.916 duration_ms=1254.786", + "metric_fields": { + "used_cells": "600", + "conditional_candidates": "0", + "display_format_calls": "600" + }, + "extra": {} + } + ], + "error": null, + "map_sha256": "44fa909984f4a645a78a5e0048d968eef3e012f940e349b580e5d11d3ce77b0d", + "map_summary": { + "sheets": 1, + "color_keys": 2, + "mapped_cells": 599 + }, + "conditional_render_validation": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "exact_workbook_colors_map_equal": true + } + ], + "comparisons": [ + { + "fixture": "static", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": null, + "hybrid": null + } + } + ] + }, + { + "fixture": "static", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": null, + "hybrid": null + } + } + ] + }, + { + "fixture": "static", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": null, + "hybrid": null + } + } + ] + }, + { + "fixture": "static", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": null, + "hybrid": null + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 1 + }, + "actual_cells_by_color": { + "FF0000": 0 + }, + "missing_minimums": { + "FF0000": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "sparse_cf", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 1 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": {}, + "status": "skipped_all_expected_colors_ignored", + "actual_cells_by_color": {} + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "dense_cf", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "0070C0": 1 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "0070C0": 1 + }, + "actual_cells_by_color": { + "0070C0": 0 + }, + "missing_minimums": { + "0070C0": { + "minimum": 1, + "actual": 0 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": false, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": false, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": true, + "ignore_colors": null, + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [], + "checked_expected_colors": { + "FF0000": 2, + "0070C0": 2 + }, + "actual_cells_by_color": { + "FF0000": 1, + "0070C0": 1 + }, + "missing_minimums": { + "FF0000": { + "minimum": 2, + "actual": 1 + }, + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + } + } + } + ] + }, + { + "fixture": "overlapping_formula", + "include_default_background": true, + "ignore_colors": [ + "FF0000" + ], + "repeats": [ + { + "repeat": 1, + "order": [ + "reference", + "hybrid" + ], + "exact_workbook_colors_map_equal": true, + "errors": {}, + "conditional_render_validation": { + "reference": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + }, + "hybrid": { + "expected_min_cells_by_color": { + "FF0000": 2, + "0070C0": 2 + }, + "ignored_expected_colors": [ + "FF0000" + ], + "checked_expected_colors": { + "0070C0": 2 + }, + "actual_cells_by_color": { + "0070C0": 1 + }, + "missing_minimums": { + "0070C0": { + "minimum": 2, + "actual": 1 + } + }, + "status": "failed" + } + } + } + ] + } + ] +} diff --git a/benchmark/baselines/issue159-colors-final-windows-fixtures/sparse_cf.xlsx b/benchmark/baselines/issue159-colors-final-windows-fixtures/sparse_cf.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a512a20021cab2c246d567c1a45de487b7f83e16 GIT binary patch literal 22931 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 Avj6}9 literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-final-windows-fixtures/static.xlsx b/benchmark/baselines/issue159-colors-final-windows-fixtures/static.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4557b4afffff3ec94c2c1f14abfff625cb5b9e8f GIT binary patch literal 24116 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 BcXa>& literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-windows-fixtures/multiple_sheets.xlsx b/benchmark/baselines/issue159-colors-windows-fixtures/multiple_sheets.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6dd900247c37d05df6b0535d11118886fb491d9f GIT binary patch literal 23943 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�jxy?u=Wmb!G`C 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 literal 0 HcmV?d00001 diff --git a/benchmark/baselines/issue159-colors-windows-fixtures/static.xlsx b/benchmark/baselines/issue159-colors-windows-fixtures/static.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5ce7e1058dff3efe479a7a861678af75e9f614c8 GIT binary patch literal 24110 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 z None: + super().__init__(logging.DEBUG) + self.records: list[dict[str, Any]] = [] + + def emit(self, record: logging.LogRecord) -> None: + standard_fields = set(logging.makeLogRecord({}).__dict__) + extras = { + key: _jsonable(value) + for key, value in record.__dict__.items() + if key not in standard_fields and key not in {"message", "asctime"} + } + message = record.getMessage() + fields = {key: value for key, value in extras.items() if key in _METRIC_KEYS} + fields.update({key: value for key, value in _METRIC_PATTERN.findall(message)}) + self.records.append( + { + "logger": record.name, + "level": record.levelname, + "message": message, + "metric_fields": fields, + "extra": extras, + } + ) + + +@contextmanager +def _capture_hybrid_debug() -> Iterator[_DebugRecordCollector]: + logger = logging.getLogger("exstruct.core.color_hybrid") + previous_level = logger.level + previous_propagate = logger.propagate + collector = _DebugRecordCollector() + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(collector) + try: + yield collector + finally: + logger.removeHandler(collector) + logger.setLevel(previous_level) + logger.propagate = previous_propagate + + +@contextmanager +def _count_display_format_wrapper() -> Iterator[dict[str, int]]: + """Count calls through cells._get_display_format_color on either path.""" + original = cells._get_display_format_color + counter = {"calls": 0} + + def counted(sheet: object, row: int, col: int) -> int | None: + counter["calls"] += 1 + return original(sheet, row, col) # type: ignore[arg-type] + + with ExitStack() as stack: + stack.enter_context(patch.object(cells, "_get_display_format_color", counted)) + # The optimized module may cache the helper at import time. Patch that + # alias as well so both paths are measured at the same call boundary. + hybrid = sys.modules.get("exstruct.core.color_hybrid") + if hybrid is not None and getattr(hybrid, "_get_display_format_color", None) is original: + stack.enter_context( + patch.object(hybrid, "_get_display_format_color", counted) + ) + yield counter + + +def _jsonable(value: object) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + return repr(value) + + +def _populate_grid(sheet: Any, rows: int) -> None: + for row in range(1, rows + 1): + sheet.append([row * 100 + col for col in range(1, _COLUMNS + 1)]) + + +def _single_sheet_fixture(name: str, rows: int) -> tuple[Workbook, list[str]]: + workbook = Workbook() + sheet = workbook.active + sheet.title = "Data" + _populate_grid(sheet, rows) + red = PatternFill(fill_type="solid", fgColor="FFFF0000", bgColor="FFFF0000") + blue = PatternFill(fill_type="solid", fgColor="FF0070C0", bgColor="FF0070C0") + yellow = PatternFill(fill_type="solid", fgColor="FFFFFF00", bgColor="FFFFFF00") + rules: list[str] = [] + extent = f"A1:T{rows}" + + if name == "static": + for row in range(1, rows + 1): + for col in range(1, _COLUMNS + 1): + serial = (row - 1) * _COLUMNS + col + if serial % 23 == 0: + sheet.cell(row, col).fill = red + elif serial % 79 == 0: + sheet.cell(row, col).fill = yellow + elif name == "sparse_cf": + sparse_start = 2 if rows >= 2 else 1 + sparse_end = min(rows, sparse_start + 29) + sparse_extent = f"D{sparse_start}:D{sparse_end}" + sheet.conditional_formatting.add( + sparse_extent, + FormulaRule( + formula=["MOD(ROW(),2)=0"], + fill=red, + ), + ) + rules.append( + f"one formula rule limited to {sparse_extent}; every other row matches" + ) + elif name == "dense_cf": + sheet.conditional_formatting.add( + extent, + FormulaRule(formula=["MOD(ROW()+COLUMN(),2)=0"], fill=blue), + ) + rules.append("one formula rule across the grid; alternating cells match") + elif name == "overlapping_formula": + # Distinct saved fills make the range-vs-cell probe meaningful even + # if Excel does not evaluate a conditional-formatting rule. + sheet["A1"].fill = red + sheet["B1"].fill = blue + for row in range(2, rows + 1, 4): + sheet.cell(row, 1).value = f"=SUM(B{row}:C{row})" + sheet.conditional_formatting.add( + extent, + FormulaRule(formula=["MOD(ROW(),7)=0"], fill=red), + ) + sheet.conditional_formatting.add( + extent, + FormulaRule(formula=["MOD(COLUMN(),5)=0"], fill=blue), + ) + sheet.conditional_formatting.add( + extent, + FormulaRule(formula=["A1>1500"], fill=yellow), + ) + # Make A1 and B1 a deterministic mixed-color probe. These highest- + # priority rules stop the broader overlapping rules on those cells. + sheet.conditional_formatting.add( + "A1:B1", + FormulaRule(formula=["COLUMN()=1"], fill=red, stopIfTrue=True), + ) + sheet.conditional_formatting.add( + "A1:B1", + FormulaRule(formula=["COLUMN()=2"], fill=blue, stopIfTrue=True), + ) + rules.extend( + [ + "three overlapping formula rules across the grid", + "two stop-if-true rules on A1:B1 for the mixed-range probe", + ] + ) + elif name == "theme_indexed": + themed = PatternFill( + fill_type="solid", fgColor=Color(theme=4, tint=0.25) + ) + indexed = PatternFill(fill_type="solid", fgColor=Color(indexed=3)) + for row in range(1, rows + 1): + for col in range(1, _COLUMNS + 1): + serial = (row - 1) * _COLUMNS + col + if serial % 17 == 0: + sheet.cell(row, col).fill = themed + elif serial % 19 == 0: + sheet.cell(row, col).fill = indexed + elif name == "merged": + for row in range(1, rows + 1, 30): + sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=2) + sheet.cell(row, 1).fill = blue + else: + raise ValueError(f"Unknown single-sheet fixture: {name}") + + return workbook, rules + + +def _multiple_sheet_fixture(rows: int) -> tuple[Workbook, list[str]]: + sheet_rows = min(rows, 30) + workbook = Workbook() + rules = [ + f"eight populated worksheets of {sheet_rows}x{_COLUMNS}; direct fills vary by sheet" + ] + for index in range(_MULTI_SHEET_COUNT): + sheet = workbook.active if index == 0 else workbook.create_sheet() + sheet.title = f"Sheet{index + 1:02d}" + _populate_grid(sheet, sheet_rows) + fill = PatternFill( + fill_type="solid", + fgColor=("FFFF0000" if index % 2 == 0 else "FF0070C0"), + ) + for row in range(1, sheet_rows + 1): + for col in range(1, _COLUMNS + 1): + if (row * 3 + col + index) % 31 == 0: + sheet.cell(row, col).fill = fill + return workbook, rules + + +def generate_fixtures( + directory: Path, rows: int, cases: tuple[str, ...] +) -> list[dict[str, Any]]: + directory.mkdir(parents=True, exist_ok=True) + fixture_specs = ( + ("static", "rows x 20 values with deterministic direct fills"), + ("sparse_cf", "conditional formatting with sparse matches"), + ("dense_cf", "conditional formatting with dense alternating matches"), + ("overlapping_formula", "overlapping CF rules, formulas, and mixed-range probe"), + ("theme_indexed", "theme and indexed direct fills"), + ("merged", "periodic horizontal merged ranges"), + ("multiple_sheets", "eight populated worksheets capped at 30 rows each"), + ) + fixtures: list[dict[str, Any]] = [] + for name, description in fixture_specs: + if name not in cases: + continue + if name == "multiple_sheets": + workbook, rules = _multiple_sheet_fixture(rows) + else: + workbook, rules = _single_sheet_fixture(name, rows) + workbook.properties.created = _FIXED_PROPERTIES_TIME + workbook.properties.modified = _FIXED_PROPERTIES_TIME + workbook.calculation.calcMode = "auto" + workbook.calculation.fullCalcOnLoad = True + workbook.calculation.forceFullCalc = True + path = directory / f"{name}.xlsx" + workbook.save(path) + workbook.close() + fixtures.append( + { + "name": name, + "description": description, + "path": str(path.resolve()), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "rows": min(rows, 30) if name == "multiple_sheets" else rows, + "columns": _COLUMNS, + "sheets": _MULTI_SHEET_COUNT if name == "multiple_sheets" else 1, + "conditional_formatting": rules, + "expected_conditional_colors_min_cells": _CF_RENDER_EXPECTATIONS.get( + name + ), + } + ) + return fixtures + + +def _reference_extract( + book: Any, include_default_background: bool, ignore_colors: set[str] | None +) -> Any: + cells._prepare_workbook_for_display_format(book) + sheets = {} + for sheet in book.sheets: + cells._prepare_sheet_for_display_format(sheet) + sheets[sheet.name] = cells._extract_sheet_colors_com( + sheet, include_default_background, ignore_colors + ) + return cells.WorkbookColorsMap(sheets=sheets) + + +def _map_fingerprint(color_map: Any) -> str: + normalized = { + sheet_name: { + color: [list(position) for position in positions] + for color, positions in sorted(sheet.colors_map.items()) + } + for sheet_name, sheet in sorted(color_map.sheets.items()) + } + encoded = json.dumps(normalized, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _map_summary(color_map: Any) -> dict[str, int]: + return { + "sheets": len(color_map.sheets), + "color_keys": sum(len(sheet.colors_map) for sheet in color_map.sheets.values()), + "mapped_cells": sum( + len(positions) + for sheet in color_map.sheets.values() + for positions in sheet.colors_map.values() + ), + } + + +def _conditional_render_validation( + fixture_name: str, + color_map: Any | None, + ignore_colors: set[str] | None, +) -> dict[str, Any] | None: + """Require visible CF colors so equal empty maps cannot pass correctness.""" + expectations = _CF_RENDER_EXPECTATIONS.get(fixture_name) + if expectations is None: + return None + ignored = { + color.strip().lstrip("#").upper()[-6:] for color in (ignore_colors or set()) + } + active = {color: minimum for color, minimum in expectations.items() if color not in ignored} + report: dict[str, Any] = { + "expected_min_cells_by_color": expectations, + "ignored_expected_colors": sorted(set(expectations) & ignored), + "checked_expected_colors": active, + } + if not active: + report["status"] = "skipped_all_expected_colors_ignored" + report["actual_cells_by_color"] = {} + return report + if color_map is None: + report["status"] = "unavailable" + report["actual_cells_by_color"] = {} + return report + + sheet = color_map.sheets.get("Data") + colors_map = sheet.colors_map if sheet is not None else {} + actual = {color: len(colors_map.get(color, [])) for color in active} + missing = { + color: {"minimum": minimum, "actual": actual[color]} + for color, minimum in active.items() + if actual[color] < minimum + } + report["actual_cells_by_color"] = actual + report["missing_minimums"] = missing + report["status"] = "failed" if missing else "passed" + return report + + +def _measure( + book: Any, + strategy: str, + include_default_background: bool, + ignore_colors: set[str] | None, + fixture_name: str, + repeat: int, +) -> tuple[dict[str, Any], Any | None]: + context_counter = getattr(cells, "_display_format_calls", None) + context_token = context_counter.set(0) if context_counter is not None else None + collector: _DebugRecordCollector | None = None + color_map = None + error = None + with _count_display_format_wrapper() as calls: + if strategy == "hybrid": + with _capture_hybrid_debug() as collector: + started = perf_counter() + try: + color_map = cells.extract_sheet_colors_map_com( + book, + include_default_background=include_default_background, + ignore_colors=ignore_colors, + ) + except Exception as exc: # Retain failures beside successful timings. + error = f"{type(exc).__name__}: {exc}" + elapsed_ms = (perf_counter() - started) * 1000 + else: + started = perf_counter() + try: + color_map = _reference_extract( + book, include_default_background, ignore_colors + ) + except Exception as exc: # Retain failures beside successful timings. + error = f"{type(exc).__name__}: {exc}" + elapsed_ms = (perf_counter() - started) * 1000 + context_calls = context_counter.get() if context_counter is not None else None + if context_token is not None: + context_counter.reset(context_token) + + record: dict[str, Any] = { + "fixture": fixture_name, + "repeat": repeat, + "strategy": strategy, + "include_default_background": include_default_background, + "ignore_colors": sorted(ignore_colors) if ignore_colors else None, + "elapsed_ms": elapsed_ms, + "wrapper_display_format_calls": calls["calls"], + "cells_context_display_format_calls": context_calls, + "hybrid_debug_records": collector.records if collector is not None else [], + "error": error, + } + if color_map is not None: + record["map_sha256"] = _map_fingerprint(color_map) + record["map_summary"] = _map_summary(color_map) + record["conditional_render_validation"] = _conditional_render_validation( + fixture_name, color_map, ignore_colors + ) + return record, color_map + + +def _probe_mixed_range(book: Any) -> dict[str, Any]: + sheet = book.sheets["Data"] + saved_before = bool(book.api.Saved) + sheet.api.Activate() + sheet.api.Calculate() + result: dict[str, Any] = { + "sheet": sheet.name, + "range": "A1:B1", + "workbook_saved_flag_before_calculate": saved_before, + "workbook_saved_flag_after_calculate": bool(book.api.Saved), + } + per_cell: list[dict[str, Any]] = [] + for address in ("A1", "B1"): + try: + value = sheet.api.Range(address).DisplayFormat.Interior.Color + per_cell.append( + { + "address": address, + "value": int(value), + "repr": repr(value), + "type": type(value).__name__, + } + ) + except Exception as exc: + per_cell.append({"address": address, "error": f"{type(exc).__name__}: {exc}"}) + try: + value = sheet.api.Range("A1:B1").DisplayFormat.Interior.Color + result["range_value"] = { + "value": int(value), + "repr": repr(value), + "type": type(value).__name__, + } + except Exception as exc: + result["range_value"] = {"error": f"{type(exc).__name__}: {exc}"} + result["per_cell_values"] = per_cell + values = [cell.get("value") for cell in per_cell] + result["per_cell_colors_differ"] = ( + len(values) == 2 and None not in values and values[0] != values[1] + ) + range_value = result["range_value"].get("value") + result["range_matches_each_cell"] = ( + range_value is not None and len(values) == 2 and values == [range_value, range_value] + ) + return result + + +def _write_output(path: Path, result: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _run(args: argparse.Namespace) -> dict[str, Any]: + import xlwings as xw + + before = sorted(xw.apps.keys()) + if before: + raise RuntimeError( + f"Close existing Excel instances before this sequential benchmark: {before}" + ) + + fixtures = generate_fixtures( + args.fixtures_dir.resolve(), args.rows, tuple(args.cases) + ) + result: dict[str, Any] = { + "schema_version": 1, + "recorded_at": datetime.now(UTC).isoformat(), + "platform": platform.platform(), + "python": sys.version, + "xlwings": xw.__version__, + "excel_version": None, + "configuration": { + "rows": args.rows, + "columns": _COLUMNS, + "cases": args.cases, + "repeats": args.repeats, + "execution": "sequential; one owned Excel application; one workbook open at a time", + "include_default_background_values": [False, True], + "ignore_color_cases": [None, sorted(_IGNORE_RED)], + }, + "fixtures": fixtures, + "mixed_range_probe": None, + "measurements": [], + "comparisons": [], + } + + app = None + try: + app = xw.App(visible=False, add_book=False) + app.display_alerts = False + app.screen_updating = True + try: + app.api.EnableEvents = False + app.api.AskToUpdateLinks = False + except Exception: + pass + result["excel_version"] = str(app.version) + + for fixture_index, fixture in enumerate(fixtures, start=1): + print( + f"[issue159] fixture {fixture_index}/{len(fixtures)}: " + f"{fixture['name']} ({fixture['rows']}x{_COLUMNS}, " + f"{fixture['sheets']} sheet(s)); opening", + flush=True, + ) + book = app.books.open( + fixture["path"], update_links=False, read_only=True + ) + try: + if fixture["name"] == "overlapping_formula": + try: + result["mixed_range_probe"] = _probe_mixed_range(book) + except Exception as exc: + result["mixed_range_probe"] = { + "error": f"{type(exc).__name__}: {exc}" + } + + for include_default_background in (False, True): + for ignore_colors in (None, _IGNORE_RED): + ignore_label = ( + "none" + if ignore_colors is None + else ",".join(sorted(ignore_colors)) + ) + print( + f" [config] include_default_background=" + f"{include_default_background}, ignore_colors={ignore_label}", + flush=True, + ) + pair: dict[str, Any] = { + "fixture": fixture["name"], + "include_default_background": include_default_background, + "ignore_colors": sorted(ignore_colors) if ignore_colors else None, + "repeats": [], + } + for repeat in range(1, args.repeats + 1): + order = ( + ("reference", "hybrid") + if repeat % 2 == 1 + else ("hybrid", "reference") + ) + by_strategy: dict[str, Any | None] = {} + repeat_records: dict[str, dict[str, Any]] = {} + print( + f" [repeat {repeat}/{args.repeats}] order=" + f"{' -> '.join(order)}", + flush=True, + ) + for strategy in order: + print(f" [{strategy}] started", flush=True) + record, color_map = _measure( + book, + strategy, + include_default_background, + ignore_colors, + fixture["name"], + repeat, + ) + result["measurements"].append(record) + repeat_records[strategy] = record + by_strategy[strategy] = color_map + outcome = ( + f"error={record['error']}" + if record["error"] + else "complete" + ) + render_check = record[ + "conditional_render_validation" + ] + if render_check is not None: + outcome += ( + "; CF visibility=" + f"{render_check['status']}" + ) + print( + f" [{strategy}] {outcome}; " + f"{record['elapsed_ms']:.1f} ms, " + f"wrapper calls={record['wrapper_display_format_calls']}", + flush=True, + ) + reference_map = by_strategy["reference"] + hybrid_map = by_strategy["hybrid"] + exact_equal = ( + reference_map == hybrid_map + if reference_map is not None and hybrid_map is not None + else None + ) + for record in repeat_records.values(): + record["exact_workbook_colors_map_equal"] = exact_equal + print( + f" [repeat {repeat}/{args.repeats}] exact map equality=" + f"{exact_equal}", + flush=True, + ) + pair["repeats"].append( + { + "repeat": repeat, + "order": list(order), + "exact_workbook_colors_map_equal": exact_equal, + "errors": { + strategy: repeat_records[strategy]["error"] + for strategy in ("reference", "hybrid") + if repeat_records[strategy]["error"] + }, + "conditional_render_validation": { + strategy: repeat_records[strategy][ + "conditional_render_validation" + ] + for strategy in ("reference", "hybrid") + }, + } + ) + result["comparisons"].append(pair) + _write_output(args.output.resolve(), result) + finally: + book.close() + finally: + if app is not None: + app.quit() + after = sorted(xw.apps.keys()) + if after != before: + raise RuntimeError( + f"Excel process cleanup mismatch: before={before}, after={after}" + ) + + _write_output(args.output.resolve(), result) + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True, help="JSON result path") + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--rows", type=int, default=300) + parser.add_argument( + "--cases", + nargs="+", + choices=_FIXTURE_CASES, + default=list(_FIXTURE_CASES), + help="Fixture cases to run; for example: --cases static sparse_cf", + ) + parser.add_argument( + "--fixtures-dir", + type=Path, + help="Directory for generated xlsx fixtures (default: -fixtures)", + ) + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + if args.rows < 1: + parser.error("--rows must be positive") + if args.fixtures_dir is None: + args.fixtures_dir = args.output.with_name(args.output.stem + "-fixtures") + if args.output.resolve() == args.fixtures_dir.resolve(): + parser.error("--output must not be the fixture directory") + + result = _run(args) + pairs = [ + repeat["exact_workbook_colors_map_equal"] + for comparison in result["comparisons"] + for repeat in comparison["repeats"] + ] + failed = any(value is False or value is None for value in pairs) or any( + record["error"] + or ( + record["conditional_render_validation"] is not None + and record["conditional_render_validation"]["status"] + in {"failed", "unavailable"} + ) + for record in result["measurements"] + ) + print( + json.dumps( + { + "output": str(args.output.resolve()), + "fixtures_dir": str(args.fixtures_dir.resolve()), + "fixture_count": len(result["fixtures"]), + "measurement_count": len(result["measurements"]), + "all_exact_comparisons_equal": not failed, + "all_conditional_render_checks_passed": not any( + record["conditional_render_validation"] is not None + and record["conditional_render_validation"]["status"] + in {"failed", "unavailable"} + for record in result["measurements"] + ), + "mixed_range_probe": result["mixed_range_probe"], + }, + indent=2, + ensure_ascii=False, + ) + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md b/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md new file mode 100644 index 0000000..a38356b --- /dev/null +++ b/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md @@ -0,0 +1,129 @@ +# ADR-0015: Hybrid Rendered Color Extraction + +## 状態 + +`proposed` + +## 背景 + +Issue #159 addresses the cost of reading Excel's rendered background color +through `DisplayFormat` for every cell in a worksheet's used range. Saved cell +fills and conditional-formatting ranges can narrow that work, but saved styles +alone do not represent every rendered fill, and Python must not reproduce +Excel's conditional-format evaluator. + +The optimization is limited to `.xlsx` / `.xlsm` workbooks whose saved file can +be tied to the open COM workbook and the extraction's openpyxl session. It does +not assume that saved formatting describes unsaved live workbook changes. The +existing public `colors_map` behavior and the Excel COM rendering authority +remain in place. + +## 決定 + +- Adopt a hybrid strategy for COM-backed `colors_map` extraction when the + workbook is `.xlsx` / `.xlsm`, the saved file exists, the COM workbook path + matches the extraction session path, and `workbook.api.Saved` is true. +- Read ordinary static fills from the saved worksheet representation. Use + saved worksheet XML to identify conditional-format ranges, clip those ranges + to the live COM `UsedRange`, and deduplicate candidate cells. Also send static + cells with unsupported saved color representations to COM as candidates. +- Continue to ask Excel for the final color of each candidate cell through + `DisplayFormat`. Excel remains responsible for all conditional-format rule + evaluation, including formula semantics, priority, overlap, and + `stopIfTrue`; Python only identifies candidate coordinates. +- Fall back to the existing full `DisplayFormat` scan for a worksheet whenever + its XML or formatting constructs make candidate coverage uncertain. This + includes `extLst`, `AlternateContent`, `pivotTableParts`, unsupported + conditional-format namespaces/rules, styled row/column definitions, + table styles, merged-cell effects, and a custom `Normal` named style + (`builtinId=0`) with a non-empty fill. Missing or unreadable files, + unsupported formats, an ineligible workbook state, a session path mismatch, + and raw XML or workbook-load failures also use the legacy scan. Discard + partial saved-analysis results before scanning that worksheet. If a strict + candidate `DisplayFormat` read fails, discard that worksheet's partial hybrid + map and retry its full legacy scan. If a legacy color read also fails, retain + existing per-cell error suppression: include the default background when + requested or omit the cell otherwise. Count failed candidate reads and the + retry scan in display-call instrumentation. +- Reuse a supplied extraction session only for the matching workbook path. + Standalone path-based calls own and close their session. Retain the current + COM preparation and full-scan implementation as the correctness fallback + and internal parity oracle. +- Preserve the existing output model and semantics: color normalization, + coordinates, per-sheet and workbook maps, `include_default_background`, and + `ignore_colors`. Do not change public APIs, modes, or serialized schemas. +- Record used-range cell count, conditional-format candidate count, actual + `DisplayFormat` call count, static/COM/total durations, and fallback reason + for diagnosis and performance comparison. + +## 影響 + +- Worksheets with ordinary saved fills and no conditional formatting can avoid + per-cell `DisplayFormat` calls. Sparse conditional-format ranges can reduce + those calls to the union of clipped candidate cells plus cells whose saved + static color representation needs Excel resolution. +- Dense conditional formatting and conservative fallbacks retain the legacy + scan cost. The strategy does not promise a wall-clock speedup for every + workbook or Excel installation. +- Raw XML validation and style classification add implementation complexity. + Conservative per-sheet fallback limits the risk of returning incomplete or + incorrectly rendered maps as the OOXML surface evolves. +- Excel remains the source of truth for rendered colors, so formula and rule + behavior stays aligned with the installed Excel version. Unsaved live + formatting is handled by the legacy path rather than inferred from the saved + file. +- Instrumentation makes candidate reduction and fallback behavior observable; + deterministic call counts can be compared independently of machine-specific + timing. + +## 根拠 + +- Tests: `tests/backends/test_colors_map.py` covers default-background and + ignored-color output; `tests/core/test_cells_color_normalization.py` covers + color-key normalization; `tests/core/test_openpyxl_extraction_session.py` + covers shared-session color extraction, standalone/session parity, and + resource lifecycle on the compatibility path. `tests/core/test_color_hybrid.py` + uses synthetic workbook data and a fake COM surface to cover static-fill + parity, candidate clipping/deduplication, conditional-color override, + unsupported XML/style fallbacks (including `pivotTableParts` and custom + `Normal` fill), strict candidate-read retry, unsaved/session-mismatch/ + non-`.xlsx` eligibility, missing/corrupt saved files, sheet isolation, shared + pipeline/session behavior, and session ownership. Automatic and gradient + fills are synthetic unit cases, not live-Excel fixtures. +- Code: `src/exstruct/core/color_hybrid.py` implements saved-fill and candidate + analysis; `src/exstruct/core/cells.py` retains the COM color entrypoint and + legacy per-cell `DisplayFormat` scan; + `src/exstruct/core/backends/com_backend.py` passes the optional extraction + session; `src/exstruct/core/openpyxl_session.py` owns the reusable workbook + session. +- Related specs: `dev-docs/specs/excel-extraction.md`, + `dev-docs/specs/hybrid-color-extraction.md`, and + `dev-docs/specs/extraction-performance.md`. +- Related decisions: ADR-0002 defines fallback policy; ADR-0011 defines + extraction-scoped workbook reuse; ADR-0013 defines the light-mode backend + boundary; ADR-0014 records the separate file-first cell-extraction decision. +- Benchmark: The final Windows / Excel 16.0 run contains 48 measurements for + 300-row static and sparse-CF fixtures: all 24 reference/hybrid map comparisons + passed across four configurations and three repeats per fixture. Conditional + color validation passed. `DisplayFormat` calls fell from 6,000 to 0 for + static fills and from 6,000 to 30 for sparse CF. See the [final benchmark + JSON](../../../benchmark/baselines/issue159-colors-final-windows.json). + The separate 10-row sparse, dense, and overlapping-formula probe also passed + all configurations and verified visible CF colors; its mixed `A1:B1` range + returned `0` while per-cell reads returned `255` and `12611584`, confirming + the range property is not a per-cell color matrix. See the [CF probe + JSON](../../../benchmark/baselines/issue159-colors-cf-probe-windows.json). + Measurement details are in [benchmark results](../../../benchmark/issue159-results.md). + Wall-clock results are environment-specific and do not imply a universal + timing guarantee. +- Classification: `recommended`; record a performance-strategy change while + retaining public semantics. Primary domain: `performance`; other domains: + `backend`, `extraction`, `compatibility`, `fallback`. + +## Supersedes + +- None + +## Superseded by + +- None diff --git a/dev-docs/adr/README.md b/dev-docs/adr/README.md index 99d01c3..f300d28 100644 --- a/dev-docs/adr/README.md +++ b/dev-docs/adr/README.md @@ -49,3 +49,4 @@ ADRs record what was decided, under which constraints, and which trade-offs were | `ADR-0012` | Optional SciPy Border Clustering | `proposed` | `performance` | | `ADR-0013` | Default OOXML Backend for Light Extraction | `proposed` | `backend` | | `ADR-0014` | Retain File-First COM Extraction | `proposed` | `performance` | +| `ADR-0015` | Hybrid Rendered Color Extraction | `proposed` | `performance` | diff --git a/dev-docs/adr/decision-map.md b/dev-docs/adr/decision-map.md index 029dfb6..56ae54b 100644 --- a/dev-docs/adr/decision-map.md +++ b/dev-docs/adr/decision-map.md @@ -5,6 +5,7 @@ This document is a human-readable map for navigating ADRs by domain. ## extraction - `ADR-0014` Retain File-First COM Extraction (`proposed`) +- `ADR-0015` Hybrid Rendered Color Extraction (`proposed`) - `ADR-0001` Extraction Mode Responsibility Boundaries (`superseded`) - `ADR-0002` Rich Backend Fallback Policy (`accepted`) @@ -22,6 +23,7 @@ This document is a human-readable map for navigating ADRs by domain. ## backend - `ADR-0014` Retain File-First COM Extraction (`proposed`) +- `ADR-0015` Hybrid Rendered Color Extraction (`proposed`) - `ADR-0002` Rich Backend Fallback Policy (`accepted`) - `ADR-0004` Patch Backend Selection Policy (`accepted`) @@ -34,6 +36,7 @@ This document is a human-readable map for navigating ADRs by domain. - `ADR-0002` Rich Backend Fallback Policy (`accepted`) - `ADR-0013` Default OOXML Backend for Light Extraction (`proposed`) +- `ADR-0015` Hybrid Rendered Color Extraction (`proposed`) ## schema @@ -46,6 +49,7 @@ This document is a human-readable map for navigating ADRs by domain. ## compatibility - `ADR-0014` Retain File-First COM Extraction (`proposed`) +- `ADR-0015` Hybrid Rendered Color Extraction (`proposed`) - `ADR-0003` Output Serialization Omission Policy (`accepted`) - `ADR-0004` Patch Backend Selection Policy (`accepted`) @@ -59,6 +63,7 @@ This document is a human-readable map for navigating ADRs by domain. ## performance - `ADR-0014` Retain File-First COM Extraction (`proposed`) +- `ADR-0015` Hybrid Rendered Color Extraction (`proposed`) - `ADR-0011` Extraction-Scoped Workbook Reuse and Direct Cell Reading (`proposed`) - `ADR-0012` Optional SciPy Border Clustering (`proposed`) diff --git a/dev-docs/adr/index.yaml b/dev-docs/adr/index.yaml index ccf2282..eb68d47 100644 --- a/dev-docs/adr/index.yaml +++ b/dev-docs/adr/index.yaml @@ -228,3 +228,20 @@ adrs: - dev-docs/specs/excel-extraction.md - dev-docs/specs/extraction-performance.md - benchmark/issue151-results.md + - id: ADR-0015 + title: Hybrid Rendered Color Extraction + status: proposed + path: dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md + primary_domain: performance + domains: + - performance + - backend + - extraction + - compatibility + - fallback + supersedes: [] + superseded_by: [] + related_specs: + - dev-docs/specs/excel-extraction.md + - dev-docs/specs/hybrid-color-extraction.md + - dev-docs/specs/extraction-performance.md diff --git a/dev-docs/specs/excel-extraction.md b/dev-docs/specs/excel-extraction.md index 8bc2859..4a1774e 100644 --- a/dev-docs/specs/excel-extraction.md +++ b/dev-docs/specs/excel-extraction.md @@ -172,9 +172,16 @@ Mode notes: ## Colors Map -- Prefer COM to include conditional formatting colors -- Overwrite with COM results when COM succeeds -- Use openpyxl results only when COM fails +- COM-backed `colors_map` extraction retains Excel-rendered conditional-format + colors and overwrites compatibility results when COM succeeds. Existing + normalization, coordinates, default-background, and ignored-color behavior + remain unchanged. +- For eligible saved `.xlsx` / `.xlsm` workbooks, use the hybrid saved-fill and + conditional-format candidate strategy in + [Hybrid rendered color extraction](hybrid-color-extraction.md). Excel + evaluates final rendered colors for every candidate; unsupported or + ambiguous worksheet constructs use the legacy full COM scan. +- Use openpyxl results only when the existing COM fallback applies. - An explicit `colors_map` request on the direct light OOXML path selects the complete openpyxl compatibility pipeline. diff --git a/dev-docs/specs/hybrid-color-extraction.md b/dev-docs/specs/hybrid-color-extraction.md new file mode 100644 index 0000000..250c52d --- /dev/null +++ b/dev-docs/specs/hybrid-color-extraction.md @@ -0,0 +1,180 @@ +# Hybrid Rendered Color Extraction Specification + +This specification defines how COM-backed `colors_map` extraction combines +saved worksheet fills with Excel-rendered colors while preserving the existing +output contract. + +## Scope and compatibility + +- Applies to COM-backed color-map extraction used by `standard` / `verbose` on + `.xlsx` and `.xlsm` workbooks. +- Does not change the public `colors_map` shape, color-key normalization, + coordinate convention, `include_default_background`, `ignore_colors`, or + mode selection. +- Excel `DisplayFormat` remains authoritative for cells affected by + conditional formatting. Python identifies candidate cells; it does not + evaluate conditional-format formulas or rule outcomes. +- The existing full COM scan remains available as the fallback and internal + parity oracle. + +## Hybrid eligibility + +Use the hybrid path only when all of the following hold: + +- The COM workbook is an `.xlsx` or `.xlsm` file. +- Its saved file exists and can be loaded and parsed. +- A usable openpyxl extraction session refers to the same normalized absolute + path as the COM workbook. +- `workbook.api.Saved` is true. + +When a caller supplies a session, reuse it only after confirming the path +match. A path-based standalone call owns a temporary session and closes it +after extraction. A session mismatch, unreadable/missing file, unsupported +format, unreadable saved state, or session load failure selects the legacy +full scan. The hybrid path makes no claim that saved formatting represents +unsaved live changes. + +## Saved worksheet analysis + +Validate each worksheet's raw XML before using it to limit COM calls. The +supported path must understand the worksheet namespace and conditional-format +markup it encounters. Fall back to a full legacy scan for that worksheet if +any of these conditions prevents a complete, trustworthy candidate set: + +- The worksheet contains `extLst`, markup-compatibility `AlternateContent`, or + `pivotTableParts`. +- Conditional-format markup uses an unrecognized namespace or an unrecognized + rule representation/type. +- Styled row/column definitions, table styles, or merged-cell effects make the + rendered background of any used cell ambiguous. The initial implementation + may conservatively fall back when any row/column style, table, or merged + range is present. +- A custom `Normal` named style (`builtinId=0`) has a non-empty fill, because + otherwise unstyled cells could inherit a saved fill that is not explicit on + the cell. +- Worksheet XML cannot be parsed or its relationships/parts cannot be loaded. + +The validator is deliberately conservative. It must not ignore unfamiliar +conditional-format constructs and then return a partially optimized map. + +## Static fills and candidate cells + +For a worksheet that passes validation: + +1. Read the saved worksheet fills and restrict all results to the live COM + `UsedRange`. +2. Resolve ordinary no-fill cells and solid direct-RGB fills without tint from + saved data, using the existing color normalization and filtering rules. +3. Add cells with other saved static color forms to the COM candidate set. + This includes theme, indexed, automatic, patterned, gradient, and tinted + fills. Excel resolves those colors through `DisplayFormat`. +4. Read every recognized conditional-format `sqref` range, intersect it with + the live COM `UsedRange`, and deduplicate the resulting cells. The count of + conditional-format candidates is the size of this clipped union. +5. Query `DisplayFormat.Interior.Color` once per unique candidate cell. Merge + these normalized COM results over the saved static map at the same + coordinates. + +The live COM used range defines the output boundary. Candidate ranges and +saved static cells outside it must not add coordinates to the result. + +## Excel evaluation and output semantics + +Excel evaluates every candidate's final rendered color. This includes +condition formulas, relative references, calculation state, priority, +overlapping rules, and `stopIfTrue`. ExStruct does not evaluate or simplify +those rules in Python. + +Apply existing behavior after combining the saved and COM colors: + +- Normalize color keys with the existing RGB conversion. +- Apply `ignore_colors` using the normalized color key. +- Include or omit the default background according to + `include_default_background`. +- Keep rows 1-based and columns 0-based in each per-sheet map. +- Return the same `SheetColorsMap` and `WorkbookColorsMap` structures. + +COM preparation required by the current rendered-color path remains in place. + +## Fallback and atomicity + +- Workbook-level eligibility, file, session, XML-container, or load failures + use the legacy full scan for the workbook. +- An unsupported or ambiguous construct isolated to one worksheet uses the + legacy full scan for that worksheet while eligible worksheets may still use + the hybrid path. +- If saved-worksheet parsing, candidate construction, or static-fill + classification fails after producing partial data for a worksheet, discard + that worksheet's partial map and recompute it through the legacy full scan. + Never return a partial hybrid map for that worksheet. +- Candidate rendering uses strict `DisplayFormat` reads. If one fails, discard + the partial hybrid map and retry a full legacy scan for that worksheet. If + a color read in that retry also fails, preserve legacy per-cell error + suppression: include the default background when requested or omit the cell + otherwise. This does not itself trigger a workbook-level fallback. +- Record a fallback reason for each legacy route. Fallback does not change the + public result shape or expose a new public mode. + +## Resource ownership + +- Backend extraction reuses the supplied openpyxl session only when its + resolved path matches the COM workbook path. +- A standalone path-based call owns and closes its session, including on parse, + COM, or fallback errors. +- The color extractor does not retain sessions across calls or share them + across concurrent extractions. + +## Instrumentation + +Record per worksheet: + +- Total cells in the live used-range rectangle. +- Unique conditional-format candidate cells after clipping and deduplication. +- Actual `DisplayFormat` cell calls, including calls for unsupported static + color representations, failed candidate reads, and legacy retry reads. A + retry can make this count exceed the used-range cell count. +- Static extraction duration, COM rendered-color duration (including a retry), + and total color extraction duration. +- Fallback reason, or no fallback. + +Use deterministic call counts to compare candidate reduction. Wall-clock +measurements are diagnostic and do not impose a universal CI timing threshold. + +## Verification fixtures + +`tests/core/test_color_hybrid.py` compares normalized maps against the retained +legacy oracle using synthetic workbook data and a fake COM surface. It covers +static/default behavior, blank cells within the COM used range, conditional +candidate clipping/deduplication, conditional-color override, synthetic +theme/indexed/tinted/pattern/automatic/gradient fill classification, XML +fallbacks (`extLst`, unknown rule, foreign namespace, `pivotTableParts`), +custom `Normal` fill, ambiguous row/column/table/merge cases, transient +candidate-read retry, unsaved/session-mismatch/non-`.xlsx` eligibility, +sheet isolation, shared pipeline/session reuse, and standalone session +ownership, including missing/corrupt saved-file fallback. Automatic and +gradient fills are synthetic unit cases, not live-Excel fixtures. + +The repeat benchmark and its current measurements are tracked in +[benchmark/issue159-results.md](../../../benchmark/issue159-results.md). Keep +Excel wall-clock results separate from correctness and deterministic +call-count checks. + +## Recorded runtime smoke evidence + +The final Windows / Excel 16.0 run has 48 measurements for 300-row static and +sparse-CF fixtures: reference and hybrid maps matched in all 24 comparisons +across four `include_default_background` / `ignore_colors` configurations and +three repeats per fixture. Conditional-color validation passed. Calls fell +from 6,000 to 0 for static fills and from 6,000 to 30 for sparse CF. + +The separate Windows / Excel 16.0 CF probe used 10-row sparse, dense, and +overlapping-formula fixtures. All configurations matched exactly and expected +conditional colors were visible. In its mixed `A1:B1` probe, +`Range("A1:B1").DisplayFormat.Interior.Color` returned `0`, while individual +cell reads returned `255` and `12611584`; the range property does not provide a +per-cell color matrix. + +See the [final benchmark JSON](../../../benchmark/baselines/issue159-colors-final-windows.json), +[CF probe JSON](../../../benchmark/baselines/issue159-colors-cf-probe-windows.json), +and [benchmark results](../../../benchmark/issue159-results.md) for details. +Wall-clock timings vary by environment and are not a universal guarantee. diff --git a/pyproject.toml b/pyproject.toml index be1a360..076f68c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "exstruct" -version = "0.8.2" +version = "0.9.0" description = "Excel to structured JSON (tables, shapes, charts) for LLM/RAG pipelines" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/exstruct/core/backends/com_backend.py b/src/exstruct/core/backends/com_backend.py index 2f8add4..1b8c5f4 100644 --- a/src/exstruct/core/backends/com_backend.py +++ b/src/exstruct/core/backends/com_backend.py @@ -17,6 +17,7 @@ extract_sheet_formulas_map_com, ) from ..charts import get_charts +from ..openpyxl_session import OpenpyxlExtractionSession from ..ranges import parse_range_zero_based from ..shapes import get_shapes_with_position from .base import ( @@ -58,6 +59,7 @@ class ComBackend: """ workbook: xw.Book + session: OpenpyxlExtractionSession | None = None def extract_cells(self, *, include_links: bool = False) -> CellData: """Prototype bulk Value2 reader; intentionally unused by public extraction. @@ -151,6 +153,13 @@ def extract_colors_map( WorkbookColorsMap | None: A mapping of workbook color definitions when extraction succeeds, or `None` if COM extraction fails. """ try: + if self.session is not None: + return extract_sheet_colors_map_com( + self.workbook, + include_default_background=include_default_background, + ignore_colors=ignore_colors, + session=self.session, + ) return extract_sheet_colors_map_com( self.workbook, include_default_background=include_default_background, diff --git a/src/exstruct/core/cells.py b/src/exstruct/core/cells.py index 457024b..7ca8bde 100644 --- a/src/exstruct/core/cells.py +++ b/src/exstruct/core/cells.py @@ -4,6 +4,7 @@ from collections import deque from collections.abc import Callable, Iterable, Sequence +from contextvars import ContextVar from dataclasses import dataclass from datetime import time from decimal import Decimal, InvalidOperation @@ -59,6 +60,10 @@ } _DEFAULT_BACKGROUND_HEX = "FFFFFF" _XL_COLOR_NONE = -4142 +_display_format_calls: ContextVar[int] = ContextVar("display_format_calls", default=0) +_strict_display_format: ContextVar[bool] = ContextVar( + "strict_display_format", default=False +) _BORDER_CLUSTER_BACKEND_ENV = "EXSTRUCT_BORDER_CLUSTER_BACKEND" ExtractionMode = Literal["light", "libreoffice", "standard", "verbose"] @@ -235,6 +240,7 @@ def extract_sheet_colors_map_com( *, include_default_background: bool, ignore_colors: set[str] | None, + session: OpenpyxlExtractionSession | None = None, ) -> WorkbookColorsMap: """ Extract per-sheet background color maps using the workbook's COM/display-format interfaces. @@ -247,15 +253,9 @@ def extract_sheet_colors_map_com( Returns: WorkbookColorsMap: Mapping of sheet names to SheetColorsMap containing detected background color positions for each worksheet. """ - _prepare_workbook_for_display_format(workbook) - sheets: dict[str, SheetColorsMap] = {} - for sheet in workbook.sheets: - _prepare_sheet_for_display_format(sheet) - sheet_map = _extract_sheet_colors_com( - sheet, include_default_background, ignore_colors - ) - sheets[sheet.name] = sheet_map - return WorkbookColorsMap(sheets=sheets) + from .color_hybrid import extract_colors + + return extract_colors(workbook, include_default_background, ignore_colors, session) def _extract_sheet_colors( @@ -552,10 +552,13 @@ def _get_display_format_color(sheet: xw.Sheet, row: int, col: int) -> int | None """ try: cell = sheet.api.Cells(row, col) + _display_format_calls.set(_display_format_calls.get() + 1) display_format = cell.DisplayFormat interior = display_format.Interior return int(interior.Color) except Exception: + if _strict_display_format.get(): + raise return None diff --git a/src/exstruct/core/color_hybrid.py b/src/exstruct/core/color_hybrid.py new file mode 100644 index 0000000..28833f1 --- /dev/null +++ b/src/exstruct/core/color_hybrid.py @@ -0,0 +1,288 @@ +"""Conservative saved-fill extraction with Excel as the rendered-color oracle.""" + +from __future__ import annotations + +from contextlib import ExitStack +import logging +from pathlib import Path +import posixpath +from time import perf_counter +from typing import TYPE_CHECKING +from zipfile import ZipFile + +from defusedxml import ElementTree + +from . import cells +from .cell_types import SheetColorsMap, WorkbookColorsMap +from .openpyxl_session import OpenpyxlExtractionSession + +if TYPE_CHECKING: + from openpyxl.worksheet.worksheet import Worksheet + import xlwings as xw + +logger = logging.getLogger(__name__) +_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +_REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_RULES = { + "expression", + "cellIs", + "colorScale", + "dataBar", + "iconSet", + "top10", + "uniqueValues", + "duplicateValues", + "containsText", + "notContainsText", + "beginsWith", + "endsWith", + "containsBlanks", + "notContainsBlanks", + "containsErrors", + "notContainsErrors", + "timePeriod", + "aboveAverage", +} +Bounds = tuple[int, int, int, int] + + +def _worksheet_parts(archive: ZipFile) -> dict[str, str]: + """Resolve worksheet names through package relationships, never sheet order.""" + rels = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + targets = {} + for rel in rels: + if rel.attrib.get("TargetMode") == "External": + continue + target = rel.attrib["Target"] + targets[rel.attrib["Id"]] = ( + target.lstrip("/") + if target.startswith("/") + else posixpath.normpath(posixpath.join("xl", target)) + ) + root = ElementTree.fromstring(archive.read("xl/workbook.xml")) + return { + sheet.attrib["name"]: targets[sheet.attrib[f"{{{_REL}}}id"]] + for sheet in root.findall(f"{{{_MAIN}}}sheets/{{{_MAIN}}}sheet") + } + + +def _conditional_ranges(raw: bytes) -> list[Bounds]: + """Reject lossy extension parsing before trusting saved conditional ranges.""" + root = ElementTree.fromstring(raw) + if root.tag != f"{{{_MAIN}}}worksheet": + raise ValueError("unsupported worksheet namespace") + ranges = [] + for element in root.iter(): + name = element.tag.rsplit("}", 1)[-1] + if name in {"extLst", "AlternateContent", "pivotTableParts"}: + raise ValueError("worksheet extensions may affect rendered fills") + if name not in {"conditionalFormatting", "cfRule"}: + continue + if element.tag != f"{{{_MAIN}}}{name}": + raise ValueError("unsupported conditional formatting namespace") + if name == "cfRule": + if element.attrib.get("type") not in _RULES: + raise ValueError("unsupported conditional formatting rule") + else: + refs = element.attrib.get("sqref", "").split() + if not refs: + raise ValueError("missing conditional formatting range") + for ref in refs: + c1, r1, c2, r2 = cells.range_boundaries(ref) + if not (1 <= r1 <= r2 <= 1048576 and 1 <= c1 <= c2 <= 16384): + raise ValueError("invalid conditional formatting range") + ranges.append((r1, c1, r2, c2)) + return ranges + + +def _candidate_cells(ranges: list[Bounds], bounds: Bounds) -> set[tuple[int, int]]: + """Clip first so entire-column rules cannot expand beyond UsedRange.""" + r1, c1, r2, c2 = bounds + return { + (row, col) + for a, b, z, y in ranges + for row in range(max(r1, a), min(r2, z) + 1) + for col in range(max(c1, b), min(c2, y) + 1) + } + + +def _static_colors( + ws: Worksheet, + bounds: Bounds, + candidates: set[tuple[int, int]], + include_default: bool, +) -> dict[tuple[int, int], str | None]: + """Only trust fills whose RGB interpretation is identical to Excel's.""" + if ws.merged_cells.ranges or ws.tables: + raise ValueError("merged cells or table styles require rendered scan") + if any(d.has_style for d in ws.row_dimensions.values()) or any( + d.has_style for d in ws.column_dimensions.values() + ): + raise ValueError("row/column styles require rendered scan") + # An absent cell style inherits Normal; it may have a custom workbook fill. + for style in ws.parent._named_styles: + if style.builtinId == 0 and style.fill.patternType not in (None, "none"): + raise ValueError("custom Normal fill requires rendered scan") + r1, c1, r2, c2 = bounds + result = {} + for row in ws.iter_rows(min_row=r1, max_row=r2, min_col=c1, max_col=c2): + for cell in row: + coord = (cell.row, cell.column) + if coord in candidates: + continue + fill = cell.fill + pattern = getattr(fill, "patternType", "unsupported") + if pattern in (None, "none"): + result[coord] = "FFFFFF" if include_default else None + elif ( + pattern == "solid" + and fill.fgColor.type == "rgb" + and not fill.fgColor.tint + ): + result[coord] = cells._resolve_cell_background(cell, include_default) + else: + candidates.add(coord) + return result + + +def _extract_sheet( + sheet: xw.Sheet, + session: OpenpyxlExtractionSession | None, + archive: ZipFile | None, + parts: dict[str, str], + reason: str, + include_default: bool, + ignore_colors: set[str] | None, +) -> SheetColorsMap: + started = perf_counter() + token = cells._display_format_calls.set(0) + static_seconds = 0.0 + rendered_seconds = 0.0 + candidates: set[tuple[int, int]] = set() + conditional_count = 0 + used_cells = 0 + fallback = True + try: + used = sheet.used_range + last = used.last_cell + bounds = ( + int(getattr(used, "row", 1)), + int(getattr(used, "column", 1)), + int(last.row), + int(last.column), + ) + r1, c1, r2, c2 = bounds + used_cells = max(0, r2 - r1 + 1) * max(0, c2 - c1 + 1) + static_started = perf_counter() + try: + if session is None or archive is None: + raise ValueError(reason) + ranges = _conditional_ranges(archive.read(parts[sheet.name])) + candidates = _candidate_cells(ranges, bounds) + conditional_count = len(candidates) + ws = session.workbook()[sheet.name] + colors = _static_colors(ws, bounds, candidates, include_default) + fallback = False + except Exception as exc: + reason = str(exc) + finally: + static_seconds = perf_counter() - static_started + rendered_started = perf_counter() + if fallback: + logger.debug( + "Color extraction legacy fallback sheet=%s reason=%s", + sheet.name, + reason, + ) + return cells._extract_sheet_colors_com( + sheet, include_default, ignore_colors + ) + strict_token = cells._strict_display_format.set(True) + try: + for row, col in sorted(candidates): + colors[row, col] = cells._resolve_cell_background_com( + sheet, row, col, include_default + ) + except Exception as exc: + fallback = True + reason = f"rendered candidate extraction failed: {exc}" + finally: + cells._strict_display_format.reset(strict_token) + if fallback: + logger.debug( + "Color extraction legacy fallback sheet=%s reason=%s", + sheet.name, + reason, + ) + return cells._extract_sheet_colors_com( + sheet, include_default, ignore_colors + ) + rendered_seconds = perf_counter() - rendered_started + ignore = cells._normalize_ignore_colors(ignore_colors) + mapping: dict[str, list[tuple[int, int]]] = {} + for (row, col), color in sorted(colors.items()): + if color is not None: + key = cells._normalize_color_key(color) + if not cells._should_ignore_color(key, ignore): + mapping.setdefault(key, []).append((row, col - 1)) + return SheetColorsMap(sheet_name=sheet.name, colors_map=mapping) + finally: + if fallback and "rendered_started" in locals(): + rendered_seconds = perf_counter() - rendered_started + logger.debug( + "Color extraction sheet=%s used_cells=%d conditional_candidates=%d " + "rendered_candidates=%d display_format_calls=%d fallback=%s " + "static_duration_ms=%.3f com_duration_ms=%.3f duration_ms=%.3f", + sheet.name, + used_cells, + conditional_count, + len(candidates), + cells._display_format_calls.get(), + fallback, + static_seconds * 1000, + rendered_seconds * 1000, + (perf_counter() - started) * 1000, + ) + cells._display_format_calls.reset(token) + + +def extract_colors( + workbook: xw.Book, + include_default: bool, + ignore_colors: set[str] | None, + session: OpenpyxlExtractionSession | None, +) -> WorkbookColorsMap: + """Own standalone resources and preserve legacy preparation and fallback.""" + cells._prepare_workbook_for_display_format(workbook) + with ExitStack() as stack: + archive = None + parts: dict[str, str] = {} + reason = "saved workbook unavailable" + try: + path = Path(workbook.fullname) + if path.suffix.lower() not in {".xlsx", ".xlsm"}: + raise ValueError("unsupported saved workbook format") + if not workbook.api.Saved: + raise ValueError("workbook has unsaved changes") + if session is not None and session.file_path.resolve() != path.resolve(): + raise ValueError("saved workbook session path mismatch") + archive = stack.enter_context(ZipFile(path)) + parts = _worksheet_parts(archive) + if session is None: + session = stack.enter_context(OpenpyxlExtractionSession(path)) + except Exception as exc: + reason = str(exc) + archive = None + sheets = {} + for sheet in workbook.sheets: + cells._prepare_sheet_for_display_format(sheet) + sheets[sheet.name] = _extract_sheet( + sheet, + session, + archive, + parts, + reason, + include_default, + ignore_colors, + ) + return WorkbookColorsMap(sheets=sheets) diff --git a/src/exstruct/core/pipeline.py b/src/exstruct/core/pipeline.py index 2406d21..af8bb44 100644 --- a/src/exstruct/core/pipeline.py +++ b/src/exstruct/core/pipeline.py @@ -806,7 +806,13 @@ def step_extract_colors_map_com( backend_type = _backend_type("ComBackend") - com_result = backend_type(workbook).extract_colors_map( + backend = backend_type(workbook) + # Preserve the minimal constructor surface of legacy backend overrides. + native_module = sys.modules.get("exstruct.core.backends.com_backend") + native_type = getattr(native_module, "ComBackend", None) + if backend_type is native_type and artifacts.openpyxl_session is not None: + backend = backend_type(workbook, session=artifacts.openpyxl_session) + com_result = backend.extract_colors_map( include_default_background=inputs.include_default_background, ignore_colors=inputs.ignore_colors, ) diff --git a/tests/core/test_color_hybrid.py b/tests/core/test_color_hybrid.py new file mode 100644 index 0000000..c107094 --- /dev/null +++ b/tests/core/test_color_hybrid.py @@ -0,0 +1,850 @@ +"""Behavioral tests for the saved-fill plus rendered-color extraction path.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import shutil +from types import SimpleNamespace +from zipfile import ZIP_DEFLATED, ZipFile + +from openpyxl import Workbook, load_workbook +from openpyxl.formatting.rule import FormulaRule +from openpyxl.styles import Color, GradientFill, PatternFill +from openpyxl.utils.cell import range_boundaries +from openpyxl.worksheet.table import Table +from openpyxl.worksheet.worksheet import Worksheet +import pytest + +from exstruct.core import cells, color_hybrid, pipeline +from exstruct.core.cell_types import SheetColorsMap +from exstruct.core.openpyxl_session import OpenpyxlExtractionSession + +_NO_FILL = -4142 + + +def _excel_color(rgb: str) -> int: + """Convert RGB hex to Excel's integer color representation.""" + red, green, blue = (int(rgb[index : index + 2], 16) for index in (0, 2, 4)) + return red | (green << 8) | (blue << 16) + + +class _FakeSheetAPI: + def __init__(self, sheet: _FakeSheet) -> None: + self.sheet = sheet + + def Activate(self) -> None: # noqa: N802 - mirrors Excel COM + pass + + def Calculate(self) -> None: # noqa: N802 - mirrors Excel COM + pass + + def Cells(self, row: int, column: int) -> SimpleNamespace: # noqa: N802 + self.sheet.display_calls.append((row, column)) + color = self.sheet.rendered_colors.get((row, column), _NO_FILL) + return SimpleNamespace( + DisplayFormat=SimpleNamespace( + Interior=_FakeInterior(self.sheet, (row, column), color) + ) + ) + + +class _FakeInterior: + def __init__( + self, + sheet: _FakeSheet, + coord: tuple[int, int], + color: int | None, + ) -> None: + self.sheet = sheet + self.coord = coord + self.color = color + + @property + def Color(self) -> int | None: # noqa: N802 - mirrors Excel COM + remaining = self.sheet.failures_remaining.get(self.coord, 0) + if remaining: + self.sheet.failures_remaining[self.coord] = remaining - 1 + raise RuntimeError("transient DisplayFormat failure") + return self.color + + +class _FakeSheet: + def __init__( + self, + name: str, + bounds: tuple[int, int, int, int], + rendered_colors: dict[tuple[int, int], int | None], + failures_remaining: dict[tuple[int, int], int] | None = None, + ) -> None: + row, column, last_row, last_column = bounds + self.name = name + self.used_range = SimpleNamespace( + row=row, + column=column, + last_cell=SimpleNamespace(row=last_row, column=last_column), + ) + self.rendered_colors = rendered_colors + self.failures_remaining = failures_remaining or {} + self.display_calls: list[tuple[int, int]] = [] + self.api = _FakeSheetAPI(self) + + +class _FakeWorkbook: + def __init__( + self, fullname: Path, sheets: list[_FakeSheet], *, saved: bool = True + ) -> None: + self.fullname = str(fullname) + self.sheets = sheets + self.api = SimpleNamespace(Saved=saved) + self.app = SimpleNamespace(calculate=lambda: None) + + +def _save_workbook(path: Path, configure: Callable[[Workbook], None]) -> None: + workbook = Workbook() + try: + configure(workbook) + workbook.save(path) + finally: + workbook.close() + + +def _fake_workbook( + path: Path, + *, + used_ranges: dict[str, tuple[int, int, int, int]] | None = None, + rendered_overrides: dict[str, dict[tuple[int, int], int | None]] | None = None, + display_failures: dict[str, dict[tuple[int, int], int]] | None = None, + fullname: Path | None = None, + saved: bool = True, +) -> _FakeWorkbook: + """Build COM-shaped sheets from a real saved workbook and optional Excel state.""" + used_ranges = used_ranges or {} + rendered_overrides = rendered_overrides or {} + display_failures = display_failures or {} + fake_sheets = [] + workbook = load_workbook(path, data_only=True, read_only=False) + try: + for worksheet in workbook.worksheets: + min_column, min_row, max_column, max_row = range_boundaries( + worksheet.calculate_dimension() + ) + bounds = used_ranges.get( + worksheet.title, (min_row, min_column, max_row, max_column) + ) + rendered: dict[tuple[int, int], int | None] = {} + row, column, last_row, last_column = bounds + for row_index in range(row, last_row + 1): + for column_index in range(column, last_column + 1): + fill = worksheet.cell(row_index, column_index).fill + if ( + getattr(fill, "patternType", None) == "solid" + and fill.fgColor.type == "rgb" + ): + rgb = fill.fgColor.rgb[-6:] + rendered[row_index, column_index] = _excel_color(rgb) + else: + rendered[row_index, column_index] = _NO_FILL + rendered.update(rendered_overrides.get(worksheet.title, {})) + fake_sheets.append( + _FakeSheet( + worksheet.title, + bounds, + rendered, + display_failures.get(worksheet.title), + ) + ) + finally: + workbook.close() + return _FakeWorkbook(fullname or path, fake_sheets, saved=saved) + + +def _legacy_result( + workbook: _FakeWorkbook, + *, + include_default: bool, + ignore_colors: set[str] | None, +) -> dict[str, SheetColorsMap]: + return { + sheet.name: cells._extract_sheet_colors_com( + sheet, include_default, ignore_colors + ) + for sheet in workbook.sheets + } + + +def _clear_display_calls(workbook: _FakeWorkbook) -> None: + for sheet in workbook.sheets: + sheet.display_calls.clear() + + +def _rewrite_sheet_xml( + path: Path, sheet_name: str, transform: Callable[[bytes], bytes] +) -> None: + with ZipFile(path) as archive: + part = color_hybrid._worksheet_parts(archive)[sheet_name] + members = [(name, archive.read(name)) for name in archive.namelist()] + rewritten = [] + found = False + for name, contents in members: + if name == part: + contents = transform(contents) + found = True + rewritten.append((name, contents)) + assert found + temporary = path.with_name(f"{path.stem}.rewritten{path.suffix}") + with ZipFile(temporary, "w", ZIP_DEFLATED) as archive: + for name, contents in rewritten: + archive.writestr(name, contents) + temporary.replace(path) + + +def _add_expression_rule(worksheet: Worksheet, ref: str = "A1") -> None: + worksheet.conditional_formatting.add(ref, FormulaRule(formula=["TRUE"])) + + +@pytest.mark.parametrize( + ("include_default", "ignore_colors", "expected"), + [ + ( + True, + None, + { + "FFFFFF": [(1, 0), (1, 2), (2, 1)], + "FF0000": [(1, 1)], + "00FF00": [(2, 0)], + "0000FF": [(2, 2)], + }, + ), + ( + False, + None, + {"FF0000": [(1, 1)], "00FF00": [(2, 0)], "0000FF": [(2, 2)]}, + ), + ( + True, + {"#ff0000", "ffffff"}, + {"00FF00": [(2, 0)], "0000FF": [(2, 2)]}, + ), + ], +) +def test_static_fills_and_default_handling_match_legacy_com( + tmp_path: Path, + include_default: bool, + ignore_colors: set[str] | None, + expected: dict[str, list[tuple[int, int]]], +) -> None: + path = tmp_path / "static.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"] = "plain" + worksheet["B1"].fill = PatternFill("solid", fgColor="FFFF0000") + worksheet["C1"].fill = PatternFill("solid", fgColor="FFFFFFFF") + worksheet["A2"].fill = PatternFill("solid", fgColor="FF00FF00") + worksheet["C2"].fill = PatternFill("solid", fgColor="FF0000FF") + + _save_workbook(path, configure) + fake = _fake_workbook(path) + legacy = _legacy_result( + fake, include_default=include_default, ignore_colors=ignore_colors + ) + _clear_display_calls(fake) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors( + fake, include_default, ignore_colors, session + ) + + assert actual.sheets == legacy + assert actual.sheets["Sheet"].colors_map == expected + assert fake.sheets[0].display_calls == [] + + +def test_blank_cells_inside_com_used_range_keep_legacy_default_semantics( + tmp_path: Path, +) -> None: + path = tmp_path / "blank-extent.xlsx" + + def configure(workbook: Workbook) -> None: + workbook.active["B2"] = "only saved value" + + _save_workbook(path, configure) + # Excel's UsedRange can include blank cells outside openpyxl's saved dimension. + fake = _fake_workbook(path, used_ranges={"Sheet": (2, 2, 3, 3)}) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets == legacy + assert actual.sheets["Sheet"].colors_map == { + "FFFFFF": [(2, 1), (2, 2), (3, 1), (3, 2)] + } + assert fake.sheets[0].display_calls == [] + + +def test_conditional_candidates_are_deduplicated_clipped_and_row_major( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "conditional.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["B2"] = "top" + worksheet["C3"] = "bottom" + _add_expression_rule(worksheet, "A1:A3") + _add_expression_rule(worksheet, "B2:B4") + _add_expression_rule(worksheet, "C3:D8") + _add_expression_rule(worksheet, "Z100:Z101") + + _save_workbook(path, configure) + rendered = { + "Sheet": { + (2, 2): _excel_color("AA0000"), + (3, 2): _excel_color("00AA00"), + (3, 3): _excel_color("0000AA"), + } + } + fake = _fake_workbook(path, rendered_overrides=rendered) + legacy = _legacy_result(fake, include_default=False, ignore_colors=None) + _clear_display_calls(fake) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, False, None, session) + + assert actual.sheets == legacy + assert fake.sheets[0].display_calls == [(2, 2), (3, 2), (3, 3)] + assert actual.sheets["Sheet"].colors_map == { + "AA0000": [(2, 1)], + "00AA00": [(3, 1)], + "0000AA": [(3, 2)], + } + log = next( + record.getMessage() + for record in caplog.records + if "Color extraction sheet=Sheet" in record.getMessage() + ) + assert "conditional_candidates=3" in log + assert "rendered_candidates=3" in log + assert "display_format_calls=3" in log + assert "fallback=False" in log + + +@pytest.mark.parametrize( + ("rendered_rgb", "ignore_colors", "expected"), + [ + ("FFFFFF", None, {"FFFFFF": [(1, 0)]}), + ("0000FF", {"#0000ff"}, {}), + ], +) +def test_conditional_rendering_overrides_static_fill_before_ignore_filter( + tmp_path: Path, + rendered_rgb: str, + ignore_colors: set[str] | None, + expected: dict[str, list[tuple[int, int]]], +) -> None: + path = tmp_path / "conditional-overrides-fill.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"].fill = PatternFill("solid", fgColor="FFFF0000") + _add_expression_rule(worksheet) + + _save_workbook(path, configure) + rendered = {"Sheet": {(1, 1): _excel_color(rendered_rgb)}} + fake = _fake_workbook(path, rendered_overrides=rendered) + legacy = _legacy_result(fake, include_default=True, ignore_colors=ignore_colors) + _clear_display_calls(fake) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, ignore_colors, session) + + assert actual.sheets == legacy + assert actual.sheets["Sheet"].colors_map == expected + assert fake.sheets[0].display_calls == [(1, 1)] + + +def test_theme_indexed_tinted_and_pattern_fills_use_rendered_candidates( + tmp_path: Path, +) -> None: + path = tmp_path / "candidate-fills.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"].fill = PatternFill("solid", fgColor=Color(theme=4, tint=0.25)) + worksheet["B1"].fill = PatternFill("solid", fgColor=Color(indexed=10)) + worksheet["C1"].fill = PatternFill( + "solid", + fgColor=Color(rgb="FF123456", tint=0.2), + bgColor="FF000000", + ) + worksheet["D1"].fill = PatternFill( + patternType="darkGrid", fgColor="FFFF0000", bgColor="FF00FF00" + ) + + _save_workbook(path, configure) + rendered = { + "Sheet": { + (1, 1): _excel_color("102030"), + (1, 2): _excel_color("203040"), + (1, 3): _excel_color("304050"), + (1, 4): _excel_color("405060"), + } + } + fake = _fake_workbook(path, rendered_overrides=rendered) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets == legacy + assert fake.sheets[0].display_calls == [(1, 1), (1, 2), (1, 3), (1, 4)] + assert actual.sheets["Sheet"].colors_map == { + "102030": [(1, 0)], + "203040": [(1, 1)], + "304050": [(1, 2)], + "405060": [(1, 3)], + } + + +def test_transient_candidate_com_failure_discards_partial_scan_and_retries_legacy( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "transient-candidate-failure.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"] = "first candidate" + worksheet["A2"] = "fails once" + _add_expression_rule(worksheet, "A1:A2") + + _save_workbook(path, configure) + rendered = { + "Sheet": { + (1, 1): _excel_color("AA1100"), + (2, 1): _excel_color("0011AA"), + } + } + legacy_fake = _fake_workbook(path, rendered_overrides=rendered) + legacy = _legacy_result(legacy_fake, include_default=False, ignore_colors=None) + hybrid_fake = _fake_workbook( + path, + rendered_overrides=rendered, + display_failures={"Sheet": {(2, 1): 1}}, + ) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(hybrid_fake, False, None, session) + + assert actual.sheets == legacy + assert actual.sheets["Sheet"].colors_map == { + "AA1100": [(1, 0)], + "0011AA": [(2, 0)], + } + assert hybrid_fake.sheets[0].display_calls == [ + (1, 1), + (2, 1), + (1, 1), + (2, 1), + ] + assert cells._strict_display_format.get() is False + assert any( + "rendered candidate extraction failed: transient DisplayFormat failure" + in record.getMessage() + for record in caplog.records + ) + log = next( + record.getMessage() + for record in caplog.records + if "Color extraction sheet=Sheet" in record.getMessage() + ) + assert "fallback=True" in log + assert "display_format_calls=4" in log + + +@pytest.mark.parametrize( + "raw_case", + ["extLst", "unknown-rule", "foreign-namespace", "pivotTableParts"], +) +def test_untrusted_conditional_formatting_xml_uses_legacy_fallback( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + raw_case: str, +) -> None: + path = tmp_path / f"raw-{raw_case}.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"] = "value" + _add_expression_rule(worksheet) + + _save_workbook(path, configure) + fake = _fake_workbook(path) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + + def mutate(raw: bytes) -> bytes: + if raw_case == "extLst": + marker = b"" + replacement = ( + b'" + ) + elif raw_case == "pivotTableParts": + marker = b"" + replacement = ( + b'" + ) + elif raw_case == "unknown-rule": + marker = b'type="expression"' + replacement = b'type="futureRule"' + else: + marker = b"" + assert closing in raw + raw = raw.replace(closing, b"", 1) + return raw + + _rewrite_sheet_xml(path, "Sheet", mutate) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets == legacy + assert fake.sheets[0].display_calls == [(1, 1)] + assert any( + "Color extraction legacy fallback sheet=Sheet" in record.getMessage() + for record in caplog.records + ) + log = next( + record.getMessage() + for record in caplog.records + if "Color extraction sheet=Sheet" in record.getMessage() + ) + assert "fallback=True" in log + + +@pytest.mark.parametrize("fallback_case", ["unsaved", "session-mismatch", "non-xlsx"]) +def test_unavailable_or_incompatible_saved_workbook_falls_back_to_legacy( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + fallback_case: str, +) -> None: + path = tmp_path / "target.xlsx" + + def configure(workbook: Workbook) -> None: + worksheet = workbook.active + worksheet["A1"] = "start" + worksheet["C2"].fill = PatternFill("solid", fgColor="FFABCDEF") + + _save_workbook(path, configure) + fullname = path + saved = fallback_case != "unsaved" + session_path = path + if fallback_case == "non-xlsx": + fullname = tmp_path / "target.xls" + shutil.copyfile(path, fullname) + elif fallback_case == "session-mismatch": + session_path = tmp_path / "other.xlsx" + + def configure_other(workbook: Workbook) -> None: + workbook.active["A1"] = "other" + + _save_workbook(session_path, configure_other) + + fake = _fake_workbook(path, fullname=fullname, saved=saved) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + if fallback_case == "non-xlsx": + actual = color_hybrid.extract_colors(fake, True, None, None) + else: + with OpenpyxlExtractionSession(session_path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets == legacy + assert fake.sheets[0].display_calls == [ + (1, 1), + (1, 2), + (1, 3), + (2, 1), + (2, 2), + (2, 3), + ] + log = next( + record.getMessage() + for record in caplog.records + if "Color extraction sheet=Sheet" in record.getMessage() + ) + assert "fallback=True" in log + + +def test_fallback_is_isolated_to_the_sheet_with_unsupported_xml( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "sheet-isolation.xlsx" + + def configure(workbook: Workbook) -> None: + broken = workbook.active + broken.title = "Broken" + broken["A1"] = "fallback" + _add_expression_rule(broken) + healthy = workbook.create_sheet("Healthy") + healthy["A1"].fill = PatternFill("solid", fgColor="FF123ABC") + + _save_workbook(path, configure) + fake = _fake_workbook(path) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + _rewrite_sheet_xml( + path, + "Broken", + lambda raw: raw.replace( + b"", + b'", + 1, + ), + ) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets["Broken"] == legacy["Broken"] + assert actual.sheets["Healthy"] == legacy["Healthy"] + assert fake.sheets[0].display_calls == [(1, 1)] + assert fake.sheets[1].display_calls == [] + sheet_logs = { + name: next( + record.getMessage() + for record in caplog.records + if f"Color extraction sheet={name}" in record.getMessage() + ) + for name in ("Broken", "Healthy") + } + assert "fallback=True" in sheet_logs["Broken"] + assert "fallback=False" in sheet_logs["Healthy"] + + +def test_pipeline_com_color_step_reuses_shared_session_until_owner_closes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "shared-session.xlsx" + + def configure(workbook: Workbook) -> None: + workbook.active["A1"] = "one" + second = workbook.create_sheet("Second") + second["B2"] = "two" + + _save_workbook(path, configure) + fake = _fake_workbook(path) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + session = OpenpyxlExtractionSession(path) + real_workbook = session.workbook + workbook_calls: list[bool] = [] + loaded_workbook = None + close_calls: list[int] = [] + + def tracked_workbook(*, data_only: bool = True) -> object: + nonlocal loaded_workbook + workbook = real_workbook(data_only=data_only) + workbook_calls.append(data_only) + if loaded_workbook is None: + loaded_workbook = workbook + original_close = workbook.close + + def close() -> None: + close_calls.append(1) + original_close() + + workbook.close = close + return workbook + + monkeypatch.setattr(session, "workbook", tracked_workbook) + inputs = pipeline.resolve_extraction_inputs( + path, + mode="standard", + include_cell_links=False, + include_print_areas=False, + include_auto_page_breaks=False, + include_colors_map=True, + include_default_background=True, + ignore_colors=set(), + include_formulas_map=False, + include_merged_cells=False, + include_merged_values_in_rows=False, + ) + artifacts = pipeline.ExtractionArtifacts(openpyxl_session=session) + with session: + pipeline.step_extract_colors_map_com(inputs, artifacts, fake) + assert workbook_calls == [True, True] + assert loaded_workbook is real_workbook() + assert artifacts.colors_map_data is not None + assert artifacts.colors_map_data.sheets == legacy + assert all(sheet.display_calls == [] for sheet in fake.sheets) + assert close_calls == [] + assert not session._closed + + assert session._closed + assert close_calls == [1] + + +def test_custom_normal_fill_uses_rendered_legacy_fallback( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + path = tmp_path / "custom-normal.xlsx" + + def configure(workbook: Workbook) -> None: + workbook._named_styles["Normal"].fill = PatternFill("solid", fgColor="FF334455") + workbook.active["A1"] = "inherits Normal" + workbook.active["B2"] = "extends COM range" + + _save_workbook(path, configure) + normal_color = _excel_color("334455") + fake = _fake_workbook( + path, + used_ranges={"Sheet": (1, 1, 2, 2)}, + rendered_overrides={ + "Sheet": { + (1, 1): normal_color, + (1, 2): normal_color, + (2, 1): normal_color, + (2, 2): normal_color, + } + }, + ) + legacy = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + caplog.set_level("DEBUG", logger=color_hybrid.__name__) + + with OpenpyxlExtractionSession(path) as session: + actual = color_hybrid.extract_colors(fake, True, None, session) + + assert actual.sheets == legacy + assert actual.sheets["Sheet"].colors_map == { + "334455": [(1, 0), (1, 1), (2, 0), (2, 1)] + } + assert fake.sheets[0].display_calls == [ + (1, 1), + (1, 2), + (2, 1), + (2, 2), + ] + assert any( + "Color extraction legacy fallback sheet=Sheet" in record.getMessage() + and "custom Normal fill" in record.getMessage() + for record in caplog.records + ) + + +def test_standalone_extraction_closes_the_session_it_creates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "owned-session.xlsx" + + def configure(workbook: Workbook) -> None: + workbook.active["A1"] = "value" + + _save_workbook(path, configure) + fake = _fake_workbook(path) + sessions: list[OpenpyxlExtractionSession] = [] + base_session = color_hybrid.OpenpyxlExtractionSession + + class TrackedSession(base_session): + def __init__(self, file_path: Path) -> None: + super().__init__(file_path) + sessions.append(self) + + monkeypatch.setattr(color_hybrid, "OpenpyxlExtractionSession", TrackedSession) + color_hybrid.extract_colors(fake, True, None, None) + + assert len(sessions) == 1 + assert sessions[0]._closed + assert sessions[0]._workbooks == {} + + +@pytest.mark.parametrize("kind", ["merge", "table", "row", "column"]) +def test_ambiguous_sheet_styles_preserve_legacy_scan( + tmp_path: Path, + kind: str, +) -> None: + path = tmp_path / f"ambiguous-{kind}.xlsx" + + def configure(workbook: Workbook) -> None: + ws = workbook.active + ws.append(["Heading", "Value"]) + ws.append(["A", 1]) + fill = PatternFill("solid", fgColor="FF112233") + if kind == "merge": + ws.merge_cells("A1:B1") + elif kind == "table": + ws.add_table(Table(displayName="Data", ref="A1:B2")) + elif kind == "row": + ws.row_dimensions[1].fill = fill + else: + ws.column_dimensions["A"].fill = fill + + _save_workbook(path, configure) + fake = _fake_workbook( + path, rendered_overrides={"Sheet": {(1, 1): _excel_color("112233")}} + ) + expected = _legacy_result(fake, include_default=True, ignore_colors=None) + _clear_display_calls(fake) + actual = color_hybrid.extract_colors(fake, True, None, None) + assert actual.sheets == expected + assert len(fake.sheets[0].display_calls) == 4 + + +@pytest.mark.parametrize("kind", ["automatic", "gradient"]) +def test_special_fills_require_excel_rendering(tmp_path: Path, kind: str) -> None: + path = tmp_path / f"special-{kind}.xlsx" + + def configure(workbook: Workbook) -> None: + workbook.active["A1"].fill = ( + PatternFill("solid", fgColor=Color(auto=True)) + if kind == "automatic" + else GradientFill(stop=("FF112233", "FF445566")) + ) + + _save_workbook(path, configure) + fake = _fake_workbook( + path, rendered_overrides={"Sheet": {(1, 1): _excel_color("112233")}} + ) + actual = color_hybrid.extract_colors(fake, False, None, None) + assert actual.sheets["Sheet"].colors_map == {"112233": [(1, 0)]} + assert fake.sheets[0].display_calls == [(1, 1)] + + +@pytest.mark.parametrize("problem", ["missing", "corrupt"]) +def test_unreadable_saved_file_uses_live_legacy_colors( + tmp_path: Path, problem: str +) -> None: + path = tmp_path / "unreadable.xlsx" + _save_workbook(path, lambda wb: wb.active.__setitem__("A1", "value")) + fake = _fake_workbook( + path, rendered_overrides={"Sheet": {(1, 1): _excel_color("112233")}} + ) + if problem == "missing": + path.unlink() + else: + path.write_bytes(b"invalid ZIP") + actual = color_hybrid.extract_colors(fake, False, None, None) + assert actual.sheets["Sheet"].colors_map == {"112233": [(1, 0)]} + assert fake.sheets[0].display_calls == [(1, 1)] diff --git a/uv.lock b/uv.lock index 5cb2c80..7a54471 100644 --- a/uv.lock +++ b/uv.lock @@ -921,7 +921,7 @@ wheels = [ [[package]] name = "exstruct" -version = "0.8.2" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "defusedxml" }, From c31656cb5d6266255f5112f40cb415040080f064 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 4 Oct 2026 11:29:20 +0900 Subject: [PATCH 2/2] Fix hybrid color extraction review findings --- benchmark/issue159-results.md | 16 ++- benchmark/issue159_colors.py | 9 +- ...R-0015-hybrid-rendered-color-extraction.md | 24 ++-- dev-docs/adr/reviews/ADR-0015-2026-10-04.md | 33 ++++++ dev-docs/specs/hybrid-color-extraction.md | 36 ++++-- src/exstruct/core/color_hybrid.py | 74 ++++++++---- src/exstruct/core/pipeline.py | 3 +- tests/core/test_color_hybrid.py | 107 ++++++++++++++++++ tests/core/test_issue159_benchmark.py | 74 ++++++++++++ 9 files changed, 327 insertions(+), 49 deletions(-) create mode 100644 dev-docs/adr/reviews/ADR-0015-2026-10-04.md create mode 100644 tests/core/test_issue159_benchmark.py diff --git a/benchmark/issue159-results.md b/benchmark/issue159-results.md index 7a22eee..7f3cf10 100644 --- a/benchmark/issue159-results.md +++ b/benchmark/issue159-results.md @@ -47,11 +47,17 @@ The probe also records the workbook's COM `Saved` flag immediately before and after its calculation call. The wrapper count measures calls through -`cells._get_display_format_color` for both implementations. The JSON also -retains the existing ContextVar counter when available. Hybrid DEBUG records -are captured verbatim with structured extras and parsed metric fields, including -`used_cells`, `conditional_candidates`, `display_format_calls`, and any logged -fallback duration fields. +`cells._get_display_format_color` for both implementations. Legacy measurements +also retain the existing ContextVar counter when available. Historical hybrid +records contain a false zero in `cells_context_display_format_calls`: the +hybrid extractor resets that per-sheet counter, so the final value is not an +aggregate. Do not use that field in existing raw JSON; the historical data is +left unchanged. `wrapper_display_format_calls` and captured hybrid DEBUG +`display_format_calls` remain the authoritative counts and are unchanged. New +hybrid records omit `cells_context_display_format_calls`. Hybrid DEBUG records +are captured verbatim with structured extras and parsed metric fields, +including `used_cells`, `conditional_candidates`, `display_format_calls`, and +any logged fallback duration fields. The valid final CF fixtures write solid-fill `fgColor` and `bgColor` as the same color, use OOXML-standard `FormulaRule` expressions without a leading `=`, diff --git a/benchmark/issue159_colors.py b/benchmark/issue159_colors.py index 89fbfe3..f877d87 100644 --- a/benchmark/issue159_colors.py +++ b/benchmark/issue159_colors.py @@ -423,7 +423,11 @@ def _measure( except Exception as exc: # Retain failures beside successful timings. error = f"{type(exc).__name__}: {exc}" elapsed_ms = (perf_counter() - started) * 1000 - context_calls = context_counter.get() if context_counter is not None else None + context_calls = ( + context_counter.get() + if context_counter is not None and strategy != "hybrid" + else None + ) if context_token is not None: context_counter.reset(context_token) @@ -435,10 +439,11 @@ def _measure( "ignore_colors": sorted(ignore_colors) if ignore_colors else None, "elapsed_ms": elapsed_ms, "wrapper_display_format_calls": calls["calls"], - "cells_context_display_format_calls": context_calls, "hybrid_debug_records": collector.records if collector is not None else [], "error": error, } + if strategy != "hybrid": + record["cells_context_display_format_calls"] = context_calls if color_map is not None: record["map_sha256"] = _map_fingerprint(color_map) record["map_summary"] = _map_summary(color_map) diff --git a/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md b/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md index a38356b..6b423b6 100644 --- a/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md +++ b/dev-docs/adr/ADR-0015-hybrid-rendered-color-extraction.md @@ -23,10 +23,18 @@ remain in place. - Adopt a hybrid strategy for COM-backed `colors_map` extraction when the workbook is `.xlsx` / `.xlsm`, the saved file exists, the COM workbook path matches the extraction session path, and `workbook.api.Saved` is true. +- Recheck `workbook.api.Saved` after preparing each sheet for rendered-color + reads. Sheet activation or calculation can run VBA events that change live + fills after the saved snapshot was opened. If `Saved` is false or cannot be + read, latch the legacy full scan for that sheet and every remaining sheet; + do not resume hybrid extraction later in the workbook. - Read ordinary static fills from the saved worksheet representation. Use saved worksheet XML to identify conditional-format ranges, clip those ranges - to the live COM `UsedRange`, and deduplicate candidate cells. Also send static - cells with unsupported saved color representations to COM as candidates. + to the live COM `UsedRange`, and deduplicate candidate cells. Also send + static cells with unsupported saved color representations to COM as candidates. +- Traverse only stored openpyxl cells when classifying saved static fills. + When default backgrounds are requested, emit entries for absent coordinates + within `UsedRange` without creating openpyxl cells for them. - Continue to ask Excel for the final color of each candidate cell through `DisplayFormat`. Excel remains responsible for all conditional-format rule evaluation, including formula semantics, priority, overlap, and @@ -87,8 +95,10 @@ remain in place. parity, candidate clipping/deduplication, conditional-color override, unsupported XML/style fallbacks (including `pivotTableParts` and custom `Normal` fill), strict candidate-read retry, unsaved/session-mismatch/ - non-`.xlsx` eligibility, missing/corrupt saved files, sheet isolation, shared - pipeline/session behavior, and session ownership. Automatic and gradient + non-`.xlsx` eligibility, post-preparation `Saved` invalidation (false or + unreadable) for the current and remaining sheets, sparse traversal without + materializing missing openpyxl cells, missing/corrupt saved files, sheet + isolation, shared pipeline/session behavior, and session ownership. Automatic and gradient fills are synthetic unit cases, not live-Excel fixtures. - Code: `src/exstruct/core/color_hybrid.py` implements saved-fill and candidate analysis; `src/exstruct/core/cells.py` retains the COM color entrypoint and @@ -107,13 +117,13 @@ remain in place. passed across four configurations and three repeats per fixture. Conditional color validation passed. `DisplayFormat` calls fell from 6,000 to 0 for static fills and from 6,000 to 30 for sparse CF. See the [final benchmark - JSON](../../../benchmark/baselines/issue159-colors-final-windows.json). + JSON](../../benchmark/baselines/issue159-colors-final-windows.json). The separate 10-row sparse, dense, and overlapping-formula probe also passed all configurations and verified visible CF colors; its mixed `A1:B1` range returned `0` while per-cell reads returned `255` and `12611584`, confirming the range property is not a per-cell color matrix. See the [CF probe - JSON](../../../benchmark/baselines/issue159-colors-cf-probe-windows.json). - Measurement details are in [benchmark results](../../../benchmark/issue159-results.md). + JSON](../../benchmark/baselines/issue159-colors-cf-probe-windows.json). + Measurement details are in [benchmark results](../../benchmark/issue159-results.md). Wall-clock results are environment-specific and do not imply a universal timing guarantee. - Classification: `recommended`; record a performance-strategy change while diff --git a/dev-docs/adr/reviews/ADR-0015-2026-10-04.md b/dev-docs/adr/reviews/ADR-0015-2026-10-04.md new file mode 100644 index 0000000..97f880b --- /dev/null +++ b/dev-docs/adr/reviews/ADR-0015-2026-10-04.md @@ -0,0 +1,33 @@ +# ADR-0015 review follow-up evidence + +## Scope + +PR #160 follow-up retains the existing saved-fill / Excel-rendering policy. +This review clarifies saved-state invalidation, sparse traversal, and evidence +references; it does not change the public API or introduce resource budgets. + +## Structural and consistency checks + +- `adr-linter`: no remaining findings. Status is `proposed`; required sections, + concrete Tests/Code/Related specs, tradeoffs, and empty supersede links exist. +- `adr-reconciler`: no remaining findings within the changed claims below. +- `adr-indexer`: all 15 ADR index entries match source titles/statuses and + README/domain-map entries. ADR-0015 metadata is unchanged; no index edits + are needed. All seven local Markdown links in ADR-0015 and its hybrid spec + resolve to files inside the repository. + +| Corrected finding | ADR/spec evidence | Implementation | Regression evidence | +| --- | --- | --- | --- | +| Saved state can change during sheet preparation | ADR decision; spec eligibility/fallback | `extract_colors` rechecks Saved and latches legacy | false/unreadable post-preparation state, current/remaining sheet colors | +| Sparse traversal created missing cells | ADR decision; spec static fills | `_static_colors` traverses stored cells, synthesizes requested defaults | cell cache unchanged with/without defaults; 100000 x 16384 range retains two cells | +| Benchmark references resolved above root | ADR benchmark evidence; spec runtime evidence | relative links use `../../benchmark/` | local link resolution check | + +## Validation limits + +Unit suite: 1,138 passed, one skipped, 12 deselected; four existing DrawingML +warnings. Ruff, mypy (90 source files), lock and diff checks passed. The +benchmark-counter regression also confirms the outer ContextVar is restored. +Live Excel benchmarks were not rerun after these fixes. Dense candidate sets +and requested default output can still scale with UsedRange as stated in the +specification. Structural/consistency review does not approve a merge or +change the ADR's proposed status. diff --git a/dev-docs/specs/hybrid-color-extraction.md b/dev-docs/specs/hybrid-color-extraction.md index 250c52d..419f9f6 100644 --- a/dev-docs/specs/hybrid-color-extraction.md +++ b/dev-docs/specs/hybrid-color-extraction.md @@ -31,8 +31,13 @@ When a caller supplies a session, reuse it only after confirming the path match. A path-based standalone call owns a temporary session and closes it after extraction. A session mismatch, unreadable/missing file, unsupported format, unreadable saved state, or session load failure selects the legacy -full scan. The hybrid path makes no claim that saved formatting represents -unsaved live changes. +full scan. After preparing each sheet for `DisplayFormat`, check the saved +state again before using the saved snapshot for that sheet. Sheet activation +or calculation can run VBA events that change live fills. If `Saved` is false +or unreadable after preparation, latch the legacy full scan for the current +sheet and all remaining sheets; do not resume hybrid extraction even if a +later check reports `Saved` as true. The hybrid path makes no claim that saved +formatting represents unsaved live changes. ## Saved worksheet analysis @@ -61,8 +66,10 @@ conditional-format constructs and then return a partially optimized map. For a worksheet that passes validation: -1. Read the saved worksheet fills and restrict all results to the live COM - `UsedRange`. +1. Classify saved static fills by traversing only cells already stored in the + openpyxl worksheet, and restrict results to the live COM `UsedRange`. When + default backgrounds are requested, emit defaults for absent coordinates in + that range without creating openpyxl cells for them. 2. Resolve ordinary no-fill cells and solid direct-RGB fills without tint from saved data, using the existing color normalization and filtering rules. 3. Add cells with other saved static color forms to the COM candidate set. @@ -78,6 +85,11 @@ For a worksheet that passes validation: The live COM used range defines the output boundary. Candidate ranges and saved static cells outside it must not add coordinates to the result. +Excluding requested defaults, static-fill traversal is proportional to the +cells already stored in the openpyxl worksheet. The conditional-format +candidate union and requested default-background output can still scale with +the live `UsedRange`. No area or candidate-count budget is currently enforced. + ## Excel evaluation and output semantics Excel evaluates every candidate's final rendered color. This includes @@ -100,6 +112,10 @@ COM preparation required by the current rendered-color path remains in place. - Workbook-level eligibility, file, session, XML-container, or load failures use the legacy full scan for the workbook. +- If the post-preparation `Saved` check is false or unreadable, latch the + workbook to legacy scanning from the current sheet onward. Sheet activation + and calculation may run VBA events that invalidate the saved snapshot; a + later `Saved` value does not re-enable hybrid extraction for later sheets. - An unsupported or ambiguous construct isolated to one worksheet uses the legacy full scan for that worksheet while eligible worksheets may still use the hybrid path. @@ -150,12 +166,14 @@ theme/indexed/tinted/pattern/automatic/gradient fill classification, XML fallbacks (`extLst`, unknown rule, foreign namespace, `pivotTableParts`), custom `Normal` fill, ambiguous row/column/table/merge cases, transient candidate-read retry, unsaved/session-mismatch/non-`.xlsx` eligibility, -sheet isolation, shared pipeline/session reuse, and standalone session +post-preparation `Saved` invalidation (false or unreadable) for the current and +remaining sheets, sparse traversal without materializing missing openpyxl +cells, sheet isolation, shared pipeline/session reuse, and standalone session ownership, including missing/corrupt saved-file fallback. Automatic and gradient fills are synthetic unit cases, not live-Excel fixtures. The repeat benchmark and its current measurements are tracked in -[benchmark/issue159-results.md](../../../benchmark/issue159-results.md). Keep +[benchmark/issue159-results.md](../../benchmark/issue159-results.md). Keep Excel wall-clock results separate from correctness and deterministic call-count checks. @@ -174,7 +192,7 @@ conditional colors were visible. In its mixed `A1:B1` probe, cell reads returned `255` and `12611584`; the range property does not provide a per-cell color matrix. -See the [final benchmark JSON](../../../benchmark/baselines/issue159-colors-final-windows.json), -[CF probe JSON](../../../benchmark/baselines/issue159-colors-cf-probe-windows.json), -and [benchmark results](../../../benchmark/issue159-results.md) for details. +See the [final benchmark JSON](../../benchmark/baselines/issue159-colors-final-windows.json), +[CF probe JSON](../../benchmark/baselines/issue159-colors-cf-probe-windows.json), +and [benchmark results](../../benchmark/issue159-results.md) for details. Wall-clock timings vary by environment and are not a universal guarantee. diff --git a/src/exstruct/core/color_hybrid.py b/src/exstruct/core/color_hybrid.py index 28833f1..40ade2b 100644 --- a/src/exstruct/core/color_hybrid.py +++ b/src/exstruct/core/color_hybrid.py @@ -106,13 +106,8 @@ def _candidate_cells(ranges: list[Bounds], bounds: Bounds) -> set[tuple[int, int } -def _static_colors( - ws: Worksheet, - bounds: Bounds, - candidates: set[tuple[int, int]], - include_default: bool, -) -> dict[tuple[int, int], str | None]: - """Only trust fills whose RGB interpretation is identical to Excel's.""" +def _validate_saved_styles(ws: Worksheet) -> None: + """Reject sheet-wide styles that cannot be resolved from stored cell fills.""" if ws.merged_cells.ranges or ws.tables: raise ValueError("merged cells or table styles require rendered scan") if any(d.has_style for d in ws.row_dimensions.values()) or any( @@ -123,25 +118,43 @@ def _static_colors( for style in ws.parent._named_styles: if style.builtinId == 0 and style.fill.patternType not in (None, "none"): raise ValueError("custom Normal fill requires rendered scan") + + +def _static_colors( + ws: Worksheet, + bounds: Bounds, + candidates: set[tuple[int, int]], + include_default: bool, +) -> dict[tuple[int, int], str | None]: + """Only trust fills whose RGB interpretation is identical to Excel's.""" + _validate_saved_styles(ws) r1, c1, r2, c2 = bounds - result = {} - for row in ws.iter_rows(min_row=r1, max_row=r2, min_col=c1, max_col=c2): - for cell in row: - coord = (cell.row, cell.column) - if coord in candidates: - continue - fill = cell.fill - pattern = getattr(fill, "patternType", "unsupported") - if pattern in (None, "none"): - result[coord] = "FFFFFF" if include_default else None - elif ( - pattern == "solid" - and fill.fgColor.type == "rgb" - and not fill.fgColor.tint - ): - result[coord] = cells._resolve_cell_background(cell, include_default) - else: - candidates.add(coord) + result: dict[tuple[int, int], str | None] = {} + # iter_rows creates and retains missing cells across the entire rectangle. + # Match the sparse traversal used by the file-backed cell extractor. + for coord, cell in ws._cells.items(): + row, col = coord + if not (r1 <= row <= r2 and c1 <= col <= c2) or coord in candidates: + continue + fill = cell.fill + pattern = getattr(fill, "patternType", "unsupported") + if pattern in (None, "none"): + color = "FFFFFF" if include_default else None + elif ( + pattern == "solid" and fill.fgColor.type == "rgb" and not fill.fgColor.tint + ): + color = cells._resolve_cell_background(cell, include_default) + else: + candidates.add(coord) + continue + if color is not None: + result[coord] = color + if include_default: + for row in range(r1, r2 + 1): + for col in range(c1, c2 + 1): + coord = (row, col) + if coord not in ws._cells and coord not in candidates: + result[coord] = "FFFFFF" return result @@ -276,6 +289,17 @@ def extract_colors( sheets = {} for sheet in workbook.sheets: cells._prepare_sheet_for_display_format(sheet) + if archive is not None: + try: + if not workbook.api.Saved: + raise ValueError( + "workbook has unsaved changes after sheet preparation" + ) + except Exception as exc: + # Activation/calculation can run VBA handlers that alter + # live fills. Once invalidated, never trust this snapshot again. + reason = str(exc) + archive = None sheets[sheet.name] = _extract_sheet( sheet, session, diff --git a/src/exstruct/core/pipeline.py b/src/exstruct/core/pipeline.py index af8bb44..49c216f 100644 --- a/src/exstruct/core/pipeline.py +++ b/src/exstruct/core/pipeline.py @@ -806,12 +806,13 @@ def step_extract_colors_map_com( backend_type = _backend_type("ComBackend") - backend = backend_type(workbook) # Preserve the minimal constructor surface of legacy backend overrides. native_module = sys.modules.get("exstruct.core.backends.com_backend") native_type = getattr(native_module, "ComBackend", None) if backend_type is native_type and artifacts.openpyxl_session is not None: backend = backend_type(workbook, session=artifacts.openpyxl_session) + else: + backend = backend_type(workbook) com_result = backend.extract_colors_map( include_default_background=inputs.include_default_background, ignore_colors=inputs.ignore_colors, diff --git a/tests/core/test_color_hybrid.py b/tests/core/test_color_hybrid.py index c107094..116139f 100644 --- a/tests/core/test_color_hybrid.py +++ b/tests/core/test_color_hybrid.py @@ -692,8 +692,22 @@ def close() -> None: include_merged_values_in_rows=False, ) artifacts = pipeline.ExtractionArtifacts(openpyxl_session=session) + backend_type = pipeline._backend_type("ComBackend") + original_init = backend_type.__init__ + backend_sessions: list[OpenpyxlExtractionSession | None] = [] + + def tracked_init( + backend: object, + workbook: object, + session: OpenpyxlExtractionSession | None = None, + ) -> None: + backend_sessions.append(session) + original_init(backend, workbook, session=session) + + monkeypatch.setattr(backend_type, "__init__", tracked_init) with session: pipeline.step_extract_colors_map_com(inputs, artifacts, fake) + assert backend_sessions == [session] assert workbook_calls == [True, True] assert loaded_workbook is real_workbook() assert artifacts.colors_map_data is not None @@ -848,3 +862,96 @@ def test_unreadable_saved_file_uses_live_legacy_colors( actual = color_hybrid.extract_colors(fake, False, None, None) assert actual.sheets["Sheet"].colors_map == {"112233": [(1, 0)]} assert fake.sheets[0].display_calls == [(1, 1)] + + +@pytest.mark.parametrize("include_default", [False, True]) +def test_sparse_static_colors_do_not_materialize_missing_cells( + include_default: bool, +) -> None: + """Default backgrounds must not enlarge the shared worksheet cell cache.""" + workbook = Workbook() + ws = workbook.active + ws["B2"].fill = PatternFill("solid", fgColor="FF123456") + ws["C3"] = "unfilled" + ws["D4"].fill = PatternFill("solid", fgColor=Color(theme=1)) + ws["E5"].fill = PatternFill("solid", fgColor="FFFFFFFF") + ws["F6"].fill = PatternFill("solid", fgColor="FFABCDEF") + stored = dict(ws._cells) + candidates = {(1, 1)} + try: + actual = color_hybrid._static_colors( + ws, (1, 1, 5, 5), candidates, include_default + ) + assert ws._cells == stored + assert candidates == {(1, 1), (4, 4)} + assert actual[2, 2] == "123456" + assert (6, 6) not in actual + assert (1, 1) not in actual + assert (4, 4) not in actual + if include_default: + assert len(actual) == 23 + assert actual[1, 2] == actual[3, 3] == actual[5, 5] == "FFFFFF" + else: + assert actual == {(2, 2): "123456"} + finally: + workbook.close() + + +def test_large_sparse_static_range_only_retains_colored_saved_cells() -> None: + """A near-full-width UsedRange must not allocate its absent coordinates.""" + workbook = Workbook() + ws = workbook.active + ws["A1"] = "start" + ws["XFD100000"].fill = PatternFill("solid", fgColor="FF123456") + try: + actual = color_hybrid._static_colors(ws, (1, 1, 100000, 16384), set(), False) + assert actual == {(100000, 16384): "123456"} + assert len(ws._cells) == 2 + finally: + workbook.close() + + +@pytest.mark.parametrize("saved_state", [False, "unreadable"]) +def test_sheet_preparation_invalidates_current_and_remaining_saved_fills( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + saved_state: bool | str, +) -> None: + """Sheet event changes force live colors even if a later sheet appears saved.""" + path = tmp_path / "events.xlsm" + + def configure(workbook: Workbook) -> None: + workbook.active.title = "Before" + for name in ("Changed", "After"): + workbook.create_sheet(name) + for ws in workbook.worksheets: + ws["A1"].fill = PatternFill("solid", fgColor="FF0000FF") + + _save_workbook(path, configure) + fake = _fake_workbook(path) + original_prepare = cells._prepare_sheet_for_display_format + + class UnreadableSaved: + @property + def Saved(self) -> bool: # noqa: N802 - Excel COM property + raise RuntimeError("Saved unavailable") + + def prepare(sheet: _FakeSheet) -> None: + original_prepare(sheet) + if sheet.name == "Changed": + sheet.rendered_colors[1, 1] = _excel_color("FF0000") + fake.sheets[2].rendered_colors[1, 1] = _excel_color("00FF00") + fake.api = ( + SimpleNamespace(Saved=False) + if saved_state is False + else UnreadableSaved() + ) + elif sheet.name == "After": + fake.api = SimpleNamespace(Saved=True) + + monkeypatch.setattr(cells, "_prepare_sheet_for_display_format", prepare) + actual = color_hybrid.extract_colors(fake, False, None, None) + assert actual.sheets["Before"].colors_map == {"0000FF": [(1, 0)]} + assert actual.sheets["Changed"].colors_map == {"FF0000": [(1, 0)]} + assert actual.sheets["After"].colors_map == {"00FF00": [(1, 0)]} + assert [sheet.display_calls for sheet in fake.sheets] == [[], [(1, 1)], [(1, 1)]] diff --git a/tests/core/test_issue159_benchmark.py b/tests/core/test_issue159_benchmark.py new file mode 100644 index 0000000..07ec1a8 --- /dev/null +++ b/tests/core/test_issue159_benchmark.py @@ -0,0 +1,74 @@ +"""No-Excel regressions for the Issue 159 benchmark metrics.""" + +import logging + +from _pytest.monkeypatch import MonkeyPatch +from benchmark import issue159_colors + + +def test_measure_preserves_legacy_counter_and_omits_hybrid_false_zero( + monkeypatch: MonkeyPatch, +) -> None: + """Keep the legacy counter while omitting the hybrid per-sheet false zero.""" + counter = issue159_colors.cells._display_format_calls + active_strategy = "reference" + + def fake_display_format_color(_sheet: object, _row: int, _col: int) -> int: + if active_strategy == "reference": + counter.set(counter.get() + 1) + return 255 + + def fake_reference_extract( + _book: object, + _include_default_background: bool, + _ignore_colors: set[str] | None, + ) -> None: + issue159_colors.cells._get_display_format_color(None, 1, 1) + + def fake_hybrid_extract( + _book: object, + *, + include_default_background: bool, + ignore_colors: set[str] | None, + ) -> None: + issue159_colors.cells._get_display_format_color(None, 1, 1) + logging.getLogger("exstruct.core.color_hybrid").debug( + "synthetic hybrid measurement", + extra={"display_format_calls": 1}, + ) + # The real hybrid extractor resets this per-sheet counter after logging. + counter.set(0) + + monkeypatch.setattr( + issue159_colors.cells, "_get_display_format_color", fake_display_format_color + ) + monkeypatch.setattr(issue159_colors, "_reference_extract", fake_reference_extract) + monkeypatch.setattr( + issue159_colors.cells, "extract_sheet_colors_map_com", fake_hybrid_extract + ) + + outer_token = counter.set(37) + try: + legacy_record, _ = issue159_colors._measure( + object(), "reference", False, None, "static", 1 + ) + + assert counter.get() == 37 + active_strategy = "hybrid" + hybrid_record, _ = issue159_colors._measure( + object(), "hybrid", False, None, "static", 1 + ) + + assert counter.get() == 37 + assert legacy_record["wrapper_display_format_calls"] == 1 + assert legacy_record["cells_context_display_format_calls"] == 1 + assert hybrid_record["wrapper_display_format_calls"] == 1 + assert "cells_context_display_format_calls" not in hybrid_record + assert ( + hybrid_record["hybrid_debug_records"][0]["metric_fields"][ + "display_format_calls" + ] + == 1 + ) + finally: + counter.reset(outer_token)