[{"data":1,"prerenderedAt":773},["ShallowReactive",2],{"content-/cfws-html-viewer":3,"all-pages-for-dir":771,"og-image-/cfws-html-viewer":772},{"id":4,"title":5,"body":6,"category":752,"description":753,"extension":754,"meta":755,"navigation":622,"path":756,"project_name":757,"published":758,"publishedAt":759,"seo":760,"stem":761,"tags":762,"todo":768,"updatedAt":769,"__hash__":770},"pages/2026-04/2026-04-15/cfws-html-viewer.md","CF精算表HTMLビューア構築 -- Excel→HTML変換・iframeリサイズ・check検証の可視化",{"type":7,"value":8,"toc":726},"minimark",[9,13,17,20,24,29,40,50,54,64,68,75,77,81,85,92,96,117,121,124,128,146,284,409,413,423,433,482,485,487,491,495,498,505,509,512,515,522,524,528,531,535,545,549,552,555,559,575,674,680,684,687,689,692,723],[10,11,12],"h2",{"id":12},"この日やったこと",[14,15,16],"p",{},"CF精算表（CFWS）のExcelシートをHTMLテーブルに変換するビューアを組み上げた。openpyxlで数式セルが空を返す壁にぶつかり、Excel COM経由で計算済みファイルを生成する迂回路を掘った。iframeの高さが足りず下半分が切れる問題は、postMessageベースの高さ通知で片づけた。最後にcheck行の値を拾ってタブを赤く染める仕組みを入れ、浮かび上がった5件のバグのうち4件を修正した。",[18,19],"hr",{},[10,21,23],{"id":22},"excelhtml変換-数式セルが空を返す","Excel→HTML変換: 数式セルが空を返す",[25,26,28],"h3",{"id":27},"data_onlyfalseの罠","data_only=Falseの罠",[14,30,31,35,36,39],{},[32,33,34],"code",{},"xlsx_to_html.py","でExcelファイルを読み込み、HTMLテーブルに変換するスクリプトを書いた。最初は",[32,37,38],{},"data_only=False","で開いたため、数式が入ったセルは数式文字列そのものが返り、計算結果は空のまま表示された。テーブルの半分以上が空白で埋まり、一目で何かがおかしいとわかった。",[14,41,42,45,46,49],{},[32,43,44],{},"data_only=True","に切り替えると、最後にExcelアプリで開いてキャッシュされた値が返る。ただしopenpyxlは数式エンジンを持たないため、一度もExcelで開かれていないファイルや、数式の依存チェーンが深いセルでは",[32,47,48],{},"None","が返る。",[25,51,53],{"id":52},"excel-com経由で計算済みファイルを生成","Excel COM経由で計算済みファイルを生成",[14,55,56,57,60,61,63],{},"openpyxl単体では数式連鎖を解決できない。そこでExcel COMオートメーション（",[32,58,59],{},"win32com.client","）を使い、Excelアプリにファイルを開かせて全数式を再計算させ、計算済みの値をxlsx形式で別名保存するスクリプトを追加した。この計算済みファイルをopenpyxlの",[32,62,44],{},"で読めば、全セルに値が入る。",[25,65,67],{"id":66},"ibフォーマットの色分け再現","IBフォーマットの色分け再現",[14,69,70,71,74],{},"CFWSはIBフォーマット（投資銀行スタイル）で色分けされている。青文字はハードコード値、黒文字は数式。openpyxlでセルのフォントカラーを読み取り、HTMLの",[32,72,73],{},"\u003Ctd>","にインラインスタイルとして反映した。数式セルと手入力セルの区別がブラウザ上でもそのまま見える。",[18,76],{},[10,78,80],{"id":79},"iframeリサイズ-3つの壁を越える","iframeリサイズ: 3つの壁を越える",[25,82,84],{"id":83},"壁1-固定高さで下半分が切れる","壁1: 固定高さで下半分が切れる",[14,86,87,88,91],{},"HTMLテーブルをiframeで埋め込んだが、",[32,89,90],{},"height: 600px","の固定値ではシートの行数によって下半分が切れた。シートごとに行数が違うため、固定値では対応できない。",[25,93,95],{"id":94},"壁2-fileのクロスオリジン制限","壁2: file://のクロスオリジン制限",[14,97,98,101,102,105,106,109,110,113,114,116],{},[32,99,100],{},"contentDocument","でiframe内のDOMを取得して",[32,103,104],{},"scrollHeight","を読もうとしたが、",[32,107,108],{},"file://","プロトコルではクロスオリジン制限に引っかかって",[32,111,112],{},"null","が返った。ローカル開発中のHTMLファイルを",[32,115,108],{},"で開いている以上、この制限は回避できない。",[25,118,120],{"id":119},"寄り道-インラインテーブル方式","寄り道: インラインテーブル方式",[14,122,123],{},"iframeをやめてHTMLテーブルを親ページに直接埋め込む方式に切り替えた。高さ問題は消えたが、各シートのHTMLが独立ファイルとして存在しなくなり、個別のデバッグや差し替えがやりにくくなった。ファイル独立性を保ちたいのでiframeに戻した。",[25,125,127],{"id":126},"解決-postmessageベースの高さ通知","解決: postMessageベースの高さ通知",[14,129,130,131,134,135,137,138,141,142,145],{},"iframe内のHTMLに小さなスクリプトを埋め込み、読み込み完了時に",[32,132,133],{},"postMessage","で自身の",[32,136,104],{},"を親ウィンドウに送る。親側は",[32,139,140],{},"message","イベントを受け取ってiframeの",[32,143,144],{},"height","属性を動的に書き換える。",[147,148,153],"pre",{"className":149,"code":150,"language":151,"meta":152,"style":152},"language-javascript shiki shiki-themes vitesse-light vitesse-light","// iframe内（各シートHTML末尾）\nwindow.addEventListener('load', () => {\n  parent.postMessage(\n    { type: 'cfws-resize', height: document.body.scrollHeight },\n    '*'\n  );\n});\n","javascript","",[32,154,155,164,204,217,260,272,278],{"__ignoreMap":152},[156,157,160],"span",{"class":158,"line":159},"line",1,[156,161,163],{"class":162},"sxvE3","// iframe内（各シートHTML末尾）\n",[156,165,167,171,175,179,182,186,190,192,195,198,201],{"class":158,"line":166},2,[156,168,170],{"class":169},"s4oTP","window",[156,172,174],{"class":173},"shFtX",".",[156,176,178],{"class":177},"senZ8","addEventListener",[156,180,181],{"class":173},"(",[156,183,185],{"class":184},"sMJiu","'",[156,187,189],{"class":188},"sdGka","load",[156,191,185],{"class":184},[156,193,194],{"class":173},",",[156,196,197],{"class":173}," ()",[156,199,200],{"class":173}," =>",[156,202,203],{"class":173}," {\n",[156,205,207,210,212,214],{"class":158,"line":206},3,[156,208,209],{"class":169},"  parent",[156,211,174],{"class":173},[156,213,133],{"class":177},[156,215,216],{"class":173},"(\n",[156,218,220,223,227,230,233,236,238,240,243,245,248,250,253,255,257],{"class":158,"line":219},4,[156,221,222],{"class":173},"    {",[156,224,226],{"class":225},"sz8Xr"," type",[156,228,229],{"class":173},":",[156,231,232],{"class":184}," '",[156,234,235],{"class":188},"cfws-resize",[156,237,185],{"class":184},[156,239,194],{"class":173},[156,241,242],{"class":225}," height",[156,244,229],{"class":173},[156,246,247],{"class":169}," document",[156,249,174],{"class":173},[156,251,252],{"class":169},"body",[156,254,174],{"class":173},[156,256,104],{"class":169},[156,258,259],{"class":173}," },\n",[156,261,263,266,269],{"class":158,"line":262},5,[156,264,265],{"class":184},"    '",[156,267,268],{"class":188},"*",[156,270,271],{"class":184},"'\n",[156,273,275],{"class":158,"line":274},6,[156,276,277],{"class":173},"  );\n",[156,279,281],{"class":158,"line":280},7,[156,282,283],{"class":173},"});\n",[147,285,287],{"className":149,"code":286,"language":151,"meta":152,"style":152},"// 親ページ\nwindow.addEventListener('message', (e) => {\n  if (e.data.type === 'cfws-resize') {\n    iframe.style.height = e.data.height + 'px';\n  }\n});\n",[32,288,289,294,325,359,400,405],{"__ignoreMap":152},[156,290,291],{"class":158,"line":159},[156,292,293],{"class":162},"// 親ページ\n",[156,295,296,298,300,302,304,306,308,310,312,315,318,321,323],{"class":158,"line":166},[156,297,170],{"class":169},[156,299,174],{"class":173},[156,301,178],{"class":177},[156,303,181],{"class":173},[156,305,185],{"class":184},[156,307,140],{"class":188},[156,309,185],{"class":184},[156,311,194],{"class":173},[156,313,314],{"class":173}," (",[156,316,317],{"class":169},"e",[156,319,320],{"class":173},")",[156,322,200],{"class":173},[156,324,203],{"class":173},[156,326,327,331,333,335,337,340,342,345,349,351,353,355,357],{"class":158,"line":206},[156,328,330],{"class":329},"sHkkW","  if",[156,332,314],{"class":173},[156,334,317],{"class":169},[156,336,174],{"class":173},[156,338,339],{"class":169},"data",[156,341,174],{"class":173},[156,343,344],{"class":169},"type",[156,346,348],{"class":347},"stQ0i"," ===",[156,350,232],{"class":184},[156,352,235],{"class":188},[156,354,185],{"class":184},[156,356,320],{"class":173},[156,358,203],{"class":173},[156,360,361,364,366,369,371,373,376,379,381,383,385,387,390,392,395,397],{"class":158,"line":219},[156,362,363],{"class":169},"    iframe",[156,365,174],{"class":173},[156,367,368],{"class":169},"style",[156,370,174],{"class":173},[156,372,144],{"class":169},[156,374,375],{"class":173}," =",[156,377,378],{"class":169}," e",[156,380,174],{"class":173},[156,382,339],{"class":169},[156,384,174],{"class":173},[156,386,144],{"class":169},[156,388,389],{"class":347}," +",[156,391,232],{"class":184},[156,393,394],{"class":188},"px",[156,396,185],{"class":184},[156,398,399],{"class":173},";\n",[156,401,402],{"class":158,"line":262},[156,403,404],{"class":173},"  }\n",[156,406,407],{"class":158,"line":274},[156,408,283],{"class":173},[25,410,412],{"id":411},"非表示タブのscrollheight0問題","非表示タブのscrollHeight=0問題",[14,414,415,416,419,420,422],{},"タブ切り替えUIを実装した後、非アクティブタブのiframeは",[32,417,418],{},"display: none","になるため",[32,421,104],{},"が0を返す。タブを切り替えた瞬間にiframeが表示されるが、高さが0のまま潰れていた。",[14,424,425,426,429,430,432],{},"対処として、タブ切り替え時に親から",[32,427,428],{},"cfws-request-height","メッセージをiframeに送り、iframe側が再度",[32,431,104],{},"を計測して返す仕組みを追加した。",[147,434,436],{"className":149,"code":435,"language":151,"meta":152,"style":152},"// 親 → iframe: 高さ再計測を要求\niframe.contentWindow.postMessage({ type: 'cfws-request-height' }, '*');\n",[32,437,438,443],{"__ignoreMap":152},[156,439,440],{"class":158,"line":159},[156,441,442],{"class":162},"// 親 → iframe: 高さ再計測を要求\n",[156,444,445,448,450,453,455,457,460,462,464,466,468,470,473,475,477,479],{"class":158,"line":166},[156,446,447],{"class":169},"iframe",[156,449,174],{"class":173},[156,451,452],{"class":169},"contentWindow",[156,454,174],{"class":173},[156,456,133],{"class":177},[156,458,459],{"class":173},"({",[156,461,226],{"class":225},[156,463,229],{"class":173},[156,465,232],{"class":184},[156,467,428],{"class":188},[156,469,185],{"class":184},[156,471,472],{"class":173}," },",[156,474,232],{"class":184},[156,476,268],{"class":188},[156,478,185],{"class":184},[156,480,481],{"class":173},");\n",[14,483,484],{},"これで、どのタブに切り替えても正しい高さでiframeが展開される。",[18,486],{},[10,488,490],{"id":489},"excelビューア機能の追加","Excelビューア機能の追加",[25,492,494],{"id":493},"行番号列ヘッダー","行番号・列ヘッダー",[14,496,497],{},"HTMLテーブルの左端に行番号（1, 2, 3...）、上端に列ヘッダー（A, B, C...）を追加した。Excelのシートを眺めている感覚にそのまま近づけるためで、セル位置の特定が速くなった。",[14,499,500,501,504],{},"列ヘッダーには",[32,502,503],{},"max-width","を設定して長い列名が折り返し表示されるようにした。ただし最終列だけは制限なしにして、端が見切れないようにした。",[25,506,508],{"id":507},"check0タブの赤色可視化","check!=0タブの赤色可視化",[14,510,511],{},"CFWSにはcheck行があり、値が0でなければどこかの計算が合っていない。この情報をタブUIに反映した。",[14,513,514],{},"最初はHTMLテーブルの全行をスキャンして「Check」というテキストを含む行を探していたが、黄色背景のセル（Checkセル固有のスタイル）を直接検索するロジックに切り替えた。テキスト検索よりも誤検出が減り、確実にcheck行だけを拾える。",[14,516,517,518,521],{},"check!=0のタブには",[32,519,520],{},"check-ng","クラスを付与し、CSSで赤い背景色とツールチップを表示する。タブ一覧を見るだけで「どのシートの計算が合っていないか」が目に飛び込んでくる。",[18,523],{},[10,525,527],{"id":526},"check0から浮かび上がった5件のバグ","check!=0から浮かび上がった5件のバグ",[14,529,530],{},"赤く染まったタブを順に開いて、原因を掘り下げた。",[25,532,534],{"id":533},"q3-6-法人税等の支払額が二重計上","Q3-6: 法人税等の支払額が二重計上",[14,536,537,538,541,542,544],{},"営業CFの小計を計算する",[32,539,540],{},"op_subtotal_items","に「法人税等の支払額」が含まれていた。法人税は営業CF小計の外（小計の下）に表示する項目なので、小計に含めると二重にカウントされる。",[32,543,540],{},"から除外して解決。",[25,546,548],{"id":547},"q3-7-q4-2-繰越利益剰余金のni列に配当金が混入","Q3-7 / Q4-2: 繰越利益剰余金のNI列に配当金が混入",[14,550,551],{},"繰越利益剰余金の当期純利益（NI）列に、配当金のalloc値が紛れ込んでいた。NI列には当期純利益の残額だけを計上すべきで、配当金の配分値は別の列に入る。alloc計算のフィルタリング条件を修正し、NI列には残額のみが入るようにした。",[14,553,554],{},"Q3-7は修正完了。Q4-2は連結仕訳が絡むため、構造の見直しが必要で保留にした。",[25,556,558],{"id":557},"q5-3-_topic_cf_mapのnone値がフォールスルー","Q5-3: _TOPIC_CF_MAPのNone値がフォールスルー",[14,560,561,564,565,567,568,571,572,574],{},[32,562,563],{},"_TOPIC_CF_MAP","辞書でキーが存在するが値が",[32,566,48],{},"のエントリがあった。",[32,569,570],{},"dict.get(key, default)","は「キーが存在しない場合」にデフォルト値を返すが、「キーが存在して値がNone」の場合はそのまま",[32,573,48],{},"を返す。",[147,576,580],{"className":577,"code":578,"language":579,"meta":152,"style":152},"language-python shiki shiki-themes vitesse-light vitesse-light","# 修正前: Noneがフォールスルーする\ncf_item = _TOPIC_CF_MAP.get(topic, fallback)\n\n# 修正後: キー存在 + 値チェック\ncf_item = _TOPIC_CF_MAP.get(topic)\nif cf_item is None:\n    cf_item = fallback\n","python",[32,581,582,587,618,624,629,647,664],{"__ignoreMap":152},[156,583,584],{"class":158,"line":159},[156,585,586],{"class":162},"# 修正前: Noneがフォールスルーする\n",[156,588,589,593,596,600,602,605,607,610,612,615],{"class":158,"line":166},[156,590,592],{"class":591},"sG7-3","cf_item ",[156,594,595],{"class":173},"=",[156,597,599],{"class":598},"snbK4"," _TOPIC_CF_MAP",[156,601,174],{"class":173},[156,603,604],{"class":591},"get",[156,606,181],{"class":173},[156,608,609],{"class":591},"topic",[156,611,194],{"class":173},[156,613,614],{"class":591}," fallback",[156,616,617],{"class":173},")\n",[156,619,620],{"class":158,"line":206},[156,621,623],{"emptyLinePlaceholder":622},true,"\n",[156,625,626],{"class":158,"line":219},[156,627,628],{"class":162},"# 修正後: キー存在 + 値チェック\n",[156,630,631,633,635,637,639,641,643,645],{"class":158,"line":262},[156,632,592],{"class":591},[156,634,595],{"class":173},[156,636,599],{"class":598},[156,638,174],{"class":173},[156,640,604],{"class":591},[156,642,181],{"class":173},[156,644,609],{"class":591},[156,646,617],{"class":173},[156,648,649,652,655,658,661],{"class":158,"line":274},[156,650,651],{"class":329},"if",[156,653,654],{"class":591}," cf_item ",[156,656,657],{"class":347},"is",[156,659,660],{"class":329}," None",[156,662,663],{"class":173},":\n",[156,665,666,669,671],{"class":158,"line":280},[156,667,668],{"class":591},"    cf_item ",[156,670,595],{"class":173},[156,672,673],{"class":591}," fallback\n",[14,675,676,677,679],{},"これで",[32,678,48],{},"値のエントリを正しくフォールバックに回せるようになった。",[25,681,683],{"id":682},"修正スコア-45","修正スコア: 4/5",[14,685,686],{},"5件中4件を修正完了。残り1件（Q4-2: 連結仕訳の繰越利益剰余金）は連結精算表側の構造に踏み込む必要があり、別タスクとして切り出した。",[18,688],{},[10,690,691],{"id":691},"学んだこと",[693,694,695,703,712,720],"ul",{},[696,697,698,699,702],"li",{},"openpyxlは数式エンジンを持たない。数式の依存チェーンを解決するにはExcel COMで一度開いて再計算させる必要がある。このステップを最初にやっておけば、",[32,700,701],{},"data_only","の罠にハマる時間を節約できた",[696,704,705,706,708,709,711],{},"iframeの高さ自動調整は",[32,707,133],{},"が鉄板。",[32,710,100],{},"はクロスオリジンで塞がれる場面が多い",[696,713,714,716,717,719],{},[32,715,570],{},"はキー不在にしか効かない。値が",[32,718,48],{},"のケースは別途チェックが要る。Pythonの基本だが、辞書のエントリ数が多いと見落とす",[696,721,722],{},"check行の可視化をタブUIに組み込んだことで、5件のバグが一目で浮かび上がった。手動でシートを1枚ずつ開いて確認していた頃と比べると、発見までの時間が段違いに縮まった",[368,724,725],{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .sG7-3, html code.shiki .sG7-3{--shiki-default:#393A34;--shiki-dark:#393A34}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}",{"title":152,"searchDepth":166,"depth":166,"links":727},[728,729,734,741,745,751],{"id":12,"depth":166,"text":12},{"id":22,"depth":166,"text":23,"children":730},[731,732,733],{"id":27,"depth":206,"text":28},{"id":52,"depth":206,"text":53},{"id":66,"depth":206,"text":67},{"id":79,"depth":166,"text":80,"children":735},[736,737,738,739,740],{"id":83,"depth":206,"text":84},{"id":94,"depth":206,"text":95},{"id":119,"depth":206,"text":120},{"id":126,"depth":206,"text":127},{"id":411,"depth":206,"text":412},{"id":489,"depth":166,"text":490,"children":742},[743,744],{"id":493,"depth":206,"text":494},{"id":507,"depth":206,"text":508},{"id":526,"depth":166,"text":527,"children":746},[747,748,749,750],{"id":533,"depth":206,"text":534},{"id":547,"depth":206,"text":548},{"id":557,"depth":206,"text":558},{"id":682,"depth":206,"text":683},{"id":691,"depth":166,"text":691},"dev","ExcelのCFWSシートをHTMLテーブルに変換し、iframeで埋め込んで高さを自動調整し、check!=0のタブを赤く光らせて5件のバグを潰した記録。openpyxlの数式未解決問題からpostMessageリサイズまでの試行錯誤を残す。","md",{},"/cfws-html-viewer","eurekapu-nuxt4",false,"2026-04-15T00:00:00.000Z",{"title":5,"description":753},"2026-04/2026-04-15/cfws-html-viewer",[763,764,765,447,133,766,767],"CFWS","Excel","HTML","Python","openpyxl","memo",null,"Z0MySDgEI7HdYPme78eRFU5LyKyYmA5jCMZYppZWG0A",[],"https://log.eurekapu.com/favicon.svg",1776326292441]